This commit is contained in:
thororen 2024-04-17 14:29:47 -04:00
parent 538b87062a
commit ea7451bcdc
326 changed files with 24876 additions and 2280 deletions

View file

@ -0,0 +1,268 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createStore } from "@api/DataStore";
import { DataStore } from "@api/index";
import { Settings } from "@api/Settings";
import { Flogger, Native, settings } from ".";
import { LoggedMessage, LoggedMessageIds, LoggedMessageJSON, LoggedMessages, MessageRecord } from "./types";
import { cleanupMessage, getNative, sortMessagesByDate } from "./utils";
import { cacheMessageImages, deleteMessageImages } from "./utils/saveImage";
export const defaultLoggedMessages = { deletedMessages: {}, editedMessages: {}, };
export const LOGGED_MESSAGES_KEY = "logged-messages-hi";
export const MessageLoggerStore = createStore("MessageLoggerData", "MessageLoggerStore");
// this gets used by the logs modal. logs modal should only use saved messages not messages that are being processed
// also hasMessageInLogs should only check saved messages not the ones that are being processed
export let savedLoggedMessages: LoggedMessages = defaultLoggedMessages;
export let loggedMessages: LoggedMessages = defaultLoggedMessages;
(async () => {
try {
const Native = getNative();
const res = await Native.getLogsFromFs();
if (res != null) {
Flogger.log("Got logged messages from native wont be checking DataStore");
const cleaned = await cleanMessages(res, Native);
loggedMessages = cleaned;
savedLoggedMessages = cleaned;
return;
}
if (IS_WEB) {
Flogger.log("hii. no point in checking DataStore if. we already did up there ^^");
return;
}
const data = await DataStore.get(LOGGED_MESSAGES_KEY, MessageLoggerStore);
if (data == null) {
Flogger.log("No logged messages in DataStore");
return;
}
Flogger.log("Loading logged messages from DataStore and writing to native");
Native.writeLogs(JSON.stringify(data));
loggedMessages = data;
savedLoggedMessages = res;
} catch (error) {
console.error("Error loading logged messages from the store:", error);
}
})();
// api
export const saveLoggedMessages = async () => {
if (settings.store.saveMessages) {
await Native.writeLogs(JSON.stringify(loggedMessages));
}
savedLoggedMessages = loggedMessages;
};
export const addMessage = async (message: LoggedMessage | LoggedMessageJSON, key: keyof LoggedMessageIds, isBulk = false) => {
if (settings.store.saveImages && key === "deletedMessages")
await cacheMessageImages(message);
const finalMessage = cleanupMessage(message);
loggedMessages[message.id] = { message: finalMessage };
if (!loggedMessages[key][message.channel_id])
loggedMessages[key][message.channel_id] = [];
if (!loggedMessages[key][message.channel_id].includes(message.id))
loggedMessages[key][message.channel_id].push(message.id);
// if limit is negative or 0 there is no limit
if (settings.store.messageLimit > 0 && (Object.keys(loggedMessages).length - 2) > settings.store.messageLimit)
await deleteOldestMessageWithoutSaving(loggedMessages);
if (!isBulk)
await saveLoggedMessages();
};
export const removeFromKey = (
message_id: string,
channel_id: string,
loggedMessages: LoggedMessages,
key: keyof LoggedMessageIds,
) => {
if (loggedMessages[key][channel_id]) {
loggedMessages[key][channel_id] = loggedMessages[key][channel_id].filter(msgid => msgid !== message_id);
if (loggedMessages[key][channel_id].length === 0) {
delete loggedMessages[key][channel_id];
}
}
};
function removeLogWithoutSaving(messageId: string, loggedMessages: LoggedMessages) {
const record = loggedMessages[messageId];
if (record) {
const channel_id = record.message?.channel_id;
if (channel_id != null) {
removeFromKey(messageId, channel_id, loggedMessages, "editedMessages");
removeFromKey(messageId, channel_id, loggedMessages, "deletedMessages");
}
delete loggedMessages[messageId];
}
return loggedMessages;
}
export async function removeLogs(ids: string[]) {
for (const msgId of ids) {
removeLogWithoutSaving(msgId, loggedMessages);
}
await saveLoggedMessages();
}
export async function removeLog(id: string) {
const record = loggedMessages[id];
if (record?.message)
deleteMessageImages(record.message);
removeLogWithoutSaving(id, loggedMessages);
await saveLoggedMessages();
}
export async function clearLogs() {
await Native.writeLogs(JSON.stringify(defaultLoggedMessages));
loggedMessages = defaultLoggedMessages;
savedLoggedMessages = defaultLoggedMessages;
}
// utils
export const hasMessageInLogs = (messageId: string) => {
const bruh = Object.values(savedLoggedMessages)
.filter(m => !Array.isArray(m)) as MessageRecord[];
return bruh.find(m => m.message?.id === messageId);
};
export const hasLogs = async () => {
const hasDeletedMessages = Object.keys(loggedMessages.deletedMessages).length > 0;
const hasEditedMessages = Object.keys(loggedMessages.editedMessages).length > 0;
const hasMessages = Object.keys(loggedMessages).filter(m => m !== "editedMessages" && m !== "deletedMessages").length > 0;
if (hasDeletedMessages && hasEditedMessages && hasMessages) return true;
return false;
};
export function findLoggedChannelByMessageIdSync(messageId: string, loggedMessages: LoggedMessages, key: keyof LoggedMessageIds) {
for (const channelId in loggedMessages[key]) {
if (loggedMessages[key][channelId].includes(messageId)) return channelId;
}
return null;
}
export async function findLoggedChannelByMessage(messageId: string, key?: keyof LoggedMessageIds): Promise<[string | null, keyof LoggedMessageIds]> {
if (!key) {
const id1 = findLoggedChannelByMessageIdSync(messageId, loggedMessages, "deletedMessages");
if (id1) return [id1, "deletedMessages"];
const id2 = findLoggedChannelByMessageIdSync(messageId, loggedMessages, "editedMessages");
return [id2, "editedMessages"];
}
return [findLoggedChannelByMessageIdSync(messageId, loggedMessages, key), key];
}
export function getOldestMessage(loggedMessageIds: LoggedMessages) {
const messags = Object.values(loggedMessageIds)
.filter(m => !Array.isArray(m) && m.message != null) as MessageRecord[];
const sortedMessages = messags.sort((a, b) => sortMessagesByDate(a.message.timestamp, b.message.timestamp));
const oldestMessage = sortedMessages[sortedMessages.length - 1];
return oldestMessage ?? null;
}
export async function deleteOldestMessageWithoutSaving(loggedMessages: LoggedMessages) {
const oldestMessage = getOldestMessage(loggedMessages);
if (!oldestMessage || !oldestMessage.message) {
console.warn("couldnt find oldest message. oldestMessage == null || oldestMessage.message == null");
return loggedMessages;
}
const { message } = oldestMessage;
const [channelId, key] = await findLoggedChannelByMessage(message.id);
if (!channelId || !key) {
console.warn("couldnt find oldest message. channelId =", channelId, " key =", key);
return loggedMessages;
}
removeLogWithoutSaving(message.id, loggedMessages);
// console.log("removing", message);
return loggedMessages;
}
async function cleanMessages(loggedMessages: LoggedMessages, _Native: any = Native) {
try {
const cleaned = { ...loggedMessages };
if (IS_WEB) return cleaned;
const messageRecords = Object.values(cleaned)
.filter(m => !Array.isArray(m)) as MessageRecord[];
let hasChanged = false;
for (const messageRecord of messageRecords) {
const { message } = messageRecord;
if (message?.attachments) {
for (const attachment of message.attachments) {
if (attachment.blobUrl) {
hasChanged = true;
delete attachment.blobUrl;
}
}
}
}
if (hasChanged)
await _Native.writeLogs(Settings.plugins.MLEnhanced.logsDir, JSON.stringify(cleaned));
return cleaned;
} catch (err) {
Flogger.error("Error cleaning messages:", err);
return loggedMessages;
}
}

View file

@ -0,0 +1,60 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { LazyComponent } from "@utils/react";
import { filters, find } from "@webpack";
import { openLogModal } from "./LogsModal";
const HeaderBarIcon = LazyComponent(() => {
const filter = filters.byCode(".HEADER_BAR_BADGE");
return find(m => m.Icon && filter(m.Icon)).Icon;
});
export function OpenLogsIcon() {
return (
<svg
stroke="currentColor"
fill="none"
strokeWidth="0"
viewBox="0 0 15 15"
height={24}
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
fill="currentColor"
d="M3 2.5C3 2.22386 3.22386 2 3.5 2H9.08579C9.21839 2 9.34557 2.05268 9.43934 2.14645L11.8536 4.56066C11.9473 4.65443 12 4.78161 12 4.91421V12.5C12 12.7761 11.7761 13 11.5 13H3.5C3.22386 13 3 12.7761 3 12.5V2.5ZM3.5 1C2.67157 1 2 1.67157 2 2.5V12.5C2 13.3284 2.67157 14 3.5 14H11.5C12.3284 14 13 13.3284 13 12.5V4.91421C13 4.51639 12.842 4.13486 12.5607 3.85355L10.1464 1.43934C9.86514 1.15804 9.48361 1 9.08579 1H3.5ZM4.5 4C4.22386 4 4 4.22386 4 4.5C4 4.77614 4.22386 5 4.5 5H7.5C7.77614 5 8 4.77614 8 4.5C8 4.22386 7.77614 4 7.5 4H4.5ZM4.5 7C4.22386 7 4 7.22386 4 7.5C4 7.77614 4.22386 8 4.5 8H10.5C10.7761 8 11 7.77614 11 7.5C11 7.22386 10.7761 7 10.5 7H4.5ZM4.5 10C4.22386 10 4 10.2239 4 10.5C4 10.7761 4.22386 11 4.5 11H10.5C10.7761 11 11 10.7761 11 10.5C11 10.2239 10.7761 10 10.5 10H4.5Z"
>
</path>
</svg>
);
}
export function OpenLogsButton() {
return (
<HeaderBarIcon
className="vc-log-toolbox-btn"
onClick={() => openLogModal()}
tooltip={"Open Logs"}
icon={OpenLogsIcon}
/>
);
}

View file

@ -0,0 +1,492 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { classNameFactory } from "@api/Styles";
import { openUserProfile } from "@utils/discord";
import { copyWithToast } from "@utils/misc";
import { closeAllModals, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { LazyComponent, useAwaiter } from "@utils/react";
import { find, findByCode, findByPropsLazy } from "@webpack";
import { Alerts, Button, ChannelStore, ContextMenuApi, FluxDispatcher, Menu, NavigationRouter, React, TabBar, Text, TextInput, useCallback, useMemo, useRef, useState } from "@webpack/common";
import { User } from "discord-types/general";
import { settings } from "../index";
import { clearLogs, defaultLoggedMessages, removeLog, removeLogs, savedLoggedMessages } from "../LoggedMessageManager";
import { LoggedMessage, LoggedMessageJSON, LoggedMessages } from "../types";
import { isGhostPinged, messageJsonToMessageClass, sortMessagesByDate } from "../utils";
import { doesMatch, parseQuery } from "../utils/parseQuery";
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 ChannelRecords = findByPropsLazy("PrivateChannelRecord");
const MessagePreview = LazyComponent<MessagePreviewProps>(() => find(m => m?.type?.toString().includes("previewLinkTarget:") && !m?.type?.toString().includes("HAS_THREAD")));
const ChildrenAccessories = LazyComponent<ChildrenAccProops>(() => findByCode("channelMessageProps:{message:"));
const cl = classNameFactory("msg-logger-modal-");
enum LogTabs {
DELETED = "Deleted",
EDITED = "Edited",
GHOST_PING = "Ghost Pinged"
}
interface Props {
modalProps: ModalProps;
initalQuery?: string;
}
export function LogsModal({ modalProps, initalQuery }: Props) {
const [x, setX] = useState(0);
const forceUpdate = () => setX(e => e + 1);
const [logs, _, pending] = useAwaiter(async () => savedLoggedMessages, {
fallbackValue: defaultLoggedMessages as LoggedMessages,
deps: [x]
});
const [currentTab, setCurrentTab] = useState(LogTabs.DELETED);
const [queryEh, setQuery] = useState(initalQuery ?? "");
const [sortNewest, setSortNewest] = useState(settings.store.sortNewest);
const [numDisplayedMessages, setNumDisplayedMessages] = useState(50);
const contentRef = useRef<HTMLDivElement | null>(null);
const handleLoadMore = useCallback(() => {
setNumDisplayedMessages(prevNum => prevNum + 50);
}, []);
// Flogger.log(logs, _, pending, contentRef);
// Flogger.time("hi");
const messages: string[][] = currentTab === LogTabs.DELETED || currentTab === LogTabs.GHOST_PING
? Object.values(logs?.deletedMessages ?? {})
: Object.values(logs?.editedMessages ?? {});
const flattendAndfilteredAndSortedMessages = useMemo(() => {
const { success, type, id, negate, query } = parseQuery(queryEh);
if (query === "" && !success) {
const result = messages
.flat()
.filter(m => currentTab === LogTabs.GHOST_PING ? isGhostPinged(logs[m].message!) : true)
.sort(sortMessagesByDate);
return sortNewest ? result : result.reverse();
}
const result = messages
.flat()
.filter(m =>
currentTab === LogTabs.GHOST_PING
? isGhostPinged(logs[m].message)
: true
)
.filter(m =>
logs[m]?.message != null &&
(
success === false
? true
: negate
? !doesMatch(type!, id!, logs[m].message!)
: doesMatch(type!, id!, logs[m].message!)
)
)
.filter(m =>
logs[m]?.message?.content?.toLowerCase()?.includes(query.toLowerCase()) ??
logs[m].message?.editHistory?.map(m => m.content?.toLowerCase()).includes(query.toLowerCase())
)
.sort(sortMessagesByDate);
return sortNewest ? result : result.reverse();
}, [currentTab, logs, queryEh, sortNewest]);
const visibleMessages = flattendAndfilteredAndSortedMessages.slice(0, numDisplayedMessages);
const canLoadMore = numDisplayedMessages < flattendAndfilteredAndSortedMessages.length;
// Flogger.timeEnd("hi");
return (
<ModalRoot className={cl("root")} {...modalProps} size={ModalSize.LARGE}>
<ModalHeader className={cl("header")}>
<TextInput value={queryEh} onChange={e => setQuery(e)} style={{ width: "100%" }} placeholder="Filter Messages" />
<TabBar
type="top"
look="brand"
className={cl("tab-bar")}
selectedItem={currentTab}
onItemSelect={e => {
setCurrentTab(e);
setNumDisplayedMessages(50);
contentRef.current?.firstElementChild?.scrollTo(0, 0);
// forceUpdate();
}}
>
<TabBar.Item
className={cl("tab-bar-item")}
id={LogTabs.DELETED}
>
Deleted
</TabBar.Item>
<TabBar.Item
className={cl("tab-bar-item")}
id={LogTabs.EDITED}
>
Edited
</TabBar.Item>
<TabBar.Item
className={cl("tab-bar-item")}
id={LogTabs.GHOST_PING}
>
Ghost Pinged
</TabBar.Item>
</TabBar>
</ModalHeader>
<div style={{ opacity: modalProps.transitionState === 1 ? "1" : "0" }} className={cl("content-container")} ref={contentRef}>
{
modalProps.transitionState === 1 &&
<ModalContent
className={cl("content")}
>
{
pending || logs == null || messages.length === 0
? <EmptyLogs />
: (
<LogsContentMemo
visibleMessages={visibleMessages}
canLoadMore={canLoadMore}
tab={currentTab}
logs={logs}
sortNewest={sortNewest}
forceUpdate={forceUpdate}
handleLoadMore={handleLoadMore}
/>
)
}
</ModalContent>
}
</div>
<ModalFooter>
<Button
color={Button.Colors.RED}
onClick={() => Alerts.show({
title: "Clear Logs",
body: "Are you sure you want to clear all the logs",
confirmText: "Clear",
confirmColor: Button.Colors.RED,
cancelText: "Cancel",
onConfirm: async () => {
await clearLogs();
forceUpdate();
}
})}
>
Clear All Logs
</Button>
<Button
style={{ marginRight: "16px" }}
color={Button.Colors.YELLOW}
disabled={visibleMessages.length === 0}
onClick={() => Alerts.show({
title: "Clear Logs",
body: `Are you sure you want to clear ${visibleMessages.length} logs`,
confirmText: "Clear",
confirmColor: Button.Colors.RED,
cancelText: "Cancel",
onConfirm: async () => {
await removeLogs(visibleMessages);
forceUpdate();
}
})}
>
Clear Visible Logs
</Button>
<Button
look={Button.Looks.LINK}
color={Button.Colors.PRIMARY}
onClick={() => {
setSortNewest(e => {
const val = !e;
settings.store.sortNewest = val;
return val;
});
contentRef.current?.firstElementChild?.scrollTo(0, 0);
}}
>
Sort {sortNewest ? "Oldest First" : "Newest First"}
</Button>
</ModalFooter>
</ModalRoot>
);
}
interface LogContentProps {
logs: LoggedMessages,
sortNewest: boolean;
tab: LogTabs;
visibleMessages: string[];
canLoadMore: boolean;
forceUpdate: () => void;
handleLoadMore: () => void;
}
function LogsContent({ logs, visibleMessages, canLoadMore, sortNewest, tab, forceUpdate, handleLoadMore }: LogContentProps) {
if (visibleMessages.length === 0)
return <NoResults tab={tab} />;
return (
<div className={cl("content-inner")}>
{visibleMessages
.map((id, i) => (
<LMessage
key={id}
log={logs[id] as { message: LoggedMessageJSON; }}
forceUpdate={forceUpdate}
isGroupStart={isGroupStart(logs[id]?.message, logs[visibleMessages[i - 1]]?.message, sortNewest)}
/>
))}
{
canLoadMore &&
<Button
style={{ marginTop: "1rem", width: "100%" }}
size={Button.Sizes.SMALL} onClick={() => handleLoadMore()}
>
Load More
</Button>
}
</div>
);
}
const LogsContentMemo = LazyComponent(() => React.memo(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("empty-logs", "content-inner")} style={{ textAlign: "center" }}>
<Text variant="text-lg/normal">
No results in <b>{tab}</b>.
</Text>
<Text variant="text-lg/normal" style={{ marginTop: "0.2rem" }}>
Maybe try <b>{nextTab}</b> or <b>{lastTab}</b>
</Text>
</div>
);
}
function EmptyLogs() {
return (
<div className={cl("empty-logs", "content-inner")} style={{ textAlign: "center" }}>
<Text variant="text-lg/normal">
Empty eh
</Text>
</div>
);
}
interface LMessageProps {
log: { message: LoggedMessageJSON; };
isGroupStart: boolean,
forceUpdate: () => void;
}
function LMessage({ log, isGroupStart, forceUpdate, }: LMessageProps) {
const message = useMemo(() => messageJsonToMessageClass(log), [log]);
if (!message) return null;
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={() =>
removeLog(log.message.id)
.then(() => {
forceUpdate();
})
}
/>
</Menu.Menu>
);
}}>
<MessagePreview
className={`${cl("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 ChannelRecords.PrivateChannelRecord({ id: "" }),
message,
compact: false,
groupId: "1",
id: message.id,
isLastItem: false,
isHighlight: false,
renderContentOnly: false,
}}
hasSpoilerEmbeds={false}
isInteracting={false}
showClydeAiEmbeds={true}
isAutomodBlockedMessage={false}
/>
}
/>
</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;
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;
}

View file

@ -0,0 +1,756 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const VERSION = "3.0.0";
export const Native = getNative();
import "./styles.css";
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings, Settings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { Alerts, Button, FluxDispatcher, Menu, MessageActions, MessageStore, React, Toasts, UserStore } from "@webpack/common";
import { OpenLogsButton } from "./components/LogsButton";
import { openLogModal } from "./components/LogsModal";
import { ImageCacheDir, LogsDir } from "./components/settings/FolderSelectInput";
import { addMessage, loggedMessages, MessageLoggerStore, removeLog } from "./LoggedMessageManager";
import * as LoggedMessageManager from "./LoggedMessageManager";
import { LoadMessagePayload, LoggedAttachment, LoggedMessage, LoggedMessageJSON, MessageCreatePayload, MessageDeleteBulkPayload, MessageDeletePayload, MessageUpdatePayload } from "./types";
import { addToXAndRemoveFromOpposite, cleanUpCachedMessage, cleanupUserObject, doesBlobUrlExist, getNative, isGhostPinged, ListType, mapEditHistory, reAddDeletedMessages, removeFromX } from "./utils";
import { DEFAULT_IMAGE_CACHE_DIR } from "./utils/constants";
import { shouldIgnore } from "./utils/index";
import { LimitedMap } from "./utils/LimitedMap";
import { doesMatch } from "./utils/parseQuery";
import * as imageUtils from "./utils/saveImage";
import * as ImageManager from "./utils/saveImage/ImageManager";
import { downloadLoggedMessages } from "./utils/settingsUtils";
export const Flogger = new Logger("MessageLoggerEnhanced", "#f26c6c");
export const cacheSentMessages = new LimitedMap<string, LoggedMessageJSON>();
const cacheThing = findByPropsLazy("commit", "getOrCreate");
const handledMessageIds = new Set();
async function messageDeleteHandler(payload: MessageDeletePayload & { isBulk: boolean; }) {
if (payload.mlDeleted) {
if (settings.store.permanentlyRemoveLogByDefault)
await removeLog(payload.id);
return;
}
if (handledMessageIds.has(payload.id)) {
// Flogger.warn("skipping duplicate message", payload.id);
return;
}
try {
handledMessageIds.add(payload.id);
let message: LoggedMessage | LoggedMessageJSON | null =
MessageStore.getMessage(payload.channelId, payload.id);
if (message == null) {
// most likely an edited message
const cachedMessage = cacheSentMessages.get(`${payload.channelId},${payload.id}`);
if (!cachedMessage) return; // Flogger.log("no message to save");
message = { ...cacheSentMessages.get(`${payload.channelId},${payload.id}`), deleted: true } as LoggedMessageJSON;
}
if (
shouldIgnore({
channelId: message?.channel_id ?? payload.channelId,
guildId: payload.guildId ?? (message as any).guildId ?? (message as any).guild_id,
authorId: message?.author?.id,
bot: message?.bot || message?.author?.bot,
flags: message?.flags,
ghostPinged: isGhostPinged(message as any),
isCachedByUs: (message as LoggedMessageJSON).ourCache
})
) {
// Flogger.log("IGNORING", message, payload);
return FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
channelId: payload.channelId,
id: payload.id,
mlDeleted: true
});
}
if (message == null || message.channel_id == null || !message.deleted) return;
// Flogger.log("ADDING MESSAGE (DELETED)", message);
await addMessage(message, "deletedMessages", payload.isBulk ?? false);
}
finally {
handledMessageIds.delete(payload.id);
}
}
async function messageDeleteBulkHandler({ channelId, guildId, ids }: MessageDeleteBulkPayload) {
// is this bad? idk man
for (const id of ids) {
await messageDeleteHandler({ type: "MESSAGE_DELETE", channelId, guildId, id, isBulk: true });
}
await LoggedMessageManager.saveLoggedMessages();
}
async function messageUpdateHandler(payload: MessageUpdatePayload) {
const cachedMessage = cacheSentMessages.get(`${payload.message.channel_id},${payload.message.id}`);
if (
shouldIgnore({
channelId: payload.message?.channel_id,
guildId: payload.guildId ?? (payload as any).guild_id,
authorId: payload.message?.author?.id,
bot: (payload.message?.author as any)?.bot,
flags: payload.message?.flags,
ghostPinged: isGhostPinged(payload.message as any),
isCachedByUs: cachedMessage?.ourCache ?? false
})
) {
const cache = cacheThing.getOrCreate(payload.message.channel_id);
const message = cache.get(payload.message.id);
if (message) {
message.editHistory = [];
cacheThing.commit(cache);
}
return;// Flogger.log("this message has been ignored", payload);
}
let message = MessageStore
.getMessage(payload.message.channel_id, payload.message.id) as LoggedMessage | LoggedMessageJSON | null;
if (message == null) {
// MESSAGE_UPDATE gets dispatched when emebeds change too and content becomes null
if (cachedMessage != null && payload.message.content != null && cachedMessage.content !== payload.message.content) {
message = {
...cachedMessage,
content: payload.message.content,
editHistory: [
...(cachedMessage.editHistory ?? []),
{
content: cachedMessage.content,
timestamp: (new Date()).toISOString()
}
]
};
cacheSentMessages.set(`${payload.message.channel_id},${payload.message.id}`, message);
}
}
if (message == null || message.channel_id == null || message.editHistory == null || message.editHistory.length === 0) return;
// Flogger.log("ADDING MESSAGE (EDITED)", message, payload);
await addMessage(message, "editedMessages");
}
function messageCreateHandler(payload: MessageCreatePayload) {
// we do this here because cache is limited and to save memory
if (!settings.store.cacheMessagesFromServers && payload.guildId != null) {
const ids = [payload.channelId, payload.message?.author?.id, payload.guildId];
const isWhitelisted =
settings.store.whitelistedIds
.split(",")
.some(e => ids.includes(e));
if (!isWhitelisted) {
return; // dont cache messages from servers when cacheMessagesFromServers is disabled and not whitelisted.
}
}
cacheSentMessages.set(`${payload.message.channel_id},${payload.message.id}`, cleanUpCachedMessage(payload.message));
// Flogger.log(`cached\nkey:${payload.message.channel_id},${payload.message.id}\nvalue:`, payload.message);
}
// also stolen from mlv2
function messageLoadSuccess(payload: LoadMessagePayload) {
const deletedMessages = loggedMessages.deletedMessages[payload.channelId];
const editedMessages = loggedMessages.editedMessages[payload.channelId];
const recordIDs: string[] = [...(deletedMessages || []), ...(editedMessages || [])];
for (let i = 0; i < payload.messages.length; ++i) {
const recievedMessage = payload.messages[i];
const record = loggedMessages[recievedMessage.id];
if (record == null || record.message == null) continue;
if (record.message.editHistory!.length !== 0) {
payload.messages[i].editHistory = record.message.editHistory;
}
}
const fetchUser = (id: string) => UserStore.getUser(id) || payload.messages.find(e => e.author.id === id);
for (let i = 0, len = recordIDs.length; i < len; i++) {
const id = recordIDs[i];
if (!loggedMessages[id]) continue;
const { message } = loggedMessages[id] as { message: LoggedMessageJSON; };
for (let j = 0, len2 = message.mentions.length; j < len2; j++) {
const user = message.mentions[j];
const cachedUser = fetchUser((user as any).id || user);
if (cachedUser) (message.mentions[j] as any) = cleanupUserObject(cachedUser);
}
const author = fetchUser(message.author.id);
if (!author) continue;
(message.author as any) = cleanupUserObject(author);
}
reAddDeletedMessages(payload.messages, deletedMessages, !payload.hasMoreAfter && !payload.isBefore, !payload.hasMoreBefore && !payload.isAfter);
}
export const settings = definePluginSettings({
saveMessages: {
default: true,
type: OptionType.BOOLEAN,
description: "Wether to save the deleted and edited messages.",
},
saveImages: {
type: OptionType.BOOLEAN,
description: "Save deleted messages",
default: false
},
sortNewest: {
default: true,
type: OptionType.BOOLEAN,
description: "Sort logs by newest.",
},
cacheMessagesFromServers: {
default: false,
type: OptionType.BOOLEAN,
description: "Usually message logger only logs from whitelisted ids and dms, enabling this would mean it would log messages from all servers as well. Note that this may cause the cache to exceed its limit, resulting in some messages being missed. If you are in a lot of servers, this may significantly increase the chances of messages being logged, which can result in a large message record and the inclusion of irrelevant messages.",
},
autoCheckForUpdates: {
default: true,
type: OptionType.BOOLEAN,
description: "Automatically check for updates on startup.",
},
ignoreBots: {
type: OptionType.BOOLEAN,
description: "Whether to ignore messages by bots",
default: false,
onChange() {
// we will be handling the ignoreBots now (enabled or not) so the original messageLogger shouldnt
Settings.plugins.MessageLogger.ignoreBots = false;
}
},
ignoreSelf: {
type: OptionType.BOOLEAN,
description: "Whether to ignore messages by yourself",
default: false,
onChange() {
Settings.plugins.MessageLogger.ignoreSelf = false;
}
},
ignoreMutedGuilds: {
default: false,
type: OptionType.BOOLEAN,
description: "Messages in muted guilds will not be logged. Whitelisted users/channels in muted guilds will still be logged."
},
ignoreMutedCategories: {
default: false,
type: OptionType.BOOLEAN,
description: "Messages in channels belonging to muted categories will not be logged. Whitelisted users/channels in muted guilds will still be logged."
},
ignoreMutedChannels: {
default: false,
type: OptionType.BOOLEAN,
description: "Messages in muted channels will not be logged. Whitelisted users/channels in muted guilds will still be logged."
},
alwaysLogDirectMessages: {
default: true,
type: OptionType.BOOLEAN,
description: "Always log DMs",
},
alwaysLogCurrentChannel: {
default: true,
type: OptionType.BOOLEAN,
description: "Always log current selected channel. Blacklisted channels/users will still be ignored.",
},
permanentlyRemoveLogByDefault: {
default: false,
type: OptionType.BOOLEAN,
description: "Vencord's base MessageLogger remove log button wiil delete logs permanently",
},
hideMessageFromMessageLoggers: {
default: false,
type: OptionType.BOOLEAN,
description: "When enabled, a context menu button will be added to messages to allow you to delete messages without them being logged by other loggers. Might not be safe, use at your own risk."
},
ShowLogsButton: {
default: true,
type: OptionType.BOOLEAN,
description: "Toggle to whenever show the toolbox or not",
restartNeeded: true,
},
hideMessageFromMessageLoggersDeletedMessage: {
default: "redacted eh",
type: OptionType.STRING,
description: "The message content to replace the message with when using the hide message from message loggers feature.",
},
messageLimit: {
default: 200,
type: OptionType.NUMBER,
description: "Maximum number of messages to save. Older messages are deleted when the limit is reached. 0 means there is no limit"
},
imagesLimit: {
default: 100,
type: OptionType.NUMBER,
description: "Maximum number of images to save. Older images are deleted when the limit is reached. 0 means there is no limit"
},
cacheLimit: {
default: 1000,
type: OptionType.NUMBER,
description: "Maximum number of messages to store in the cache. Older messages are deleted when the limit is reached. This helps reduce memory usage and improve performance. 0 means there is no limit",
},
whitelistedIds: {
default: "",
type: OptionType.STRING,
description: "Whitelisted server, channel, or user IDs."
},
blacklistedIds: {
default: "",
type: OptionType.STRING,
description: "Blacklisted server, channel, or user IDs."
},
imageCacheDir: {
type: OptionType.COMPONENT,
description: "Select saved images directory",
component: ErrorBoundary.wrap(ImageCacheDir) as any
},
logsDir: {
type: OptionType.COMPONENT,
description: "Select logs directory",
component: ErrorBoundary.wrap(LogsDir) as any
},
exportLogs: {
type: OptionType.COMPONENT,
description: "Export Logs From IndexedDB",
component: () =>
<Button onClick={downloadLoggedMessages}>
Export Logs
</Button>
},
openLogs: {
type: OptionType.COMPONENT,
description: "Open Logs",
component: () =>
<Button onClick={() => openLogModal()}>
Open Logs
</Button>
},
openImageCacheFolder: {
type: OptionType.COMPONENT,
description: "Opens the image cache directory",
component: () =>
<Button
disabled={
IS_WEB
|| settings.store.imageCacheDir == null
|| settings.store.imageCacheDir === DEFAULT_IMAGE_CACHE_DIR
}
onClick={() => Native.showItemInFolder(settings.store.imageCacheDir)}
>
Open Image Cache Folder
</Button>
},
clearLogs: {
type: OptionType.COMPONENT,
description: "Clear Logs",
component: () =>
<Button
color={Button.Colors.RED}
onClick={() => Alerts.show({
title: "Clear Logs",
body: "Are you sure you want to clear all logs?",
confirmColor: Button.Colors.RED,
confirmText: "Clear",
cancelText: "Cancel",
onConfirm: () => {
LoggedMessageManager.clearLogs();
},
})}
>
Clear Logs
</Button>
},
});
const idFunctions = {
Server: props => props?.guild?.id,
User: props => props?.message?.author?.id || props?.user?.id,
Channel: props => props.message?.channel_id || props.channel?.id
} as const;
type idKeys = keyof typeof idFunctions;
function renderListOption(listType: ListType, IdType: idKeys, props: any) {
const id = idFunctions[IdType](props);
if (!id) return null;
const isBlocked = settings.store[listType].includes(id);
const oppositeListType = listType === "blacklistedIds" ? "whitelistedIds" : "blacklistedIds";
const isOppositeBlocked = settings.store[oppositeListType].includes(id);
const list = listType === "blacklistedIds" ? "Blacklist" : "Whitelist";
const addToList = () => addToXAndRemoveFromOpposite(listType, id);
const removeFromList = () => removeFromX(listType, id);
return (
<Menu.MenuItem
id={`${listType}-${IdType}-${id}`}
label={
isOppositeBlocked
? `Move ${IdType} to ${list}`
: isBlocked ? `Remove ${IdType} From ${list}` : `${list} ${IdType}`
}
action={isBlocked ? removeFromList : addToList}
/>
);
}
function renderOpenLogs(idType: idKeys, props: any) {
const id = idFunctions[idType](props);
if (!id) return null;
return (
<Menu.MenuItem
id={`open-logs-for-${idType.toLowerCase()}`}
label={`Open Logs For ${idType}`}
action={() => openLogModal(`${idType.toLowerCase()}:${id}`)}
/>
);
}
const contextMenuPath: NavContextMenuPatchCallback = (children, props) => {
if (!props) return;
if (!children.some(child => child?.props?.id === "message-logger")) {
children.push(
<Menu.MenuSeparator />,
<Menu.MenuItem
id="message-logger"
label="Message Logger"
>
<Menu.MenuItem
id="open-logs"
label="Open Logs"
action={() => openLogModal()}
/>
{Object.keys(idFunctions).map(IdType => renderOpenLogs(IdType as idKeys, props))}
<Menu.MenuSeparator />
{Object.keys(idFunctions).map(IdType => (
<React.Fragment key={IdType}>
{renderListOption("blacklistedIds", IdType as idKeys, props)}
{renderListOption("whitelistedIds", IdType as idKeys, props)}
</React.Fragment>
))}
{
props.navId === "message"
&& (props.message?.deleted || props.message?.editHistory?.length > 0)
&& (
<>
<Menu.MenuSeparator />
<Menu.MenuItem
id="remove-message"
label={props.message?.deleted ? "Remove Message (Permanent)" : "Remove Message History (Permanent)"}
color="danger"
action={() =>
removeLog(props.message.id)
.then(() => {
if (props.message.deleted) {
FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
channelId: props.message.channel_id,
id: props.message.id,
mlDeleted: true
});
} else {
props.message.editHistory = [];
}
}).catch(() => Toasts.show({
type: Toasts.Type.FAILURE,
message: "Failed to remove message",
id: Toasts.genId()
}))
}
/>
</>
)
}
{
settings.store.hideMessageFromMessageLoggers
&& props.navId === "message"
&& props.message?.author?.id === UserStore.getCurrentUser().id
&& props.message?.deleted === false
&& (
<>
<Menu.MenuSeparator />
<Menu.MenuItem
id="hide-from-message-loggers"
label="Delete Message (Hide From Message Loggers)"
color="danger"
action={async () => {
await MessageActions.deleteMessage(props.message.channel_id, props.message.id);
MessageActions._sendMessage(props.message.channel_id, {
"content": settings.store.hideMessageFromMessageLoggersDeletedMessage,
"tts": false,
"invalidEmojis": [],
"validNonShortcutEmojis": []
}, { nonce: props.message.id });
}}
/>
</>
)
}
</Menu.MenuItem>
);
}
};
export default definePlugin({
name: "MLEnhanced",
authors: [Devs.Aria],
description: "G'day",
dependencies: ["MessageLogger"],
contextMenus: {
"message": contextMenuPath,
"channel-context": contextMenuPath,
"user-context": contextMenuPath,
"guild-context": contextMenuPath,
"gdm-context": contextMenuPath
},
patches: [
{
find: '"MessageStore"',
replacement: {
match: /LOAD_MESSAGES_SUCCESS:function\(\i\){/,
replace: "$&$self.messageLoadSuccess(arguments[0]);"
}
},
{
find: "THREAD_STARTER_MESSAGE?null===",
replacement: {
match: /(attachments: \i\(.{1,500})deleted:.{1,50},editHistory:.{1,30},/,
replace: "$1deleted: $self.getDeleted(...arguments),editHistory: $self.getEdited(...arguments),"
}
},
{
find: "toolbar:function",
predicate: () => settings.store.ShowLogsButton,
replacement: {
match: /(function \i\(\i\){)(.{1,200}toolbar.{1,100}mobileToolbar)/,
replace: "$1$self.addIconToToolBar(arguments[0]);$2"
}
},
{
find: ",guildId:void 0}),childrenMessageContent",
replacement: {
match: /(cozyMessage.{1,50},)childrenHeader:/,
replace: "$1childrenAccessories:arguments[0].childrenAccessories || null,childrenHeader:"
}
},
// https://regex101.com/r/TMV1vY/1
{
find: ".removeAttachmentHoverButton",
replacement: {
match: /(\i=(\i)=>{)(.{1,250}isSingleMosaicItem)/,
replace: "$1 let forceUpdate=Vencord.Util.useForceUpdater();$self.patchAttachments($2,forceUpdate);$3"
}
},
{
find: "handleImageLoad=",
replacement: {
match: /(render\(\){)(.{1,100}zoomThumbnailPlaceholder)/,
replace: "$1$self.checkImage(this);$2"
}
},
// dont fetch messages for channels in modal
{
find: "Using PollReferenceMessageContext without",
replacement: {
match: /\i\.(?:default\.)?focusMessage\(/,
replace: "!(arguments[0]?.message?.deleted || arguments[0]?.message?.editHistory?.length > 0) && $&"
}
},
// only check for expired attachments if the message is not deleted
{
find: "\"/ephemeral-attachments/\"",
replacement: {
match: /\i\.attachments\.some\(\i\)\|\|\i\.embeds\.some/,
replace: "!arguments[0].deleted && $&"
}
}
],
settings,
toolboxActions: {
"Message Logger"() {
openLogModal();
}
},
addIconToToolBar(e: { toolbar: React.ReactNode[] | React.ReactNode; }) {
if (Array.isArray(e.toolbar))
return e.toolbar.push(
<ErrorBoundary noop={true}>
<OpenLogsButton />
</ErrorBoundary>
);
e.toolbar = [
<ErrorBoundary noop={true}>
<OpenLogsButton />
</ErrorBoundary>,
e.toolbar,
];
},
messageLoadSuccess,
store: MessageLoggerStore,
openLogModal,
doesMatch,
LoggedMessageManager,
ImageManager,
imageUtils,
isDeletedMessage: (id: string) => loggedMessages.deletedMessages[id] != null,
getDeleted(m1, m2) {
const deleted = m2?.deleted;
if (deleted == null && m1?.deleted != null) return m1.deleted;
return deleted;
},
getEdited(m1, m2) {
const editHistory = m2?.editHistory;
if (editHistory == null && m1?.editHistory != null && m1.editHistory.length > 0)
return m1.editHistory.map(mapEditHistory);
return editHistory;
},
attachments: new Map<string, LoggedAttachment>(),
patchAttachments(props: { attachment: LoggedAttachment, message: LoggedMessage; }, forceUpdate: () => void) {
const { attachment, message } = props;
if (!message.deleted || !LoggedMessageManager.hasMessageInLogs(message.id))
return; // Flogger.log("ignoring", message.id);
if (this.attachments.has(attachment.id))
return props.attachment = this.attachments.get(attachment.id)!; // Flogger.log("blobUrl already exists");
imageUtils.getAttachmentBlobUrl(attachment).then((blobUrl: string | null) => {
if (blobUrl == null) {
Flogger.error("image not found. for message.id =", message.id, blobUrl);
return;
}
Flogger.log("Got blob url for message.id =", message.id, blobUrl);
// we need to copy because changing this will change the attachment for the message in the logs
const attachmentCopy = { ...attachment };
attachmentCopy.oldUrl = attachment.url;
const finalBlobUrl = blobUrl + "#";
attachmentCopy.blobUrl = finalBlobUrl;
attachmentCopy.url = finalBlobUrl;
attachmentCopy.proxy_url = finalBlobUrl;
this.attachments.set(attachment.id, attachmentCopy);
forceUpdate();
});
},
async checkImage(instance: any) {
if (!instance.props.isBad && instance.state?.readyState !== "READY" && instance.props?.src?.startsWith("blob:")) {
if (await doesBlobUrlExist(instance.props.src)) {
Flogger.log("image exists", instance.props.src);
return instance.setState(e => ({ ...e, readyState: "READY" }));
}
instance.props.isBad = true;
}
},
flux: {
"MESSAGE_DELETE": messageDeleteHandler,
"MESSAGE_DELETE_BULK": messageDeleteBulkHandler,
"MESSAGE_UPDATE": messageUpdateHandler,
"MESSAGE_CREATE": messageCreateHandler
},
async start() {
Native.init();
const { imageCacheDir, logsDir } = await Native.getSettings();
settings.store.imageCacheDir = imageCacheDir;
settings.store.logsDir = logsDir;
}
});

View file

@ -0,0 +1,139 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { Queue } from "@utils/Queue";
import { dialog, IpcMainInvokeEvent, shell } from "electron";
import { DATA_DIR } from "../../../main/utils/constants";
import { getSettings, saveSettings } from "./settings";
import { ensureDirectoryExists, getAttachmentIdFromFilename } from "./utils";
export { getSettings };
// so we can filter the native helpers by this key
export function messageLoggerEnhancedUniqueIdThingyIdkMan() { }
// Map<attachmetId, path>()
const nativeSavedImages = new Map<string, string>();
export const getNativeSavedImages = () => nativeSavedImages;
let logsDir: string;
let imageCacheDir: string;
const getImageCacheDir = async () => imageCacheDir ?? await getDefaultNativeImageDir();
const getLogsDir = async () => logsDir ?? await getDefaultNativeDataDir();
export async function initDirs() {
const { logsDir: ld, imageCacheDir: icd } = await getSettings();
logsDir = ld || await getDefaultNativeDataDir();
imageCacheDir = icd || await getDefaultNativeImageDir();
}
initDirs();
export async function init(_event: IpcMainInvokeEvent) {
const imageDir = await getImageCacheDir();
await ensureDirectoryExists(imageDir);
const files = await readdir(imageDir);
for (const filename of files) {
const attachmentId = getAttachmentIdFromFilename(filename);
nativeSavedImages.set(attachmentId, path.join(imageDir, filename));
}
}
export async function getImageNative(_event: IpcMainInvokeEvent, attachmentId: string): Promise<Uint8Array | Buffer | null> {
const imagePath = nativeSavedImages.get(attachmentId);
if (!imagePath) return null;
return await readFile(imagePath);
}
export async function writeImageNative(_event: IpcMainInvokeEvent, filename: string, content: Uint8Array) {
if (!filename || !content) return;
const imageDir = await getImageCacheDir();
// returns the file name
// ../../someMalicousPath.png -> someMalicousPath
const attachmentId = getAttachmentIdFromFilename(filename);
const existingImage = nativeSavedImages.get(attachmentId);
if (existingImage) return;
const imagePath = path.join(imageDir, filename);
await ensureDirectoryExists(imageDir);
await writeFile(imagePath, content);
nativeSavedImages.set(attachmentId, imagePath);
}
export async function deleteFileNative(_event: IpcMainInvokeEvent, attachmentId: string) {
const imagePath = nativeSavedImages.get(attachmentId);
if (!imagePath) return;
await unlink(imagePath);
}
const LOGS_DATA_FILENAME = "message-logger-logs.json";
const dataWriteQueue = new Queue();
export async function getLogsFromFs(_event: IpcMainInvokeEvent) {
const logsDir = await getLogsDir();
await ensureDirectoryExists(logsDir);
try {
return JSON.parse(await readFile(path.join(logsDir, LOGS_DATA_FILENAME), "utf-8"));
} catch { }
return null;
}
export async function writeLogs(_event: IpcMainInvokeEvent, contents: string) {
const logsDir = await getLogsDir();
dataWriteQueue.push(() => writeFile(path.join(logsDir, LOGS_DATA_FILENAME), contents));
}
export async function getDefaultNativeImageDir(): Promise<string> {
return path.join(await getDefaultNativeDataDir(), "savedImages");
}
export async function getDefaultNativeDataDir(): Promise<string> {
return path.join(DATA_DIR, "MessageLoggerData");
}
export async function chooseDir(event: IpcMainInvokeEvent, logKey: "logsDir" | "imageCacheDir") {
const settings = await getSettings();
const defaultPath = settings[logKey] || await getDefaultNativeDataDir();
const res = await dialog.showOpenDialog({ properties: ["openDirectory"], defaultPath: defaultPath });
const dir = res.filePaths[0];
if (!dir) throw Error("Invalid Directory");
settings[logKey] = dir;
await saveSettings(settings);
switch (logKey) {
case "logsDir": logsDir = dir; break;
case "imageCacheDir": imageCacheDir = dir; break;
}
if (logKey === "imageCacheDir")
await init(event);
return dir;
}
export async function showItemInFolder(_event: IpcMainInvokeEvent, filePath: string) {
shell.showItemInFolder(filePath);
}

View file

@ -0,0 +1,49 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import fs from "fs/promises";
import path from "path";
import { getDefaultNativeDataDir, getDefaultNativeImageDir } from ".";
import { ensureDirectoryExists } from "./utils";
interface MLSettings {
logsDir: string;
imageCacheDir: string;
}
export async function getSettings(): Promise<MLSettings> {
try {
const settings = await fs.readFile(await getSettingsFilePath(), "utf8");
return JSON.parse(settings);
} catch (err) {
// probably doesnt exist
// time to create it
const settings = {
logsDir: await getDefaultNativeDataDir(),
imageCacheDir: await getDefaultNativeImageDir(),
};
try {
await saveSettings(settings);
} catch (err) { }
return settings;
}
}
// dont expose this to renderer future me
export async function saveSettings(settings: MLSettings) {
if (!settings) return;
await fs.writeFile(await getSettingsFilePath(), JSON.stringify(settings, null, 4), "utf8");
}
async function getSettingsFilePath() {
// mlSettings.json will always in that folder
const MlDataDir = await getDefaultNativeDataDir();
await ensureDirectoryExists(MlDataDir);
const mlSettingsDir = path.join(MlDataDir, "mlSettings.json");
return mlSettingsDir;
}

View file

@ -0,0 +1,26 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { access, mkdir } from "fs/promises";
import path from "path";
export async function exists(filename: string) {
try {
await access(filename);
return true;
} catch (error) {
return false;
}
}
export async function ensureDirectoryExists(cacheDir: string) {
if (!await exists(cacheDir))
await mkdir(cacheDir);
}
export function getAttachmentIdFromFilename(filename: string) {
return path.parse(filename).name;
}

View file

@ -0,0 +1,82 @@
.msg-logger-modal-root {
max-height: 80vh;
min-height: 80vh;
min-width: 85vw;
max-width: 85vw;
}
.msg-logger-modal-empty-logs {
display: grid;
place-content: center;
height: 100%;
}
.msg-logger-modal-header {
flex-direction: column;
/* width: 100%; */
}
.msg-logger-modal-content-container {
/* max-height: 80vh; */
overflow: hidden;
height: 100%;
transition: opacity 100ms ease-in;
}
.msg-logger-modal-content {
padding-bottom: 20px;
height: 100%;
}
.msg-logger-modal-content-inner {
padding-top: 1rem;
padding-bottom: 1rem;
height: 100%;
}
.msg-logger-modal-header>div:has(input) {
width: 100%;
}
.msg-logger-modal-tab-bar-item {
margin-right: 32px;
padding-bottom: 16px;
margin-bottom: -2px;
}
.msg-logger-modal-tab-bar {
margin-top: 20px;
width: 100%;
justify-content: space-around;
}
.vc-log-toolbox-btn svg {
color: var(--interactive-normal);
}
:is(.vc-log-toolbox-btn:hover, .vc-log-toolbox-btn[class*='selected']) svg {
color: var(--interactive-active);
}
.folder-upload-container {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--background-secondary);
}
.folder-upload-input {
cursor: pointer;
padding: 10px 0 10px 10px;
color: var(--header-secondary);
}
.folder-upload-button {
margin: 6px;
padding: 4px 8px;
}
.vc-updater-modal-content {
padding: 1rem 2rem;
}

View file

@ -0,0 +1,124 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Message, MessageAttachment, MessageJSON } from "discord-types/general";
export interface LoggedAttachment extends MessageAttachment {
fileExtension?: string | null;
path?: string | null;
blobUrl?: string;
nativefileSystem?: boolean;
oldUrl?: string;
}
export type RefrencedMessage = LoggedMessageJSON & { message_id: string; };
export interface LoggedMessageJSON extends Omit<LoggedMessage, "timestamp"> {
mention_everyone?: string;
guildId?: string;
guild_id?: string;
ghostPinged?: boolean;
timestamp: string;
ourCache?: boolean;
referenced_message: RefrencedMessage;
message_reference: RefrencedMessage;
}
export interface LoggedMessage extends Message {
attachments: LoggedAttachment[];
deleted?: boolean;
deletedTimestamp?: string;
editHistory?: {
timestamp: string;
content: string;
}[];
}
export interface MessageDeletePayload {
type: string;
guildId: string;
id: string;
channelId: string;
mlDeleted?: boolean;
}
export interface MessageDeleteBulkPayload {
type: string;
guildId: string;
ids: string[];
channelId: string;
}
export interface MessageUpdatePayload {
type: string;
guildId: string;
message: MessageJSON;
}
export interface MessageCreatePayload {
type: string;
guildId: string;
channelId: string;
message: MessageJSON;
optimistic: boolean;
isPushNotification: boolean;
}
export interface LoadMessagePayload {
type: string;
channelId: string;
messages: LoggedMessageJSON[];
isBefore: boolean;
isAfter: boolean;
hasMoreBefore: boolean;
hasMoreAfter: boolean;
limit: number;
isStale: boolean;
}
export interface AttachmentData {
messageId: string;
attachmentId: string;
}
export type SavedImages = Record<string, AttachmentData>;
export type LoggedMessageIds = {
// [channel_id: string]: message_id
deletedMessages: Record<string, string[]>;
editedMessages: Record<string, string[]>;
};
export type MessageRecord = { message: LoggedMessageJSON; };
export type LoggedMessages = LoggedMessageIds & { [message_id: string]: { message?: LoggedMessageJSON; }; };
export type GitValue = {
value: any;
stderr?: string;
ok: true;
};
export type GitError = {
ok: false;
cmd: string;
message: string;
error: any;
};
export type GitResult = GitValue | GitError;

View file

@ -0,0 +1,36 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { settings } from "../index";
export class LimitedMap<K, V> {
public map: Map<K, V> = new Map();
constructor() { }
set(key: K, value: V) {
if (settings.store.cacheLimit > 0 && this.map.size >= settings.store.cacheLimit) {
// delete the first entry
this.map.delete(this.map.keys().next().value);
}
this.map.set(key, value);
}
get(key: K) {
return this.map.get(key);
}
}

View file

@ -0,0 +1,116 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { MessageStore } from "@webpack/common";
import { User } from "discord-types/general";
import { LoggedMessageJSON, RefrencedMessage } from "../types";
import { getGuildIdByChannel, isGhostPinged } from "./index";
export function cleanupMessage(message: any, removeDetails: boolean = true): LoggedMessageJSON {
const ret: LoggedMessageJSON = typeof message.toJS === "function" ? JSON.parse(JSON.stringify(message.toJS())) : { ...message };
if (removeDetails) {
ret.author.phone = undefined;
ret.author.email = undefined;
}
ret.ghostPinged = ret.mentioned ?? isGhostPinged(message);
ret.guildId = ret.guild_id ?? getGuildIdByChannel(ret.channel_id);
ret.embeds = (ret.embeds ?? []).map(cleanupEmbed);
ret.deleted = ret.deleted ?? false;
ret.deletedTimestamp = ret.deleted ? (new Date()).toISOString() : undefined;
ret.editHistory = ret.editHistory ?? [];
if (ret.type === 19) {
ret.message_reference = message.message_reference || message.messageReference;
if (ret.message_reference) {
if (message.referenced_message) {
ret.referenced_message = cleanupMessage(message.referenced_message) as RefrencedMessage;
} else if (MessageStore.getMessage(ret.message_reference.channel_id, ret.message_reference.message_id)) {
ret.referenced_message = cleanupMessage(MessageStore.getMessage(ret.message_reference.channel_id, ret.message_reference.message_id)) as RefrencedMessage;
}
}
}
return ret;
}
export function cleanUpCachedMessage(message: any) {
const ret = cleanupMessage(message, false);
ret.ourCache = true;
return ret;
}
// stolen from mlv2
export function cleanupEmbed(embed) {
/* backported code from MLV2 rewrite */
if (!embed.id) return embed; /* already cleaned */
const retEmbed: any = {};
if (typeof embed.rawTitle === "string") retEmbed.title = embed.rawTitle;
if (typeof embed.rawDescription === "string") retEmbed.description = embed.rawDescription;
if (typeof embed.referenceId !== "undefined") retEmbed.reference_id = embed.referenceId;
// if (typeof embed.color === "string") retEmbed.color = ZeresPluginLibrary.ColorConverter.hex2int(embed.color);
if (typeof embed.type !== "undefined") retEmbed.type = embed.type;
if (typeof embed.url !== "undefined") retEmbed.url = embed.url;
if (typeof embed.provider === "object") retEmbed.provider = { name: embed.provider.name, url: embed.provider.url };
if (typeof embed.footer === "object") retEmbed.footer = { text: embed.footer.text, icon_url: embed.footer.iconURL, proxy_icon_url: embed.footer.iconProxyURL };
if (typeof embed.author === "object") retEmbed.author = { name: embed.author.name, url: embed.author.url, icon_url: embed.author.iconURL, proxy_icon_url: embed.author.iconProxyURL };
if (typeof embed.timestamp === "object" && embed.timestamp._isAMomentObject) retEmbed.timestamp = embed.timestamp.milliseconds();
if (typeof embed.thumbnail === "object") {
if (typeof embed.thumbnail.proxyURL === "string" || (typeof embed.thumbnail.url === "string" && !embed.thumbnail.url.endsWith("?format=jpeg"))) {
retEmbed.thumbnail = {
url: embed.thumbnail.url,
proxy_url: typeof embed.thumbnail.proxyURL === "string" ? embed.thumbnail.proxyURL.split("?format")[0] : undefined,
width: embed.thumbnail.width,
height: embed.thumbnail.height
};
}
}
if (typeof embed.image === "object") {
retEmbed.image = {
url: embed.image.url,
proxy_url: embed.image.proxyURL,
width: embed.image.width,
height: embed.image.height
};
}
if (typeof embed.video === "object") {
retEmbed.video = {
url: embed.video.url,
proxy_url: embed.video.proxyURL,
width: embed.video.width,
height: embed.video.height
};
}
if (Array.isArray(embed.fields) && embed.fields.length) {
retEmbed.fields = embed.fields.map(e => ({ name: e.rawName, value: e.rawValue, inline: e.inline }));
}
return retEmbed;
}
// stolen from mlv2
export function cleanupUserObject(user: User) {
/* backported from MLV2 rewrite */
return {
discriminator: user.discriminator,
username: user.username,
avatar: user.avatar,
id: user.id,
bot: user.bot,
public_flags: typeof user.publicFlags !== "undefined" ? user.publicFlags : (user as any).public_flags
};
}

View file

@ -0,0 +1,19 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const DEFAULT_IMAGE_CACHE_DIR = "savedImages";

View file

@ -0,0 +1,19 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! hi this file is now usless. but ill keep it here just in case some people forgot to remove it from the preload.ts

View file

@ -0,0 +1,184 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Settings } from "@api/Settings";
import { findStoreLazy } from "@webpack";
import { ChannelStore, SelectedChannelStore, UserStore } from "@webpack/common";
import { settings } from "../index";
import { loggedMessages } from "../LoggedMessageManager";
import { LoggedMessageJSON } from "../types";
import { findLastIndex, getGuildIdByChannel } from "./misc";
export * from "./cleanUp";
export * from "./misc";
// stolen from mlv2
// https://github.com/1Lighty/BetterDiscordPlugins/blob/master/Plugins/MessageLoggerV2/MessageLoggerV2.plugin.js#L2367
interface Id { id: string, time: number; }
export const DISCORD_EPOCH = 14200704e5;
export function reAddDeletedMessages(messages: LoggedMessageJSON[], deletedMessages: string[], channelStart: boolean, channelEnd: boolean) {
if (!messages.length || !deletedMessages?.length) return;
const IDs: Id[] = [];
const savedIDs: Id[] = [];
for (let i = 0, len = messages.length; i < len; i++) {
const { id } = messages[i];
IDs.push({ id: id, time: (parseInt(id) / 4194304) + DISCORD_EPOCH });
}
for (let i = 0, len = deletedMessages.length; i < len; i++) {
const id = deletedMessages[i];
const record = loggedMessages[id];
if (!record) continue;
savedIDs.push({ id: id, time: (parseInt(id) / 4194304) + DISCORD_EPOCH });
}
savedIDs.sort((a, b) => a.time - b.time);
if (!savedIDs.length) return;
const { time: lowestTime } = IDs[IDs.length - 1];
const [{ time: highestTime }] = IDs;
const lowestIDX = channelEnd ? 0 : savedIDs.findIndex(e => e.time > lowestTime);
if (lowestIDX === -1) return;
const highestIDX = channelStart ? savedIDs.length - 1 : findLastIndex(savedIDs, e => e.time < highestTime);
if (highestIDX === -1) return;
const reAddIDs = savedIDs.slice(lowestIDX, highestIDX + 1);
reAddIDs.push(...IDs);
reAddIDs.sort((a, b) => b.time - a.time);
for (let i = 0, len = reAddIDs.length; i < len; i++) {
const { id } = reAddIDs[i];
if (messages.findIndex(e => e.id === id) !== -1) continue;
const record = loggedMessages[id];
if (!record.message) continue;
messages.splice(i, 0, record.message);
}
}
interface ShouldIgnoreArguments {
channelId?: string,
authorId?: string,
guildId?: string;
flags?: number,
bot?: boolean;
ghostPinged?: boolean;
isCachedByUs?: boolean;
}
const EPHEMERAL = 64;
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
/**
* the function `shouldIgnore` evaluates whether a message should be ignored or kept, following a priority hierarchy: User > Channel > Server.
* In this hierarchy, whitelisting takes priority; if any element (User, Channel, or Server) is whitelisted, the message is kept.
* However, if a higher-priority element, like a User, is blacklisted, it will override the whitelisting status of a lower-priority element, such as a Server, causing the message to be ignored.
* @param {ShouldIgnoreArguments} args - An object containing the message details.
* @returns {boolean} - True if the message should be ignored, false if it should be kept.
*/
export function shouldIgnore({ channelId, authorId, guildId, flags, bot, ghostPinged, isCachedByUs }: ShouldIgnoreArguments): boolean {
const isEphemeral = ((flags ?? 0) & EPHEMERAL) === EPHEMERAL;
if (isEphemeral) return true; // ignore
if (channelId && guildId == null)
guildId = getGuildIdByChannel(channelId);
const myId = UserStore.getCurrentUser().id;
const { ignoreUsers, ignoreChannels, ignoreGuilds } = Settings.plugins.MessageLogger;
const { ignoreBots, ignoreSelf } = settings.store;
if (ignoreSelf && authorId === myId)
return true; // ignore
if (settings.store.alwaysLogDirectMessages && ChannelStore.getChannel(channelId ?? "-1")?.isDM?.())
return false; // keep
const shouldLogCurrentChannel = settings.store.alwaysLogCurrentChannel && SelectedChannelStore.getChannelId() === channelId;
const ids = [authorId, channelId, guildId];
const whitelistedIds = settings.store.whitelistedIds.split(",");
const isWhitelisted = settings.store.whitelistedIds.split(",").some(e => ids.includes(e));
const isAuthorWhitelisted = whitelistedIds.includes(authorId!);
const isChannelWhitelisted = whitelistedIds.includes(channelId!);
const isGuildWhitelisted = whitelistedIds.includes(guildId!);
const blacklistedIds = [
...settings.store.blacklistedIds.split(","),
...(ignoreUsers ?? []).split(","),
...(ignoreChannels ?? []).split(","),
...(ignoreGuilds ?? []).split(",")
];
const isBlacklisted = blacklistedIds.some(e => ids.includes(e));
const isAuthorBlacklisted = blacklistedIds.includes(authorId);
const isChannelBlacklisted = blacklistedIds.includes(channelId);
const shouldIgnoreMutedGuilds = settings.store.ignoreMutedGuilds;
const shouldIgnoreMutedCategories = settings.store.ignoreMutedCategories;
const shouldIgnoreMutedChannels = settings.store.ignoreMutedChannels;
if ((ignoreBots && bot) && !isAuthorWhitelisted) return true; // ignore
if (ghostPinged) return false; // keep
// author has highest priority
if (isAuthorWhitelisted) return false; // keep
if (isAuthorBlacklisted) return true; // ignore
if (isChannelWhitelisted) return false; // keep
if (isChannelBlacklisted) return true; // ignore
if (shouldLogCurrentChannel) return false; // keep
if (isWhitelisted) return false; // keep
if (isCachedByUs && (!settings.store.cacheMessagesFromServers && guildId != null && !isGuildWhitelisted)) return true; // ignore
if (isBlacklisted && (!isAuthorWhitelisted || !isChannelWhitelisted)) return true; // ignore
if (guildId != null && shouldIgnoreMutedGuilds && UserGuildSettingsStore.isMuted(guildId)) return true; // ignore
if (channelId != null && shouldIgnoreMutedCategories && UserGuildSettingsStore.isCategoryMuted(guildId, channelId)) return true; // ignore
if (channelId != null && shouldIgnoreMutedChannels && UserGuildSettingsStore.isChannelMuted(guildId, channelId)) return true; // ignore
return false; // keep;
}
export type ListType = "blacklistedIds" | "whitelistedIds";
export function addToXAndRemoveFromOpposite(list: ListType, id: string) {
const oppositeListType = list === "blacklistedIds" ? "whitelistedIds" : "blacklistedIds";
removeFromX(oppositeListType, id);
addToX(list, id);
}
export function addToX(list: ListType, id: string) {
const items = settings.store[list] ? settings.store[list].split(",") : [];
items.push(id);
settings.store[list] = items.join(",");
}
export function removeFromX(list: ListType, id: string) {
const items = settings.store[list] ? settings.store[list].split(",") : [];
const index = items.indexOf(id);
if (index !== -1) {
items.splice(index, 1);
}
settings.store[list] = items.join(",");
}

View file

@ -0,0 +1,41 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
type MemoizedFunction<T extends (...args: any[]) => any> = {
(...args: Parameters<T>): ReturnType<T>;
clear(): void;
};
export function memoize<T extends (...args: any[]) => any>(func: T): MemoizedFunction<T> {
const cache = new Map<string, ReturnType<T>>();
const memoizedFunc = (...args: Parameters<T>): ReturnType<T> => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = func(...args);
cache.set(key, result);
return result;
};
memoizedFunc.clear = () => cache.clear();
return memoizedFunc;
}

View file

@ -0,0 +1,156 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { get, set } from "@api/DataStore";
import { PluginNative } from "@utils/types";
import { findByPropsLazy, findLazy } from "@webpack";
import { ChannelStore, moment, UserStore } from "@webpack/common";
import { LOGGED_MESSAGES_KEY, MessageLoggerStore } from "../LoggedMessageManager";
import { LoggedMessageJSON } from "../types";
import { DEFAULT_IMAGE_CACHE_DIR } from "./constants";
import { DISCORD_EPOCH } from "./index";
import { memoize } from "./memoize";
const MessageClass: any = findLazy(m => m?.prototype?.isEdited);
const AuthorClass = findLazy(m => m?.prototype?.getAvatarURL);
const embedModule = findByPropsLazy("sanitizeEmbed");
export function getGuildIdByChannel(channel_id: string) {
return ChannelStore.getChannel(channel_id)?.guild_id;
}
export const isGhostPinged = (message?: LoggedMessageJSON) => {
return message?.ghostPinged || message?.deleted && hasPingged(message);
};
export const hasPingged = (message?: LoggedMessageJSON | { mention_everyone: boolean, mentions: any[]; }) => {
return message && !!(
message.mention_everyone ||
message.mentions?.find(m => (typeof m === "string" ? m : m.id) === UserStore.getCurrentUser().id)
);
};
export const discordIdToDate = (id: string) => new Date((parseInt(id) / 4194304) + DISCORD_EPOCH);
export const sortMessagesByDate = (timestampA: string, timestampB: string) => {
// very expensive
// const timestampA = discordIdToDate(a).getTime();
// const timestampB = discordIdToDate(b).getTime();
// return timestampB - timestampA;
// newest first
if (timestampA < timestampB) {
return 1;
} else if (timestampA > timestampB) {
return -1;
} else {
return 0;
}
};
// stolen from mlv2
export function findLastIndex<T>(array: T[], predicate: (e: T, t: number, n: T[]) => boolean) {
let l = array.length;
while (l--) {
if (predicate(array[l], l, array))
return l;
}
return -1;
}
const getTimestamp = (timestamp: any): Date => {
return new Date(timestamp);
};
export const mapEditHistory = (m: any) => {
m.timestamp = getTimestamp(m.timestamp);
return m;
};
export const messageJsonToMessageClass = memoize((log: { message: LoggedMessageJSON; }) => {
// console.time("message populate");
if (!log?.message) return null;
const message: any = new MessageClass(log.message);
// @ts-ignore
message.timestamp = getTimestamp(message.timestamp);
const editHistory = message.editHistory?.map(mapEditHistory);
if (editHistory && editHistory.length > 0) {
message.editHistory = editHistory;
}
if (message.editedTimestamp)
message.editedTimestamp = getTimestamp(message.editedTimestamp);
message.author = new AuthorClass(message.author);
message.author.nick = message.author.globalName ?? message.author.username;
message.embeds = message.embeds.map(e => embedModule.sanitizeEmbed(message.channel_id, message.id, e));
if (message.poll)
message.poll.expiry = moment(message.poll.expiry);
// console.timeEnd("message populate");
return message;
});
export function parseJSON(json?: string | null) {
try {
return JSON.parse(json!);
} finally {
return null;
}
}
export async function doesBlobUrlExist(url: string) {
const res = await fetch(url);
return res.ok;
}
export function getNative(): PluginNative<typeof import("../native")> {
if (IS_WEB) {
const Native = {
getLogsFromFs: async () => get(LOGGED_MESSAGES_KEY, MessageLoggerStore),
writeLogs: async (logs: string) => set(LOGGED_MESSAGES_KEY, JSON.parse(logs), MessageLoggerStore),
getDefaultNativeImageDir: async () => DEFAULT_IMAGE_CACHE_DIR,
getDefaultNativeDataDir: async () => "",
deleteFileNative: async () => { },
chooseDir: async (x: string) => "",
getSettings: async () => ({ imageCacheDir: DEFAULT_IMAGE_CACHE_DIR, logsDir: "" }),
init: async () => { },
initDirs: async () => { },
getImageNative: async (x: string) => new Uint8Array(0),
getNativeSavedImages: async () => new Map(),
messageLoggerEnhancedUniqueIdThingyIdkMan: async () => { },
showItemInFolder: async () => { },
writeImageNative: async () => { },
} satisfies PluginNative<typeof import("../native")>;
return Native;
}
return Object.values(VencordNative.pluginHelpers)
.find(m => m.messageLoggerEnhancedUniqueIdThingyIdkMan) as PluginNative<typeof import("../native")>;
}

View file

@ -0,0 +1,101 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ChannelStore, GuildStore } from "@webpack/common";
import { LoggedMessageJSON } from "../types";
import { getGuildIdByChannel } from "./index";
import { memoize } from "./memoize";
const validIdSearchTypes = ["server", "guild", "channel", "in", "user", "from", "message"] as const;
type ValidIdSearchTypesUnion = typeof validIdSearchTypes[number];
interface QueryResult {
success: boolean;
query: string;
type?: ValidIdSearchTypesUnion;
id?: string;
negate?: boolean;
}
export const parseQuery = memoize((query: string = ""): QueryResult => {
let trimmedQuery = query.trim();
if (!trimmedQuery) {
return { success: false, query };
}
let negate = false;
if (trimmedQuery.startsWith("!")) {
negate = true;
trimmedQuery = trimmedQuery.substring(trimmedQuery.length, 1);
}
const [filter, rest] = trimmedQuery.split(" ", 2);
if (!filter) {
return { success: false, query };
}
const [type, id] = filter.split(":") as [ValidIdSearchTypesUnion, string];
if (!type || !id || !validIdSearchTypes.includes(type)) {
return { success: false, query };
}
return {
success: true,
type,
id,
negate,
query: rest ?? ""
};
});
export const doesMatch = (type: typeof validIdSearchTypes[number], value: string, message: LoggedMessageJSON) => {
switch (type) {
case "in":
case "channel":
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel)
return message.channel_id === value;
const { name, id } = channel;
return id === value
|| name.toLowerCase().includes(value.toLowerCase());
case "message":
return message.id === value;
case "from":
case "user":
return message.author.id === value
|| message.author?.username?.toLowerCase().includes(value.toLowerCase())
|| (message.author as any)?.globalName?.toLowerCase()?.includes(value.toLowerCase());
case "guild":
case "server": {
const guildId = message.guildId ?? getGuildIdByChannel(message.channel_id);
if (!guildId) return false;
const guild = GuildStore.getGuild(guildId);
if (!guild)
return guildId === value;
return guild.id === value
|| guild.name.toLowerCase().includes(value.toLowerCase());
}
default:
return false;
}
};

View file

@ -0,0 +1,80 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createStore,
del,
get,
keys,
set,
} from "@api/DataStore";
import { Flogger, Native } from "../..";
import { DEFAULT_IMAGE_CACHE_DIR } from "../constants";
const ImageStore = createStore("MessageLoggerImageData", "MessageLoggerImageStore");
interface IDBSavedImages { attachmentId: string, path: string; }
let idbSavedImages: IDBSavedImages[] = [];
(async () => {
try {
idbSavedImages = (await keys(ImageStore))
.map(m => {
const str = m.toString();
if (!str.startsWith(DEFAULT_IMAGE_CACHE_DIR)) return null;
return { attachmentId: str.split("/")?.[1]?.split(".")?.[0], path: str };
})
.filter(Boolean) as IDBSavedImages[];
} catch (err) {
Flogger.error("Failed to get idb images", err);
}
})();
export async function getImage(attachmentId: string, fileExt?: string | null): Promise<any> {
// for people who have access to native api but some images are still in idb
// also for people who dont have native api
const idbPath = idbSavedImages.find(m => m.attachmentId === attachmentId)?.path;
if (idbPath)
return get(idbPath, ImageStore);
if (IS_WEB) return null;
return await Native.getImageNative(attachmentId);
}
// file name shouldnt have any query param shinanigans
export async function writeImage(imageCacheDir: string, filename: string, content: Uint8Array): Promise<void> {
if (IS_WEB) {
const path = `${imageCacheDir}/${filename}`;
idbSavedImages.push({ attachmentId: filename.split(".")?.[0], path });
return set(path, content, ImageStore);
}
Native.writeImageNative(filename, content);
}
export async function deleteImage(attachmentId: string): Promise<void> {
const idbPath = idbSavedImages.find(m => m.attachmentId === attachmentId)?.path;
if (idbPath)
return await del(idbPath, ImageStore);
if (IS_WEB) return;
await Native.deleteFileNative(attachmentId);
}

View file

@ -0,0 +1,115 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { sleep } from "@utils/misc";
import { MessageAttachment } from "discord-types/general";
import { Flogger, Native, settings } from "../..";
import { LoggedAttachment, LoggedMessage, LoggedMessageJSON } from "../../types";
import { memoize } from "../memoize";
import { deleteImage, getImage, writeImage, } from "./ImageManager";
export function getFileExtension(str: string) {
const matches = str.match(/(\.[a-zA-Z0-9]+)(?:\?.*)?$/);
if (!matches) return null;
return matches[1];
}
export function isImage(url: string) {
return /\.(jpe?g|png|gif|bmp)(\?.*)?$/i.test(url);
}
export function isAttachmentImage(attachment: MessageAttachment) {
return isImage(attachment.filename ?? attachment.url) || (attachment.content_type?.split("/")[0] === "image");
}
function transformAttachmentUrl(messageId: string, attachmentUrl: string) {
const url = new URL(attachmentUrl);
url.searchParams.set("messageId", messageId);
return url.toString();
}
export async function cacheImage(url: string, attachmentIdx: number, attachmentId: string, messageId: string, channelId: string, fileExtension: string | null, attempts = 0) {
const res = await fetch(url);
if (res.status !== 200) {
if (res.status === 404 || res.status === 403) return;
attempts++;
if (attempts > 3) {
Flogger.warn(`Failed to get image ${attachmentId} for caching, error code ${res.status}`);
return;
}
await sleep(1000);
return cacheImage(url, attachmentIdx, attachmentId, messageId, channelId, fileExtension, attempts);
}
const ab = await res.arrayBuffer();
const imageCacheDir = settings.store.imageCacheDir ?? await Native.getDefaultNativeImageDir();
const path = `${imageCacheDir}/${attachmentId}${fileExtension}`;
await writeImage(imageCacheDir, `${attachmentId}${fileExtension}`, new Uint8Array(ab));
return path;
}
export async function cacheMessageImages(message: LoggedMessage | LoggedMessageJSON) {
try {
for (let i = 0; i < message.attachments.length; i++) {
const attachment = message.attachments[i];
if (!isAttachmentImage(attachment)) {
Flogger.log("skipping", attachment.filename);
continue;
}
// apparently proxy urls last longer
attachment.url = transformAttachmentUrl(message.id, attachment.proxy_url);
const fileExtension = getFileExtension(attachment.filename ?? attachment.url) ?? attachment.content_type?.split("/")?.[1] ?? ".png";
const path = await cacheImage(attachment.url, i, attachment.id, message.id, message.channel_id, fileExtension);
if (path == null) {
Flogger.error("Failed to save image from attachment. id: ", attachment.id);
continue;
}
attachment.fileExtension = fileExtension;
attachment.path = path;
}
} catch (error) {
Flogger.error("Error caching message images:", error);
}
}
export async function deleteMessageImages(message: LoggedMessage | LoggedMessageJSON) {
for (let i = 0; i < message.attachments.length; i++) {
const attachment = message.attachments[i];
await deleteImage(attachment.id);
}
}
export const getAttachmentBlobUrl = memoize(async (attachment: LoggedAttachment) => {
const imageData = await getImage(attachment.id, attachment.fileExtension);
if (!imageData) return null;
const blob = new Blob([imageData]);
const resUrl = URL.createObjectURL(blob);
return resUrl;
});

View file

@ -0,0 +1,51 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { DataStore } from "@api/index";
import { LOGGED_MESSAGES_KEY, MessageLoggerStore } from "../LoggedMessageManager";
// 99% of this is coppied from src\utils\settingsSync.ts
export async function downloadLoggedMessages() {
const filename = "message-logger-logs.json";
const exportData = await exportLogs();
const data = new TextEncoder().encode(exportData);
if (IS_WEB || IS_VESKTOP) {
const file = new File([data], filename, { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = filename;
document.body.appendChild(a);
a.click();
setImmediate(() => {
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
});
} else {
DiscordNative.fileManager.saveWithDialog(data, filename);
}
}
export async function exportLogs() {
const logger_data = await DataStore.get(LOGGED_MESSAGES_KEY, MessageLoggerStore);
return JSON.stringify(logger_data, null, 4);
}