Plugin

XSOverlay

Forwards discord notifications to XSOverlay, for easy viewing in VR

Notifications
index.tsx
Download

Source

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