Merge branch 'dev'

This commit is contained in:
thororen1234 2025-09-20 18:05:07 -04:00
commit b69fbed05d
No known key found for this signature in database
46 changed files with 1784 additions and 745 deletions

View file

@ -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";

View file

@ -49,7 +49,6 @@ import { SettingsRouter } from "./webpack/common";
if (IS_REPORTER) {
require("./debug/runReporter");
Settings.plugins.CharacterCounter.enabled = false;
}
async function syncSettings() {

260
src/api/AudioPlayer.ts Normal file
View file

@ -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<string, AudioProcessor> = {};
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<HTMLAudioElement>;
_volume: number;
_speed: number;
outputChannel: string;
type: AudioType;
preload: boolean;
persistent: boolean;
onEnded?: AudioCallback;
onError?: AudioErrorHandler;
processAudio: () => void;
ensureAudio(): Promise<HTMLAudioElement>;
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<number> | null;
/** The current time of the audio in seconds, or null if not yet loaded. */
time: Promise<number> | null;
/** The paused state of the audio, or null if not yet loaded. */
paused: Promise<boolean> | null;
/** The muted state of the audio, or null if not yet loaded. */
muted: Promise<boolean> | 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<number> | 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<boolean> | 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<boolean> | 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<number> | 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;
}

View file

@ -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<number | null>(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;

View file

@ -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++)
);
}
}

View file

@ -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
},

View file

@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;

View file

@ -52,7 +52,6 @@
width: 100%;
align-items: center;
flex-grow: 1;
gap: 8px;
}
.vc-addon-author {

View file

@ -73,8 +73,8 @@ export function AddonCard({ disabled, isNew, sourceBadge, name, infoButton, foot
{name}
</div>
</div>
{sourceBadge}
{isNew && <AddonBadge text="NEW" color="#ED4245" />}
{sourceBadge}
</Text>
{!!author && (

View file

@ -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 ? (
<img
src="https://equicord.org/assets/icons/userplugin.png"
alt="Userplugin"
title="Userplugin"
style={{
width: "20px",
height: "20px",
marginLeft: "8px",
borderRadius: "2px"
}}
/>
) : (
<img
src="https://vencord.dev/assets/favicon-dark.png"

View file

@ -98,9 +98,10 @@ const enum SearchStatus {
ALL,
ENABLED,
DISABLED,
NEW,
EQUICORD,
VENCORD
VENCORD,
CUSTOM,
NEW,
}
function ExcludedPluginsList({ search }: { search: string; }) {
@ -191,12 +192,14 @@ export default function PluginSettings() {
const enabled = Vencord.Plugins.isPluginEnabled(plugin.name);
const pluginMeta = PluginMeta[plugin.name];
const isEquicordPlugin = pluginMeta?.folderName?.startsWith("src/equicordplugins/") ?? false;
const isUserplugin = pluginMeta?.userPlugin ?? false;
if (enabled && status === SearchStatus.DISABLED) return false;
if (!enabled && status === SearchStatus.ENABLED) return false;
if (status === SearchStatus.NEW && !newPlugins?.includes(plugin.name)) return false;
if (status === SearchStatus.EQUICORD && !isEquicordPlugin) return false;
if (status === SearchStatus.VENCORD && isEquicordPlugin) return false;
if (status === SearchStatus.CUSTOM && !isUserplugin) return false;
if (!search.length) return true;
return (
@ -369,9 +372,10 @@ export default function PluginSettings() {
{ label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED },
{ label: "Show New", value: SearchStatus.NEW },
{ label: "Show Equicord", value: SearchStatus.EQUICORD },
{ label: "Show Vencord", value: SearchStatus.VENCORD }
{ label: "Show Vencord", value: SearchStatus.VENCORD },
...(totalUserPlugins > 0 ? [{ label: "Show Custom", value: SearchStatus.CUSTOM }] : []),
{ label: "Show New", value: SearchStatus.NEW },
]}
serialize={String}
select={onStatusChange}

View file

@ -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}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Missed Notification Count</Forms.FormTitle>
<Switch
value={settings.missed}
onChange={(v: boolean) => settings.missed = v}
>
When refocusing discord a notification will popup with how you missed
</Switch>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Timeout</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>Set to 0s to never automatically time out</Forms.FormText>
<Slider

View file

@ -0,0 +1,175 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { AudioPlayerInternal, AudioPlayerOptions, audioProcessorFunctions, AudioType, identifyAudioType, playAudio } from "@api/AudioPlayer";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "AudioPlayerAPI",
description: "API to play internal Discord audio files or external audio links.",
authors: [EquicordDevs.Etorix],
AudioType,
playAudio,
patches: [
{
find: "could not play audio",
group: true,
replacement: [
{
// Uses the audio as-is if external, otherwise checks for an internal Discord sound.
// Also force loads the internal sounds module to account for the second patch group below,
// as well as accounting for not calling the module in this patch when this.type is not DISCORD.
match: /(let \i=class.{0,900}?new Audio;\i.src=)((\i\(\d+\)).{0,50}concat\()this.name(,".mp3"\)\))/,
replace: "$3;$1this.type!==$self.AudioType.DISCORD?this.audio:$2this.audio$4"
},
{
// Adds an optional persistent boolean as well as a callback and error handler to the
// audio player which is called after the audio finishes playing and when an error occurs.
// Also processes the audio before playing to apply override functions set by plugins.
match: /(?<=constructor\((\i,\i,\i,\i)).{0,200}outputChannel=\i/,
replace: ",options){$self.buildPlayer(this,$1,options);"
},
{
// Prevents an error from the source being cleared during destroyAudio().
match: /(\i.pause\(\),(\i).src="".{0,20}?null)/,
replace: "$2.onerror=()=>{},$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();
}
});

View file

@ -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<void> {
try {

View file

@ -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<HTMLInputElement>;
@ -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<HTMLInputElement>(null);
const update = useForceUpdater();
const sound = React.useRef<SoundPlayer | null>(null);
const sound = React.useRef<AudioPlayerInterface | null>(null);
const [files, setFiles] = React.useState<Record<string, StoredAudioFile>>({});
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();
}}

View file

@ -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<string | null> {
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");
}
});

View file

@ -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",

View file

@ -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())

View file

@ -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);
}
});

View file

@ -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(
<Menu.MenuItem
label="Copy Image Link"
key="copy-image-link"
id="copy-image-link"
action={() => {
copyToClipboard(gif.url);
showToast("Image link copied to clipboard", Toasts.Type.SUCCESS);
}}
/>
);
}
group.push(
<Menu.MenuItem
label="Add To Collection"
@ -112,6 +126,11 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: false,
},
showCopyImageLink: {
description: "Show 'Copy Image Link' option in context menus",
type: OptionType.BOOLEAN,
default: false,
},
defaultEmptyCollectionImage: {
description: "The image / gif that will be shown when a collection has no images / gifs",
type: OptionType.STRING,
@ -642,7 +661,23 @@ const RemoveItemContextMenu = ({ type, nameOrId, instance }) => (
const MenuThingy = ({ gif }) => {
const collections = cache_collections;
return (
const menuItems: React.ReactNode[] = [];
if (settings.store.showCopyImageLink) {
menuItems.push(
<Menu.MenuItem
label="Copy Image Link"
key="copy-image-link"
id="copy-image-link"
action={() => {
copyToClipboard(gif.url);
showToast("Image link copied to clipboard", Toasts.Type.SUCCESS);
}}
/>
);
}
menuItems.push(
<Menu.MenuItem label="Add To Collection" key="add-to-collection" id="add-to-collection">
{collections.map(col => (
<Menu.MenuItem
@ -663,6 +698,8 @@ const MenuThingy = ({ gif }) => {
/>
</Menu.MenuItem>
);
return <>{menuItems}</>;
};
function CreateCollectionModal({ gif, onClose, modalProps }) {

View file

@ -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(() => {

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
* 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<string>();
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);
},
});

View file

@ -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"
];

View file

@ -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<string>, listType: ListType, ignoreCase: boolean; };
let keywordEntries: Array<KeywordEntry> = [];
let currentUser: User;
let keywordLog: Array<any> = [];
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<KeywordEntry>) {
const idx = str.indexOf(matches[0]);
return [
<span key={idx}>{str.substring(0, idx)}</span>,
<span className="highlight" key={idx}>{matches[0]}</span>,
<span key={idx}>{str.substring(idx + matches[0].length)}</span>
<>
<span>{str.substring(0, idx)}</span>,
<span className="highlight">{matches[0]}</span>,
<span>{str.substring(idx + matches[0].length)}</span>
</>
];
}
@ -102,7 +102,11 @@ function Collapsible({ title, children }) {
size={Button.Sizes.ICON}
className={cl("collapsible")}>
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{ marginLeft: "auto", color: "var(--text-muted)", paddingRight: "5px" }}>{isOpen ? "▼" : "▶"}</div>
<div style={{
marginLeft: "auto",
color: "var(--text-muted)",
paddingRight: "5px"
}}>{isOpen ? "▼" : "▶"}</div>
<Forms.FormTitle tag="h4">{title}</Forms.FormTitle>
</div>
</Button>
@ -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 (
<Flex flexDirection="row" style={{ marginBottom: "5px" }} key={index}>
<div style={{ flexGrow: 1 }}>
<TextInput
placeholder="ID"
spellCheck={false}
value={currentValue}
onChange={e => onChange(e, index)}
/>
</div>
<Button
onClick={() => {
values.splice(index, 1);
setListIds(values);
update();
}}
look={Button.Looks.BLANK}
size={Button.Sizes.ICON}
className={cl("delete")}>
<DeleteIcon />
</Button>
</Flex>
<>
<Flex flexDirection="row" style={{ marginBottom: "5px" }}>
<div style={{ flexGrow: 1 }}>
<TextInput
placeholder="ID"
spellCheck={false}
value={currentValue}
onChange={e => onChange(e, index)}
/>
</div>
<Button
onClick={() => {
values.splice(index, 1);
setListIds(values);
update();
}}
look={Button.Looks.BLANK}
size={Button.Sizes.ICON}
className={cl("delete")}>
<DeleteIcon />
</Button>
</Flex>
</>
);
});
@ -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() {
<Switch
value={values[i].ignoreCase}
onChange={() => {
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 = (
<><MenuHeader tab={8} setTab={setTab} closePopout={closePopout} badgeState={{ badgeForYou: false }} />
<span>
<Tooltip text="Clear All">
{({ onMouseLeave, onMouseEnter }) => (
<div className={classes(tabClass.controlButton, buttonClass.button, buttonClass.tertiary, buttonClass.size32)}
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onClick={() => {
keywordLog = [];
DataStore.set(KEYWORD_LOG_KEY, []);
this.onUpdate();
}}>
<DoubleCheckmarkIcon />
</div>
)}
</Tooltip>
</span>
</>
keywordClearButton() {
return (
<Tooltip text="Clear All">
{({ onMouseLeave, onMouseEnter }) => (
<div
className={classes(tabClass.controlButton, buttonClass.button, buttonClass.tertiary, buttonClass.size32)}
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onClick={() => {
keywordLog = [];
DataStore.set(KEYWORD_LOG_KEY, []);
this.onUpdate();
}}>
<DoubleCheckmarkIcon />
</div>
)}
</Tooltip>
);
},
tryKeywordMenu(onJump) {
const channel = ChannelStore.getChannel(SelectedChannelStore.getChannelId());
const [tempLogs, setKeywordLog] = useState(keywordLog);
@ -503,7 +543,7 @@ export default definePlugin({
<>
<Popout
className={classes(recentMentionsPopoutClass.recentMentionsPopout)}
renderHeader={() => header}
renderHeader={() => null}
renderMessage={messageRender}
channel={channel}
onJump={onJump}

View file

@ -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"
];

View file

@ -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,

View file

@ -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<GitResult> {
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<GitResult> {
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<GitResult> {
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 };
}
}

View file

@ -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));

View file

@ -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: {

View file

@ -158,10 +158,6 @@ export function getNative(): PluginNative<typeof import("../native")> {
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<typeof import("../native")>;

View file

@ -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<Element>) {
// 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: {

View file

@ -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<typeof setInterval> = 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<DynamicDropdownSettingOption | string>) {
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 {
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle", "form-subtitle-spacier")}>
Left Click Action
</Forms.FormTitle>
<Select
options={questButtonClickOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonLeftClickAction}
select={handleLeftClickActionChange}
/>
</div>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle", "form-subtitle-spacier")}>
Middle Click Action
</Forms.FormTitle>
<Select
options={questButtonClickOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonMiddleClickAction}
select={handleMiddleClickActionChange}
/>
</div>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle", "form-subtitle-spacier")}>
Right Click Action
</Forms.FormTitle>
<Select
options={questButtonClickOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonRightClickAction}
select={handleRightClickActionChange}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle")}>
@ -496,45 +632,28 @@ function QuestButtonSettings(): JSX.Element {
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Forms.FormSection>
<Forms.FormTitle className={q("form-subtitle", "form-subtitle-spacier")}>
Left Click Action
Included Reward Types
</Forms.FormTitle>
<Select
options={questButtonClickOptions}
<Forms.FormText className={q("form-description")}>
Only count Quests with these reward types as unclaimed when determining button
visibility, badge count, and when playing the alert sound.
</Forms.FormText>
<DynamicDropdown
placeholder="Select which reward types to include in the unclaimed count..."
feedback="There's no supported Quest feature by that name."
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonLeftClickAction}
select={handleLeftClickActionChange}
/>
</div>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle", "form-subtitle-spacier")}>
Middle Click Action
</Forms.FormTitle>
<Select
options={questButtonClickOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonMiddleClickAction}
select={handleMiddleClickActionChange}
/>
</div>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle", "form-subtitle-spacier")}>
Right Click Action
</Forms.FormTitle>
<Select
options={questButtonClickOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonRightClickAction}
select={handleRightClickActionChange}
/>
</div>
maxVisibleItems={questButtonRewardDisplayOptions.length}
clearable={true}
multi={true}
value={currentRewardsOptions as any}
options={questButtonRewardDisplayOptions}
onChange={handleQuestRewardDisplayChange}
closeOnSelect={false}
>
</DynamicDropdown>
</Forms.FormSection>
</div>
</Forms.FormSection>
</div>
@ -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
</Forms.FormTitle>
<Forms.FormText className={q("form-description")}>
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.
<br /><br />
Comma-separated list must contain all of: <span className={q("inline-code-block")}>UNCLAIMED, CLAIMED, IGNORED, EXPIRED</span>.
</Forms.FormText>
@ -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; }}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle")}>
Remember Sort Choice
</Forms.FormTitle>
<Select
options={[
{ label: "Yes", value: true },
{ label: "No", value: false }
]}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: boolean) => value === rememberQuestPageSort}
select={(value: boolean) => { settings.store.rememberQuestPageSort = value; }}
/>
</div>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle")}>
Remember Filter Choice
</Forms.FormTitle>
<Select
options={[
{ label: "Yes", value: true },
{ label: "No", value: false }
]}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: boolean) => value === rememberQuestPageFilters}
select={(value: boolean) => { settings.store.rememberQuestPageFilters = value; }}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<Forms.FormText className={q("form-description")}>
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.
</Forms.FormText>
</div>
</Forms.FormSection>
</div>
</ErrorBoundary>
@ -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.",

View file

@ -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?: {

View file

@ -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;

View file

@ -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

View file

@ -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",

View file

@ -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<void> {

View file

@ -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<void> {
});
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;

View file

@ -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 (
<Tooltip
position="top"
@ -192,14 +225,14 @@ const TimestampComponent = ErrorBoundary.wrap(({ userId, timestamp, type }: Prop
spacing={8}
hideOnClick={true}
tooltipClassName="timezone-tooltip"
text={longTime}
text={tooltipText}
>
{toolTipProps => (
<span
{...toolTipProps}
className={type === "message" ? `timezone-message-item ${classes.timestamp}` : "timezone-profile-item"}
className={`${type === "message" ? `timezone-message-item ${classes.timestamp}` : "timezone-profile-item"}${isLocal ? " timezone-local-text" : ""}`}
>
{type === "message" ? `(${shortTime})` : shortTime}
{type === "message" ? `(${displayTime})` : displayTime}
</span>
)}
</Tooltip>

View file

@ -39,3 +39,8 @@
max-width: none !important;
white-space: nowrap
}
.timezone-local-text {
opacity: 0.7;
font-style: italic;
}

View file

@ -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) {

View file

@ -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})`;

View file

@ -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;
}

View file

@ -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 <p style={{ margin: 0 }}>Connected for <span style={{ fontFamily: "var(--font-code)" }}>{formatDurationMs(time, Settings.plugins.CallTimer.format === "human")}</span></p>;
return <p style={{ margin: 0, fontFamily: "var(--font-code)" }}>{formatDurationMs(time, Settings.plugins.CallTimer.format === "human")}</p>;
}
});

View file

@ -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<React.ComponentPropsWithoutRef<"div">, "children"> & {
size?: number;
iconClassName?: string;
}
};
function SpeakerIcon(props: IconProps) {
props.size ??= 16;
function Icon(props: PropsWithChildren<IconProps>) {
const {
size = 16,
className,
iconClassName,
...restProps
} = props;
return (
<div
{...props}
role={props.onClick != null ? "button" : undefined}
className={classes(cl("speaker"), props.onClick != null ? cl("clickable") : undefined, props.className)}
{...restProps}
className={classes(cl("speaker"), className)}
>
<svg
className={props.iconClassName}
width={props.size}
height={props.size}
className={iconClassName}
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.1 20.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5Z" />
<path d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02Z" />
{props.children}
</svg>
</div>
);
}
function LockedSpeakerIcon(props: IconProps) {
props.size ??= 16;
function SpeakerIcon(props: IconProps) {
return (
<div
{...props}
role={props.onClick != null ? "button" : undefined}
className={classes(cl("speaker"), props.onClick != null ? cl("clickable") : undefined, props.className)}
>
<svg
width={props.size}
height={props.size}
viewBox="0 0 24 24"
fill="currentColor"
>
<path fillRule="evenodd" clipRule="evenodd" d="M16 4h.5v-.5a2.5 2.5 0 0 1 5 0V4h.5a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm4-.5V4h-2v-.5a1 1 0 1 1 2 0Z" />
<path d="M11 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1h-.06a1 1 0 0 1-.74-.32L5.92 17H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h2.92l4.28-4.68a1 1 0 0 1 .74-.32H11ZM20.5 12c-.28 0-.5.22-.52.5a7 7 0 0 1-5.13 6.25c-.48.13-.85.55-.85 1.05v.03c0 .6.52 1.06 1.1.92a9 9 0 0 0 6.89-8.25.48.48 0 0 0-.49-.5h-1ZM16.5 12c-.28 0-.5.23-.54.5a3 3 0 0 1-1.33 2.02c-.35.23-.63.6-.63 1.02v.14c0 .63.59 1.1 1.16.83a5 5 0 0 0 2.82-4.01c.02-.28-.2-.5-.48-.5h-1Z" />
</svg>
</div>
<Icon {...props}>
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.1 20.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5Z" />
<path d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02Z" />
</Icon>
);
}
function LockedSpeakerIcon(props: IconProps) {
return (
<Icon {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M16 4h.5v-.5a2.5 2.5 0 0 1 5 0V4h.5a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm4-.5V4h-2v-.5a1 1 0 1 1 2 0Z" />
<path d="M11 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1h-.06a1 1 0 0 1-.74-.32L5.92 17H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h2.92l4.28-4.68a1 1 0 0 1 .74-.32H11ZM20.5 12c-.28 0-.5.22-.52.5a7 7 0 0 1-5.13 6.25c-.48.13-.85.55-.85 1.05v.03c0 .6.52 1.06 1.1.92a9 9 0 0 0 6.89-8.25.48.48 0 0 0-.49-.5h-1ZM16.5 12c-.28 0-.5.23-.54.5a3 3 0 0 1-1.33 2.02c-.35.23-.63.6-.63 1.02v.14c0 .63.59 1.1 1.16.83a5 5 0 0 0 2.82-4.01c.02-.28-.2-.5-.48-.5h-1Z" />
</Icon>
);
}
function MutedIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="m2.7 22.7 20-20a1 1 0 0 0-1.4-1.4l-20 20a1 1 0 1 0 1.4 1.4ZM10.8 17.32c-.21.21-.1.58.2.62V20H9a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2h-2v-2.06A8 8 0 0 0 20 10a1 1 0 0 0-2 0c0 1.45-.52 2.79-1.38 3.83l-.02.02A5.99 5.99 0 0 1 12.32 16a.52.52 0 0 0-.34.15l-1.18 1.18ZM15.36 4.52c.15-.15.19-.38.08-.56A4 4 0 0 0 8 6v4c0 .3.03.58.1.86.07.34.49.43.74.18l6.52-6.52ZM5.06 13.98c.16.28.53.31.75.09l.75-.75c.16-.16.19-.4.08-.61A5.97 5.97 0 0 1 6 10a1 1 0 0 0-2 0c0 1.45.39 2.81 1.06 3.98Z" />
</Icon>
);
}
function DeafIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M22.7 2.7a1 1 0 0 0-1.4-1.4l-20 20a1 1 0 1 0 1.4 1.4l20-20ZM17.06 2.94a.48.48 0 0 0-.11-.77A11 11 0 0 0 2.18 16.94c.14.3.53.35.76.12l3.2-3.2c.25-.25.15-.68-.2-.76a5 5 0 0 0-1.02-.1H3.05a9 9 0 0 1 12.66-9.2c.2.09.44.05.59-.1l.76-.76ZM20.2 8.28a.52.52 0 0 1 .1-.58l.76-.76a.48.48 0 0 1 .77.11 11 11 0 0 1-4.5 14.57c-1.27.71-2.73.23-3.55-.74a3.1 3.1 0 0 1-.17-3.78l1.38-1.97a5 5 0 0 1 4.1-2.13h1.86a9.1 9.1 0 0 0-.75-4.72ZM10.1 17.9c.25-.25.65-.18.74.14a3.1 3.1 0 0 1-.62 2.84 2.85 2.85 0 0 1-3.55.74.16.16 0 0 1-.04-.25l3.48-3.48Z" />
</Icon>
);
}
@ -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
? <GroupDMAvatars recipients={channel.recipients} size="SIZE_32" />
: <Avatar src={getDMChannelIcon(channel)} size="SIZE_32" />;
})
.otherwise(() => null);
const channelName = useChannelName(channel);
const Icon = isLocked ? LockedSpeakerIcon : SpeakerIcon;
return (
<>
{guild != null && (
<div className={cl("name")}>
{guildIcon != null && <img className={cl("guild-icon")} src={guildIcon} alt="" />}
<Text variant="text-sm/bold">{guild.name}</Text>
</div>
)}
<div className={cl("name2")} style={{ display: "flex", alignItems: "center" }}>
{channelIcon}
<Text variant="text-sm/semibold">
{channelName}
</Text>
<div style={{ display: "flex", alignItems: "center", marginLeft: "10px" }}>
<Text
style={{
color: "gray",
fontSize: "12px",
boxSizing: "border-box",
padding: "0 6px 0 0",
backgroundColor: "var(--background-tertiary)",
borderRadius: "0 8px 0 0",
}}
>
{users.length < 10 ? `0${users.length}` : `${users.length}`}
</Text>
<Text
style={{
color: "gray",
fontSize: "12px",
boxSizing: "border-box",
padding: "0 6px 0 0",
backgroundColor: "var(--background-mod-strong)",
borderRadius: "0 8px 0 0",
}}
>
{channel.userLimit < 10 ? `0${channel.userLimit}` : `${channel.userLimit}`}
</Text>
</div>
</div>
<Text variant="text-sm/bold">In Voice Chat</Text>
<Text variant="text-sm/bold">{Parser.parse(`<#${channel.id}>`)}</Text>
<div className={cl("vc-members")}>
{isLocked ? <LockedSpeakerIcon size={18} /> : <SpeakerIcon size={18} />}
<Icon size={18} />
<UserSummaryItem
users={users}
renderIcon={false}
@ -164,11 +125,19 @@ export type VoiceChannelIndicatorProps = {
shouldHighlight?: boolean;
};
const clickTimers = {} as Record<string, any>;
const clickTimers = new Map<string, any>();
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 (
<Tooltip
text={<VoiceChannelTooltip channel={channel} isLocked={isLocked} />}
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 ?
<LockedSpeakerIcon {...iconProps} />
: <SpeakerIcon {...iconProps} />;
}}
{props => (
<IconComponent
{...props}
role="button"
onClick={onClick}
className={classes(cl("clickable"), isActionButton && ActionButtonClasses.actionButton, isActionButton && shouldHighlight && ActionButtonClasses.highlight)}
iconClassName={classes(cl(isProfile && "profile-speaker"))}
size={isActionButton ? 20 : 16}
/>
)}
</Tooltip>
);
}, { noop: true });

View file

@ -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;
}
}