From a480ab051799f1e86ffdde46f228f479eb3c6fb4 Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sat, 7 Mar 2026 08:31:31 -0500 Subject: [PATCH] Revert "goodbye" This reverts commit 7dd37b53bc37d3ac0a5e2fad670934846162cc94. --- .../copyProfileColors/index.tsx | 98 ++++ src/equicordplugins/equissant/index.ts | 47 ++ src/equicordplugins/exitSounds/index.tsx | 147 ++++++ src/equicordplugins/exitSounds/styles.css | 17 + .../fastDeleteChannels/index.tsx | 128 +++++ src/equicordplugins/followVoiceUser/index.tsx | 115 ++++ src/equicordplugins/fontLoader/index.tsx | 219 ++++++++ src/equicordplugins/fontLoader/styles.css | 23 + .../frequentQuickSwitcher/index.tsx | 51 ++ src/equicordplugins/friendTags/index.tsx | 230 ++++++++ src/equicordplugins/friendTags/styles.css | 11 + src/equicordplugins/friendshipRanks/index.tsx | 154 ++++++ .../friendshipRanks/styles.css | 19 + src/equicordplugins/ghosted/index.tsx | 302 +++++------ src/equicordplugins/ghosted/styles.css | 87 ++++ src/equicordplugins/hopOn/index.tsx | 48 ++ src/equicordplugins/husk/index.tsx | 122 +++++ .../iRememberYou/components/data.tsx | 217 ++++++++ .../iRememberYou/components/ui.tsx | 141 +++++ src/equicordplugins/iRememberYou/index.tsx | 55 ++ src/equicordplugins/iRememberYou/styles.css | 78 +++ src/equicordplugins/idleAutoRestart/index.tsx | 85 +++ .../inRole/RoleMembersModal.tsx | 44 ++ src/equicordplugins/inRole/index.tsx | 127 +++++ src/equicordplugins/inRole/style.css | 29 ++ src/equicordplugins/ingtoninator/index.tsx | 161 ++++++ .../components/DecryptionModal.tsx | 77 +++ .../components/EncryptionModal.tsx | 113 ++++ .../invisibleChat.desktop/index.tsx | 234 +++++++++ src/equicordplugins/jumpscare/index.tsx | 90 ++++ src/equicordplugins/jumpscare/styles.css | 33 ++ src/equicordplugins/lastActive/index.tsx | 144 +++++ src/equicordplugins/messageBurst/index.ts | 96 ++++ .../messageFetchTimer/index.tsx | 178 +++++++ src/equicordplugins/messageNotifier/index.ts | 63 +++ src/equicordplugins/moyai/index.ts | 174 +++++++ src/equicordplugins/noRoleHeaders/index.ts | 23 + .../index.tsx | 67 +++ src/equicordplugins/partyMode/index.ts | 109 ++++ .../pingNotifications/index.tsx | 145 ++++++ .../profileSets/components/confirmModal.tsx | 81 +++ .../profileSets/components/presetList.tsx | 220 ++++++++ .../profileSets/components/presetManager.tsx | 290 +++++++++++ src/equicordplugins/profileSets/index.tsx | 56 ++ src/equicordplugins/profileSets/styles.css | 209 ++++++++ .../profileSets/utils/actions.ts | 152 ++++++ .../profileSets/utils/profile.ts | 415 +++++++++++++++ .../profileSets/utils/storage.ts | 120 +++++ .../questionMarkReplacement/index.tsx | 46 ++ .../screenRecorder.equibop/index.tsx | 66 +++ src/equicordplugins/serverSearch/index.tsx | 64 +++ src/equicordplugins/serverSearch/styles.css | 33 ++ src/equicordplugins/signature/index.tsx | 155 ++++++ src/equicordplugins/snowfall/index.tsx | 490 ++++++++++++++++++ src/equicordplugins/soggy/index.tsx | 167 ++++++ src/equicordplugins/talkInReverse/index.tsx | 74 +++ src/equicordplugins/toggleVideoBind/index.ts | 70 +++ .../toneIndicators/ToneIndicator.tsx | 36 ++ src/equicordplugins/toneIndicators/index.tsx | 188 +++++++ .../toneIndicators/indicators.ts | 62 +++ src/equicordplugins/translatePlus/index.tsx | 68 +++ .../translatePlus/misc/languages.ts | 173 +++++++ .../translatePlus/misc/types.ts | 19 + src/equicordplugins/translatePlus/settings.ts | 35 ++ src/equicordplugins/translatePlus/style.css | 22 + .../translatePlus/utils/accessory.tsx | 42 ++ .../translatePlus/utils/icon.tsx | 27 + .../translatePlus/utils/translator.ts | 143 +++++ src/equicordplugins/triviaAI/index.tsx | 66 +++ src/equicordplugins/triviaAI/settings.ts | 53 ++ src/equicordplugins/triviaAI/utils.ts | 188 +++++++ .../unreadBadgeCount/index.tsx | 80 +++ .../unreadBadgeCount/styles.css | 39 ++ .../VoiceChannelLogEntryComponent.css | 42 ++ .../VoiceChannelLogEntryComponent.tsx | 37 ++ .../components/VoiceChannelLogEntryIcons.tsx | 29 ++ .../components/VoiceChannelLogModal.tsx | 68 +++ .../voiceChannelLog.dev/index.tsx | 164 ++++++ .../voiceChannelLog.dev/logs.ts | 41 ++ .../index.tsx | 0 .../index.tsx | 0 .../tar.ts | 0 .../webpack.ts | 0 src/equicordplugins/wigglyText/index.tsx | 199 +++++++ .../ui/components/ExampleWiggle.tsx | 20 + src/equicordplugins/writeUpperCase/index.ts | 49 ++ src/plugins/_core/supportHelper.tsx | 5 +- 87 files changed, 8742 insertions(+), 162 deletions(-) create mode 100644 src/equicordplugins/copyProfileColors/index.tsx create mode 100644 src/equicordplugins/equissant/index.ts create mode 100644 src/equicordplugins/exitSounds/index.tsx create mode 100644 src/equicordplugins/exitSounds/styles.css create mode 100644 src/equicordplugins/fastDeleteChannels/index.tsx create mode 100644 src/equicordplugins/followVoiceUser/index.tsx create mode 100644 src/equicordplugins/fontLoader/index.tsx create mode 100644 src/equicordplugins/fontLoader/styles.css create mode 100644 src/equicordplugins/frequentQuickSwitcher/index.tsx create mode 100644 src/equicordplugins/friendTags/index.tsx create mode 100644 src/equicordplugins/friendTags/styles.css create mode 100644 src/equicordplugins/friendshipRanks/index.tsx create mode 100644 src/equicordplugins/friendshipRanks/styles.css create mode 100644 src/equicordplugins/hopOn/index.tsx create mode 100644 src/equicordplugins/husk/index.tsx create mode 100644 src/equicordplugins/iRememberYou/components/data.tsx create mode 100644 src/equicordplugins/iRememberYou/components/ui.tsx create mode 100644 src/equicordplugins/iRememberYou/index.tsx create mode 100644 src/equicordplugins/iRememberYou/styles.css create mode 100644 src/equicordplugins/idleAutoRestart/index.tsx create mode 100644 src/equicordplugins/inRole/RoleMembersModal.tsx create mode 100644 src/equicordplugins/inRole/index.tsx create mode 100644 src/equicordplugins/inRole/style.css create mode 100644 src/equicordplugins/ingtoninator/index.tsx create mode 100644 src/equicordplugins/invisibleChat.desktop/components/DecryptionModal.tsx create mode 100644 src/equicordplugins/invisibleChat.desktop/components/EncryptionModal.tsx create mode 100644 src/equicordplugins/invisibleChat.desktop/index.tsx create mode 100644 src/equicordplugins/jumpscare/index.tsx create mode 100644 src/equicordplugins/jumpscare/styles.css create mode 100644 src/equicordplugins/lastActive/index.tsx create mode 100644 src/equicordplugins/messageBurst/index.ts create mode 100644 src/equicordplugins/messageFetchTimer/index.tsx create mode 100644 src/equicordplugins/messageNotifier/index.ts create mode 100644 src/equicordplugins/moyai/index.ts create mode 100644 src/equicordplugins/noRoleHeaders/index.ts create mode 100644 src/equicordplugins/notificationTitle.discordDesktop/index.tsx create mode 100644 src/equicordplugins/partyMode/index.ts create mode 100644 src/equicordplugins/pingNotifications/index.tsx create mode 100644 src/equicordplugins/profileSets/components/confirmModal.tsx create mode 100644 src/equicordplugins/profileSets/components/presetList.tsx create mode 100644 src/equicordplugins/profileSets/components/presetManager.tsx create mode 100644 src/equicordplugins/profileSets/index.tsx create mode 100644 src/equicordplugins/profileSets/styles.css create mode 100644 src/equicordplugins/profileSets/utils/actions.ts create mode 100644 src/equicordplugins/profileSets/utils/profile.ts create mode 100644 src/equicordplugins/profileSets/utils/storage.ts create mode 100644 src/equicordplugins/questionMarkReplacement/index.tsx create mode 100644 src/equicordplugins/screenRecorder.equibop/index.tsx create mode 100644 src/equicordplugins/serverSearch/index.tsx create mode 100644 src/equicordplugins/serverSearch/styles.css create mode 100644 src/equicordplugins/signature/index.tsx create mode 100644 src/equicordplugins/snowfall/index.tsx create mode 100644 src/equicordplugins/soggy/index.tsx create mode 100644 src/equicordplugins/talkInReverse/index.tsx create mode 100644 src/equicordplugins/toggleVideoBind/index.ts create mode 100644 src/equicordplugins/toneIndicators/ToneIndicator.tsx create mode 100644 src/equicordplugins/toneIndicators/index.tsx create mode 100644 src/equicordplugins/toneIndicators/indicators.ts create mode 100644 src/equicordplugins/translatePlus/index.tsx create mode 100644 src/equicordplugins/translatePlus/misc/languages.ts create mode 100644 src/equicordplugins/translatePlus/misc/types.ts create mode 100644 src/equicordplugins/translatePlus/settings.ts create mode 100644 src/equicordplugins/translatePlus/style.css create mode 100644 src/equicordplugins/translatePlus/utils/accessory.tsx create mode 100644 src/equicordplugins/translatePlus/utils/icon.tsx create mode 100644 src/equicordplugins/translatePlus/utils/translator.ts create mode 100644 src/equicordplugins/triviaAI/index.tsx create mode 100644 src/equicordplugins/triviaAI/settings.ts create mode 100644 src/equicordplugins/triviaAI/utils.ts create mode 100644 src/equicordplugins/unreadBadgeCount/index.tsx create mode 100644 src/equicordplugins/unreadBadgeCount/styles.css create mode 100644 src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css create mode 100644 src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx create mode 100644 src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx create mode 100644 src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx create mode 100644 src/equicordplugins/voiceChannelLog.dev/index.tsx create mode 100644 src/equicordplugins/voiceChannelLog.dev/logs.ts rename src/equicordplugins/{voiceRejoin.dev => voiceRejoin}/index.tsx (100%) rename src/equicordplugins/{webpackTarball.dev => webpackTarball}/index.tsx (100%) rename src/equicordplugins/{webpackTarball.dev => webpackTarball}/tar.ts (100%) rename src/equicordplugins/{webpackTarball.dev => webpackTarball}/webpack.ts (100%) create mode 100644 src/equicordplugins/wigglyText/index.tsx create mode 100644 src/equicordplugins/wigglyText/ui/components/ExampleWiggle.tsx create mode 100644 src/equicordplugins/writeUpperCase/index.ts diff --git a/src/equicordplugins/copyProfileColors/index.tsx b/src/equicordplugins/copyProfileColors/index.tsx new file mode 100644 index 000000000..256e86994 --- /dev/null +++ b/src/equicordplugins/copyProfileColors/index.tsx @@ -0,0 +1,98 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { copyToClipboard } from "@utils/clipboard"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { Menu, Toasts, UserProfileStore } from "@webpack/common"; + +function getProfileColors(userId) { + try { + const profile = UserProfileStore.getUserProfile(userId); + + if (!profile || !profile.themeColors || profile.themeColors.length < 2) { + return null; + } + + const primaryColor = profile.themeColors[0].toString(16).padStart(6, "0"); + const secondaryColor = profile.themeColors[1].toString(16).padStart(6, "0"); + + return { primaryColor, secondaryColor }; + } catch (e) { + console.error("Failed to get profile colors:", e); + return null; + } +} + +function copyProfileColors(userId) { + const colors = getProfileColors(userId); + + if (!colors) { + Toasts.show({ + type: Toasts.Type.FAILURE, + message: "No profile colors found!", + id: Toasts.genId() + }); + return; + } + + const { primaryColor, secondaryColor } = colors; + + // Formatting + const formattedColors = `Primary-color #${primaryColor}, Secondary-Color #${secondaryColor}`; + + try { + copyToClipboard(formattedColors); + Toasts.show({ + type: Toasts.Type.SUCCESS, + message: "Profile colors copied to clipboard!", + id: Toasts.genId() + }); + } catch (e) { + console.error("Failed to copy to clipboard:", e); + Toasts.show({ + type: Toasts.Type.FAILURE, + message: "Error copying profile colors!", + id: Toasts.genId() + }); + } +} + +export function ColorIcon() { + return ( + + + + ); +} +// spawn in the context menu +const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }) => { + if (!user) return; + children.push( + copyProfileColors(user.id)} + /> + ); +}; + +export default definePlugin({ + name: "CopyProfileColors", + description: "A plugin to copy people's profile gradient colors to clipboard.", + authors: [EquicordDevs.Crxa, EquicordDevs.Cortex], + contextMenus: { + "user-context": userContextMenuPatch, + "user-profile-actions": userContextMenuPatch + } +}); diff --git a/src/equicordplugins/equissant/index.ts b/src/equicordplugins/equissant/index.ts new file mode 100644 index 000000000..188316213 --- /dev/null +++ b/src/equicordplugins/equissant/index.ts @@ -0,0 +1,47 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer"; +import { definePluginSettings } from "@api/Settings"; +import { Devs, EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; + +let clickCount = 0; +let croissant: AudioPlayerInterface | null = null; + +function play() { + const triggerAmount = settings.store.amount; + clickCount++; + + if (clickCount % triggerAmount === 0) { + croissant?.play(); + clickCount = 0; + } +} + +const settings = definePluginSettings({ + amount: { + type: OptionType.NUMBER, + description: "amount of clicks to trigger crossant", + default: 10, + } +}); + +export default definePlugin({ + name: "Equissant", + description: "Crossant every specified amount of clicks :trolley:", + authors: [EquicordDevs.SomeAspy, Devs.thororen], + dependencies: ["AudioPlayerAPI"], + settings, + start() { + croissant = createAudioPlayer("https://github.com/Equicord/Equibored/raw/main/sounds/equissant/croissant.mp3", { persistent: true }); + document.addEventListener("click", play); + }, + stop() { + croissant?.delete(); + document.removeEventListener("click", play); + } +}); diff --git a/src/equicordplugins/exitSounds/index.tsx b/src/equicordplugins/exitSounds/index.tsx new file mode 100644 index 000000000..db67fe6ed --- /dev/null +++ b/src/equicordplugins/exitSounds/index.tsx @@ -0,0 +1,147 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { playAudio } from "@api/AudioPlayer"; +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; +import { Button } from "@components/Button"; +import { Devs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import definePlugin, { OptionType } from "@utils/types"; +import { findComponentByCodeLazy } from "@webpack"; +import { ChannelActions, Constants, GuildStore, IconUtils, MediaEngineStore, Menu, RestAPI, SearchableSelect, SelectedChannelStore, TextInput, Toasts } from "@webpack/common"; + +const cl = classNameFactory("vc-exitsounds-"); + +const PlayIcon = findComponentByCodeLazy("4.96v14.08c0"); + +function GuildSelector() { + const { soundGuildId } = settings.use(["soundGuildId"]); + const options = Object.values(GuildStore.getGuilds()).map(g => ({ + value: g.id, + label: g.name + })); + + return ( + o.value === soundGuildId)?.value} + placeholder="Select a server..." + maxVisibleItems={6} + closeOnSelect={true} + onChange={v => settings.store.soundGuildId = v} + renderOptionPrefix={o => { + const guild = GuildStore.getGuild(o?.value); + if (!guild?.icon) return null; + return ( + + ); + }} + /> + ); +} + +function SoundIdInput() { + const { soundId } = settings.use(["soundId"]); + + return ( +
+
+ settings.store.soundId = v} + placeholder="Enter sound ID..." + /> +
+ +
+ ); +} + +const settings = definePluginSettings({ + soundGuildId: { + type: OptionType.COMPONENT, + description: "Select the server containing the sound.", + component: GuildSelector + }, + soundId: { + type: OptionType.COMPONENT, + description: "Enter the ID of the sound you want to play.", + component: SoundIdInput + } +}); + +const SoundButtonContext: NavContextMenuPatchCallback = (children, { sound }: { sound: { soundId: string; guildId: string; }; }) => { + children.splice(1, 0, + + { + settings.store.soundGuildId = sound.guildId; + settings.store.soundId = sound.soundId; + }} + /> + + ); +}; + +let original: typeof ChannelActions.selectVoiceChannel; + +export default definePlugin({ + name: "ExitSounds", + description: "Play soundboard sounds when you disconnect from voice.", + authors: [Devs.prism], + dependencies: ["AudioPlayerAPI"], + settings, + + contextMenus: { + "sound-button-context": SoundButtonContext + }, + + start() { + original = ChannelActions.selectVoiceChannel; + ChannelActions.selectVoiceChannel = async (id: string | null, ...args: unknown[]) => { + const { soundGuildId, soundId } = settings.store; + const voiceId = SelectedChannelStore.getVoiceChannelId(); + + if (soundGuildId && soundId && voiceId !== id && !MediaEngineStore.isDeaf()) { + try { + await RestAPI.post({ + url: Constants.Endpoints.SEND_SOUNDBOARD_SOUND(voiceId), + body: { + sound_id: soundId, + source_guild_id: soundGuildId + } + }); + await new Promise(r => setTimeout(r, 500)); + } catch { + Toasts.show({ + message: "Oops! Something went wrong.", + id: Toasts.genId(), + type: Toasts.Type.FAILURE + }); + } + } + + return original(id, ...args); + }; + }, + + stop() { + ChannelActions.selectVoiceChannel = original; + } +}); diff --git a/src/equicordplugins/exitSounds/styles.css b/src/equicordplugins/exitSounds/styles.css new file mode 100644 index 000000000..246817838 --- /dev/null +++ b/src/equicordplugins/exitSounds/styles.css @@ -0,0 +1,17 @@ +.vc-exitsounds-input-row { + display: flex; + gap: 8px; + align-items: center; +} + +.vc-exitsounds-input-wrapper { + flex: 1; +} + +.vc-exitsounds-guild-icon { + width: 24px; + height: 24px; + border-radius: 4px; + margin-right: 8px; + vertical-align: middle; +} diff --git a/src/equicordplugins/fastDeleteChannels/index.tsx b/src/equicordplugins/fastDeleteChannels/index.tsx new file mode 100644 index 000000000..e4b20c200 --- /dev/null +++ b/src/equicordplugins/fastDeleteChannels/index.tsx @@ -0,0 +1,128 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { Constants, PermissionsBits, PermissionStore, React, RestAPI, useCallback, useEffect, useState } from "@webpack/common"; + +const validKeycodes = [ + "Backspace", "Tab", "Enter", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Pause", "CapsLock", + "Escape", "Space", "PageUp", "PageDown", "End", "Home", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "PrintScreen", "Insert", + "Delete", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC", + "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", + "KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ", "MetaLeft", "MetaRight", "ContextMenu", "Numpad0", "Numpad1", "Numpad2", "Numpad3", + "Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "NumpadMultiply", "NumpadAdd", "NumpadSubtract", "NumpadDecimal", + "NumpadDivide", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "NumLock", "ScrollLock", +]; + +function showIcon() { + const [show, setShow] = useState(false); + const { keyBind, reqCtrl, reqShift, reqAlt } = settings.store; + + const handleKeys = useCallback((e: KeyboardEvent) => { + const isMatchingKey = + e.code === keyBind && + (!reqCtrl || e.ctrlKey) && + (!reqShift || e.shiftKey) && + (!reqAlt || e.altKey); + + setShow(isMatchingKey); + }, [keyBind, reqCtrl, reqShift, reqAlt]); + + useEffect(() => { + window.addEventListener("keydown", handleKeys); + window.addEventListener("keyup", handleKeys); + + return () => { + window.removeEventListener("keydown", handleKeys); + window.removeEventListener("keyup", handleKeys); + }; + }, [handleKeys]); + + return show; +} + +// TY ToggleVideoBind +const settings = definePluginSettings({ + keyBind: { + description: "The key to toggle trash when pressed.", + type: OptionType.STRING, + default: "KeyZ", + isValid: (value: string) => validKeycodes.includes(value), + }, + reqCtrl: { + description: "Require control to be held.", + type: OptionType.BOOLEAN, + default: true, + }, + reqShift: { + description: "Require shift to be held.", + type: OptionType.BOOLEAN, + default: true, + }, + reqAlt: { + description: "Require alt to be held.", + type: OptionType.BOOLEAN, + default: false, + }, +}); + +export default definePlugin({ + name: "FastDeleteChannels", + description: "Adds a trash icon to delete channels", + authors: [Devs.thororen], + settings, + patches: [ + // TY TypingIndicator + // Normal Channels + { + find: "UNREAD_IMPORTANT:", + replacement: { + match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/, + replace: "$&,$self.TrashIcon($1)" + } + }, + // Threads + { + find: "18V16H9v2H6Zm3", + replacement: { + match: /mentionsCount:\i.+?null(?<=channel:(\i).+?)/, + replace: "$&,$self.TrashIcon($1)" + } + } + ], + TrashIcon: channel => { + const show = showIcon(); + + if (!show || !PermissionStore.can(PermissionsBits.MANAGE_CHANNELS, channel)) return null; + + return ( + RestAPI.del({ url: Constants.Endpoints.CHANNEL(channel.id) })} + > + + + + + + ); + } +}); diff --git a/src/equicordplugins/followVoiceUser/index.tsx b/src/equicordplugins/followVoiceUser/index.tsx new file mode 100644 index 000000000..18ef15244 --- /dev/null +++ b/src/equicordplugins/followVoiceUser/index.tsx @@ -0,0 +1,115 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; +import { Notice } from "@components/Notice"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { Channel, User, VoiceState } from "@vencord/discord-types"; +import { findByPropsLazy, findStoreLazy } from "@webpack"; +import { Menu, React, VoiceStateStore } from "@webpack/common"; + +type TFollowedUserInfo = { + lastChannelId: string; + userId: string; +} | null; + +interface UserContextProps { + channel: Channel; + user: User; + guildId?: string; +} + +let followedUserInfo: TFollowedUserInfo = null; + +const voiceChannelAction = findByPropsLazy("selectVoiceChannel"); +const UserStore = findStoreLazy("UserStore"); +const RelationshipStore = findStoreLazy("RelationshipStore"); + +const settings = definePluginSettings({ + onlyWhenInVoice: { + type: OptionType.BOOLEAN, + default: true, + description: "Only follow the user when you are in a voice channel" + }, + leaveWhenUserLeaves: { + type: OptionType.BOOLEAN, + default: false, + description: "Leave the voice channel when the user leaves. (That can cause you to sometimes enter infinite leave/join loop)" + } +}); + +const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { channel, user }: UserContextProps) => { + if (UserStore.getCurrentUser().id === user.id || !RelationshipStore.getFriendIDs().includes(user.id)) return; + + const [checked, setChecked] = React.useState(followedUserInfo?.userId === user.id); + + children.push( + , + { + if (followedUserInfo?.userId === user.id) { + followedUserInfo = null; + setChecked(false); + return; + } + + followedUserInfo = { + lastChannelId: UserStore.getCurrentUser().id, + userId: user.id + }; + setChecked(true); + }} + > + ); +}; + +export default definePlugin({ + name: "FollowVoiceUser", + description: "Follow a friend in voice chat.", + authors: [EquicordDevs.TheArmagan], + settings, + settingsAboutComponent: () => ( + + This Plugin is used to follow a Friend/Friends into voice chat(s). + + ), + flux: { + async VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { + if (!followedUserInfo) return; + if (!RelationshipStore.getFriendIDs().includes(followedUserInfo.userId)) return; + + if ( + settings.store.onlyWhenInVoice + && !VoiceStateStore.getVoiceStateForUser(UserStore.getCurrentUser().id) + ) return; + + voiceStates.forEach(voiceState => { + if ( + voiceState.userId === followedUserInfo!.userId + && voiceState.channelId + && voiceState.channelId !== followedUserInfo!.lastChannelId + ) { + followedUserInfo!.lastChannelId = voiceState.channelId; + voiceChannelAction.selectVoiceChannel(followedUserInfo!.lastChannelId); + } else if ( + voiceState.userId === followedUserInfo!.userId + && !voiceState.channelId + && settings.store.leaveWhenUserLeaves + ) { + voiceChannelAction.selectVoiceChannel(null); + } + }); + } + }, + contextMenus: { + "user-context": UserContextMenuPatch + } +}); diff --git a/src/equicordplugins/fontLoader/index.tsx b/src/equicordplugins/fontLoader/index.tsx new file mode 100644 index 000000000..f81c1d9a5 --- /dev/null +++ b/src/equicordplugins/fontLoader/index.tsx @@ -0,0 +1,219 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { definePluginSettings, migratePluginSetting } from "@api/Settings"; +import { Card } from "@components/Card"; +import { HeadingSecondary, HeadingTertiary } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { debounce } from "@shared/debounce"; +import { EquicordDevs } from "@utils/constants"; +import { Margins } from "@utils/margins"; +import { classes } from "@utils/misc"; +import definePlugin, { OptionType } from "@utils/types"; +import { React, TextInput } from "@webpack/common"; + +interface GoogleFontMetadata { + family: string; + displayName: string; + authors: string[]; + category?: number; + popularity?: number; + variants: Array<{ + axes: Array<{ + tag: string; + min: number; + max: number; + }>; + }>; +} + +const createGoogleFontUrl = (family: string, options = "") => + `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}${options}&display=swap`; + +const loadFontStyle = (url: string) => { + document.head.insertAdjacentHTML("beforeend", ``); + return document.createElement("style"); +}; + +async function searchGoogleFonts(query: string) { + try { + const response = await fetch("https://fonts.google.com/$rpc/fonts.fe.catalog.actions.metadata.MetadataService/FontSearch", { + method: "POST", + headers: { + "content-type": "application/json+protobuf", + "x-user-agent": "grpc-web-javascript/0.1" + }, + body: JSON.stringify([[query, null, null, null, null, null, 1], [5], null, 16]) + }); + + const data = await response.json(); + if (!data?.[1]) return []; + return data[1].map(([_, fontData]: [string, any[]]) => ({ + family: fontData[0], + displayName: fontData[1], + authors: fontData[2], + category: fontData[3], + variants: fontData[6].map((variant: any[]) => ({ + axes: variant[0].map(([tag, min, max]: [string, number, number]) => ({ + tag, min, max + })) + })) + })); + } catch (err) { + console.error("Failed to fetch fonts:", err); + return []; + } +} + +const preloadFont = (family: string) => + loadFontStyle(createGoogleFontUrl(family, "&text=The quick brown fox jumps over the lazy dog")); + +let styleElement: HTMLStyleElement | null = null; + +const applyFont = async (fontFamily: string) => { + if (!fontFamily) { + styleElement?.remove(); + styleElement = null; + return; + } + + try { + if (!styleElement) { + styleElement = document.createElement("style"); + document.head.appendChild(styleElement); + } + + loadFontStyle(createGoogleFontUrl(fontFamily, ":wght@300;400;500;600;700")); + styleElement.textContent = ` + * { + --font-primary: '${fontFamily}', sans-serif !important; + --font-display: '${fontFamily}', sans-serif !important; + --font-headline: '${fontFamily}', sans-serif !important; + ${settings.store.applyOnCodeBlocks ? "--font-code: '${fontFamily}', monospace !important;" : ""} + } + `; + } catch (err) { + console.error("Failed to load font:", err); + } +}; + +function GoogleFontSearch({ onSelect }: { onSelect: (font: GoogleFontMetadata) => void; }) { + const [query, setQuery] = React.useState(""); + const [results, setResults] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const previewStyles = React.useRef([]); + + React.useEffect(() => () => { + previewStyles.current.forEach(style => style.remove()); + }, []); + + const debouncedSearch = debounce(async (value: string) => { + setLoading(true); + if (!value) { + setResults([]); + setLoading(false); + return; + } + + const fonts = await searchGoogleFonts(value); + previewStyles.current.forEach(style => style.remove()); + previewStyles.current = await Promise.all(fonts.map(f => preloadFont(f.family))); + setResults(fonts); + setLoading(false); + }, 300); + + const handleSearch = (e: string) => { + setQuery(e); + debouncedSearch(e); + }; + + return ( +
+ Search Google Fonts + Click on any font to apply it. + + handleSearch(e)} + placeholder="Search fonts..." + disabled={loading} + className={Margins.bottom16} + /> + + {results.length > 0 && ( +
+ {results.map(font => ( + onSelect(font)} + > +
+ {font.displayName} + The quick brown fox jumps over the lazy dog +
+ {font.authors?.length && ( + + by {font.authors.join(", ")} + + )} +
+ ))} +
+ )} +
+ ); +} + +migratePluginSetting("FontLoader", "applyOnCodeBlocks", "applyOnClodeBlocks"); +const settings = definePluginSettings({ + selectedFont: { + type: OptionType.STRING, + description: "Currently selected font", + default: "", + hidden: true + }, + fontSearch: { + type: OptionType.COMPONENT, + description: "Search and select Google Fonts", + component: () => ( + { + settings.store.selectedFont = font.family; + applyFont(font.family); + }} + /> + ) + }, + applyOnCodeBlocks: { + type: OptionType.BOOLEAN, + description: "Apply the font to code blocks", + default: false + } +}); + +export default definePlugin({ + name: "FontLoader", + description: "Loads any font from Google Fonts", + authors: [EquicordDevs.vmohammad], + settings, + + async start() { + const savedFont = settings.store.selectedFont; + if (savedFont) { + await applyFont(savedFont); + } + }, + + stop() { + if (styleElement) { + styleElement.remove(); + styleElement = null; + } + } +}); diff --git a/src/equicordplugins/fontLoader/styles.css b/src/equicordplugins/fontLoader/styles.css new file mode 100644 index 000000000..7ca42e5ea --- /dev/null +++ b/src/equicordplugins/fontLoader/styles.css @@ -0,0 +1,23 @@ +.eq-googlefonts-results { + max-height: 400px; + overflow-y: auto; +} + +.eq-googlefonts-card { + padding: 16px; + cursor: pointer !important; + background: var(--background-secondary-alt); + border: 1px solid var(--background-base-low); +} + +.eq-googlefonts-card:hover { + background: var(--background-mod-subtle); + cursor: pointer !important; +} + +.eq-googlefonts-preview { + display: flex; + flex-direction: column; + gap: 8px; + cursor: pointer !important; +} \ No newline at end of file diff --git a/src/equicordplugins/frequentQuickSwitcher/index.tsx b/src/equicordplugins/frequentQuickSwitcher/index.tsx new file mode 100644 index 000000000..c2d61a11e --- /dev/null +++ b/src/equicordplugins/frequentQuickSwitcher/index.tsx @@ -0,0 +1,51 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { ChannelStore, UserSettingsActionCreators } from "@webpack/common"; + +function generateSearchResults(query) { + const frequentChannelsWithQuery = Object.entries(UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels) + .map(([key, value]) => key) + .filter(id => ChannelStore.getChannel(id) != null) + .filter(id => ChannelStore.getChannel(id).name.includes(query)) + .sort((id1, id2) => { + const channel1 = UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels[id1]; + const channel2 = UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels[id2]; + return channel2.totalUses - channel1.totalUses; + }) + .slice(0, 20); + + return frequentChannelsWithQuery.map(channelID => { + const channel = ChannelStore.getChannel(channelID); + return ( + { + "type": "TEXT_CHANNEL", + "record": channel, + "score": 20, + "comparator": query, + "sortable": query + } + ); + }); +} + +export default definePlugin({ + name: "FrequentQuickSwitcher", + description: "Rewrites and filters the quick switcher results to be your most frequent channels", + authors: [Devs.Samwich], + generateSearchResults: generateSearchResults, + patches: [ + { + find: "#{intl::QUICKSWITCHER_PLACEHOLDER}", + replacement: { + match: /let{selectedIndex:\i,results:\i}/, + replace: "this.props.results = $self.generateSearchResults(this.state.query);$&" + }, + } + ] +}); diff --git a/src/equicordplugins/friendTags/index.tsx b/src/equicordplugins/friendTags/index.tsx new file mode 100644 index 000000000..0f893cdb8 --- /dev/null +++ b/src/equicordplugins/friendTags/index.tsx @@ -0,0 +1,230 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "styles.css?managed"; + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { DataStore } from "@api/index"; +import { definePluginSettings } from "@api/Settings"; +import { BaseText } from "@components/BaseText"; +import { Divider } from "@components/Divider"; +import { Devs } from "@utils/constants"; +import { useForceUpdater } from "@utils/react"; +import definePlugin, { OptionType } from "@utils/types"; +import { Button, ChannelStore, Menu, RelationshipStore, TextInput, useEffect, UserStore, useState } from "@webpack/common"; + +interface UserTagData { + tagName: string; + userIds: string[]; +} + +let SavedData: UserTagData[] = []; +const tagStoreName = "vc-friendtags-tags"; + +function parseUsertags(text: string): string[] { + const matches = text.match(/&([^&]+)/g); + if (!matches) return []; + const tags = matches.map(match => match.substring(1).trim()); + return tags.filter(tag => tag !== ""); +} + +function queryFriendTags(query) { + const tags = parseUsertags(query); + if (!tags.length) return []; + + const filteredTagObjects = SavedData + .filter(data => data.tagName.length && data.userIds.length) + .filter(data => tags.some(tag => tag.toLowerCase() === data.tagName.toLowerCase())); + if (!filteredTagObjects.length) return []; + + const users = Array.from(new Set([...ChannelStore.getDMUserIds(), ...RelationshipStore.getFriendIDs()])) + .filter(user => filteredTagObjects.some(tag => tag.userIds.includes(user))); + + return users.map(user => { + const userObject: any = UserStore.getUser(user); + return { + type: "USER", + record: userObject, + score: 20, + comparator: userObject.globalName || userObject.username, + sortable: userObject.globalName || userObject.username + }; + }); +} + +async function SetData() { + const fetchData = await DataStore.get(tagStoreName); + if (SavedData !== fetchData) { + await DataStore.set(tagStoreName, JSON.stringify(SavedData)); + } + return true; +} + +async function GetData() { + const fetchData = await DataStore.get(tagStoreName); + if (!fetchData) { + DataStore.set(tagStoreName, JSON.stringify([])); + SavedData = []; + return; + } + SavedData = JSON.parse(fetchData); +} + +function TagConfigCard(props) { + const { tag } = props; + const [tagName, setTagName] = useState(tag.tagName); + const [userIds, setUserIDs] = useState(tag.userIds.join(", ")); + const update = useForceUpdater(); + + useEffect(() => { + const dataTag = SavedData.find(obj => obj.tagName === tag.tagName); + if (dataTag) { + dataTag.tagName = tagName; + } + SetData(); + update(); + }, [tagName]); + + useEffect(() => { + const dataTag = SavedData.find(obj => obj.userIds === tag.userIds); + if (dataTag) { + dataTag.userIds = userIds.split(", "); + } + SetData(); + update(); + }, [userIds]); + + return ( + <> + Name + + Users (Seperated by comma) + +
+ User List (Click A User To Remove) +
+ { + userIds.split(", ").map(user => { + const userData: any = UserStore.getUser(user); + if (!userData) return null; + return ( +
+ + setUserIDs(userIds.replace(`, ${user}`, "").replace(user, ""))}>{userData.globalName || userData.username} +
+ ); + }) + } +
+
+ + + ); +} + +function TagConfigurationComponent() { + const update = useForceUpdater(); + + return ( + <> + + { + SavedData?.map(e => ( + <> + + + + )) + } + + + ); +} + +const settings = definePluginSettings({ + tagConfiguration: { + type: OptionType.COMPONENT, + description: "The tag configuration component", + component: () => { + return ( + + ); + } + } +}); + +function UserToTagID(user, tag, remove) { + if (remove) { + SavedData.filter(e => e.tagName === tag)[0].userIds = SavedData.filter(e => e.tagName === tag)[0].userIds.filter(e => e !== user); + } + else { + SavedData.filter(e => e.tagName === tag)[0]?.userIds.push(user); + } + SetData(); +} + +const userPatch: NavContextMenuPatchCallback = (children, { user }) => { + const buttonElement = + + {SavedData.map(tag => { + const isTagged = SavedData.filter(e => e.tagName === tag.tagName)[0].userIds.includes(user.id); + + return ( + { UserToTagID(user.id, tag.tagName, isTagged); }} + /> + ); + })} + ; + + children.push({ ...buttonElement }); +}; + +export default definePlugin({ + name: "FriendTags", + description: "Allows you to filter by custom tags in the quick switcher by starting a search with &", + authors: [Devs.Samwich], + settings, + contextMenus: { + "user-context": userPatch + }, + patches: [ + { + find: "#{intl::QUICKSWITCHER_PLACEHOLDER}", + replacement: { + match: /let{selectedIndex:\i,results:\i}/, + replace: "if(this.state.query.includes(\"&\")){ this.props.results = $self.queryFriendTags(this.state.query); }$&" + }, + } + ], + async start() { + GetData(); + }, + queryFriendTags, +}); diff --git a/src/equicordplugins/friendTags/styles.css b/src/equicordplugins/friendTags/styles.css new file mode 100644 index 000000000..c214d5d9c --- /dev/null +++ b/src/equicordplugins/friendTags/styles.css @@ -0,0 +1,11 @@ +.vc-permviewer-user-header-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.vc-permviewer-user-header-btns { + display: flex; + gap: 8px; +} \ No newline at end of file diff --git a/src/equicordplugins/friendshipRanks/index.tsx b/src/equicordplugins/friendshipRanks/index.tsx new file mode 100644 index 000000000..9294c9f61 --- /dev/null +++ b/src/equicordplugins/friendshipRanks/index.tsx @@ -0,0 +1,154 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { BadgePosition, BadgeUserArgs, ProfileBadge } from "@api/Badges"; +import { Badges } from "@api/index"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Flex } from "@components/Flex"; +import { Paragraph } from "@components/Paragraph"; +import { Devs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import definePlugin from "@utils/types"; +import { Forms, RelationshipStore } from "@webpack/common"; + +interface rankInfo { + title: string; + description: string; + requirement: number; + iconSrc: string; +} + +const cl = classNameFactory("vc-friendship-ranks-"); + +function daysSince(dateString: string): number { + const date = new Date(dateString); + const currentDate = new Date(); + + const differenceInMs = currentDate.getTime() - date.getTime(); + + const days = differenceInMs / (1000 * 60 * 60 * 24); + + return Math.floor(days); +} + +const ranks: rankInfo[] = + [ + { + title: "Sprout", + description: "Your friendship is just starting", + requirement: 0, + iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/sprout.png" + }, + { + title: "Blooming", + description: "Your friendship is getting there! (1 Month)", + requirement: 30, + iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/blooming.png" + }, + { + title: "Burning", + description: "Your friendship has reached terminal velocity (3 Months)", + requirement: 90, + iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/burning.png" + }, + { + title: "Fighter", + description: "Your friendship is strong (6 Months)", + requirement: 182.5, + iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/fighter.png" + }, + { + title: "Star", + description: "Your friendship has been going on for a WHILE (1 Year)", + requirement: 365, + iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/star.png" + }, + { + title: "Royal", + description: "Your friendship has gone through thick and thin- a whole 2 years!", + requirement: 730, + iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/royal.png" + }, + { + title: "Besties", + description: "How do you even manage this??? (5 Years)", + requirement: 1826.25, + iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/besties.png" + } + ]; + +function openRankModal(rank: rankInfo) { + openModal(props => ( + + + + + + rank icon + {rank.title} + + + + +
+ + {rank.description} + +
+
+
+
+ )); +} + +function shouldShowBadge(userId: string, requirement: number, index: number) { + if (!RelationshipStore.isFriend(userId)) return false; + + const days = daysSince(RelationshipStore.getSince(userId)); + + if (ranks[index + 1] == null) return days > requirement; + + return (days > requirement && days < ranks[index + 1].requirement); +} + +function getBadgesToApply() { + const badgesToApply: ProfileBadge[] = ranks.map((rank, index) => { + return ({ + description: rank.title, + iconSrc: rank.iconSrc, + position: BadgePosition.END, + onClick: () => openRankModal(rank), + shouldShow: (info: BadgeUserArgs) => shouldShowBadge(info.userId, rank.requirement, index), + props: { + style: { + borderRadius: "50%", + transform: "scale(0.9)" + } + }, + }); + }); + + return badgesToApply; +} + +export default definePlugin({ + name: "FriendshipRanks", + description: "Adds badges showcasing how long you have been friends with a user for", + authors: [Devs.Samwich], + start() { + getBadgesToApply().forEach(b => Badges.addProfileBadge(b)); + + }, + stop() { + getBadgesToApply().forEach(b => Badges.removeProfileBadge(b)); + }, +}); diff --git a/src/equicordplugins/friendshipRanks/styles.css b/src/equicordplugins/friendshipRanks/styles.css new file mode 100644 index 000000000..b331f601e --- /dev/null +++ b/src/equicordplugins/friendshipRanks/styles.css @@ -0,0 +1,19 @@ +.vc-friendship-ranks-flex { + width: 100%; + justify-content: center; + height: 18px; +} + +.vc-friendship-ranks-img { + display: flex; + width: 100%; + text-align: center; + place-content: center center; + margin: 0; + gap: 4px; +} + +.vc-friendship-ranks-text { + padding: 1em; + text-align: center; +} \ No newline at end of file diff --git a/src/equicordplugins/ghosted/index.tsx b/src/equicordplugins/ghosted/index.tsx index 13c1b167d..845a34f6b 100644 --- a/src/equicordplugins/ghosted/index.tsx +++ b/src/equicordplugins/ghosted/index.tsx @@ -7,197 +7,179 @@ import "./styles.css"; import { findGroupChildrenByChildId } from "@api/ContextMenu"; -import { - addServerListElement, - removeServerListElement, - ServerListRenderPosition, -} from "@api/ServerList"; +import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList"; import { definePluginSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Devs, EquicordDevs } from "@utils/constants"; import { classNameFactory } from "@utils/css"; import { closeModal, openModal } from "@utils/modal"; import definePlugin, { OptionType } from "@utils/types"; -import type { Channel } from "@vencord/discord-types"; +import { Channel } from "@vencord/discord-types"; import { Menu, Tooltip, useEffect, useState } from "@webpack/common"; -import { - Boo, - clearChannelFromGhost, - getBooCount, - getGhostedChannels, - onBooCountChange, -} from "./Boo"; -import { getChannelDisplayName,GhostedUsersModal } from "./GhostedUsersModal"; +import { Boo, clearChannelFromGhost, getBooCount, getGhostedChannels, onBooCountChange } from "./Boo"; +import { getChannelDisplayName, GhostedUsersModal } from "./GhostedUsersModal"; import { IconGhost } from "./IconGhost"; export const cl = classNameFactory("vc-boo-"); export const settings = definePluginSettings({ - showIndicator: { - type: OptionType.BOOLEAN, - description: "Show the ghost counter at the top of the server list", - default: true, - restartNeeded: false, - }, - showDmIcons: { - type: OptionType.BOOLEAN, - description: "Show ghost icons next to individual DMs", - default: true, - restartNeeded: false, - }, - ignoreGroupDms: { - type: OptionType.BOOLEAN, - description: "Exclude all group dms from ghosting", - default: false, - }, - exemptedChannels: { - type: OptionType.STRING, - description: - "Comma-separated list of channel IDs to exempt from ghosting (right-click a DM channel to copy its ID)", - default: "", - restartNeeded: false, - }, - ignoreBots: { - type: OptionType.BOOLEAN, - description: "Ignore DMs from bots", - default: true, - restartNeeded: false, - }, + showIndicator: { + type: OptionType.BOOLEAN, + description: "Show the ghost counter at the top of the server list", + default: true, + restartNeeded: false + }, + showDmIcons: { + type: OptionType.BOOLEAN, + description: "Show ghost icons next to individual DMs", + default: true, + restartNeeded: false + }, + ignoreGroupDms: { + type: OptionType.BOOLEAN, + description: "Exclude all group dms from ghosting", + default: false + }, + exemptedChannels: { + type: OptionType.STRING, + description: "Comma-separated list of channel IDs to exempt from ghosting (right-click a DM channel to copy its ID)", + default: "", + restartNeeded: false + }, + ignoreBots: { + type: OptionType.BOOLEAN, + description: "Ignore DMs from bots", + default: true, + restartNeeded: false + } }); function BooIndicator() { - const [count, setCount] = useState(getBooCount()); + const [count, setCount] = useState(getBooCount()); + const [showJumpscare, setShowJumpscare] = useState(false); - useEffect(() => { - const unsubscribe = onBooCountChange(newCount => { - setCount(newCount); - }); + useEffect(() => { + const unsubscribe = onBooCountChange(newCount => { + setCount(newCount); + }); - return () => { - unsubscribe(); - }; - }, []); + return () => { + unsubscribe(); + }; + }, []); - if (!settings.store.showIndicator) return null; + if (!settings.store.showIndicator && !showJumpscare) return null; - const handleClick = () => { - const ghostedChannels = getGhostedChannels(); - const modalKey = openModal(modalProps => ( - - closeModal(modalKey)} - onClearGhost={clearChannelFromGhost} - /> - - )); - }; + const handleClick = () => { + const ghostedChannels = getGhostedChannels(); + const modalKey = openModal(modalProps => ( + + closeModal(modalKey)} + onClearGhost={clearChannelFromGhost} + /> + + )); + }; - const getTooltipText = () => { - const ghostedChannels = getGhostedChannels(); - if (ghostedChannels.length === 0) { - return "No Ghosted Users"; - } - if (ghostedChannels.length <= 5) { - return ghostedChannels.map(id => getChannelDisplayName(id)).join(", "); - } - return `${ghostedChannels.length} Ghosted Users`; - }; + const getTooltipText = () => { + const ghostedChannels = getGhostedChannels(); + if (ghostedChannels.length === 0) { + return "No Ghosted Users"; + } + if (ghostedChannels.length <= 5) { + return ghostedChannels + .map(id => getChannelDisplayName(id)) + .join(", "); + } + return `${ghostedChannels.length} Ghosted Users`; + }; - return ( - <> - {settings.store.showIndicator && getGhostedChannels().length > 0 && ( -
- - {({ onMouseEnter, onMouseLeave }) => ( -
- {count} -
- )} -
-
- )} - - ); + return ( + <> + {settings.store.showIndicator && getGhostedChannels().length > 0 && ( +
+ + {({ onMouseEnter, onMouseLeave }) => ( +
+ {count} +
+ )} +
+
+ )} + + ); } function makeContextItem(props) { - return ( - { - clearChannelFromGhost(props.channel.id); - }} - /> - ); + return { + clearChannelFromGhost(props.channel.id); + }} + />; } export default definePlugin({ - name: "Ghosted", - description: "A cute ghost will appear if you don't answer their DMs", - authors: [ - EquicordDevs.vei, - Devs.sadan, - EquicordDevs.justjxke, - EquicordDevs.iamme, - ], - settings, - dependencies: ["AudioPlayerAPI", "ServerListAPI"], - contextMenus: { - "gdm-context": (menuItems, props) => { - const group = findGroupChildrenByChildId("leave", menuItems, true); - group?.unshift(makeContextItem(props)); - }, - "user-context": (menuItems, props) => { - const group = findGroupChildrenByChildId("close-dm", menuItems); - group?.push(makeContextItem(props)); - }, - }, + name: "Ghosted", + description: "A cute ghost will appear if you don't answer their DMs", + authors: [EquicordDevs.vei, Devs.sadan, EquicordDevs.justjxke, EquicordDevs.iamme], + settings, + dependencies: ["AudioPlayerAPI", "ServerListAPI"], + contextMenus: { + "gdm-context": (menuItems, props) => { + const group = findGroupChildrenByChildId("leave", menuItems, true); + group?.unshift(makeContextItem(props)); + }, + "user-context": (menuItems, props) => { + const group = findGroupChildrenByChildId("close-dm", menuItems); + group?.push(makeContextItem(props)); + } + }, - patches: [ - { - find: "PrivateChannel.renderAvatar", - replacement: { - match: /\]:i\|\|i.{0,50}children:\[/, - replace: "$&$self.renderBoo(arguments[0]),", - }, - }, - ], + patches: [ + { + find: "PrivateChannel.renderAvatar", + replacement: { + match: /\]:\i\|\|\i.{0,50}children:\[/, + replace: "$&$self.renderBoo(arguments[0])," + } + }, + ], - renderBoo(props: { channel: Channel }) { - return ( - - - - ); - }, + renderBoo(props: { channel: Channel; }) { + return ( + + + + ); + }, - renderIndicator() { - return ( - - - - ); - }, + renderIndicator() { + return ( + + + + ); + }, - start() { - addServerListElement(ServerListRenderPosition.Above, this.renderIndicator); - }, + start() { + addServerListElement(ServerListRenderPosition.Above, this.renderIndicator); + }, - stop() { - removeServerListElement( - ServerListRenderPosition.Above, - this.renderIndicator, - ); - }, + stop() { + removeServerListElement(ServerListRenderPosition.Above, this.renderIndicator); + }, }); diff --git a/src/equicordplugins/ghosted/styles.css b/src/equicordplugins/ghosted/styles.css index a1db4c98f..bb5d05bbf 100644 --- a/src/equicordplugins/ghosted/styles.css +++ b/src/equicordplugins/ghosted/styles.css @@ -53,6 +53,93 @@ gap: 2px; } +/* Jumpscare overlay styling */ +.vc-boo-jumpscare-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgb(0 0 0 / 50%); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + cursor: pointer; +} + +.vc-boo-jumpscare-image { + max-width: 90%; + max-height: 90%; + object-fit: contain; +} + +/* job application */ +@keyframes boo-jumpscare-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes boo-jumpscare-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes boo-jumpscare-shake { + + /* stylelint-disable-next-line rule-empty-line-before */ + 0%, + 100% { + transform: translate(0, 0) rotate(0deg); + } + + 10% { + transform: translate(-5px, -5px) rotate(-2deg); + } + + 20% { + transform: translate(5px, 5px) rotate(2deg); + } + + 30% { + transform: translate(-5px, 5px) rotate(-1deg); + } + + 40% { + transform: translate(5px, -5px) rotate(1deg); + } + + 50% { + transform: translate(-3px, -3px) rotate(-1deg); + } + + 60% { + transform: translate(3px, 3px) rotate(1deg); + } + + 70% { + transform: translate(-3px, 3px) rotate(-0.5deg); + } + + 80% { + transform: translate(3px, -3px) rotate(0.5deg); + } + + 90% { + transform: translate(-2px, -2px) rotate(-0.5deg); + } +} + .vc-boo-clear { margin-right: 16px; } diff --git a/src/equicordplugins/hopOn/index.tsx b/src/equicordplugins/hopOn/index.tsx new file mode 100644 index 000000000..cd0de30ed --- /dev/null +++ b/src/equicordplugins/hopOn/index.tsx @@ -0,0 +1,48 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { Message } from "@vencord/discord-types"; +import { RelationshipStore, SelectedChannelStore } from "@webpack/common"; + +interface IMessageCreate { + type: "MESSAGE_CREATE"; + optimistic: boolean; + channelId: string; + message: Message; +} + +const settings = definePluginSettings({ + regex: { + type: OptionType.STRING, + description: "Regex to trigger on", + default: "hop on (?:fortnite|fn)" + }, + url: { + type: OptionType.STRING, + description: "URL to open", + default: "com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true" + } +}); +export default definePlugin({ + name: "HopOn", + description: "Hop on! Opens a configurable URL when a message matches a custom regex in the current channel.", + authors: [Devs.ImLvna], + settings, + flux: { + async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) { + if (optimistic || type !== "MESSAGE_CREATE") return; + if (message.state === "SENDING") return; + if (RelationshipStore.isBlocked(message.author?.id)) return; + if (channelId !== SelectedChannelStore.getChannelId()) return; + if (!message.content?.match(new RegExp(settings.store.regex, "i"))) return; + + VencordNative.native.openExternal(settings.store.url); + } + } +}); diff --git a/src/equicordplugins/husk/index.tsx b/src/equicordplugins/husk/index.tsx new file mode 100644 index 000000000..25aad8e37 --- /dev/null +++ b/src/equicordplugins/husk/index.tsx @@ -0,0 +1,122 @@ +/* eslint-disable simple-header/header */ + +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 your mom lol + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import { classes } from "@utils/misc"; +import definePlugin, { OptionType } from "@utils/types"; +import { ChannelStore, EmojiStore, RestAPI } from "@webpack/common"; +import type { SVGProps } from "react"; +// eslint-disable-next-line no-duplicate-imports +import { PropsWithChildren } from "react"; + +interface BaseIconProps extends IconProps { + viewBox: string; +} + +interface IconProps extends SVGProps { + className?: string; + height?: string | number; + width?: string | number; +} + +function Icon({ height = 24, width = 24, className, children, viewBox, ...svgProps }: PropsWithChildren) { + return ( + + {children} + + ); +} + +export function Husk(props: IconProps) { + return ( + + + + ); +} + +const settings = definePluginSettings({ + findInServer: { + description: "Attempt to find emoji of same name in server before using ID in settings (useful if no nitro)", + type: OptionType.BOOLEAN, + default: true + }, + emojiName: { + description: "Emoji name (default (from Vencord Server): husk)", + type: OptionType.STRING, + default: "husk" + }, + emojiID: { + description: "Emoji ID (default (from Vencord Server): 1026532993923293184)", + type: OptionType.BIGINT, + default: 1026532993923293184n + } +}); + +function getEmojiIdThatShouldBeUsed(guildId: string) { + if (!settings.store.findInServer || guildId === "") return settings.store.emojiID; + let id = ""; + EmojiStore.getGuildEmoji(guildId).forEach(emoji => { + if (emoji.name === settings.store.emojiName) { + id = emoji.id; + } + }); + return id !== "" ? id : settings.store.emojiID; +} + +export default definePlugin({ + name: "Husk", + description: "Adds Husk button (check settings to change used emoji)", + authors: [Devs.nin0dev], + settings, + messagePopoverButton: { + icon: Husk, + render(msg) { + return { + label: "Husk", + icon: Husk, + message: msg, + channel: ChannelStore.getChannel(msg.channel_id), + onClick: () => { + const guildId = ChannelStore.getChannel(msg.channel_id).guild_id !== null ? ChannelStore.getChannel(msg.channel_id).guild_id : ""; + RestAPI.put({ + url: `/channels/${msg.channel_id}/messages/${msg.id}/reactions/${settings.store.emojiName}:${getEmojiIdThatShouldBeUsed(guildId)}/@me` + } + ); + } + }; + }, + } +}); diff --git a/src/equicordplugins/iRememberYou/components/data.tsx b/src/equicordplugins/iRememberYou/components/data.tsx new file mode 100644 index 000000000..4d75730bb --- /dev/null +++ b/src/equicordplugins/iRememberYou/components/data.tsx @@ -0,0 +1,217 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { DataStore } from "@api/index"; +import { Guild, User } from "@vencord/discord-types"; +import { ChannelStore, GuildMemberStore, GuildStore, InviteActions, MessageStore, PermissionsBits, PermissionStore, UserStore, } from "@webpack/common"; + +export interface IUserExtra { + isOwner?: boolean; + updatedAt?: number; +} + +export interface IStorageUser { + id: string; + username: string, + tag: string, + iconURL?: string; + extra?: IUserExtra; +} + +export interface GroupData { + id: string; + users: { [key: string]: IStorageUser; }; + name: string; + inviteLink?: string; +} + +export class Data { + declare usersCollection: Record; + declare _storageAutoSaveProtocol_interval; + declare _onMessagePreSend_preSend; + + withStart() { + return this; + } + + onMessagePreSend(channelId, message, extra) { + const target: Set<{ user: User; source?: Guild, extra: IUserExtra; }> = new Set(); + const now = Date.now(); + const { replyOptions } = extra; + + const guild = (() => { + const channel = ChannelStore.getChannel(channelId); + return GuildStore.getGuild(channel.guild_id) || undefined; + })(); + + if (replyOptions.messageReference) { + const { channel_id, message_id } = replyOptions.messageReference; + const message = MessageStore.getMessage(channel_id, message_id); + if (!message) { + return; + } + const { author } = message; + + target.add({ user: author, source: guild, extra: { updatedAt: now } }); + } + + if (message.content) { + const { content } = message; + const ids = [...content.matchAll(/<@!?(?\d{17,23})>/g)].map( + ({ groups }) => groups.id + ); + + const users = ids + .map(id => UserStore.getUser(id)) + .filter(Boolean); + for (const user of users) { + target.add({ user, source: guild, extra: { updatedAt: now } }); + } + } + + this.processUsersToCollection([...target]); + } + + async processUsersToCollection( + array: { user: User; source?: Guild; extra?: IUserExtra; }[] + ) { + const target = this.usersCollection; + const processedGuilds = new Set(); + + for (const { user, source, extra } of array) { + if (user.bot) { + continue; + } + + const groupKey = source?.id ?? "dm"; + const group = (target[groupKey] ||= { + name: source?.name || "dm", + id: source?.id || user.id, + users: {}, + inviteLink: undefined + }); + const usersField = group.users; + const previouExtra = usersField[user.id]?.extra ?? {}; + const { id, username } = user; + + usersField[id] = { + id, + username, + tag: user.discriminator === "0" ? user.username : user.tag, + extra: { ...previouExtra, ...extra }, + iconURL: user.getAvatarURL(), + }; + + if (source && !processedGuilds.has(source.id) && this.hasInvitePermissions(source.id)) { + this.collectInviteLink(source.id); + processedGuilds.add(source.id); + } + } + } + + async updateStorage() { + await DataStore.set("irememberyou.data", this.usersCollection); + } + + async initializeUsersCollection() { + const data = await DataStore.get("irememberyou.data"); + this.usersCollection = data ?? {}; + } + + writeMembersFromUserGuildsToCollection() { + const target: Set<{ user: User; source?: Guild, extra: IUserExtra; }> = + new Set(); + + const now = Date.now(); + const LIMIT = 1_000; + + const clientId = UserStore.getCurrentUser().id; + if (!clientId) { + return; + } + for (const guild of Object.values(GuildStore.getGuilds())) { + const { ownerId } = guild; + if (ownerId !== clientId) { + continue; + } + + const members = GuildMemberStore.getMembers(guild.id); + if (members.length > LIMIT) { + members.length = LIMIT; + } + for (const member of members) { + const user = UserStore.getUser(member.userId); + target.add({ user, source: guild, extra: { updatedAt: now } }); + } + + this.processUsersToCollection([...target]); + + if (guild && this.hasInvitePermissions(guild.id)) { + this.collectInviteLink(guild.id); + } + } + } + + writeGuildsOwnersToCollection() { + const target: Set<{ user: User; source?: Guild; extra: IUserExtra; }> = + new Set(); + const now = Date.now(); + + for (const guild of Object.values(GuildStore.getGuilds())) { + const { ownerId } = guild; + const owner = UserStore.getUser(ownerId); + if (!owner) { + continue; + } + target.add({ + user: owner, + source: guild, + extra: { isOwner: true, updatedAt: now }, + }); + } + + this.processUsersToCollection([...target]); + } + + hasInvitePermissions(guildId: string): boolean { + const guild = GuildStore.getGuild(guildId); + if (!guild) return false; + + const currentUser = UserStore.getCurrentUser(); + if (!currentUser) return false; + + if (guild.ownerId === currentUser.id) return true; + + const member = GuildMemberStore.getMember(guildId, currentUser.id); + if (!member) return false; + + return PermissionStore.can(PermissionsBits.CREATE_INSTANT_INVITE, guild); + } + + async collectInviteLink(guildId: string) { + try { + const invites = await InviteActions.getInvites(guildId); + if (invites && invites.length > 0) { + const invite = invites[0]; + const inviteCode = invite.code; + const inviteLink = `https://discord.gg/${inviteCode}`; + + if (this.usersCollection[guildId]) { + this.usersCollection[guildId].inviteLink = inviteLink; + } + } + } catch (error) { + console.warn("Failed to collect invite link for guild:", guildId, error); + } + } + + storageAutoSaveProtocol() { + this._storageAutoSaveProtocol_interval = setInterval( + this.updateStorage.bind(this), + 60_000 * 3 + ); + } +} diff --git a/src/equicordplugins/iRememberYou/components/ui.tsx b/src/equicordplugins/iRememberYou/components/ui.tsx new file mode 100644 index 000000000..ba79e6da6 --- /dev/null +++ b/src/equicordplugins/iRememberYou/components/ui.tsx @@ -0,0 +1,141 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Card } from "@components/Card"; +import { HeadingPrimary } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { SettingsTab, wrapTab } from "@components/settings"; +import { classNameFactory } from "@utils/css"; +import { copyWithToast, openUserProfile } from "@utils/discord"; +import { Avatar, Clickable, ContextMenuApi, Menu, React, TextInput, Tooltip, UserStore } from "@webpack/common"; + +import { Data, IStorageUser } from "./data"; + +const cl = classNameFactory("vc-i-remember-you-"); + +function tooltipText(user: IStorageUser) { + const { updatedAt } = user.extra || {}; + const updatedAtContent = updatedAt ? new Intl.DateTimeFormat().format(updatedAt) : null; + return `${user.username ?? user.tag}, updated at ${updatedAtContent}`; +} + +function UsersCollectionRows({ usersCollection }: { usersCollection: Data["usersCollection"]; }) { + if (Object.keys(usersCollection).length === 0) return ( + + It's empty right now + + ); + + return ( + <> + {Object.entries(usersCollection) + .map(([_key, { users, name }]) => ({ name, users: Object.values(users) })) + .sort((a, b) => b.users.length - a.users.length) + .map(({ name, users }) => ( + + ))} + + ); +} + +function UserRow({ user, allowOwner = true }: { user: IStorageUser, allowOwner?: boolean; }) { + return ( +
+
+ openUserProfile(user.id)}> + + + + +
+ + {props => + + {user.tag} {allowOwner && user.extra?.isOwner && "(owner)"} + + } + + { + e.preventDefault(); + const userObj = UserStore.getUser(user.id); + if (userObj) { + ContextMenuApi.openContextMenu(e, () => ( + + copyWithToast(user.id, "User ID copied to clipboard")} + /> + openUserProfile(user.id)} + /> + + )); + } + }} + > + copyWithToast(user.id, "User ID copied to clipboard")}> + + {user.id} + + + +
+
+
+ ); +} + +function SearchElement({ usersCollection }: { usersCollection: Data["usersCollection"]; }) { + const [current, setCurrent] = React.useState(""); + const list = Object.values(usersCollection).flatMap(col => Object.values(col.users)) as IStorageUser[]; + + return ( +
+ + {current && ( +
+ {list.filter(user => user.tag.includes(current) || user.username.includes(current)) + .map(user => )} +
+ )} +
+ ); +} + +export function DataUI({ usersCollection }: { usersCollection: Data["usersCollection"]; }) { + return ( + + + + Provides a list of users you have mentioned or replied to, or those who own the servers you belong to (owner*), or are members of your guild + + + +
+ +
+
+ ); +} + +export default wrapTab(DataUI, "IRememberYouTab"); diff --git a/src/equicordplugins/iRememberYou/index.tsx b/src/equicordplugins/iRememberYou/index.tsx new file mode 100644 index 000000000..8b4614331 --- /dev/null +++ b/src/equicordplugins/iRememberYou/index.tsx @@ -0,0 +1,55 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents"; +import { EyeIcon } from "@components/Icons"; +import SettingsPlugin from "@plugins/_core/settings"; +import { EquicordDevs } from "@utils/constants"; +import { removeFromArray } from "@utils/misc"; +import definePlugin from "@utils/types"; + +import { Data } from "./components/data"; +import DataUI from "./components/ui"; + +export default definePlugin({ + name: "IRememberYou", + description: "Locally saves everyone you've been communicating with (including servers), in case of lose", + authors: [EquicordDevs.zoodogood, EquicordDevs.keircn], + dependencies: ["MessageEventsAPI"], + + patches: [], + + async start() { + SettingsPlugin.customEntries.push({ + key: "equicord_i_remember_you", + title: "I Remember You", + Component: () => , + Icon: EyeIcon + }); + SettingsPlugin.settingsSectionMap.push(["EquicordIRememberYou", "equicord_i_remember_you"]); + + const data = (this.dataManager = await new Data().withStart()); + + await data.initializeUsersCollection(); + data.writeGuildsOwnersToCollection(); + data.writeMembersFromUserGuildsToCollection(); + data._onMessagePreSend_preSend = addMessagePreSendListener( + data.onMessagePreSend.bind(data) + ); + data.storageAutoSaveProtocol(); + }, + + stop() { + removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_i_remember_you"); + removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_i_remember_you"); + + const dataManager = this.dataManager as Data; + removeMessagePreSendListener(dataManager._onMessagePreSend_preSend); + clearInterval(dataManager._storageAutoSaveProtocol_interval); + }, +}); diff --git a/src/equicordplugins/iRememberYou/styles.css b/src/equicordplugins/iRememberYou/styles.css new file mode 100644 index 000000000..3a0b283ba --- /dev/null +++ b/src/equicordplugins/iRememberYou/styles.css @@ -0,0 +1,78 @@ +.vc-i-remember-you-header-container { + display: contents; + justify-content: space-between; + align-items: center; + flex-direction: column; +} + +.vc-i-remember-you-header-name { + margin-top: 16px; +} + +.vc-i-remember-you-header-btns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 350px)); + gap: 10px; + margin-top: 8px; +} + +.vc-i-remember-you-header { + color: var(--text-default); +} + +.vc-i-remember-you-rows { + gap: 5px; + flex-direction: column; +} + +.vc-i-remember-you-search { + margin-top: 10px; +} + +.vc-i-remember-you-search-user { + flex-direction: column; + padding-top: 18px; +} + +.vc-i-remember-you-user-avatar { + cursor: pointer; + margin-right: 0; +} + +.vc-i-remember-you-user-row { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px; + align-items: flex-start; +} + +.vc-i-remember-you-user-id { + align-self: flex-start; + opacity: 0.75; + height: min-content; + cursor: pointer; +} + +.vc-i-remember-you-user-id:hover { + opacity: 1; +} + +.vc-i-remember-you-user { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; + margin-top: 0; +} + +.vc-i-remember-you-user-username { + margin-top: 2px; +} + +.vc-i-remember-you-user-tooltip { + display: flex; + flex-direction: column; + align-items: flex-start; +} \ No newline at end of file diff --git a/src/equicordplugins/idleAutoRestart/index.tsx b/src/equicordplugins/idleAutoRestart/index.tsx new file mode 100644 index 000000000..2112f671f --- /dev/null +++ b/src/equicordplugins/idleAutoRestart/index.tsx @@ -0,0 +1,85 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; +import definePlugin, { OptionType } from "@utils/types"; +import { Menu, VoiceStateStore } from "@webpack/common"; + +const logger = new Logger("IdleAutoRestart"); +let lastActivity = 0; +let intervalId: ReturnType | null = null; + +const settings = definePluginSettings({ + isEnabled: { + description: "Enable automatic restart after idle", + type: OptionType.BOOLEAN, + default: true, + }, + idleMinutes: { + description: "Minutes of inactivity before restarting (when not in VC)", + type: OptionType.SLIDER, + markers: [5, 10, 15, 30, 60, 120], + default: 30, + stickToMarkers: false, + }, +}); + +function onActivity() { + lastActivity = Date.now(); +} + +export default definePlugin({ + name: "IdleAutoRestart", + description: "Automatically restarts the client after being idle for a configurable amount of time, but avoids restarting while you are in VC.", + authors: [EquicordDevs.SteelTech], + settings, + + toolboxActions() { + return ( + { + settings.store.isEnabled = !settings.store.isEnabled; + }} + /> + ); + }, + + start() { + lastActivity = Date.now(); + + document.addEventListener("mousemove", onActivity); + document.addEventListener("keydown", onActivity); + document.addEventListener("mousedown", onActivity); + document.addEventListener("wheel", onActivity, { passive: true }); + + if (intervalId) clearInterval(intervalId); + intervalId = setInterval(() => { + if (!settings.store.isEnabled || VoiceStateStore.isCurrentClientInVoiceChannel()) return; + + const idleMs = settings.store.idleMinutes * 60_000; + if (Date.now() - lastActivity >= idleMs) { + logger.info("Idle timeout reached, reloading client"); + location.reload(); + } + }, 30_000); + }, + + stop() { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + + document.removeEventListener("mousemove", onActivity); + document.removeEventListener("keydown", onActivity); + document.removeEventListener("mousedown", onActivity); + document.removeEventListener("wheel", onActivity); + }, +}); diff --git a/src/equicordplugins/inRole/RoleMembersModal.tsx b/src/equicordplugins/inRole/RoleMembersModal.tsx new file mode 100644 index 000000000..6483b105b --- /dev/null +++ b/src/equicordplugins/inRole/RoleMembersModal.tsx @@ -0,0 +1,44 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import ErrorBoundary from "@components/ErrorBoundary"; +import { Paragraph } from "@components/Paragraph"; +import { classNameFactory } from "@utils/css"; +import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { GuildMember } from "@vencord/discord-types"; +import { Parser } from "@webpack/common"; + +const cl = classNameFactory("vc-inrole-"); + +export function showInRoleModal(members: GuildMember[], roleId: string, channelId: string) { + openModal(props => + <> + + + + Members of role { + Parser.parse(`<@&${roleId}>`, true, { channelId, viewingChannelId: channelId }) + } ({members.length}) + + + +
+ { + members.length !== 0 ? members.map(member => + <> + + {Parser.parse(`<@${member.userId}>`, true, { channelId, viewingChannelId: channelId })} + + + ) : Looks like no online cached members with that role were found. Try scrolling down on your member list to cache more users! + } +
+
+
+
+ + ); +} diff --git a/src/equicordplugins/inRole/index.tsx b/src/equicordplugins/inRole/index.tsx new file mode 100644 index 000000000..755f9219c --- /dev/null +++ b/src/equicordplugins/inRole/index.tsx @@ -0,0 +1,127 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 nin0dev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./style.css"; + +import { ApplicationCommandInputType, ApplicationCommandOptionType, sendBotMessage } from "@api/Commands"; +import { getUserSettingLazy } from "@api/UserSettings"; +import { InfoIcon } from "@components/Icons"; +import { Paragraph } from "@components/Paragraph"; +import { Devs } from "@utils/constants"; +import { getCurrentChannel, getCurrentGuild } from "@utils/discord"; +import definePlugin from "@utils/types"; +import { GuildMember } from "@vencord/discord-types"; +import { GuildMemberStore, GuildRoleStore, Menu, Parser } from "@webpack/common"; + +import { showInRoleModal } from "./RoleMembersModal"; + +const DeveloperMode = getUserSettingLazy("appearance", "developerMode")!; + +function getMembersInRole(roleId: string, guildId: string) { + const members = GuildMemberStore.getMembers(guildId); + const membersInRole: GuildMember[] = []; + members.forEach(member => { + if (member.roles.includes(roleId)) { + membersInRole.push(member); + } + }); + return membersInRole; +} + +export default definePlugin({ + name: "InRole", + description: "Know who is in a role with the role context menu or /inrole command (read plugin info!)", + authors: [Devs.nin0dev], + dependencies: ["UserSettingsAPI"], + start() { + // DeveloperMode needs to be enabled for the context menu to be shown + DeveloperMode.updateSetting(true); + }, + settingsAboutComponent: () => { + return ( + <> + {Parser.parse(":warning:")} Limitations + If you don't have mod permissions on the server, and that server is large (over 100 members), the plugin may be limited in the following ways: + • Offline members won't be listed + • Up to 100 members will be listed by default. To get more, scroll down in the member list to load more members. + • However, friends will always be shown regardless of their status. + + ); + }, + + commands: [ + { + name: "inrole", + description: "Know who is in a role", + inputType: ApplicationCommandInputType.BUILT_IN, + options: [ + { + name: "role", + description: "The role", + type: ApplicationCommandOptionType.ROLE, + required: true + }, + ], + execute: (args, ctx) => { + // Guild check + if (!ctx.guild) { + return sendBotMessage(ctx.channel.id, { content: "Make sure that you are in a server." }); + } + const role = args[0].value; + showInRoleModal(getMembersInRole(role, ctx.guild.id), role, ctx.channel.id); + } + } + ], + contextMenus: { + "dev-context"(children, { id }: { id: string; }) { + const guild = getCurrentGuild(); + if (!guild) return; + + const channel = getCurrentChannel(); + if (!channel) return; + + const role = GuildRoleStore.getRole(guild.id, id); + if (!role) return; + + children.push( + { + showInRoleModal(getMembersInRole(role.id, guild.id), role.id, channel.id); + }} + icon={InfoIcon} + /> + ); + }, + "message"(children, { message }: { message: any; }) { + const guild = getCurrentGuild(); + if (!guild) return; + + const roleMentions = message.content.match(/<@&(\d+)>/g); + if (!roleMentions?.length) return; + + const channel = getCurrentChannel(); + if (!channel) return; + + const roleIds = roleMentions.map(mention => mention.match(/<@&(\d+)>/)![1]); + + const role = GuildRoleStore.getRole(guild.id, roleIds); + if (!role) return; + + children.push( + { + showInRoleModal(getMembersInRole(role.id, guild.id), role.id, channel.id); + }} + icon={InfoIcon} + /> + ); + } + } +}); diff --git a/src/equicordplugins/inRole/style.css b/src/equicordplugins/inRole/style.css new file mode 100644 index 000000000..c7d398a0c --- /dev/null +++ b/src/equicordplugins/inRole/style.css @@ -0,0 +1,29 @@ +.vc-inrole-member-list { + max-height: 400px; + margin-top: 10px; + margin-bottom: 13px; + overflow-x: hidden; +} + +.vc-inrole-member-list::-webkit-scrollbar { + background-color: #fff1; + border-radius: 100px; + width: 10px; +} + +.vc-inrole-member-list::-webkit-scrollbar-thumb { + background-color: #fff3; + border-radius: 100px; +} + +.vc-inrole-modal-member { + margin: 11px 0; +} + +.vc-inrole-header { + padding-top: 15px; +} + +.vc-inrole-close { + margin-left: auto; +} diff --git a/src/equicordplugins/ingtoninator/index.tsx b/src/equicordplugins/ingtoninator/index.tsx new file mode 100644 index 000000000..43013fc4f --- /dev/null +++ b/src/equicordplugins/ingtoninator/index.tsx @@ -0,0 +1,161 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; +import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents"; +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { React } from "@webpack/common"; + +type WordMatch = { + word: string, + startIndex: number, +}; + +const settings = definePluginSettings({ + showIcon: { + type: OptionType.BOOLEAN, + default: true, + description: "Show a button to toggle the Ingtoninator plugin", + restartNeeded: true + }, + isEnabled: { + type: OptionType.BOOLEAN, + default: true, + description: "Enable or disable the Ingtoninator" + } +}); + +const isLegal = (word: string): boolean => { + if (word === "i" || word === "I") return false; + + const lastChar = word.slice(-1).toLowerCase(); + if (["a", "e", "o", "u", "y"].includes(lastChar)) return false; + + return true; +}; + +const getWords = (string: string): WordMatch[] => { + const linkRegex = /https?:\/\/[^\s]+\.[^\s]+/g; + const linkRanges = new Set(); + + for (const match of string.matchAll(linkRegex)) { + const start = match.index!; + const end = start + match[0].length; + for (let i = start; i < end; i++) { + linkRanges.add(i); + } + } + + return Array.from(string.matchAll(/[\p{L}]+/gu), match => ({ + word: match[0], + startIndex: match.index! + })).filter(match => !linkRanges.has(match.startIndex)); +}; + +const chooseRandomWord = (message: string): WordMatch | null => { + const words: WordMatch[] = getWords(message); + + while (words.length > 0) { + const index: number = Math.floor(Math.random() * words.length); + const wordMatch: WordMatch = words[index]; + + if (!isLegal(wordMatch.word)) { + words.splice(index, 1); + continue; + } + + return wordMatch; + } + + return null; +}; + +const ington = (word: string): string => { + if (word.endsWith("INGTON")) return ""; + if (word.endsWith("INGTO")) return "N"; + if (word.endsWith("INGT")) return "ON"; + if (word.endsWith("ING")) return "TON"; + if (word.endsWith("IN")) return "GTON"; + if (word.endsWith("I")) return "NGTON"; + return "INGTON"; +}; + +const handleMessage = ((channelId, message) => { + if (!settings.store.isEnabled) return; + + const msg = message.content; + if (!msg || !msg.trim()) return; + + const wordMatch: WordMatch | null = chooseRandomWord(msg); + if (wordMatch === null) return; + + const { word } = wordMatch; + const wordUpper = word.toUpperCase(); + const isUpper = word === wordUpper; + + let insertion = ington(wordUpper); + if (!isUpper) { + insertion = insertion.toLowerCase(); + } + + const idx: number = wordMatch.startIndex + word.length; + message.content = msg.slice(0, idx) + insertion + msg.slice(idx); +}); + +const IngtoninatorButton: ChatBarButtonFactory = ({ isMainChat }) => { + const { isEnabled, showIcon } = settings.use(["isEnabled", "showIcon"]); + const toggle = () => settings.store.isEnabled = !settings.store.isEnabled; + + if (!isMainChat || !showIcon) return null; + + return ( + + {isEnabled ? ( + enabledIcon() + ) : ( + disabledIcon() + )} + + ); +}; + +function enabledIcon() { + return ( + + + + ); +} + +function disabledIcon() { + return ( + + + + ); +} + +export default definePlugin({ + name: "Ingtoninator", + description: "Suffixes 'ington' to a random word in your message", + authors: [EquicordDevs.zyqunix, EquicordDevs.BioTomateDE], + settings, + chatBarButton: { + icon: disabledIcon, + render: IngtoninatorButton + }, + start() { + addMessagePreSendListener(handleMessage); + }, + stop() { + removeMessagePreSendListener(handleMessage); + } +}); diff --git a/src/equicordplugins/invisibleChat.desktop/components/DecryptionModal.tsx b/src/equicordplugins/invisibleChat.desktop/components/DecryptionModal.tsx new file mode 100644 index 000000000..b4946e28f --- /dev/null +++ b/src/equicordplugins/invisibleChat.desktop/components/DecryptionModal.tsx @@ -0,0 +1,77 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { Heading, HeadingTertiary } from "@components/Heading"; +import { + ModalContent, + ModalFooter, + ModalHeader, + ModalRoot, + openModal, +} from "@utils/modal"; +import { Button, React, TextInput } from "@webpack/common"; + +import { buildEmbed, decrypt } from "../index"; + +export function DecModal(props: any) { + const encryptedMessage: string = props?.message?.content; + const [password, setPassword] = React.useState("password"); + + return ( + + + Decrypt Message + + + + Message with Encryption + + Password + + + + + + + + + ); +} + +export function buildDecModal(msg: any): any { + openModal((props: any) => ); +} diff --git a/src/equicordplugins/invisibleChat.desktop/components/EncryptionModal.tsx b/src/equicordplugins/invisibleChat.desktop/components/EncryptionModal.tsx new file mode 100644 index 000000000..04a225d5c --- /dev/null +++ b/src/equicordplugins/invisibleChat.desktop/components/EncryptionModal.tsx @@ -0,0 +1,113 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { FormSwitch } from "@components/FormSwitch"; +import { Heading, HeadingTertiary } from "@components/Heading"; +import { insertTextIntoChatInputBox } from "@utils/discord"; +import { + ModalContent, + ModalFooter, + ModalHeader, + ModalProps, + ModalRoot, + openModal, +} from "@utils/modal"; +import { Button, React, TextInput } from "@webpack/common"; + +import { encrypt } from "../index"; + +function EncModal(props: ModalProps) { + const [secret, setSecret] = React.useState(""); + const [cover, setCover] = React.useState(""); + const [password, setPassword] = React.useState("password"); + const [noCover, setNoCover] = React.useState(false); + + const isValid = secret && (noCover || (cover && cover.trim().split(" ").length > 1)); + + return ( + + + Encrypt Message + + + + Secret + { + setSecret(e); + }} + /> + Cover (2 or more Words!!) + { + setCover(e); + }} + /> + Password + { + setPassword(e); + }} + /> + { + setNoCover(e); + }} + /> + + + + + + + + ); +} + +export function buildEncModal(): any { + openModal(props => ); +} diff --git a/src/equicordplugins/invisibleChat.desktop/index.tsx b/src/equicordplugins/invisibleChat.desktop/index.tsx new file mode 100644 index 000000000..5589f8dec --- /dev/null +++ b/src/equicordplugins/invisibleChat.desktop/index.tsx @@ -0,0 +1,234 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; +import { updateMessage } from "@api/MessageUpdater"; +import { definePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import { getStegCloak } from "@utils/dependencies"; +import definePlugin, { OptionType, ReporterTestable } from "@utils/types"; +import { Message } from "@vencord/discord-types"; +import { ChannelStore, Constants, RestAPI, Tooltip } from "@webpack/common"; + +import { buildDecModal } from "./components/DecryptionModal"; +import { buildEncModal } from "./components/EncryptionModal"; + +let steggo: any; + +function PopOverIcon() { + return ( + + + + + ); +} + +function Indicator() { + return ( + + {({ onMouseEnter, onMouseLeave }) => ( + + )} + + + ); + +} + +const ChatBarRender: ChatBarButtonFactory = ({ isMainChat }) => { + if (!isMainChat) return null; + + return ( + buildEncModal()} + buttonProps={{ + "aria-haspopup": "dialog", + }} + > + {ChatBarIcon()} + + ); +}; + +function ChatBarIcon() { + return ( + + + + ); +} + +const settings = definePluginSettings({ + savedPasswords: { + type: OptionType.STRING, + default: "password, Password", + description: "Saved Passwords (Seperated with a , )" + } +}); + +const EMBED_API_URL = "https://embed.sammcheese.net"; +const INV_REGEX = new RegExp(/( \u200c|\u200d |[\u2060-\u2064])[^\u200b]/); +const URL_REGEX = new RegExp(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/); +function colorCodeFromNumber(color: number): string { + return `#${[color >> 16, color >> 8, color] + .map(x => (x & 0xFF).toString(16)) + .join("")}`; +} + +async function getEmbed(url: URL): Promise { + const { body } = await RestAPI.post({ + url: Constants.Endpoints.UNFURL_EMBED_URLS, + body: { + urls: [url] + } + }); + // The endpoint returns the color as a number, but Discord expects a string + body.embeds[0].color = colorCodeFromNumber(body.embeds[0].color); + return await body.embeds[0]; +} + +export async function buildEmbed(message: any, revealed: string): Promise { + const urlCheck = revealed.match(URL_REGEX); + + message.embeds.push({ + type: "rich", + rawTitle: "Decrypted Message", + color: "#45f5f5", + rawDescription: revealed, + footer: { + text: "Made with ❤️ by c0dine and Sammy!", + }, + }); + + if (urlCheck?.length) { + const embed = await getEmbed(new URL(urlCheck[0])); + if (embed) + message.embeds.push(embed); + } + + updateMessage(message.channel_id, message.id, { embeds: message.embeds }); +} + +export default definePlugin({ + name: "InvisibleChat", + description: "Encrypt your Messages in a non-suspicious way!", + authors: [Devs.SammCheese], + dependencies: ["MessageUpdaterAPI"], + reporterTestable: ReporterTestable.Patches, + settings, + + chatBarButton: { + icon: ChatBarIcon, + render: ChatBarRender + }, + + patches: [ + { + // Indicator + find: ".SEND_FAILED,", + replacement: { + match: /let\{className:\i,message:\i[^}]*\}=(\i)/, + replace: "try {$1 && $self.INV_REGEX.test($1.message.content) ? $1.content.push($self.indicator()) : null } catch {};$&" + } + }, + ], + INV_REGEX, + + async start() { + const { default: StegCloak } = await getStegCloak(); + steggo = new StegCloak(true, false); + }, + messagePopoverButton: { + icon: PopOverIcon, + render(message) { + return INV_REGEX.test(message?.content) + ? { + label: "Decrypt Message", + icon: PopOverIcon, + message: message, + channel: ChannelStore.getChannel(message.channel_id), + onClick: async () => { + const res = await iteratePasswords(message); + + if (res) + buildEmbed(message, res); + else + buildDecModal({ message }); + } + } + : null; + } + }, + + indicator: ErrorBoundary.wrap(Indicator, { noop: true }) +}); + +export function encrypt(secret: string, password: string, cover: string): string { + return steggo.hide(secret + "\u200b", password, cover); +} + +export function decrypt(encrypted: string, password: string, removeIndicator: boolean): string { + const decrypted = steggo.reveal(encrypted, password); + return removeIndicator ? decrypted.replace("\u200b", "") : decrypted; +} + +export function isCorrectPassword(result: string): boolean { + return result.endsWith("\u200b"); +} + +export async function iteratePasswords(message: Message): Promise { + const passwords = settings.store.savedPasswords.split(",").map(s => s.trim()); + + if (!message?.content || !passwords?.length) return false; + + let { content } = message; + + // we use an extra variable so we dont have to edit the message content directly + if (/^\W/.test(message.content)) content = `d ${message.content}d`; + + for (let i = 0; i < passwords.length; i++) { + const result = decrypt(content, passwords[i], false); + if (isCorrectPassword(result)) { + return result; + } + } + + return false; +} diff --git a/src/equicordplugins/jumpscare/index.tsx b/src/equicordplugins/jumpscare/index.tsx new file mode 100644 index 000000000..5a8e4d531 --- /dev/null +++ b/src/equicordplugins/jumpscare/index.tsx @@ -0,0 +1,90 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { createAudioPlayer } from "@api/AudioPlayer"; +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { createRoot, FluxDispatcher, useCallback, useEffect, useState } from "@webpack/common"; +import { Root } from "react-dom/client"; + +let jumpscareRoot: Root | undefined; + +const settings = definePluginSettings({ + imageSource: { + type: OptionType.STRING, + description: "Sets the image url of the jumpscare", + default: "https://github.com/Equicord/Equibored/blob/main/icons/jumpscare/troll.gif?raw=true" + }, + audioSource: { + type: OptionType.STRING, + description: "Sets the audio url of the jumpscare", + default: "https://github.com/Equicord/Equibored/raw/main/sounds/jumpscare/trollolol.mp3?raw=true" + }, + chance: { + type: OptionType.NUMBER, + description: "The chance of a jumpscare happening (1 in X so: 100 = 1/100 or 1%, 50 = 1/50 or 2%, etc.)", + default: 1000 + } +}); + +function getJumpscareRoot(): Root { + if (!jumpscareRoot) { + const element = document.createElement("div"); + element.id = "jumpscare-root"; + element.classList.add("jumpscare-root"); + document.body.append(element); + jumpscareRoot = createRoot(element); + } + + return jumpscareRoot; +} + +export default definePlugin({ + name: "Jumpscare", + description: "Adds a configurable chance of jumpscaring you whenever you open a channel. Inspired by Geometry Dash Mega Hack", + authors: [Devs.surgedevs], + dependencies: ["AudioPlayerAPI"], + settings, + + start() { + getJumpscareRoot().render( + + ); + }, + + stop() { + jumpscareRoot?.unmount(); + jumpscareRoot = undefined; + }, + + JumpscareComponent() { + const [isPlaying, setIsPlaying] = useState(false); + const jumpscareAudio = createAudioPlayer(settings.store.audioSource, { volume: 100, onEnded: () => { setIsPlaying(false); } }); + + const jumpscare = useCallback(event => { + if (isPlaying) return; + + const chance = 1 / settings.store.chance; + if (Math.random() > chance) return; + + setIsPlaying(true); + jumpscareAudio.play(); + }, [isPlaying]); + + useEffect(() => { + FluxDispatcher.subscribe("CHANNEL_SELECT", jumpscare); + + return () => { + FluxDispatcher.unsubscribe("CHANNEL_SELECT", jumpscare); + }; + }, [jumpscare]); + + return ; + } +}); diff --git a/src/equicordplugins/jumpscare/styles.css b/src/equicordplugins/jumpscare/styles.css new file mode 100644 index 000000000..3bfd50c3c --- /dev/null +++ b/src/equicordplugins/jumpscare/styles.css @@ -0,0 +1,33 @@ +.jumpscare-root { + pointer-events: none; +} + +.jumpscare-img { + position: absolute; + width: 100%; + height: 100%; + background-color: #000; + z-index: 99999; + object-fit: contain; + opacity: 0; +} + +.jumpscare-animate { + animation: jumpscare-animation 0.7s; +} + +@keyframes jumpscare-animation { + 0% { + transform: scale(0); + } + + 80% { + transform: scale(1); + opacity: 1; + } + + 100% { + transform: scale(0); + opacity: 0; + } +} diff --git a/src/equicordplugins/lastActive/index.tsx b/src/equicordplugins/lastActive/index.tsx new file mode 100644 index 000000000..9e8333a5e --- /dev/null +++ b/src/equicordplugins/lastActive/index.tsx @@ -0,0 +1,144 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { Menu, NavigationRouter, RestAPI, Toasts, UserStore } from "@webpack/common"; + +async function findLastMessageFromUser(guildId: string, channelId: string, userId: string) { + try { + const res = await RestAPI.get({ + url: `/guilds/${guildId}/messages/search?author_id=${userId}&channel_id=${channelId}&sort_by=timestamp&sort_order=desc&offset=0` + }); + + const allMessages = res.body.messages?.flat() || []; + const newestMessage = allMessages.find(msg => msg && msg.id); + + if (newestMessage) return newestMessage.id; + + Toasts.show({ + type: Toasts.Type.FAILURE, + message: "Couldn't find any recent messages from this user.", + id: Toasts.genId() + }); + return null; + } catch (error) { + console.error("Error finding last message:", error); + Toasts.show({ + type: Toasts.Type.FAILURE, + message: "Failed to find messages. Check console for details.", + id: Toasts.genId() + }); + return null; + } +} + +async function jumpToLastActive(channel: any, targetUserId?: string) { + try { + if (!channel) { + Toasts.show({ + type: Toasts.Type.FAILURE, + message: "Channel information not available.", + id: Toasts.genId() + }); + return; + } + const guildId = channel.guild_id !== null ? channel.guild_id : "@me"; + const channelId = channel.id; + let userId: string; + if (targetUserId) { + + userId = targetUserId; + } else { + const currentUser = UserStore.getCurrentUser(); + userId = currentUser.id; + } + const messageId = await findLastMessageFromUser(guildId, channelId, userId); + if (messageId) { + const url = `/channels/${guildId}/${channelId}/${messageId}`; + NavigationRouter.transitionTo(url); + } + } catch (error) { + console.error("Error in jumpToLastActive:", error); + Toasts.show({ + type: Toasts.Type.FAILURE, + message: "Failed to jump to message. Check console for details.", + id: Toasts.genId() + }); + } +} +const ChannelContextMenuPatch: NavContextMenuPatchCallback = (children, { channel }) => { + children.push( + Your Last Message} + icon={LastActiveIcon} + action={() => { + jumpToLastActive(channel); + }} + /> + ); +}; +const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user, channel }) => { + if (!channel || !user?.id) return; + + children.push( + User's Last Message} + icon={UserLastActiveIcon} + action={() => { + jumpToLastActive(channel, user.id); + }} + /> + ); +}; + +export function UserLastActiveIcon() { + return ( + + + + + + + ); +} + +export function LastActiveIcon() { + return ( + + + + ); +} + +export default definePlugin({ + name: "LastActive", + description: "A plugin to jump to last active message from yourself or another user in a channel/server.", + authors: [EquicordDevs.Crxa], + contextMenus: { + "channel-context": ChannelContextMenuPatch, + "user-context": UserContextMenuPatch, + "thread-context": ChannelContextMenuPatch + } +}); diff --git a/src/equicordplugins/messageBurst/index.ts b/src/equicordplugins/messageBurst/index.ts new file mode 100644 index 000000000..0d60216c8 --- /dev/null +++ b/src/equicordplugins/messageBurst/index.ts @@ -0,0 +1,96 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { Channel, Message } from "@vencord/discord-types"; +import { ChannelStore, MessageActions, MessageStore, UserStore } from "@webpack/common"; + +function shouldEdit(channel: Channel, message: Message, timePeriod: number, shouldMergeWithAttachment: boolean) { + let should = true; + + if (channel.isGroupDM()) { + if (channel.name === message.content) { + should = false; + } + } + + if (message.author.id !== UserStore.getCurrentUser().id) { + should = false; + } + + if (document.querySelector('[class*="replyBar"]')) { + should = false; + } + + if (message.attachments.length > 0 && !shouldMergeWithAttachment) { + should = false; + } + + // @ts-ignore + const timestamp = new Date(message.timestamp); + const now = new Date(); + + if ((now.getTime() - timestamp.getTime()) > (timePeriod * 1000)) { + should = false; + } + + return { + should: should, + content: message.content + }; +} + +const settings = definePluginSettings({ + timePeriod: { + type: OptionType.NUMBER, + description: "The duration of bursts (in seconds).", + default: 3 + }, + shouldMergeWithAttachment: { + type: OptionType.BOOLEAN, + description: "Should the message be merged if the last message has an attachment?", + default: false + }, + useSpace: { + type: OptionType.BOOLEAN, + description: "Whether to add a space between messages when merging instead of new lines.", + default: false + } +}); + +export default definePlugin({ + name: "MessageBurst", + description: "Merges messages sent within a time period with your previous sent message if no one else sends a message before you.", + authors: [EquicordDevs.port22exposed], + settings, + onBeforeMessageSend(channelId, message) { + const messages = MessageStore.getMessages(channelId)._map; + + if (!messages) { + return; + } + + const entries = Object.entries(messages); + const [lastMessageId, lastMessage] = entries[entries.length - 1]; + + const channel = ChannelStore.getChannel(channelId); + + const { should, content } = shouldEdit(channel, lastMessage as Message, this.settings.store.timePeriod, this.settings.store.shouldMergeWithAttachment); + + if (should) { + const separator = settings.store.useSpace ? " " : "\n"; + const newContent = content + separator + message.content; + + MessageActions.editMessage(channelId, lastMessageId, { + content: newContent, + }); + + message.content = ""; + } + }, +}); diff --git a/src/equicordplugins/messageFetchTimer/index.tsx b/src/equicordplugins/messageFetchTimer/index.tsx new file mode 100644 index 000000000..139a35209 --- /dev/null +++ b/src/equicordplugins/messageFetchTimer/index.tsx @@ -0,0 +1,178 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { getCurrentChannel } from "@utils/discord"; +import definePlugin, { OptionType } from "@utils/types"; +import { FluxDispatcher, React } from "@webpack/common"; + +interface FetchTiming { + channelId: string; + startTime: number; + endTime?: number; + duration?: number; + timestamp?: Date; +} + +let currentFetch: FetchTiming | null = null; +let currentChannelId: string | null = null; +const channelTimings: Map = new Map(); + +const settings = definePluginSettings({ + showIcon: { + type: OptionType.BOOLEAN, + description: "Show fetch time icon in message bar", + default: true, + }, + showMs: { + type: OptionType.BOOLEAN, + description: "Show milliseconds in timing", + default: true, + }, + iconColor: { + type: OptionType.STRING, + description: "Icon color (CSS color value)", + default: "#00d166", + } +}); + +const FetchTimeButton: ChatBarButtonFactory = ({ isMainChat }) => { + const { showMs, iconColor } = settings.use(["showMs", "iconColor"]); + + if (!isMainChat || !settings.store.showIcon || !currentChannelId) { + return null; + } + + const channelData = channelTimings.get(currentChannelId); + if (!channelData) { + return null; + } + + const { time, timestamp } = channelData; + const displayTime = showMs ? `${Math.round(time)}ms` : `${Math.round(time / 1000)}s`; + + if (!showMs && Math.round(time / 1000) === 0) { + return null; + } + + const timeAgo = formatTimeAgo(timestamp); + + return ( + { }} + > +
+ {FetchTimeIcon()} + + {displayTime} + +
+
+ ); +}; + +function FetchTimeIcon() { + return ( + + + + ); +} + +function formatTimeAgo(timestamp: Date): string { + const now = new Date(); + const diff = now.getTime() - timestamp.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days} day${days > 1 ? "s" : ""} ago`; + } else if (hours > 0) { + return `${hours} hour${hours > 1 ? "s" : ""} ago`; + } else if (minutes > 0) { + return `${minutes} minute${minutes > 1 ? "s" : ""} ago`; + } else { + return "just now"; + } +} + +function handleChannelSelect(data: any) { + if (data.channelId && data.channelId !== currentChannelId) { + currentChannelId = data.channelId; + currentFetch = { + channelId: data.channelId, + startTime: performance.now() + }; + } +} + +function handleMessageLoad(data: any) { + if (!currentFetch || data.channelId !== currentFetch.channelId) return; + + const existing = channelTimings.get(currentFetch.channelId); + if (existing) return; + + const endTime = performance.now(); + const duration = endTime - currentFetch.startTime; + + channelTimings.set(currentFetch.channelId, { + time: duration, + timestamp: new Date() + }); + + currentFetch = null; +} + +export default definePlugin({ + name: "MessageFetchTimer", + description: "Shows how long it took to fetch messages for the current channel", + authors: [EquicordDevs.GroupXyz], + settings, + + chatBarButton: { + icon: FetchTimeIcon, + render: FetchTimeButton + }, + + start() { + FluxDispatcher.subscribe("CHANNEL_SELECT", handleChannelSelect); + FluxDispatcher.subscribe("LOAD_MESSAGES_SUCCESS", handleMessageLoad); + FluxDispatcher.subscribe("MESSAGE_CREATE", handleMessageLoad); + + const currentChannel = getCurrentChannel(); + if (currentChannel) { + currentChannelId = currentChannel.id; + } + }, + + stop() { + FluxDispatcher.unsubscribe("CHANNEL_SELECT", handleChannelSelect); + FluxDispatcher.unsubscribe("LOAD_MESSAGES_SUCCESS", handleMessageLoad); + FluxDispatcher.unsubscribe("MESSAGE_CREATE", handleMessageLoad); + + currentFetch = null; + channelTimings.clear(); + currentChannelId = null; + } +}); diff --git a/src/equicordplugins/messageNotifier/index.ts b/src/equicordplugins/messageNotifier/index.ts new file mode 100644 index 000000000..7a41158d0 --- /dev/null +++ b/src/equicordplugins/messageNotifier/index.ts @@ -0,0 +1,63 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Notifications } from "@api/index"; +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { getCurrentChannel } from "@utils/discord"; +import definePlugin, { OptionType } from "@utils/types"; +import { MessageJSON } from "@vencord/discord-types"; +import { MessageType } from "@vencord/discord-types/enums"; +import { ChannelStore, GuildStore, NavigationRouter, RelationshipStore } from "@webpack/common"; + +interface MessageCreatePayload { + guildId: string; + channelId: string; + message: MessageJSON; +} + +const settings = definePluginSettings({ + users: { + type: OptionType.STRING, + description: "Comma separated list of user ids to get message toasts for", + default: "", + isValid(value: string) { + if (value === "") return true; + const userIds = value.split(",").map(id => id.trim()); + for (const id of userIds) + if (!/\d+/.test(id)) return `${id} isn't a valid user id`; + return true; + }, + }, +}); + +export default definePlugin({ + authors: [EquicordDevs.cassie, EquicordDevs.mochienya], + name: "MessageNotifier", + description: "Get toasts for when chosen users send a message", + settings, + flux: { + MESSAGE_CREATE({ message, channelId, guildId }: MessageCreatePayload) { + if (message.type !== MessageType.DEFAULT || getCurrentChannel()?.id === channelId) return; + + const userIds = settings.store.users.split(",").map(id => id.trim()); + if (!userIds.includes(message.author.id)) return; + + const username = RelationshipStore.getNickname(message.author.id) ?? message.author.globalName ?? message.author.username; + const guild = GuildStore.getGuild(guildId); + const channel = ChannelStore.getChannel(channelId); + const locationName = guild ? `${guild.name}#${channel.name}` : channel?.name ?? "their dms"; + + Notifications.showNotification({ + title: `${username} sent a message`, + body: `Click to jump to ${locationName}`, + onClick() { + NavigationRouter.transitionTo(`/channels/${guild?.id ?? "@me"}/${channel.id}/${message.id}`); + }, + }); + }, + }, +}); diff --git a/src/equicordplugins/moyai/index.ts b/src/equicordplugins/moyai/index.ts new file mode 100644 index 000000000..4c6d79391 --- /dev/null +++ b/src/equicordplugins/moyai/index.ts @@ -0,0 +1,174 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import { sleep } from "@utils/misc"; +import definePlugin, { makeRange, OptionType } from "@utils/types"; +import { Message, ReactionEmoji } from "@vencord/discord-types"; +import { RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common"; + +interface IMessageCreate { + type: "MESSAGE_CREATE"; + optimistic: boolean; + isPushNotification: boolean; + channelId: string; + message: Message; +} + +interface IReactionAdd { + type: "MESSAGE_REACTION_ADD"; + optimistic: boolean; + channelId: string; + messageId: string; + messageAuthorId: string; + userId: "195136840355807232"; + emoji: ReactionEmoji; +} + +interface IVoiceChannelEffectSendEvent { + type: string; + emoji?: ReactionEmoji; // Just in case... + channelId: string; + userId: string; + animationType: number; + animationId: number; +} + +const MOYAI = "🗿"; +const MOYAI_URL = "https://github.com/Equicord/Equibored/raw/main/sounds/moyai/moyai.mp3"; +const MOYAI_URL_HD = "https://github.com/Equicord/Equibored/raw/main/sounds/moyai/moyai.wav"; + +const settings = definePluginSettings({ + volume: { + description: "Volume of the 🗿🗿🗿", + type: OptionType.SLIDER, + markers: makeRange(0, 1, 0.1), + default: 0.5, + stickToMarkers: false + }, + quality: { + description: "Quality of the 🗿🗿🗿", + type: OptionType.SELECT, + options: [ + { label: "Normal", value: "Normal", default: true }, + { label: "HD", value: "HD" } + ], + }, + triggerWhenUnfocused: { + description: "Trigger the 🗿 even when the window is unfocused", + type: OptionType.BOOLEAN, + default: true + }, + ignoreBots: { + description: "Ignore bots", + type: OptionType.BOOLEAN, + default: true + }, + ignoreBlocked: { + description: "Ignore blocked users", + type: OptionType.BOOLEAN, + default: true + } +}); + +export default definePlugin({ + name: "Moyai", + authors: [Devs.Megu, Devs.Nuckyz], + description: "Plays a 🗿 sound effect whenever a moyai emoji is sent, reacted, or used as a voice effect in your current channel.", + settings, + + flux: { + async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) { + if (optimistic || type !== "MESSAGE_CREATE") return; + if (message.state === "SENDING") return; + if (settings.store.ignoreBots && message.author?.bot) return; + if (settings.store.ignoreBlocked && RelationshipStore.isBlocked(message.author?.id)) return; + if (!message.content) return; + if (channelId !== SelectedChannelStore.getChannelId()) return; + + const moyaiCount = getMoyaiCount(message.content); + + for (let i = 0; i < moyaiCount; i++) { + boom(); + await sleep(300); + } + }, + + MESSAGE_REACTION_ADD({ optimistic, type, channelId, userId, messageAuthorId, emoji }: IReactionAdd) { + if (optimistic || type !== "MESSAGE_REACTION_ADD") return; + if (settings.store.ignoreBots && UserStore.getUser(userId)?.bot) return; + if (settings.store.ignoreBlocked && RelationshipStore.isBlocked(messageAuthorId)) return; + if (channelId !== SelectedChannelStore.getChannelId()) return; + + const name = emoji.name.toLowerCase(); + if (name !== MOYAI && !name.includes("moyai") && !name.includes("moai")) return; + + boom(); + }, + + VOICE_CHANNEL_EFFECT_SEND({ emoji }: IVoiceChannelEffectSendEvent) { + if (!emoji?.name) return; + const name = emoji.name.toLowerCase(); + if (name !== MOYAI && !name.includes("moyai") && !name.includes("moai")) return; + + boom(); + } + } +}); + +function countOccurrences(sourceString: string, subString: string) { + let i = 0; + let lastIdx = 0; + while ((lastIdx = sourceString.indexOf(subString, lastIdx) + 1) !== 0) + i++; + + return i; +} + +function countMatches(sourceString: string, pattern: RegExp) { + if (!pattern.global) + throw new Error("pattern must be global"); + + let i = 0; + while (pattern.test(sourceString)) + i++; + + return i; +} + +const customMoyaiRe = //gi; + +function getMoyaiCount(message: string) { + const count = countOccurrences(message, MOYAI) + + countMatches(message, customMoyaiRe); + + return Math.min(count, 10); +} + +function boom() { + if (!settings.store.triggerWhenUnfocused && !document.hasFocus()) return; + const audioElement = document.createElement("audio"); + + audioElement.src = settings.store.quality === "HD" + ? MOYAI_URL_HD + : MOYAI_URL; + + audioElement.volume = settings.store.volume; + audioElement.play(); +} diff --git a/src/equicordplugins/noRoleHeaders/index.ts b/src/equicordplugins/noRoleHeaders/index.ts new file mode 100644 index 000000000..424ed032f --- /dev/null +++ b/src/equicordplugins/noRoleHeaders/index.ts @@ -0,0 +1,23 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +export default definePlugin({ + name: "NoRoleHeaders", + description: "We are all equal!! Removes the role headers in the member list.", + authors: [Devs.Samwich], + patches: [ + { + find: "._areActivitiesExperimentallyHidden=(", + replacement: { + match: /return \i===\i\.\i\.UNKNOWN/, + replace: "return null;$&" + } + } + ] +}); diff --git a/src/equicordplugins/notificationTitle.discordDesktop/index.tsx b/src/equicordplugins/notificationTitle.discordDesktop/index.tsx new file mode 100644 index 000000000..56d8b3fef --- /dev/null +++ b/src/equicordplugins/notificationTitle.discordDesktop/index.tsx @@ -0,0 +1,67 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import { getIntlMessage } from "@utils/discord"; +import definePlugin from "@utils/types"; +import { findByCodeLazy, findByPropsLazy } from "@webpack"; +import { ChannelStore, GuildStore, RelationshipStore, UserStore } from "@webpack/common"; + +const { getName } = findByPropsLazy("getName", "useName", "getNickname"); +const computeChannelName = findByCodeLazy(".isThread())return`\""); + +const ChannelTypes = findByPropsLazy("DM", "GUILD_TEXT", "PUBLIC_THREAD", "UNKNOWN"); +const ChannelTypesSets = findByPropsLazy("THREADS", "GUILD_TEXTUAL", "ALL_DMS"); +const MessageTypes = findByPropsLazy("REPLY", "STAGE_RAISE_HAND", "CHANNEL_NAME_CHANGE"); + +export default definePlugin({ + name: "NotificationTitle", + description: "Makes desktop notifications more informative", + authors: [Devs.Kyuuhachi], + + patches: [ + { + find: '"SystemMessageUtils.stringify(...) could not convert"', + replacement: { + match: /{icon:.{0,50}emoji:\i}/, + replace: "($self.makeTitle($&,...arguments))", + } + }, + ], + + makeTitle(result, channel, message, user) { + const username = getName(channel.guild_id, channel.id, user); + + let title = username; + if (message.type === MessageTypes.REPLY && message.referenced_message?.author) { + const replyUser = UserStore.getUser(message.referenced_message.author.id); + const replyUsername = getName(channel.guild_id, channel.id, replyUser); + title = getIntlMessage("CHANNEL_MESSAGE_REPLY_A11Y_LABEL", { + author: username, + repliedAuthor: replyUsername, + }); + } + + const guild = GuildStore.getGuild(channel.guild_id); + const parent = ChannelStore.getChannel(channel.parent_id); + + if (channel.type !== ChannelTypes.DM) { + let where = ChannelTypesSets.THREADS.has(channel.type) + ? `${channelName(channel)} in ${channelName(parent, true)}` + : `${channelName(channel, true)}`; + if (guild != null) + where += `, ${guild.name}`; + title += `\n(${where})`; + } + result.title = title; + + return result; + } +}); + +function channelName(channel, withPrefix = false) { + return computeChannelName(channel, UserStore, RelationshipStore, withPrefix); +} diff --git a/src/equicordplugins/partyMode/index.ts b/src/equicordplugins/partyMode/index.ts new file mode 100644 index 000000000..f7cddbf97 --- /dev/null +++ b/src/equicordplugins/partyMode/index.ts @@ -0,0 +1,109 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType, ReporterTestable } from "@utils/types"; +import { FluxDispatcher } from "@webpack/common"; + +const enum Intensity { + Normal, + Better, + ProjectX, +} + +const settings = definePluginSettings({ + superIntensePartyMode: { + description: "Party intensity", + type: OptionType.SELECT, + options: [ + { label: "Normal", value: Intensity.Normal, default: true }, + { label: "Better", value: Intensity.Better }, + { label: "Project X", value: Intensity.ProjectX }, + ], + restartNeeded: false, + onChange: setSettings + }, +}); + +export default definePlugin({ + name: "PartyMode", + description: "Allows you to use party mode cause the party never ends ✨", + authors: [Devs.UwUDev], + reporterTestable: ReporterTestable.None, + settings, + + start() { + setPoggerState(true); + setSettings(settings.store.superIntensePartyMode); + }, + + stop() { + setPoggerState(false); + }, +}); + +function setPoggerState(state: boolean) { + FluxDispatcher.dispatch({ + type: "POGGERMODE_SETTINGS_UPDATE", + settings: { + enabled: state, + settingsVisible: state + } + }); +} + +function setSettings(intensity: Intensity) { + const state = { + screenshakeEnabledLocations: { 0: true, 1: true, 2: true }, + shakeIntensity: 1, + confettiSize: 16, + confettiCount: 5, + combosRequiredCount: 1 + }; + + switch (intensity) { + case Intensity.Normal: { + Object.assign(state, { + screenshakeEnabledLocations: { 0: true, 1: false, 2: false }, + combosRequiredCount: 5 + }); + break; + } + case Intensity.Better: { + Object.assign(state, { + confettiSize: 12, + confettiCount: 8, + }); + break; + } + case Intensity.ProjectX: { + Object.assign(state, { + shakeIntensity: 20, + confettiSize: 25, + confettiCount: 15, + }); + break; + } + } + + FluxDispatcher.dispatch({ + type: "POGGERMODE_SETTINGS_UPDATE", + settings: state + }); +} diff --git a/src/equicordplugins/pingNotifications/index.tsx b/src/equicordplugins/pingNotifications/index.tsx new file mode 100644 index 000000000..e6b0e45e2 --- /dev/null +++ b/src/equicordplugins/pingNotifications/index.tsx @@ -0,0 +1,145 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { showNotification } from "@api/Notifications"; +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { findStoreLazy } from "@webpack"; +import { + ChannelStore, + GuildStore, + NavigationRouter, + PresenceStore, + RelationshipStore, + SelectedChannelStore, + UserStore +} from "@webpack/common"; + +const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore"); + +const settings = definePluginSettings({ + friends: { + type: OptionType.BOOLEAN, + default: false, + description: "Notify when friends send messages in servers" + }, + mentions: { + type: OptionType.BOOLEAN, + default: true, + description: "Notify when someone @mentions you directly" + }, + dms: { + type: OptionType.BOOLEAN, + default: true, + description: "Notify for direct messages (DMs)" + }, + showInActive: { + type: OptionType.BOOLEAN, + default: false, + description: "Show notifications even for currently active channel" + }, + ignoreMuted: { + type: OptionType.BOOLEAN, + default: true, + description: "Skip notifications from muted servers, channels, or users" + } +}); + +function formatContent(message) { + let content = message.content || ""; + message.mentions?.forEach(user => { + content = content.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${user.username}`); + }); + return content.slice(0, 200) + (content.length > 200 ? "..." : ""); +} + +function checkIfMuted(channel) { + if (!settings.store.ignoreMuted) return false; + if (!channel) return false; + + if (channel.isMuted?.()) return true; + + const isDM = [1, 3].includes(channel.type); + if (isDM) { + const recipientIds = channel.recipients || []; + for (const userId of recipientIds) { + if (RelationshipStore.isBlocked(userId)) return true; + } + + if (UserGuildSettingsStore?.getMutedChannels?.()?.includes?.(channel.id)) return true; + } + + if (channel.guild_id) { + const guild = GuildStore.getGuild(channel.guild_id); + if (UserGuildSettingsStore.isMuted(channel.guild_id)) return true; + + if (UserGuildSettingsStore?.isMuted?.(channel.guild_id)) return true; + if (UserGuildSettingsStore?.isChannelMuted?.(channel.guild_id, channel.id)) return true; + if (UserGuildSettingsStore?.isCategoryMuted?.(channel.guild_id, channel.id)) return true; + } + + return false; +} + +function isUserBlocked(userId) { + return settings.store.ignoreMuted && RelationshipStore.isBlocked?.(userId); +} + +export default definePlugin({ + name: "PingNotifications", + description: "Customizable notifications with improved mention formatting", + authors: [EquicordDevs.smuki], + settings, + + flux: { + MESSAGE_CREATE({ message }) { + try { + if (!message?.channel_id || message.state === "SENDING") return; + + const channel = ChannelStore.getChannel(message.channel_id); + const currentUser = UserStore.getCurrentUser(); + + if (!channel || !currentUser) return; + if (message.author?.id === currentUser.id) return; + + const isDM = [1, 3].includes(channel.type); + + if (checkIfMuted(channel)) return; + if (isUserBlocked(message.author.id)) return; + if (!settings.store.showInActive && channel.id === SelectedChannelStore.getChannelId()) return; + if (PresenceStore.getStatus(currentUser.id) === "dnd") return; + + const author = UserStore.getUser(message.author.id) || { username: "Unknown" }; + const channelName = channel.name || (isDM ? "DM" : "Group"); + const body = formatContent(message); + + let shouldNotify = false; + + if (settings.store.mentions && message.mentions?.some(u => u.id === currentUser.id)) { + shouldNotify = true; + } else if (settings.store.friends && RelationshipStore.isFriend(message.author.id) && !isDM) { + shouldNotify = true; + } else if (isDM && settings.store.dms) { + shouldNotify = true; + } + + if (shouldNotify) { + showNotification({ + title: `${author.username} in ${channelName}`, + body, + icon: author.getAvatarURL?.(undefined, 128), + onClick: () => NavigationRouter.transitionTo( + `/channels/${channel.guild_id || "@me"}/${channel.id}/${message.id}` + ) + }); + } + } catch (err) { + console.error("[PingNotifications] Error:", err); + } + } + } +}); diff --git a/src/equicordplugins/profileSets/components/confirmModal.tsx b/src/equicordplugins/profileSets/components/confirmModal.tsx new file mode 100644 index 000000000..76da537d0 --- /dev/null +++ b/src/equicordplugins/profileSets/components/confirmModal.tsx @@ -0,0 +1,81 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Button } from "@components/Button"; +import { Heading } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal"; +interface ConfirmModalProps extends ModalProps { + title: string; + message: string; + confirmText: string; + cancelText: string; + onConfirm: () => void; + onCancel: () => void; +} + +interface ImportProfilesModalProps extends ModalProps { + title: string; + message: string; + onOverride: () => void; + onMerge: () => void; + onCancel: () => void; +} + +export function ConfirmModal({ title, message, confirmText, cancelText, onConfirm, onCancel, onClose, transitionState }: ConfirmModalProps) { + const closeAfter = (action: () => void) => () => { + action(); + onClose(); + }; + + return ( + + + {title} + + + {message} + + + + + + + ); +} + +export function ImportProfilesModal({ title, message, onOverride, onMerge, onCancel, onClose, transitionState }: ImportProfilesModalProps) { + const closeAfter = (action: () => void) => () => { + action(); + onClose(); + }; + + return ( + + + {title} + + + {message} + + + + + + + + ); +} diff --git a/src/equicordplugins/profileSets/components/presetList.tsx b/src/equicordplugins/profileSets/components/presetList.tsx new file mode 100644 index 000000000..61bf18ce6 --- /dev/null +++ b/src/equicordplugins/profileSets/components/presetList.tsx @@ -0,0 +1,220 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { isNonNullish } from "@utils/guards"; +import { classes } from "@utils/misc"; +import { ProfilePreset } from "@vencord/discord-types"; +import { ContextMenuApi, Menu, React, TextInput } from "@webpack/common"; + +import { cl } from ".."; +import { deletePreset, movePreset, renamePreset, updatePresetField } from "../utils/actions"; +import { getCurrentProfile } from "../utils/profile"; +import { PresetSection, type ProfilePresetEx } from "../utils/storage"; + +interface PresetListProps { + presets: ProfilePresetEx[]; + allPresets: ProfilePresetEx[]; + avatarSize: number; + selectedPreset: number; + onLoad: (index: number) => void; + onUpdate: () => void; + guildId?: string; + section: PresetSection; + currentPage: number; + onPageChange: (page: number) => void; +} + +export function PresetList({ + presets, + allPresets, + avatarSize, + selectedPreset, + onLoad, + onUpdate, + guildId, + section, + currentPage, + onPageChange +}: PresetListProps) { + type EditableProfile = Omit; + const [renaming, setRenaming] = React.useState(-1); + const [renameText, setRenameText] = React.useState(""); + const isGuildProfile = section === "server"; + + return ( +
+ {presets.map(preset => { + const actualIndex = allPresets.indexOf(preset); + const isRenaming = renaming === actualIndex; + const isSelected = !isRenaming && selectedPreset === actualIndex; + const date = new Date(preset.timestamp); + const formattedDate = date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric" + }); + const formattedTime = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit" + }); + + const commitRename = () => { + const nextName = renameText.trim(); + if (!nextName) return; + renamePreset(actualIndex, nextName, section, guildId); + onUpdate(); + }; + + return ( +
{ + if (!isRenaming) { + onLoad(actualIndex); + } + }} + onKeyDown={e => { + if (!isRenaming && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onLoad(actualIndex); + } + }} + className={classes(cl("row"), isSelected ? "selected" : "")} + > +
+ {preset.avatarDataUrl && ( + + )} +
+ {isRenaming ? ( + { + commitRename(); + setRenaming(-1); + }} + onKeyDown={e => { + if (e.key === "Enter") { + commitRename(); + setRenaming(-1); + } else if (e.key === "Escape") { + setRenaming(-1); + } + e.stopPropagation(); + }} + onClick={e => e.stopPropagation()} + autoFocus + /> + ) : ( + <> +
+ {preset.name} +
+
+ {formattedDate} at {formattedTime} +
+ + )} +
+
+
+ { + e.stopPropagation(); + const target = e.currentTarget; + ContextMenuApi.openContextMenu(e, () => ( + + { + setRenaming(actualIndex); + setRenameText(preset.name); + }} + /> + { + const profile = await getCurrentProfile(guildId, { isGuildProfile }); + await Promise.all( + (Object.entries(profile) as [keyof EditableProfile, EditableProfile[keyof EditableProfile]][]) + .filter(([, value]) => isNonNullish(value)) + .map(([key, value]) => updatePresetField(actualIndex, key, value, section, guildId)) + ); + onUpdate(); + }} + /> + + {actualIndex > 0 && ( + { + movePreset(actualIndex, actualIndex - 1, section, guildId); + onUpdate(); + }} + /> + )} + {actualIndex < allPresets.length - 1 && ( + { + movePreset(actualIndex, actualIndex + 1, section, guildId); + onUpdate(); + }} + /> + )} + {currentPage > 1 && ( + { + movePreset(actualIndex, 0, section, guildId); + onPageChange(1); + onUpdate(); + }} + /> + )} + + { + await deletePreset(actualIndex, section, guildId); + onUpdate(); + }} + /> + + )); + }} + > + + +
+
+ ); + })} +
+ ); +} diff --git a/src/equicordplugins/profileSets/components/presetManager.tsx b/src/equicordplugins/profileSets/components/presetManager.tsx new file mode 100644 index 000000000..74f2c5bc8 --- /dev/null +++ b/src/equicordplugins/profileSets/components/presetManager.tsx @@ -0,0 +1,290 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Button } from "@components/Button"; +import { Heading } from "@components/Heading"; +import { classes } from "@utils/misc"; +import { openModal } from "@utils/modal"; +import { React, SelectedGuildStore, TextInput, useStateFromStores } from "@webpack/common"; + +import { cl, settings } from "../index"; +import { exportPresets, ImportDecision, importPresets, savePreset } from "../utils/actions"; +import { loadPresetAsPending } from "../utils/profile"; +import { loadPresets, presets, PresetSection, setCurrentPresetIndex } from "../utils/storage"; +import { ImportProfilesModal } from "./confirmModal"; +import { PresetList } from "./presetList"; + +const PRESETS_PER_PAGE = 5; + +type PresetManagerProps = { + section?: PresetSection; + guildId?: string; +}; + +export function PresetManager({ section, guildId }: PresetManagerProps) { + const [presetName, setPresetName] = React.useState(""); + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + const [isSaving, setIsSaving] = React.useState(false); + const [currentPage, setCurrentPage] = React.useState(1); + const [pageInput, setPageInput] = React.useState("1"); + const [selectedPreset, setSelectedPreset] = React.useState(-1); + const [searchMode, setSearchMode] = React.useState(false); + const lastRandomIndexRef = React.useRef(-1); + const resolvedSection: PresetSection = section ?? "main"; + const isServerSection = resolvedSection === "server"; + const lastSelectedGuildId = useStateFromStores( + [SelectedGuildStore], + () => SelectedGuildStore.getLastSelectedGuildId() ?? SelectedGuildStore.getGuildId() + ); + const resolvedGuildId = isServerSection ? (guildId ?? lastSelectedGuildId ?? undefined) : undefined; + const canUseGuild = !isServerSection || Boolean(resolvedGuildId); + + React.useEffect(() => { + let isActive = true; + (async () => { + await loadPresets(resolvedSection); + if (!isActive) return; + setSelectedPreset(-1); + setCurrentPage(1); + setPageInput("1"); + forceUpdate(); + })(); + return () => { + isActive = false; + }; + }, [resolvedGuildId, resolvedSection]); + + const filteredPresets = !searchMode + ? presets + : presets.filter(preset => preset.name.toLowerCase().includes(presetName.toLowerCase())); + + const totalPages = Math.ceil(filteredPresets.length / PRESETS_PER_PAGE); + const startIndex = (currentPage - 1) * PRESETS_PER_PAGE; + const currentPresets = filteredPresets.slice(startIndex, startIndex + PRESETS_PER_PAGE); + + const handlePageChange = (newPage: number) => { + if (newPage >= 1 && newPage <= totalPages) { + setCurrentPage(newPage); + setPageInput(String(newPage)); + } + }; + + const handleSavePreset = async () => { + if (!canUseGuild) return; + const trimmedName = presetName.trim(); + if (!trimmedName) return; + setIsSaving(true); + await savePreset(trimmedName, resolvedSection, resolvedGuildId); + setPresetName(""); + setIsSaving(false); + const newTotalPages = Math.ceil(presets.length / PRESETS_PER_PAGE); + handlePageChange(newTotalPages); + forceUpdate(); + }; + + const applyPreset = (index: number) => { + setSelectedPreset(index); + setCurrentPresetIndex(index); + loadPresetAsPending(presets[index], resolvedGuildId, { + isGuildProfile: resolvedSection === "server" + }); + forceUpdate(); + }; + + const handleLoadPreset = (index: number) => { + if (!canUseGuild) return; + applyPreset(index); + }; + + const selectRandomPreset = (sectionType: PresetSection) => { + const availablePresets = presets; + if (!availablePresets.length) return null; + const randomIndex = Math.floor(Math.random() * availablePresets.length); + return { preset: availablePresets[randomIndex], index: randomIndex, section: sectionType }; + }; + + const handleRandomPreset = () => { + if (!canUseGuild) return; + const selection = selectRandomPreset(resolvedSection); + if (!selection) return; + let nextIndex = selection.index; + if (presets.length > 1 && nextIndex === lastRandomIndexRef.current) { + let attempts = 0; + while (attempts < 5 && nextIndex === lastRandomIndexRef.current) { + nextIndex = Math.floor(Math.random() * presets.length); + attempts++; + } + } + lastRandomIndexRef.current = nextIndex; + applyPreset(nextIndex); + }; + + const showImportPrompt = (existingCount: number): Promise => { + return new Promise(resolve => { + openModal(props => ( + resolve("override")} + onMerge={() => resolve("merge")} + onCancel={() => resolve("cancel")} + /> + )); + }); + }; + + const { avatarSize } = settings.store; + const hasPresets = presets.length > 0; + const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE; + + return ( +
+ + Saved Profiles + + +
+ +
+ +
+ {!searchMode && ( + + )} + {hasPresets && ( + + )} +
+ + {hasPresets && ( + <> + { + const newTotal = Math.ceil(presets.length / PRESETS_PER_PAGE); + if (newTotal === 0) { + handlePageChange(1); + } else if (currentPage > newTotal) { + handlePageChange(newTotal); + } + forceUpdate(); + }} + guildId={resolvedGuildId} + section={resolvedSection} + currentPage={currentPage} + onPageChange={handlePageChange} + /> + + {shouldShowPagination && ( +
+ +
+ { + const { value } = e.target; + setPageInput(value); + const num = parseInt(value); + if (!isNaN(num) && num >= 1 && num <= totalPages) { + setCurrentPage(num); + } + }} + className={cl("page-input")} + /> + + / {totalPages} + +
+ +
+ )} + +
+ + )} + +
+ + + +
+
+ {resolvedSection === "server" && ( +
+ )} +
+ ); +} diff --git a/src/equicordplugins/profileSets/index.tsx b/src/equicordplugins/profileSets/index.tsx new file mode 100644 index 000000000..a2db97aa4 --- /dev/null +++ b/src/equicordplugins/profileSets/index.tsx @@ -0,0 +1,56 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import definePlugin, { OptionType } from "@utils/types"; +import { React } from "@webpack/common"; + +import { PresetManager } from "./components/presetManager"; +import { loadPresets, PresetSection } from "./utils/storage"; + +export const cl = classNameFactory("vc-profile-presets-"); +export const settings = definePluginSettings({ + avatarSize: { + type: OptionType.SLIDER, + description: "Avatar size in preset list.", + markers: [56, 64, 72, 80, 88, 96], + default: 56, + stickToMarkers: true + }, +}); + +export default definePlugin({ + name: "ProfileSets", + description: "Allows you to save and load different profile presets, via the Profile Section in Settings.", + authors: [EquicordDevs.omaw, EquicordDevs.justjxke], + settings, + patches: [ + { + find: "DefaultCustomizationSections: user cannot be undefined", + replacement: { + match: /return.{0,50}children:\[(?<=\.getLegacyUsername\(\).*?)/, + replace: "$&$self.renderPresetSection(\"main\")," + } + }, + { + find: "USER_SETTINGS_GUILD_PROFILE)", + replacement: { + match: /guildId:(\i\.id),onChange:(\i)\}\)(?=.{0,25}profilePreviewTitle:)/, + replace: 'guildId:$1,onChange:$2}),$self.renderPresetSection("server",$1)' + } + } + ], + start() { + loadPresets("main"); + }, + renderPresetSection(section: PresetSection, guildId?: string) { + return ; + } +}); diff --git a/src/equicordplugins/profileSets/styles.css b/src/equicordplugins/profileSets/styles.css new file mode 100644 index 000000000..390c827b9 --- /dev/null +++ b/src/equicordplugins/profileSets/styles.css @@ -0,0 +1,209 @@ +.vc-profile-presets-section { + margin-bottom: 20px; + padding: 0; + background-color: transparent; + max-width: 660px; + width: 100%; + min-width: 0; + align-self: flex-start; +} + +.vc-profile-presets-section-server { + max-width: 660px; +} + +.vc-profile-presets-heading { + margin-bottom: 8px; + font-size: 16px; + font-weight: 600; +} + +.vc-profile-presets-text { + margin-bottom: 8px; +} + +.vc-profile-presets-text-input { + width: 100%; + max-width: 500px; + min-width: 0; +} + +.vc-profile-presets-search { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.vc-profile-presets-search-button { + min-width: 120px; +} + +.vc-profile-presets-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 12px; +} + +.vc-profile-presets-section-server .vc-profile-presets-pagination { + width: 300px; + max-width: 100%; +} + +.vc-profile-presets-page { + display: flex; + align-items: center; + gap: 4px; +} + +.vc-profile-presets-page-of { + font-size: 14px; + color: var(--text-normal); + font-weight: 500; +} + +.vc-profile-presets-import { + display: flex; + gap: 8px; + margin-top: 12px; + margin-bottom: 16px; +} + +.vc-profile-presets-random { + background: linear-gradient(135deg, #ff5f6d 0%, #ffc371 50%, #8a7bff 100%); + color: #fff; + border: none; +} + +.vc-profile-presets-random:hover { + filter: brightness(1.05); +} + +.vc-profile-presets-random-content { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.vc-profile-presets-random-icon { + width: 16px; + height: 16px; + display: inline-block; +} + +.vc-profile-presets-block { + margin: 16px 0; + border: none; + border-top: 1px solid var(--background-modifier-accent); +} + +.vc-profile-presets-avatar-url { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.vc-profile-presets-rename { + min-width: 0; + flex: 1; +} + +.vc-profile-presets-updated { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.vc-profile-presets-list-container { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + padding: 12px; + background-color: var(--background-secondary); + border-radius: 8px; + border: 2px solid var(--background-modifier-accent); + min-width: 0; + width: 100%; + max-width: 660px; + align-self: flex-start; +} + +.vc-profile-presets-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background-color: var(--background-primary); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + border: 2px solid transparent; + outline: none; + min-width: 0; + width: 100%; + max-width: 300px; + align-self: flex-start; +} + +.vc-profile-presets-row:hover { + background-color: var(--background-modifier-hover) !important; + border-color: var(--background-modifier-accent) !important; +} + +.vc-profile-presets-row:focus-visible { + border-color: var(--brand-experiment) !important; + box-shadow: 0 0 0 2px var(--brand-experiment-30a); +} + +.vc-profile-presets-row.selected { + background-color: var(--background-modifier-selected); +} + +.vc-profile-presets-avatar { + border-radius: 50%; + border: 2px solid var(--background-tertiary); + flex-shrink: 0; +} + +.vc-profile-presets-name { + font-size: 15px; + font-weight: 600; + color: var(--header-primary); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vc-profile-presets-timestamp { + font-size: 12px; + color: var(--text-normal); + font-weight: 500; + opacity: 0.8; +} + +.vc-profile-presets-menu-icon { + opacity: 0.7; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.vc-profile-presets-menu-icon:hover { + opacity: 1; +} + +.vc-profile-presets-page-input { + width: 40px; + text-align: center; + padding: 4px; + background-color: var(--background-secondary); + border: 2px solid var(--background-modifier-accent); + border-radius: 4px; + color: var(--text-normal); +} diff --git a/src/equicordplugins/profileSets/utils/actions.ts b/src/equicordplugins/profileSets/utils/actions.ts new file mode 100644 index 000000000..a43aa3e24 --- /dev/null +++ b/src/equicordplugins/profileSets/utils/actions.ts @@ -0,0 +1,152 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { isNonNullish } from "@utils/guards"; +import { ProfilePreset } from "@vencord/discord-types"; +import { findStoreLazy } from "@webpack"; +import { showToast, Toasts } from "@webpack/common"; + +import { getCurrentProfile } from "./profile"; +import { addPreset, movePresetInArray, presets, PresetSection, type ProfilePresetEx, removePreset, replaceAllPresets, savePresetsData, updatePreset } from "./storage"; + +const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore"); + +function isImageInput(value: unknown): value is string | { imageUri: string; } { + if (typeof value === "string") return value.length > 0; + return typeof value === "object" && isNonNullish(value) && "imageUri" in value && typeof (value as { imageUri: unknown }).imageUri === "string"; +} + +function getFreshPendingAvatar(section: PresetSection, guildId?: string): string | null { + const pending = (section === "server" && guildId + ? UserProfileSettingsStore.getPendingChanges?.(guildId) + : UserProfileSettingsStore.getPendingChanges?.()) ?? {}; + const pendingObj = pending as Record; + + const candidates = [ + pendingObj.selectedAvatarRaw, + pendingObj.presetAvatarRaw, + pendingObj.pendingAvatar, + pendingObj.avatar, + pendingObj.selectedAvatarProcessed, + pendingObj.presetAvatarProcessed + ]; + const selected = candidates.find(isImageInput); + if (!selected) return null; + return typeof selected === "string" ? selected : selected.imageUri; +} + +export async function savePreset(name: string, section: PresetSection, guildId?: string) { + const profile = await getCurrentProfile(guildId, { isGuildProfile: section === "server" }); + const freshPendingAvatar = getFreshPendingAvatar(section, guildId); + const effectiveAvatar = freshPendingAvatar ?? profile.avatarDataUrl ?? null; + + const newPreset: ProfilePresetEx = { + name, + timestamp: Date.now(), + ...profile, + avatarDataUrl: effectiveAvatar, + }; + addPreset(newPreset); + await savePresetsData(section); +} + +export async function updatePresetField>( + index: number, + field: K, + value: Omit[K], + section: PresetSection, + guildId?: string +) { + if (index < 0 || index >= presets.length) return; + void guildId; + + const updatedPreset = { + ...presets[index], + [field]: value, + timestamp: Date.now() + }; + updatePreset(index, updatedPreset); + await savePresetsData(section); +} + +export async function deletePreset(index: number, section: PresetSection, guildId?: string) { + if (index < 0 || index >= presets.length) return; + + removePreset(index); + await savePresetsData(section); +} + +export async function movePreset(fromIndex: number, toIndex: number, section: PresetSection, guildId?: string) { + if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return; + + movePresetInArray(fromIndex, toIndex); + await savePresetsData(section); +} + +export async function renamePreset(index: number, newName: string, section: PresetSection, guildId?: string) { + if (index < 0 || index >= presets.length || !newName.trim()) return; + + const updatedPreset = { ...presets[index], name: newName.trim() }; + updatePreset(index, updatedPreset); + await savePresetsData(section); +} + +export function exportPresets(section: PresetSection) { + const dataStr = JSON.stringify(presets, null, 2); + const dataBlob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement("a"); + link.href = url; + link.download = `profile-presets-${section}-${Date.now()}.json`; + link.click(); + URL.revokeObjectURL(url); +} + +export type ImportDecision = "override" | "merge" | "cancel"; + +export async function importPresets( + forceUpdate: () => void, + onImportPrompt: (existingCount: number) => Promise, + section: PresetSection, + guildId?: string +) { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/json"; + input.onchange = async (event: Event) => { + try { + const target = event.currentTarget as HTMLInputElement | null; + const file = target?.files?.[0]; + if (!file) return; + + const text = await file.text(); + const importedPresets = JSON.parse(text); + + if (!Array.isArray(importedPresets)) { + return; + } + + if (presets.length > 0) { + const decision = await onImportPrompt(presets.length); + if (decision === "cancel") return; + if (decision === "override") { + replaceAllPresets(importedPresets); + } else { + const combined = [...presets, ...importedPresets]; + replaceAllPresets(combined); + } + } else { + replaceAllPresets(importedPresets); + } + + await savePresetsData(section); + forceUpdate(); + } catch { + showToast("Failed to import presets. The file might be invalid.", Toasts.Type.FAILURE); + } + }; + input.click(); +} diff --git a/src/equicordplugins/profileSets/utils/profile.ts b/src/equicordplugins/profileSets/utils/profile.ts new file mode 100644 index 000000000..148081443 --- /dev/null +++ b/src/equicordplugins/profileSets/utils/profile.ts @@ -0,0 +1,415 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { getUserSettingLazy } from "@api/UserSettings"; +import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types"; +import { findStoreLazy } from "@webpack"; +import { FluxDispatcher, GuildMemberStore, IconUtils, UserProfileStore, UserStore } from "@webpack/common"; + +const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore"); +const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!; + +type PendingChanges = Record & { + selectedAvatarRaw?: ImageInput; + presetAvatarRaw?: ImageInput; + selectedAvatarProcessed?: ImageInput; + presetAvatarProcessed?: ImageInput; + pendingAvatar?: ImageInput; + avatar?: ImageInput; + pendingBanner?: ImageInput; + banner?: ImageInput; + pendingAvatarDecoration?: { asset: string; skuId: string; } | null; + pendingProfileEffect?: ProfileEffect | null; + pendingNameplate?: Nameplate | null; + pendingDisplayNameStyles?: DisplayNameStyles | null; + pendingAccentColor?: number | null; + pendingThemeColors?: number[] | null; + pendingBio?: string | null; + pendingPronouns?: string | null; + pendingNickname?: string | null; + pendingGlobalName?: string | null; + pendingPrimaryGuildId?: string | null; +}; + +type ImageInput = string | { imageUri: string; } | null | undefined; +type DisplayNameStylesLike = DisplayNameStyles & { + fontId?: number; + effectId?: number; +}; + +type CurrentProfileOptions = { + isGuildProfile?: boolean; +}; + +type LoadPresetOptions = { + skipGlobalName?: boolean; + skipBio?: boolean; + skipPronouns?: boolean; + isGuildProfile?: boolean; +}; + +function dispatch(type: string, payload: Record) { + FluxDispatcher.dispatch({ type, ...payload }); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function hasImageInput(value: ImageInput): boolean { + if (!value) return false; + if (typeof value === "string") return value.length > 0; + return typeof value === "object" && isNonEmptyString(value?.imageUri); +} + +function normalizeDisplayNameStyles(value: DisplayNameStylesLike | null | undefined): DisplayNameStylesLike | null { + if (!value) return null; + const fontId = value.fontId ?? value.font_id; + const effectId = value.effectId ?? value.effect_id; + if (typeof fontId !== "number" || typeof effectId !== "number") return null; + const colors = Array.isArray(value.colors) ? [...value.colors] : []; + + return { + fontId, + effectId, + font_id: fontId, + effect_id: effectId, + colors + }; +} + +export async function imageUrlToBase64(url: string): Promise { + try { + const response = await fetch(url); + const blob = await response.blob(); + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } catch { + return null; + } +} + +async function processImage(imageData: ImageInput, userId: string, type: "avatar" | "banner", guildId?: string, useGuildPath?: boolean): Promise { + if (!imageData) return null; + + if (typeof imageData === "object" && isNonEmptyString(imageData?.imageUri)) { + return imageData.imageUri; + } + + if (typeof imageData === "string") { + if (imageData.startsWith("data:")) return imageData; + if (/^https?:\/\//.test(imageData)) { + return await imageUrlToBase64(imageData); + } + + const isAnimated = imageData.startsWith("a_"); + const size = type === "banner" ? 1024 : 512; + const urlPath = type === "banner" ? "banners" : "avatars"; + const guildPath = guildId ? `guilds/${guildId}/users/${userId}/${type === "banner" ? "banners" : "avatars"}` : urlPath; + const guildUrl = `https://cdn.discordapp.com/${guildPath}/${imageData}.${isAnimated ? "gif" : "png"}?size=${size}`; + const globalUrl = `https://cdn.discordapp.com/${urlPath}/${userId}/${imageData}.${isAnimated ? "gif" : "png"}?size=${size}`; + if (useGuildPath && guildId) { + const guildResult = await imageUrlToBase64(guildUrl); + if (guildResult) return guildResult; + } + return await imageUrlToBase64(globalUrl); + } + + return null; +} + +export async function getCurrentProfile(guildId?: string, options: CurrentProfileOptions = {}): Promise> { + const currentUser = UserStore.getCurrentUser(); + const baseProfile = UserProfileStore.getUserProfile(currentUser.id); + const isGuildProfile = options.isGuildProfile ?? Boolean(guildId); + const effectiveGuildId = isGuildProfile ? guildId : undefined; + const guildProfile = effectiveGuildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, effectiveGuildId) : null; + const userProfile = guildProfile ?? baseProfile; + const userAny = currentUser; + const guildMember = effectiveGuildId ? GuildMemberStore.getMember(effectiveGuildId, currentUser.id) : null; + + const pendingChangesDefault: PendingChanges = UserProfileSettingsStore.getPendingChanges() ?? {}; + const pendingChangesForGuild: PendingChanges = UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {}; + const pendingChanges: PendingChanges = isGuildProfile && Object.keys(pendingChangesForGuild).length > 0 + ? pendingChangesForGuild + : pendingChangesDefault; + + const customStatusSetting = CustomStatusSettings.getSetting(); + const customStatus = isGuildProfile + ? null + : { + text: customStatusSetting?.text ?? "", + emojiId: customStatusSetting?.emojiId ?? "0", + emojiName: customStatusSetting?.emojiName ?? "", + expiresAtMs: customStatusSetting?.expiresAtMs ?? "0" + }; + + const avatarDecorationSource = pendingChanges.pendingAvatarDecoration ?? userAny.avatarDecorationData; + const avatarDecoration = avatarDecorationSource ? { + asset: avatarDecorationSource.asset, + skuId: avatarDecorationSource.skuId + } : null; + + let profileEffect: ProfileEffect | null = null; + const effectToUse = pendingChanges.pendingProfileEffect ?? userProfile?.profileEffect; + + if (effectToUse) { + if (effectToUse.skuId && effectToUse.effects) { + profileEffect = { + skuId: effectToUse.skuId, + title: effectToUse.title, + description: effectToUse.description, + accessibilityLabel: effectToUse.accessibilityLabel, + reducedMotionSrc: effectToUse.reducedMotionSrc, + thumbnailPreviewSrc: effectToUse.thumbnailPreviewSrc, + effects: effectToUse.effects, + animationType: effectToUse.animationType, + staticFrameSrc: effectToUse.staticFrameSrc, + type: effectToUse.type || 1 + }; + } else if (effectToUse.skuId) { + const collectibles = userProfile?.collectibles; + const collectible = collectibles?.find(c => c?.skuId === effectToUse.skuId); + if (collectible) { + profileEffect = { + skuId: collectible.skuId, + title: collectible.title, + description: collectible.description, + accessibilityLabel: collectible.accessibilityLabel, + reducedMotionSrc: collectible.reducedMotionSrc, + thumbnailPreviewSrc: collectible.thumbnailPreviewSrc, + effects: collectible.effects, + animationType: collectible.animationType, + staticFrameSrc: collectible.staticFrameSrc, + type: collectible.type || 1 + }; + } + } + } + + const nameplateToUse = pendingChanges.pendingNameplate ?? userAny.collectibles?.nameplate; + const nameplate = nameplateToUse ? { + skuId: nameplateToUse.skuId, + asset: nameplateToUse.asset, + label: nameplateToUse.label, + palette: typeof nameplateToUse.palette === "string" ? nameplateToUse.palette : undefined, + type: nameplateToUse.type || 2 + } : null; + + const savedDisplayNameStyles = isGuildProfile + ? (guildMember?.displayNameStyles ?? userAny.displayNameStyles) + : userAny.displayNameStyles; + const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? savedDisplayNameStyles; + const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse); + + const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar; + const avatarToUse: ImageInput = hasImageInput(pendingAvatar) + ? pendingAvatar + : (isGuildProfile ? (guildMember?.avatar ?? currentUser.avatar ?? null) : (currentUser.avatar ?? null)); + + const useGuildAvatar = !!(effectiveGuildId && isGuildProfile && guildMember?.avatar && avatarToUse === guildMember.avatar); + + const avatarInput: ImageInput = hasImageInput(avatarToUse) + ? avatarToUse + : IconUtils.getUserAvatarURL(currentUser, true, 512); + const avatarDataUrl = await processImage(avatarInput, currentUser.id, "avatar", effectiveGuildId, useGuildAvatar); + const resolvedAvatarDataUrl = avatarDataUrl ?? IconUtils.getDefaultAvatarURL(currentUser.id); + + const pendingBanner = pendingChanges.pendingBanner ?? pendingChanges.banner; + const bannerToUse: ImageInput = hasImageInput(pendingBanner) + ? pendingBanner + : (isGuildProfile ? (guildProfile?.banner ?? baseProfile?.banner) : baseProfile?.banner); + const useGuildBanner = !!(effectiveGuildId && isGuildProfile && guildProfile?.banner && bannerToUse === guildProfile?.banner); + + const bannerDataUrl = await processImage(bannerToUse, currentUser.id, "banner", effectiveGuildId, useGuildBanner); + + return { + avatarDataUrl: resolvedAvatarDataUrl, + bannerDataUrl, + bio: pendingChanges.pendingBio ?? userProfile?.bio ?? null, + accentColor: pendingChanges.pendingAccentColor ?? userProfile?.accentColor ?? null, + themeColors: pendingChanges.pendingThemeColors ?? userProfile?.themeColors ?? null, + globalName: isGuildProfile + ? (pendingChanges.pendingNickname ?? guildMember?.nick ?? null) + : (pendingChanges.pendingGlobalName ?? currentUser.globalName ?? null), + pronouns: isGuildProfile ? null : (pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null), + avatarDecoration, + profileEffect, + nameplate, + primaryGuildId: isGuildProfile + ? null + : (pendingChanges.pendingPrimaryGuildId ?? userAny.primaryGuild?.identityGuildId ?? null), + customStatus, + displayNameStyles + }; +} + +function jsonEq(a: unknown, b: unknown): boolean { + if (a === b) return true; + return JSON.stringify(a) === JSON.stringify(b); +} + +function customStatusEq(a: CustomStatus | null | undefined, b: CustomStatus | null | undefined): boolean { + if (a == null || b == null) return a == null && b == null; + return a.text === b.text + && String(a.emojiId ?? "") === String(b.emojiId ?? "") + && a.emojiName === b.emojiName + && String(a.expiresAtMs ?? "0") === String(b.expiresAtMs ?? "0"); +} + +function resolvePendingAvatar(pendingChanges: PendingChanges | null): ImageInput { + if (!pendingChanges) return null; + + const candidates: ImageInput[] = [ + pendingChanges.selectedAvatarRaw, + pendingChanges.presetAvatarRaw, + pendingChanges.pendingAvatar, + pendingChanges.avatar, + pendingChanges.selectedAvatarProcessed, + pendingChanges.presetAvatarProcessed + ]; + return candidates.find(hasImageInput) ?? null; +} + +function normalizeImageValue(value: unknown): string | null { + if (typeof value === "string") return value; + if (value && typeof value === "object" && "imageUri" in value) { + const { imageUri } = value as { imageUri: unknown; }; + return typeof imageUri === "string" ? imageUri : null; + } + return null; +} + +function collectibleEqBySku(a: { skuId?: string | number | null; } | null | undefined, b: { skuId?: string | number | null; } | null | undefined): boolean { + if (a == null || b == null) return a == null && b == null; + return String(a.skuId ?? "") === String(b.skuId ?? ""); +} + +function avatarDecorationEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean { + if (a == null || b == null) return a == null && b == null; + return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? ""); +} + +function nameplateEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean { + if (a == null || b == null) return a == null && b == null; + return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? ""); +} + +export async function loadPresetAsPending(preset: ProfilePreset, guildId?: string, options: LoadPresetOptions = {}) { + try { + const isGuild = options.isGuildProfile ?? Boolean(guildId); + if (isGuild && !guildId) return; + const currentUser = UserStore.getCurrentUser(); + const baseProfile = UserProfileStore.getUserProfile(currentUser.id); + const guildProfile = isGuild && guildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, guildId) : null; + const equippedProfile = guildProfile ?? baseProfile; + const current = await getCurrentProfile(guildId, { + isGuildProfile: isGuild + }); + const pendingChanges = (isGuild && guildId + ? UserProfileSettingsStore.getPendingChanges(guildId) + : UserProfileSettingsStore.getPendingChanges()); + const equippedAvatarDecoration = currentUser.avatarDecorationData ?? null; + const equippedProfileEffect = equippedProfile?.profileEffect ?? null; + const equippedNameplate = currentUser.collectibles?.nameplate ?? null; + const pendingAvatarDecoration = pendingChanges?.pendingAvatarDecoration ?? null; + const pendingProfileEffect = pendingChanges?.pendingProfileEffect ?? null; + const pendingNameplate = pendingChanges?.pendingNameplate ?? null; + const setPending = (field: string, payload: Record) => { + const cleanPayload = Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== undefined)); + if (!Object.keys(cleanPayload).length) return; + dispatch(`USER_PROFILE_SETTINGS_SET_PENDING_${field}`, isGuild ? { ...cleanPayload, guildId } : cleanPayload); + }; + + if ("avatarDataUrl" in preset) { + const avatarValue = preset.avatarDataUrl; + const presetAvatar = normalizeImageValue(avatarValue); + const currentAvatar = normalizeImageValue(current.avatarDataUrl); + const pendingAvatar = normalizeImageValue(resolvePendingAvatar(pendingChanges)); + if (isGuild || (presetAvatar !== currentAvatar && presetAvatar !== pendingAvatar)) { + const avatarPayload = + typeof avatarValue === "string" && avatarValue.startsWith("data:") + ? { + assetOrigin: "NEW_ASSET", + imageUri: avatarValue, + description: `profilesets-${preset.name ?? "preset"}` + } + : avatarValue; + setPending("AVATAR", { avatar: avatarPayload }); + } + } + + if ("bannerDataUrl" in preset && preset.bannerDataUrl !== current.bannerDataUrl) { + setPending("BANNER", { banner: preset.bannerDataUrl }); + } + + if (!options.skipBio && preset?.bio !== current?.bio) { + const bioValue = preset.bio === null ? "" : preset.bio; + setPending("BIO", { bio: bioValue }); + } + + if (!isGuild && !options.skipPronouns && preset?.pronouns !== current?.pronouns) { + const pronounsValue = preset.pronouns === null ? "" : preset.pronouns; + setPending("PRONOUNS", { pronouns: pronounsValue }); + } + + if (!options.skipGlobalName && preset?.globalName !== current?.globalName) { + if (isGuild) { + setPending("NICKNAME", { nickname: preset.globalName }); + } else { + setPending("GLOBAL_NAME", { globalName: preset.globalName }); + } + } + + if (preset.avatarDecoration && ![pendingAvatarDecoration, equippedAvatarDecoration, current.avatarDecoration].some(d => avatarDecorationEq(preset.avatarDecoration, d))) { + setPending("COLLECTIBLES_ITEM", { + item: { type: 0, value: preset.avatarDecoration } + }); + } + + if (preset.profileEffect && ![pendingProfileEffect, equippedProfileEffect, current.profileEffect].some(d => collectibleEqBySku(preset.profileEffect, d))) { + setPending("COLLECTIBLES_ITEM", { + item: { type: 1, value: preset.profileEffect } + }); + } + + if (preset.nameplate && ![pendingNameplate, equippedNameplate, current.nameplate].some(d => nameplateEq(preset.nameplate, d))) { + setPending("COLLECTIBLES_ITEM", { + item: { type: 2, value: preset.nameplate } + }); + } + + if (preset.displayNameStyles) { + const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles); + if (!jsonEq(presetDisplayNameStyles, current.displayNameStyles)) { + setPending("DISPLAY_NAME_STYLES", { displayNameStyles: presetDisplayNameStyles }); + } + } + + if (preset.themeColors && !jsonEq(preset.themeColors, current.themeColors)) { + setPending("THEME_COLORS", { themeColors: preset.themeColors }); + } + + if (preset.primaryGuildId && !isGuild && preset.primaryGuildId !== current.primaryGuildId) { + dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId }); + } + + if (preset.customStatus && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) { + CustomStatusSettings.updateSetting({ + text: preset.customStatus?.text ?? "", + expiresAtMs: preset.customStatus?.expiresAtMs ?? "0", + emojiId: preset.customStatus?.emojiId ?? "0", + emojiName: preset.customStatus?.emojiName ?? "" + }); + } + } catch (err) { + throw err; + } +} diff --git a/src/equicordplugins/profileSets/utils/storage.ts b/src/equicordplugins/profileSets/utils/storage.ts new file mode 100644 index 000000000..ff5cc965f --- /dev/null +++ b/src/equicordplugins/profileSets/utils/storage.ts @@ -0,0 +1,120 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { DataStore } from "@api/index"; +import { Logger } from "@utils/Logger"; +import { ProfilePreset } from "@vencord/discord-types"; +import { UserStore } from "@webpack/common"; + +const logger = new Logger("ProfilePresets"); +const LEGACY_PRESETS_KEY = "ProfileDataset"; +const MAIN_PRESETS_KEY = "ProfilePresets_v2_Main"; +const SERVER_PRESETS_KEY = "ProfilePresets_v2_Server"; + +export type PresetSection = "main" | "server"; + +export type ProfilePresetEx = ProfilePreset & { + avatarRaw?: string | null; +}; + +export let presets: ProfilePresetEx[] = []; +export let currentPresetIndex = -1; +let activeScopeKey: string | null = null; + +function resetPresets(nextPresets: ProfilePresetEx[] = []) { + presets = nextPresets; + currentPresetIndex = -1; +} + +function getPresetsKey(section: PresetSection, userId: string) { + const baseKey = section === "main" ? MAIN_PRESETS_KEY : SERVER_PRESETS_KEY; + return `${baseKey}:${userId}`; +} + +function getLegacyKey(userId: string) { + return `${LEGACY_PRESETS_KEY}:${userId}:main`; +} + +export async function loadPresets(section: PresetSection) { + try { + const currentUser = UserStore.getCurrentUser(); + const userId = currentUser!.id; + const key = getPresetsKey(section, userId); + activeScopeKey = key; + const stored = await DataStore.get(key); + if (stored && Array.isArray(stored)) { + resetPresets(stored); + return; + } + + if (section === "main") { + const legacyKey = getLegacyKey(userId); + const legacyStored = await DataStore.get(legacyKey); + const legacyBaseStored = await DataStore.get(LEGACY_PRESETS_KEY); + const legacyToUse = Array.isArray(legacyStored) + ? legacyStored + : (Array.isArray(legacyBaseStored) ? legacyBaseStored : null); + if (legacyToUse) { + resetPresets(legacyToUse); + await DataStore.set(key, legacyToUse); + await DataStore.del(legacyKey); + await DataStore.del(LEGACY_PRESETS_KEY); + return; + } + } + resetPresets(); + } catch (err) { + logger.error("Failed to load presets", err); + resetPresets(); + } +} + +export async function savePresetsData(section?: PresetSection) { + try { + if (!activeScopeKey && !section) return; + const currentUser = UserStore.getCurrentUser(); + const userId = currentUser!.id; + const key = section ? getPresetsKey(section, userId) : activeScopeKey!; + await DataStore.set(key, presets); + } catch (err) { + logger.error("Failed to save presets", err); + } +} + +export function setCurrentPresetIndex(index: number) { + currentPresetIndex = index; +} + +export function addPreset(preset: ProfilePresetEx) { + presets.push(preset); +} + +export function updatePreset(index: number, preset: ProfilePresetEx) { + if (index >= 0 && index < presets.length) { + presets[index] = preset; + } +} + +export function removePreset(index: number) { + if (index >= 0 && index < presets.length) { + presets.splice(index, 1); + if (currentPresetIndex === index) { + currentPresetIndex = -1; + } else if (currentPresetIndex > index) { + currentPresetIndex--; + } + } +} + +export function movePresetInArray(fromIndex: number, toIndex: number) { + if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return; + const [preset] = presets.splice(fromIndex, 1); + presets.splice(toIndex, 0, preset); +} + +export function replaceAllPresets(newPresets: ProfilePresetEx[]) { + presets = newPresets; +} diff --git a/src/equicordplugins/questionMarkReplacement/index.tsx b/src/equicordplugins/questionMarkReplacement/index.tsx new file mode 100644 index 000000000..1c9acf14f --- /dev/null +++ b/src/equicordplugins/questionMarkReplacement/index.tsx @@ -0,0 +1,46 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents"; +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; + +const settings = definePluginSettings({ + replace: { + type: OptionType.STRING, + description: "Replace with", + default: ":face_with_monocle:" + }, +}); + +function replaceQuestionMarks(content: string): string { + const allQuestionMarks = content.split("").every(char => char === "?"); + + if (allQuestionMarks) { + return content.replace(/\?/g, settings.store.replace); + } else { + return content; + } +} + +export default definePlugin({ + name: "QuestionMarkReplacement", + description: "Replace all question marks with chosen string, if message only contains question marks.", + authors: [Devs.nyx], + + settings, + + start() { + this.preSend = addMessagePreSendListener((_, msg) => { + msg.content = replaceQuestionMarks(msg.content); + }); + }, + + stop() { + removeMessagePreSendListener(this.preSend); + } +}); diff --git a/src/equicordplugins/screenRecorder.equibop/index.tsx b/src/equicordplugins/screenRecorder.equibop/index.tsx new file mode 100644 index 000000000..a24ef052b --- /dev/null +++ b/src/equicordplugins/screenRecorder.equibop/index.tsx @@ -0,0 +1,66 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu"; +import { ScreenshareIcon } from "@components/Icons"; +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { Menu, UploadHandler } from "@webpack/common"; + +let recoder: MediaRecorder; + +export default definePlugin({ + name: "ScreenRecorder", + description: "epic screen recorder lol", + authors: [Devs.AutumnVN], + contextMenus: { + "channel-attach": startRecording + } +}); + +function startRecording(children) { + children.push( + + +
Start Recording
+ + } + action={async () => { + const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: { frameRate: { ideal: 60 } } }); + recoder = new MediaRecorder(stream); + recoder.start(); + removeContextMenuPatch("channel-attach", startRecording); + addContextMenuPatch("channel-attach", stopRecording); + }} + /> + ); +} + +function stopRecording(children, props) { + children.push( + + +
Stop Recording
+ + } + action={() => { + recoder.addEventListener("dataavailable", e => { + const file = new File([e.data], "watch if cute.webm", { type: "video/webm" }); + UploadHandler.promptToUpload([file], props.channel, 0); + }); + recoder.stop(); + removeContextMenuPatch("channel-attach", stopRecording); + addContextMenuPatch("channel-attach", startRecording); + }} + /> + ); +} diff --git a/src/equicordplugins/serverSearch/index.tsx b/src/equicordplugins/serverSearch/index.tsx new file mode 100644 index 000000000..48cbe348f --- /dev/null +++ b/src/equicordplugins/serverSearch/index.tsx @@ -0,0 +1,64 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated, camila314, and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { FluxDispatcher } from "@webpack/common"; +import { Tooltip } from "webpack/common/components"; + +function SearchIcon() { + return ( + + + + ); +} + +export default definePlugin({ + name: "ServerSearch", + authors: [EquicordDevs.camila314], + description: "Navigate your servers better with a quick search button", + + renderButton() { + return +
+ + {({ onMouseEnter, onMouseLeave }) => ( +
+ FluxDispatcher.dispatch({ + type: "QUICKSWITCHER_SHOW", + query: "", + queryMode: null + }) + }> + +
+ )} +
+
+
; + }, + + start() { + addServerListElement(ServerListRenderPosition.Above, this.renderButton); + }, + + stop() { + removeServerListElement(ServerListRenderPosition.Above, this.renderButton); + } +}); diff --git a/src/equicordplugins/serverSearch/styles.css b/src/equicordplugins/serverSearch/styles.css new file mode 100644 index 000000000..545036734 --- /dev/null +++ b/src/equicordplugins/serverSearch/styles.css @@ -0,0 +1,33 @@ +#vc-searchbutton-container { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; +} + +#vc-searchbutton { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--background-primary); + height: 48px; + width: 48px; + border-radius: 100%; + transition: border-radius 0.15s ease-out, background-color 0.15s ease-out; +} + +#vc-searchbutton-icon { + color: var(--text-default); +} + +#vc-searchbutton:hover { + border-radius: 34%; + background-color: var(--green-360); + cursor: pointer; +} + +#vc-searchbutton:hover #vc-searchbutton-icon { + color: var(--white-500); +} \ No newline at end of file diff --git a/src/equicordplugins/signature/index.tsx b/src/equicordplugins/signature/index.tsx new file mode 100644 index 000000000..1a7d28605 --- /dev/null +++ b/src/equicordplugins/signature/index.tsx @@ -0,0 +1,155 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; +import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, sendBotMessage } from "@api/Commands"; +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; +import { Devs, EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { Menu, React } from "@webpack/common"; + +// Big thank you too slientTyping + +const settings = definePluginSettings( + { + name: { + type: OptionType.STRING, + description: "The signature that will be added to the end of your messages", + default: "a chronic discord user" + }, + textHeader: { + description: "What header to preface text with", + type: OptionType.STRING, + default: ">", + }, + showIcon: { + type: OptionType.BOOLEAN, + default: true, + description: "Show an icon for toggling the plugin in the chat bar", + restartNeeded: true, + }, + contextMenu: { + type: OptionType.BOOLEAN, + description: "Add option to toggle the functionality in the chat input context menu", + default: true + }, + isEnabled: { + type: OptionType.BOOLEAN, + description: "Toggle functionality", + default: true, + }, + }); + +const SignatureToggle: ChatBarButtonFactory = ({ isMainChat }) => { + const { isEnabled, showIcon } = settings.use(["isEnabled", "showIcon"]); + const toggle = () => settings.store.isEnabled = !settings.store.isEnabled; + + if (!isMainChat || !showIcon) return null; + + return ( + + + + {isEnabled && ( + <> + + + + + )} + + + ); +}; + +function SignatureIcon() { + return ( + + + + ); +} + +const ChatBarContextCheckbox: NavContextMenuPatchCallback = children => { + const { isEnabled, contextMenu } = settings.use(["isEnabled", "contextMenu"]); + if (!contextMenu) return; + + const group = findGroupChildrenByChildId("submit-button", children); + + if (!group) return; + + const idx = group.findIndex(c => c?.props?.id === "submit-button"); + + group.splice(idx + 1, 0, + settings.store.isEnabled = !settings.store.isEnabled} + /> + ); +}; + +export default definePlugin({ + name: "Signature", + description: "Automated fingerprint/end text", + authors: [Devs.Ven, Devs.Rini, Devs.ImBanana, EquicordDevs.KrystalSkull], + onBeforeMessageSend(channelId, msg) { + if (!settings.store.isEnabled) { + msg.content = msg.content; + } else { + msg.content = textProcessing(msg.content); + } + }, + start: () => { + if (settings.store.isEnabled) true; + }, + stop: () => { + if (settings.store.isEnabled) false; + }, + + chatBarButton: { + icon: SignatureIcon, + render: SignatureToggle + }, + + settings, + + contextMenus: { + "textarea-context": ChatBarContextCheckbox + }, + + commands: [ + { + name: "signature", + description: "Toggle your signature", + inputType: ApplicationCommandInputType.BUILT_IN, + options: [ + { + name: "value", + description: "Toggle your signature (default is toggle)", + required: false, + type: ApplicationCommandOptionType.BOOLEAN, + }, + ], + execute: async (args, ctx) => { + settings.store.isEnabled = !!findOption(args, "value", !settings.store.isEnabled); + sendBotMessage(ctx.channel.id, { + content: settings.store.isEnabled ? "Signature enabled!" : "Signature disabled!", + }); + }, + } + ], +}); + +// text processing injection processor +function textProcessing(input: string) { + return `${input}\n${settings.store.textHeader ? settings.store.textHeader + " " : ""}${settings.store.name}`; +} diff --git a/src/equicordplugins/snowfall/index.tsx b/src/equicordplugins/snowfall/index.tsx new file mode 100644 index 000000000..7f103e9f4 --- /dev/null +++ b/src/equicordplugins/snowfall/index.tsx @@ -0,0 +1,490 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Heading } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { createRoot, React } from "@webpack/common"; +import type { Root } from "react-dom/client"; + +const SnowfallCSS = ` +#snowfield { + pointer-events: none; + user-select: none; + z-index: 100000; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; +} +.snowflake { + position: absolute; + color: #fff; + line-height: 1; + -webkit-font-smoothing: antialiased; +} +.snowflake-solid { + border-radius: 50%; + background: #ffffff; +} +.snowflake-image { + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.5)); +} +`; + +// SVG snowflake images as data URIs +const SNOWFLAKE_SVGS = [ + // 6-pointed snowflake + "data:image/svg+xml," + encodeURIComponent(` + + + + + `), + // 8-pointed star snowflake + "data:image/svg+xml," + encodeURIComponent(` + + + + + + + + + + `), + // Discord snowflake + "data:image/svg+xml," + encodeURIComponent(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `), +]; + +const settings = definePluginSettings({ + typeOfSnow: { + description: "Change the type of snow displayed (Affects performance).", + type: OptionType.SELECT, + options: [ + { label: "Solid (Highest Performance)", value: "solid" }, + { label: "Text (Medium Performance)", value: "text", default: true }, + { label: "Image (Lowest Performance)", value: "image" } + ], + }, + maxSize: { + description: "Maximum snowflake size", + type: OptionType.SLIDER, + default: 30, + markers: [10, 20, 30, 40, 50] + }, + speed: { + description: "Snowfall speed (higher = faster fall)", + type: OptionType.SLIDER, + default: 50, + markers: [50, 100, 200, 300, 400, 500] + }, + flakesPerSecond: { + description: "Snowflakes per second (higher = denser snowfall)", + type: OptionType.SLIDER, + default: 5, + markers: [1, 5, 10, 20, 40, 60], + min: 1, + max: 60, + } +}); + +class CopleSnow { + private static winWidth = window.innerWidth; + private static winHeight = window.innerHeight; + + static readonly defaultOptions = { + minSize: 10, + maxSize: 30, + type: "text" as "text" | "solid" | "image", + content: "❄" as string | string[], + fadeOut: true, + autoplay: true, + interval: 200 + }; + + private static cssPrefix(propertyName: string): string | null { + const capitalize = propertyName.charAt(0).toUpperCase() + propertyName.slice(1); + const tempDiv = document.createElement("div"); + const { style } = tempDiv; + const prefixes = ["Webkit", "Moz", "ms", "O"]; + + if (propertyName in style) return propertyName; + for (const prefix of prefixes) { + const name = prefix + capitalize; + if (name in style) return name; + } + return null; + } + + private static readonly cssPrefixedNames = { + transform: this.cssPrefix("transform"), + transition: this.cssPrefix("transition") + }; + + private static readonly transitionEndEvent = + { WebkitTransition: "webkitTransitionEnd", OTransition: "oTransitionEnd", Moztransition: "transitionend", transition: "transitionend" }[ + this.cssPrefixedNames.transition ?? "transition" + ] ?? "transitionend"; + + private static random(min: number, max: number, deviation?: number): number { + if (deviation !== undefined) { + deviation *= max; + max += deviation; + min = max - deviation; + } else { + min = min || 0; + } + return Math.floor(Math.random() * (max - min + 1) + min); + } + + private static setStyle(element: HTMLElement, rules: Record) { + for (const [name, value] of Object.entries(rules)) { + const cssName = CopleSnow.cssPrefixedNames[name as keyof typeof CopleSnow.cssPrefixedNames] || name; + (element.style as any)[cssName] = value; + } + } + + private options = { ...CopleSnow.defaultOptions }; + private queue: HTMLElement[] = []; + private $snowfield: HTMLDivElement; + private timer: number | null = null; + public playing = false; + + constructor(newOptions: Partial = {}) { + Object.assign(this.options, newOptions); + + this.$snowfield = document.createElement("div"); + this.$snowfield.id = "snowfield"; + document.body.appendChild(this.$snowfield); + + const updateSize = () => { + CopleSnow.winHeight = window.innerHeight; + CopleSnow.winWidth = window.innerWidth; + }; + window.addEventListener("resize", updateSize); + (this as any)._resizeHandler = updateSize; + + this.$snowfield.addEventListener(CopleSnow.transitionEndEvent, e => { + const snowflake = e.target as HTMLElement; + if (snowflake.classList.contains("snowflake")) { + this.$snowfield.removeChild(snowflake); + this.queue.push(snowflake); + } + }); + + const handleVisibilityChange = () => { + if (document.hidden) this.stop(); + else this.play(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + (this as any)._visibilityHandler = handleVisibilityChange; + + if (this.options.autoplay) this.play(); + } + + private createSnowflake(): HTMLElement { + const { type, content } = this.options as { type: "text" | "image" | "solid"; content: string | string[]; }; + const cntLength = Array.isArray(content) ? content.length : 1; + + let snowflake: HTMLElement; + + if (type === "image") { + snowflake = document.createElement("img"); + const src = typeof content === "string" + ? content + : content[Math.floor(Math.random() * cntLength)]; + (snowflake as HTMLImageElement).src = src; + snowflake.setAttribute("draggable", "false"); + } else { + snowflake = document.createElement("div"); + if (type === "text") { + const textContent = typeof content === "string" + ? content + : content[Math.floor(Math.random() * cntLength)]; + snowflake.textContent = textContent; + } + // if type is solid we don't need to set content + } + + snowflake.className = `snowflake snowflake-${type}`; + (snowflake as any).dataset.type = type; + + return snowflake; + } + + private animateSnowflake() { + const { winWidth, winHeight } = CopleSnow; + const size = CopleSnow.random(this.options.minSize, this.options.maxSize); + const top = -2 * size; + const left = CopleSnow.random(0, winWidth - size); + const opacity = CopleSnow.random(5, 10) / 10; + const angle = CopleSnow.random(0, winHeight * 0.8, 1); + const translateX = CopleSnow.random(-100, 100); + const translateY = winHeight + size * 2; + const baseSpeed = settings.store.speed; + const duration = (winHeight * 10) / (baseSpeed / 50); + let snowflake: HTMLElement; + if (this.queue.length > 0) { + const reused = this.queue.shift()!; + if ((reused.dataset.type as string) !== this.options.type) { + snowflake = this.createSnowflake(); + } else { + snowflake = reused; + if (this.options.type === "text") { + const { content } = this.options; + if (Array.isArray(content)) { + snowflake.textContent = content[Math.floor(Math.random() * content.length)]; + } + } else if (this.options.type === "image") { + const { content } = this.options; + if (Array.isArray(content)) { + (snowflake as HTMLImageElement).src = content[Math.floor(Math.random() * content.length)]; + } + } + } + } else { + snowflake = this.createSnowflake(); + } + + const styleRules: Record = { + top: `${top}px`, + left: `${left}px`, + opacity: opacity, + transform: "none", + transition: `${duration}ms linear` + }; + + switch (this.options.type) { + case "solid": + styleRules.width = styleRules.height = `${size}px`; + break; + case "text": + styleRules.fontSize = `${size}px`; + break; + case "image": + styleRules.width = `${size}px`; + styleRules.height = "auto"; + break; + } + + CopleSnow.setStyle(snowflake, styleRules); + this.$snowfield.appendChild(snowflake); + + setTimeout(() => { + CopleSnow.setStyle(snowflake, { + transform: `translate(${translateX}px, ${translateY}px) rotate(${angle}deg)`, + opacity: this.options.fadeOut ? 0 : opacity + }); + }, 100); + } + + play() { + if (this.playing) return; + this.timer = window.setInterval(() => this.animateSnowflake(), this.options.interval); + this.playing = true; + } + + stop() { + if (!this.playing) return; + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + this.playing = false; + } + + destroy() { + this.stop(); + if (this.$snowfield.parentNode) { + this.$snowfield.remove(); + } + window.removeEventListener("resize", (this as any)._resizeHandler); + document.removeEventListener("visibilitychange", (this as any)._visibilityHandler); + } + + updateOptions(newOptions: Partial) { + Object.assign(this.options, newOptions); + } +} + +const SnowfallManager: React.FC = () => { + const snowRef = React.useRef(null); + const styleRef = React.useRef(null); + + React.useEffect(() => { + // Inject CSS + const styleEl = document.createElement("style"); + styleEl.id = "snowfall-styles"; + styleEl.textContent = SnowfallCSS; + document.head.appendChild(styleEl); + styleRef.current = styleEl; + + // Get initial snow type from settings + const snowType = settings.store.typeOfSnow as "text" | "solid" | "image"; + const snowOptions: Partial = { + autoplay: false, + type: snowType, + maxSize: settings.store.maxSize, + interval: 1000 / settings.store.flakesPerSecond + }; + + snowOptions.interval = settings.store.speed; + + // Set content based on type + if (snowType === "image") { + snowOptions.content = SNOWFLAKE_SVGS; + } + + // Create snow instance with settings + const snow = new CopleSnow(snowOptions); + snowRef.current = snow; + + const blurHandler = () => snow.stop(); + const focusHandler = () => { + if (document.hasFocus()) snow.play(); + }; + + window.addEventListener("blur", blurHandler); + window.addEventListener("focus", focusHandler); + + if (document.hasFocus()) { + snow.play(); + } + + // Custom listener for settings changes (we don't have a built-in one) + let lastSettings = { + type: settings.store.typeOfSnow as "text" | "solid" | "image", + maxSize: settings.store.maxSize, + speed: settings.store.speed, + flakesPerSecond: settings.store.flakesPerSecond + }; + + const settingsInterval = setInterval(() => { + const newSettings = { + type: settings.store.typeOfSnow as "text" | "solid" | "image", + maxSize: settings.store.maxSize, + speed: settings.store.speed, + flakesPerSecond: settings.store.flakesPerSecond + }; + + if (Object.keys(newSettings).some(k => newSettings[k as keyof typeof newSettings] !== lastSettings[k as keyof typeof lastSettings])) { + lastSettings = newSettings; + + const updateOptions: Partial = { + type: newSettings.type, + maxSize: newSettings.maxSize, + interval: 1000 / newSettings.flakesPerSecond + }; + + if (newSettings.type === "image") updateOptions.content = SNOWFLAKE_SVGS; + else if (newSettings.type === "text") updateOptions.content = "❄"; + + snow.updateOptions(updateOptions); + } + }, 5000); + + return () => { + snow.destroy(); + snowRef.current = null; + + window.removeEventListener("blur", blurHandler); + window.removeEventListener("focus", focusHandler); + + clearInterval(settingsInterval); + + if (styleEl.parentNode) { + styleEl.remove(); + } + styleRef.current = null; + + const snowfield = document.getElementById("snowfield"); + if (snowfield) snowfield.remove(); + }; + }, []); + + return null; +}; + +let snowRoot: Root | null = null; +let container: HTMLDivElement | null = null; + +export default definePlugin({ + name: "Snowfall", + description: "Let it snow on Discord! Ported from the BetterDiscord plugin by square.", + authors: [EquicordDevs.ZcraftElite, EquicordDevs.square], + + settingsAboutComponent: () => ( + <> + Information + + This plugin adds a christmas-y snowfall effect on top of Discord's interface. + You can change the type of snow in the settings below. +

+ NOTE: While on most computers this plugin will not impact performance any more than your average Equicord extension, + it may cause some lag on lower end systems. +
+ + ), + + settings, + + start() { + container = document.createElement("div"); + container.id = "snowfall-plugin-container"; + document.body.appendChild(container); + + snowRoot = createRoot(container); + snowRoot.render(); + }, + + stop() { + if (snowRoot) { + snowRoot.unmount(); + snowRoot = null; + } + if (container) { + if (container.parentNode) container.remove(); + container = null; + } + } +}); diff --git a/src/equicordplugins/soggy/index.tsx b/src/equicordplugins/soggy/index.tsx new file mode 100644 index 000000000..a622197d9 --- /dev/null +++ b/src/equicordplugins/soggy/index.tsx @@ -0,0 +1,167 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer"; +import { HeaderBarButton } from "@api/HeaderBar"; +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { ModalProps, ModalRoot, openModal } from "@utils/modal"; +import definePlugin, { OptionType } from "@utils/types"; +import { React } from "@webpack/common"; +let boopSound: AudioPlayerInterface; +let song: AudioPlayerInterface; + +function assignSong(url: string, volume: number) { + song?.delete(); + song = createAudioPlayer(url, { volume, preload: true, persistent: true }); + song.load(); +} + +function assignBoop(url: string, volume: number) { + boopSound?.delete(); + boopSound = createAudioPlayer(url, { volume, preload: true, persistent: true }); + boopSound.load(); +} + +function SoggyModal(props: ModalProps) { + if (settings.store.songVolume !== 0) { + React.useEffect(() => { + song?.loop(); + + return () => { + song?.stop(); + }; + }, []); + } + + const boop = (e: React.MouseEvent) => { + const { offsetX, offsetY } = e.nativeEvent; + + const region = { x: 155, y: 220, width: 70, height: 70 }; + + if ( + settings.store.boopVolume !== 0 && + offsetX >= region.x && + offsetX <= region.x + region.width && + offsetY >= region.y && + offsetY <= region.y + region.height + ) { + boopSound?.play(); + } + }; + + return ( + + + + ); +} + +function buildSoggyModal(): any { + openModal(props => ); +} + +function SoggyButton() { + return ( + ( + + )} + onClick={() => buildSoggyModal()} + selected={false} + /> + ); +} + +const settings = definePluginSettings({ + songVolume: { + description: "Volume of the song. 0 to disable", + type: OptionType.SLIDER, + default: 0.25, + markers: [0, 0.25, 0.5, 0.75, 1], + stickToMarkers: false, + + }, + boopVolume: { + description: "Volume of the boop sound", + type: OptionType.SLIDER, + default: 0.2, + markers: [0, 0.25, 0.5, 0.75, 1], + stickToMarkers: false, + }, + tooltipText: { + description: "The text shown when hovering over the button", + type: OptionType.STRING, + default: "the soggy", + }, + imageLink: { + description: "URL for the image (button and modal)", + type: OptionType.STRING, + default: "https://equicord.org/assets/plugins/soggy/cat.png", + }, + songLink: { + description: "URL for the song to play", + type: OptionType.STRING, + default: "https://github.com/Equicord/Equibored/raw/main/sounds/soggy/song.mp3?raw=true", + onChange(newValue) { + assignSong(newValue, settings.store.songVolume * 100); + }, + }, + boopLink: { + description: "URL for the boop sound", + type: OptionType.STRING, + default: "https://github.com/Equicord/Equibored/raw/main/sounds/soggy/honk.wav?raw=true", + onChange(newValue) { + assignBoop(newValue, settings.store.boopVolume * 100); + } + } +}); + +export default definePlugin({ + name: "Soggy", + description: "Adds a soggy button to the toolbox", + authors: [EquicordDevs.sliwka], + settings, + dependencies: ["AudioPlayerAPI"], + + headerBarButton: { + icon: () => ( + + ), + render: SoggyButton + }, + + start() { + assignBoop(settings.store.boopLink, settings.store.boopVolume * 100); + assignSong(settings.store.songLink, settings.store.songVolume * 100); + }, + + stop() { + boopSound?.delete(); + song?.delete(); + }, +}); diff --git a/src/equicordplugins/talkInReverse/index.tsx b/src/equicordplugins/talkInReverse/index.tsx new file mode 100644 index 000000000..c469c9b31 --- /dev/null +++ b/src/equicordplugins/talkInReverse/index.tsx @@ -0,0 +1,74 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; +import { addMessagePreSendListener, MessageSendListener, removeMessagePreSendListener } from "@api/MessageEvents"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { React, useEffect, useState } from "@webpack/common"; + +let lastState = false; + +const ReverseMessageToggle: ChatBarButtonFactory = ({ isMainChat }) => { + const [enabled, setEnabled] = useState(lastState); + + function setEnabledValue(value: boolean) { + lastState = value; + + setEnabled(value); + } + + useEffect(() => { + const listener: MessageSendListener = async (_, message) => { + if (enabled && message.content) message.content = message.content.split("").reverse().join(""); + }; + + addMessagePreSendListener(listener); + return () => void removeMessagePreSendListener(listener); + }, [enabled]); + + if (!isMainChat) return null; + + return ( + setEnabledValue(!enabled)} + > + + + ); +}; + +function ReverseMessageIcon() { + return ( + + + + ); +} + +export default definePlugin({ + name: "TalkInReverse", + authors: [EquicordDevs.Tolgchu], + description: "Reverses the message content before sending it.", + dependencies: ["MessageEventsAPI"], + chatBarButton: { + icon: ReverseMessageIcon, + render: ReverseMessageToggle + } +}); diff --git a/src/equicordplugins/toggleVideoBind/index.ts b/src/equicordplugins/toggleVideoBind/index.ts new file mode 100644 index 000000000..3a56c4e8f --- /dev/null +++ b/src/equicordplugins/toggleVideoBind/index.ts @@ -0,0 +1,70 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { findByPropsLazy } from "@webpack"; +import { FluxDispatcher } from "@webpack/common"; + +const validKeycodes = [ + "Backspace", "Tab", "Enter", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Pause", "CapsLock", + "Escape", "Space", "PageUp", "PageDown", "End", "Home", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "PrintScreen", "Insert", + "Delete", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC", + "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", + "KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ", "MetaLeft", "MetaRight", "ContextMenu", "Numpad0", "Numpad1", "Numpad2", "Numpad3", + "Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "NumpadMultiply", "NumpadAdd", "NumpadSubtract", "NumpadDecimal", + "NumpadDivide", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "NumLock", "ScrollLock", +]; + +const settings = definePluginSettings({ + keyBind: { + description: "The key to toggle webcam when pressed.", + type: OptionType.STRING, + default: "KeyX", + isValid: (value: string) => validKeycodes.includes(value), + }, + reqCtrl: { + description: "Require control to be held.", + type: OptionType.BOOLEAN, + default: true, + }, + reqShift: { + description: "Require shift to be held.", + type: OptionType.BOOLEAN, + default: true, + }, + reqAlt: { + description: "Require alt to be held.", + type: OptionType.BOOLEAN, + default: false, + }, +}); + +const { isVideoEnabled } = findByPropsLazy("isVideoEnabled"); + +function handleKeydown({ code, ctrlKey, shiftKey, altKey }: KeyboardEvent) { + const { keyBind, reqCtrl, reqShift, reqAlt } = settings.store; + if (keyBind !== code || ctrlKey !== reqCtrl || shiftKey !== reqShift || altKey !== reqAlt) { return; } // please don't think about it + + FluxDispatcher.dispatch({ + type: "MEDIA_ENGINE_SET_VIDEO_ENABLED", + enabled: !isVideoEnabled(), + }); +} + +export default definePlugin({ + name: "ToggleVideoBind", + description: "Adds a customizable bind to toggle webcam.", + authors: [EquicordDevs.mochienya], + settings, + start() { + document.addEventListener("keydown", handleKeydown); + }, + stop() { + document.removeEventListener("keydown", handleKeydown); + }, +}); diff --git a/src/equicordplugins/toneIndicators/ToneIndicator.tsx b/src/equicordplugins/toneIndicators/ToneIndicator.tsx new file mode 100644 index 000000000..631b5736e --- /dev/null +++ b/src/equicordplugins/toneIndicators/ToneIndicator.tsx @@ -0,0 +1,36 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Tooltip } from "@webpack/common"; +import type { ReactElement } from "react"; + +export interface ToneIndicatorProps { + prefix: string; + indicator: string; + desc: string; +} + +export default function ToneIndicator({ + prefix, + indicator, + desc, +}: ToneIndicatorProps): ReactElement { + return ( + + {tooltipProps => ( + + {prefix}{indicator} + + )} + + ); +} diff --git a/src/equicordplugins/toneIndicators/index.tsx b/src/equicordplugins/toneIndicators/index.tsx new file mode 100644 index 000000000..6dc0ba1a0 --- /dev/null +++ b/src/equicordplugins/toneIndicators/index.tsx @@ -0,0 +1,188 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { React } from "@webpack/common"; +import { type ReactNode } from "react"; + +import indicatorsDefault from "./indicators"; +import ToneIndicator from "./ToneIndicator"; + +const settings = definePluginSettings({ + prefix: { + type: OptionType.STRING, + description: "Prefix character(s) for tone indicators.", + default: "/", + }, + customIndicators: { + type: OptionType.STRING, + description: "Custom tone indicators (format: jk=Joking; srs=Serious)", + default: "", + }, +}); + +function getCustomIndicators(): Record { + const raw = settings.store.customIndicators || ""; + const result: Record = {}; + + raw.split("; ").forEach(entry => { + const [key, ...rest] = entry.split("="); + if (key && rest.length > 0) { + result[key.trim().toLowerCase()] = rest.join("=").trim(); + } + }); + + return result; +} + +function getIndicator(text: string): string | null { + text = text.toLowerCase(); + const customIndicators = getCustomIndicators(); + + return ( + customIndicators[text] || + customIndicators[`_${text}`] || + indicatorsDefault.get(text) || + indicatorsDefault.get(`_${text}`) || + null + ); +} + +function buildIndicatorRegex(): RegExp { + const customIndicators = getCustomIndicators(); + const allIndicators = new Set(); + + indicatorsDefault.forEach((_, key) => { + allIndicators.add(key.replace(/^_/, "")); // remove underscore prefix for aliases + }); + Object.keys(customIndicators).forEach(key => { + allIndicators.add(key.replace(/^_/, "")); // remove underscore prefix for aliases + }); + + // escape special regex characters and sort by length (longest first) + const escaped = Array.from(allIndicators) + .map(ind => ind.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .sort((a, b) => b.length - a.length); // longest first to avoid partial matches (should fix some edge cases) + + const prefix = settings.store.prefix || "/"; + let escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // if prefix is a markdown character, also match escaped version + const isMarkdown = /[*_~`|]/.test(prefix); + if (isMarkdown) { + escapedPrefix = `(?:\\\\${escapedPrefix}|${escapedPrefix})`; + } + + // exclude forward slash from punctuation to prevent sed syntax conflicts (s/find/replace) + const pattern = `(?:^|\\s)${escapedPrefix}(${escaped.join("|")})(?=\\s|$|[^\\s\\w/])`; + return new RegExp(pattern, "giu"); // 'i' = case-insensitive, 'u' = unicode +} + +function splitTextWithIndicators(text: string): ReactNode[] { + const nodes: ReactNode[] = []; + let lastIndex = 0; + const regex = buildIndicatorRegex(); + const prefix = settings.store.prefix || "/"; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text))) { + const indicator = match[1]; + const desc = getIndicator(indicator); + + const fullMatch = match[0]; + const leadingWhitespace = fullMatch.match(/^(\s*)/)?.[1] ?? ""; + + const matchStart = match.index; + const matchEnd = regex.lastIndex; + + if (matchStart > lastIndex) { + nodes.push(text.slice(lastIndex, matchStart)); + } + + if (desc) { + if (leadingWhitespace) nodes.push(leadingWhitespace); + nodes.push( + , + ); + } + + lastIndex = matchEnd; + } + + if (lastIndex < text.length) nodes.push(text.slice(lastIndex)); + + return nodes; +} + +function patchChildrenTree(children: any): any { + const transform = (node: any): any => { + if (node == null) return node; + + if (typeof node === "string") { + const prefix = settings.store.prefix || "/"; + let escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // if prefix is markdown character, also check for escaped version + const isMarkdown = /[*_~`|]/.test(prefix); + if (isMarkdown) { + escapedPrefix = `(?:\\\\${escapedPrefix}|${escapedPrefix})`; + } + + if (!new RegExp(`${escapedPrefix}[\\p{L}_]+`, "iu").test(node)) return node; + const parts = splitTextWithIndicators(node); + return parts.length === 1 ? parts[0] : parts; + } + + if (node?.props?.children != null) { + const c = node.props.children; + if (Array.isArray(c)) { + node.props.children = c.map(transform).flat(); + } else { + node.props.children = transform(c); + } + return node; + } + + return node; + }; + + if (Array.isArray(children)) return children.map(transform).flat(); + return transform(children); +} + +export default definePlugin({ + name: "ToneIndicators", + description: "Show tooltips for tone indicators like /srs, /gen, etc. in sent messages.", + authors: [EquicordDevs.justjxke], + settings, + + patches: [ + { + find: '["strong","em","u","text","inlineCode","s","spoiler"]', + replacement: [ + { + match: /(?=return\{hasSpoilerEmbeds:\i,.{0,15}content:(\i))/, + replace: "$1=$self.patchToneIndicators($1);", + }, + ], + }, + ], + + patchToneIndicators(content: any): any { + try { + return patchChildrenTree(content); + } catch { + return content; + } + }, +}); diff --git a/src/equicordplugins/toneIndicators/indicators.ts b/src/equicordplugins/toneIndicators/indicators.ts new file mode 100644 index 000000000..e03a45015 --- /dev/null +++ b/src/equicordplugins/toneIndicators/indicators.ts @@ -0,0 +1,62 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +const defaultIndicators = new Map( + Object.entries({ + j: "Joking", + hj: "Half Joking", + s: "Sarcastic", + _sarc: "Sarcastic", // Alias for s + gen: "Genuine", + _g: "Genuine", // Alias for gen, + genq: "Genuine Question", + _gq: "Genuine Question", // Alias for genq + srs: "Serious", + nsrs: "Non-Serious", + pos: "Positive Connotation", + _pc: "Positive Connotation", // Alias for pos + neu: "Neutral Connotation", + neg: "Negative Connotation", + _nc: "Negative Connotation", // Alias for neg + p: "Platonic", + r: "Romantic", + a: "Alterous", + c: "Copypasta", + l: "Lyrics", + _ly: "Lyrics", // Alias for l + lh: "Light-Hearted", + nm: "Not Mad", + lu: "A Little Upset", + nbh: "Not Directed At Anybody Here", + nay: "Not Directed At You", + ay: "Directed At You", + nsb: "Not Subtweeting", + x: "Sexual Intent", + _sx: "Sexual Intent", // Alias for x + nx: "Non-Sexual Intent", + _nsx: "Non-Sexual Intent", // Alias for nx + rh: "Rhetorical Question", + _rt: "Rhetorical Question", // Alias for rh + t: "Teasing", + ij: "Inside Joke", + m: "Metaphorically", + li: "Literally", + hyp: "Hyperbole", + ex: "Exaggeration", + f: "Fake", + q: "Quote", + th: "Threat", + cb: "Clickbait", + ref: "Reference", + nf: "Not Forced", + nbr: "Not Being Rude", + ot: "Off-Topic", + nj: "Not Joking", + naj: "Not A Joke", + }), +); + +export default defaultIndicators; diff --git a/src/equicordplugins/translatePlus/index.tsx b/src/equicordplugins/translatePlus/index.tsx new file mode 100644 index 000000000..78e48b120 --- /dev/null +++ b/src/equicordplugins/translatePlus/index.tsx @@ -0,0 +1,68 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import "./style.css"; + +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { Devs, EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { ChannelStore, Menu } from "@webpack/common"; + +import { settings } from "./settings"; +import { Accessory, handleTranslate } from "./utils/accessory"; +import { Icon } from "./utils/icon"; + +const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }) => { + if (!message.content) return; + + const group = findGroupChildrenByChildId("copy-text", children); + if (!group) return; + + group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, ( + handleTranslate(message)} + /> + )); +}; + +export default definePlugin({ + name: "Translate+", + description: "Vencord's translate plugin but with support for artistic languages!", + authors: [Devs.Ven, EquicordDevs.Prince527], + settings, + contextMenus: { + "message": messageCtxPatch + }, + renderMessageAccessory: props => , + messagePopoverButton: { + icon: Icon, + render(message) { + if (!message.content) return null; + return { + label: "Translate", + icon: Icon, + message: message, + channel: ChannelStore.getChannel(message.channel_id), + onClick: () => handleTranslate(message), + }; + } + } +}); diff --git a/src/equicordplugins/translatePlus/misc/languages.ts b/src/equicordplugins/translatePlus/misc/languages.ts new file mode 100644 index 000000000..20b0448dc --- /dev/null +++ b/src/equicordplugins/translatePlus/misc/languages.ts @@ -0,0 +1,173 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +/* +To generate: +- Visit https://translate.google.com/?sl=auto&tl=en&op=translate +- Open Language dropdown +- Open Devtools and use the element picker to pick the root of the language picker +- Right click on the element in devtools and click "Store as global variable" + +copy(Object.fromEntries( + Array.from( + temp1.querySelectorAll("[data-language-code]"), + e => [e.dataset.languageCode, e.children[1].textContent] + ).sort((a, b) => a[1] === "Detect language" ? -1 : b[1] === "Detect language" ? 1 : a[1].localeCompare(b[1])) +)) +*/ + +export type languages = keyof typeof languages; + +export const languages = { + "auto": "Detect language", + "af": "Afrikaans", + "sq": "Albanian", + "am": "Amharic", + "ar": "Arabic", + "hy": "Armenian", + "as": "Assamese", + "ay": "Aymara", + "az": "Azerbaijani", + "bm": "Bambara", + "eu": "Basque", + "be": "Belarusian", + "bn": "Bengali", + "bho": "Bhojpuri", + "bs": "Bosnian", + "bg": "Bulgarian", + "ca": "Catalan", + "ceb": "Cebuano", + "ny": "Chichewa", + "zh-CN": "Chinese (Simplified)", + "zh-TW": "Chinese (Traditional)", + "co": "Corsican", + "hr": "Croatian", + "cs": "Czech", + "da": "Danish", + "dv": "Dhivehi", + "doi": "Dogri", + "nl": "Dutch", + "en": "English", + "eo": "Esperanto", + "et": "Estonian", + "ee": "Ewe", + "tl": "Filipino", + "tp": "Toki Pona", + "sh": "Shavian", + "fi": "Finnish", + "fr": "French", + "fy": "Frisian", + "gl": "Galician", + "ka": "Georgian", + "de": "German", + "el": "Greek", + "gn": "Guarani", + "gu": "Gujarati", + "ht": "Haitian Creole", + "ha": "Hausa", + "haw": "Hawaiian", + "iw": "Hebrew", + "hi": "Hindi", + "hmn": "Hmong", + "hu": "Hungarian", + "is": "Icelandic", + "ig": "Igbo", + "ilo": "Ilocano", + "id": "Indonesian", + "ga": "Irish", + "it": "Italian", + "ja": "Japanese", + "jw": "Javanese", + "kn": "Kannada", + "kk": "Kazakh", + "km": "Khmer", + "rw": "Kinyarwanda", + "gom": "Konkani", + "ko": "Korean", + "kri": "Krio", + "ku": "Kurdish (Kurmanji)", + "ckb": "Kurdish (Sorani)", + "ky": "Kyrgyz", + "lo": "Lao", + "la": "Latin", + "lv": "Latvian", + "ln": "Lingala", + "lt": "Lithuanian", + "lg": "Luganda", + "lb": "Luxembourgish", + "mk": "Macedonian", + "mai": "Maithili", + "mg": "Malagasy", + "ms": "Malay", + "ml": "Malayalam", + "mt": "Maltese", + "mi": "Maori", + "mr": "Marathi", + "mni-Mtei": "Meiteilon (Manipuri)", + "lus": "Mizo", + "mn": "Mongolian", + "my": "Myanmar (Burmese)", + "ne": "Nepali", + "no": "Norwegian", + "or": "Odia (Oriya)", + "om": "Oromo", + "ps": "Pashto", + "fa": "Persian", + "pl": "Polish", + "pt": "Portuguese", + "pa": "Punjabi", + "qu": "Quechua", + "ro": "Romanian", + "ru": "Russian", + "sm": "Samoan", + "sa": "Sanskrit", + "gd": "Scots Gaelic", + "nso": "Sepedi", + "sr": "Serbian", + "st": "Sesotho", + "sn": "Shona", + "sd": "Sindhi", + "si": "Sinhala", + "sk": "Slovak", + "sl": "Slovenian", + "so": "Somali", + "es": "Spanish", + "su": "Sundanese", + "sw": "Swahili", + "sv": "Swedish", + "tg": "Tajik", + "ta": "Tamil", + "tt": "Tatar", + "te": "Telugu", + "th": "Thai", + "ti": "Tigrinya", + "ts": "Tsonga", + "tr": "Turkish", + "tk": "Turkmen", + "ak": "Twi", + "uk": "Ukrainian", + "ur": "Urdu", + "ug": "Uyghur", + "uz": "Uzbek", + "vi": "Vietnamese", + "cy": "Welsh", + "xh": "Xhosa", + "yi": "Yiddish", + "yo": "Yoruba", + "zu": "Zulu" +} as const; diff --git a/src/equicordplugins/translatePlus/misc/types.ts b/src/equicordplugins/translatePlus/misc/types.ts new file mode 100644 index 000000000..291be4576 --- /dev/null +++ b/src/equicordplugins/translatePlus/misc/types.ts @@ -0,0 +1,19 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; + +export const cl = classNameFactory("eq-trans-"); + +export interface Translation { + text: string; + src: string; +} + +export type IconProps = { + width?: number; + height?: number; +}; diff --git a/src/equicordplugins/translatePlus/settings.ts b/src/equicordplugins/translatePlus/settings.ts new file mode 100644 index 000000000..3fd2f030b --- /dev/null +++ b/src/equicordplugins/translatePlus/settings.ts @@ -0,0 +1,35 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +export const settings = definePluginSettings({ + target: { + type: OptionType.STRING, + description: "Target language", + default: "en", + restartNeeded: true + }, + toki: { + type: OptionType.BOOLEAN, + description: "Enable Toki Pona", + default: true, + restartNeeded: true + }, + sitelen: { + type: OptionType.BOOLEAN, + description: "Enable Sitelen Pona", + default: true, + restartNeeded: true + }, + shavian: { + type: OptionType.BOOLEAN, + description: "Enable Shavian", + default: true, + restartNeeded: true + } +}); diff --git a/src/equicordplugins/translatePlus/style.css b/src/equicordplugins/translatePlus/style.css new file mode 100644 index 000000000..85bdfc2b2 --- /dev/null +++ b/src/equicordplugins/translatePlus/style.css @@ -0,0 +1,22 @@ +.eq-trans-accessory { + color: var(--text-muted); + margin-top: 0.5em; + font-style: italic; + font-weight: 400; + display: block; + white-space: pre-wrap; +} + +.eq-trans-accessory svg { + margin-right: 0.25em; +} + +.eq-trans-dismiss { + all: unset; + cursor: pointer; + color: var(--text-link); +} + +.eq-trans-dismiss:is(:hover, :focus) { + text-decoration: underline; +} \ No newline at end of file diff --git a/src/equicordplugins/translatePlus/utils/accessory.tsx b/src/equicordplugins/translatePlus/utils/accessory.tsx new file mode 100644 index 000000000..7ad167d73 --- /dev/null +++ b/src/equicordplugins/translatePlus/utils/accessory.tsx @@ -0,0 +1,42 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { languages } from "@equicordplugins/translatePlus/misc/languages"; +import { cl, Translation } from "@equicordplugins/translatePlus/misc/types"; +import { Message } from "@vencord/discord-types"; +import { Parser, useEffect, useState } from "@webpack/common"; + +import { SmallIcon } from "./icon"; +import { translate } from "./translator"; + +const setters = new Map(); + +export function Accessory({ message }: { message: Message; }) { + const [translation, setTranslation] = useState(undefined); + + useEffect(() => { + if ((message as any).vencordEmbeddedBy) return; + + setters.set(message.id, setTranslation); + + return () => void setters.delete(message.id); + }, []); + + if (!translation) return null; + + return ( +
+ + {Parser.parse(translation.text)} + {" "} + (translated from {languages[translation.src] ?? translation.src} - ) +
+ ); +} + +export async function handleTranslate(message: Message) { + setters.get(message.id)!(await translate(message.content)); +} diff --git a/src/equicordplugins/translatePlus/utils/icon.tsx b/src/equicordplugins/translatePlus/utils/icon.tsx new file mode 100644 index 000000000..8a08675e4 --- /dev/null +++ b/src/equicordplugins/translatePlus/utils/icon.tsx @@ -0,0 +1,27 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { cl } from "@equicordplugins/translatePlus/misc/types"; + +export function Icon() { + return ( + + + + + + ); +} + +export function SmallIcon() { + return ( + + + + + + ); +} diff --git a/src/equicordplugins/translatePlus/utils/translator.ts b/src/equicordplugins/translatePlus/utils/translator.ts new file mode 100644 index 000000000..6b002d769 --- /dev/null +++ b/src/equicordplugins/translatePlus/utils/translator.ts @@ -0,0 +1,143 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { settings } from "@equicordplugins/translatePlus/settings"; + +function isTokiPona(text: string) { + const dictionary = /\b(?:leko|weka|pan|lete|linja|lipu|suli|nimi|akesi|misikeke|selo|ike|sijelo|sona|lili|pimeja|ante|jo|loje|telo|walo|kijetesantakalu|kasi|waso|wile|utala|lukin|sina|lape|ma|pilin|jasima|la|olin|pipi|meso|lawa|pi|pakala|oko|tan|ken|jaki|unpa|esun|seme|sitelen|len|kule|soko|open|ala|tenpo|lon|sinpin|pini|kokosila|mama|musi|monsi|mewika|taso|ona|mun|kiwen|tomo|mute|mi|nena|palisa|meli|laso|wawa|ale|kipisi|kulupu|ilo|lupa|nanpa|en|mu|jelo|kili|tonsi|moku|ni|kama|pu|poki|monsuta|sin|lasina|poka|soweli|sewi|elena|epiku|moli|pona|lanpan|alasa|anu|kute|uta|luka|suno|sama|awen|namako|suwi|noka|seli|mije|sike|jan|pali|tawa|inli|nasa|mani|wan|insa|nijon|nasin|kalama|ijo|toki|anpa|kala|kepeken|ko|kon|pana|tu|supa|kin|usawi|yupekosi)\b/gm; + + return (text.match(dictionary) || []).length >= text.split(/\s+/).length * 0.5; +} + +function isSitelen(text: string) { + const dictionary = /(?:󱤀|󱤁|󱤂|󱤃|󱤄|󱤅|󱤆|󱤇|󱤈|󱤉|󱤊|󱤋|󱤌|󱤍|󱤎|󱤏|󱤐|󱤑|󱤒|󱤓|󱤔|󱤕|󱤖|󱤗|󱤘|󱤙|󱤚|󱤛|󱤜|󱤝|󱤞|󱤟|󱤠|󱤡|󱤢|󱤣|󱤤|󱤥|󱤦|󱤧|󱤨|󱤩|󱤪|󱤫|󱤬|󱤭|󱤮|󱤯|󱤰|󱤱|󱤲|󱤳|󱤴|󱤵|󱤶|󱤷|󱤸|󱤹|󱤺|󱤻|󱤼|󱤽|󱤾|󱤿|󱥀|󱥁|󱥂|󱥃|󱥄|󱥅|󱥆|󱥇|󱥈|󱥉|󱥊|󱥋|󱥌|󱥍|󱥎|󱥏|󱥐|󱥑|󱥒|󱥓|󱥔|󱥕|󱥖|󱥗|󱥘|󱥙|󱥚|󱥛|󱥜|󱥝|󱥞|󱥟|󱥠|󱥡|󱥢|󱥣|󱥤|󱥥|󱥦|󱥧|󱥨|󱥩|󱥪|󱥫|󱥬|󱥭|󱥮|󱥯|󱥰|󱥱|󱥲|󱥳|󱥴|󱥵|󱥶|󱥷|󱦠|󱦡|󱦢|󱦣|󱥸|󱥹|󱥺|󱥻|󱥼|󱥽|󱥾|󱥿|󱦀|󱦁|󱦂|󱦃|󱦄|󱦅|󱦆|󱦇|󱦈|󱦐|󱦑|󱦒|󱦓|󱦔|󱦕|󱦖|󱦗|󱦘|󱦙|󱦚|󱦛|󱦜|󱦝)/gm; + + return dictionary.test(text); +} + +function isShavian(text: string) { + const shavianRegex = /[\u{10450}-\u{1047F}]+/u; + + return shavianRegex.test(text); +} + +async function translateShavian(message: string) { + const dictionary = await (await fetch("https://raw.githubusercontent.com/ForkPrince/TranslatePlus/322199d5fdb1a9506591c9f4a2826338b5d67e38/shavian.json")).json(); + + const punctuationMap = { + '"': "\"", + "«": "\"", + "»": "\"", + ",": ",", + "!": "!", + "?": "?", + ".": ".", + "(": "(", + ")": ")", + "/": "/", + ";": ";", + ":": ":" + }; + + let translated = ""; + const words = message.split(/\s+/); + + for (let word of words) { + let punctuationBefore = "", punctuationAfter = ""; + + if (word[0] in punctuationMap) { + punctuationBefore = punctuationMap[word[0]]; + word = word.slice(1); + } + + if (word[word.length - 1] in punctuationMap) { + punctuationAfter = punctuationMap[word[word.length - 1]]; + word = word.slice(0, -1); + } + + translated += punctuationBefore; + + if (word in dictionary) translated += dictionary[word]; + else translated += word; + + translated += punctuationAfter + " "; + } + + return translated.trim(); +} + +async function translateSitelen(message: string) { + message = Array.from(message).join(" "); + + const dictionary = await (await fetch("https://raw.githubusercontent.com/ForkPrince/TranslatePlus/5ca152b134ea11433971f21b2ef8d556d4306717/sitelen-pona.json")).json(); + + const sorted = Object.keys(dictionary).sort((a, b) => b.length - a.length); + + const pattern = new RegExp(`(${sorted.join("|")})`, "g"); + + const translate = message.replace(pattern, match => dictionary[match]); + + return translate; +} + +async function google(target: string, text: string) { + if (!text) return { src: "", text: "" }; + try { + const res = await fetch(`https://translate.googleapis.com/translate_a/single?${new URLSearchParams({ client: "gtx", sl: "auto", tl: target, dt: "t", dj: "1", source: "input", q: text })}`); + if (!res.ok) throw new Error(`Request failed with status ${res.status}`); + const translate = await res.json(); + + return { + src: translate.src, + text: translate.sentences?.map(s => s.trans).filter(Boolean).join("\n") + }; + } catch (error) { + console.error("[TranslatePlus] Google Translate request failed:", error); + return { src: "en", text: "Translation failed due to an error." }; + } +} + +export async function translate(text: string): Promise { + const { target, toki, sitelen, shavian } = settings.store; + + const output = { src: "", text: "" }; + + if ((isTokiPona(text) || isSitelen(text)) && (toki || sitelen)) { + if (isSitelen(text) && sitelen) text = await translateSitelen(text); + + console.log(text); + + const translate = await (await fetch("https://aiapi.serversmp.xyz/toki", { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + text: text, + src: "tl", + target: "en" + }) + })).json(); + + console.log(translate); + + output.src = "tp"; + output.text = target === "en" ? translate.translation[0] : (await google(target, translate.translation[0])).text; + } else if (isShavian(text) && shavian) { + const translate = await translateShavian(text); + + output.src = "sh"; + output.text = target === "en" ? translate : (await google(target, translate)).text; + } else { + const translate = await google(target, text); + + output.src = translate.src; + output.text = translate.text; + } + + return output; +} diff --git a/src/equicordplugins/triviaAI/index.tsx b/src/equicordplugins/triviaAI/index.tsx new file mode 100644 index 000000000..f4c860347 --- /dev/null +++ b/src/equicordplugins/triviaAI/index.tsx @@ -0,0 +1,66 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { IconComponent } from "@utils/types"; +import { Message } from "@vencord/discord-types"; +import { findExportedComponentLazy } from "@webpack"; +import { ChannelStore, Menu } from "@webpack/common"; + +import { settings } from "./settings"; +import { getResponse, handleResponse, parseMessageContent } from "./utils"; + +const RobotIconLazy = findExportedComponentLazy("RobotIcon"); +const RobotIcon: IconComponent = props => ; + +const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => { + const payload = parseMessageContent(message); + if (!payload) return; + + const group = findGroupChildrenByChildId("copy-text", children); + if (!group) return; + + group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, ( + { + const ans = await getResponse(payload); + handleResponse(message, ans); + }} + /> + )); +}; + +export default definePlugin({ + name: "TriviaAI", + description: "A plugin that helps you answer trivia questions using AI.", + authors: [EquicordDevs.yash], + settings, + contextMenus: { + "message": messageCtxPatch + }, + messagePopoverButton: { + icon: RobotIcon, + render(message: Message) { + const payload = parseMessageContent(message); + if (!payload) return null; + + return { + label: "Answer With AI", + icon: RobotIcon, + message, + channel: ChannelStore.getChannel(message.channel_id), + onClick: async () => { + const ans = await getResponse(payload); + handleResponse(message, ans); + } + }; + } + } +}); diff --git a/src/equicordplugins/triviaAI/settings.ts b/src/equicordplugins/triviaAI/settings.ts new file mode 100644 index 000000000..b4a97e823 --- /dev/null +++ b/src/equicordplugins/triviaAI/settings.ts @@ -0,0 +1,53 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +export const settings = definePluginSettings({ + apiKey: { + type: OptionType.STRING, + description: "API Key.", + default: "", + placeholder: "Enter API Key here for your AI endpoint.", + componentProps: { + type: "password" + } + }, + model: { + type: OptionType.STRING, + description: "AI Model to use.", + default: "google/gemini-3-flash-preview", + placeholder: "e.g. google/gemini-3-flash-preview, inception/mercury, openai/gpt-5.2-chat, etc." + }, + systemPrompt: { + type: OptionType.STRING, + description: "System Prompt for the AI.", + default: "You are a helpful assistant who answers questions for the user in a concise and short way while using the least amount of words and punctuation.", + placeholder: "Enter system prompt." + }, + maxTokens: { + type: OptionType.NUMBER, + description: "Maximum number of tokens in the response.", + default: 500 + }, + endpoint: { + type: OptionType.STRING, + description: "OpenAI Compatible AI Endpoint.", + default: "https://openrouter.ai/api/v1/chat/completions", + placeholder: "Enter your OpenAI compatible AI endpoint here." + }, + autoRespond: { + type: OptionType.BOOLEAN, + description: "Automatically respond to messages on receiving a response.", + default: false + }, + supportImages: { + type: OptionType.BOOLEAN, + description: "Pass images to the AI for context (if any). This is not supported by all models.", + default: true + } +}); diff --git a/src/equicordplugins/triviaAI/utils.ts b/src/equicordplugins/triviaAI/utils.ts new file mode 100644 index 000000000..501a14ffb --- /dev/null +++ b/src/equicordplugins/triviaAI/utils.ts @@ -0,0 +1,188 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { insertTextIntoChatInputBox, sendMessage } from "@utils/discord"; +import { Logger } from "@utils/Logger"; +import { Message } from "@vencord/discord-types"; +import { showToast, Toasts } from "@webpack/common"; + +import { settings } from "./settings"; + +const logger = new Logger("TriviaAI"); + +type TextPart = { + type: "text"; + text: string; +}; + +type ImagePart = { + type: "image_url"; + image_url: { + url: string; + detail?: "auto" | "high" | "low"; + }; +}; + +export type ContentPayload = string | (TextPart | ImagePart)[]; + +export function parseMessageContent(message: Message): ContentPayload | null { + const textParts: string[] = []; + + if (message.content && message.content.trim().length > 0) { + textParts.push(message.content); + } + + message.embeds.forEach(embed => { + const embedBuffer: string[] = []; + + const parts = [ + embed.provider?.name ? `> ${embed.provider.name}` : null, + embed.author?.name ? `**${embed.author.name}**` : null, + embed.rawTitle ? `## ${embed.rawTitle}` : null, + embed.rawDescription ?? null, + ...(embed.fields?.map(f => (f.rawName && f.rawValue) ? `**${f.rawName}**: ${f.rawValue}` : null) ?? []), + embed.footer?.text ? `_${embed.footer.text}_` : null, + ]; + + parts.forEach(p => { + if (p) embedBuffer.push(p); + }); + + if (embedBuffer.length > 0) textParts.push(embedBuffer.join("\n")); + }); + + const combinedText = textParts.join("\n\n"); + + if (!settings.store.supportImages) { + return combinedText || null; + } + + const imageUrls = new Set(); + + message.attachments + .filter(att => att.content_type?.startsWith("image/")) + .forEach(att => imageUrls.add(att.url)); + + message.embeds.forEach(embed => { + const potentialUrls = [ + embed.image?.url, + embed.thumbnail?.url, + ...(embed.images?.map(img => img.url) ?? []) + ]; + + potentialUrls.forEach(url => { + if (url) imageUrls.add(url); + }); + }); + + if (imageUrls.size === 0) { + return combinedText || null; + } + + const payload: (TextPart | ImagePart)[] = []; + + if (combinedText.length > 0) { + payload.push({ + type: "text", + text: combinedText + }); + } + + imageUrls.forEach(url => { + payload.push({ + type: "image_url", + image_url: { url } + }); + }); + + return payload; +} + +export async function handleResponse(message: Message, response: string): Promise { + if (settings.store.autoRespond) { + sendMessage( + message.channel_id, + { content: response }, + true, + { messageReference: { channel_id: message.channel_id, message_id: message.id } } + ); + return response; + } else { + insertTextIntoChatInputBox(response); + return response; + } +} + +export async function getResponse(payload: ContentPayload): Promise { + if (!settings.store.apiKey) { + showToast("TriviaAI: API Key is not set.", Toasts.Type.FAILURE); + return ""; + } + + if (!settings.store.endpoint) { + showToast("TriviaAI: Endpoint is not set.", Toasts.Type.FAILURE); + return ""; + } + + if (!settings.store.model) { + showToast("TriviaAI: Model is not set.", Toasts.Type.FAILURE); + return ""; + } + + try { + const req = await fetch(settings.store.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${settings.store.apiKey}` + }, + body: JSON.stringify({ + model: settings.store.model, + messages: [ + { + role: "system", + content: settings.store.systemPrompt + }, + { + role: "user", + content: payload + } + ], + max_tokens: settings.store.maxTokens, + }) + }); + + if (!req.ok) { + const errorBody = await req.text(); + const errorMessage = `API request failed with status ${req.status}: ${errorBody}`; + logger.error(errorMessage); + showToast(errorMessage, Toasts.Type.FAILURE); + return ""; + } + + const data = await req.json(); + + if (data.error) { + const errorMessage = data.error.message ?? "An unknown error occurred"; + logger.error(`API Error: ${errorMessage}`); + showToast(errorMessage, Toasts.Type.FAILURE); + return ""; + } + + const response = data.choices[0].message.content; + + if (!response?.trim()) { + logger.warn("no response from AI model"); + return ""; + } + + return response; + } catch (e) { + logger.error("Error getting response from AI model", e); + showToast("Error getting response from AI model", Toasts.Type.FAILURE); + return ""; + } +} diff --git a/src/equicordplugins/unreadBadgeCount/index.tsx b/src/equicordplugins/unreadBadgeCount/index.tsx new file mode 100644 index 000000000..0f08d06cd --- /dev/null +++ b/src/equicordplugins/unreadBadgeCount/index.tsx @@ -0,0 +1,80 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { Channel } from "@vencord/discord-types"; +import { findComponentByCodeLazy, findStoreLazy } from "@webpack"; +import { ReadStateStore, useStateFromStores } from "@webpack/common"; + +const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore"); +const JoinedThreadsStore = findStoreLazy("JoinedThreadsStore"); +const NumberBadge = findComponentByCodeLazy("BADGE_NOTIFICATION_BACKGROUND", "let{count:"); + +const settings = definePluginSettings({ + showOnMutedChannels: { + description: "Show unread count on muted channels", + type: OptionType.BOOLEAN, + default: false, + }, + notificationCountLimit: { + description: "Show +99 instead of true amount", + type: OptionType.BOOLEAN, + default: false, + }, +}); + +export default definePlugin({ + name: "UnreadCountBadge", + authors: [Devs.Joona], + description: "Shows unread message count badges on channels in the channel list", + settings, + + patches: [ + // Kanged from typingindicators + { + find: "UNREAD_IMPORTANT:", + replacement: [ + { + match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/, + replace: "$&,$self.CountBadge({channel: arguments[0].channel})", + }, + ] + }, + // Threads + { + // This is the thread "spine" that shows in the left + find: "M0 15H2c0 1.6569", + replacement: [ + { + match: /mentionsCount:\i.{0,50}?null/, + replace: "$&,$self.CountBadge({channel: arguments[0].thread})", + }, + ] + }, + ], + + CountBadge: ErrorBoundary.wrap(({ channel }: { channel: Channel; }) => { + const unreadCount = useStateFromStores([ReadStateStore], () => ReadStateStore.getUnreadCount(channel.id)); + if (!unreadCount) return null; + + if (!settings.store.showOnMutedChannels && (UserGuildSettingsStore.isChannelMuted(channel.guild_id, channel.id) || JoinedThreadsStore.isMuted(channel.id))) + return null; + + return ( + 99 && settings.store.notificationCountLimit + ? "+99" + : unreadCount + } + /> + ); + }, { noop: true }), +}); diff --git a/src/equicordplugins/unreadBadgeCount/styles.css b/src/equicordplugins/unreadBadgeCount/styles.css new file mode 100644 index 000000000..878ef5162 --- /dev/null +++ b/src/equicordplugins/unreadBadgeCount/styles.css @@ -0,0 +1,39 @@ +.vc-unreadCountBadge { + box-sizing: border-box; + height: 16px; + min-height: 16px; + min-width: 16px; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + background-color: var(--brand-500); + color: var(--white); + font-size: 12px; + font-family: var(--font-display); + border-radius: 8px; + margin-left: 4px; + line-height: 1.33; + font-weight: 700; + letter-spacing: 0.02em; +} + +.vc-unreadCountBadge-dot { + position: absolute; + top: 25%; + margin-left: -3%; + scale: 0.9; + z-index: 1; +} + +.vc-unreadCountBadge-dot-thread { + position: absolute; + top: 20%; + margin-left: -18%; + scale: 0.9; + z-index: 1; +} + +[class*="modeMuted"] .unreadCountBadge { + display: none; +} \ No newline at end of file diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css new file mode 100644 index 000000000..4a08e56f9 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css @@ -0,0 +1,42 @@ +.vc-voice-channel-log { + list-style-type: none; + height: 50px; + display: grid; + grid-template-columns: 2.25rem 24px 40px max-content; + gap: 4px; + width: 4px; + background-color: var(--border-subtle); +} + +.vc-voice-channel-log-date-separator, +.vc-voice-channel-log-timestamp { + margin-left: 4px; + height: 48px; + line-height: 48px; + font-size: .75rem; + text-align: center; + display: inline-block; + vertical-align: middle; + color: var(--text-default, white); +} + +.vc-voice-channel-log-avatar { + height: 40px; + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + font-weight: 600; + overflow: hidden; + text-align: center; + margin-top: 10px; + padding-left: 10px; + padding-right: 10px; + cursor: pointer; +} + +.vc-voice-channel-log-icon { + margin: auto 3px; + margin-left: 15px; +} \ No newline at end of file diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx new file mode 100644 index 000000000..0ce2611ef --- /dev/null +++ b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx @@ -0,0 +1,37 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./VoiceChannelLogEntryComponent.css"; + +import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs"; +import { classes } from "@utils/misc"; +import { Channel } from "@vencord/discord-types"; +import { React, Timestamp, Tooltip, UserStore } from "@webpack/common"; +import { Util } from "Vencord"; + +import { cl } from ".."; +import Icon from "./VoiceChannelLogEntryIcons"; + +export function VoiceChannelLogEntryComponent({ logEntry, channel }: { logEntry: VoiceChannelLogEntry; channel: Channel; }) { + const user = UserStore.getUser(logEntry.userId); + return
  • + + + + {(tooltipProps: any) => ( + Util.openUserProfile(logEntry.userId)} + src={user.getAvatarURL(channel.getGuildId())} + /> + )} + +
    + { } +
    +
  • ; +} diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx new file mode 100644 index 000000000..0054323f4 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx @@ -0,0 +1,29 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs"; +import { classes } from "@utils/misc"; +import { Channel } from "@vencord/discord-types"; +import { React } from "@webpack/common"; + +import { cl } from ".."; + +export default function Icon({ logEntry, channel, className }: { logEntry: VoiceChannelLogEntry; channel: Channel; className: string; }) { + // Taken from /assets/7378a83d74ce97d83380.svg + const Join = ; + // Taken from /assets/192510ade1abc3149b46.svg + const Leave = ; + // For other contributors, please DO make specific designs for these instead of how I just copied the join/leave icons and making them orange + const MovedTo = ; + const MovedFrom = ; + + if (logEntry.newChannel && !logEntry.oldChannel) return React.cloneElement(Join, { className: classes(className, cl("join")) }); + if (!logEntry.newChannel && logEntry.oldChannel) return React.cloneElement(Leave, { className: classes(className, cl("leave")) }); + if (logEntry.newChannel === channel.id && logEntry.oldChannel) return React.cloneElement(MovedFrom, { className: classes(className, cl("moved-from")) }); + if (logEntry.newChannel && logEntry.oldChannel === channel.id) return React.cloneElement(MovedTo, { className: classes(className, cl("moved-to")) }); + // we should never get here, this is just here to shut up the type checker + return ; +} diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx new file mode 100644 index 000000000..6315e8b06 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx @@ -0,0 +1,68 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { getVcLogs, vcLogSubscribe } from "@equicordplugins/voiceChannelLog.dev/logs"; +import { classes } from "@utils/misc"; +import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { Channel } from "@vencord/discord-types"; +import { findStoreLazy } from "@webpack"; +import { React, ScrollerThin } from "@webpack/common"; + +import { cl } from ".."; +import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent"; + +const AccessibilityStore = findStoreLazy("AccessibilityStore"); + +export function openVoiceChannelLog(channel: Channel) { + return openModal(props => ( + + )); +} + +export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) { + React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id)); + const vcLogs = getVcLogs(channel.id); + const logElements: (React.ReactNode)[] = []; + + if (vcLogs.length > 0) { + for (let i = 0; i < vcLogs.length; i++) { + const logEntry = vcLogs[i]; + if (i === 0 || logEntry.timestamp.toDateString() !== vcLogs[i - 1].timestamp.toDateString()) { + logElements.push(
    + + {logEntry.timestamp.toDateString()} + +
    ); + } else { + logElements.push(); + } + } + } else { + logElements.push(
    No logs to display.
    ); + } + + return ( + + + {channel.name} logs + + + + + + {logElements} + + + + ); +} diff --git a/src/equicordplugins/voiceChannelLog.dev/index.tsx b/src/equicordplugins/voiceChannelLog.dev/index.tsx new file mode 100644 index 000000000..94941f6e1 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog.dev/index.tsx @@ -0,0 +1,164 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; +import { Devs, EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import definePlugin, { OptionType } from "@utils/types"; +import { Message, User } from "@vencord/discord-types"; +import { findByCodeLazy } from "@webpack"; +import { FluxDispatcher, Menu, MessageActions, MessageStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common"; + +import { openVoiceChannelLog } from "./components/VoiceChannelLogModal"; +import { addLogEntry } from "./logs"; + +export const cl = classNameFactory("vc-voice-channel-log-"); +const createBotMessage = findByCodeLazy('username:"Clyde"'); + +const settings = definePluginSettings({ + mode: { + type: OptionType.SELECT, + description: "How to show the voice channel log", + options: [ + { label: "Log menu", value: 1, default: true }, + { label: "Log to associated chat directly", value: 2 }, + { label: "Log to chat and log menu", value: 3 }, + ] + }, + voiceChannelChatSelf: { + type: OptionType.BOOLEAN, + description: "Log your own voice channel events in the voice channels", + default: true + }, + voiceChannelChatSilent: { + type: OptionType.BOOLEAN, + description: "Join/leave/move messages in voice channel chats will be silent", + default: true + }, + voiceChannelChatSilentSelf: { + type: OptionType.BOOLEAN, + description: "Join/leave/move messages in voice channel chats will be silent if you are in the voice channel", + default: false + }, + ignoreBlockedUsers: { + type: OptionType.BOOLEAN, + description: "Do not log blocked users", + default: false + }, +}); + +interface VoiceState { + guildId?: string; + channelId?: string; + oldChannelId?: string; + user: User; + userId: string; +} + +function getMessageFlags(selfInChannel: boolean) { + let flags = 1 << 6; + if (selfInChannel ? settings.store.voiceChannelChatSilentSelf : settings.store.voiceChannelChatSilent) flags += 1 << 12; + return flags; +} + +function sendVoiceStatusMessage(channelId: string, content: string, userId: string, selfInChannel: boolean): Message | null { + if (!channelId) return null; + + const message: Message = createBotMessage({ channelId, content, embeds: [] }); + message.flags = getMessageFlags(selfInChannel); + message.author = UserStore.getUser(userId); + // If we try to send a message into an unloaded channel, the client-sided messages get overwritten when the channel gets loaded + // This might be messy but It Works:tm: + const messagesLoaded: Promise = MessageStore.hasPresent(channelId) ? new Promise(r => r()) : MessageActions.fetchMessages({ channelId }); + messagesLoaded.then(() => { + FluxDispatcher.dispatch({ + type: "MESSAGE_CREATE", + channelId, + message, + optimistic: true, + sendMessageOptions: {}, + isPushNotification: false + }); + }); + return message; +} + +const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => { + const group = findGroupChildrenByChildId("mark-channel-read", children) ?? children; + group.push( + { openVoiceChannelLog(channel); }} + /> + ); +}; + +// Blatantly stolen from VcNarrator plugin + +// For every user, channelId and oldChannelId will differ when moving channel. +// Only for the local user, channelId and oldChannelId will be the same when moving channel, +// for some ungodly reason +let clientOldChannelId: string | undefined; + +export default definePlugin({ + name: "VoiceChannelLog", + description: "Logs who joins and leaves voice channels", + authors: [Devs.Sqaaakoi, Devs.thororen, EquicordDevs.nyx], + contextMenus: { + "channel-context": patchChannelContextMenu + }, + settings, + flux: { + VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { + const clientUserId = UserStore.getCurrentUser().id; + voiceStates.forEach(state => { + // mmmm hacky workaround + const { userId, channelId } = state; + const user = UserStore.getUser(userId) as User & { globalName: string; }; + const username = user.globalName || user.username; + let { oldChannelId } = state; + if (userId === clientUserId && channelId !== clientOldChannelId) { + oldChannelId = clientOldChannelId; + clientOldChannelId = channelId; + } + if (settings.store.ignoreBlockedUsers && RelationshipStore.isBlocked(userId)) return; + // Ignore events from same channel + if (oldChannelId === channelId) return; + + const logEntry = { + userId, + username, + oldChannel: oldChannelId || null, + newChannel: channelId || null, + timestamp: new Date() + }; + + if (settings.store.mode !== 2) { + addLogEntry(logEntry, oldChannelId); + addLogEntry(logEntry, channelId); + } + + if (!settings.store.voiceChannelChatSelf && userId === clientUserId) return; + if (settings.store.mode === 1) return; + // Join / Leave + if ((!oldChannelId && channelId) || (oldChannelId && !channelId)) { + // empty string is to make type checker shut up + const targetChannelId = oldChannelId || channelId || ""; + const selfInChannel = SelectedChannelStore.getVoiceChannelId() === targetChannelId; + sendVoiceStatusMessage(targetChannelId, `${(channelId ? "Joined" : "Left")} <#${targetChannelId}>`, userId, selfInChannel); + } + // Move between channels + if (oldChannelId && channelId) { + sendVoiceStatusMessage(oldChannelId, `Moved to <#${channelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === oldChannelId); + sendVoiceStatusMessage(channelId, `Moved from <#${oldChannelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === channelId); + } + + }); + }, + } +}); diff --git a/src/equicordplugins/voiceChannelLog.dev/logs.ts b/src/equicordplugins/voiceChannelLog.dev/logs.ts new file mode 100644 index 000000000..6fefe4ba7 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog.dev/logs.ts @@ -0,0 +1,41 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface VoiceChannelLogEntry { + userId: string; + username: string; + oldChannel: string | null; + newChannel: string | null; + timestamp: Date; +} + +export const vcLogs = new Map(); +let vcLogSubscriptions: (() => void)[] = []; + +export function getVcLogs(channel?: string): VoiceChannelLogEntry[] { + if (!channel) return []; + if (!vcLogs.has(channel)) vcLogs.set(channel, []); + return vcLogs.get(channel) || []; +} + +export function addLogEntry(logEntry: VoiceChannelLogEntry, channel?: string) { + if (!channel) return; + vcLogs.set(channel, [...getVcLogs(channel), logEntry]); + vcLogSubscriptions.forEach(u => u()); +} + +export function clearLogs(channel?: string) { + if (!channel) return; + vcLogs.set(channel, []); + vcLogSubscriptions.forEach(u => u()); +} + +export function vcLogSubscribe(listener: () => void) { + vcLogSubscriptions = [...vcLogSubscriptions, listener]; + return () => { + vcLogSubscriptions = vcLogSubscriptions.filter(l => l !== listener); + }; +} diff --git a/src/equicordplugins/voiceRejoin.dev/index.tsx b/src/equicordplugins/voiceRejoin/index.tsx similarity index 100% rename from src/equicordplugins/voiceRejoin.dev/index.tsx rename to src/equicordplugins/voiceRejoin/index.tsx diff --git a/src/equicordplugins/webpackTarball.dev/index.tsx b/src/equicordplugins/webpackTarball/index.tsx similarity index 100% rename from src/equicordplugins/webpackTarball.dev/index.tsx rename to src/equicordplugins/webpackTarball/index.tsx diff --git a/src/equicordplugins/webpackTarball.dev/tar.ts b/src/equicordplugins/webpackTarball/tar.ts similarity index 100% rename from src/equicordplugins/webpackTarball.dev/tar.ts rename to src/equicordplugins/webpackTarball/tar.ts diff --git a/src/equicordplugins/webpackTarball.dev/webpack.ts b/src/equicordplugins/webpackTarball/webpack.ts similarity index 100% rename from src/equicordplugins/webpackTarball.dev/webpack.ts rename to src/equicordplugins/webpackTarball/webpack.ts diff --git a/src/equicordplugins/wigglyText/index.tsx b/src/equicordplugins/wigglyText/index.tsx new file mode 100644 index 000000000..1f683e9e6 --- /dev/null +++ b/src/equicordplugins/wigglyText/index.tsx @@ -0,0 +1,199 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { BaseText } from "@components/BaseText"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { makeRange, OptionType } from "@utils/types"; +import { ReactNode } from "react"; + +import ExampleWiggle from "./ui/components/ExampleWiggle"; + +const settings = definePluginSettings({ + intensity: { + type: OptionType.SLIDER, + description: "Animation intensity in px", + markers: makeRange(1, 10, 1), + default: 4, + stickToMarkers: true, + onChange: () => updateStyles() + } +}); + +const dirMap = { + x: "0.6s wiggle-wavy-x alternate ease-in-out infinite", + y: "1.2s wiggle-wavy-y linear infinite" +}; + +const classMap = [ + { + chars: ["<", ">"], + className: "wiggle-inner-x", + }, + { + chars: ["^", "^"], + className: "wiggle-inner-y", + }, + { + chars: [")", "("], + className: "wiggle-inner-xy" + } +]; + +let styles: HTMLStyleElement; +const updateStyles = () => { + const inten = settings.store.intensity + "px"; + styles.textContent = ` +.wiggle-example { + list-style-type: disc; + list-style-position: outside; + margin: 4px 0 0 16px; +} + +.wiggle-example li { + white-space: break-spaces; + margin-bottom: 4px; +} + +.wiggle-inner { + position: relative; + top: 0; + left: 0; + + &.wiggle-inner-x { + animation: ${dirMap.x}; + } + + &.wiggle-inner-y { + animation: ${dirMap.y}; + } + + &.wiggle-inner-xy { + animation: ${dirMap.x}, ${dirMap.y}; + } +} + +@keyframes wiggle-wavy-x { + from { + left: -${inten}; + } + + to { + left: ${inten}; + } +} + +@keyframes wiggle-wavy-y { + 0% { + top: 0; + animation-timing-function: ease-out; + } + + 25% { + top: -${inten}; + animation-timing-function: ease-in; + } + + 50% { + top: 0; + animation-timing-function: ease-out; + } + + 75% { + top: ${inten}; + animation-timing-function: ease-in; + } +}`; +}; + +export default definePlugin({ + name: "WigglyText", + description: "Adds a new markdown formatting that makes text wiggly.", + authors: [EquicordDevs.nexpid], + settings, + settingsAboutComponent: () => ( + + You can make text wiggle with the following:
    +
      +
    • left and right by typing <~text~>
    • +
    • up and down by typing ^~text~^
    • +
    • in a circle by typing )~text~(
    • +
    +
    + ), + + patches: [ + { + find: "parseToAST:", + replacement: { + match: /(parse[\w]*):(.*?)\((\i)\),/g, + replace: "$1:$2({...$3,wiggly:$self.wigglyRule}),", + }, + }, + ], + + wigglyRule: { + order: 24, + match: (source: string) => classMap.map(({ chars }) => source.match(new RegExp(`^(\\${chars[0]})~([\\s\\S]+?)~(\\${chars[1]})(?!_)`))).find(x => x !== null), + parse: ( + capture: RegExpMatchArray, + transform: (...args: any[]) => any, + state: any + ) => { + const className = classMap.find(({ chars }) => chars[0] === capture[1] && chars[1] === capture[3])?.className ?? ""; + + return { + content: transform(capture[2], state), + className + }; + }, + react: ( + data: { content: any[]; className: string; }, + output: (...args: any[]) => ReactNode[] + ) => { + let offset = 0; + const traverse = (raw: any) => { + const children = !Array.isArray(raw) ? [raw] : raw; + let modified = false; + + let j = -1; + for (const child of children) { + j++; + if (typeof child === "string") { + modified = true; + children[j] = child.split("").map((x, i) => ( + + + {x} + + + )); + } else if (child?.props?.children) + child.props.children = traverse(child.props.children); + } + + return modified ? children : raw; + }; + + return traverse(output(data.content)); + }, + }, + + start: () => { + styles = document.createElement("style"); + styles.id = "WigglyText"; + document.head.appendChild(styles); + + updateStyles(); + }, + + stop: () => styles.remove() +}); diff --git a/src/equicordplugins/wigglyText/ui/components/ExampleWiggle.tsx b/src/equicordplugins/wigglyText/ui/components/ExampleWiggle.tsx new file mode 100644 index 000000000..1f6e709ab --- /dev/null +++ b/src/equicordplugins/wigglyText/ui/components/ExampleWiggle.tsx @@ -0,0 +1,20 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export default function ExampleWiggle({ wiggle, children }: { wiggle: "x" | "y" | "xy", children: string; }) { + return children.split("").map((x, i) => ( + + + {x} + + + )); +} diff --git a/src/equicordplugins/writeUpperCase/index.ts b/src/equicordplugins/writeUpperCase/index.ts new file mode 100644 index 000000000..263b1a231 --- /dev/null +++ b/src/equicordplugins/writeUpperCase/index.ts @@ -0,0 +1,49 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { addMessagePreSendListener, MessageSendListener, removeMessagePreSendListener } from "@api/MessageEvents"; +import { definePluginSettings } from "@api/Settings"; +import { Devs, EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; + +const settings = definePluginSettings( + { + blockedWords: { + type: OptionType.STRING, + description: "Strings not to capitilise (seperate with a comma)", + default: "http, https, ok" + } + } +); + +const presendObject: MessageSendListener = (_, msg) => { + const blockedWordsArray: string[] = settings.store.blockedWords.split(", "); + const sentences = msg.content.split(/(?<=[.!?]+['")\]]*)(\s+)/); + + msg.content = sentences.map((element, i) => { + if (i % 2 === 1) return element; + + if (!blockedWordsArray.some(word => element.toLowerCase().startsWith(word.toLocaleLowerCase()))) { + return element.charAt(0).toUpperCase() + element.slice(1); + } else { + return element; + } + }).join(""); +}; + +export default definePlugin({ + name: "WriteUpperCase", + description: "Changes the first Letter of each Sentence in Message Inputs to Uppercase", + authors: [Devs.Samwich, EquicordDevs.KrystalSkull], + settings, + + start() { + addMessagePreSendListener(presendObject); + }, + stop() { + removeMessagePreSendListener(presendObject); + } +}); diff --git a/src/plugins/_core/supportHelper.tsx b/src/plugins/_core/supportHelper.tsx index 0c390eeae..a2e1d8172 100644 --- a/src/plugins/_core/supportHelper.tsx +++ b/src/plugins/_core/supportHelper.tsx @@ -156,8 +156,9 @@ async function generateDebugInfoMessage() { } const potentiallyProblematicPlugins = ([ - "NoRPC", "NoProfileThemes", "NoMosaic", "NoSystemBadge", - "AlwaysAnimate", "ClientTheme", "SoundTroll", "NeverPausePreviews", + "NoRPC", "NoProfileThemes", "NoMosaic", "NoRoleHeaders", "NoSystemBadge", + "AlwaysAnimate", "ClientTheme", "SoundTroll", "Ingtoninator", "NeverPausePreviews", + "IdleAutoRestart", ].filter(isPluginEnabled) ?? []).sort(); if (isPluginEnabled(customIdle.name) && customIdle.settings.store.idleTimeout === 0) {