Equicord/src/equicordplugins/messageLoggerEnhanced/components/LogsModal.tsx
2026-03-01 15:08:38 -05:00

453 lines
18 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import { Flex } from "@components/Flex";
import { InfoIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { copyWithToast, openUserProfile } from "@utils/discord";
import { closeAllModals, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { LazyComponent } from "@utils/react";
import { type User } from "@vencord/discord-types";
import { find, findByCode, findByCodeLazy } from "@webpack";
import { Alerts, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, NavigationRouter, React, TabBar, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common";
import { clearMessagesIDB, DBMessageRecord, deleteMessageIDB, deleteMessagesBulkIDB } from "../db";
import { cl, settings } from "../index";
import { LoggedMessage, LoggedMessageJSON } from "../types";
import { messageJsonToMessageClass } from "../utils";
import { importLogs } from "../utils/settingsUtils";
import { useMessages } from "./hooks";
export interface MessagePreviewProps {
className: string;
author: User;
message: LoggedMessage;
compact: boolean;
isGroupStart: boolean;
hideSimpleEmbedContent: boolean;
childrenAccessories: any;
}
export interface ChildrenAccProops {
channelMessageProps: {
compact: boolean;
channel: any;
message: LoggedMessage;
groupId: string;
id: string;
isLastItem: boolean;
isHighlight: boolean;
renderContentOnly: boolean;
};
hasSpoilerEmbeds: boolean;
isInteracting: boolean;
isAutomodBlockedMessage: boolean;
showClydeAiEmbeds: boolean;
}
const PrivateChannelRecord = findByCodeLazy(".is_message_request_timestamp,");
const MessagePreview = LazyComponent<MessagePreviewProps>(() => find(m => m?.type?.toString().includes("previewLinkTarget:") && !m?.type?.toString().includes("HAS_THREAD")));
const ChildrenAccessories = LazyComponent<ChildrenAccProops>(() => findByCode("channelMessageProps:{message:"));
export enum LogTabs {
DELETED = "Deleted",
EDITED = "Edited",
GHOST_PING = "Ghost Pinged"
}
interface Props {
modalProps: ModalProps;
initalQuery?: string;
}
export function LogsModal({ modalProps, initalQuery }: Props) {
const [currentTab, setCurrentTab] = useState(LogTabs.DELETED);
const [queryEh, setQuery] = useState(initalQuery ?? "");
const [sortNewest, setSortNewest] = useState(settings.store.sortNewest);
const [numDisplayedMessages, setNumDisplayedMessages] = useState(settings.store.messagesToDisplayAtOnceInLogs);
const contentRef = useRef<HTMLDivElement | null>(null);
const { messages, total, statusTotal, pending, reset } = useMessages(queryEh, currentTab, sortNewest, numDisplayedMessages);
return (
<ModalRoot className={cl("modal-root")} {...modalProps} size={ModalSize.LARGE}>
<ModalHeader className={cl("modal-header")}>
<TextInput value={queryEh} onChange={e => setQuery(e)} style={{ width: "100%" }} placeholder="Filter Messages" />
<TabBar
type="top"
look="brand"
className={cl("modal-tab-bar")}
selectedItem={currentTab}
onItemSelect={e => {
setCurrentTab(e);
setNumDisplayedMessages(settings.store.messagesToDisplayAtOnceInLogs);
contentRef.current?.firstElementChild?.scrollTo(0, 0);
}}
>
<TabBar.Item
className={cl("modal-tab-bar-item")}
id={LogTabs.DELETED}
>
Deleted
</TabBar.Item>
<TabBar.Item
className={cl("modal-tab-bar-item")}
id={LogTabs.EDITED}
>
Edited
</TabBar.Item>
<TabBar.Item
className={cl("modal-tab-bar-item")}
id={LogTabs.GHOST_PING}
>
Ghost Pinged
</TabBar.Item>
</TabBar>
</ModalHeader>
<div style={{ opacity: modalProps.transitionState === 1 ? "1" : "0" }} className={cl("modal-content-container")} ref={contentRef}>
{
modalProps.transitionState === 1 &&
<ModalContent
className={cl("modal-content")}
>
{messages != null && total === 0 && (
<EmptyLogs
hasQuery={queryEh.length !== 0}
reset={reset}
/>
)}
{!pending && messages != null && (
<LogsContentMemo
visibleMessages={messages}
canLoadMore={messages.length < statusTotal && messages.length >= settings.store.messagesToDisplayAtOnceInLogs}
tab={currentTab}
sortNewest={sortNewest}
reset={reset}
handleLoadMore={() => setNumDisplayedMessages(e => e + settings.store.messagesToDisplayAtOnceInLogs)}
/>
)}
</ModalContent>
}
</div>
<ModalFooter className={cl("footer")}>
<Button
variant="dangerPrimary"
onClick={() => Alerts.show({
title: "Clear Logs",
body: "Are you sure you want to clear all the logs",
confirmText: "Clear",
// @ts-expect-error not typed
confirmVariant: "critical-primary",
cancelText: "Cancel",
onConfirm: async () => {
await clearMessagesIDB();
reset();
}
})}
>
Clear All Logs
</Button>
<Button
style={{ marginRight: "16px" }}
variant="dangerSecondary"
disabled={messages?.length === 0}
onClick={() => Alerts.show({
title: "Clear Logs",
body: `Are you sure you want to clear ${messages.length} logs`,
confirmText: "Clear",
// @ts-expect-error not typed
confirmVariant: "critical-primary",
cancelText: "Cancel",
onConfirm: async () => {
await deleteMessagesBulkIDB(messages.map(e => e.message_id));
reset();
}
})}
>
Clear Visible Logs
</Button>
<Link
style={{ marginRight: "1rem" }}
onClick={() => {
setSortNewest(e => {
const val = !e;
settings.store.sortNewest = val;
return val;
});
contentRef.current?.firstElementChild?.scrollTo(0, 0);
}}
>
Sort {sortNewest ? "Oldest First" : "Newest First"}
</Link>
</ModalFooter>
</ModalRoot>
);
}
interface LogContentProps {
sortNewest: boolean;
tab: LogTabs;
visibleMessages: DBMessageRecord[];
canLoadMore: boolean;
reset: () => void;
handleLoadMore: () => void;
}
function LogsContent({ visibleMessages, canLoadMore, sortNewest, tab, reset, handleLoadMore }: LogContentProps) {
if (visibleMessages.length === 0)
return <NoResults tab={tab} />;
return (
<div className={cl("modal-content-inner")}>
{visibleMessages
.map(({ message }, i) => (
<LMessage
key={message.id}
log={{ message }}
reset={reset}
isGroupStart={isGroupStart(message, visibleMessages[i - 1]?.message, sortNewest)}
/>
))}
{
canLoadMore &&
<Button
style={{ marginTop: "1rem", width: "100%" }}
size="small" onClick={() => handleLoadMore()}
>
Load More
</Button>
}
</div>
);
}
const LogsContentMemo = LazyComponent(() => LogsContent);
function NoResults({ tab }: { tab: LogTabs; }) {
const generateSuggestedTabs = (tab: LogTabs) => {
switch (tab) {
case LogTabs.DELETED:
return { nextTab: LogTabs.EDITED, lastTab: LogTabs.GHOST_PING };
case LogTabs.EDITED:
return { nextTab: LogTabs.GHOST_PING, lastTab: LogTabs.DELETED };
case LogTabs.GHOST_PING:
return { nextTab: LogTabs.DELETED, lastTab: LogTabs.EDITED };
default:
return { nextTab: "", lastTab: "" };
}
};
const { nextTab, lastTab } = generateSuggestedTabs(tab);
return (
<div className={cl("modal-empty-logs", "modal-content-inner")} style={{ textAlign: "center" }}>
<BaseText size="lg">
No results in <b>{tab}</b>.
</BaseText>
<BaseText size="lg" style={{ marginTop: "0.2rem" }}>
Maybe try <b>{nextTab}</b> or <b>{lastTab}</b>
</BaseText>
</div>
);
}
function EmptyLogs({ hasQuery, reset: forceUpdate }: { hasQuery: boolean; reset: () => void; }) {
return (
<div className={cl("modal-empty-logs", "modal-content-inner")} style={{ textAlign: "center" }}>
<Flex flexDirection="column" style={{ position: "relative" }}>
<BaseText size="lg">
Empty eh
</BaseText>
{!hasQuery && (
<>
<Tooltip text="ML Enhanced now stores logs in indexeddb. You need to import your old logs from the logs directory. Importing wont overwrite existing logs">
{({ onMouseEnter, onMouseLeave }) => (
<div
className={cl("modal-info-icon")}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<InfoIcon />
</div>
)}
</Tooltip>
<Button onClick={() => importLogs().then(() => forceUpdate())}>
Import Logs
</Button>
</>
)}
</Flex>
</div>
);
}
interface LMessageProps {
log: { message: LoggedMessageJSON; };
isGroupStart: boolean,
reset: () => void;
}
function LMessage({ log, isGroupStart, reset, }: LMessageProps) {
const message = useMemo(() => messageJsonToMessageClass(log), [log]);
if (!message) return null;
const channel = ChannelStore.getChannel(message?.channel_id);
const guild = GuildStore.getGuild(channel?.guild_id);
return (
<div
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () =>
<Menu.Menu
navId="message-logger"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Message Logger"
>
<Menu.MenuItem
key="jump-to-message"
id="jump-to-message"
label="Jump To Message"
action={() => {
NavigationRouter.transitionTo(`/channels/${ChannelStore.getChannel(message.channel_id)?.guild_id ?? "@me"}/${message.channel_id}${message.id ? "/" + message.id : ""}`);
closeAllModals();
}}
/>
<Menu.MenuItem
key="open-user-profile"
id="open-user-profile"
label="Open user profile"
action={() => {
closeAllModals();
openUserProfile(message.author.id);
}}
/>
<Menu.MenuItem
key="copy-content"
id="copy-content"
label="Copy Content"
action={() => copyWithToast(message.content)}
/>
<Menu.MenuItem
key="copy-user-id"
id="copy-user-id"
label="Copy User ID"
action={() => copyWithToast(message.author.id)}
/>
<Menu.MenuItem
key="copy-message-id"
id="copy-message-id"
label="Copy Message ID"
action={() => copyWithToast(message.id)}
/>
<Menu.MenuItem
key="copy-channel-id"
id="copy-channel-id"
label="Copy Channel ID"
action={() => copyWithToast(message.channel_id)}
/>
{
log.message.guildId != null
&& (
<Menu.MenuItem
key="copy-server-id"
id="copy-server-id"
label="Copy Server ID"
action={() => copyWithToast(log.message.guildId!)}
/>
)
}
<Menu.MenuItem
key="delete-log"
id="delete-log"
label="Delete Log"
color="danger"
action={() =>
deleteMessageIDB(log.message.id).then(() => reset())
}
/>
</Menu.Menu>
);
}}>
<MessagePreview
className={`${cl("modal-msg-preview")} ${message.deleted ? "messagelogger-deleted" : ""}`}
author={message.author}
message={message}
compact={false}
isGroupStart={isGroupStart}
hideSimpleEmbedContent={false}
childrenAccessories={
<ChildrenAccessories
channelMessageProps={{
channel: ChannelStore.getChannel(message.channel_id) || new PrivateChannelRecord({ id: "" }),
message,
compact: false,
groupId: "1",
id: message.id,
isLastItem: false,
isHighlight: false,
renderContentOnly: false,
}}
hasSpoilerEmbeds={false}
isInteracting={false}
showClydeAiEmbeds={true}
isAutomodBlockedMessage={false}
/>
}
/>
{settings.store.ShowWhereMessageIsFrom && channel?.isDM() && message?.author && (
<span className={`${cl("modal-from")} ${message.deleted ? cl("modal-from-deleted") : cl("modal-from-edited")}`}>From {message.author.username}'s DMs</span>
)}
{settings.store.ShowWhereMessageIsFrom && channel?.isGroupDM() && channel?.name && (
<span className={`${cl("modal-from")} ${message.deleted ? cl("modal-from-deleted") : cl("modal-from-edited")}`}>From {channel.name} Group DM</span>
)}
{settings.store.ShowWhereMessageIsFrom && !channel?.isDM() && !channel?.isGroupDM() && channel?.name && guild?.name && (
<span className={`${cl("modal-from")} ${message.deleted ? cl("modal-from-deleted") : cl("modal-from-edited")}`}>From {channel.name} in {guild.name}</span>
)}
</div>
);
}
export const openLogModal = (initalQuery?: string) => openModal(modalProps => <LogsModal modalProps={modalProps} initalQuery={initalQuery} />);
function isGroupStart(
currentMessage: LoggedMessageJSON | undefined,
previousMessage: LoggedMessageJSON | undefined,
sortNewest: boolean
) {
if (!currentMessage || !previousMessage) return true;
if (currentMessage.id === previousMessage.id) return true;
const [newestMessage, oldestMessage] = sortNewest
? [previousMessage, currentMessage]
: [currentMessage, previousMessage];
if (newestMessage.author.id !== oldestMessage.author.id) return true;
const timeDifferenceInMinutes = Math.abs(
(new Date(newestMessage.timestamp)?.getTime() - new Date(oldestMessage.timestamp)?.getTime()) / (1000 * 60)
);
return timeDifferenceInMinutes >= 5;
}