Plugin
NotifyUserChanges
Adds a notify option in the user context menu to get notified when a user changes voice channels or online status
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { NavContextMenuPatchCallback } from "@api/ContextMenu";8
import { showNotification } from "@api/Notifications";9
import { definePluginSettings, Settings } from "@api/Settings";10
import { Devs } from "@utils/constants";11
import definePlugin, { OptionType } from "@utils/types";12
import { findByPropsLazy, findStoreLazy } from "@webpack";13
import { Menu, PresenceStore, React, SelectedChannelStore, Tooltip, UserStore } from "@webpack/common";14
import type { Channel, User } from "discord-types/general";15
import { CSSProperties } from "react";16
17
import { NotificationsOffIcon } from "./components/NotificationsOffIcon";18
import { NotificationsOnIcon } from "./components/NotificationsOnIcon";19
20
interface PresenceUpdate {21
user: {22
id: string;23
username?: string;24
global_name?: string;25
};26
clientStatus: {27
desktop?: string;28
web?: string;29
mobile?: string;30
console?: string;31
};32
guildId?: string;33
status: string;34
broadcast?: any; class="ts-cmt">// what039;s this?35
activities: Array<{36
session_id: string;37
created_at: number;38
id: string;39
name: string;40
details?: string;41
type: number;42
}>;43
}44
45
interface VoiceState {46
userId: string;47
channelId?: string;48
oldChannelId?: string;49
deaf: boolean;50
mute: boolean;51
selfDeaf: boolean;52
selfMute: boolean;53
selfStream: boolean;54
selfVideo: boolean;55
sessionId: string;56
suppress: boolean;57
requestToSpeakTimestamp: string | null;58
}59
60
function shouldBeNative() {61
if (typeof Notification === "undefined") return false;62
63
const { useNative } = Settings.notifications;64
if (useNative === "always") return true;65
if (useNative === "not-focused") return !document.hasFocus();66
return false;67
}68
69
const SessionsStore = findStoreLazy("SessionsStore");70
71
const StatusUtils = findByPropsLazy("useStatusFillColor", "StatusTypes");72
73
function Icon(path: string, opts?: { viewBox?: string; width?: number; height?: number; }) {74
return ({ color, tooltip, small }: { color: string; tooltip: string; small: boolean; }) => (75
<Tooltip text={tooltip} >76
{(tooltipProps: any) => (77
<svg78
{...tooltipProps}79
height={(opts?.height ?? 20) - (small ? 3 : 0)}80
width={(opts?.width ?? 20) - (small ? 3 : 0)}81
viewBox={opts?.viewBox ?? "0 0 24 24"}82
fill={color}83
>84
<path d={path} />85
</svg>86
)}87
</Tooltip>88
);89
}90
91
const Icons = {92
desktop: Icon("M4 2.5c-1.103 0-2 .897-2 2v11c0 1.104.897 2 2 2h7v2H7v2h10v-2h-4v-2h7c1.103 0 2-.896 2-2v-11c0-1.103-.897-2-2-2H4Zm16 2v9H4v-9h16Z"),93
web: Icon("M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93Zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39Z"),94
mobile: Icon("M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z", { viewBox: "0 0 1000 1500", height: 17, width: 17 }),95
console: Icon("M14.8 2.7 9 3.1V47h3.3c1.7 0 6.2.3 10 .7l6.7.6V2l-4.2.2c-2.4.1-6.9.3-10 .5zm1.8 6.4c1 1.7-1.3 3.6-2.7 2.2C12.7 10.1 13.5 8 15 8c.5 0 1.2.5 1.6 1.1zM16 33c0 6-.4 10-1 10s-1-4-1-10 .4-10 1-10 1 4 1 10zm15-8v23.3l3.8-.7c2-.3 4.7-.6 6-.6H43V3h-2.2c-1.3 0-4-.3-6-.6L31 1.7V25z", { viewBox: "0 0 50 50" }),96
};97
type Platform = keyof typeof Icons;98
99
const PlatformIcon = ({ platform, status, small }: { platform: Platform, status: string; small: boolean; }) => {100
const tooltip = platform[0].toUpperCase() + platform.slice(1);101
const Icon = Icons[platform] ?? Icons.desktop;102
103
return <Icon color={StatusUtils.useStatusFillColor(status)} tooltip={tooltip} small={small} />;104
};105
106
interface PlatformIndicatorProps {107
user: User;108
wantMargin?: boolean;109
wantTopMargin?: boolean;110
small?: boolean;111
style?: CSSProperties;112
}113
114
const PlatformIndicator = ({ user, wantMargin = true, wantTopMargin = false, small = false, style = {} }: PlatformIndicatorProps) => {115
if (!user || user.bot) return null;116
117
if (user.id === UserStore.getCurrentUser().id) {118
const sessions = SessionsStore.getSessions();119
if (typeof sessions !== "object") return null;120
const sortedSessions = Object.values(sessions).sort(({ status: a }: any, { status: b }: any) => {121
if (a === b) return 0;122
if (a === "online") return 1;123
if (b === "online") return -1;124
if (a === "idle") return 1;125
if (b === "idle") return -1;126
return 0;127
});128
129
const ownStatus = Object.values(sortedSessions).reduce((acc: any, curr: any) => {130
if (curr.clientInfo.client !== "unknown")131
acc[curr.clientInfo.client] = curr.status;132
return acc;133
}, {});134
135
const { clientStatuses } = PresenceStore.getState();136
clientStatuses[UserStore.getCurrentUser().id] = ownStatus;137
}138
139
const status = PresenceStore.getState()?.clientStatuses?.[user.id] as Record<Platform, string>;140
if (!status) return null;141
142
const icons = Object.entries(status).map(([platform, status]) => (143
<PlatformIcon144
key={platform}145
platform={platform as Platform}146
status={status}147
small={small}148
/>149
));150
151
if (!icons.length) return null;152
153
return (154
<span155
className="vc-platform-indicator"156
style={{157
display: "inline-flex",158
justifyContent: "center",159
alignItems: "center",160
marginLeft: wantMargin ? 4 : 0,161
verticalAlign: "top",162
position: "relative",163
top: wantTopMargin ? 2 : 0,164
padding: !wantMargin ? 1 : 0,165
gap: 2,166
...style167
}}168
169
>170
{icons}171
</span>172
);173
};174
175
export const settings = definePluginSettings({176
notifyStatus: {177
type: OptionType.BOOLEAN,178
description: "Notify on status changes",179
restartNeeded: false,180
default: true,181
},182
notifyVoice: {183
type: OptionType.BOOLEAN,184
description: "Notify on voice channel changes",185
restartNeeded: false,186
default: false,187
},188
persistNotifications: {189
type: OptionType.BOOLEAN,190
description: "Persist notifications",191
restartNeeded: false,192
default: false,193
},194
userIds: {195
type: OptionType.STRING,196
description: "User IDs (comma separated)",197
restartNeeded: false,198
default: "",199
}200
});201
202
function getUserIdList() {203
try {204
return settings.store.userIds.split(",").filter(Boolean);205
} catch (e) {206
settings.store.userIds = "";207
return [];208
}209
}210
211
// show rich body with user avatar212
const getRichBody = (user: User, text: string | React.ReactNode) => <div213
style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "10px" }}>214
<div style={{ position: "relative" }}>215
<img src={user.getAvatarURL(void 0, 80, true)}216
style={{ width: "80px", height: "80px", borderRadius: "15%" }} alt={`${user.username}039;s avatar`} />217
<PlatformIndicator user={user} style={{ position: "absolute", top: "-8px", right: "-10px" }} />218
</div>219
<span>{text}</span>220
</div>;221
222
function triggerVoiceNotification(userId: string, userChannelId: string | null) {223
const user = UserStore.getUser(userId);224
const myChanId = SelectedChannelStore.getVoiceChannelId();225
226
const name = user.username;227
228
const title = shouldBeNative() ? `User ${name} changed voice status` : "User voice status change";229
if (userChannelId) {230
if (userChannelId !== myChanId) {231
showNotification({232
title,233
body: "joined a new voice channel",234
noPersist: !settings.store.persistNotifications,235
richBody: getRichBody(user, `${name} joined a new voice channel`),236
});237
}238
} else {239
showNotification({240
title,241
body: "left their voice channel",242
noPersist: !settings.store.persistNotifications,243
richBody: getRichBody(user, `${name} left their voice channel`),244
});245
}246
}247
248
function toggleUserNotify(userId: string) {249
const userIds = getUserIdList();250
if (userIds.includes(userId)) {251
userIds.splice(userIds.indexOf(userId), 1);252
} else {253
userIds.push(userId);254
}255
settings.store.userIds = userIds.join(",");256
}257
258
interface UserContextProps {259
channel?: Channel;260
guildId?: string;261
user: User;262
}263
264
const UserContext: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => {265
if (!user || user.id === UserStore.getCurrentUser().id) return;266
const isNotifyOn = getUserIdList().includes(user.id);267
const label = isNotifyOn ? "Don039;t notify on changes" : "Notify on changes";268
const icon = isNotifyOn ? NotificationsOffIcon : NotificationsOnIcon;269
270
children.splice(-1, 0, (271
<Menu.MenuGroup>272
<Menu.MenuItem273
id="toggle-notify-user"274
label={label}275
action={() => toggleUserNotify(user.id)}276
icon={icon}277
/>278
</Menu.MenuGroup>279
));280
};281
282
const lastStatuses = new Map<string, string>();283
284
export default definePlugin({285
name: "NotifyUserChanges",286
description: "Adds a notify option in the user context menu to get notified when a user changes voice channels or online status",287
authors: [Devs.FiveCord],288
289
settings,290
291
contextMenus: {292
"user-context": UserContext293
},294
295
flux: {296
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {297
if (!settings.store.notifyVoice || !settings.store.userIds) {298
return;299
}300
for (const { userId, channelId, oldChannelId } of voiceStates) {301
if (channelId !== oldChannelId) {302
const isFollowed = getUserIdList().includes(userId);303
if (!isFollowed) {304
continue;305
}306
307
if (channelId) {308
// move or join new channel309
triggerVoiceNotification(userId, channelId);310
} else if (oldChannelId) {311
// leave312
triggerVoiceNotification(userId, null);313
}314
}315
}316
},317
PRESENCE_UPDATES({ updates }: { updates: PresenceUpdate[]; }) {318
if (!settings.store.notifyStatus || !settings.store.userIds) {319
return;320
}321
for (const { user: { id: userId, username }, status } of updates) {322
const isFollowed = getUserIdList().includes(userId);323
if (!isFollowed) {324
continue;325
}326
327
// this is also triggered for multiple guilds and when only the activities change, so we have to check if the status actually changed328
if (lastStatuses.has(userId) && lastStatuses.get(userId) !== status) {329
const user = UserStore.getUser(userId);330
const name = username ?? user.username;331
332
showNotification({333
title: shouldBeNative() ? `User ${name} changed status` : "User status change",334
body: `is now ${status}`,335
noPersist: !settings.store.persistNotifications,336
richBody: getRichBody(user, `${name}039;s status is now ${status}`),337
});338
}339
lastStatuses.set(userId, status);340
}341
}342
},343
344
});345