Plugin

NotifyUserChanges

Adds a notify option in the user context menu to get notified when a user changes voice channels or online status

index.tsx
Download

Source

src/plugins/notifyUserChanges/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 { NavContextMenuPatchCallback } from "@api/ContextMenu";
8import { showNotification } from "@api/Notifications";
9import { definePluginSettings, Settings } from "@api/Settings";
10import { Devs } from "@utils/constants";
11import definePlugin, { OptionType } from "@utils/types";
12import { findByPropsLazy, findStoreLazy } from "@webpack";
13import { Menu, PresenceStore, React, SelectedChannelStore, Tooltip, UserStore } from "@webpack/common";
14import type { Channel, User } from "discord-types/general";
15import { CSSProperties } from "react";
16
17import { NotificationsOffIcon } from "./components/NotificationsOffIcon";
18import { NotificationsOnIcon } from "./components/NotificationsOnIcon";
19
20interface 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">// what'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
45interface 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
60function 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
69const SessionsStore = findStoreLazy("SessionsStore");
70
71const StatusUtils = findByPropsLazy("useStatusFillColor", "StatusTypes");
72
73function 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 <svg
78 {...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
91const 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};
97type Platform = keyof typeof Icons;
98
99const 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
106interface PlatformIndicatorProps {
107 user: User;
108 wantMargin?: boolean;
109 wantTopMargin?: boolean;
110 small?: boolean;
111 style?: CSSProperties;
112}
113
114const 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 <PlatformIcon
144 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 <span
155 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 ...style
167 }}
168
169 >
170 {icons}
171 </span>
172 );
173};
174
175export 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
202function 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 avatar
212const getRichBody = (user: User, text: string | React.ReactNode) => <div
213 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
222function 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
248function 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
258interface UserContextProps {
259 channel?: Channel;
260 guildId?: string;
261 user: User;
262}
263
264const 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 ? "Don&#039;t notify on changes" : "Notify on changes";
268 const icon = isNotifyOn ? NotificationsOffIcon : NotificationsOnIcon;
269
270 children.splice(-1, 0, (
271 <Menu.MenuGroup>
272 <Menu.MenuItem
273 id="toggle-notify-user"
274 label={label}
275 action={() => toggleUserNotify(user.id)}
276 icon={icon}
277 />
278 </Menu.MenuGroup>
279 ));
280};
281
282const lastStatuses = new Map<string, string>();
283
284export 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": UserContext
293 },
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 channel
309 triggerVoiceNotification(userId, channelId);
310 } else if (oldChannelId) {
311 // leave
312 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 changed
328 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