-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
New Plugin: Custom DM notification sounds #4221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
XliteChase
wants to merge
6
commits into
Vendicated:main
Choose a base branch
from
XliteChase:Custom-notification-sounds
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+341
−0
Open
Changes from 2 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5c2149d
Working 1 user version
XliteChase 5162615
Fixed errors
XliteChase 2efceda
Update src/plugins/customDmNotificationSound/index.tsx
XliteChase 4fd7773
Update src/plugins/customDmNotificationSound/README.md
XliteChase 1852f54
Addressed Comments
XliteChase 8137c28
Fixed missing drop down for group dm support
XliteChase File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # CustomDMNotificationSound | ||
|
|
||
| Set custom direct message notification sounds for specific users. | ||
|
|
||
| Right click a user and choose **Add Custom DM Sound** to select an audio file for that user. Users with configured sounds can be changed, previewed, or removed from the same context menu, or managed from the plugin settings. | ||
|
|
||
| Audio files are stored in Vencord settings as data URLs. The plugin supports up to 10 users with a 2 MB limit per audio file. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,304 @@ | ||
| /* | ||
| * Vencord, a Discord client mod | ||
| * Copyright (c) 2026 Vendicated and contributors | ||
| * SPDX-License-Identifier: GPL-3.0-or-later | ||
| */ | ||
|
|
||
| import { NavContextMenuPatchCallback } from "@api/ContextMenu"; | ||
| import { definePluginSettings } from "@api/Settings"; | ||
| import { CloudUploadIcon, DeleteIcon } from "@components/Icons"; | ||
| import { Devs } from "@utils/constants"; | ||
| import { Margins } from "@utils/margins"; | ||
| import definePlugin, { makeRange, OptionType } from "@utils/types"; | ||
| import { chooseFile } from "@utils/web"; | ||
| import { MessageJSON, User } from "@vencord/discord-types"; | ||
| import { ChannelType } from "@vencord/discord-types/enums"; | ||
| import { Button, ChannelStore, Forms, Menu, showToast, Toasts, UserStore } from "@webpack/common"; | ||
|
|
||
| const MAX_CUSTOM_SOUNDS = 10; | ||
| const MAX_SOUND_FILE_SIZE = 2 * 1024 * 1024; | ||
|
XliteChase marked this conversation as resolved.
Outdated
|
||
|
|
||
| const markedMessages = new Set<string>(); | ||
| let currentAudio: HTMLAudioElement | undefined; | ||
|
|
||
| interface CustomSound { | ||
| soundData: string; | ||
| fileName: string; | ||
| } | ||
|
|
||
| type CustomSounds = Record<string, CustomSound>; | ||
|
|
||
| function readFileAsDataUrl(file: File) { | ||
| return new Promise<string>((resolve, reject) => { | ||
| const reader = new FileReader(); | ||
| reader.onload = () => resolve(reader.result as string); | ||
| reader.onerror = reject; | ||
| reader.readAsDataURL(file); | ||
| }); | ||
| } | ||
|
|
||
| function playSound(src: string, volume: number) { | ||
| currentAudio?.pause(); | ||
|
|
||
| const audio = new Audio(src); | ||
| audio.volume = Math.min(1, Math.max(0, volume)); | ||
| currentAudio = audio; | ||
|
|
||
| void audio.play().catch(error => { | ||
| showToast(`Failed to play custom notification sound: ${error.message}`, Toasts.Type.FAILURE); | ||
| }); | ||
| } | ||
|
|
||
| function getErrorMessage(error: unknown) { | ||
| return error instanceof Error ? error.message : String(error); | ||
| } | ||
|
|
||
| function cloneCustomSounds(customSounds: CustomSounds) { | ||
| return Object.fromEntries( | ||
| Object.entries(customSounds).map(([userId, sound]) => [ | ||
| userId, | ||
| { | ||
| soundData: sound.soundData, | ||
| fileName: sound.fileName | ||
| } | ||
| ]) | ||
| ) as CustomSounds; | ||
| } | ||
|
|
||
| async function chooseSound() { | ||
| const file = await chooseFile("audio/*"); | ||
| if (!file) return null; | ||
|
|
||
| if (file.size > MAX_SOUND_FILE_SIZE) { | ||
| showToast("Audio file is too large. Please choose a file under 2 MB.", Toasts.Type.FAILURE); | ||
| return null; | ||
| } | ||
|
|
||
| return { | ||
| soundData: await readFileAsDataUrl(file), | ||
| fileName: file.name | ||
| }; | ||
| } | ||
|
|
||
| async function addOrChangeSound(user: User) { | ||
| try { | ||
| const customSounds = cloneCustomSounds(settings.store.customSounds); | ||
|
|
||
| if (!customSounds[user.id] && Object.keys(customSounds).length >= MAX_CUSTOM_SOUNDS) { | ||
| showToast(`You can only add up to ${MAX_CUSTOM_SOUNDS} custom DM sounds.`, Toasts.Type.FAILURE); | ||
| return; | ||
| } | ||
|
|
||
| const sound = await chooseSound(); | ||
| if (!sound) return; | ||
|
|
||
| settings.store.customSounds = { | ||
| ...customSounds, | ||
| [user.id]: sound | ||
| }; | ||
|
|
||
| showToast(`Selected ${sound.fileName} for ${user.username}`, Toasts.Type.SUCCESS); | ||
| } catch (error) { | ||
| showToast(`Failed to load audio file: ${getErrorMessage(error)}`, Toasts.Type.FAILURE); | ||
|
XliteChase marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
| function removeSound(userId: string) { | ||
| const customSounds = cloneCustomSounds(settings.store.customSounds); | ||
| delete customSounds[userId]; | ||
| settings.store.customSounds = customSounds; | ||
| } | ||
|
|
||
| function CustomSoundsManager({ setValue }: { setValue(value: CustomSounds): void; }) { | ||
| const { customSounds } = settings.use(["customSounds"]); | ||
| const entries = Object.entries(customSounds); | ||
|
|
||
| function updateSound(userId: string, sound: CustomSound) { | ||
| setValue({ | ||
| ...cloneCustomSounds(customSounds), | ||
| [userId]: sound | ||
| }); | ||
| } | ||
|
|
||
| function remove(userId: string) { | ||
| const nextCustomSounds = cloneCustomSounds(customSounds); | ||
| delete nextCustomSounds[userId]; | ||
| setValue(nextCustomSounds); | ||
| currentAudio?.pause(); | ||
| } | ||
|
|
||
| return ( | ||
| <div className={Margins.top8}> | ||
| <Forms.FormTitle>Custom User Sounds</Forms.FormTitle> | ||
| {entries.length === 0 ? ( | ||
| <Forms.FormText>Right click a user and choose Add Custom DM Sound to pick a sound for them.</Forms.FormText> | ||
| ) : entries.map(([userId, sound]) => { | ||
| const user = UserStore.getUser(userId); | ||
|
|
||
| return ( | ||
| <div | ||
| key={userId} | ||
| style={{ display: "flex", gap: "8px", flexWrap: "wrap", alignItems: "center", marginBottom: 8 }} | ||
| > | ||
| <Forms.FormText style={{ minWidth: 180, flex: "1 1 180px" }}> | ||
| {user?.username ?? userId} - {sound.fileName} | ||
| </Forms.FormText> | ||
| <Button | ||
| onClick={async () => { | ||
| try { | ||
| const nextSound = await chooseSound(); | ||
| if (!nextSound) return; | ||
|
|
||
| updateSound(userId, nextSound); | ||
| showToast(`Selected ${nextSound.fileName}`, Toasts.Type.SUCCESS); | ||
| } catch (error) { | ||
| showToast(`Failed to load audio file: ${getErrorMessage(error)}`, Toasts.Type.FAILURE); | ||
| } | ||
| }} | ||
| > | ||
| <CloudUploadIcon height={16} width={16} /> | ||
| Change | ||
| </Button> | ||
| <Button onClick={() => playSound(sound.soundData, settings.store.volume / 100)}> | ||
| Preview | ||
| </Button> | ||
| <Button | ||
| color={Button.Colors.RED} | ||
| onClick={() => remove(userId)} | ||
| > | ||
| <DeleteIcon height={16} width={16} /> | ||
| Remove | ||
| </Button> | ||
| </div> | ||
| ); | ||
| })} | ||
| <Forms.FormText className={Margins.top8}> | ||
| Pick audio files Discord can play. Files are stored in Vencord settings as data URLs. Limit: {MAX_CUSTOM_SOUNDS} users, 2 MB per file. | ||
| </Forms.FormText> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const settings = definePluginSettings({ | ||
| customSounds: { | ||
| type: OptionType.COMPONENT, | ||
| component: CustomSoundsManager, | ||
| default: {} as CustomSounds | ||
| }, | ||
| volume: { | ||
| type: OptionType.SLIDER, | ||
| description: "Custom sound volume", | ||
| markers: makeRange(0, 100, 10), | ||
| default: 100, | ||
| stickToMarkers: false | ||
| } | ||
| }).withPrivateSettings<{ | ||
| userId?: string; | ||
| soundData?: string; | ||
| }>(); | ||
|
|
||
| interface UserContextProps { | ||
| user?: User; | ||
| } | ||
|
|
||
| const userContextPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => { | ||
| if (!user || user.id === UserStore.getCurrentUser().id) return; | ||
|
|
||
| const customSound = settings.store.customSounds[user.id]; | ||
|
|
||
| children.push( | ||
| <Menu.MenuItem | ||
| id="vc-custom-dm-notification-sound" | ||
| label={customSound ? "Custom DM Sound" : "Add Custom DM Sound"} | ||
| action={customSound ? undefined : () => void addOrChangeSound(user)} | ||
| > | ||
| {customSound && ( | ||
| <> | ||
| <Menu.MenuItem | ||
| id="vc-custom-dm-notification-sound-change" | ||
| label="Change Sound" | ||
| action={() => void addOrChangeSound(user)} | ||
| /> | ||
| <Menu.MenuItem | ||
| id="vc-custom-dm-notification-sound-preview" | ||
| label="Preview Sound" | ||
| action={() => playSound(customSound.soundData, settings.store.volume / 100)} | ||
| /> | ||
| <Menu.MenuItem | ||
| id="vc-custom-dm-notification-sound-remove" | ||
| label="Remove Sound" | ||
| color="danger" | ||
| action={() => { | ||
| removeSound(user.id); | ||
| showToast(`Removed custom DM sound for ${user.username}`, Toasts.Type.SUCCESS); | ||
| }} | ||
| /> | ||
| </> | ||
| )} | ||
| </Menu.MenuItem> | ||
| ); | ||
| }; | ||
|
|
||
| export default definePlugin({ | ||
| name: "CustomDMNotificationSound", | ||
| description: "Replaces Discord's default DM notification sound for marked users with selected custom sounds", | ||
| tags: ["Notifications", "Customisation"], | ||
| authors: [Devs.Xlite], | ||
| settings, | ||
|
|
||
| start() { | ||
| if (!settings.store.userId || !settings.store.soundData || settings.store.customSounds[settings.store.userId]) return; | ||
|
|
||
| settings.store.customSounds = { | ||
| ...cloneCustomSounds(settings.store.customSounds), | ||
| [settings.store.userId]: { | ||
| soundData: settings.store.soundData, | ||
| fileName: "Migrated custom sound" | ||
| } | ||
| }; | ||
|
XliteChase marked this conversation as resolved.
|
||
| }, | ||
|
|
||
| stop() { | ||
| currentAudio?.pause(); | ||
| currentAudio = undefined; | ||
| markedMessages.clear(); | ||
| }, | ||
|
|
||
| contextMenus: { | ||
| "user-context": userContextPatch, | ||
| "user-profile-actions": userContextPatch, | ||
| "user-profile-overflow-menu": userContextPatch | ||
| }, | ||
|
|
||
| patches: [ | ||
| { | ||
| find: ".getDesktopType()===", | ||
| replacement: { | ||
| match: /sound:(\i\?\i:void 0),volume:(\i),onClick/, | ||
| replace: "sound:$self.getSound($1,arguments[0]?.message,$2),volume:$2,onClick" | ||
| } | ||
| } | ||
| ], | ||
|
|
||
| getSound(defaultSound: unknown, message?: MessageJSON, volume = 1) { | ||
| const customSound = this.getCustomSound(message); | ||
| if (!customSound) return defaultSound; | ||
|
|
||
| playSound(customSound.soundData, settings.store.volume / 100 * volume); | ||
| return undefined; | ||
| }, | ||
|
|
||
| getCustomSound(message?: MessageJSON) { | ||
| if (!message?.id || markedMessages.has(message.id)) return null; | ||
|
|
||
| const customSound = message.author?.id ? settings.store.customSounds[message.author.id] : null; | ||
| if (!customSound?.soundData) return null; | ||
|
|
||
| const channel = ChannelStore.getChannel(message.channel_id); | ||
| if (channel?.type !== ChannelType.DM) return null; | ||
|
|
||
| if (markedMessages.size > 500) markedMessages.clear(); | ||
| markedMessages.add(message.id); | ||
| return customSound; | ||
| } | ||
|
XliteChase marked this conversation as resolved.
|
||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.