Plugin
XSOverlay
Forwards discord notifications to XSOverlay, for easy viewing in VR
1
import { definePluginSettings } from "@api/Settings";2
import { Devs } from "@utils/constants";3
import { Logger } from "@utils/Logger";4
import definePlugin, { makeRange, OptionType, PluginNative, ReporterTestable } from "@utils/types";5
import type { Channel, Embed, GuildMember, MessageAttachment, User } from "@vencord/discord-types";6
import { findByCodeLazy, findLazy } from "@webpack";7
import { Button, ChannelStore, GuildRoleStore, GuildStore, UserStore } from "@webpack/common";8
9
const ChannelTypes = findLazy(m => m.ANNOUNCEMENT_THREAD === 10);10
11
interface 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
35
interface 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
45
interface 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
56
interface Call {57
channel_id: string,58
guild_id: string,59
message_id: string,60
region: string,61
ringing: string[];62
}63
64
interface ApiObject {65
sender: string,66
target: string,67
command: string,68
jsonData: string,69
rawData: string | null,70
}71
72
interface 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
86
const notificationsShouldNotify = findByCodeLazy(".SUPPRESS_NOTIFICATIONS))return!1");87
const logger = new Logger("XSOverlay");88
89
const 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_WEB104
},105
botNotifications: {106
type: OptionType.BOOLEAN,107
description: "Allow bot notifications",108
default: false109
},110
serverNotifications: {111
type: OptionType.BOOLEAN,112
description: "Allow server notifications",113
default: true114
},115
dmNotifications: {116
type: OptionType.BOOLEAN,117
displayName: "DM Notifications",118
description: "Allow Direct Message notifications",119
default: true120
},121
groupDmNotifications: {122
type: OptionType.BOOLEAN,123
displayName: "Group DM Notifications",124
description: "Allow Group DM notifications",125
default: true126
},127
callNotifications: {128
type: OptionType.BOOLEAN,129
description: "Allow call notifications",130
default: true131
},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: true156
},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
171
let socket: WebSocket;172
173
async 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
183
const Native = VencordNative.pluginHelpers.XSOverlay as PluginNative<typeof import("./native")>;184
185
export 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 readable260
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 readable275
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 mentions285
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 notification308
</Button>309
</>310
)311
});312
313
function 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
319
function 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
345
function 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
362
async 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: null373
};374
if (socket.readyState !== socket.OPEN) await start();375
socket.send(JSON.stringify(apiObject));376
}377
378
function 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
385
function 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
392
function 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