Plugin
IRememberYou
Locally saves everyone you've been communicating with (including servers), in case of lose
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { DataStore } from "@api/index";8
import { addPreSendListener, removePreSendListener } from "@api/MessageEvents";9
import ExpandableHeader from "@components/ExpandableHeader";10
import { Flex } from "@components/Flex";11
import { Heart } from "@components/Heart";12
import { Devs } from "@utils/constants";13
import { openUserProfile } from "@utils/discord";14
import * as Modal from "@utils/modal";15
import definePlugin from "@utils/types";16
import {17
Avatar, Button, ChannelStore,18
Clickable, GuildMemberStore,19
GuildStore,20
MessageStore,21
React,22
Text, TextArea, TextInput, Tooltip,23
UserStore24
} from "@webpack/common";25
import { Guild, User } from "discord-types/general";26
27
interface IUserExtra {28
isOwner?: boolean;29
updatedAt?: number;30
}31
32
interface IStorageUser {33
id: string;34
username: string,35
tag: string,36
iconURL?: string;37
extra?: IUserExtra;38
}39
40
interface GroupData {41
id: string;42
users: { [key: string]: IStorageUser; };43
name: string;44
}45
46
const 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
59
class 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.id93
);94
95
const users = ids96
.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 * 3199
);200
}201
}202
203
class 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>It039;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
<Text284
color="header-primary"285
variant="heading-lg/semibold"286
tag="h1"287
style={{ flexGrow: 1 }}288
>289
Editor290
</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 #ffffff350
*/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
371
export default definePlugin({372
name: "IRememberYou",373
description: "Locally saves everyone you039;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-ignore391
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