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,213 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findByCode } from "@webpack";
import { ChannelStore, lodash, Toasts, UserStore } from "@webpack/common";
import { Channel, Message } from "discord-types/general";
import { Discord, HolyNotes } from "./types";
import { deleteCacheFromDataStore, DeleteEntireStore, saveCacheToDataStore } from "./utils";
export const noteHandlerCache = new Map();
export default new (class NoteHandler {
private _formatNote(channel: Channel, message: Message): HolyNotes.Note {
return {
id: message.id,
channel_id: message.channel_id,
guild_id: channel.guild_id,
content: message.content,
author: {
id: message.author.id,
avatar: message.author.avatar,
discriminator: message.author.discriminator,
username: message.author.username,
},
flags: message.flags,
// Moment has a toString() function, this doesn't convert to '[object Object]'.
// eslint-disable-next-line @typescript-eslint/no-base-to-string
timestamp: message.timestamp.toString(),
attachments: message.attachments as Discord.Attachment[],
embeds: message.embeds,
reactions: message.reactions as Discord.Reaction[],
stickerItems: message.stickerItems,
};
}
public getNotes(notebook?: string): Record<string, HolyNotes.Note> {
return noteHandlerCache.get(notebook);
}
public getAllNotes(): HolyNotes.Note[] {
const data = noteHandlerCache.entries();
const notes = {};
for (const [key, value] of data) {
notes[key] = value;
}
return notes as HolyNotes.Note[];
}
public addNote = async (message: Message, notebook: string) => {
const notes = this.getNotes(notebook);
const channel = ChannelStore.getChannel(message.channel_id);
const newNotes = Object.assign({ [message.id]: this._formatNote(channel, message) }, notes);
noteHandlerCache.set(notebook, newNotes);
saveCacheToDataStore(notebook, newNotes as unknown as HolyNotes.Note[]);
Toasts.show({
id: Toasts.genId(),
message: `Successfully added note to ${notebook}.`,
type: Toasts.Type.SUCCESS,
});
};
public deleteNote = async (noteId: string, notebook: string) => {
const notes = this.getNotes(notebook);
noteHandlerCache.set(notebook, lodash.omit(notes, noteId));
saveCacheToDataStore(notebook, lodash.omit(notes, noteId) as unknown as HolyNotes.Note[]);
Toasts.show({
id: Toasts.genId(),
message: `Successfully deleted note from ${notebook}.`,
type: Toasts.Type.SUCCESS,
});
};
public moveNote = async (note: HolyNotes.Note, from: string, to: string) => {
const origNotebook = this.getNotes(from);
const newNoteBook = lodash.cloneDeep(this.getNotes(to));
newNoteBook[note.id] = note;
noteHandlerCache.set(from, lodash.omit(origNotebook, note.id));
noteHandlerCache.set(to, newNoteBook);
saveCacheToDataStore(from, lodash.omit(origNotebook, note.id) as unknown as HolyNotes.Note[]);
saveCacheToDataStore(to, newNoteBook as unknown as HolyNotes.Note[]);
Toasts.show({
id: Toasts.genId(),
message: `Successfully moved note from ${from} to ${to}.`,
type: Toasts.Type.SUCCESS,
});
};
public newNoteBook = async (notebookName: string, silent?: Boolean) => {
let notebookExists = false;
for (const key of noteHandlerCache.keys()) {
if (key === notebookName) {
notebookExists = true;
break;
}
}
if (notebookExists) {
Toasts.show({
id: Toasts.genId(),
message: `Notebook ${notebookName} already exists.`,
type: Toasts.Type.FAILURE,
});
return;
}
noteHandlerCache.set(notebookName, {});
saveCacheToDataStore(notebookName, {} as HolyNotes.Note[]);
if (!silent) return Toasts.show({
id: Toasts.genId(),
message: `Successfully created ${notebookName}.`,
type: Toasts.Type.SUCCESS,
});
};
public deleteEverything = async () => {
noteHandlerCache.clear();
await DeleteEntireStore();
Toasts.show({
id: Toasts.genId(),
message: "Successfully deleted all notes.",
type: Toasts.Type.SUCCESS,
});
};
public deleteNotebook = async (notebookName: string) => {
noteHandlerCache.delete(notebookName);
deleteCacheFromDataStore(notebookName);
Toasts.show({
id: Toasts.genId(),
message: `Successfully deleted ${notebookName}.`,
type: Toasts.Type.SUCCESS,
});
};
public refreshAvatars = async () => {
const notebooks = this.getAllNotes();
const User = findByCode("tag", "isClyde");
for (const notebook in notebooks)
for (const noteId in notebooks[notebook]) {
const note = notebooks[notebook][noteId];
const user = UserStore.getUser(note.author.id) ?? new User({ ...note.author });
Object.assign(notebooks[notebook][noteId].author, {
avatar: user.avatar,
discriminator: user.discriminator,
username: user.username,
});
}
for (const notebook in notebooks) {
noteHandlerCache.set(notebook, notebooks[notebook]);
saveCacheToDataStore(notebook, notebooks[notebook] as unknown as HolyNotes.Note[]);
}
Toasts.show({
id: Toasts.genId(),
message: "Successfully refreshed avatars.",
type: Toasts.Type.SUCCESS,
});
};
public exportNotes = async () => {
return this.getAllNotes();
};
public importNotes = async (notes: HolyNotes.Note[]) => {
try {
var parseNotes = JSON.parse(notes as unknown as string);
} catch (e) {
console.log(e);
return Toasts.show({
id: Toasts.genId(),
message: "Invalid JSON.",
type: Toasts.Type.FAILURE,
});
}
for (const notebook in parseNotes) {
noteHandlerCache.set(notebook, parseNotes[notebook]);
saveCacheToDataStore(notebook, parseNotes[notebook] as unknown as HolyNotes.Note[]);
}
Toasts.show({
id: Toasts.genId(),
message: "Successfully imported notes.",
type: Toasts.Type.SUCCESS,
});
};
});

View file

@ -0,0 +1,23 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classes } from "@utils/misc";
export default ({ className }: { className?: string; }): JSX.Element => (
<svg
x="0"
y="0"
className={classes("vc-holynotes-icon")}
aria-hidden="true"
role="img"
width="24"
height="24"
viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 2C6.486 2 2 6.487 2 12C2 17.515 6.486 22 12 22C17.514 22 22 17.515 22 12C22 6.487 17.514 2 12 2ZM12 18.25C11.31 18.25 10.75 17.691 10.75 17C10.75 16.31 11.31 15.75 12 15.75C12.69 15.75 13.25 16.31 13.25 17C13.25 17.691 12.69 18.25 12 18.25ZM13 13.875V15H11V12H12C13.104 12 14 11.103 14 10C14 8.896 13.104 8 12 8C10.896 8 10 8.896 10 10H8C8 7.795 9.795 6 12 6C14.205 6 16 7.795 16 10C16 11.861 14.723 13.429 13 13.875Z"></path>
</svg>
);

View file

@ -0,0 +1,17 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const component = (props: React.SVGProps<SVGSVGElement>): JSX.Element => (
<svg viewBox="6 3.7 16 16" width={24} height={24} {...(props as React.SVGProps<SVGSVGElement>)}>
<path
fill="currentColor"
d="M15.44,5H12.88v6.42L11.12,9.67,9.38,11.42V5H7a.58.58,0,0,0-.58.58V16.5a.56.56,0,0,0,.09.31l1.18,1.91a.55.55,0,0,0,.49.28H17a.58.58,0,0,0,.58-.58V8.48a.52.52,0,0,0-.07-.27L16,5.31A.6.6,0,0,0,15.44,5Zm.94,12.83H8.52l-.71-1.16h7.4a.58.58,0,0,0,.58-.59V7.41l.59,1.25Z"
/>
</svg>
);
export default component;
export const Popover = component as unknown as (props: unknown) => JSX.Element;

View file

@ -0,0 +1,31 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const OverFlowIcon = ({ width = 24, height = 24, color = "var(--interactive-normal)", className, ...rest }) => (
<svg
{...rest}
width={width}
height={height}
viewBox="0 0 16 16"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.45329 8.53891L3.26217 13.7844C2.95995 14.0719 2.49772 14.0719 2.21328 13.7844C1.92883 13.497 1.92883 13.0299 2.21328 12.7245L6.88884 7.99999L2.21328 3.27543C1.92883 2.988 1.92883 2.50297 2.21328 2.21555C2.49772 1.92812 2.95995 1.92812 3.26217 2.21555L8.45329 7.47903C8.73774 7.76645 8.73774 8.23352 8.45329 8.53891Z"
fill={color}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.4533 8.53891L9.26217 13.7844C8.95995 14.0719 8.49772 14.0719 8.21328 13.7844C7.92883 13.497 7.92883 13.0299 8.21328 12.7245L12.8888 7.99999L8.21328 3.27543C7.92883 2.988 7.92883 2.50297 8.21328 2.21555C8.49772 1.92812 8.95995 1.92812 9.26217 2.21555L14.4533 7.47903C14.7377 7.76645 14.7377 8.23352 14.4533 8.53891Z"
fill={color}
/>
</svg>
);
export default OverFlowIcon;
export const SvgOverFlowIcon = OverFlowIcon as unknown as (props: React.SVGProps<SVGSVGElement>) => JSX.Element;

View file

@ -0,0 +1,51 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findByProps } from "@webpack";
export default ({ error }: { error?: Error; } = {}) => {
const classes = findByProps("emptyResultsWrap");
if (error) {
// Error
console.log(error);
return (
<div className={classes.emptyResultsWrap}>
<div className={classes.emptyResultsContent} style={{ paddingBottom: "0px" }}>
<div className={classes.errorImage} />
<div className={classes.emptyResultsText}>
There was an error parsing your notes! The issue was logged in your console, press CTRL
+ I to access it! Please visit the support server if you need extra help!
</div>
</div>
</div>
);
} else if (Math.floor(Math.random() * 100) <= 10)
// Easter egg
return (
<div className={classes.emptyResultsWrap}>
<div className={classes.emptyResultsContent} style={{ paddingBottom: "0px" }}>
<div className={`${classes.noResultsImage} ${classes.alt}`} />
<div className={classes.emptyResultsText}>
No notes were found. Empathy banana is here for you.
</div>
</div>
</div>
);
// Empty notebook
else
return (
<div className={classes.emptyResultsWrap}>
<div className={classes.emptyResultsContent} style={{ paddingBottom: "0px" }}>
<div className={classes.noResultsImage} />
<div className={classes.emptyResultsText}>
No notes were found saved in this notebook.
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,89 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { findByProps } from "@webpack";
import { Button, Forms, Text } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import { downloadNotes, uploadNotes } from "../../utils";
export default ({ onClose, ...modalProps }: ModalProps & { onClose: () => void; }) => {
const { colorStatusGreen } = findByProps("colorStatusGreen");
return (
<ModalRoot {...modalProps} className="vc-help-modal" size={ModalSize.MEDIUM}>
<ModalHeader className="notebook-header">
<Text tag="h3">Help Modal</Text>
<ModalCloseButton onClick={onClose} />
</ModalHeader>
<ModalContent>
<div className="vc-help-markdown">
<Text>Adding Notes</Text>
<Forms.FormText>
To add a note right click on a message then hover over the "Note Message" item and click
<br />
the button with the notebook name you would like to note the message to.
<br />
<span style={{ fontWeight: "bold" }} className={colorStatusGreen}>
Protip:
</span>{" "}
Clicking the "Note Message" button by itself will note to Main by default!
</Forms.FormText>
<hr />
<Text>Deleting Notes</Text>
<Forms.FormText>
Note you can either right click the note and hit "Delete Note" or you can hold the
'DELETE' key on your keyboard and click on a note; it's like magic!
</Forms.FormText>
<hr />
<Text>Moving Notes</Text>
<Forms.FormText>
To move a note right click on a note and hover over the "Move Note" item and click on
the button corresponding to the notebook you would like to move the note to.
</Forms.FormText>
<hr />
<Text>Jump To Message</Text>
<Forms.FormText>
To jump to the location that the note was originally located at just right click on the
note and hit "Jump to Message".
</Forms.FormText>
</div>
</ModalContent>
<ModalFooter>
<div className="vc-notebook-display-left">
<Button
look={Button.Looks.FILLED}
color={Button.Colors.GREEN}
style={{ marginRight: "10px" }}
onClick={() => {
noteHandler.refreshAvatars();
}}>Refresh Avatars</Button>
<Button
look={Button.Looks.FILLED}
color={Button.Colors.GREEN}
style={{ marginRight: "10px" }}
onClick={() => {
uploadNotes();
}}>Import Notes</Button>
<Button
look={Button.Looks.FILLED}
color={Button.Colors.GREEN}
style={{ marginRight: "70px" }}
onClick={() => {
downloadNotes();
}}>Export Notes</Button>
<Button
look={Button.Looks.FILLED}
color={Button.Colors.RED}
onClick={() => {
noteHandler.deleteEverything();
}}>Delete All Notes</Button>
</div>
</ModalFooter>
</ModalRoot>
);
};

View file

@ -0,0 +1,28 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { openModal } from "@utils/modal";
import { Button, React } from "@webpack/common";
import NotebookCreateModal from "./NotebookCreateModal";
import NotebookDeleteModal from "./NotebookDeleteModal";
export default ({ notebook, setNotebook }: { notebook: string, setNotebook: React.Dispatch<React.SetStateAction<string>>; }) => {
const isNotMain = notebook !== "Main";
return (
<Button
color={isNotMain ? Button.Colors.RED : Button.Colors.GREEN}
onClick={
isNotMain
? () => openModal(props => <NotebookDeleteModal {...props} notebook={notebook} onChangeTab={setNotebook} />)
: () => openModal(props => <NotebookCreateModal {...props} />)
}
>
{isNotMain ? "Delete Notebook" : "Create Notebook"}
</Button>
);
};

View file

@ -0,0 +1,185 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classes } from "@utils/misc";
import { findByProps } from "@webpack";
import { Button, Clickable, Menu, Popout, React } from "@webpack/common";
import { SvgOverFlowIcon } from "../icons/overFlowIcon";
export function NoteBookTabs({ tabs, selectedTabId, onSelectTab }: { tabs: string[], selectedTabId: string, onSelectTab: (tab: string) => void }) {
const tabBarRef = React.useRef<HTMLDivElement>(null);
const widthRef = React.useRef<number>(0);
const tabWidthMapRef = React.useRef(new Map());
const [overflowedTabs, setOverflowedTabs] = React.useState<string[]>([]);
const resizeObserverRef = React.useRef<ResizeObserver | null>(null);
const [show, setShow] = React.useState(false);
const { isNotNullish } = findByProps("isNotNullish");
const { overflowIcon } = findByProps("overflowIcon");
const handleResize = React.useCallback(() => {
if (!tabBarRef.current) return;
const overflowed = [] as string[];
const totalWidth = tabBarRef.current.clientWidth;
if (totalWidth !== widthRef.current) {
// Thanks to daveyy1 for the help with this
let width = 0;
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
const tabRef = tabWidthMapRef.current.get(tab);
if (!tabRef) continue;
width += tabRef.width;
if (width > totalWidth) {
overflowed.push(tab);
}
}
setOverflowedTabs(overflowed);
}
}, [tabs, selectedTabId]);
React.useEffect(() => {
handleResize();
resizeObserverRef.current = new ResizeObserver(handleResize);
if (tabBarRef.current) resizeObserverRef.current.observe(tabBarRef.current);
return () => {
if (resizeObserverRef.current) resizeObserverRef.current.disconnect();
};
}, [handleResize]);
const TabItem = React.forwardRef(function ({ id, selected, onClick, children }: { id: string, selected: boolean, onClick: () => void, children: React.ReactNode }, ref) {
return (
<Clickable
className={classes("vc-notebook-tabbar-item", selected ? "vc-notebook-selected" : "")}
data-tab-id={id}
// @ts-expect-error
innerRef={ref}
onClick={onClick}
>
{children}
</Clickable>
);
});
const renderOverflowMenu = React.useCallback((closePopout: () => void) => {
return (
<Menu.Menu
navId="notebook-tabs"
aria-label="Notebook Tabs"
variant="fixed"
onClose={closePopout}
onSelect={closePopout}
>
{tabs.map(tab => {
return overflowedTabs.includes(tab) && selectedTabId !== tab ? (
<Menu.MenuItem
id={tab}
label={tab}
action={() => onSelectTab(tab)}
/>
) : null;
}).filter(isNotNullish)}
</Menu.Menu>
);
}, [tabs, selectedTabId, onSelectTab, overflowedTabs]);
return (
<div
className={classes("vc-notebook-tabbar")}
ref={tabBarRef}
>
{tabs.map(tab => {
if (!overflowedTabs.includes(tab)) {
return (
<TabItem
id={tab}
selected={selectedTabId === tab}
ref={(el: HTMLElement | null) => {
const width = tabWidthMapRef.current.get(tab)?.width ?? 0;
tabWidthMapRef.current.set(tab, {
node: el,
width: el ? el.getBoundingClientRect().width : width
});
}}
onClick={selectedTabId !== tab ? () => onSelectTab(tab) : () => {}}
>
{tab}
</TabItem>
);
}
return null;
}).filter(isNotNullish)}
{overflowedTabs.length > 0 && (
<Popout
shouldShow={show}
onRequestClose={() => setShow(false)}
renderPopout={() => renderOverflowMenu(() => setShow(false))}
position="bottom"
align="right"
spacing={0}
>
{props => (
<Button
{...props}
className={"vc-notebook-overflow-chevron"}
size={Button.Sizes.ICON}
look={Button.Looks.BLANK}
onClick={() => setShow(v => !v)}
>
<SvgOverFlowIcon className={classes(overflowIcon)} width={16} height={16}/>
</Button>
)}
</Popout>
)}
</div >
);
}
export function CreateTabBar({ tabs, firstSelectedTab, onChangeTab }) {
const tabKeys = Object.keys(tabs);
const mainTabIndex = tabKeys.indexOf("Main");
if (mainTabIndex !== -1 && mainTabIndex !== 0) {
tabKeys.splice(mainTabIndex, 1);
tabKeys.unshift("Main");
}
const [selectedTab, setSelectedTab] = React.useState(
firstSelectedTab || (tabKeys.length > 0 ? tabKeys[0] : null)
);
const renderSelectedTab = React.useCallback(() => {
const selectedTabId = tabKeys.find(tab => tab === selectedTab);
return selectedTabId;
}, [tabs, selectedTab]);
return {
TabBar: <NoteBookTabs
tabs={tabKeys}
selectedTabId={selectedTab}
onSelectTab={tab => {
setSelectedTab(tab);
if (onChangeTab) onChangeTab(tab);
}} />,
renderSelectedTab,
selectedTab
};
}

View file

@ -0,0 +1,174 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { classes } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { findByProps } from "@webpack";
import { ContextMenuApi, Flex, FluxDispatcher, Menu, React, Text, TextInput } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import { HolyNotes } from "../../types";
import HelpIcon from "../icons/HelpIcon";
import Errors from "./Error";
import HelpModal from "./HelpModal";
import ManageNotebookButton from "./ManageNotebookButton";
import { CreateTabBar } from "./NoteBookTab";
import { RenderMessage } from "./RenderMessage";
const renderNotebook = ({
notes, notebook, updateParent, sortDirection, sortType, searchInput, closeModal
}: {
notes: Record<string, HolyNotes.Note>;
notebook: string;
updateParent: () => void;
sortDirection: boolean;
sortType: boolean;
searchInput: string;
closeModal: () => void;
}) => {
const messageArray = Object.values(notes).map(note => (
<RenderMessage
note={note}
notebook={notebook}
updateParent={updateParent}
fromDeleteModal={false}
closeModal={closeModal}
/>
));
if (sortType)
messageArray.sort(
(a, b) =>
new Date(b.props.note?.timestamp)?.getTime() - new Date(a.props.note?.timestamp)?.getTime(),
);
if (sortDirection) messageArray.reverse();
const filteredMessages = messageArray.filter(message =>
message.props.note?.content?.toLowerCase().includes(searchInput.toLowerCase()),
);
return filteredMessages.length > 0 ? filteredMessages : <Errors />;
};
export const NoteModal = (props: ModalProps & { onClose: () => void; }) => {
const [sortType, setSortType] = React.useState(true);
const [searchInput, setSearch] = React.useState("");
const [sortDirection, setSortDirection] = React.useState(true);
const [currentNotebook, setCurrentNotebook] = React.useState("Main");
const { quickSelect, quickSelectLabel, quickSelectQuick, quickSelectValue, quickSelectArrow } = findByProps("quickSelect");
const forceUpdate = React.useReducer(() => ({}), {})[1] as () => void;
const notes = noteHandler.getNotes(currentNotebook);
if (!notes) return <></>;
const { TabBar, selectedTab } = CreateTabBar({ tabs: noteHandler.getAllNotes(), firstSelectedTab: currentNotebook, onChangeTab: setCurrentNotebook });
return (
<ErrorBoundary>
<ModalRoot {...props} className={classes("vc-notebook")} size={ModalSize.LARGE}>
<Flex className={classes("vc-notebook-flex")} direction={Flex.Direction.VERTICAL} style={{ width: "100%" }}>
<div className={classes("vc-notebook-top-section")}>
<ModalHeader className={classes("vc-notebook-header-main")}>
<Text
variant="heading-lg/semibold"
style={{ flexGrow: 1 }}
className={classes("vc-notebook-heading")}>
NOTEBOOK
</Text>
<div className={classes("vc-notebook-flex", "vc-help-icon")} onClick={() => openModal(HelpModal)}>
<HelpIcon />
</div>
<div style={{ marginBottom: "10px" }} className={classes("vc-notebook-search")}>
<TextInput
autoFocus={false}
placeholder="Search for a message..."
onChange={e => setSearch(e)}
/>
</div>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<div className={classes("vc-notebook-tabbar-container")}>
{TabBar}
</div>
</div>
<ModalContent style={{ marginTop: "20px" }}>
<ErrorBoundary>
{renderNotebook({
notes,
notebook: currentNotebook,
updateParent: () => forceUpdate(),
sortDirection: sortDirection,
sortType: sortType,
searchInput: searchInput,
closeModal: props.onClose,
})}
</ErrorBoundary>
</ModalContent>
</Flex>
<ModalFooter>
<ManageNotebookButton notebook={currentNotebook} setNotebook={setCurrentNotebook} />
<div className={classes("sort-button-container", "vc-notebook-display-left")}>
<Flex
align={Flex.Align.CENTER}
className={quickSelect}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
ContextMenuApi.openContextMenu(event, () => (
<Menu.Menu
navId="sort-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Sort Menu"
>
<Menu.MenuItem
label="Ascending / Date Added"
id="ada"
action={() => {
setSortDirection(true);
setSortType(true);
}} /><Menu.MenuItem
label="Ascending / Message Date"
id="amd"
action={() => {
setSortDirection(true);
setSortType(false);
}} /><Menu.MenuItem
label="Descending / Date Added"
id="dda"
action={() => {
setSortDirection(false);
setSortType(true);
}} /><Menu.MenuItem
label="Descending / Message Date"
id="dmd"
action={() => {
setSortDirection(false);
setSortType(false);
}} />
</Menu.Menu>
));
}}
>
<Text className={quickSelectLabel}>Change Sorting:</Text>
<Flex grow={0} align={Flex.Align.CENTER} className={quickSelectQuick}>
<Text className={quickSelectValue}>
{sortDirection ? "Ascending" : "Descending"} /{" "}
{sortType ? "Date Added" : "Message Date"}
</Text>
<div className={quickSelectArrow} />
</Flex>
</Flex>
</div>
</ModalFooter>
</ModalRoot>
</ErrorBoundary>
);
};

View file

@ -0,0 +1,40 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { Button, React, Text, TextInput } from "@webpack/common";
import noteHandler from "../../NoteHandler";
export default (props: ModalProps & { onClose: () => void; }) => {
const [notebookName, setNotebookName] = React.useState("");
const handleCreateNotebook = React.useCallback(() => {
if (notebookName !== "") noteHandler.newNoteBook(notebookName);
props.onClose();
}, [notebookName]);
return (
<div>
<ModalRoot className="vc-create-notebook" size={ModalSize.SMALL} {...props}>
<ModalHeader className="vc-notebook-header">
<Text tag="h3">Create Notebook</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<TextInput
value={notebookName}
placeholder="Notebook Name"
onChange={value => setNotebookName(value)}
style={{ marginBottom: "10px" }} />
</ModalContent>
<ModalFooter>
<Button onClick={handleCreateNotebook} color={Button.Colors.GREEN}>Create Notebook</Button>
</ModalFooter>
</ModalRoot>
</div>
);
};

View file

@ -0,0 +1,57 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { Button, React, Text } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import Error from "./Error";
import { RenderMessage } from "./RenderMessage";
export default ({ onClose, notebook, onChangeTab, ...props }: ModalProps & { onClose: () => void; notebook: string; onChangeTab: React.Dispatch<React.SetStateAction<string>>; }) => {
const notes = noteHandler.getNotes(notebook);
const handleDelete = () => {
onClose();
onChangeTab("Main");
noteHandler.deleteNotebook(notebook);
};
return (
<ModalRoot
{...props}
className="vc-delete-notebook"
size={ModalSize.LARGE}>
<ModalHeader>
<Text tag="h3">Confirm Deletion</Text>
<ModalCloseButton onClick={onClose} />
</ModalHeader>
<ModalContent>
<ErrorBoundary>
{notes && Object.keys(notes).length > 0 ? (
Object.values(notes).map(note => (
<RenderMessage
note={note}
notebook={notebook}
fromDeleteModal={true} />
))
) : (
<Error />
)}
</ErrorBoundary>
</ModalContent>
<ModalFooter>
<Button
color={Button.Colors.RED}
onClick={handleDelete}
>
DELETE
</Button>
</ModalFooter>
</ModalRoot>
);
};

View file

@ -0,0 +1,188 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classes } from "@utils/misc";
import { ModalProps } from "@utils/modal";
import { findByCode, findByProps } from "@webpack";
import { Clipboard, ContextMenuApi, FluxDispatcher, Menu, NavigationRouter, React } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import { HolyNotes } from "../../types";
export const RenderMessage = ({
note,
notebook,
updateParent,
fromDeleteModal,
closeModal,
}: {
note: HolyNotes.Note;
notebook: string;
updateParent?: () => void;
fromDeleteModal: boolean;
closeModal?: () => void;
}) => {
const ChannelMessage = findByProps("ThreadStarterChatMessage").default;
const { message, groupStart, cozyMessage } = findByProps("cozyMessage");
const User = findByCode("isClyde(){");
const Message = findByCode("isEdited(){");
const Channel = findByProps("ChannelRecordBase").ChannelRecordBase;
const [isHoldingDelete, setHoldingDelete] = React.useState(false);
React.useEffect(() => {
const deleteHandler = (e: { key: string; type: string; }) =>
e.key.toLowerCase() === "delete" && setHoldingDelete(e.type.toLowerCase() === "keydown");
document.addEventListener("keydown", deleteHandler);
document.addEventListener("keyup", deleteHandler);
return () => {
document.removeEventListener("keydown", deleteHandler);
document.removeEventListener("keyup", deleteHandler);
};
}, []);
return (
<div
className="vc-holy-note"
style={{
marginBottom: "8px",
marginTop: "8px",
paddingTop: "4px",
paddingBottom: "4px",
}}
onClick={() => {
if (isHoldingDelete && !fromDeleteModal) {
noteHandler.deleteNote(note.id, notebook);
updateParent?.();
}
}}
onContextMenu={(event: any) => {
if (!fromDeleteModal)
// @ts-ignore
return ContextMenuApi.openContextMenu(event, (props: any) => (
// @ts-ignore
<NoteContextMenu
{...Object.assign({}, props, { onClose: close })}
note={note}
notebook={notebook}
updateParent={updateParent}
closeModal={closeModal}
/>
));
}}
>
<ChannelMessage
className={classes("vc-holy-render", message, groupStart, cozyMessage)}
key={note.id}
groupId={note.id}
id={note.id}
compact={false}
isHighlight={false}
isLastItem={false}
renderContentOnly={false}
// @ts-ignore
channel={new Channel({ id: "holy-notes" })}
message={
new Message(
Object.assign(
{ ...note },
{
author: new User({ ...note?.author }),
timestamp: new Date(note?.timestamp),
// @ts-ignore
embeds: note?.embeds?.map((embed: { timestamp: string | number | Date; }) =>
embed.timestamp
? Object.assign(embed, {
timestamp: new Date(embed.timestamp),
})
: embed,
),
},
),
)
}
/>
</div>
);
};
const NoteContextMenu = (
props: ModalProps & {
updateParent?: () => void;
notebook: string;
note: HolyNotes.Note;
closeModal?: () => void;
}) => {
const { note, notebook, updateParent, closeModal } = props;
return (
<Menu.Menu
navId="holynotes"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Holy Notes"
>
<Menu.MenuItem
label="Jump To Message"
id="jump"
action={() => {
NavigationRouter.transitionTo(`/channels/${note.guild_id ?? "@me"}/${note.channel_id}/${note.id}`);
closeModal?.();
}}
/>
<Menu.MenuItem
label="Copy Text"
id="copy-text"
action={() => Clipboard.copy(note.content)}
/>
{note?.attachments.length ? (
<Menu.MenuItem
label="Copy Attachment URL"
id="copy-url"
action={() => Clipboard.copy(note.attachments[0].url)}
/>) : null}
<Menu.MenuItem
color="danger"
label="Delete Note"
id="delete"
action={() => {
noteHandler.deleteNote(note.id, notebook);
updateParent?.();
}}
/>
{Object.keys(noteHandler.getAllNotes()).length !== 1 ? (
<Menu.MenuItem
label="Move Note"
id="move-note"
>
{Object.keys(noteHandler.getAllNotes()).map((key: string) => {
if (key !== notebook) {
return (
<Menu.MenuItem
label={`Move to ${key}`}
id={key}
action={() => {
noteHandler.moveNote(note, notebook, key);
updateParent?.();
}}
/>
);
}
})}
</Menu.MenuItem>
) : null}
<Menu.MenuItem
label="Copy ID"
id="copy-id"
action={() => Clipboard.copy(note.id)}
/>
</Menu.Menu>
);
};

View file

@ -0,0 +1,132 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2024 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 "./style.css";
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { DataStore } from "@api/index";
import { addButton, removeButton } from "@api/MessagePopover";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { findByProps, findExportedComponentLazy } from "@webpack";
import { ChannelStore, Menu } from "@webpack/common";
import { Message } from "discord-types/general";
import { Popover as NoteButtonPopover, Popover } from "./components/icons/NoteButton";
import { NoteModal } from "./components/modals/Notebook";
import noteHandler, { noteHandlerCache } from "./NoteHandler";
import { DataStoreToCache, HolyNoteStore } from "./utils";
const HeaderBarIcon = findExportedComponentLazy("Icon", "Divider");
const messageContextMenuPatch: NavContextMenuPatchCallback = async (children, { message }: { message: Message; }) => {
children.push(
<Menu.MenuItem label="Add Message To" id="add-message-to-note">
{Object.keys(noteHandler.getAllNotes()).map((notebook: string, index: number) => (
<Menu.MenuItem
label={notebook}
id={notebook}
action={() => noteHandler.addNote(message, notebook)}
/>
))}
</Menu.MenuItem>
);
};
function ToolBarHeader() {
const iconClasses = findByProps("iconWrapper", "clickable");
return (
<ErrorBoundary noop={true}>
<HeaderBarIcon
tooltip="Holy Notes"
position="bottom"
className={classes("vc-note-button", iconClasses.iconWrapper, iconClasses.clickable)}
icon={e => Popover(e)}
onClick={() => openModal(props => <NoteModal {...props} />)}
/>
</ErrorBoundary>
);
}
export default definePlugin({
name: "HolyNotes",
description: "Holy Notes allows you to save messages",
authors: [Devs.Wolfie],
dependencies: ["MessagePopoverAPI", "ChatInputButtonAPI"],
patches: [
{
find: "toolbar:function",
replacement: {
match: /(function \i\(\i\){)(.{1,200}toolbar.{1,100}mobileToolbar)/,
replace: "$1$self.toolbarAction(arguments[0]);$2"
}
}
],
toolboxActions: {
async "Open Notes"() {
openModal(props => <NoteModal {...props} />);
}
},
contextMenus: {
"message": messageContextMenuPatch
},
toolbarAction(e) {
if (Array.isArray(e.toolbar))
return e.toolbar.push(
<ErrorBoundary noop={true}>
<ToolBarHeader />
</ErrorBoundary>
);
e.toolbar = [
<ErrorBoundary noop={true}>
<ToolBarHeader />
</ErrorBoundary>,
e.toolbar,
];
},
async start() {
if (await DataStore.keys(HolyNoteStore).then(keys => !keys.includes("Main"))) return noteHandler.newNoteBook("Main");
if (!noteHandlerCache.has("Main")) await DataStoreToCache();
addButton("HolyNotes", message => {
return {
label: "Save Note",
icon: NoteButtonPopover,
message: message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: () => noteHandler.addNote(message, "Main")
};
});
},
async stop() {
removeButton("HolyNotes");
}
});

View file

@ -0,0 +1,136 @@
/* stylelint-disable shorthand-property-no-redundant-values */
/* stylelint-disable length-zero-no-unit */
/*
$modifier: var(--background-modifier-accent);
*/
.vc-notebook-search {
flex: auto;
margin-right: 15px;
margin-bottom: 6px;
background-color: var(--background-tertiary);
border: solid 2px var(--background-secondary);
}
.vc-notebook-header {
margin-bottom: 10px;
padding: 16px 16px 10px 16px;
}
.vc-notebook-header-main {
padding-top: 15px;
padding-left: 10px;
padding-bottom: 0px;
background-color: var(--background-tertiary);
box-shadow: 0 1px 0 0 var(--background-tertiary), 0 1px 2px 0 var(--background-tertiary) !important;
}
.vc-notebook-heading {
max-width: 110px;
margin-right: 0px;
padding-bottom: 6px;
transform: scale(1);
}
.vc-notebook-flex {
overflow: hidden;
}
.vc-notebook-flex .vc-help-icon {
width: 23px;
opacity: 0.5;
cursor: pointer;
padding-left: 0px;
margin: 0px 14px 6px 0px;
color: var(--interactive-normal);
transition: opacity 0.2s ease-in-out;
}
.vc-notebook-flex .help-icon:hover {
opacity: 1;
}
.vc-notebook-display-left {
flex: auto;
display: flex;
}
.vc-help-markdown {
margin-top: 6px;
}
.vc-help-markdown hr {
border: 0;
height: 2px;
margin: 12px 0px 16px 0px;
background-color: var(--background-modifier-accent);
}
.vc-holy-note [class*="buttonContainer"] {
display: none;
}
.vc-holy-note [class*="messageListItem"] {
list-style-type: none;
}
.vc-notebook-tabbar {
border-bottom: 1px solid var(--background-tertiary);
background-color: var(--background-tertiary);
white-space: nowrap;
overflow: hidden;
height: 36px;
flex-shrink: 0;
}
/* .vc-notebook-tabbar-bar {
margin: 0;
padding: 10px 20px 0;
} */
.vc-notebook-overflow-chevron {
display: inline-block;
padding: 0 6px 2px;
height: 16px;
position: relative;
top: -10px;
}
.vc-notebook-tabbar-container {
border-bottom: 1px solid var(--profile-body-divider-color);
margin: 20px 12px 0;
padding: 0;
}
.vc-notebook-tabbar-item {
display: inline-block;
padding: 6px 12px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 22px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
color: var(--interactive-normal);
border-bottom-width: 2px;
border-bottom-style: solid;
/* stylelint-disable-next-line declaration-block-no-redundant-longhand-properties */
border-bottom-color: transparent;
}
.vc-notebook-selected {
border-bottom-color: var(--control-brand-foreground);
}
.vc-notebook-tabbar-item:hover {
border-bottom-color: var(--brand-experiment);
}
.vc-notebook-top-section {
margin-bottom: calc(-8px + .15*(var(--custom-user-profile-modal-header-avatar-size) + var(--custom-user-profile-modal-header-total-avatar-border-size)));
z-index: 1;
}

View file

@ -0,0 +1,47 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Embed, MessageAttachment, MessageReaction } from "discord-types/general";
export declare namespace Discord {
export interface Sticker {
format_type: number;
id: string;
name: string;
}
export interface Attachment extends MessageAttachment {
sensitive: boolean;
}
export interface Reaction extends MessageReaction {
burst_colors: string[];
borst_count: number;
count_details: { burst: number; normal: number; };
me_burst: boolean;
}
}
export declare namespace HolyNotes {
export interface Note {
id: string;
channel_id: string;
guild_id: string;
content: string;
author: {
id: string;
avatar: string;
discriminator: string;
username: string;
};
flags: number;
timestamp: string;
attachments: Discord.Attachment[];
embeds: Embed[];
reactions: Discord.Reaction[];
stickerItems: Discord.Sticker[];
}
}

View file

@ -0,0 +1,106 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { createStore } from "@api/DataStore";
import { DataStore } from "@api/index";
import { Toasts } from "@webpack/common";
import noteHandler, { noteHandlerCache } from "./NoteHandler";
import { HolyNotes } from "./types";
export const HolyNoteStore = createStore("HolyNoteData", "HolyNoteStore");
export async function saveCacheToDataStore(key: string, value?: HolyNotes.Note[]) {
await DataStore.set(key, value, HolyNoteStore);
}
export async function deleteCacheFromDataStore(key: string) {
await DataStore.del(key, HolyNoteStore);
}
export async function getFormatedEntries() {
const data = await DataStore.entries(HolyNoteStore);
const notebooks: Record<string, HolyNotes.Note> = {};
data.forEach(function (note) {
notebooks[note[0].toString()] = note[1];
});
return notebooks;
}
export async function DataStoreToCache() {
const data = await DataStore.entries(HolyNoteStore);
data.forEach(function (note) {
noteHandlerCache.set(note[0].toString(), note[1]);
});
}
export async function DeleteEntireStore() {
await DataStore.clear(HolyNoteStore);
return noteHandler.newNoteBook("Main", true);
}
export async function downloadNotes() {
const filename = "notes.json";
const exportData = await noteHandler.exportNotes();
const data = JSON.stringify(exportData, null, 2);
if (IS_VESKTOP || IS_WEB) {
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 uploadNotes() {
if (IS_VESKTOP || IS_WEB) {
const input = document.createElement("input");
input.type = "file";
input.style.display = "none";
input.accept = "application/json";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
await noteHandler.importNotes(reader.result as unknown as HolyNotes.Note[]);
} catch (err) {
console.error(err);
Toasts.show({
id: Toasts.genId(),
message: "Invalid JSON.",
type: Toasts.Type.FAILURE,
});
}
};
reader.readAsText(file);
};
document.body.appendChild(input);
input.click();
setImmediate(() => document.body.removeChild(input));
} else {
const [file] = await DiscordNative.fileManager.openFiles({ filters: [{ name: "notes", extensions: ["json"] }] });
if (file) {
await noteHandler.importNotes(new TextDecoder().decode(file.data) as unknown as HolyNotes.Note[]);
}
}
}