├── screenshot.png ├── context-menu.png ├── README.md ├── components ├── NotificationsOnIcon.tsx └── NotificationsOffIcon.tsx └── index.tsx /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/D3SOX/vc-notifyUserChanges/HEAD/screenshot.png -------------------------------------------------------------------------------- /context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/D3SOX/vc-notifyUserChanges/HEAD/context-menu.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NotifyUserChanges 2 | 3 | Adds a notify option in the user context menu to get notified when a user changes voice channels or online status 4 | 5 | ![Context Menu](./context-menu.png) 6 | ![Screenshot](./screenshot.png) 7 | 8 | I will not add more features to this plugin. If you're insane you can try https://github.com/zastlx/vc-stalker-plugin 9 | 10 | # Installation 11 | See [here](https://github.com/D3SOX/vencord-userplugins#install) and the [Vencord docs for installing custom plugins](https://docs.vencord.dev/installing/custom-plugins/) 12 | -------------------------------------------------------------------------------- /components/NotificationsOnIcon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Vencord, a Discord client mod 3 | * Copyright (c) 2024 Vendicated and contributors 4 | * SPDX-License-Identifier: GPL-3.0-or-later 5 | */ 6 | 7 | import type { SVGProps } from "react"; 8 | 9 | export function NotificationsOnIcon(props: SVGProps) { 10 | return (); 11 | } 12 | -------------------------------------------------------------------------------- /components/NotificationsOffIcon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Vencord, a Discord client mod 3 | * Copyright (c) 2024 Vendicated and contributors 4 | * SPDX-License-Identifier: GPL-3.0-or-later 5 | */ 6 | 7 | import type { SVGProps } from "react"; 8 | 9 | export function NotificationsOffIcon(props: SVGProps) { 10 | return (); 11 | } 12 | -------------------------------------------------------------------------------- /index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Vencord, a Discord client mod 3 | * Copyright (c) 2024 Vendicated and contributors 4 | * SPDX-License-Identifier: GPL-3.0-or-later 5 | */ 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 type { Channel, User } from "@vencord/discord-types"; 13 | import { findByPropsLazy, findStoreLazy } from "@webpack"; 14 | import { Menu, PresenceStore, React, SelectedChannelStore, Tooltip, UserStore } from "@webpack/common"; 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; // 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 | 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 | 76 | {(tooltipProps: any) => ( 77 | 84 | 85 | 86 | )} 87 | 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 ; 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; 140 | if (!status) return null; 141 | 142 | const icons = Object.entries(status).map(([platform, status]) => ( 143 | 149 | )); 150 | 151 | if (!icons.length) return null; 152 | 153 | return ( 154 | 170 | {icons} 171 | 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 avatar 212 | const getRichBody = (user: User, text: string | React.ReactNode) =>
214 |
215 | {`${user.username}'s 217 | 218 |
219 | {text} 220 |
; 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 ? "Don't notify on changes" : "Notify on changes"; 268 | const icon = isNotifyOn ? NotificationsOffIcon : NotificationsOnIcon; 269 | 270 | children.splice(-1, 0, ( 271 | 272 | toggleUserNotify(user.id)} 276 | icon={icon} 277 | /> 278 | 279 | )); 280 | }; 281 | 282 | const lastStatuses = new Map(); 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.D3SOX], 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, clientStatus } of updates) { 322 | const isFollowed = getUserIdList().includes(userId); 323 | if (!isFollowed) { 324 | continue; 325 | } 326 | 327 | if (!clientStatus) { 328 | continue; 329 | } 330 | // this is also triggered for multiple guilds and when only the activities change, so we have to check if the status actually changed 331 | if (lastStatuses.has(userId) && lastStatuses.get(userId) !== status) { 332 | const user = UserStore.getUser(userId); 333 | // @ts-ignore 334 | const name = user.globalName || username; 335 | 336 | showNotification({ 337 | title: shouldBeNative() ? `${name} changed status` : "User status change", 338 | body: `They are now ${status}`, 339 | noPersist: !settings.store.persistNotifications, 340 | richBody: getRichBody(user, `${name}'s status is now ${status}`), 341 | }); 342 | } 343 | lastStatuses.set(userId, status); 344 | } 345 | } 346 | }, 347 | 348 | }); 349 | --------------------------------------------------------------------------------