Plugin
XSOverlay
Forwards discord notifications to XSOverlay, for easy viewing in VR
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { definePluginSettings } from "@api/Settings";8
import { BRAND_NAME, BRAND_NAME_LOWER } from "@utils/branding";9
import { Devs } from "@utils/constants";10
import { Logger } from "@utils/Logger";11
import definePlugin, { makeRange, OptionType, PluginNative, ReporterTestable } from "@utils/types";12
import type { Channel, Embed, GuildMember, MessageAttachment, User } from "@vencord/discord-types";13
import { findByCodeLazy, findLazy } from "@webpack";14
import { Button, ChannelStore, GuildRoleStore, GuildStore, UserStore } from "@webpack/common";15
16
const ChannelTypes = findLazy(m => m.ANNOUNCEMENT_THREAD === 10);17
18
interface 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
42
interface 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
52
interface 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
63
interface Call {64
channel_id: string,65
guild_id: string,66
message_id: string,67
region: string,68
ringing: string[];69
}70
71
interface ApiObject {72
sender: string,73
target: string,74
command: string,75
jsonData: string,76
rawData: string | null,77
}78
79
interface 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
93
const notificationsShouldNotify = findByCodeLazy(".SUPPRESS_NOTIFICATIONS))return!1");94
const logger = new Logger("XSOverlay");95
96
const 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_WEB111
},112
botNotifications: {113
type: OptionType.BOOLEAN,114
description: "Allow bot notifications",115
default: false116
},117
serverNotifications: {118
type: OptionType.BOOLEAN,119
description: "Allow server notifications",120
default: true121
},122
dmNotifications: {123
type: OptionType.BOOLEAN,124
displayName: "DM Notifications",125
description: "Allow Direct Message notifications",126
default: true127
},128
groupDmNotifications: {129
type: OptionType.BOOLEAN,130
displayName: "Group DM Notifications",131
description: "Allow Group DM notifications",132
default: true133
},134
callNotifications: {135
type: OptionType.BOOLEAN,136
description: "Allow call notifications",137
default: true138
},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: true163
},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
178
let socket: WebSocket;179
180
async 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
190
const Native = VencordNative.pluginHelpers.XSOverlay as PluginNative<typeof import("./native")>;191
192
export 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 readable267
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 readable282
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 mentions292
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 notification315
</Button>316
</>317
)318
});319
320
function 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
326
function 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_NAME346
};347
348
sendToOverlay(msgData);349
});350
}351
352
function 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_NAME365
};366
sendToOverlay(msgData);367
}368
369
async 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: null380
};381
if (socket.readyState !== socket.OPEN) await start();382
socket.send(JSON.stringify(apiObject));383
}384
385
function 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
392
function 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
399
function 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