Plugin

XSOverlay

Forwards discord notifications to XSOverlay, for easy viewing in VR

Notifications
index.tsx
Download

Source

src/plugins/xsOverlay/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 { definePluginSettings } from "@api/Settings";
8import { BRAND_NAME, BRAND_NAME_LOWER } from "@utils/branding";
9import { Devs } from "@utils/constants";
10import { Logger } from "@utils/Logger";
11import definePlugin, { makeRange, OptionType, PluginNative, ReporterTestable } from "@utils/types";
12import type { Channel, Embed, GuildMember, MessageAttachment, User } from "@vencord/discord-types";
13import { findByCodeLazy, findLazy } from "@webpack";
14import { Button, ChannelStore, GuildRoleStore, GuildStore, UserStore } from "@webpack/common";
15
16const ChannelTypes = findLazy(m => m.ANNOUNCEMENT_THREAD === 10);
17
18interface Message {
19 guild_id: string,
20 attachments: MessageAttachment[],
21 author: User,
22 channel_id: string,
23 components: any[],
24 content: string,
25 edited_timestamp: string,
26 embeds: Embed[],
27 sticker_items?: Sticker[],
28 flags: number,
29 id: string,
30 member: GuildMember,
31 mention_everyone: boolean,
32 mention_roles: string[],
33 mentions: Mention[],
34 nonce: string,
35 pinned: false,
36 referenced_message: any,
37 timestamp: string,
38 tts: boolean,
39 type: number;
40}
41
42interface Mention {
43 avatar: string,
44 avatar_decoration_data: any,
45 discriminator: string,
46 global_name: string,
47 id: string,
48 public_flags: number,
49 username: string;
50}
51
52interface Sticker {
53 t: "Sticker";
54 description: string;
55 format_type: number;
56 guild_id: string;
57 id: string;
58 name: string;
59 tags: string;
60 type: number;
61}
62
63interface Call {
64 channel_id: string,
65 guild_id: string,
66 message_id: string,
67 region: string,
68 ringing: string[];
69}
70
71interface ApiObject {
72 sender: string,
73 target: string,
74 command: string,
75 jsonData: string,
76 rawData: string | null,
77}
78
79interface NotificationObject {
80 type: number;
81 timeout: number;
82 height: number;
83 opacity: number;
84 volume: number;
85 audioPath: string;
86 title: string;
87 content: string;
88 useBase64Icon: boolean;
89 icon: string;
90 sourceApp: string;
91}
92
93const notificationsShouldNotify = findByCodeLazy(".SUPPRESS_NOTIFICATIONS))return!1");
94const logger = new Logger("XSOverlay");
95
96const settings = definePluginSettings({
97 webSocketPort: {
98 type: OptionType.NUMBER,
99 description: "Websocket port",
100 default: 42070,
101 async onChange() {
102 await start();
103 }
104 },
105 preferUDP: {
106 type: OptionType.BOOLEAN,
107 displayName: "Prefer UDP",
108 description: "Enable if you use an older build of XSOverlay unable to connect through websockets. This setting is ignored on web.",
109 default: false,
110 disabled: () => IS_WEB
111 },
112 botNotifications: {
113 type: OptionType.BOOLEAN,
114 description: "Allow bot notifications",
115 default: false
116 },
117 serverNotifications: {
118 type: OptionType.BOOLEAN,
119 description: "Allow server notifications",
120 default: true
121 },
122 dmNotifications: {
123 type: OptionType.BOOLEAN,
124 displayName: "DM Notifications",
125 description: "Allow Direct Message notifications",
126 default: true
127 },
128 groupDmNotifications: {
129 type: OptionType.BOOLEAN,
130 displayName: "Group DM Notifications",
131 description: "Allow Group DM notifications",
132 default: true
133 },
134 callNotifications: {
135 type: OptionType.BOOLEAN,
136 description: "Allow call notifications",
137 default: true
138 },
139 pingColor: {
140 type: OptionType.STRING,
141 description: "User mention color",
142 default: "#7289da"
143 },
144 channelPingColor: {
145 type: OptionType.STRING,
146 description: "Channel mention color",
147 default: "#8a2be2"
148 },
149 soundPath: {
150 type: OptionType.STRING,
151 description: "Notification sound (default/warning/error)",
152 default: "default"
153 },
154 timeout: {
155 type: OptionType.NUMBER,
156 description: "Notification duration (secs)",
157 default: 3,
158 },
159 lengthBasedTimeout: {
160 type: OptionType.BOOLEAN,
161 description: "Extend duration with message length",
162 default: true
163 },
164 opacity: {
165 type: OptionType.SLIDER,
166 description: "Notif opacity",
167 default: 1,
168 markers: makeRange(0, 1, 0.1)
169 },
170 volume: {
171 type: OptionType.SLIDER,
172 description: "Volume",
173 default: 0.2,
174 markers: makeRange(0, 1, 0.1)
175 },
176});
177
178let socket: WebSocket;
179
180async function start() {
181 if (socket) socket.close();
182 socket = new WebSocket(`ws:class="ts-cmt">//127.0.0.1:${settings.store.webSocketPort ?? 42070}/?client=${BRAND_NAME_LOWER}`);
183 return new Promise((resolve, reject) => {
184 socket.onopen = resolve;
185 socket.onerror = reject;
186 setTimeout(reject, 3000);
187 });
188}
189
190const Native = VencordNative.pluginHelpers.XSOverlay as PluginNative<typeof import("./native")>;
191
192export default definePlugin({
193 name: "XSOverlay",
194 description: "Forwards discord notifications to XSOverlay, for easy viewing in VR",
195 tags: ["Notifications"],
196 authors: [Devs.Nyako],
197 searchTerms: ["vr", "notify"],
198 reporterTestable: ReporterTestable.None,
199 settings,
200
201 flux: {
202 CALL_UPDATE({ call }: { call: Call; }) {
203 if (call?.ringing?.includes(UserStore.getCurrentUser().id) && settings.store.callNotifications) {
204 const channel = ChannelStore.getChannel(call.channel_id);
205 sendOtherNotif("Incoming call", `${channel.name} is calling you...`);
206 }
207 },
208 MESSAGE_CREATE({ message, optimistic }: { message: Message; optimistic: boolean; }) {
209 if (optimistic) return;
210 const channel = ChannelStore.getChannel(message.channel_id);
211 if (!shouldNotify(message, message.channel_id)) return;
212
213 const pingColor = settings.store.pingColor.replaceAll("#", "").trim();
214 const channelPingColor = settings.store.channelPingColor.replaceAll("#", "").trim();
215 let finalMsg = message.content;
216 let titleString = "";
217
218 if (channel.guild_id) {
219 const guild = GuildStore.getGuild(channel.guild_id);
220 titleString = `${message.author.username} (${guild.name}, #${channel.name})`;
221 }
222
223
224 switch (channel.type) {
225 case ChannelTypes.DM:
226 titleString = message.author.username.trim();
227 break;
228 case ChannelTypes.GROUP_DM:
229 const channelName = channel.name.trim() ?? channel.rawRecipients.map(e => e.username).join(", ");
230 titleString = `${message.author.username} (${channelName})`;
231 break;
232 }
233
234 if (message.referenced_message) {
235 titleString += " (reply)";
236 }
237
238 if (message.embeds.length > 0) {
239 finalMsg += " [embed] ";
240 if (message.content === "") {
241 finalMsg = "sent message embed(s)";
242 }
243 }
244
245 if (message.sticker_items) {
246 finalMsg += " [sticker] ";
247 if (message.content === "") {
248 finalMsg = "sent a sticker";
249 }
250 }
251
252 const images = message.attachments.filter(e =>
253 typeof e?.content_type === "string"
254 && e?.content_type.startsWith("image")
255 );
256
257
258 images.forEach(img => {
259 finalMsg += ` [image: ${img.filename}] `;
260 });
261
262 message.attachments.filter(a => a && !a.content_type?.startsWith("image")).forEach(a => {
263 finalMsg += ` [attachment: ${a.filename}] `;
264 });
265
266 // make mentions readable
267 if (message.mentions.length > 0) {
268 finalMsg = finalMsg.replace(/<@!?(\d{17,20})>/g, (_, id) => `<color=#${pingColor}><b>@${UserStore.getUser(id)?.username || "unknown-user"}</color></b>`);
269 }
270
271 // color role mentions (unity styling btw lol)
272 if (message.mention_roles.length > 0) {
273 for (const roleId of message.mention_roles) {
274 const role = GuildRoleStore.getRole(channel.guild_id, roleId);
275 if (!role) continue;
276 const roleColor = role.colorString ?? `#${pingColor}`;
277 finalMsg = finalMsg.replace(`<@&${roleId}>`, `<b><color=${roleColor}>@${role.name}</color></b>`);
278 }
279 }
280
281 // make emotes and channel mentions readable
282 const emoteMatches = finalMsg.match(new RegExp("(<a?:\\w+:\\d+>)", "g"));
283 const channelMatches = finalMsg.match(new RegExp("<(#\\d+)>", "g"));
284
285 if (emoteMatches) {
286 for (const eMatch of emoteMatches) {
287 finalMsg = finalMsg.replace(new RegExp(`${eMatch}`, "g"), `:${eMatch.split(":")[1]}:`);
288 }
289 }
290
291 // color channel mentions
292 if (channelMatches) {
293 for (const cMatch of channelMatches) {
294 let channelId = cMatch.split("<#")[1];
295 channelId = channelId.substring(0, channelId.length - 1);
296 finalMsg = finalMsg.replace(new RegExp(`${cMatch}`, "g"), `<b><color=#${channelPingColor}>#${ChannelStore.getChannel(channelId).name}</color></b>`);
297 }
298 }
299
300 if (shouldIgnoreForChannelType(channel)) return;
301 sendMsgNotif(titleString, finalMsg, message);
302 }
303 },
304
305 start,
306
307 stop() {
308 socket.close();
309 },
310
311 settingsAboutComponent: () => (
312 <>
313 <Button onClick={() => sendOtherNotif("This is a test notification! explode", "Hello from Vendor!")}>
314 Send test notification
315 </Button>
316 </>
317 )
318});
319
320function shouldIgnoreForChannelType(channel: Channel) {
321 if (channel.type === ChannelTypes.DM && settings.store.dmNotifications) return false;
322 if (channel.type === ChannelTypes.GROUP_DM && settings.store.groupDmNotifications) return false;
323 else return !settings.store.serverNotifications;
324}
325
326function sendMsgNotif(titleString: string, content: string, message: Message) {
327 fetch(`https:class="ts-cmt">//cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128`)
328 .then(response => response.blob())
329 .then(blob => new Promise<string>(resolve => {
330 const r = new FileReader();
331 r.onload = () => resolve((r.result as string).split(",")[1]);
332 r.readAsDataURL(blob);
333 })).then(result => {
334 const msgData: NotificationObject = {
335 type: 1,
336 timeout: settings.store.lengthBasedTimeout ? calculateTimeout(content) : settings.store.timeout,
337 height: calculateHeight(content),
338 opacity: settings.store.opacity,
339 volume: settings.store.volume,
340 audioPath: settings.store.soundPath,
341 title: titleString,
342 content: content,
343 useBase64Icon: true,
344 icon: result,
345 sourceApp: BRAND_NAME
346 };
347
348 sendToOverlay(msgData);
349 });
350}
351
352function sendOtherNotif(content: string, titleString: string) {
353 const msgData: NotificationObject = {
354 type: 1,
355 timeout: settings.store.lengthBasedTimeout ? calculateTimeout(content) : settings.store.timeout,
356 height: calculateHeight(content),
357 opacity: settings.store.opacity,
358 volume: settings.store.volume,
359 audioPath: settings.store.soundPath,
360 title: titleString,
361 content: content,
362 useBase64Icon: false,
363 icon: "default",
364 sourceApp: BRAND_NAME
365 };
366 sendToOverlay(msgData);
367}
368
369async function sendToOverlay(notif: NotificationObject) {
370 if (!IS_WEB && settings.store.preferUDP) {
371 Native.sendToOverlay(notif);
372 return;
373 }
374 const apiObject: ApiObject = {
375 sender: BRAND_NAME,
376 target: "xsoverlay",
377 command: "SendNotification",
378 jsonData: JSON.stringify(notif),
379 rawData: null
380 };
381 if (socket.readyState !== socket.OPEN) await start();
382 socket.send(JSON.stringify(apiObject));
383}
384
385function shouldNotify(message: Message, channel: string) {
386 const currentUser = UserStore.getCurrentUser();
387 if (message.author.id === currentUser.id) return false;
388 if (message.author.bot && !settings.store.botNotifications) return false;
389 return notificationsShouldNotify(message, channel);
390}
391
392function calculateHeight(content: string) {
393 if (content.length <= 100) return 100;
394 if (content.length <= 200) return 150;
395 if (content.length <= 300) return 200;
396 return 250;
397}
398
399function calculateTimeout(content: string) {
400 if (content.length <= 100) return 3;
401 if (content.length <= 200) return 4;
402 if (content.length <= 300) return 5;
403 return 6;
404}
405