Merge branch 'dev'
This commit is contained in:
commit
b69fbed05d
46 changed files with 1784 additions and 745 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
260
src/api/AudioPlayer.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@
|
|||
width: 100%;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vc-addon-author {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
175
src/equicordplugins/_api/audioPlayer.ts
Normal file
175
src/equicordplugins/_api/audioPlayer.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
78
src/equicordplugins/keyboardSounds/packs.ts
Normal file
78
src/equicordplugins/keyboardSounds/packs.ts
Normal 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"
|
||||
];
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
103
src/equicordplugins/messageLoggerEnhanced/list.ts
Normal file
103
src/equicordplugins/messageLoggerEnhanced/list.ts
Normal 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"
|
||||
];
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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")>;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -39,3 +39,8 @@
|
|||
max-width: none !important;
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
.timezone-local-text {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue