Plugin

IRememberYou

Locally saves everyone you've been communicating with (including servers), in case of lose

index.tsx
Download

Source

src/plugins/iRememberYou/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { DataStore } from "@api/index";
8import { addPreSendListener, removePreSendListener } from "@api/MessageEvents";
9import ExpandableHeader from "@components/ExpandableHeader";
10import { Flex } from "@components/Flex";
11import { Heart } from "@components/Heart";
12import { Devs } from "@utils/constants";
13import { openUserProfile } from "@utils/discord";
14import * as Modal from "@utils/modal";
15import definePlugin from "@utils/types";
16import {
17 Avatar, Button, ChannelStore,
18 Clickable, GuildMemberStore,
19 GuildStore,
20 MessageStore,
21 React,
22 Text, TextArea, TextInput, Tooltip,
23 UserStore
24} from "@webpack/common";
25import { Guild, User } from "discord-types/general";
26
27interface IUserExtra {
28 isOwner?: boolean;
29 updatedAt?: number;
30}
31
32interface IStorageUser {
33 id: string;
34 username: string,
35 tag: string,
36 iconURL?: string;
37 extra?: IUserExtra;
38}
39
40interface GroupData {
41 id: string;
42 users: { [key: string]: IStorageUser; };
43 name: string;
44}
45
46const constants = {
47 pluginLabel: "IRememberYou",
48 pluginId: "irememberyou",
49
50 DM: "dm",
51 DataUIDescription:
52 "Provides a list of users you have mentioned or replied to, or those who own the servers you belong to (owner*), or are members of your guild",
53 marks: {
54 Owner: "owner"
55 }
56};
57
58
59class Data {
60 declare usersCollection: Record<string, GroupData>;
61 declare _storageAutoSaveProtocol_interval;
62 declare _onMessagePreSend_preSend;
63
64 withStart() {
65 return this;
66 }
67
68 onMessagePreSend(channelId, message, extra) {
69 const target: Set<{ user: User; source?: Guild, extra: IUserExtra; }> = new Set();
70 const now = Date.now();
71 const { replyOptions } = extra;
72
73 const guild = (() => {
74 const channel = ChannelStore.getChannel(channelId);
75 return GuildStore.getGuild(channel.guild_id) || undefined;
76 })();
77
78 if (replyOptions.messageReference) {
79 const { channel_id, message_id } = replyOptions.messageReference;
80 const message = MessageStore.getMessage(channel_id, message_id);
81 if (!message) {
82 return;
83 }
84 const { author } = message;
85
86 target.add({ user: author, source: guild, extra: { updatedAt: now } });
87 }
88
89 if (message.content) {
90 const { content } = message;
91 const ids = [...content.matchAll(/<@!?(?<id>\d{17,23})>/g)].map(
92 ({ groups }) => groups.id
93 );
94
95 const users = ids
96 .map(id => UserStore.getUser(id))
97 .filter(Boolean);
98 for (const user of users) {
99 target.add({ user, source: guild, extra: { updatedAt: now } });
100 }
101 }
102
103 this.processUsersToCollection([...target]);
104 }
105
106 async processUsersToCollection(
107 array: { user: User; source?: Guild; extra?: IUserExtra; }[]
108 ) {
109 const target = this.usersCollection;
110 for (const { user, source, extra } of array) {
111 if (user.bot) {
112 continue;
113 }
114
115 const groupKey = source?.id ?? constants.DM;
116 const group = (target[groupKey] ||= {
117 name: source?.name || constants.DM,
118 id: source?.id || user.id,
119 users: {}
120 });
121 const usersField = group.users;
122 const previouExtra = usersField[user.id]?.extra ?? {};
123 const { id, username } = user;
124
125 usersField[id] = {
126 id,
127 username,
128 tag: user.discriminator === "0" ? user.username : user.tag,
129 extra: { ...previouExtra, ...extra },
130 iconURL: user.getAvatarURL(),
131 };
132 }
133 }
134
135 async updateStorage() {
136 await DataStore.set("irememberyou.data", this.usersCollection);
137 }
138
139 async initializeUsersCollection() {
140 const data = await DataStore.get("irememberyou.data");
141 this.usersCollection = data ?? {};
142 }
143
144 writeMembersFromUserGuildsToCollection() {
145 const target: Set<{ user: User; source?: Guild, extra: IUserExtra; }> =
146 new Set();
147
148 const now = Date.now();
149 const LIMIT = 1_000;
150
151 const clientId = UserStore.getCurrentUser().id;
152 if (!clientId) {
153 return;
154 }
155 for (const guild of Object.values(GuildStore.getGuilds())) {
156 const { ownerId } = guild;
157 if (ownerId !== clientId) {
158 continue;
159 }
160
161 const members = GuildMemberStore.getMembers(guild.id);
162 if (members.length > LIMIT) {
163 members.length = LIMIT;
164 }
165 for (const member of members) {
166 const user = UserStore.getUser(member.userId);
167 target.add({ user, source: guild, extra: { updatedAt: now } });
168 }
169
170 this.processUsersToCollection([...target]);
171 }
172 }
173
174 writeGuildsOwnersToCollection() {
175 const target: Set<{ user: User; source?: Guild; extra: IUserExtra; }> =
176 new Set();
177 const now = Date.now();
178
179 for (const guild of Object.values(GuildStore.getGuilds())) {
180 const { ownerId } = guild;
181 const owner = UserStore.getUser(ownerId);
182 if (!owner) {
183 continue;
184 }
185 target.add({
186 user: owner,
187 source: guild,
188 extra: { isOwner: true, updatedAt: now },
189 });
190 }
191
192 this.processUsersToCollection([...target]);
193 }
194
195 storageAutoSaveProtocol() {
196 this._storageAutoSaveProtocol_interval = setInterval(
197 this.updateStorage.bind(this),
198 60_000 * 3
199 );
200 }
201}
202
203class DataUI {
204 declare plugin;
205
206 constructor(plugin) {
207 this.plugin = plugin;
208 }
209 start() {
210 return this;
211 }
212
213 renderSectionDescription() {
214 return <Text>{constants.DataUIDescription}</Text>;
215 }
216
217 renderUsersCollectionAsRows(usersCollection: Data["usersCollection"]) {
218 if (Object.keys(usersCollection).length === 0) {
219 return <Text>It&#039;s empty right now</Text>;
220 }
221 const elements = Object.entries(usersCollection)
222 .map(([_key, { users, name }]) => ({ name, users: Object.values(users) }))
223 .sort((a, b) => b.users.length - a.users.length)
224 .map(({ name, users }) =>
225 this.renderUsersCollectionRows(name, users)
226 );
227
228 return elements;
229 }
230
231 renderUsersCollectionRows(key: string, users: IStorageUser[]) {
232 const usersElements = users.map(user => this.renderUserRow(user));
233
234
235 return <aside key={key} >
236 <ExpandableHeader defaultState={true} headerText={key.toUpperCase()}>
237 <Flex style={{ gap: "calc(0.5em + 0.5vw) 0.2em", flexDirection: "column" }}>
238 {usersElements}
239 </Flex>
240 </ExpandableHeader>
241
242 </aside>;
243 }
244
245 renderUserAvatar(user: IStorageUser) {
246 return <Clickable onClick={() => openUserProfile(user.id)}>
247 <span style={{ cursor: "pointer" }} >
248 <Avatar src={user.iconURL} size="SIZE_24" />
249 </span>
250 </Clickable>;
251 }
252 userTooltipText(user: IStorageUser) {
253 const { updatedAt } = user.extra || {};
254 const updatedAtContent = updatedAt ? new Intl.DateTimeFormat().format(updatedAt) : null;
255 return `${user.username ?? user.tag}, updated at ${updatedAtContent}`;
256 }
257
258 renderUserRow(user: IStorageUser, allowExtra: { owner?: boolean; } = {}) {
259 allowExtra = Object.assign({ owner: true }, allowExtra);
260
261 return <Flex key={user.id} style={{ margin: 0, width: "100%", flexWrap: "wrap", alignItems: "center" }}>
262 <span style={{ width: "24em" }}>
263 <Flex style={{ gap: "0.5em", alignItems: "center", margin: 0, wordBreak: "break-word" }}>
264 {this.renderUserAvatar(user)}
265 <Tooltip text={this.userTooltipText(user)}>
266 {props =>
267 <Text {...props} selectable>{user.tag} {allowExtra.owner && user.extra?.isOwner && `(${constants.marks.Owner})`}</Text>
268 }
269 </Tooltip>
270 </Flex>
271 </span>
272
273 <span style={{ height: "min-content" }}><Text selectable variant="code" style={{ opacity: 0.75 }}>{user.id}</Text></span>
274 </Flex>;
275 }
276
277 renderButtonsFooter(usersCollection: Data["usersCollection"]) {
278 return <footer>
279 <Flex style={{ gap: "1.5em", marginTop: "2em" }}>
280
281 <Clickable onClick={() => Modal.openModal(props => <Modal.ModalRoot size={Modal.ModalSize.LARGE} fullscreenOnMobile={true} {...props}>
282 <Modal.ModalHeader separator={false}>
283 <Text
284 color="header-primary"
285 variant="heading-lg/semibold"
286 tag="h1"
287 style={{ flexGrow: 1 }}
288 >
289 Editor
290 </Text>
291 <Modal.ModalCloseButton onClick={props.onClose} />
292 </Modal.ModalHeader>
293 <Modal.ModalContent>
294 <Flex style={{ paddingBlock: "0.5em", gap: "0.75em" }}>
295 <Button label="Validate and save" >Validate and save</Button>
296 <Button label="Cancel" color={Button.Colors.TRANSPARENT}>Cancel</Button>
297 </Flex>
298 <TextArea value={JSON.stringify(usersCollection, null, "\t")} onChange={() => { }} rows={20} />
299 </Modal.ModalContent>
300 </Modal.ModalRoot>)}>
301 <Text variant="eyebrow" style={{ cursor: "pointer" }} >Open editor</Text>
302 </Clickable>
303
304 <Clickable onClick={
305 async () => {
306 const confirmed = confirm("Sure?");
307 if (!confirmed) {
308 return;
309 }
310
311 const { plugin } = this;
312 const data = plugin.dataManager as Data;
313 data.usersCollection = {};
314 await data.updateStorage();
315 }
316 }><Text style={{ cursor: "pointer" }}>Reset storage</Text>
317 </Clickable>
318 </Flex>
319 </footer >;
320 }
321
322 renderSearchElement(usersCollection: Data["usersCollection"]) {
323 const [current, setState] = React.useState<string>();
324 const map: Map<string, IStorageUser> = Object.values(usersCollection)
325 .reduce((acc, { users }) => (acc.push(...Object.values(users)), acc), [] as IStorageUser[])
326 .reduce((acc, current) => acc.set(current.id, current), new Map());
327
328 const list = [...map.values()];
329
330 return <section style={{ paddingBlock: "1em" }}>
331 <TextInput placeholder="Filter by tag, username" name="Filter" onChange={value => setState(value)} />
332 {current &&
333 <Flex style={{ flexDirection: "column", gap: "0.5em", paddingTop: "1em" }}>
334 {list.filter(user => user.tag.includes(current) || user.username.includes(current))
335 .map(user => this.renderUserRow(
336 user,
337 { owner: false }
338 ))
339 }
340 </Flex>
341 }
342 </section>;
343 }
344
345 toElement(usersCollection: Data["usersCollection"]) {
346 return (
347 /*
348 > ![Important]
349 > Let me know a more promising color, instead of #ffffff
350 */
351 <main style={{ color: "#ffffff", paddingBottom: "4em" }}>
352 <Text tag="h1" variant="heading-lg/bold">
353 {constants.pluginLabel}{" "}
354 <Heart />
355 </Text>
356
357
358 {this.renderSectionDescription()}
359 <br />
360 {this.renderSearchElement(usersCollection)}
361 <Flex style={{ gap: "1.5em", flexDirection: "column" }}>
362 {this.renderUsersCollectionAsRows(usersCollection)}
363 </Flex>
364 {this.renderButtonsFooter(usersCollection)}
365 </main>
366
367 );
368 }
369}
370
371export default definePlugin({
372 name: "IRememberYou",
373 description: "Locally saves everyone you&#039;ve been communicating with (including servers), in case of lose",
374 authors: [Devs.FiveCord],
375 dependencies: ["MessageEventsAPI"],
376 patches: [],
377
378 async start() {
379 const data = (this.dataManager = await new Data().withStart());
380 const ui = (this.uiManager = await new DataUI(this).start());
381
382 await data.initializeUsersCollection();
383 data.writeGuildsOwnersToCollection();
384 data.writeMembersFromUserGuildsToCollection();
385 data._onMessagePreSend_preSend = addPreSendListener(
386 data.onMessagePreSend.bind(data)
387 );
388 data.storageAutoSaveProtocol();
389
390 // @ts-ignore
391 FiveCord.Plugins.plugins.Settings.customSections.push(ID => ({
392 section: `${constants.pluginId}.display-data`,
393 label: constants.pluginLabel,
394 element: () => ui.toElement(data.usersCollection),
395 }));
396 },
397
398 stop() {
399 const dataManager = this.dataManager as Data;
400
401 removePreSendListener(dataManager._onMessagePreSend_preSend);
402 clearInterval(dataManager._storageAutoSaveProtocol_interval);
403 },
404});
405