Merge branch 'dev'

This commit is contained in:
thororen1234 2026-03-11 10:47:28 -04:00
commit ff31cc0593
No known key found for this signature in database
17 changed files with 735 additions and 309 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "equicord", "name": "equicord",
"private": "true", "private": "true",
"version": "1.14.4.1", "version": "1.14.5.0",
"description": "The other cutest Discord client mod", "description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme", "homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": { "bugs": {

View file

@ -6,7 +6,7 @@ export interface UserAndActivity {
activity: Activity; activity: Activity;
} }
export type DiscordPlatform = "desktop" | "mobile" | "web" | "embedded"; export type DiscordPlatform = "desktop" | "mobile" | "web" | "embedded" | "vr";
export interface PresenceStoreState { export interface PresenceStoreState {
presencesForGuilds: Record<string, Record<string, { status: OnlineStatus; activities: Activity[]; clientStatus: Partial<Record<DiscordPlatform, OnlineStatus>>; }>>; presencesForGuilds: Record<string, Record<string, { status: OnlineStatus; activities: Activity[]; clientStatus: Partial<Record<DiscordPlatform, OnlineStatus>>; }>>;

View file

@ -297,6 +297,19 @@ export function migratePluginToSettings(deleteOldSettings: boolean, newName: str
} }
} }
export function migrateSettingToPlugin(newName: string, oldName: string, settingName: string) {
const { plugins } = SettingsStore.plain;
const newPlugin = plugins[newName];
const oldPlugin = plugins[oldName];
if (newPlugin && oldPlugin?.enabled && oldPlugin?.[settingName]) {
logger.info(`Migrating setting ${settingName} from ${oldName} to seperate plugin ${newName}`);
delete oldPlugin[settingName];
newPlugin.enabled = true;
SettingsStore.markAsChanged();
}
}
export function migrateSettingsFromPlugin(newPlugin: string, oldPlugin: string, ...settings: string[]) { export function migrateSettingsFromPlugin(newPlugin: string, oldPlugin: string, ...settings: string[]) {
const { plugins } = SettingsStore.plain; const { plugins } = SettingsStore.plain;

View file

@ -96,19 +96,22 @@ export async function authorizeCloud() {
const res = await fetch(location, { const res = await fetch(location, {
headers: { Accept: "application/json" } headers: { Accept: "application/json" }
}); });
const { secret } = await res.json(); const data = await res.json();
if (secret) { if (data.secret) {
logger.info("Authorized with secret"); logger.info("Authorized with cloud");
await setAuthorization(secret); await setAuthorization(data.secret);
showNotification({ showNotification({
title: "Cloud Integration", title: "Cloud Integration",
body: "Cloud integrations enabled!" body: "Cloud integrations enabled!"
}); });
Settings.cloud.authenticated = true; Settings.cloud.authenticated = true;
} else { } else {
logger.error("OAuth callback returned no secret", data);
showNotification({ showNotification({
title: "Cloud Integration", title: "Cloud Integration",
body: "Setup failed (no secret returned?)." body: data.error
? `Setup failed: ${data.error}`
: "Setup failed (no secret returned)."
}); });
Settings.cloud.authenticated = false; Settings.cloud.authenticated = false;
} }

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications"; import { showNotification } from "@api/Notifications";
import { PlainSettings, Settings } from "@api/Settings"; import { PlainSettings, Settings } from "@api/Settings";
import { localStorage } from "@utils/localStorage"; import { localStorage } from "@utils/localStorage";
@ -14,196 +15,483 @@ import { deflateSync, inflateSync } from "fflate";
import { deauthorizeCloud, getCloudAuth, getCloudUrl } from "./cloudSetup"; import { deauthorizeCloud, getCloudAuth, getCloudUrl } from "./cloudSetup";
import { exportSettings, importSettings } from "./offline"; import { exportSettings, importSettings } from "./offline";
import { ManifestEntry, SyncRequest, SyncResponse } from "./types";
const logger = new Logger("SettingsSync:Cloud", "#39b7e0"); const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
const MANIFEST_STORE_KEY = "Vencord_cloudManifest";
const API_VERSION_KEY = "Vencord_cloudApiVersion";
type ApiVersion = "v2" | "v1";
async function getApiVersion(): Promise<ApiVersion> {
return await DataStore.get<ApiVersion>(API_VERSION_KEY) ?? "v2";
}
async function setApiVersion(version: ApiVersion) {
await DataStore.set(API_VERSION_KEY, version);
}
function toBase64(data: Uint8Array): string {
let binary = "";
for (let i = 0; i < data.length; i++)
binary += String.fromCharCode(data[i]);
return btoa(binary);
}
function fromBase64(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++)
bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function computeChecksum(data: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(data));
const bytes = new Uint8Array(hash, 0, 8);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
async function getLocalManifest(): Promise<ManifestEntry[]> {
return await DataStore.get<ManifestEntry[]>(MANIFEST_STORE_KEY) ?? [];
}
async function saveLocalManifest(manifest: ManifestEntry[]) {
await DataStore.set(MANIFEST_STORE_KEY, manifest);
}
async function buildLocalData(): Promise<Map<string, Uint8Array>> {
const encoder = new TextEncoder();
const data = new Map<string, Uint8Array>();
data.set("settings", encoder.encode(JSON.stringify(VencordNative.settings.get())));
const quickCss = await VencordNative.quickCss.get();
if (quickCss) data.set("quickCss", encoder.encode(quickCss));
return data;
}
async function applyDownloads(downloads: SyncResponse["downloads"]) {
if (downloads.length === 0) return false;
let settingsChanged = false;
const decoder = new TextDecoder();
for (const dl of downloads) {
const text = decoder.decode(fromBase64(dl.value));
if (dl.key === "settings") {
try {
await importSettings(JSON.stringify({ settings: JSON.parse(text) }), "all", true);
settingsChanged = true;
} catch (e) {
logger.error("Failed to apply settings download", e);
}
} else if (dl.key === "quickCss") {
await VencordNative.quickCss.set(text);
settingsChanged = true;
} else if (dl.key.startsWith("dataStore/")) {
const dsKey = dl.key.slice("dataStore/".length);
try {
await DataStore.set(dsKey, JSON.parse(text));
} catch (e) {
logger.error(`Failed to apply dataStore download for ${dsKey}`, e);
}
}
}
return settingsChanged;
}
function handleAuthFailure() {
showNotification({
title: "Cloud Settings",
body: "Cloud sync was disabled because this account isn't connected. Reconnect in Cloud Settings.",
color: "var(--yellow-360)",
onClick: () => SettingsRouter.openUserSettings("equicord_cloud_panel"),
});
Settings.cloud.authenticated = false;
}
async function doSyncV2(uploads: SyncRequest["uploads"], clientManifest: ManifestEntry[]): Promise<SyncResponse | null> {
let res: Response;
try {
res = await fetch(new URL("/v2/sync", getCloudUrl()), {
method: "POST",
headers: {
Authorization: await getCloudAuth(),
"Content-Type": "application/json",
},
body: JSON.stringify({ client_manifest: clientManifest, uploads } satisfies SyncRequest),
});
} catch {
logger.info("Server does not support v2, falling back to v1");
await setApiVersion("v1");
return null;
}
if (res.status === 404) {
logger.info("Server does not support v2, falling back to v1");
await setApiVersion("v1");
return null;
}
if (res.status === 401) {
handleAuthFailure();
return null;
}
if (!res.ok) {
logger.error(`Sync failed, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings (API returned ${res.status}).`,
color: "var(--red-360)",
});
return null;
}
return await res.json();
}
async function putV2(manual?: boolean) {
const localManifest = await getLocalManifest();
const manifestMap = new Map(localManifest.map(e => [e.key, e]));
const localData = await buildLocalData();
const uploads: SyncRequest["uploads"] = [];
for (const [key, value] of localData) {
const checksum = await computeChecksum(value);
const existing = manifestMap.get(key);
if (!existing || existing.checksum !== checksum)
uploads.push({ key, value: toBase64(value), checksum });
}
if (uploads.length === 0 && !manual) {
logger.info("No changes to push");
delete localStorage.Vencord_settingsDirty;
return;
}
const response = await doSyncV2(uploads, localManifest);
if (!response) return;
for (const err of response.errors)
logger.error(`Sync error for ${err.key}: ${err.error}`);
const hadDownloads = await applyDownloads(response.downloads);
await saveLocalManifest(response.server_manifest);
PlainSettings.cloud.settingsSyncVersion = Date.now();
await VencordNative.settings.set(PlainSettings);
logger.info(`Sync complete: ${response.uploaded.length} uploaded, ${response.downloads.length} downloaded`);
if (manual) {
showNotification({
title: "Cloud Settings",
body: hadDownloads
? "Settings synced! Click here to restart to fully apply changes."
: "Settings synchronized to the cloud!",
color: "var(--green-360)",
onClick: hadDownloads ? (IS_WEB ? () => location.reload() : relaunch) : undefined,
noPersist: true,
});
}
delete localStorage.Vencord_settingsDirty;
}
async function getV2(shouldNotify: boolean, force: boolean) {
const localManifest = force ? [] : await getLocalManifest();
const response = await doSyncV2([], localManifest);
if (!response) return false;
for (const err of response.errors)
logger.error(`Sync error for ${err.key}: ${err.error}`);
if (response.downloads.length === 0) {
logger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date.",
noPersist: true,
});
return false;
}
const settingsChanged = await applyDownloads(response.downloads);
await saveLocalManifest(response.server_manifest);
PlainSettings.cloud.settingsSyncVersion = Date.now();
await VencordNative.settings.set(PlainSettings);
logger.info(`Pulled ${response.downloads.length} keys from cloud`);
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: settingsChanged
? "Your settings have been updated! Click here to restart to fully apply changes!"
: "Cloud data synchronized.",
color: "var(--green-360)",
onClick: settingsChanged ? (IS_WEB ? () => location.reload() : relaunch) : undefined,
noPersist: true,
});
delete localStorage.Vencord_settingsDirty;
return true;
}
async function deleteV2() {
const auth = await getCloudAuth();
const manifestRes = await fetch(new URL("/v2/manifest", getCloudUrl()), {
headers: { Authorization: auth },
});
if (!manifestRes.ok) {
showNotification({
title: "Cloud Settings",
body: `Could not fetch manifest for deletion (API returned ${manifestRes.status}).`,
color: "var(--red-360)",
});
return;
}
const { entries }: { entries: ManifestEntry[] } = await manifestRes.json();
await Promise.all(entries.map(async entry => {
const res = await fetch(new URL(`/v2/data/${encodeURIComponent(entry.key)}`, getCloudUrl()), {
method: "DELETE",
headers: { Authorization: auth },
});
if (!res.ok && res.status !== 404)
logger.error(`Failed to delete key ${entry.key}: ${res.status}`);
}));
await saveLocalManifest([]);
PlainSettings.cloud.settingsSyncVersion = 0;
await VencordNative.settings.set(PlainSettings);
logger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)",
});
}
async function putV1(manual?: boolean) {
const settings = await exportSettings({ syncDataStore: false, minify: true });
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "PUT",
headers: {
Authorization: await getCloudAuth(),
"Content-Type": "application/octet-stream",
},
body: deflateSync(new TextEncoder().encode(settings)) as Uint8Array<ArrayBuffer>,
});
if (!res.ok) {
logger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to cloud (API returned ${res.status}).`,
color: "var(--red-360)",
});
return;
}
const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings uploaded to cloud successfully");
if (manual) {
showNotification({
title: "Cloud Settings",
body: "Synchronized settings to the cloud!",
noPersist: true,
});
}
delete localStorage.Vencord_settingsDirty;
}
async function getV1(shouldNotify: boolean, force: boolean) {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
headers: {
Authorization: await getCloudAuth(),
Accept: "application/octet-stream",
"If-None-Match": Settings.cloud.settingsSyncVersion.toString(),
},
});
if (res.status === 401) {
handleAuthFailure();
return false;
}
if (res.status === 404) {
logger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "There are no settings in the cloud.",
noPersist: true,
});
return false;
}
if (res.status === 304) {
logger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date.",
noPersist: true,
});
return false;
}
if (!res.ok) {
logger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`,
color: "var(--red-360)",
});
return false;
}
const written = Number(res.headers.get("etag")!);
const localWritten = Settings.cloud.settingsSyncVersion;
if (!force && written < localWritten) {
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your local settings are newer than the cloud ones.",
noPersist: true,
});
return false;
}
const data = await res.arrayBuffer();
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
await importSettings(settings, "all", true);
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: IS_WEB ? () => location.reload() : relaunch,
noPersist: true,
});
delete localStorage.Vencord_settingsDirty;
return true;
}
async function deleteV1() {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() },
});
if (!res.ok) {
logger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
color: "var(--red-360)",
});
return;
}
logger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)",
});
}
export function shouldCloudSync(direction: "push" | "pull") { export function shouldCloudSync(direction: "push" | "pull") {
const localDirection = localStorage.Vencord_cloudSyncDirection; const localDirection = localStorage.Vencord_cloudSyncDirection;
return localDirection === direction || localDirection === "both"; return localDirection === direction || localDirection === "both";
} }
export async function putCloudSettings(manual?: boolean) { export async function putCloudSettings(manual?: boolean) {
const settings = await exportSettings({ syncDataStore: false, minify: true });
try { try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), { const version = await getApiVersion();
method: "PUT", if (version === "v2") {
headers: { await putV2(manual);
Authorization: await getCloudAuth(), if (await getApiVersion() === "v1")
"Content-Type": "application/octet-stream" await putV1(manual);
}, } else {
body: deflateSync(new TextEncoder().encode(settings)) as Uint8Array<ArrayBuffer> await putV1(manual);
});
if (!res.ok) {
logger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
} }
const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings uploaded to cloud successfully");
if (manual) {
showNotification({
title: "Cloud Settings",
body: "Synchronized settings to the cloud!",
noPersist: true,
});
}
delete localStorage.Vencord_settingsDirty;
} catch (e: any) { } catch (e: any) {
logger.error("Failed to sync up", e); logger.error("Failed to sync up", e);
showNotification({ showNotification({
title: "Cloud Settings", title: "Cloud Settings",
body: `Could not synchronize settings to the cloud (${e.toString()}).`, body: `Could not synchronize settings to the cloud (${e.toString()}).`,
color: "var(--red-360)" color: "var(--red-360)",
}); });
} }
} }
export async function getCloudSettings(shouldNotify = true, force = false) { export async function getCloudSettings(shouldNotify = true, force = false) {
try { try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), { const version = await getApiVersion();
method: "GET", if (version === "v2") {
headers: { const result = await getV2(shouldNotify, force);
Authorization: await getCloudAuth(), if (await getApiVersion() === "v1")
Accept: "application/octet-stream", return await getV1(shouldNotify, force);
"If-None-Match": Settings.cloud.settingsSyncVersion.toString() return result;
},
});
if (res.status === 401) {
// User switched to an account that isn't connected to cloud
showNotification({
title: "Cloud Settings",
body: "Cloud sync was disabled because this account isn't connected to the cloud App. You can enable it again by connecting this account in Cloud Settings. (note: it will store your preferences separately)",
color: "var(--yellow-360)",
onClick: () => SettingsRouter.openUserSettings("equicord_cloud_panel")
});
// Disable cloud sync globally
Settings.cloud.authenticated = false;
return false;
} }
return await getV1(shouldNotify, force);
if (res.status === 404) {
logger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "There are no settings in the cloud.",
noPersist: true
});
return false;
}
if (res.status === 304) {
logger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date.",
noPersist: true
});
return false;
}
if (!res.ok) {
logger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return false;
}
const written = Number(res.headers.get("etag")!);
const localWritten = Settings.cloud.settingsSyncVersion;
// don't need to check for written > localWritten because the server will return 304 due to if-none-match
if (!force && written < localWritten) {
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your local settings are newer than the cloud ones.",
noPersist: true,
});
return;
}
const data = await res.arrayBuffer();
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
await importSettings(settings, "all", true);
// sync with server timestamp instead of local one
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: IS_WEB ? () => location.reload() : relaunch,
noPersist: true
});
delete localStorage.Vencord_settingsDirty;
return true;
} catch (e: any) { } catch (e: any) {
logger.error("Failed to sync down", e); logger.error("Failed to sync down", e);
showNotification({ showNotification({
title: "Cloud Settings", title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (${e.toString()}).`, body: `Could not synchronize settings from the cloud (${e.toString()}).`,
color: "var(--red-360)" color: "var(--red-360)",
}); });
return false; return false;
} }
} }
export async function deleteCloudSettings() { export async function deleteCloudSettings() {
try { try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), { const version = await getApiVersion();
method: "DELETE", if (version === "v2")
headers: { Authorization: await getCloudAuth() }, await deleteV2();
}); else
await deleteV1();
if (!res.ok) {
logger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
}
logger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)"
});
} catch (e: any) { } catch (e: any) {
logger.error("Failed to delete", e); logger.error("Failed to delete", e);
showNotification({ showNotification({
title: "Cloud Settings", title: "Cloud Settings",
body: `Could not delete settings (${e.toString()}).`, body: `Could not delete settings (${e.toString()}).`,
color: "var(--red-360)" color: "var(--red-360)",
}); });
} }
} }
@ -211,7 +499,7 @@ export async function deleteCloudSettings() {
export async function eraseAllCloudData() { export async function eraseAllCloudData() {
const res = await fetch(new URL("/v1/", getCloudUrl()), { const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE", method: "DELETE",
headers: { Authorization: await getCloudAuth() } headers: { Authorization: await getCloudAuth() },
}); });
if (!res.ok) { if (!res.ok) {
@ -219,17 +507,18 @@ export async function eraseAllCloudData() {
showNotification({ showNotification({
title: "Cloud Integrations", title: "Cloud Integrations",
body: `Could not erase all data (API returned ${res.status}), please contact support.`, body: `Could not erase all data (API returned ${res.status}), please contact support.`,
color: "var(--red-360)" color: "var(--red-360)",
}); });
return; return;
} }
Settings.cloud.authenticated = false; Settings.cloud.authenticated = false;
await deauthorizeCloud(); await deauthorizeCloud();
await saveLocalManifest([]);
showNotification({ showNotification({
title: "Cloud Integrations", title: "Cloud Integrations",
body: "Successfully erased all data.", body: "Successfully erased all data.",
color: "var(--green-360)" color: "var(--green-360)",
}); });
} }

View file

@ -0,0 +1,47 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface ManifestEntry {
key: string;
version: number;
checksum: string;
}
export interface SyncUpload {
key: string;
value: string;
checksum?: string;
}
export interface SyncRequest {
client_manifest: ManifestEntry[];
uploads: SyncUpload[];
}
export interface SyncDownload {
key: string;
value: string;
version: number;
checksum: string;
}
export interface SyncUploadResult {
key: string;
version: number;
checksum: string;
}
export interface SyncError {
key: string;
error: string;
}
export interface SyncResponse {
server_manifest: ManifestEntry[];
downloads: SyncDownload[];
uploaded: SyncUploadResult[];
errors: SyncError[];
}

View file

@ -21,16 +21,18 @@ import type { CSSProperties, HTMLAttributes } from "react";
export interface FlexProps extends HTMLAttributes<HTMLDivElement> { export interface FlexProps extends HTMLAttributes<HTMLDivElement> {
flexDirection?: CSSProperties["flexDirection"]; flexDirection?: CSSProperties["flexDirection"];
gap?: CSSProperties["gap"]; gap?: CSSProperties["gap"];
alignContent?: CSSProperties["alignContent"];
justifyContent?: CSSProperties["justifyContent"]; justifyContent?: CSSProperties["justifyContent"];
alignItems?: CSSProperties["alignItems"]; alignItems?: CSSProperties["alignItems"];
flexWrap?: CSSProperties["flexWrap"]; flexWrap?: CSSProperties["flexWrap"];
} }
export function Flex({ flexDirection, gap = "1em", justifyContent, alignItems, flexWrap, children, style, ...restProps }: FlexProps) { export function Flex({ flexDirection, gap = "1em", alignContent, justifyContent, alignItems, flexWrap, children, style, ...restProps }: FlexProps) {
style = { style = {
display: "flex", display: "flex",
flexDirection, flexDirection,
gap, gap,
alignContent,
justifyContent, justifyContent,
alignItems, alignItems,
flexWrap, flexWrap,

View file

@ -70,7 +70,7 @@ export default definePlugin({
commands: [ commands: [
{ {
name: "refresh", name: "refresh",
description: "Refreshes Discord application commands locally", description: "Refreshes the specified applications commands locally",
options: [ options: [
{ {
name: "user", name: "user",

View file

@ -86,12 +86,6 @@ const settings = definePluginSettings({
restartNeeded: true, restartNeeded: true,
default: false, default: false,
}, },
noDefaultHangStatus: {
type: OptionType.BOOLEAN,
description: "Disable the default hang status when joining voice channels",
restartNeeded: true,
default: false,
},
refreshSlashCommands: { refreshSlashCommands: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Refreshes Slash Commands to show newly added commands without restarting your client.", description: "Refreshes Slash Commands to show newly added commands without restarting your client.",
@ -210,15 +204,6 @@ export default definePlugin({
replace: "$& && false" replace: "$& && false"
} }
}, },
// No Default Hang Status
{
find: ".CHILLING)",
predicate: () => settings.store.noDefaultHangStatus,
replacement: {
match: /{enableHangStatus:(\i),/,
replace: "{_enableHangStatus:$1=false,"
}
},
// Force Role Icon // Force Role Icon
{ {
find: "Message Username", find: "Message Username",

View file

@ -1126,12 +1126,24 @@ function getLastFilterChoices(): { group: string; filter: string; }[] | null {
} }
function setLastSortChoice(sort: string): void { function setLastSortChoice(sort: string): void {
if (!sort) sort = "questify";
settings.store.lastQuestPageSort = sort; settings.store.lastQuestPageSort = sort;
} }
function setLastFilterChoices(filters: { group: string; filter: string; }[]): void { function setLastFilterChoices(filters: { group: string; filter: string; }[] | null): void {
if (!filters || !Object.keys(filters).length || !Object.values(filters).every(f => f.group && f.filter)) { return; } if (!filters || filters.length === 0) {
settings.store.lastQuestPageFilters = JSON.parse(JSON.stringify(filters)).reduce((acc, item) => ({ ...acc, [item.filter]: item }), {}); settings.store.lastQuestPageFilters = {};
return;
}
if (!filters.every(f => f && f.group && f.filter)) {
return;
}
settings.store.lastQuestPageFilters = (JSON.parse(JSON.stringify(filters)) as { group: string; filter: string; }[]).reduce((acc, item) => {
acc[item.filter] = item;
return acc;
}, {});
} }
function getQuestAcceptedButtonProps(quest: Quest, text: string, disabled: boolean, onClick?: () => void) { function getQuestAcceptedButtonProps(quest: Quest, text: string, disabled: boolean, onClick?: () => void) {
@ -1247,8 +1259,8 @@ export default definePlugin({
replace: "const shouldHideSponsoredQuestBanner=$self.shouldHideSponsoredQuestBanner();" replace: "const shouldHideSponsoredQuestBanner=$self.shouldHideSponsoredQuestBanner();"
}, },
{ {
match: /(?<=if\(null!=\i\))return(.{0,60}?}\))/, match: /(?<=return null;if\(\i)(\).{0,30}?null!=\i)/,
replace: "if(!shouldHideSponsoredQuestBanner)return $1" replace: "&&!shouldHideSponsoredQuestBanner$1&&!shouldHideSponsoredQuestBanner"
} }
] ]
}, },
@ -1533,24 +1545,18 @@ export default definePlugin({
replace: "$self.getLastSortChoice()??$1" replace: "$self.getLastSortChoice()??$1"
}, },
{ {
// Set the initial filters. // Set the initial filters and update the filters and sort method when they change.
match: /(get\(\i\)\)\?\?)(\i)/, match: /(get\(\i\)\)\?\?)(\i,\[\i\]\),\i=\i.useCallback\((\i)=>{)(.{0,60}?useCallback\((\i)=>{)/,
replace: "$1$self.getLastFilterChoices()??$2" replace: "$1$self.getLastFilterChoices()??$2$self.setLastSortChoice($3);$4$self.setLastFilterChoices($5);"
},
{
// 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. // Update the last used sort and filter choices when the toggle setting for either is changed.
match: /(?<=ALL,\i.useMemo\(\(\)=>\()({sortMethod:(\i),filters:(\i))/, match: /(?<=ALL,\i.useMemo\(\(\)=>\()({sortMethod:(\i),filters:(\i))/,
replace: "$self.setLastSortChoice($2),$self.setLastFilterChoices($3),$1" replace: "$self.setLastSortChoice($2),$self.setLastFilterChoices($3),$1"
},
{
match: /(?<=resetSortingFiltering:\(\)=>{\i\(\),\i\()\i.\i.SUGGESTED/,
replace: '"questify"'
} }
] ]
}, },

View file

@ -7,11 +7,14 @@
import * as DataStore from "@api/DataStore"; import * as DataStore from "@api/DataStore";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants"; import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { makeRange, OptionType } from "@utils/types"; import definePlugin, { makeRange, OptionType } from "@utils/types";
import { VoiceState } from "@vencord/discord-types";
import { ChannelStore, FluxDispatcher, UserStore, VoiceStateStore } from "@webpack/common"; import { ChannelStore, FluxDispatcher, UserStore, VoiceStateStore } from "@webpack/common";
const DATASTORE_KEY = "VCLastVoiceChannel"; const DATASTORE_KEY = "VCLastVoiceChannel";
const DATASTORE_SESSION_KEY = "VCLastVoiceChannelSession"; const DATASTORE_SESSION_KEY = "VCLastVoiceChannelSession";
const logger = new Logger("VoiceRejoin");
const settings = definePluginSettings({ const settings = definePluginSettings({
rejoinDelay: { rejoinDelay: {
@ -48,12 +51,15 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "VoiceRejoin", name: "VoiceRejoin",
description: "Rejoins DM/Server call automatically when restarting Discord.", description: "Rejoins DM/Server call automatically when restarting Discord.",
authors: [EquicordDevs.omaw], authors: [EquicordDevs.omaw, EquicordDevs.keircn],
settings, settings,
flux: { flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: any[]; }) { VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
const myUserId = UserStore.getCurrentUser().id; const currentUser = UserStore.getCurrentUser();
if (!currentUser) return;
const myUserId = currentUser.id;
const myState = voiceStates.find(s => s.userId === myUserId); const myState = voiceStates.find(s => s.userId === myUserId);
if (!myState) return; if (!myState) return;
@ -63,77 +69,92 @@ export default definePlugin({
channelId: myState.channelId, channelId: myState.channelId,
timestamp: Date.now(), timestamp: Date.now(),
}; };
DataStore.set(DATASTORE_KEY, saved); void Promise.all([
DataStore.set(DATASTORE_SESSION_KEY, true); DataStore.set(DATASTORE_KEY, saved),
DataStore.set(DATASTORE_SESSION_KEY, true)
]).catch(err => logger.error("Failed to persist last voice channel", err));
} else { } else {
DataStore.set(DATASTORE_SESSION_KEY, false); void DataStore.set(DATASTORE_SESSION_KEY, false)
.catch(err => logger.error("Failed to persist voice session state", err));
} }
}, },
async CONNECTION_OPEN() { async CONNECTION_OPEN() {
const wasInVC = await DataStore.get(DATASTORE_SESSION_KEY); const wasInVC = await DataStore.get(DATASTORE_SESSION_KEY);
if (wasInVC === false) { if (wasInVC === false) {
DataStore.del(DATASTORE_KEY); await DataStore.del(DATASTORE_KEY);
return; return;
} }
setTimeout(async () => { setTimeout(async () => {
const saved = await DataStore.get(DATASTORE_KEY); try {
if (!saved?.channelId) return; const saved = await DataStore.get(DATASTORE_KEY);
if (!saved?.channelId) return;
const channel = ChannelStore.getChannel(saved.channelId); let channel = ChannelStore.getChannel(saved.channelId);
const isDM = channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM(); for (let i = 0; i < 20 && !channel; i++) {
const myUserId = UserStore.getCurrentUser().id; await new Promise(resolve => setTimeout(resolve, 250));
const myVoiceState = VoiceStateStore.getVoiceStateForUser(myUserId); channel = ChannelStore.getChannel(saved.channelId);
const preventionMode = settings.store.preventReconnectIfCallEnded; }
const timeoutMs = settings.store.rejoinTimeout * 1000;
if (saved.timestamp && Date.now() - saved.timestamp > timeoutMs) {
DataStore.set(DATASTORE_SESSION_KEY, false);
return;
}
if (settings.store.applyOnlyToDms && !isDM) {
DataStore.set(DATASTORE_SESSION_KEY, false);
return;
}
if (preventionMode !== "none") {
if (!channel) { if (!channel) {
DataStore.set(DATASTORE_SESSION_KEY, false); await DataStore.set(DATASTORE_SESSION_KEY, false);
return; return;
} }
const shouldPrevent = const currentUser = UserStore.getCurrentUser();
preventionMode === "both" || if (!currentUser) return;
(preventionMode === "dms" && isDM) ||
(preventionMode === "servers" && !isDM);
if (shouldPrevent) { const isDM = channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM();
const connectedUsers = VoiceStateStore.getVoiceStatesForChannel(saved.channelId); const myUserId = currentUser.id;
const othersInCall = Object.values(connectedUsers).filter( const myVoiceState = VoiceStateStore.getVoiceStateForUser(myUserId);
(vs: any) => vs.userId !== UserStore.getCurrentUser().id const preventionMode = settings.store.preventReconnectIfCallEnded;
); const timeoutMs = settings.store.rejoinTimeout * 1000;
if (othersInCall.length === 0) { if (saved.timestamp && Date.now() - saved.timestamp > timeoutMs) {
DataStore.set(DATASTORE_SESSION_KEY, false); await DataStore.set(DATASTORE_SESSION_KEY, false);
return; return;
}
if (settings.store.applyOnlyToDms && !isDM) {
await DataStore.set(DATASTORE_SESSION_KEY, false);
return;
}
if (preventionMode !== "none") {
const shouldPrevent =
preventionMode === "both" ||
(preventionMode === "dms" && isDM) ||
(preventionMode === "servers" && !isDM);
if (shouldPrevent) {
const connectedUsers = VoiceStateStore.getVoiceStatesForChannel(saved.channelId) as Record<string, VoiceState>;
const othersInCall = Object.values(connectedUsers).filter(
vs => vs.userId !== myUserId
);
if (othersInCall.length === 0) {
await DataStore.set(DATASTORE_SESSION_KEY, false);
return;
}
} }
} }
if (myVoiceState?.channelId) {
await DataStore.set(DATASTORE_SESSION_KEY, false);
return;
}
FluxDispatcher.dispatch({
type: "VOICE_CHANNEL_SELECT",
guildId: saved.guildId,
channelId: saved.channelId,
});
await DataStore.set(DATASTORE_SESSION_KEY, true);
} catch (err) {
logger.error("Failed to run voice rejoin", err);
} }
if (myVoiceState?.channelId) {
DataStore.set(DATASTORE_SESSION_KEY, false);
return;
}
FluxDispatcher.dispatch({
type: "VOICE_CHANNEL_SELECT",
guildId: saved.guildId,
channelId: saved.channelId,
});
DataStore.set(DATASTORE_SESSION_KEY, true);
}, settings.store.rejoinDelay * 1000); }, settings.store.rejoinDelay * 1000);
}, },
}, },

View file

@ -4,12 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import "./styles.css";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { Heading, HeadingSecondary } from "@components/Heading"; import { Heading, HeadingSecondary } from "@components/Heading";
import { Paragraph } from "@components/Paragraph"; import { Paragraph } from "@components/Paragraph";
import { EquicordDevs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { getIntlMessage, openUserProfile } from "@utils/discord"; import { getIntlMessage, openUserProfile } from "@utils/discord";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
@ -25,12 +27,49 @@ interface WatchingProps {
guildId?: string; guildId?: string;
} }
const cl = classNameFactory("whosWatching-"); const ApplicationStreamingStore = findStoreLazy("ApplicationStreamingStore");
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
const AvatarStyles = findCssClassesLazy("moreUsers", "clickableAvatar", "avatar");
const cl = classNameFactory("vc-whos-watching-");
function getUsername(user: any): string { function getUsername(user: User): string {
return RelationshipStore.getNickname(user.id) || user.globalName || user.username; return RelationshipStore.getNickname(user.id) || user.globalName || user.username;
} }
function Watching({ userIds, guildId }: WatchingProps): JSX.Element {
let missingUsers = 0;
const users = userIds.map(id => UserStore.getUser(id)).filter(user => Boolean(user) ? true : (missingUsers += 1, false));
return (
<div className={cl("content")}>
{userIds.length ?
(
<div className={cl("spectating")}>
<Heading>{getIntlMessage("SPECTATORS", { numViewers: userIds.length })}</Heading>
<Flex flexDirection="column" gap="6" >
{users.map(user => (
<Flex key={user.id} flexDirection="row" gap="6" alignContent="center">
<img className={cl("user-avatar")} src={user.getAvatarURL(guildId)} alt="" />
{getUsername(user)}
</Flex>
))}
{missingUsers > 0 &&
<span className={cl("more-users")}>
{`+${getIntlMessage("NUM_USERS", { num: missingUsers })}`}
</span>
}
</Flex>
</div>
)
: (
<span className={cl("no-viewers")}>
No spectators
</span>
)
}
</div>
);
}
const settings = definePluginSettings({ const settings = definePluginSettings({
showPanel: { showPanel: {
description: "Show spectators under screenshare panel", description: "Show spectators under screenshare panel",
@ -40,53 +79,23 @@ const settings = definePluginSettings({
}, },
}); });
function Watching({ userIds, guildId }: WatchingProps): JSX.Element {
// Missing Users happen when UserStore.getUser(id) returns null
// The client should automatically cache spectators, so this might not be possible but it's better to be sure just in case
let missingUsers = 0;
const users = userIds.map(id => UserStore.getUser(id)).filter(user => Boolean(user) ? true : (missingUsers += 1, false));
return (
<div className={cl("content")}>
{userIds.length ?
(<>
<Heading>{getIntlMessage("SPECTATORS", { numViewers: userIds.length })}</Heading>
<Flex flexDirection="column" style={{ gap: 6 }} >
{users.map(user => (
<Flex key={user.id} flexDirection="row" style={{ gap: 6, alignContent: "center" }} className={cl("user")} >
<img src={user.getAvatarURL(guildId)} style={{ borderRadius: 8, width: 16, height: 16 }} alt="" />
{getUsername(user)}
</Flex>
))}
{missingUsers > 0 && <span className={cl("more_users")}>{`+${getIntlMessage("NUM_USERS", { num: missingUsers })}`}</span>}
</Flex>
</>)
: (<span className={cl("no_viewers")}>No spectators</span>)}
</div>
);
}
const ApplicationStreamingStore = findStoreLazy("ApplicationStreamingStore");
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
const AvatarStyles = findCssClassesLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar", "avatar");
export default definePlugin({ export default definePlugin({
name: "WhosWatching", name: "WhosWatching",
description: "Hover over the screenshare icon to view what users are watching your stream", description: "Hover over the screenshare icon to view what users are watching your stream",
authors: [EquicordDevs.Fres], authors: [EquicordDevs.Fres, Devs.thororen],
settings, settings,
patches: [ patches: [
{ {
find: ".Masks.STATUS_SCREENSHARE,width:32", find: ".Masks.STATUS_SCREENSHARE,width:32",
replacement: { replacement: {
match: /jsx\)\((\i\.\i),{mask:/, match: /\((\i\.\i)(?=,{mask:\i\.\i\.Masks\.STATUS_SCREENSHARE)/,
replace: "jsx)($self.component({OriginalComponent:$1}),{mask:" replace: "($self.component({OriginalComponent:$1})"
} }
}, },
{ {
find: "this.renderEmbeddedActivity()", find: "this.renderEmbeddedActivity()",
replacement: { replacement: {
match: /"div"(?=.{0,50}this.renderActions)/, match: /"div"(?=.{0,40}?,this.renderActions\(\))/,
replace: "$self.WrapperComponent" replace: "$self.WrapperComponent"
} }
} }
@ -95,8 +104,8 @@ export default definePlugin({
const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream()); const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream());
if (!stream) return <div {...props}>{props.children}</div>; if (!stream) return <div {...props}>{props.children}</div>;
const userIds: string[] = ApplicationStreamingStore.getViewerIds(stream);
let missingUsers = 0; let missingUsers = 0;
const userIds: string[] = ApplicationStreamingStore.getViewerIds(stream);
const users = userIds.map(id => UserStore.getUser(id)).filter(user => Boolean(user) ? true : (missingUsers += 1, false)); const users = userIds.map(id => UserStore.getUser(id)).filter(user => Boolean(user) ? true : (missingUsers += 1, false));
function renderMoreUsers(_label: string, count: number) { function renderMoreUsers(_label: string, count: number) {
@ -117,14 +126,14 @@ export default definePlugin({
} }
return ( return (
<> <div className={cl("screenshare-panel")}>
<div {...props}>{props.children}</div> <div {...props}>{props.children}</div>
<div className={classes(cl("spectators_panel"), Margins.top8)}> <div className={classes(cl("spectating-panel"), Margins.top8)}>
<HeadingSecondary style={{ marginTop: 8, marginBottom: 0, textTransform: "uppercase" }}> <HeadingSecondary className={cl("spectating-header")}>
{getIntlMessage("SPECTATORS", { numViewers: userIds.length })} {getIntlMessage("SPECTATORS", { numViewers: userIds.length })}
</HeadingSecondary> </HeadingSecondary>
{users.length ? {users.length ?
<> <div className={cl("spectating-users")}>
<UserSummaryItem <UserSummaryItem
users={users} users={users}
count={userIds.length} count={userIds.length}
@ -147,20 +156,20 @@ export default definePlugin({
</Clickable> </Clickable>
)} )}
/> />
</> </div>
: <Paragraph>No spectators</Paragraph> : <Paragraph>
No spectators
</Paragraph>
} }
</div> </div>
</> </div>
); );
}), }),
component: function ({ OriginalComponent }) { component: function ({ OriginalComponent }) {
return ErrorBoundary.wrap((props: any) => { return ErrorBoundary.wrap(props => {
const stream = useStateFromStores( const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream());
[ApplicationStreamingStore],
() => ApplicationStreamingStore.getCurrentUserActiveStream()
);
if (!stream) return null; if (!stream) return null;
const viewers = ApplicationStreamingStore.getViewerIds(stream); const viewers = ApplicationStreamingStore.getViewerIds(stream);
return <Tooltip text={<Watching userIds={viewers} guildId={stream.guildId} />}> return <Tooltip text={<Watching userIds={viewers} guildId={stream.guildId} />}>
{({ onMouseEnter, onMouseLeave }) => ( {({ onMouseEnter, onMouseLeave }) => (

View file

@ -0,0 +1,11 @@
.vc-whos-watching-user-avatar {
border-radius: 8px;
width: 16px;
height: 16px;
}
.vc-whos-watching-spectating-header {
margin-top: 8px;
margin-bottom: 0;
text-transform: uppercase;
}

View file

@ -20,7 +20,7 @@ import { gitHashShort } from "@shared/vencordUserAgent";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { isTruthy } from "@utils/guards"; import { isTruthy } from "@utils/guards";
import definePlugin, { IconProps, OptionType } from "@utils/types"; import definePlugin, { IconProps, OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy, waitFor } from "@webpack";
import { React } from "@webpack/common"; import { React } from "@webpack/common";
import type { ComponentType, PropsWithChildren, ReactNode } from "react"; import type { ComponentType, PropsWithChildren, ReactNode } from "react";
@ -46,7 +46,14 @@ const enum LayoutType {
CUSTOM = 18 CUSTOM = 18
} }
const LayoutTypes: typeof LayoutType = findByPropsLazy("SECTION", "SIDEBAR_ITEM", "PANEL"); let LayoutTypes = {
SECTION: 1,
SIDEBAR_ITEM: 2,
PANEL: 3,
CATEGORY: 5,
CUSTOM: 19,
};
waitFor(["SECTION", "SIDEBAR_ITEM", "PANEL", "CUSTOM"], v => LayoutTypes = v);
const enum SectionType { const enum SectionType {
HEADER = "HEADER", HEADER = "HEADER",
@ -175,20 +182,25 @@ export default definePlugin({
key: key + "_panel", key: key + "_panel",
type: LayoutTypes.PANEL, type: LayoutTypes.PANEL,
useTitle: () => panelTitle, useTitle: () => panelTitle,
buildLayout: () => [], buildLayout: () => [{
StronglyDiscouragedCustomComponent: () => <Component />, type: LayoutTypes.CATEGORY,
render: () => <Component />, key: key + "_category",
buildLayout: () => [{
type: LayoutTypes.CUSTOM,
key: key + "_custom",
Component: Component,
useSearchTerms: () => [title]
}]
}]
}; };
return { return ({
key, key,
type: LayoutTypes.SIDEBAR_ITEM, type: LayoutTypes.SIDEBAR_ITEM,
legacySearchKey: title.toUpperCase(),
getLegacySearchKey: () => title.toUpperCase(),
useTitle: () => title, useTitle: () => title,
icon: () => <Icon width={20} height={20} />, icon: () => <Icon width={20} height={20} />,
buildLayout: () => [panel] buildLayout: () => [panel]
}; });
}, },
getSettingsSectionMappings() { getSettingsSectionMappings() {

View file

@ -161,9 +161,6 @@ export default definePlugin({
}, },
], ],
buildPluginMenuEntries,
buildThemeMenuEntries,
// This is the very outer layer of the entire ui, so we can't wrap this in an ErrorBoundary // This is the very outer layer of the entire ui, so we can't wrap this in an ErrorBoundary
// without possibly also catching unrelated errors of children. // without possibly also catching unrelated errors of children.
// //

View file

@ -0,0 +1,5 @@
# NoDefaultHangStatus
Disable the default hang status when joining voice channels
![Visualization](https://github.com/Vendicated/Vencord/assets/24937357/329a9742-236f-48f7-94ff-c3510eca505a)

View file

@ -0,0 +1,26 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { migrateSettingToPlugin } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
migrateSettingToPlugin("NoDefaultHangStatus", "EquicordHelper", "noDefaultHangStatus");
export default definePlugin({
name: "NoDefaultHangStatus",
description: "Disable the default hang status when joining voice channels",
authors: [Devs.D3SOX],
patches: [
{
find: ".CHILLING)",
replacement: {
match: /{enableHangStatus:(\i),/,
replace: "{_enableHangStatus:$1=false,"
}
}
]
});