diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index 240409673..2293eaa75 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -146,7 +146,7 @@ export const globPlugins = kind => ({ }); build.onLoad({ filter, namespace: "import-plugins" }, async () => { - const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins"]; + const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins", "equicordplugins/_api"]; let code = ""; let pluginsCode = "\n"; let metaCode = "\n"; diff --git a/src/Vencord.ts b/src/Vencord.ts index e052692e0..835e350d2 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -49,7 +49,6 @@ import { SettingsRouter } from "./webpack/common"; if (IS_REPORTER) { require("./debug/runReporter"); - Settings.plugins.CharacterCounter.enabled = false; } async function syncSettings() { diff --git a/src/api/AudioPlayer.ts b/src/api/AudioPlayer.ts new file mode 100644 index 000000000..a2ad4250d --- /dev/null +++ b/src/api/AudioPlayer.ts @@ -0,0 +1,260 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findByCodeLazy, findLazy } from "webpack"; + +let defaultSounds: null | string[] = null; +const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3")), false); +const AudioPlayerConstructor = findByCodeLazy("could not play audio"); + +export type AudioProcessor = (data: PreprocessAudioData) => void; +export type AudioCallback = (() => void); +export type AudioErrorHandler = ((error: Error) => void); +export const audioProcessorFunctions: Record = {}; + +export enum AudioType { + /** An external URL that follows the Content Security Policy. */ + URL = "url", + /** A base64-encoded data URI. */ + DATA = "data-uri", + /** A Blob URI. */ + BLOB = "blob", + /** A file path. */ + PATH = "file-path", + /** An internal Discord audio filename (e.g. "discodo"). */ + DISCORD = "discord", + /** Any other unrecognized audio type. */ + OTHER = "other" +} + +export interface PreprocessAudioData { + /** The original audio string passed to the player. */ + audio: string; + /** The read-only type of audio of the original audio string. */ + readonly type: AudioType; + /** The volume of the original audio between 0 and 100. */ + volume: number; + /** The playback speed of the original audio between 0.0625 and 16. */ + speed: number; +} + +export interface AudioPlayerInternal { + preprocessDataOriginal: PreprocessAudioData; + preprocessDataPrevious: PreprocessAudioData | null; + preprocessDataCurrent: PreprocessAudioData; + audio: string; + _audio: null | Promise; + _volume: number; + _speed: number; + outputChannel: string; + type: AudioType; + preload: boolean; + persistent: boolean; + onEnded?: AudioCallback; + onError?: AudioErrorHandler; + processAudio: () => void; + ensureAudio(): Promise; + destroyAudio(): void; + loop(): void; + play(): void; + pause(): void; + stop(restart?: boolean): void; +} + +export interface AudioPlayerInterface { + /** The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP. */ + audio: string; + /** The read-only type of audio determined during processing. */ + readonly type: AudioType; + /** The duration of the audio in seconds, or null if not yet loaded. */ + readonly duration: Promise | null; + /** The current time of the audio in seconds, or null if not yet loaded. */ + time: Promise | null; + /** The paused state of the audio, or null if not yet loaded. */ + paused: Promise | null; + /** The muted state of the audio, or null if not yet loaded. */ + muted: Promise | null; + /** The volume of the audio between 0 and 100. */ + volume: number; + /** The playback speed of the audio between 0.0625 and 16. */ + speed: number; + /** Whether to load the audio immediately. If persistent is false, this will only apply until the first playback. */ + preload: boolean; + /** Whether the audio element is persistent and not recreated for every playback. */ + persistent: boolean; + /** Preloads the audio before playback. Automatically called when persistent is true. */ + load(): void; + /** Sets the audio to loop until paused or stopped. */ + loop(): void; + /** Plays the audio. */ + play(): void; + /** Pauses the audio. */ + pause(): void; + /** Stops the audio. */ + stop(): void; + /** Plays the audio from the beginning. */ + restart(): void; + /** Seeks to a specific time in seconds. */ + seek(time: number): void; + /** Mutes the audio. */ + mute(): void; + /** Unmutes the audio. */ + unmute(): void; + /** Deletes the audio element. Necessary if persistent is true. */ + delete(): void; +} + +export interface AudioPlayerOptions { + /** The volume of the audio, between 0 and 100, defaulting to 100. */ + volume?: number; + /** The playback speed of the audio, between 0.0625 and 16, defaulting to 1. */ + speed?: number; + /** Whether to preload the audio as soon as the player is created. */ + preload?: boolean; + /** Whether the audio element is persistent and not recreated for every playback. If persistent, you must call delete() to free the memory. Defaults to false. */ + persistent?: boolean; + /** An optional callback that is called every time the audio finishes playing. */ + onEnded?: AudioCallback; + /** An optional error handler that is called when an error occurs during audio playback. */ + onError?: AudioErrorHandler; +} + +// Wrap the player to allow reprocessing the audio when properties are changed and to alleviate +// the confusion between the public API accepting 0-100 volume while the internal API uses 0-1 volume. +class AudioPlayerWrapper implements AudioPlayerInterface { + private internalPlayer: AudioPlayerInternal; + constructor(internalPlayer: AudioPlayerInternal) { this.internalPlayer = internalPlayer; } + + get audio(): string { return this.internalPlayer.audio; } + set audio(value: string) { this.internalPlayer.preprocessDataOriginal.audio = value; this.internalPlayer.processAudio(); } + + get volume(): number { return this.internalPlayer._volume * 100; } + set volume(value: number) { this.internalPlayer.preprocessDataOriginal.volume = Math.max(0, Math.min(1, value / 100)); this.internalPlayer.processAudio(); } + + get speed(): number { return this.internalPlayer._speed; } + set speed(value: number) { this.internalPlayer.preprocessDataOriginal.speed = Math.max(0.0625, Math.min(16, value)); this.internalPlayer.processAudio(); } + + get time(): Promise | null { return this.internalPlayer._audio?.then(audio => audio.currentTime) ?? null; } + set time(value: number) { this.internalPlayer.ensureAudio().then(audio => audio.currentTime = value); } + + get persistent(): boolean { return this.internalPlayer.persistent; } + set persistent(value: boolean) { this.internalPlayer.persistent = value; } + + get preload(): boolean { return this.internalPlayer.preload; } + set preload(value: boolean) { this.internalPlayer.preload = value; value && this.internalPlayer.ensureAudio(); } + + get muted(): Promise | null { return this.internalPlayer._audio?.then(audio => audio.muted) ?? null; } + set muted(value: boolean) { this.internalPlayer.ensureAudio().then(audio => audio.muted = value); } + + get paused(): Promise | null { return this.internalPlayer._audio?.then(audio => audio.paused) ?? null; } + set paused(value: boolean) { value ? this.internalPlayer.pause() : this.internalPlayer.play(); } + + get type(): AudioType { return this.internalPlayer.type; } + get duration(): Promise | null { return this.internalPlayer._audio?.then(audio => audio.duration) ?? null; } + + load(): void { this.internalPlayer.ensureAudio(); } + loop(): void { this.internalPlayer.loop(); } + play(): void { this.internalPlayer.play(); } + pause(): void { this.internalPlayer.pause(); } + stop(restart?: boolean): void { this.internalPlayer.stop(restart); } + restart(): void { this.internalPlayer.stop(true); } + seek(time: number): void { this.internalPlayer.ensureAudio().then(audio => audio.currentTime = time); } + mute(): void { this.internalPlayer.ensureAudio().then(audio => audio.muted = true); } + unmute(): void { this.internalPlayer.ensureAudio().then(audio => audio.muted = false); } + delete(): void { this.internalPlayer.destroyAudio(); } +} + +/** + * Creates an audio player. + * @param audio The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP. + * @param options Additional options for the audio player. + * @param options.volume The volume of the audio, between 0 and 100, defaulting to 100. + * @param options.speed The playback speed of the audio, between 0.0625 and 16, defaulting to 1. + * @param options.preload Whether to load the audio immediately. If persistent is false, this will only apply until the first playback. + * @param options.persistent Whether the audio element is persistent and not recreated for every playback. If persistent, you must call delete() to free the memory. Defaults to false. + * @param options.onEnded An optional callback that is called every time the audio finishes playing. + * @param options.onError An optional error handler that is passed an Error object when an error occurs during audio playback. + * @return The created audio player. + */ +export function createAudioPlayer( + audio: string, + options: AudioPlayerOptions = {} +): AudioPlayerInterface { + const internalPlayer: AudioPlayerInternal = new AudioPlayerConstructor( + audio, + null, + null, + "default", + options + ); + + return new AudioPlayerWrapper(internalPlayer); +} + +/** + * Plays an audio instantly and returns the player. + * @param audio The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP. + * @param options Additional options for the audio player. + * @param options.volume The volume of the audio, between 0 and 100, defaulting to 100. + * @param options.speed The playback speed of the audio, between 0.0625 and 16, defaulting to 1. + * @param options.preload Whether to load the audio immediately. If persistent is false, this will only apply until the first playback. + * @param options.persistent Whether the audio element is persistent and not recreated for every playback. If persistent, you must call delete() to free the memory. Defaults to false. + * @param options.onEnded An optional callback that is called every time the audio finishes playing. + * @param options.onError An optional error handler that is passed an Error object when an error occurs during audio playback. + * @return The created audio player. + */ +export function playAudio(audio: string, options: AudioPlayerOptions = {}): AudioPlayerInterface { + const player = createAudioPlayer(audio, options); + player.play(); + return player; +} + +/** + * Identifies the type of audio based on its string. + * @param audio The audio string to identify. + * @returns The identified AudioType. + */ +export function identifyAudioType(audio: string): AudioType { + if (defaultAudioNames().includes(audio)) return AudioType.DISCORD; + + try { + const url = new URL(audio); + if (url.protocol === "http:" || url.protocol === "https:") return AudioType.URL; + if (url.protocol === "data:") return AudioType.DATA; + if (url.protocol === "blob:") return AudioType.BLOB; + if (url.protocol === "file:") return AudioType.PATH; + return AudioType.OTHER; + } catch { + return AudioType.OTHER; + } +} + +/** + * Adds a function to process an audio before it is played. + * @param key A unique identifier for this audio processor. Plugin name is recommended. + * @param processor A function that takes an audio, volume (0-100), and type (AudioType), and returns the replacement audio and volume. + */ +export function addAudioProcessor(key: string, processor: AudioProcessor): void { + audioProcessorFunctions[key] = processor; +} + +/** + * Removes an audio processor by its key. + * @param key The unique identifier of the audio processor to remove. + */ +export function removeAudioProcessor(key: string): void { + delete audioProcessorFunctions[key]; +} + +/** Returns an array of all internal Discord audio filenames. */ +export function defaultAudioNames(): string[] { + defaultSounds ??= (findDefaultSounds.keys() || []).map(key => { + const match = key.match(/((?:\w|-)+)\.mp3$/); + return match ? match[1] : null; + }).filter(Boolean) as string[]; + + return defaultSounds; +} diff --git a/src/api/Notifications/NotificationComponent.tsx b/src/api/Notifications/NotificationComponent.tsx index 52e56d5d0..e8f0cc206 100644 --- a/src/api/Notifications/NotificationComponent.tsx +++ b/src/api/Notifications/NotificationComponent.tsx @@ -21,7 +21,7 @@ import "./styles.css"; import { useSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { classes } from "@utils/misc"; -import { React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common"; +import { React, useEffect, useRef, useState } from "@webpack/common"; import { NotificationData } from "./Notifications"; @@ -39,26 +39,38 @@ export default ErrorBoundary.wrap(function NotificationComponent({ dismissOnClick }: NotificationData & { className?: string; }) { const { timeout, position } = useSettings(["notifications.timeout", "notifications.position"]).notifications; - const hasFocus = useStateFromStores([WindowStore], () => WindowStore.isFocused()); const [isHover, setIsHover] = useState(false); const [elapsed, setElapsed] = useState(0); - const start = useMemo(() => Date.now(), [timeout, isHover, hasFocus]); + const start = useRef(Date.now()); + const pause = useRef(null); useEffect(() => { - if (isHover || !hasFocus || timeout === 0 || permanent) return void setElapsed(0); + if (timeout === 0 || permanent) return; + + if (isHover) { + if (pause.current === null) pause.current = Date.now(); + return; + } + + if (pause.current !== null) { + const pausedFor = Date.now() - pause.current; + start.current += pausedFor; + pause.current = null; + } const intervalId = setInterval(() => { - const elapsed = Date.now() - start; - if (elapsed >= timeout) + const elapsedNow = Date.now() - start.current; + if (elapsedNow >= timeout) { onClose!(); - else - setElapsed(elapsed); + } else { + setElapsed(elapsedNow); + } }, 10); return () => clearInterval(intervalId); - }, [timeout, isHover, hasFocus]); + }, [timeout, isHover, permanent]); const timeoutProgress = elapsed / timeout; diff --git a/src/api/Notifications/Notifications.tsx b/src/api/Notifications/Notifications.tsx index 96a3425b5..4fd8c3d93 100644 --- a/src/api/Notifications/Notifications.tsx +++ b/src/api/Notifications/Notifications.tsx @@ -18,17 +18,31 @@ import { Settings } from "@api/Settings"; import { Queue } from "@utils/Queue"; -import { createRoot } from "@webpack/common"; +import { createRoot, WindowStore } from "@webpack/common"; import type { ReactNode } from "react"; import type { Root } from "react-dom/client"; import NotificationComponent from "./NotificationComponent"; -import { persistNotification } from "./notificationLog"; +import { openNotificationLogModal, persistNotification } from "./notificationLog"; const NotificationQueue = new Queue(); let reactRoot: Root; let id = 42; +let missedCount = 0; + +window.addEventListener("focus", () => { + const { missed, timeout, useNative } = Settings.notifications; + if (!missed) return; + if (missedCount > 0 && timeout > 0 && useNative === "never") { + showNotification({ + title: "While you were away", + body: `${missedCount} notifications received`, + onClick: () => openNotificationLogModal(), + }); + missedCount = 0; + } +}); function getRoot() { if (!reactRoot) { @@ -105,7 +119,17 @@ export async function showNotification(data: NotificationData) { }); n.onclick = onClick; n.onclose = onClose; + + if (!WindowStore.isFocused()) missedCount++; } else { - NotificationQueue.push(() => _showNotification(data, id++)); + NotificationQueue.push(() => + _showNotification({ + ...data, + onClose: () => { + data.onClose?.(); + if (!WindowStore.isFocused()) missedCount++; + } + }, id++) + ); } } diff --git a/src/api/Settings.ts b/src/api/Settings.ts index ddf326b01..d4c64647f 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -68,6 +68,7 @@ export interface Settings { timeout: number; position: "top-right" | "bottom-right"; useNative: "always" | "never" | "not-focused"; + missed: boolean; logLimit: number; }; @@ -109,6 +110,7 @@ const DefaultSettings: Settings = { timeout: 5000, position: "bottom-right", useNative: "not-focused", + missed: true, logLimit: 50 }, diff --git a/src/api/index.ts b/src/api/index.ts index 8af6a49f6..d3cb0ed38 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import * as $AudioPlayer from "./AudioPlayer"; import * as $Badges from "./Badges"; import * as $ChatButtons from "./ChatButtons"; import * as $Commands from "./Commands"; @@ -37,27 +38,30 @@ import * as $UserSettings from "./UserSettings"; /** * An API allowing you to listen to Message Clicks or run your own logic - * before a message is sent + * before a message is sent. * - * If your plugin uses this, you must add MessageEventsAPI to its dependencies + * If your plugin uses this, you must add MessageEventsAPI to its dependencies. */ export const MessageEvents = $MessageEventsAPI; + /** * An API allowing you to create custom notices - * (snackbars on the top, like the Update prompt) + * (snackbars on the top, like the Update prompt). */ export const Notices = $Notices; + /** - * An API allowing you to register custom commands + * An API allowing you to register custom commands. */ export const Commands = $Commands; + /** * A wrapper around IndexedDB. This can store arbitrarily * large data and supports a lot of datatypes (Blob, Map, ...). - * For a full list, see the mdn link below + * For a full list, see the mdn link below. * * This should always be preferred over the Settings API if possible, as - * localstorage has very strict size restrictions and blocks the event loop + * localstorage has very strict size restrictions and blocks the event loop. * * Make sure your keys are unique (tip: prefix them with ur plugin name) * and please clean up no longer needed entries. @@ -66,70 +70,84 @@ export const Commands = $Commands; * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#supported_types} */ export const DataStore = $DataStore; + /** - * An API allowing you to add custom components as message accessories + * An API allowing you to add custom components as message accessories. */ export const MessageAccessories = $MessageAccessories; + /** - * An API allowing you to add custom buttons in the message popover + * An API allowing you to add custom buttons in the message popover. */ export const MessagePopover = $MessagePopover; + /** - * An API allowing you to add badges to user profiles + * An API allowing you to add badges to user profiles. */ export const Badges = $Badges; + /** - * An API allowing you to add custom elements to the server list + * An API allowing you to add custom elements to the server list. */ export const ServerList = $ServerList; + /** - * An API allowing you to add components as message accessories + * An API allowing you to add components as message accessories. */ export const MessageDecorations = $MessageDecorations; + /** - * An API allowing you to add components to member list users, in both DM's and servers + * An API allowing you to add components to member list users, in both DM's and servers. */ export const MemberListDecorators = $MemberListDecorators; + /** - * An API allowing you to persist data + * An API allowing you to persist data. */ export const Settings = $Settings; + /** - * An API allowing you to dynamically load styles + * An API allowing you to dynamically load styles. * a */ export const Styles = $Styles; + /** - * An API allowing you to display notifications + * An API allowing you to display notifications. */ export const Notifications = $Notifications; /** - * An api allowing you to patch and add/remove items to/from context menus + * An api allowing you to patch and add/remove items to/from context menus. */ export const ContextMenu = $ContextMenu; /** - * An API allowing you to add buttons to the chat input + * An API allowing you to add buttons to the chat input. */ export const ChatButtons = $ChatButtons; /** - * An API allowing you to update and re-render messages + * An API allowing you to update and re-render messages. */ export const MessageUpdater = $MessageUpdater; /** - * An API allowing you to get an user setting + * An API allowing you to get an user setting. */ export const UserSettings = $UserSettings; /** - * An API allowing you to add icons to the nickname, in profiles + * An API allowing you to add icons to the nickname, in profiles. */ export const NicknameIcons = $NicknameIcons; /** - * Just used to identify if user is on Equicord as Vencord doesnt have this + * An API allowing you to play internal Discord audio files or external audio URLs/URIs. + */ +export const AudioPlayer = $AudioPlayer; + +/** + * Just used to identify if user is on Equicord as Vencord doesn't have this. */ export const isEquicord = true; diff --git a/src/components/settings/AddonCard.css b/src/components/settings/AddonCard.css index ffc083c4c..0e87cca5d 100644 --- a/src/components/settings/AddonCard.css +++ b/src/components/settings/AddonCard.css @@ -52,7 +52,6 @@ width: 100%; align-items: center; flex-grow: 1; - gap: 8px; } .vc-addon-author { diff --git a/src/components/settings/AddonCard.tsx b/src/components/settings/AddonCard.tsx index 42e954c28..63be7c8af 100644 --- a/src/components/settings/AddonCard.tsx +++ b/src/components/settings/AddonCard.tsx @@ -73,8 +73,8 @@ export function AddonCard({ disabled, isNew, sourceBadge, name, infoButton, foot {name} - {sourceBadge} {isNew && } + {sourceBadge} {!!author && ( diff --git a/src/components/settings/tabs/plugins/PluginCard.tsx b/src/components/settings/tabs/plugins/PluginCard.tsx index 2aa270fb8..9cf7f74b8 100644 --- a/src/components/settings/tabs/plugins/PluginCard.tsx +++ b/src/components/settings/tabs/plugins/PluginCard.tsx @@ -39,6 +39,7 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on const settings = Settings.plugins[plugin.name]; const pluginMeta = PluginMeta[plugin.name]; const isEquicordPlugin = pluginMeta?.folderName?.startsWith("src/equicordplugins/") ?? false; + const isUserplugin = pluginMeta?.userPlugin ?? false; const isEnabled = () => isPluginEnabled(plugin.name); @@ -104,6 +105,18 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on borderRadius: "2px" }} /> + ) : isUserplugin ? ( + Userplugin ) : ( 0 ? [{ label: "Show Custom", value: SearchStatus.CUSTOM }] : []), + { label: "Show New", value: SearchStatus.NEW }, ]} serialize={String} select={onStatusChange} diff --git a/src/components/settings/tabs/vencord/NotificationSettings.tsx b/src/components/settings/tabs/vencord/NotificationSettings.tsx index 95e6760d6..65057c6d1 100644 --- a/src/components/settings/tabs/vencord/NotificationSettings.tsx +++ b/src/components/settings/tabs/vencord/NotificationSettings.tsx @@ -11,7 +11,7 @@ import { Flex } from "@components/Flex"; import { Margins } from "@utils/margins"; import { identity } from "@utils/misc"; import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { Button, Forms, Select, Slider, Text } from "@webpack/common"; +import { Button, Forms, Select, Slider, Switch, Text } from "@webpack/common"; export function NotificationSection() { return ( @@ -88,6 +88,14 @@ function NotificationSettings() { serialize={identity} /> + Missed Notification Count + settings.missed = v} + > + When refocusing discord a notification will popup with how you missed + + Notification Timeout Set to 0s to never automatically time out {},$1" + }, + { + // Applies the playback rate passed through options. + match: /(?<=(\i).onloadeddata=\(\)=>{)/, + replace: "$1.playbackRate=this._speed," + }, + { + // Makes use of the error handler if an error occurs during playback. + match: /(onerror=\()(\)=>)(\i\(Error\("[^"]+"\)\)),/, + replace: "$1error$2{this.onError?.(error);$3;}," + }, + { + // Makes use of the onEnded callback and persists flag once the audio ends. + match: /(?<=onended=\(\)=>)(.{0,40}?),/, + replace: "{$self.stopAudio(this);this.onEnded?.();}," + }, + { + // Makes use of the persists flag once the audio is stopped. + match: /(stop\()(\){)this.destroyAudio\(\)/, + replace: "$1restart$2$self.stopAudio(this,restart);" + }, + { + // Use our playAudio function in place of the default playGiftSound. + match: /let \i=new Audio\((\(0,\i.\i\)\(\i\)).{0,35}?play\(\)/, + replace: "$self.playAudio($1)" + } + ] + }, + { + // Prevents Discord from forcing full volume for the "discodo" effect on client load. + // The internal sounds module being loaded on startup relies on one of these calls to volume + // regardless of if the "discodo" effect is enabled or not. This is due to the volume setter + // internally calling the ensureAudio function which is where the internal sounds module is loaded + // by default. To account for this, the module is force loaded in the first patch in the above group. + find: "UPDATE_OPEN_ON_STARTUP", + group: true, + replacement: [ + { + // Pass the unprocessed volume as 1 instead of overwriting it to 1 afterwards. + match: /(?<=discodo",\i)(\);return )\i.volume=1,/, + replace: ",1$1" + }, + { + // Don't re-set volume since it was already set during initialization. + match: /,(this._connectedSound.volume)=1/, + replace: ";" + } + ] + } + ], + + stopAudio(player: AudioPlayerInternal, restart?: boolean) { + if (restart) { + player.ensureAudio().then(audio => { + audio.currentTime = 0; + audio.play(); + }); + } else { + if (!player.persistent) { + player.destroyAudio(); + } else { + // Use _audio instead of ensureAudio() to avoid + // loading the audio if there is none loaded. + player._audio?.then(audio => { + audio.pause(); + audio.currentTime = 0; + }); + } + } + }, + + processAudio(player: AudioPlayerInternal) { + player.preprocessDataPrevious = player.preprocessDataCurrent ? structuredClone(player.preprocessDataCurrent) : null; + player.preprocessDataCurrent = structuredClone(player.preprocessDataOriginal); + player.preprocessDataCurrent.volume *= 100; + + for (const processor of Object.values(audioProcessorFunctions)) { + processor(player.preprocessDataCurrent); + } + + player.preprocessDataCurrent.volume /= 100; + player.audio = player.preprocessDataCurrent.audio; + player.type = identifyAudioType(player.preprocessDataCurrent.audio); + player._volume = Math.max(0, Math.min(1, player.preprocessDataCurrent.volume)); + player._speed = Math.max(0.0625, Math.min(16, player.preprocessDataCurrent.speed)); + + if (player.preprocessDataCurrent.audio !== player.preprocessDataPrevious?.audio) { + player.destroyAudio(); + player.persistent && player.ensureAudio(); + } + + if (player.preprocessDataCurrent.volume !== player.preprocessDataPrevious?.volume) { + player._audio?.then(audio => { + audio.volume = player._volume; + }); + } + + if (player.preprocessDataCurrent.speed !== player.preprocessDataPrevious?.speed) { + player._audio?.then(audio => { + audio.playbackRate = player._speed; + }); + } + }, + + buildPlayer( + player: AudioPlayerInternal, + audio: string, + unused: any, + internalVolume: number, + channel: string, + options: AudioPlayerOptions = {} + ) { + player.preprocessDataOriginal = { + audio: audio, + type: identifyAudioType(audio), + volume: Math.max(0, Math.min(1, (internalVolume || (options.volume ? options.volume / 100 : 1)))), + speed: Math.max(0.0625, Math.min(16, options.speed ?? 1)), + }; + + player.audio = player.preprocessDataOriginal.audio; + player._audio = null; + player._volume = player.preprocessDataOriginal.volume; + player._speed = player.preprocessDataOriginal.speed; + player.preload = options.preload ?? false; + player.persistent = options.persistent ?? false; + player.type = identifyAudioType(audio); + player.outputChannel = channel; + player.onEnded = options.onEnded; + player.onError = options.onError; + + player.processAudio = () => this.processAudio(player); + player.processAudio(); + player.preload && player.ensureAudio(); + } +}); diff --git a/src/equicordplugins/bypassStatus/index.tsx b/src/equicordplugins/bypassStatus/index.tsx index bc91399b4..37cafab2e 100644 --- a/src/equicordplugins/bypassStatus/index.tsx +++ b/src/equicordplugins/bypassStatus/index.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { type NavContextMenuPatchCallback } from "@api/ContextMenu"; import { Notifications } from "@api/index"; import { definePluginSettings } from "@api/Settings"; @@ -59,7 +60,7 @@ async function showNotification(message: Message, guildId: string | undefined): }); if (settings.store.notificationSound) { - new Audio("https://discord.com/assets/9422aef94aa931248105.mp3").play(); + playAudio("message1"); } } catch (error) { new Logger("BypassStatus").error("Failed to notify user: ", error); @@ -150,6 +151,7 @@ export default definePlugin({ name: "BypassStatus", description: "Still get notifications from specific sources when in do not disturb mode. Right-click on users/channels/guilds to set them to bypass do not disturb mode.", authors: [Devs.Inbestigator], + dependencies: ["AudioPlayerAPI"], flux: { async MESSAGE_CREATE({ message, guildId, channelId }: IMessageCreate): Promise { try { diff --git a/src/equicordplugins/customSounds/SoundOverrideComponent.tsx b/src/equicordplugins/customSounds/SoundOverrideComponent.tsx index 246cf691d..5d7aa402a 100644 --- a/src/equicordplugins/customSounds/SoundOverrideComponent.tsx +++ b/src/equicordplugins/customSounds/SoundOverrideComponent.tsx @@ -4,17 +4,18 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { AudioPlayerInterface, playAudio } from "@api/AudioPlayer"; import { classNameFactory } from "@api/Styles"; import { Margins } from "@utils/margins"; import { useForceUpdater } from "@utils/react"; import { makeRange } from "@utils/types"; -import { findByCodeLazy, findLazy } from "@webpack"; +import { findLazy } from "@webpack"; import { Button, Card, Forms, React, Select, showToast, Slider, Switch } from "@webpack/common"; import { ComponentType, Ref, SyntheticEvent } from "react"; import { deleteAudio, getAllAudio, saveAudio, StoredAudioFile } from "./audioStore"; import { ensureDataURICached } from "./index"; -import { SoundOverride, SoundPlayer, SoundType } from "./types"; +import { SoundOverride, SoundType } from "./types"; type FileInput = ComponentType<{ ref: Ref; @@ -25,7 +26,6 @@ type FileInput = ComponentType<{ const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "aac", "flac", "webm", "wma", "mp4"]; const cl = classNameFactory("vc-custom-sounds-"); -const playSound: (id: string) => SoundPlayer = findByCodeLazy(".playWithListener().then"); const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef); const capitalizeWords = (str: string) => @@ -38,7 +38,7 @@ export function SoundOverrideComponent({ type, override, onChange }: { }) { const fileInputRef = React.useRef(null); const update = useForceUpdater(); - const sound = React.useRef(null); + const sound = React.useRef(null); const [files, setFiles] = React.useState>({}); React.useEffect(() => { @@ -54,7 +54,7 @@ export function SoundOverrideComponent({ type, override, onChange }: { sound.current?.stop(); if (!override.enabled) { - sound.current = playSound(type.id); + sound.current = playAudio(type.id); return; } @@ -69,32 +69,20 @@ export function SoundOverrideComponent({ type, override, onChange }: { return; } - const audio = new Audio(dataUri); - audio.volume = override.volume / 100; - - audio.onerror = e => { - console.error("[CustomSounds] Error playing custom audio:", e); - showToast("Error playing custom sound. File may be corrupted."); - }; - - await audio.play(); - sound.current = { - play: () => audio.play(), - pause: () => audio.pause(), - stop: () => { - audio.pause(); - audio.currentTime = 0; - }, - loop: () => { audio.loop = true; } - }; + sound.current = playAudio(dataUri, { + volume: override.volume, onError: e => { + console.error("[CustomSounds] Error playing custom audio:", e); + showToast("Error playing custom sound. File may be corrupted."); + } + }); } catch (error) { console.error("[CustomSounds] Error in previewSound:", error); showToast("Error playing sound."); } } else if (selectedSound === "default") { - sound.current = playSound(type.id); + sound.current = playAudio(type.id); } else { - sound.current = playSound(selectedSound); + sound.current = playAudio(selectedSound); } }; @@ -207,6 +195,7 @@ export function SoundOverrideComponent({ type, override, onChange }: { markers={makeRange(0, 100, 10)} initialValue={override.volume} onValueChange={val => { + sound.current && (sound.current.volume = val); override.volume = val; saveAndNotify(); }} diff --git a/src/equicordplugins/customSounds/index.tsx b/src/equicordplugins/customSounds/index.tsx index 3458a0554..3b0624411 100644 --- a/src/equicordplugins/customSounds/index.tsx +++ b/src/equicordplugins/customSounds/index.tsx @@ -6,6 +6,7 @@ import "./styles.css"; +import { addAudioProcessor, AudioProcessor, PreprocessAudioData, removeAudioProcessor } from "@api/AudioPlayer"; import { get as getFromDataStore } from "@api/DataStore"; import { definePluginSettings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; @@ -42,42 +43,55 @@ function setOverride(id: string, override: SoundOverride) { settings.store[id] = JSON.stringify(override); } -export function getCustomSoundURL(id: string): string | null { - const override = getOverride(id); +export const getCustomSoundURL: AudioProcessor = (data: PreprocessAudioData) => { + let audioOverride = data.audio; + + if (data.audio in seasonalSounds) { + audioOverride = soundTypes.find(sound => sound.seasonal?.includes(data.audio))?.id || data.audio; + } + + const override = getOverride(audioOverride); if (!override?.enabled) { - return null; + return; } if (override.selectedSound === "custom" && override.selectedFileId) { const dataUri = dataUriCache.get(override.selectedFileId); if (dataUri) { - console.log(`[CustomSounds] Returning cached data URI for ${id}`); - return dataUri; + data.audio = dataUri; + data.volume = override.volume; + return; } else { - console.warn(`[CustomSounds] No cached data URI for ${id} with file ID ${override.selectedFileId}`); - return null; + return; } } if (override.selectedSound !== "default" && override.selectedSound !== "custom") { if (override.selectedSound in seasonalSounds) { - return seasonalSounds[override.selectedSound]; + data.audio = seasonalSounds[override.selectedSound]; + data.volume = override.volume; + return; } - const soundType = allSoundTypes.find(t => t.id === id); + const soundType = allSoundTypes.find(t => t.id === data.audio); + if (soundType?.seasonal) { const seasonalId = soundType.seasonal.find(seasonalId => seasonalId.startsWith(`${override.selectedSound}_`) ); + if (seasonalId && seasonalId in seasonalSounds) { - return seasonalSounds[seasonalId]; + data.audio = seasonalSounds[seasonalId]; + data.volume = override.volume; + return; } } } - return null; -} + data.volume = override.volume; + return; +}; export async function ensureDataURICached(fileId: string): Promise { if (dataUriCache.has(fileId)) { @@ -379,43 +393,15 @@ export default definePlugin({ name: "CustomSounds", description: "Customize Discord's sounds.", authors: [Devs.ScattrdBlade, Devs.TheKodeToad], - patches: [ - { - find: 'Error("could not play audio")', - replacement: [ - { - match: /(?<=new Audio;\i\.src=).{0,75}.concat\(this\.name,"\.mp3"\)\)/, - replace: "$self.getSoundUrl(this.name,$&)" - }, - { - match: /Math.min\(\i\.\i\.getOutputVolume\(\).{0,20}volume/, - replace: "$& * ($self.findOverride(this.name)?.volume ?? 100) / 100" - } - ] - }, - { - find: ".playWithListener().then", - replacement: { - match: /\i\.\i\.getSoundpack\(\)/, - replace: '$self.isOverriden(arguments[0]) ? "classic" : $&' - } - } - ], - getSoundUrl(name, extra) { - const customUrl = this.getCustomSoundURL(name); - return customUrl || extra; - }, + dependencies: ["AudioPlayerAPI"], + settings, - findOverride, - isOverriden, getCustomSoundURL, - refreshDataURI, - ensureDataURICached, - debugCustomSounds, startAt: StartAt.Init, async start() { console.log("[CustomSounds] Plugin starting..."); + addAudioProcessor("CustomSounds", this.getCustomSoundURL); try { await preloadDataURIs(); @@ -423,5 +409,10 @@ export default definePlugin({ } catch (error) { console.error("[CustomSounds] Startup failed:", error); } + }, + + stop() { + removeAudioProcessor("CustomSounds"); + console.log("[CustomSounds] Plugin stopped"); } }); diff --git a/src/equicordplugins/customSounds/types.ts b/src/equicordplugins/customSounds/types.ts index 570a8b838..a586ae361 100644 --- a/src/equicordplugins/customSounds/types.ts +++ b/src/equicordplugins/customSounds/types.ts @@ -18,13 +18,6 @@ export interface SoundOverride { selectedFileId?: string; } -export interface SoundPlayer { - loop(): void; - play(): void; - pause(): void; - stop(): void; -} - export const seasonalSounds = { "halloween_call_calling": "https://canary.discord.com/assets/0950a7ea4f1dd037870b.mp3", "winter_call_calling": "https://canary.discord.com/assets/7b945e7be3f86c5b7c82.mp3", diff --git a/src/equicordplugins/demonstration/index.tsx b/src/equicordplugins/demonstration/index.tsx index 54c2b459e..bb50917ba 100644 --- a/src/equicordplugins/demonstration/index.tsx +++ b/src/equicordplugins/demonstration/index.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; import { Devs, EquicordDevs } from "@utils/constants"; @@ -72,6 +73,7 @@ function ToggleModal() { } function handleToggle() { + console.log("Toggling demonstration mode"); const style = document.getElementById("DemonstrationStyle"); if (style != null) { style.remove(); @@ -118,18 +120,19 @@ function handleKeydown(event: KeyboardEvent) { } async function playSound(url: string) { - const audio = new Audio(url); - audio.volume = settings.store.soundVolume; - await audio.play().catch(error => { - console.error("Error playing sound:", error); + playAudio(url, { + volume: settings.store.soundVolume * 100, + onError: error => { + console.error("Error playing sound:", error); + } }); - audio.remove(); } export default definePlugin({ name: "Demonstration", description: "Plugin for taking theme screenshots - censors identifying images and text.", authors: [Devs.Samwich, EquicordDevs.Panniku], + dependencies: ["AudioPlayerAPI"], settings, toolboxActions: { "Toggle Demonstration": (() => handleToggle()) diff --git a/src/equicordplugins/equissant/index.ts b/src/equicordplugins/equissant/index.ts index 849d1ad04..188316213 100644 --- a/src/equicordplugins/equissant/index.ts +++ b/src/equicordplugins/equissant/index.ts @@ -4,20 +4,20 @@ * 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++; - const triggerAmount = settings.store.amount; - if (clickCount % triggerAmount === 0) { - const audio = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/equissant/croissant.mp3"); - audio.play(); + croissant?.play(); clickCount = 0; } } @@ -34,11 +34,14 @@ 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/gifCollections/index.tsx b/src/equicordplugins/gifCollections/index.tsx index c6a098223..c345aea25 100644 --- a/src/equicordplugins/gifCollections/index.tsx +++ b/src/equicordplugins/gifCollections/index.tsx @@ -41,6 +41,20 @@ const addCollectionContextMenuPatch: NavContextMenuPatchCallback = (children, pr if (group && !group.some(child => child?.props?.id === "add-to-collection")) { const collections = cache_collections; + if (settings.store.showCopyImageLink) { + group.push( + { + copyToClipboard(gif.url); + showToast("Image link copied to clipboard", Toasts.Type.SUCCESS); + }} + /> + ); + } + group.push( ( const MenuThingy = ({ gif }) => { const collections = cache_collections; - return ( + const menuItems: React.ReactNode[] = []; + + if (settings.store.showCopyImageLink) { + menuItems.push( + { + copyToClipboard(gif.url); + showToast("Image link copied to clipboard", Toasts.Type.SUCCESS); + }} + /> + ); + } + + menuItems.push( {collections.map(col => ( { /> ); + + return <>{menuItems}; }; function CreateCollectionModal({ gif, onClose, modalProps }) { diff --git a/src/equicordplugins/jumpscare/index.tsx b/src/equicordplugins/jumpscare/index.tsx index 67ddcf7c0..3357ecb97 100644 --- a/src/equicordplugins/jumpscare/index.tsx +++ b/src/equicordplugins/jumpscare/index.tsx @@ -6,6 +6,7 @@ import "./styles.css"; +import { createAudioPlayer } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; @@ -48,6 +49,7 @@ 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() { @@ -63,8 +65,7 @@ export default definePlugin({ JumpscareComponent() { const [isPlaying, setIsPlaying] = useState(false); - - const audio = new Audio(settings.store.audioSource); + const jumpscareAudio = createAudioPlayer(settings.store.audioSource, { volume: 100, onEnded: () => { setIsPlaying(false); } }); const jumpscare = event => { if (isPlaying) return; @@ -73,13 +74,7 @@ export default definePlugin({ if (Math.random() > chance) return; setIsPlaying(true); - audio.play(); - - console.log(isPlaying); - - setTimeout(() => { - setIsPlaying(false); - }, 1000); + jumpscareAudio.play(); }; useEffect(() => { diff --git a/src/equicordplugins/keyboardSounds/index.ts b/src/equicordplugins/keyboardSounds/index.ts index 2b6d292aa..14c8018ff 100644 --- a/src/equicordplugins/keyboardSounds/index.ts +++ b/src/equicordplugins/keyboardSounds/index.ts @@ -1,73 +1,142 @@ /* - * 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 . -*/ + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -import { Devs } from "@utils/constants"; +import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer"; +import { definePluginSettings } from "@api/Settings"; +import { Devs, EquicordDevs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; -let click1, click2, click3, backspace; -let sounds = { - click1, - click2, - click3, - backspace +import { ignoredKeys, packs } from "./packs"; + +const allSounds = { + backspaces: [] as { playing: boolean; player: AudioPlayerInterface; }[], + caps: [] as { playing: boolean; player: AudioPlayerInterface; }[], + enters: [] as { playing: boolean; player: AudioPlayerInterface; }[], + arrows: [] as { playing: boolean; player: AudioPlayerInterface; }[], + others: [] as { playing: boolean; player: AudioPlayerInterface; }[] }; -const ignoredKeys = ["CapsLock", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", "ArrowUp", "ArrowRight", "ArrowLeft", "ArrowDown", "MediaPlayPause", "MediaStop", "MediaTrackNext", "MediaTrackPrevious", "MediaSelect", "MediaEject", "MediaVolumeUp", "MediaVolumeDown", "AudioVolumeUp", "AudioVolumeDown"]; +let chosenPack: typeof packs[keyof typeof packs]; +const keysCurrentlyPressed = new Set(); + +const keyup = (e: KeyboardEvent) => { keysCurrentlyPressed.delete(e.code); }; const keydown = (e: KeyboardEvent) => { - if (ignoredKeys.includes(e.code)) return; - for (const sound of Object.values(sounds)) sound.pause(); - if (e.code === "Backspace") { - sounds.backspace.currentTime = 0; - sounds.backspace.play(); - } else { - const click = sounds[`click${Math.floor(Math.random() * 3) + 1}`]; - click.currentTime = 0; - click.play(); + if (!chosenPack) return; + if (ignoredKeys.includes(e.code) && !chosenPack.allowedIgnored?.includes(e.key)) return; + if (keysCurrentlyPressed.has(e.code)) return; + keysCurrentlyPressed.add(e.code); + + function getRandomSound(soundsArray: { playing: boolean; player: AudioPlayerInterface; }[]) { + const nonplayingSounds = soundsArray.filter(sound => !sound?.playing); + let randomIndex; + let chosenSound; + + if (nonplayingSounds.length) { + randomIndex = Math.floor(Math.random() * nonplayingSounds.length); + chosenSound = nonplayingSounds[randomIndex]; + } else { + randomIndex = Math.floor(Math.random() * soundsArray.length); + chosenSound = soundsArray[randomIndex]; + } + + if (chosenSound) { + chosenSound.playing = true; + chosenSound.player.restart(); + } + } + + if (e.code === "Backspace" && allSounds.backspaces.length) { + getRandomSound(allSounds.backspaces); + } else if (e.code === "CapsLock" && allSounds.caps.length) { + getRandomSound(allSounds.caps); + } else if (e.code === "Enter" && allSounds.enters.length) { + getRandomSound(allSounds.enters); + } else if (["ArrowUp", "ArrowRight", "ArrowLeft", "ArrowDown"].includes(e.code) && allSounds.arrows.length) { + getRandomSound(allSounds.arrows); + } else if (allSounds.others.length) { + getRandomSound(allSounds.others); } }; +function clearSounds() { + Array.from(Object.values(allSounds)).forEach(soundsArray => { soundsArray.forEach(sound => sound.player.delete()); }); + Object.keys(allSounds).forEach(key => { allSounds[key as keyof typeof allSounds] = []; }); +} + +function assignSounds(volume: number, pack: "operagx" | "osu") { + clearSounds(); + chosenPack = packs[pack]; + + if (!chosenPack) { + return; + } + + function addSounds(key: keyof typeof allSounds) { + if (!chosenPack[key]) return; + let soundIndex = -1; + + for (let i = 0; i < 3; i++) { + for (const url of chosenPack[key]) { + soundIndex++; + + allSounds[key].push({ + playing: false, + player: createAudioPlayer(url, { + volume, + preload: true, + persistent: true, + onEnded: () => { allSounds[key][soundIndex].playing = false; } + }) + }); + } + } + } + + chosenPack.backspaces && addSounds("backspaces"); + chosenPack.caps && addSounds("caps"); + chosenPack.enters && addSounds("enters"); + chosenPack.arrows && addSounds("arrows"); + chosenPack.others && addSounds("others"); +} + +const settings = definePluginSettings({ + volume: { + description: "Volume of the keyboard sounds.", + type: OptionType.SLIDER, + markers: [0, 25, 50, 75, 100], + stickToMarkers: false, + default: 100, + onChange: value => { assignSounds(value, settings.store.soundPack); } + }, + soundPack: { + description: "Sound pack to use.", + type: OptionType.SELECT, + options: [ + { label: "OperaGX", value: "operagx" as "operagx", default: true }, + { label: "osu!", value: "osu" as "osu" } + ], + onChange: value => { assignSounds(settings.store.volume, value); } + } +}); + export default definePlugin({ name: "KeyboardSounds", - description: "Adds the Opera GX Keyboard Sounds to Discord", - authors: [Devs.HypedDomi], - start: () => { - click1 = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click1.wav"); - click2 = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click2.wav"); - click3 = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click3.wav"); - backspace = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/backspace.wav"); - sounds = { - click1, - click2, - click3, - backspace, - }; + description: "Adds OperaGX or osu! sound effects when typing on your keyboard.", + authors: [Devs.HypedDomi, EquicordDevs.Etorix], + dependencies: ["AudioPlayerAPI"], + settings, + start() { + assignSounds(settings.store.volume, settings.store.soundPack); + document.addEventListener("keyup", keyup); document.addEventListener("keydown", keydown); }, - stop: () => document.removeEventListener("keydown", keydown), - options: { - volume: { - description: "Volume", - type: OptionType.SLIDER, - markers: [0, 100], - stickToMarkers: false, - default: 100, - onChange: value => { for (const sound of Object.values(sounds)) sound.volume = value / 100; } - } - } + stop: () => { + clearSounds(); + document.removeEventListener("keyup", keyup); + document.removeEventListener("keydown", keydown); + }, }); diff --git a/src/equicordplugins/keyboardSounds/packs.ts b/src/equicordplugins/keyboardSounds/packs.ts new file mode 100644 index 000000000..8eb839fa6 --- /dev/null +++ b/src/equicordplugins/keyboardSounds/packs.ts @@ -0,0 +1,78 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export const packs = { + "operagx": { + others: [ + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/operagx/click1.wav", + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/operagx/click2.wav", + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/operagx/click3.wav" + ], + backspaces: [ + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/operagx/backspace.wav" + ] + }, + "osu": { + others: [ + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/osu/key-press-1.mp3", + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/osu/key-press-2.mp3", + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/osu/key-press-3.mp3", + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/osu/key-press-4.mp3" + ], + backspaces: [ + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/osu/key-delete.mp3" + ], + caps: [ + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/osu/key-caps.mp3" + ], + enters: [ + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/osu/key-confirm.mp3" + ], + arrows: [ + "https://github.com/Equicord/Equibored/raw/main/sounds/keyboardSounds/osu/key-movement.mp3" + ], + allowedIgnored: [ + "CapsLock", + "ArrowUp", + "ArrowRight", + "ArrowLeft", + "ArrowDown" + ] + } +} as Record<"operagx" | "osu", { + others: string[]; + backspaces: string[]; + caps?: string[]; + enters?: string[]; + arrows?: string[]; + allowedIgnored?: string[]; +}>; + +export const ignoredKeys = [ + "CapsLock", + "ShiftLeft", + "ShiftRight", + "ControlLeft", + "ControlRight", + "AltLeft", + "AltRight", + "MetaLeft", + "MetaRight", + "ArrowUp", + "ArrowRight", + "ArrowLeft", + "ArrowDown", + "MediaPlayPause", + "MediaStop", + "MediaTrackNext", + "MediaTrackPrevious", + "MediaSelect", + "MediaEject", + "MediaVolumeUp", + "MediaVolumeDown", + "AudioVolumeUp", + "AudioVolumeDown" +]; diff --git a/src/equicordplugins/keywordNotify/index.tsx b/src/equicordplugins/keywordNotify/index.tsx index 7003fc2da..b4d506b95 100644 --- a/src/equicordplugins/keywordNotify/index.tsx +++ b/src/equicordplugins/keywordNotify/index.tsx @@ -16,7 +16,7 @@ import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; import { useForceUpdater } from "@utils/react"; import definePlugin, { OptionType } from "@utils/types"; -import { Message, User } from "@vencord/discord-types"; +import { Message } from "@vencord/discord-types"; import { findByCodeLazy, findByPropsLazy } from "@webpack"; import { Button, ChannelStore, FluxDispatcher, Forms, Select, SelectedChannelStore, Switch, TabBar, TextInput, Tooltip, UserStore, useState } from "@webpack/common"; import type { JSX, PropsWithChildren } from "react"; @@ -25,14 +25,12 @@ type IconProps = JSX.IntrinsicElements["svg"]; type KeywordEntry = { regex: string, listIds: Array, listType: ListType, ignoreCase: boolean; }; let keywordEntries: Array = []; -let currentUser: User; let keywordLog: Array = []; let interceptor: (e: any) => void; const recentMentionsPopoutClass = findByPropsLazy("recentMentionsPopout"); const tabClass = findByPropsLazy("inboxTitle", "tab"); const buttonClass = findByPropsLazy("size36"); -const MenuHeader = findByCodeLazy(".getUnseenInviteCount())"); const Popout = findByCodeLazy("getProTip", "canCloseAllMessages:"); const createMessageRecord = findByCodeLazy(".createFromServer(", ".isBlockedForMessage", "messageReference:"); const KEYWORD_ENTRIES_KEY = "KeywordNotify_keywordEntries"; @@ -85,9 +83,11 @@ function highlightKeywords(str: string, entries: Array) { const idx = str.indexOf(matches[0]); return [ - {str.substring(0, idx)}, - {matches[0]}, - {str.substring(idx + matches[0].length)} + <> + {str.substring(0, idx)}, + {matches[0]}, + {str.substring(idx + matches[0].length)} + ]; } @@ -102,7 +102,11 @@ function Collapsible({ title, children }) { size={Button.Sizes.ICON} className={cl("collapsible")}>
-
{isOpen ? "â–¼" : "â–¶"}
+
{isOpen ? "â–¼" : "â–¶"}
{title}
@@ -116,34 +120,36 @@ function ListedIds({ listIds, setListIds }) { const [values] = useState(listIds); async function onChange(e: string, index: number) { - values[index] = e; + values[index] = e.trim(); setListIds(values); update(); } const elements = values.map((currentValue: string, index: number) => { return ( - -
- onChange(e, index)} - /> -
- -
+ <> + +
+ onChange(e, index)} + /> +
+ +
+ ); }); @@ -192,6 +198,12 @@ function KeywordEntries() { update(); } + async function setIgnoreCase(index: number, value: boolean) { + keywordEntries[index].ignoreCase = value; + await DataStore.set(KEYWORD_ENTRIES_KEY, keywordEntries); + update(); + } + const elements = keywordEntries.map((entry, i) => { return ( <> @@ -216,8 +228,7 @@ function KeywordEntries() { { - values[i].ignoreCase = !values[i].ignoreCase; - update(); + setIgnoreCase(i, !values[i].ignoreCase); }} style={{ marginTop: "0.5em", marginRight: "40px" }} > @@ -314,16 +325,22 @@ export default definePlugin({ patches: [ { find: "#{intl::UNREADS_TAB_LABEL})}", - replacement: { - match: /,(\i\?\(0,\i\.jsxs\)\(\i\.\i\i\.Item)/, - replace: ",$self.keywordTabBar()$&" - } + replacement: [ + { + match: /,(\i\?\(0,\i\.jsxs\)\(\i\.\i\i\.Item)/, + replace: ",$self.keywordTabBar()$&" + }, + { + match: /:(\i)===\i\.\i\.MENTIONS\?/, + replace: ": $1 === 8 ? $self.keywordClearButton() $&" + } + ] }, { find: "location:\"RecentsPopout\"});", replacement: { - match: /(?<=setTab:(\i),badgeState:\i,closePopout:(\i).{0,50}):(\i)===\i\.\i\.MENTIONS\?\(0,.+?onJump:(\i)/, - replace: ": $3 === 8 ? $self.tryKeywordMenu($1, $4, $2) $&" + match: /:(\i)===\i\.\i\.MENTIONS\?\(0,.+?onJump:(\i)}\)/, + replace: ": $1 === 8 ? $self.tryKeywordMenu($2) $&" } }, { @@ -343,11 +360,14 @@ export default definePlugin({ async start() { this.onUpdate = () => null; - currentUser = UserStore.getCurrentUser(); keywordEntries = await DataStore.get(KEYWORD_ENTRIES_KEY) ?? []; await DataStore.set(KEYWORD_ENTRIES_KEY, keywordEntries); (await DataStore.get(KEYWORD_LOG_KEY) ?? []).map(e => JSON.parse(e)).forEach(e => { - this.addToLog(e); + try { + this.addToLog(e); + } catch (err) { + console.error(err); + } }); interceptor = (e: any) => { @@ -371,11 +391,11 @@ export default definePlugin({ continue; } - let listed = entry.listIds.some(id => id === m.channel_id || id === m.author.id); + let listed = entry.listIds.some(id => id.trim() === m.channel_id || id === m.author.id); if (!listed) { const channel = ChannelStore.getChannel(m.channel_id); if (channel != null) { - listed = entry.listIds.some(id => id === channel.guild_id); + listed = entry.listIds.some(id => id.trim() === channel.guild_id); } } @@ -413,24 +433,45 @@ export default definePlugin({ } if (matches) { - // @ts-ignore - m.mentions.push({ id: currentUser.id }); + const id = UserStore.getCurrentUser()?.id; + if (id !== null) { + // @ts-ignore + m.mentions.push({ id: id }); + } - if (m.author.id !== currentUser.id) + if (m.author.id !== id) { + this.storeMessage(m); this.addToLog(m); + } } }, + storeMessage(m: Message) { + if (m == null) + return; + DataStore.get(KEYWORD_LOG_KEY).then(log => { + log = log ? log.map((e: string) => JSON.parse(e)) : []; + + log.push(m); + if (log.length > settings.store.amountToKeep) { + log = log.slice(-settings.store.amountToKeep); + } + + DataStore.set(KEYWORD_LOG_KEY, log.map(e => JSON.stringify(e))); + }); + }, addToLog(m: Message) { if (m == null || keywordLog.some(e => e.id === m.id)) return; - DataStore.get(KEYWORD_LOG_KEY).then(log => { - DataStore.set(KEYWORD_LOG_KEY, [...log, JSON.stringify(m)]); - }); + let messageRecord: any; + try { + messageRecord = createMessageRecord(m); + } catch (err) { + return; + } - const thing = createMessageRecord(m); - keywordLog.push(thing); + keywordLog.push(messageRecord); keywordLog.sort((a, b) => b.timestamp - a.timestamp); while (keywordLog.length > settings.store.amountToKeep) { @@ -453,28 +494,27 @@ export default definePlugin({ ); }, - tryKeywordMenu(setTab, onJump, closePopout) { - const header = ( - <> - - - {({ onMouseLeave, onMouseEnter }) => ( -
{ - keywordLog = []; - DataStore.set(KEYWORD_LOG_KEY, []); - this.onUpdate(); - }}> - -
- )} -
-
- + keywordClearButton() { + return ( + + {({ onMouseLeave, onMouseEnter }) => ( +
{ + keywordLog = []; + DataStore.set(KEYWORD_LOG_KEY, []); + this.onUpdate(); + }}> + +
+ )} +
); + }, + tryKeywordMenu(onJump) { const channel = ChannelStore.getChannel(SelectedChannelStore.getChannelId()); const [tempLogs, setKeywordLog] = useState(keywordLog); @@ -503,7 +543,7 @@ export default definePlugin({ <> header} + renderHeader={() => null} renderMessage={messageRender} channel={channel} onJump={onJump} diff --git a/src/equicordplugins/messageLoggerEnhanced/list.ts b/src/equicordplugins/messageLoggerEnhanced/list.ts new file mode 100644 index 000000000..36e7a1826 --- /dev/null +++ b/src/equicordplugins/messageLoggerEnhanced/list.ts @@ -0,0 +1,103 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export const blockedExts = [ + "com", + "exe", + "dll", + "ocx", + "ps1", + "ps1xml", + "ps2", + "ps2xml", + "psc1", + "psc2", + "msh", + "msh1", + "msh2", + "mshxml", + "msh1xml", + "msh2xml", + "js", + "jse", + "vbs", + "vb", + "vbe", + "cmd", + "bat", + "hta", + "inf", + "reg", + "pif", + "scr", + "cpl", + "scf", + "msc", + "pol", + "hlp", + "chm", + "ws", + "wsf", + "wsc", + "wsh", + "jar", + "z", + "bz2", + "cab", + "tar", + "ace", + "msi", + "msp", + "mst", + "msu", + "ppkg", + "bak", + "tmp", + "ost", + "pst", + "pkg", + "iso", + "img", + "vhd", + "vhdx", + "application", + "lock", + "lck", + "sln", + "cs", + "csproj", + "resx", + "config", + "resources", + "pdb", + "manifest", + "doc", + "dot", + "wbk", + "xls", + "xlt", + "xlm", + "xla", + "ppt", + "pot", + "pps", + "ade", + "adp", + "mdb", + "cdb", + "mda", + "mdn", + "mdt", + "mdf", + "mde", + "ldb", + "wps", + "xlsb", + "xlam", + "xll", + "xlw", + "ppam" +]; diff --git a/src/equicordplugins/messageLoggerEnhanced/native/index.ts b/src/equicordplugins/messageLoggerEnhanced/native/index.ts index c50608880..7dc35a291 100644 --- a/src/equicordplugins/messageLoggerEnhanced/native/index.ts +++ b/src/equicordplugins/messageLoggerEnhanced/native/index.ts @@ -10,11 +10,9 @@ import path from "node:path"; import { DATA_DIR } from "@main/utils/constants"; import { dialog, IpcMainInvokeEvent, shell } from "electron"; -import { getSettings, saveSettings } from "./settings"; -export * from "./updater"; - import { LoggedAttachment } from "../types"; import { LOGS_DATA_FILENAME } from "../utils/constants"; +import { getSettings, saveSettings } from "./settings"; import { ensureDirectoryExists, getAttachmentIdFromFilename, sleep } from "./utils"; export { getSettings }; @@ -145,23 +143,23 @@ export async function chooseFile(_event: IpcMainInvokeEvent, title: string, filt // doing it in native because you can only fetch images from the renderer // other types of files will cause cors issues -export async function downloadAttachment(_event: IpcMainInvokeEvent, attachemnt: LoggedAttachment, attempts = 0, useOldUrl = false): Promise<{ error: string | null; path: string | null; }> { +export async function downloadAttachment(_event: IpcMainInvokeEvent, attachment: LoggedAttachment, attempts = 0, useOldUrl = false): Promise<{ error: string | null; path: string | null; }> { try { - if (!attachemnt?.url || !attachemnt.oldUrl || !attachemnt?.id || !attachemnt?.fileExtension) + if (!attachment?.url || !attachment.oldUrl || !attachment?.id) return { error: "Invalid Attachment", path: null }; - if (attachemnt.id.match(/[\\/.]/)) { + if (attachment.id.match(/[\\/.]/)) { return { error: "Invalid Attachment ID", path: null }; } - const existingImage = nativeSavedImages.get(attachemnt.id); + const existingImage = nativeSavedImages.get(attachment.id); if (existingImage) return { error: null, path: existingImage }; - const res = await fetch(useOldUrl ? attachemnt.oldUrl : attachemnt.url); + const res = await fetch(useOldUrl ? attachment.oldUrl : attachment.url); if (res.status !== 200) { if (res.status === 404 || res.status === 403 || res.status === 415) @@ -170,23 +168,23 @@ export async function downloadAttachment(_event: IpcMainInvokeEvent, attachemnt: attempts++; if (attempts > 3) { return { - error: `Failed to get attachment ${attachemnt.id} for caching. too many attempts, error code ${res.status}`, + error: `Failed to get attachment ${attachment.id} for caching. too many attempts, error code ${res.status}`, path: null, }; } await sleep(1000); - return downloadAttachment(_event, attachemnt, attempts, useOldUrl); + return downloadAttachment(_event, attachment, attempts, useOldUrl); } const ab = await res.arrayBuffer(); const imageCacheDir = await getImageCacheDir(); await ensureDirectoryExists(imageCacheDir); - const finalPath = path.join(imageCacheDir, `${attachemnt.id}${attachemnt.fileExtension}`); + const finalPath = path.join(imageCacheDir, `${attachment.id}${attachment.fileExtension}`); await writeFile(finalPath, Buffer.from(ab)); - nativeSavedImages.set(attachemnt.id, finalPath); + nativeSavedImages.set(attachment.id, finalPath); return { error: null, diff --git a/src/equicordplugins/messageLoggerEnhanced/native/updater.ts b/src/equicordplugins/messageLoggerEnhanced/native/updater.ts deleted file mode 100644 index 9055fc96a..000000000 --- a/src/equicordplugins/messageLoggerEnhanced/native/updater.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { execFile as cpExecFile, ExecFileOptions } from "node:child_process"; - -import { readdir } from "fs/promises"; -import { join } from "path"; -import { promisify } from "util"; - -import type { GitResult } from "../types"; -import { memoize } from "../utils/memoize"; - -const execFile = promisify(cpExecFile); - -const isFlatpak = process.platform === "linux" && Boolean(process.env.FLATPAK_ID?.includes("discordapp") || process.env.FLATPAK_ID?.includes("Discord")); -if (process.platform === "darwin") process.env.PATH = `/usr/local/bin:${process.env.PATH}`; - - -const VENCORD_USER_PLUGIN_DIR = join(__dirname, "..", "src", "userplugins"); -const getCwd = memoize(async () => { - const dirs = await readdir(VENCORD_USER_PLUGIN_DIR, { withFileTypes: true }); - - for (const dir of dirs) { - if (!dir.isDirectory()) continue; - - const pluginDir = join(VENCORD_USER_PLUGIN_DIR, dir.name); - const files = await readdir(pluginDir); - - if (files.includes("LoggedMessageManager.ts")) return join(VENCORD_USER_PLUGIN_DIR, dir.name); - } - - return; -}); - -async function git(...args: string[]): Promise { - const opts: ExecFileOptions = { cwd: await getCwd(), shell: true }; - - try { - let result; - if (isFlatpak) { - result = await execFile("flatpak-spawn", ["--host", "git", ...args], opts); - } else { - result = await execFile("git", args, opts); - } - - return { value: result.stdout.trim(), stderr: result.stderr, ok: true }; - } catch (error: any) { - return { - ok: false, - cmd: error.cmd as string, - message: error.stderr as string, - error - }; - } -} - -export async function update() { - return await git("pull"); -} - -export async function getCommitHash() { - return await git("rev-parse", "HEAD"); -} - -export interface GitInfo { - repo: string; - gitHash: string; -} - -export async function getRepoInfo(): Promise { - const res = await git("remote", "get-url", "origin"); - if (!res.ok) { - return res; - } - - const gitHash = await getCommitHash(); - if (!gitHash.ok) { - return gitHash; - } - - return { - ok: true, - value: { - repo: res.value - .replace(/git@(.+):/, "https://$1/") - .replace(/\.git$/, ""), - gitHash: gitHash.value - } - }; -} - -export interface Commit { - hash: string; - longHash: string; - message: string; - author: string; -} - -export async function getNewCommits(): Promise { - const branch = await git("branch", "--show-current"); - if (!branch.ok) { - return branch; - } - - const logFormat = "%H;%an;%s"; - const branchRange = `HEAD..origin/${branch.value}`; - - try { - await git("fetch"); - - const logOutput = await git("log", `--format="${logFormat}"`, branchRange); - - if (!logOutput.ok) { - return logOutput; - } - - if (logOutput.value.trim() === "") { - return { ok: true, value: [] }; - } - - const commitLines = logOutput.value.trim().split("\n"); - const commits: Commit[] = commitLines.map(line => { - const [hash, author, ...rest] = line.split(";"); - return { longHash: hash, hash: hash.slice(0, 7), author, message: rest.join(";") } satisfies Commit; - }); - - return { ok: true, value: commits }; - } catch (error: any) { - return { ok: false, cmd: error.cmd, message: error.message, error }; - } -} diff --git a/src/equicordplugins/messageLoggerEnhanced/native/utils.ts b/src/equicordplugins/messageLoggerEnhanced/native/utils.ts index 691747f04..e29bdca0c 100644 --- a/src/equicordplugins/messageLoggerEnhanced/native/utils.ts +++ b/src/equicordplugins/messageLoggerEnhanced/native/utils.ts @@ -25,4 +25,8 @@ export function getAttachmentIdFromFilename(filename: string) { return path.parse(filename).name; } +export function getAttachmentExtFromFilename(filename: string) { + return path.parse(filename).ext; +} + export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/src/equicordplugins/messageLoggerEnhanced/settings.tsx b/src/equicordplugins/messageLoggerEnhanced/settings.tsx index 089304936..4ede6d099 100644 --- a/src/equicordplugins/messageLoggerEnhanced/settings.tsx +++ b/src/equicordplugins/messageLoggerEnhanced/settings.tsx @@ -14,6 +14,7 @@ import { Native } from "."; import { ImageCacheDir, LogsDir } from "./components/FolderSelectInput"; import { openLogModal } from "./components/LogsModal"; import { clearMessagesIDB } from "./db"; +import { blockedExts } from "./list"; import { DEFAULT_IMAGE_CACHE_DIR } from "./utils/constants"; import { exportLogs, importLogs } from "./utils/settingsUtils"; @@ -131,7 +132,19 @@ export const settings = definePluginSettings({ attachmentFileExtensions: { default: "png,jpg,jpeg,gif,webp,mp4,webm,mp3,ogg,wav", type: OptionType.STRING, - description: "Comma separated list of file extensions to save. Attachments with file extensions not in this list will not be saved. Leave empty to save all attachments." + description: "Comma separated list of file extensions to save. Attachments with file extensions not in this list will not be saved. Leave empty to save all attachments.", + onChange: (value: string) => { + if (!value) return; + const exts = value.split(",").map(ext => ext.trim().toLowerCase()); + + const invalid = exts.filter(ext => blockedExts.includes(ext)); + if (invalid.length > 0) { + console.warn("Rejected invalid file extensions:", invalid); + return exts.filter(ext => !blockedExts.includes(ext)).join(","); + } + + return exts.join(","); + } }, cacheLimit: { diff --git a/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts b/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts index 34dfab23d..052b9908f 100644 --- a/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts +++ b/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts @@ -158,10 +158,6 @@ export function getNative(): PluginNative { messageLoggerEnhancedUniqueIdThingyIdkMan: async () => { }, showItemInFolder: async () => { }, writeImageNative: async () => { }, - getCommitHash: async () => ({ ok: true, value: "" }), - getRepoInfo: async () => ({ ok: true, value: { repo: "", gitHash: "" } }), - getNewCommits: async () => ({ ok: true, value: [] }), - update: async () => ({ ok: true, value: "" }), chooseFile: async () => "", downloadAttachment: async () => ({ error: "web", path: null }), } satisfies PluginNative; diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx index e0a4a9646..00dd5a987 100644 --- a/src/equicordplugins/questify/index.tsx +++ b/src/equicordplugins/questify/index.tsx @@ -12,10 +12,10 @@ import { EquicordDevs } from "@utils/constants"; import { getIntlMessage } from "@utils/index"; import definePlugin, { StartAt } from "@utils/types"; import { onceReady } from "@webpack"; -import { ContextMenuApi, Menu, NavigationRouter } from "@webpack/common"; +import { ContextMenuApi, Menu, NavigationRouter, useEffect, useState } from "@webpack/common"; import { JSX } from "react"; -import { addIgnoredQuest, autoFetchCompatible, fetchAndAlertQuests, maximumAutoFetchIntervalValue, minimumAutoFetchIntervalValue, questIsIgnored, removeIgnoredQuest, rerenderQuests, settings, startAutoFetchingQuests, stopAutoFetchingQuests, validateAndOverwriteIgnoredQuests } from "./settings"; +import { addIgnoredQuest, addRerenderCallback, autoFetchCompatible, fetchAndAlertQuests, maximumAutoFetchIntervalValue, minimumAutoFetchIntervalValue, questIsIgnored, removeIgnoredQuest, rerenderQuests, settings, startAutoFetchingQuests, stopAutoFetchingQuests, validateAndOverwriteIgnoredQuests } from "./settings"; import { ExcludedQuestMap, GuildlessServerListItem, Quest, QuestIcon, QuestMap, QuestStatus, RGB } from "./utils/components"; import { adjustRGB, decimalToRGB, fetchAndDispatchQuests, formatLowerBadge, getFormattedNow, getIgnoredQuestIDs, getQuestStatus, isDarkish, leftClick, middleClick, normalizeQuestName, q, QuestifyLogger, questPath, QuestsStore, refreshQuest, reportPlayGameQuestProgress, reportVideoQuestProgress, rightClick, setIgnoredQuestIDs, waitUntilEnrolled } from "./utils/misc"; @@ -51,16 +51,16 @@ function questMenuIgnoreAllClicked(): void { settings.store.unclaimedUnignoredQuests = 0; } +function showQuestsButton(questButtonDisplay: string, unclaimedUnignoredQuests: number, onQuestsPage: boolean): boolean { + const canShow = questButtonDisplay !== "never"; + const alwaysShow = questButtonDisplay === "always"; + return canShow && (alwaysShow || !!unclaimedUnignoredQuests || onQuestsPage); +} + export function QuestButton(): JSX.Element { const { questButtonDisplay, questButtonUnclaimed, questButtonBadgeColor, unclaimedUnignoredQuests, onQuestsPage } = settings.use(["questButtonDisplay", "questButtonUnclaimed", "questButtonBadgeColor", "unclaimedUnignoredQuests", "onQuestsPage"]); const questButtonBadgeColorRGB = questButtonBadgeColor === null ? null : decimalToRGB(questButtonBadgeColor); - function showQuestsButton(): boolean { - const canShow = questButtonDisplay !== "never"; - const alwaysShow = questButtonDisplay === "always"; - return canShow && (alwaysShow || !!unclaimedUnignoredQuests || onQuestsPage); - } - function handleClick(event: React.MouseEvent) { // ListItem does not support onAuxClick, so we have to listen for mousedown events. // Ignore left and right clicks sent via mousedown events to prevent double events. @@ -128,7 +128,7 @@ export function QuestButton(): JSX.Element { icon={QuestIcon(26, 26)} tooltip="Quests" showPill={true} - isVisible={showQuestsButton()} + isVisible={showQuestsButton(questButtonDisplay, unclaimedUnignoredQuests, onQuestsPage)} isSelected={onQuestsPage} hasUnread={!!unclaimedUnignoredQuests && ["pill", "both"].includes(questButtonUnclaimed)} lowerBadgeProps={lowerBadgeProps} @@ -325,32 +325,8 @@ export function getQuestTileClasses(originalClasses: string, quest: Quest, color return returnClasses.join(" "); } -function preprocessQuests(quests: Quest[]): Quest[] { - const { - ignoredQuestIDs, - ignoredQuestProfile, - reorderQuests, - unclaimedSubsort, - claimedSubsort, - ignoredSubsort, - expiredSubsort, - makeMobileQuestsDesktopCompatible, - completeVideoQuestsInBackground, - completeGameQuestsInBackground, - triggerQuestsRerender - } = settings.use([ - "ignoredQuestIDs", - "ignoredQuestProfile", - "reorderQuests", - "unclaimedSubsort", - "claimedSubsort", - "ignoredSubsort", - "expiredSubsort", - "makeMobileQuestsDesktopCompatible", - "completeVideoQuestsInBackground", - "completeGameQuestsInBackground", - "triggerQuestsRerender" - ]); +function makeDesktopCompatible(quests: Quest[]): void { + const { makeMobileQuestsDesktopCompatible, triggerQuestsRerender } = settings.use(["makeMobileQuestsDesktopCompatible", "triggerQuestsRerender"]); if (makeMobileQuestsDesktopCompatible) { quests.forEach(quest => { @@ -378,8 +354,36 @@ function preprocessQuests(quests: Quest[]): Quest[] { patchedMobileQuests.clear(); } +} - if (!reorderQuests || !reorderQuests.trim()) { +function sortQuests(quests: Quest[], skip?: boolean): Quest[] { + const { + ignoredQuestIDs, + ignoredQuestProfile, + reorderQuests, + unclaimedSubsort, + claimedSubsort, + ignoredSubsort, + expiredSubsort, + completeVideoQuestsInBackground, + completeGameQuestsInBackground, + triggerQuestsRerender + } = settings.use([ + "ignoredQuestIDs", + "ignoredQuestProfile", + "reorderQuests", + "unclaimedSubsort", + "claimedSubsort", + "ignoredSubsort", + "expiredSubsort", + "completeVideoQuestsInBackground", + "completeGameQuestsInBackground", + "triggerQuestsRerender" + ]); + + makeDesktopCompatible(quests); + + if (skip || !reorderQuests?.trim()) { return quests; } @@ -786,55 +790,77 @@ function getQuestPanelOverride(): Quest | null { return closestQuest; } +function disguiseHomeButton(location: string): boolean { + const { questButtonDisplay, unclaimedUnignoredQuests, onQuestsPage } = settings.use(["questButtonDisplay", "unclaimedUnignoredQuests", "onQuestsPage"]); + + if (!showQuestsButton(questButtonDisplay, unclaimedUnignoredQuests, onQuestsPage)) { + return false; + } + + return location === questPath; +} + +function useQuestRerender(): number { + const { triggerQuestsRerender } = settings.use(["triggerQuestsRerender"]); + const [renderTrigger, setRenderTrigger] = useState(0); + useEffect(() => addRerenderCallback(() => setRenderTrigger(prev => prev + 1)), []); + return renderTrigger; +} + +function getLastSortChoice(): string | null { + const { rememberQuestPageSort, lastQuestPageSort } = settings.store; + return rememberQuestPageSort ? lastQuestPageSort : "questify"; +} + +function getLastFilterChoices(): { group: string; filter: string; }[] | null { + const { rememberQuestPageFilters, lastQuestPageFilters } = settings.store; + return rememberQuestPageFilters ? Object.values(lastQuestPageFilters) : null; +} + +function setLastSortChoice(sort: string): void { + const { rememberQuestPageFilters } = settings.use(["rememberQuestPageFilters"]); + settings.store.lastQuestPageSort = sort; +} + +function setLastFilterChoices(filters: { group: string; filter: string; }[]): void { + const { rememberQuestPageFilters } = settings.use(["rememberQuestPageFilters"]); + if (!filters || !Object.keys(filters).length || !Object.values(filters).every(f => f.group && f.filter)) { return; } + settings.store.lastQuestPageFilters = JSON.parse(JSON.stringify(filters)).reduce((acc, item) => ({ ...acc, [item.filter]: item }), {}); +} + export default definePlugin({ name: "Questify", description: "Enhance your Quest experience with a suite of features, or disable them entirely if they're not your thing.", authors: [EquicordDevs.Etorix], - dependencies: ["ServerListAPI"], + dependencies: ["AudioPlayerAPI", "ServerListAPI"], startAt: StartAt.Init, // Needed in order to beat Read All Messages to inserting above the server list. settings, + sortQuests, formatLowerBadge, getQuestTileStyle, - preprocessQuests, getQuestTileClasses, - shouldPreloadQuestAssets, + makeDesktopCompatible, shouldHideQuestPopup, shouldHideDiscoveryTab, + shouldPreloadQuestAssets, shouldPreventFetchingQuests, shouldHideBadgeOnUserProfiles, shouldHideGiftInventoryRelocationNotice, shouldHideFriendsListActiveNowPromotion, shouldDisableQuestAcceptedButton, - getQuestPanelOverride, - getQuestAcceptedButtonText, processQuestForAutoComplete, + getQuestAcceptedButtonText, + getQuestPanelOverride, + setLastFilterChoices, + getLastFilterChoices, activeQuestIntervals, + disguiseHomeButton, + getLastSortChoice, + setLastSortChoice, + useQuestRerender, patches: [ - { - find: "could not play audio", - group: true, - replacement: [ - { - // Enables external audio sources for playing audio. - match: /(?<=new Audio;\i\.src=)/, - replace: "this.name.startsWith('https')?this.name:" - }, - { - // Adds an optional callback to the audio player. This is needed to detect - // when the audio has finished playing as playWithListener() relies on a duration - // variable which is never present. - match: /(constructor\(\i,\i,\i,\i)(\){)/, - replace: "$1,callback$2this.callback=callback||null," - }, - { - // Makes use of the callback if provided. - match: /(?<=.onended=\(\)=>)(this.destroyAudio\(\))/, - replace: "{this.callback?this.callback():null;$1;}" - } - ] - }, { // Hides the notice in the gift inventory that Quests have been relocated to the Discovery tab. find: "quests-wumpus-hikes-mountain-transparent-background", @@ -987,26 +1013,109 @@ export default definePlugin({ // Encourages reward icons to load quicker if the setting is enabled. match: /(onReceiveErrorHints:\i,)isVisibleInViewport:(\i)/, replace: "$1isVisibleInViewport:$self.shouldPreloadQuestAssets()?true:$2" - } + }, ] }, { - // Sorts the "All Quests" tab Quest tiles. - // Sorts the "Claimed Quests" tab Quest tiles. - // Also sets mobile-only Quests as desktop compatible if the setting is enabled. + // Sorts the "Claimed Quests" tabs. find: ".ALL)}):(", group: true, replacement: [ { match: /(claimedQuests:(\i).{0,50}?;)/, - replace: "$1$2=$self.preprocessQuests($2);" + replace: "$1$2=$self.sortQuests($2);" + }, + ] + }, + { + // Sorts the "All Quests" tab. + find: "CLAIMED=\"claimed\",", + group: true, + replacement: [ + { + // Run Questify's sort function every time due to hook requirements but return + // early if not applicable. If the sort method is set to "Questify", replace the + // quests with the sorted ones. Also, setup a trigger to rerender the memo. + match: /(?<=function \i\((\i).{0,100}?useRef\(\i\);)(return \i.useMemo\(\(\)=>{)/, + replace: "const questRerenderTrigger=$self.useQuestRerender();const questifySorted=$self.sortQuests($1,arguments[1].sortMethod!==\"questify\");$2if(arguments[1].sortMethod===\"questify\"){$1=questifySorted;};" }, { - match: /(quests:(\i).{0,50}?;)/, - replace: "$1$2=$self.preprocessQuests($2);" + // Account for Quest status changes. + match: /return (\i).current;/, + replace: "null;" + }, + { + // If we already applied Questify's sort, skip further sorting. + match: /(?<=(\i)\)\);)(return )((\i).sort)/, + replace: "$2$1===\"questify\"?$4:$3" + }, + { + // Add the trigger to the memo for rerendering Quests order due to progress changes, etc. + match: /(?<=current=\i,\i},\[\i,\i,\i)/, + replace: ",questRerenderTrigger,questifySorted" } ] }, + { + // Adds the "Questify" sort option to the sort enum. + find: "SUGGESTED=\"suggested\",", + replacement: { + match: /return ((\i).SUGGESTED="suggested",)/, + replace: "return $2.QUESTIFY=\"questify\",$1" + } + }, + { + // Adds the "Questify" sort option to the sort dropdown. + find: "radioItemTitle,options", + group: true, + replacement: [ + { + match: /(?=case (\i.\i).SUGGESTED)/, + replace: "case $1.QUESTIFY:return \"Questify\";" + } + ] + }, + { + // Loads the last used sort method and filter choices. + // Defaults to sorting by "Questify" and no filters. + find: "QUEST_HOME_SORT_METHOD_CHANGED,", + group: true, + replacement: [ + { + // Set the initial sort method. + match: /(\i.\i.SUGGESTED)/, + replace: "$self.getLastSortChoice()??$1" + }, + { + // Set the initial filters. + match: /(useState\()(\i\),{quests)/, + replace: "$1$self.getLastFilterChoices()??$2" + }, + { + // Update the last used sort method when it changes. + match: /(onChange:)(\i)(.{0,40}?selectedSortMethod)/, + replace: "$1(value)=>{$self.settings.store.lastQuestPageSort=value;$2(value);}$3" + }, + { + // Update the last used filter choices when they change. + match: /(onChange:)(\i)(.{0,40}?selectedFilters)/, + replace: "$1(value)=>{$self.settings.store.lastQuestPageFilters=value.reduce((acc,item)=>({...acc,[item.filter]:item}),{});$2(value);}$3" + }, + { + // Update the last used sort and filter choices when the toggle setting for either is changed. + match: /(\[(\i),\i\]=\i.useState.{0,80}?\[(\i),\i\]=\i.useState.{0,350}?)(return \i.useEffect)/, + replace: "$1$self.setLastSortChoice($2);$self.setLastFilterChoices($3);$4" + } + ] + }, + { + // Prevent scrolling to a sponsored Quest. + find: "Id(\"quest-tile-\".concat", + replacement: { + match: /(?=document.getElementById)/, + replace: "null&&" + } + }, { // Whether preloading assets is enabled or not, the placeholders loading // before the assets causes a lot of element shifting, whereas if @@ -1028,14 +1137,19 @@ export default definePlugin({ replace: "$1$self.processQuestForAutoComplete($3)&&$2" }, { - // Start Quest - match: /(onClick:.{0,10}?{)(.{0,5}?0,\i.\i\)\((\i))/, - replace: "$1const questifyContinue=$self.processQuestForAutoComplete($3);$2" + // Start Video & Play Game Quests. + match: /(?<=onClick:async\(\)=>{)/, + replace: "const startingAutoComplete=$self.processQuestForAutoComplete(arguments[0].quest);" }, { - // Open Video Modal + // Prevent the new Video Quest entry point. + match: /(?<=QUEST_HOME_DESKTOP\))/, + replace: "&&false" + }, + { + // Conditionally open the Video Quest modal. match: /(\i\?)?(\(0,\i.openVideoQuestModal\)\({quest:(\i))/, - replace: "$1questifyContinue&&$2" + replace: "$1startingAutoComplete&&$2" }, { // The "Resume (XX:XX)" text is changed to "Watching (XX:XX)" if the Quest is active. @@ -1043,12 +1157,26 @@ export default definePlugin({ replace: "$1$self.getQuestAcceptedButtonText(arguments[0].quest)??", }, { - // Add trigger to memo for rerendering the progress label. + // Setup a trigger to rerender the memo. + match: /(?=return \i.useMemo)/, + replace: "const questRerenderTrigger=$self.useQuestRerender();" + }, + { + // Add the trigger to the memo for rerendering the progress label. match: /(\i.intl.string\(\i.\i#{intl::QUESTS_SEE_CODE}\)}\)}},\[)/, - replace: "$1$self.settings.store.triggerQuestsRerender," + replace: "$1questRerenderTrigger," }, ] }, + // This patch covers the new entry point blocked in the above group. + // If the old entry point gets removed in the future, this will be useful. + // { + // find: "CAPTCHA_FAILED:", + // replacement: { + // match: /(?<=SUCCESS:)(\i\({)/, + // replace: "$self.processQuestForAutoComplete(arguments[0])&&$1" + // } + // }, { // Sets intervals to progress Play Game Quests in the background. // Triggers if a Quest has already been started but was interrupted, such as by a reload. @@ -1075,6 +1203,15 @@ export default definePlugin({ } ] }, + { + // Prevents the new Quests location from counting as part of the + // DM button highlight logic while the Quest button is visible. + find: "GLOBAL_DISCOVERY),", + replacement: { + match: /(pathname:(\i)}.{0,250}?return )/, + replace: "$1$self.disguiseHomeButton($2)?false:" + } + } ], contextMenus: { diff --git a/src/equicordplugins/questify/settings.tsx b/src/equicordplugins/questify/settings.tsx index 51c82ebb7..c6c4321a1 100644 --- a/src/equicordplugins/questify/settings.tsx +++ b/src/equicordplugins/questify/settings.tsx @@ -4,19 +4,18 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { defaultAudioNames, playAudio } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { ErrorBoundary } from "@components/index"; import { Logger } from "@utils/Logger"; import { OptionType } from "@utils/types"; -import { findLazy } from "@webpack"; import { Button, ColorPicker, ContextMenuApi, Forms, Menu, Select, TextInput, useEffect, useRef, useState } from "@webpack/common"; import { JSX } from "react"; import { activeQuestIntervals, getQuestTileClasses, getQuestTileStyle } from "./index"; -import { DynamicDropdown, DynamicDropdownSettingOption, ExcludedQuest, GuildlessServerListItem, Quest, QuestIcon, QuestStatus, QuestTile, RadioGroup, RadioOption, SelectOption, SoundIcon } from "./utils/components"; -import { AudioPlayer, decimalToRGB, fetchAndDispatchQuests, getFormattedNow, getIgnoredQuestIDs, getQuestStatus, isDarkish, isSoundAllowed, leftClick, middleClick, q, QuestifyLogger, QuestsStore, rightClick, setIgnoredQuestIDs, validCommaSeparatedList } from "./utils/misc"; +import { DynamicDropdown, DynamicDropdownSettingOption, ExcludedQuest, GuildlessServerListItem, Quest, QuestIcon, QuestRewardType, QuestStatus, QuestTile, RadioGroup, RadioOption, SelectOption, SoundIcon } from "./utils/components"; +import { decimalToRGB, fetchAndDispatchQuests, getFormattedNow, getIgnoredQuestIDs, getQuestStatus, isDarkish, isSoundAllowed, leftClick, middleClick, q, QuestifyLogger, QuestsStore, rightClick, setIgnoredQuestIDs, validCommaSeparatedList } from "./utils/misc"; -let defaultSounds: string[] | null = null; let autoFetchInterval: null | ReturnType = null; const defaultLeftClickAction = "open-quests"; const defaultMiddleClickAction = "plugin-settings"; @@ -30,15 +29,32 @@ const defaultIgnoredColor = 8334124; const defaultExpiredColor = 2368553; const defaultRestyleQuestsGradient = "intense"; const defaultFetchQuestsAlert = "discodo"; -const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3")), false); export const minimumAutoFetchIntervalValue = 30 * 60; export const maximumAutoFetchIntervalValue = 12 * 60 * 60; +const rerenderCallbacks = new Set<() => void>(); + +export function addRerenderCallback(callback: () => void): () => void { + rerenderCallbacks.add(callback); + return () => rerenderCallbacks.delete(callback); +} + export function rerenderQuests(): void { settings.store.triggerQuestsRerender = !settings.store.triggerQuestsRerender; + rerenderCallbacks.forEach(callback => callback()); } export function fetchAndAlertQuests(source: string, logger: Logger): void { + const { questRewardIncludeRewardCode, questRewardIncludeNitroCode, questRewardIncludeCollectibles, questRewardIncludeInGame, questRewardIncludeOrbs } = settings.store; + + const rewardTypeToSettingMap = { + [QuestRewardType.REWARD_CODE]: questRewardIncludeRewardCode, + [QuestRewardType.IN_GAME]: questRewardIncludeInGame, + [QuestRewardType.COLLECTIBLE]: questRewardIncludeCollectibles, + [QuestRewardType.VIRTUAL_CURRENCY]: questRewardIncludeOrbs, + [QuestRewardType.FRACTIONAL_PREMIUM]: questRewardIncludeNitroCode + }; + const currentQuests = Array.from(QuestsStore.quests.values()) as Quest[]; fetchAndDispatchQuests(source, logger).then(newQuests => { @@ -46,12 +62,17 @@ export function fetchAndAlertQuests(source: string, logger: Logger): void { const currentIds = new Set(currentQuests.map((q: Quest) => q.id)); const newOnly = newQuests.filter((q: Quest) => !currentIds.has(q.id)); + const newOnlyFiltered = newOnly.filter(quest => { + const rewardType = getQuestRewardType(quest); + return rewardType in rewardTypeToSettingMap && rewardTypeToSettingMap[rewardType]; + }); + if (newOnly.length > 0) { const shouldAlert = settings.store.fetchingQuestsAlert; - if (shouldAlert) { + if (shouldAlert && newOnlyFiltered.length > 0) { logger.info(`[${getFormattedNow()}] New Quests detected. Playing alert sound.`); - AudioPlayer(shouldAlert, 1).play(); + playAudio(shouldAlert); } else { logger.info(`[${getFormattedNow()}] New Quests detected.`); } @@ -146,7 +167,23 @@ export function questIsIgnored(questID: string): boolean { return ignoredQuests.includes(questID); } +function getQuestRewardType(quest: Quest): QuestRewardType { + const reward = quest.config.rewardsConfig.rewards[0]; + if (!(reward.type in QuestRewardType)) return QuestRewardType.UNKNOWN; + return reward.type as QuestRewardType; +} + export function validateIgnoredQuests(ignoredQuests?: string[], questsData?: Quest[]): [string[], number] { + const { questRewardIncludeRewardCode, questRewardIncludeNitroCode, questRewardIncludeCollectibles, questRewardIncludeInGame, questRewardIncludeOrbs } = settings.store; + + const rewardTypeToSettingMap = { + [QuestRewardType.REWARD_CODE]: questRewardIncludeRewardCode, + [QuestRewardType.IN_GAME]: questRewardIncludeInGame, + [QuestRewardType.COLLECTIBLE]: questRewardIncludeCollectibles, + [QuestRewardType.VIRTUAL_CURRENCY]: questRewardIncludeOrbs, + [QuestRewardType.FRACTIONAL_PREMIUM]: questRewardIncludeNitroCode + }; + const quests = questsData ?? Array.from(QuestsStore.quests.values()) as Quest[]; const excludedQuests = Array.from(QuestsStore.excludedQuests.values()) as ExcludedQuest[]; const currentlyIgnored = ignoredQuests ? new Set(ignoredQuests) : new Set(getIgnoredQuestIDs(questsData?.[0]?.userStatus?.userId)); @@ -159,7 +196,11 @@ export function validateIgnoredQuests(ignoredQuests?: string[], questsData?: Que if (currentlyIgnored.has(quest.id)) { validIgnored.add(quest.id); } else if (questStatus === QuestStatus.Unclaimed) { - numUnclaimedUnignoredQuests++; + const rewardType = getQuestRewardType(quest); + + if (rewardTypeToSettingMap[rewardType]) { + numUnclaimedUnignoredQuests++; + } } } @@ -176,6 +217,7 @@ export function validateAndOverwriteIgnoredQuests(ignoredQuests?: string[], ques const [validIgnored, numUnclaimedUnignoredQuests] = validateIgnoredQuests(ignoredQuests, questsData); settings.store.unclaimedUnignoredQuests = numUnclaimedUnignoredQuests; setIgnoredQuestIDs(validIgnored, questsData?.[0]?.userStatus?.userId); + rerenderQuests(); return validIgnored; } @@ -349,6 +391,11 @@ function QuestButtonSettings(): JSX.Element { const { questButtonDisplay, + questRewardIncludeRewardCode, + questRewardIncludeNitroCode, + questRewardIncludeCollectibles, + questRewardIncludeInGame, + questRewardIncludeOrbs, questButtonUnclaimed, questButtonBadgeColor, questButtonLeftClickAction, @@ -356,6 +403,11 @@ function QuestButtonSettings(): JSX.Element { questButtonRightClickAction } = settings.use([ "questButtonDisplay", + "questRewardIncludeRewardCode", + "questRewardIncludeNitroCode", + "questRewardIncludeCollectibles", + "questRewardIncludeInGame", + "questRewardIncludeOrbs", "questButtonUnclaimed", "questButtonBadgeColor", "questButtonLeftClickAction", @@ -383,6 +435,15 @@ function QuestButtonSettings(): JSX.Element { { label: "Nothing", value: "nothing" } ]; + const questButtonRewardDisplayOptions: DynamicDropdownSettingOption[] = [ + { label: "Orbs", value: "orbs", selected: questRewardIncludeOrbs }, + { label: "Nitro Codes", value: "nitro-code", selected: questRewardIncludeNitroCode }, + { label: "Reward Codes", value: "reward-code", selected: questRewardIncludeRewardCode }, + { label: "In Game Items", value: "in-game", selected: questRewardIncludeInGame }, + { label: "Profile Collectibles", value: "collectibles", selected: questRewardIncludeCollectibles }, + ]; + + const [currentRewardsOptions, setCurrentRewardsOptions] = useState(questButtonRewardDisplayOptions.filter(option => option.selected)); const [currentQuestButtonDisplay, setCurrentQuestButtonDisplay] = useState((questButtonDisplayOptions.find(option => option.value === questButtonDisplay) as RadioOption)); const [currentQuestButtonUnclaimed, setCurrentQuestButtonUnclaimed] = useState((questButtonUnclaimedOptions.find(option => option.value === questButtonUnclaimed) as RadioOption)); const [currentQuestButtonLeftClickAction, setCurrentQuestButtonLeftClickAction] = useState<"open-quests" | "plugin-settings" | "context-menu" | "nothing">(questButtonLeftClickAction as "open-quests" | "plugin-settings" | "context-menu" | "nothing"); @@ -391,6 +452,40 @@ function QuestButtonSettings(): JSX.Element { const [currentBadgeColor, setCurrentBadgeColor] = useState((questButtonBadgeColor as number | null)); const [dummySelected, setDummySelected] = useState(false); + function updateSettingsTruthy(enabled: DynamicDropdownSettingOption[]) { + const enabledValues = enabled.map(option => option.value); + + questButtonRewardDisplayOptions.forEach(option => { + option.selected = enabledValues.includes(option.value); + }); + + settings.store.questRewardIncludeRewardCode = enabledValues.includes("reward-code"); + settings.store.questRewardIncludeNitroCode = enabledValues.includes("nitro-code"); + settings.store.questRewardIncludeCollectibles = enabledValues.includes("collectibles"); + settings.store.questRewardIncludeInGame = enabledValues.includes("in-game"); + settings.store.questRewardIncludeOrbs = enabledValues.includes("orbs"); + + setCurrentRewardsOptions(enabled); + validateAndOverwriteIgnoredQuests(); + } + + function handleQuestRewardDisplayChange(values: Array) { + if (values.length === 0) { + updateSettingsTruthy([]); + return; + } + + const stringlessValues = values.filter(v => typeof v !== "string") as DynamicDropdownSettingOption[]; + const selectedOption = values.find(v => typeof v === "string") as string; + const option = questButtonRewardDisplayOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption; + + if (option.selected) { + updateSettingsTruthy(stringlessValues.filter(v => v.value !== selectedOption)); + } else { + updateSettingsTruthy([...stringlessValues, option]); + } + } + function handleQuestButtonDisplayChange(value: RadioOption) { setCurrentQuestButtonDisplay(value); settings.store.questButtonDisplay = value.value; @@ -451,6 +546,47 @@ function QuestButtonSettings(): JSX.Element { /> +
+
+ + Left Click Action + + value === currentQuestButtonMiddleClickAction} + select={handleMiddleClickActionChange} + /> +
+
+ + Right Click Action + + + Only count Quests with these reward types as unclaimed when determining button + visibility, badge count, and when playing the alert sound. + + value === currentQuestButtonLeftClickAction} - select={handleLeftClickActionChange} - /> -
-
- - Middle Click Action - - value === currentQuestButtonRightClickAction} - select={handleRightClickActionChange} - /> -
+ maxVisibleItems={questButtonRewardDisplayOptions.length} + clearable={true} + multi={true} + value={currentRewardsOptions as any} + options={questButtonRewardDisplayOptions} + onChange={handleQuestRewardDisplayChange} + closeOnSelect={false} + > + +
@@ -921,13 +1040,17 @@ function ReorderQuestsSetting(): JSX.Element { claimedSubsort, ignoredSubsort, expiredSubsort, - ignoredQuestProfile + ignoredQuestProfile, + rememberQuestPageSort, + rememberQuestPageFilters } = settings.use([ "unclaimedSubsort", "claimedSubsort", "ignoredSubsort", "expiredSubsort", - "ignoredQuestProfile" + "ignoredQuestProfile", + "rememberQuestPageSort", + "rememberQuestPageFilters" ]); const getSubsortOptions = (source: string): SelectOption[] => { @@ -968,7 +1091,7 @@ function ReorderQuestsSetting(): JSX.Element { Reorder Quests - Sort Quests by their status. Leave empty for default sorting. + Sort Quests by their status. Applied when the "Questify" sort option is selected on the Quest page.

Comma-separated list must contain all of: UNCLAIMED, CLAIMED, IGNORED, EXPIRED.
@@ -990,9 +1113,9 @@ function ReorderQuestsSetting(): JSX.Element { settings.store.reorderQuests = cleaned; } }} - placeholder="Using Discord's default sorting." + placeholder="You must include all of UNCLAIMED, CLAIMED, IGNORED, EXPIRED" error={ - validCommaSeparatedList(reorderQuests, ["UNCLAIMED", "CLAIMED", "IGNORED", "EXPIRED"], true, true, true, false) + validCommaSeparatedList(reorderQuests, ["UNCLAIMED", "CLAIMED", "IGNORED", "EXPIRED"], false, true, true, false) ? undefined : "Invalid format." } @@ -1077,12 +1200,51 @@ function ReorderQuestsSetting(): JSX.Element { popoutPosition="bottom" serialize={String} isSelected={(value: string) => value === ignoredQuestProfile} - select={(value: string) => { - settings.store.ignoredQuestProfile = value; - }} + select={(value: string) => { settings.store.ignoredQuestProfile = value; }} /> +
+
+ + Remember Sort Choice + + value === rememberQuestPageFilters} + select={(value: boolean) => { settings.store.rememberQuestPageFilters = value; }} + /> +
+
+
+ + This sort and filter choice refers to the built-in sort and filter options on the Quest page. + The custom sorting above is only applied when the "Questify" sort option is selected on the Quest page. + If remembering is disabled, the sort or filter options will be reset each time you open the Quest page. + +
@@ -1112,12 +1274,7 @@ function FetchingQuestsSetting(): JSX.Element { { value: 60 * 60 * 6, label: "12 Hours" }, ]; - defaultSounds ??= (findDefaultSounds.keys() || []).map(key => { - const match = key.match(/((?:\w|-)+)\.mp3$/); - return match ? match[1] : null; - }).filter(Boolean) as string[]; - - const resolvedSounds: SelectOption[] = defaultSounds.map(sound => { + const resolvedSounds: SelectOption[] = defaultAudioNames().map(sound => { const label = sound.toUpperCase().replace(/_/g, " ").replace(/(\d+)/g, " $1"); return { value: sound, label }; }); @@ -1392,8 +1549,7 @@ function FetchingQuestsSetting(): JSX.Element { if (activePlayer.current) { clearActivePlayer(); } else { - activePlayer.current = AudioPlayer(currentAlertSelection.value as string, 1, clearActivePlayer); - activePlayer.current?.play(); + activePlayer.current = playAudio(currentAlertSelection.value as string, { onEnded: clearActivePlayer }); setIsPlaying(true); } } @@ -1513,6 +1669,36 @@ export const settings = definePluginSettings({ default: defaultQuestButtonDisplay, // "always", "unclaimed", "never" hidden: true, }, + questRewardIncludeRewardCode: { + type: OptionType.BOOLEAN, + description: "Include Quests with Reward Codes when displaying Quest counts.", + default: true, + hidden: true, + }, + questRewardIncludeNitroCode: { + type: OptionType.BOOLEAN, + description: "Include Quests with Nitro Codes when displaying Quest counts.", + default: true, + hidden: true, + }, + questRewardIncludeInGame: { + type: OptionType.BOOLEAN, + description: "Include Quests with In-Game rewards when displaying Quest counts.", + default: true, + hidden: true, + }, + questRewardIncludeCollectibles: { + type: OptionType.BOOLEAN, + description: "Include Quests with Collectibles when displaying Quest counts.", + default: true, + hidden: true, + }, + questRewardIncludeOrbs: { + type: OptionType.BOOLEAN, + description: "Include Quests with Orbs when displaying Quest counts.", + default: true, + hidden: true, + }, questButtonUnclaimed: { type: OptionType.STRING, description: "Which display type to use for the unclaimed indicator on the Quest button in the server list.", @@ -1655,6 +1841,30 @@ export const settings = definePluginSettings({ default: "private", // "shared", "private" hidden: true }, + rememberQuestPageSort: { + type: OptionType.BOOLEAN, + description: "Remember the last used sort on the Quests page.", + default: true, + hidden: true + }, + rememberQuestPageFilters: { + type: OptionType.BOOLEAN, + description: "Remember the last used filters on the Quests page.", + default: true, + hidden: true + }, + lastQuestPageSort: { + type: OptionType.STRING, + description: "Remember the last used sort on the Quests page.", + default: "questify" as string, // sort key + hidden: true + }, + lastQuestPageFilters: { + type: OptionType.CUSTOM, + description: "Remember the last used filters on the Quests page.", + default: {} as { [filter: string]: { group: string, filter: string; }; }, // Array of filters + hidden: true + }, ignoredQuestIDs: { type: OptionType.CUSTOM, description: "An array of Quest IDs that are ignored.", diff --git a/src/equicordplugins/questify/utils/components.tsx b/src/equicordplugins/questify/utils/components.tsx index a5c17f630..d182e35e0 100644 --- a/src/equicordplugins/questify/utils/components.tsx +++ b/src/equicordplugins/questify/utils/components.tsx @@ -19,6 +19,15 @@ export const ServerListItemUpperBadgeComponent = findComponentByCodeLazy("STATUS export const RadioGroup = findComponentByCodeLazy("\"label\",\"description\"]);"); export const QuestTile = findComponentByCodeLazy(".rowIndex,trackGuildAndChannelMetadata"); +export enum QuestRewardType { + UNKNOWN = 0, + REWARD_CODE = 1, + IN_GAME = 2, + COLLECTIBLE = 3, + VIRTUAL_CURRENCY = 4, + FRACTIONAL_PREMIUM = 5 +} + export enum QuestStatus { Claimed = "CLAIMED", Unclaimed = "UNCLAIMED", @@ -76,6 +85,11 @@ export interface Quest { messages: { questName: string; }; + rewardsConfig: { + rewards: { + type: QuestRewardType; + }[]; + }, taskConfigV2: { tasks: { WATCH_VIDEO?: { diff --git a/src/equicordplugins/questify/utils/misc.tsx b/src/equicordplugins/questify/utils/misc.tsx index 5f62452b5..25a14f820 100644 --- a/src/equicordplugins/questify/utils/misc.tsx +++ b/src/equicordplugins/questify/utils/misc.tsx @@ -6,7 +6,7 @@ import { classNameFactory } from "@api/Styles"; import { Logger } from "@utils/Logger"; -import { findByCodeLazy, findStoreLazy } from "@webpack"; +import { findStoreLazy } from "@webpack"; import { FluxDispatcher, RestAPI, UserStore } from "@webpack/common"; import { questIsIgnored, settings } from "../settings"; @@ -14,13 +14,8 @@ import { Quest, QuestStatus, RGB } from "./components"; export const q = classNameFactory("questify-"); export const QuestifyLogger = new Logger("Questify"); -// Only takes an audio name or URL. Plays instantly. -export const PlayAudio = findByCodeLazy("Unable to find sound for pack name:"); -// Takes an audio name or URL, volume, and a callback function. Plays when play() is called. -const AudioPlayerConstructor = findByCodeLazy("sound has no duration"); -export function AudioPlayer(name: string, volume: number = 1, callback?: () => void): any { return new AudioPlayerConstructor(name, null, volume, "default", callback ?? (() => { })); } export const QuestsStore = findStoreLazy("QuestsStore"); -export const questPath = "/discovery/quests"; +export const questPath = "/quest-home"; export const leftClick = 0; export const middleClick = 1; export const rightClick = 2; diff --git a/src/equicordplugins/soggy/index.tsx b/src/equicordplugins/soggy/index.tsx index 5c37506a9..d1f6fba72 100644 --- a/src/equicordplugins/soggy/index.tsx +++ b/src/equicordplugins/soggy/index.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { EquicordDevs } from "@utils/constants"; @@ -13,19 +14,28 @@ import { findComponentByCodeLazy } from "@webpack"; import { React } from "@webpack/common"; const HeaderBarIcon = findComponentByCodeLazy(".HEADER_BAR_BADGE_TOP:", '.iconBadge,"top"'); +let boopSound: AudioPlayerInterface; +let song: AudioPlayerInterface; -let preloadSong, preloadBoopSound, song, boopSound; +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 = new Audio(preloadSong.src); - song.volume = settings.store.songVolume; - song.play(); + song?.loop(); return () => { - song.pause(); - song.remove(); + song?.stop(); }; }, []); } @@ -42,9 +52,7 @@ function SoggyModal(props: ModalProps) { offsetY >= region.y && offsetY <= region.y + region.height ) { - boopSound = new Audio(preloadBoopSound.src); - boopSound.volume = settings.store.boopVolume; - boopSound.play(); + boopSound?.play(); } }; @@ -115,16 +123,16 @@ const settings = definePluginSettings({ 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: (value: string) => { - song = new Audio(value); - } + 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: (value: string) => { - boopSound = new Audio(value); + onChange(newValue) { + assignBoop(newValue, settings.store.boopVolume * 100); } } }); @@ -134,6 +142,7 @@ export default definePlugin({ description: "Adds a soggy button to the toolbox", authors: [EquicordDevs.sliwka], settings, + dependencies: ["AudioPlayerAPI"], patches: [ { find: ".controlButtonWrapper,", @@ -144,9 +153,14 @@ export default definePlugin({ } ], - start: () => { - preloadSong = new Audio(settings.store.songLink); - preloadBoopSound = new Audio(settings.store.boopLink); + start() { + assignBoop(settings.store.boopLink, settings.store.boopVolume * 100); + assignSong(settings.store.songLink, settings.store.songVolume * 100); + }, + + stop() { + boopSound?.delete(); + song?.delete(); }, // taken from message logger lol diff --git a/src/equicordplugins/soundBoardLogger/index.tsx b/src/equicordplugins/soundBoardLogger/index.tsx index 369b2137e..705bc9498 100644 --- a/src/equicordplugins/soundBoardLogger/index.tsx +++ b/src/equicordplugins/soundBoardLogger/index.tsx @@ -22,7 +22,7 @@ export default definePlugin({ name: "SoundBoardLogger", authors: [Devs.Moxxie, EquicordDevs.Fres, Devs.amy, Devs.thororen], description: "Logs all soundboards that are played in a voice chat and allows you to download them", - dependencies: ["ChatInputButtonAPI"], + dependencies: ["AudioPlayerAPI", "ChatInputButtonAPI"], patches: [ { predicate: () => settings.store.IconLocation === "toolbar", diff --git a/src/equicordplugins/soundBoardLogger/utils.ts b/src/equicordplugins/soundBoardLogger/utils.ts index 9bb91868d..f25200a8f 100644 --- a/src/equicordplugins/soundBoardLogger/utils.ts +++ b/src/equicordplugins/soundBoardLogger/utils.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { classNameFactory } from "@api/Styles"; import { proxyLazy } from "@utils/lazy"; import { LazyComponent } from "@utils/react"; @@ -40,9 +41,7 @@ export function getEmojiUrl(emoji) { } export const playSound = id => { - const audio = new Audio(`https://cdn.discordapp.com/soundboard-sounds/${id}`); - audio.volume = settings.store.soundVolume; - audio.play(); + playAudio(`https://cdn.discordapp.com/soundboard-sounds/${id}`, { volume: settings.store.soundVolume * 100 }); }; export async function downloadAudio(id: string): Promise { diff --git a/src/equicordplugins/tiktokTTS/index.ts b/src/equicordplugins/tiktokTTS/index.ts index 41f988f30..7362fcff4 100644 --- a/src/equicordplugins/tiktokTTS/index.ts +++ b/src/equicordplugins/tiktokTTS/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { EquicordDevs } from "@utils/constants"; import { getCurrentChannel } from "@utils/discord"; import definePlugin from "@utils/types"; @@ -16,22 +17,14 @@ async function readOutText(text: string): Promise { }); const mp3JSON = await mp3Response.json(); - - const audioElement = new Audio(); - - audioElement.src = `data:audio/mpeg;base64,${mp3JSON.data}`; - audioElement.volume = 0.5; - audioElement.onended = () => audioElement.remove(); - - document.body.appendChild(audioElement); - - audioElement.play(); + playAudio(`data:audio/mpeg;base64,${mp3JSON.data}`, { volume: 50 }); } export default definePlugin({ name: "TiktokTTS", description: "Reads out chat messages with the good ol' Tiktok TTS voice :sob:", authors: [EquicordDevs.VillainsRule], + dependencies: ["AudioPlayerAPI"], flux: { async MESSAGE_CREATE({ optimistic, type, message }) { if (optimistic || type !== "MESSAGE_CREATE") return; diff --git a/src/equicordplugins/timezones/index.tsx b/src/equicordplugins/timezones/index.tsx index abc60abb2..c46f12a2f 100644 --- a/src/equicordplugins/timezones/index.tsx +++ b/src/equicordplugins/timezones/index.tsx @@ -55,6 +55,12 @@ export const settings = definePluginSettings({ default: false }, + showTimezoneInfo: { + type: OptionType.BOOLEAN, + description: "Show timezone info next to time", + default: true + }, + showMessageHeaderTime: { type: OptionType.BOOLEAN, description: "Show time in message headers", @@ -136,6 +142,17 @@ function getTime(timezone: string, timestamp: string | number, props: Intl.DateT return formatter.format(date); } +function getTimezoneAbbreviation(timezone: string, timestamp: string | number) { + const date = new Date(timestamp); + const formatter = new Intl.DateTimeFormat(locale.getLocale() ?? "en-US", { + timeZone: timezone, + timeZoneName: "short" + }); + const parts = formatter.formatToParts(date); + const timeZonePart = parts.find(part => part.type === "timeZoneName"); + return timeZonePart ? timeZonePart.value : ""; +} + interface Props { userId: string; timestamp?: string; @@ -174,6 +191,20 @@ const TimestampComponent = ErrorBoundary.wrap(({ userId, timestamp, type }: Prop if (!timezone) return null; const shortTime = getTime(timezone, currentTime, { hour: "numeric", minute: "numeric" }); + let displayTime = shortTime; + let isLocal = false; + + if (settings.store.showTimezoneInfo) { + const userTimezone = getSystemTimezone(); + if (timezone === userTimezone) { + displayTime = "local"; + isLocal = true; + } else { + const timezoneInfo = getTimezoneAbbreviation(timezone, currentTime); + displayTime = `${shortTime} ${timezoneInfo || timezone}`; + } + } + const longTime = getTime(timezone, currentTime, { weekday: "long", year: "numeric", @@ -183,6 +214,8 @@ const TimestampComponent = ErrorBoundary.wrap(({ userId, timestamp, type }: Prop minute: "numeric" }); + const tooltipText = isLocal ? `${longTime} (Your local timezone)` : longTime; + return ( {toolTipProps => ( - {type === "message" ? `(${shortTime})` : shortTime} + {type === "message" ? `(${displayTime})` : displayTime} )} diff --git a/src/equicordplugins/timezones/styles.css b/src/equicordplugins/timezones/styles.css index 4db7dbd33..a4c9583cb 100644 --- a/src/equicordplugins/timezones/styles.css +++ b/src/equicordplugins/timezones/styles.css @@ -39,3 +39,8 @@ max-width: none !important; white-space: nowrap } + +.timezone-local-text { + opacity: 0.7; + font-style: italic; +} diff --git a/src/equicordplugins/vcNarratorCustom/index.tsx b/src/equicordplugins/vcNarratorCustom/index.tsx index d4a84b9c9..f11c12daf 100644 --- a/src/equicordplugins/vcNarratorCustom/index.tsx +++ b/src/equicordplugins/vcNarratorCustom/index.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { Devs, EquicordDevs } from "@utils/constants"; import { Margins } from "@utils/margins"; @@ -90,10 +91,7 @@ async function speak(text: string, { volume, rate, customVoice } = settings.stor // 1. Check the in-memory cache (fast check) if (ttsCache.has(cacheKey)) { const cachedUrl = ttsCache.get(cacheKey)!; - const audio = new Audio(cachedUrl); - audio.volume = volume; - audio.playbackRate = rate; - audio.play(); + playAudio(cachedUrl, { volume: volume * 100, speed: rate }); return; } @@ -105,10 +103,7 @@ async function speak(text: string, { volume, rate, customVoice } = settings.stor const url = URL.createObjectURL(cachedBlob); // Save it in the in-memory cache for next time. ttsCache.set(cacheKey, url); - const audio = new Audio(url); - audio.volume = volume; - audio.playbackRate = rate; - audio.play(); + playAudio(url, { volume: volume * 100, speed: rate }); return; } } catch (err) { @@ -155,10 +150,7 @@ async function speak(text: string, { volume, rate, customVoice } = settings.stor console.error("Error storing in IndexedDB:", err); } - const audio = new Audio(url); - audio.volume = volume; - audio.playbackRate = rate; - audio.play(); + playAudio(url, { volume: volume * 100, speed: rate }); } function clean(str: string) { diff --git a/src/plugins/_core/settings.tsx b/src/plugins/_core/settings.tsx index 9633624d3..665687b39 100644 --- a/src/plugins/_core/settings.tsx +++ b/src/plugins/_core/settings.tsx @@ -40,7 +40,7 @@ export default definePlugin({ find: ".versionHash", replacement: [ { - match: /\[\(0,\i\.jsxs?\)\((.{1,10}),(\{[^{}}]+\{.{0,20}.versionHash,.+?\})\)," "/, + match: /\.info.+?\[\(0,\i\.jsxs?\)\((.{1,10}),(\{[^{}}]+\{.{0,20}.versionHash,.+?\})\)," "/, replace: (m, component, props) => { props = props.replace(/children:\[.+\]/, ""); return `${m},$self.makeInfoElements(${component}, ${props})`; diff --git a/src/plugins/callTimer/alignedChatInputFix.css b/src/plugins/callTimer/alignedChatInputFix.css index 9bb4a039e..33c14fbf4 100644 --- a/src/plugins/callTimer/alignedChatInputFix.css +++ b/src/plugins/callTimer/alignedChatInputFix.css @@ -1,4 +1,4 @@ -.align-chat-input [class*="panels"] [class*="inner_"], -.align-chat-input [class*="rtcConnectionStatus_"] { - height: fit-content; +[class*="panels"] [class*="inner_"], +[class*="rtcConnectionStatus_"] { + height: fit-content !important; } \ No newline at end of file diff --git a/src/plugins/callTimer/index.tsx b/src/plugins/callTimer/index.tsx index b1f274cba..6d270a2b1 100644 --- a/src/plugins/callTimer/index.tsx +++ b/src/plugins/callTimer/index.tsx @@ -26,29 +26,6 @@ import { React } from "@webpack/common"; import alignedChatInputFix from "./alignedChatInputFix.css?managed"; -function formatDuration(ms: number) { - // here be dragons (moment fucking sucks) - const human = Settings.plugins.CallTimer.format === "human"; - - const format = (n: number) => human ? n : n.toString().padStart(2, "0"); - const unit = (s: string) => human ? s : ""; - const delim = human ? " " : ":"; - - // thx copilot - const d = Math.floor(ms / 86400000); - const h = Math.floor((ms % 86400000) / 3600000); - const m = Math.floor(((ms % 86400000) % 3600000) / 60000); - const s = Math.floor((((ms % 86400000) % 3600000) % 60000) / 1000); - - let res = ""; - if (d) res += `${d}d `; - if (h || res) res += `${format(h)}${unit("h")}${delim}`; - if (m || res || !human) res += `${format(m)}${unit("m")}${delim}`; - res += `${format(s)}${unit("s")}`; - - return res; -} - export default definePlugin({ name: "CallTimer", description: "Adds a timer to vcs", @@ -96,6 +73,6 @@ export default definePlugin({ deps: [channelId] }); - return

Connected for {formatDurationMs(time, Settings.plugins.CallTimer.format === "human")}

; + return

{formatDurationMs(time, Settings.plugins.CallTimer.format === "human")}

; } }); diff --git a/src/plugins/userVoiceShow/components.tsx b/src/plugins/userVoiceShow/components.tsx index ba5f36054..815efbbce 100644 --- a/src/plugins/userVoiceShow/components.tsx +++ b/src/plugins/userVoiceShow/components.tsx @@ -8,8 +8,9 @@ import { classNameFactory } from "@api/Styles"; import ErrorBoundary from "@components/ErrorBoundary"; import { classes } from "@utils/misc"; import { Channel } from "@vencord/discord-types"; -import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, mapMangledModuleLazy } from "@webpack"; -import { ChannelRouter, ChannelStore, GuildStore, IconUtils, match, P, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, UserSummaryItem, useStateFromStores, VoiceStateStore } from "@webpack/common"; +import { filters, findByPropsLazy, mapMangledModuleLazy } from "@webpack"; +import { ChannelRouter, ChannelStore, Parser, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, UserSummaryItem, useStateFromStores, VoiceStateStore } from "@webpack/common"; +import { PropsWithChildren } from "react"; const cl = classNameFactory("vc-uvs-"); @@ -17,60 +18,71 @@ const { selectVoiceChannel } = findByPropsLazy("selectVoiceChannel", "selectChan const { useChannelName } = mapMangledModuleLazy("#{intl::GROUP_DM_ALONE}", { useChannelName: filters.byCode("()=>null==") }); -const getDMChannelIcon = findByCodeLazy(".getChannelIconURL({"); - -const Avatar = findComponentByCodeLazy(".status)/2):0"); -const GroupDMAvatars = findComponentByCodeLazy("frontSrc:", "getAvatarURL"); const ActionButtonClasses = findByPropsLazy("actionButton", "highlight"); -interface IconProps extends React.ComponentPropsWithoutRef<"div"> { +type IconProps = Omit, "children"> & { size?: number; iconClassName?: string; -} +}; -function SpeakerIcon(props: IconProps) { - props.size ??= 16; +function Icon(props: PropsWithChildren) { + const { + size = 16, + className, + iconClassName, + ...restProps + } = props; return (
- - + {props.children}
); } -function LockedSpeakerIcon(props: IconProps) { - props.size ??= 16; - +function SpeakerIcon(props: IconProps) { return ( -
- - - - -
+ + + + + ); +} + +function LockedSpeakerIcon(props: IconProps) { + return ( + + + + + ); +} + +function MutedIcon(props: IconProps) { + return ( + + + + ); +} + +function DeafIcon(props: IconProps) { + return ( + + + ); } @@ -87,64 +99,13 @@ function VoiceChannelTooltip({ channel, isLocked }: VoiceChannelTooltipProps) { [voiceStates] ); - const guild = channel.getGuildId() == null ? undefined : GuildStore.getGuild(channel.getGuildId()); - const guildIcon = guild?.icon == null ? undefined : IconUtils.getGuildIconURL({ - id: guild.id, - icon: guild.icon, - size: 30 - }); - - const channelIcon = match(channel.type) - .with(P.union(1, 3), () => { - return channel.recipients.length >= 2 && channel.icon == null - ? - : ; - }) - .otherwise(() => null); - const channelName = useChannelName(channel); - + const Icon = isLocked ? LockedSpeakerIcon : SpeakerIcon; return ( <> - {guild != null && ( -
- {guildIcon != null && } - {guild.name} -
- )} -
- {channelIcon} - - {channelName} - -
- - {users.length < 10 ? `0${users.length}` : `${users.length}`} - - - {channel.userLimit < 10 ? `0${channel.userLimit}` : `${channel.userLimit}`} - -
-
+ In Voice Chat + {Parser.parse(`<#${channel.id}>`)}
- {isLocked ? : } + ; +const clickTimers = new Map(); export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId, isProfile, isActionButton, shouldHighlight }: VoiceChannelIndicatorProps) => { const channelId = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStateForUser(userId)?.channelId); + const { isMuted, isDeaf } = useStateFromStores([VoiceStateStore], () => { + const voiceState = VoiceStateStore.getVoiceStateForUser(userId); + return { + isMuted: voiceState?.mute || voiceState?.selfMute || false, + isDeaf: voiceState?.deaf || voiceState?.selfDeaf || false + }; + }); + const channel = channelId == null ? undefined : ChannelStore.getChannel(channelId); if (channel == null) return null; @@ -183,8 +152,8 @@ export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId, isProfile, is if (channel == null || channelId == null) return; - clearTimeout(clickTimers[channelId]); - delete clickTimers[channelId]; + clearTimeout(clickTimers.get(channelId)); + clickTimers.delete(channelId); if (e.detail > 1) { if (!isDM && !PermissionStore.can(PermissionsBits.CONNECT, channel)) { @@ -194,32 +163,39 @@ export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId, isProfile, is selectVoiceChannel(channelId); } else { - clickTimers[channelId] = setTimeout(() => { + const timeoutId = setTimeout(() => { ChannelRouter.transitionToChannel(channelId); - delete clickTimers[channelId]; + clickTimers.delete(channelId); }, 250); + clickTimers.set(channelId, timeoutId); } } + const IconComponent = + isLocked + ? LockedSpeakerIcon + : isDeaf + ? DeafIcon + : isMuted + ? MutedIcon + : SpeakerIcon; + return ( } tooltipClassName={cl("tooltip-container")} tooltipContentClassName={cl("tooltip-content")} > - {props => { - const iconProps: IconProps = { - ...props, - className: classes(isActionButton && ActionButtonClasses.actionButton, isActionButton && shouldHighlight && ActionButtonClasses.highlight), - iconClassName: classes(isProfile && cl("profile-speaker")), - size: isActionButton ? 20 : 16, - onClick - }; - - return isLocked ? - - : ; - }} + {props => ( + + )} ); }, { noop: true }); diff --git a/src/plugins/userVoiceShow/style.css b/src/plugins/userVoiceShow/style.css index 32b422087..322b8ed39 100644 --- a/src/plugins/userVoiceShow/style.css +++ b/src/plugins/userVoiceShow/style.css @@ -19,7 +19,7 @@ } .vc-uvs-tooltip-container { - max-width: 300px; + max-width: 50vw; } .vc-uvs-tooltip-content { @@ -42,4 +42,4 @@ .vc-uvs-vc-members { display: flex; gap: 6px; -} +} \ No newline at end of file