diff --git a/package.json b/package.json index 6967bb960..266de9355 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "equicord", "private": "true", - "version": "1.14.4.1", + "version": "1.14.5.0", "description": "The other cutest Discord client mod", "homepage": "https://github.com/Equicord/Equicord#readme", "bugs": { diff --git a/packages/discord-types/src/stores/PresenceStore.d.ts b/packages/discord-types/src/stores/PresenceStore.d.ts index c0462dce9..f94df8f49 100644 --- a/packages/discord-types/src/stores/PresenceStore.d.ts +++ b/packages/discord-types/src/stores/PresenceStore.d.ts @@ -6,7 +6,7 @@ export interface UserAndActivity { activity: Activity; } -export type DiscordPlatform = "desktop" | "mobile" | "web" | "embedded"; +export type DiscordPlatform = "desktop" | "mobile" | "web" | "embedded" | "vr"; export interface PresenceStoreState { presencesForGuilds: Record>; }>>; diff --git a/src/api/Settings.ts b/src/api/Settings.ts index f0db98933..e0549259c 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -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[]) { const { plugins } = SettingsStore.plain; diff --git a/src/api/SettingsSync/cloudSetup.tsx b/src/api/SettingsSync/cloudSetup.tsx index 20a21859d..3cbc97656 100644 --- a/src/api/SettingsSync/cloudSetup.tsx +++ b/src/api/SettingsSync/cloudSetup.tsx @@ -96,19 +96,22 @@ export async function authorizeCloud() { const res = await fetch(location, { headers: { Accept: "application/json" } }); - const { secret } = await res.json(); - if (secret) { - logger.info("Authorized with secret"); - await setAuthorization(secret); + const data = await res.json(); + if (data.secret) { + logger.info("Authorized with cloud"); + await setAuthorization(data.secret); showNotification({ title: "Cloud Integration", body: "Cloud integrations enabled!" }); Settings.cloud.authenticated = true; } else { + logger.error("OAuth callback returned no secret", data); showNotification({ 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; } diff --git a/src/api/SettingsSync/cloudSync.ts b/src/api/SettingsSync/cloudSync.ts index 91dc199c0..da501d7e4 100644 --- a/src/api/SettingsSync/cloudSync.ts +++ b/src/api/SettingsSync/cloudSync.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import * as DataStore from "@api/DataStore"; import { showNotification } from "@api/Notifications"; import { PlainSettings, Settings } from "@api/Settings"; import { localStorage } from "@utils/localStorage"; @@ -14,196 +15,483 @@ import { deflateSync, inflateSync } from "fflate"; import { deauthorizeCloud, getCloudAuth, getCloudUrl } from "./cloudSetup"; import { exportSettings, importSettings } from "./offline"; +import { ManifestEntry, SyncRequest, SyncResponse } from "./types"; 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 { + return await DataStore.get(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 { + 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 { + return await DataStore.get(MANIFEST_STORE_KEY) ?? []; +} + +async function saveLocalManifest(manifest: ManifestEntry[]) { + await DataStore.set(MANIFEST_STORE_KEY, manifest); +} + +async function buildLocalData(): Promise> { + const encoder = new TextEncoder(); + const data = new Map(); + + 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 { + 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, + }); + + 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") { const localDirection = localStorage.Vencord_cloudSyncDirection; - return localDirection === direction || localDirection === "both"; } export async function putCloudSettings(manual?: boolean) { - const settings = await exportSettings({ syncDataStore: false, minify: true }); - try { - 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 - }); - - 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 version = await getApiVersion(); + if (version === "v2") { + await putV2(manual); + if (await getApiVersion() === "v1") + await putV1(manual); + } else { + await putV1(manual); } - - 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) { logger.error("Failed to sync up", e); showNotification({ title: "Cloud Settings", 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) { try { - 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) { - // 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; + const version = await getApiVersion(); + if (version === "v2") { + const result = await getV2(shouldNotify, force); + if (await getApiVersion() === "v1") + return await getV1(shouldNotify, force); + return result; } - - 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; + return await getV1(shouldNotify, force); } catch (e: any) { logger.error("Failed to sync down", e); showNotification({ title: "Cloud Settings", body: `Could not synchronize settings from the cloud (${e.toString()}).`, - color: "var(--red-360)" + color: "var(--red-360)", }); - return false; } } export async function deleteCloudSettings() { try { - 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)" - }); + const version = await getApiVersion(); + if (version === "v2") + await deleteV2(); + else + await deleteV1(); } catch (e: any) { logger.error("Failed to delete", e); showNotification({ title: "Cloud Settings", 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() { const res = await fetch(new URL("/v1/", getCloudUrl()), { method: "DELETE", - headers: { Authorization: await getCloudAuth() } + headers: { Authorization: await getCloudAuth() }, }); if (!res.ok) { @@ -219,17 +507,18 @@ export async function eraseAllCloudData() { showNotification({ title: "Cloud Integrations", body: `Could not erase all data (API returned ${res.status}), please contact support.`, - color: "var(--red-360)" + color: "var(--red-360)", }); return; } Settings.cloud.authenticated = false; await deauthorizeCloud(); + await saveLocalManifest([]); showNotification({ title: "Cloud Integrations", body: "Successfully erased all data.", - color: "var(--green-360)" + color: "var(--green-360)", }); } diff --git a/src/api/SettingsSync/types.ts b/src/api/SettingsSync/types.ts new file mode 100644 index 000000000..9d64de3c8 --- /dev/null +++ b/src/api/SettingsSync/types.ts @@ -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[]; +} diff --git a/src/components/Flex.tsx b/src/components/Flex.tsx index e2b9a71be..d86489f6c 100644 --- a/src/components/Flex.tsx +++ b/src/components/Flex.tsx @@ -21,16 +21,18 @@ import type { CSSProperties, HTMLAttributes } from "react"; export interface FlexProps extends HTMLAttributes { flexDirection?: CSSProperties["flexDirection"]; gap?: CSSProperties["gap"]; + alignContent?: CSSProperties["alignContent"]; justifyContent?: CSSProperties["justifyContent"]; alignItems?: CSSProperties["alignItems"]; 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 = { display: "flex", flexDirection, gap, + alignContent, justifyContent, alignItems, flexWrap, diff --git a/src/equicordplugins/betterCommands/index.ts b/src/equicordplugins/betterCommands/index.ts index 63bf1eb45..6c7df169f 100644 --- a/src/equicordplugins/betterCommands/index.ts +++ b/src/equicordplugins/betterCommands/index.ts @@ -70,7 +70,7 @@ export default definePlugin({ commands: [ { name: "refresh", - description: "Refreshes Discord application commands locally", + description: "Refreshes the specified applications commands locally", options: [ { name: "user", diff --git a/src/equicordplugins/equicordHelper/index.tsx b/src/equicordplugins/equicordHelper/index.tsx index 0cf7b4841..54b79e9e1 100644 --- a/src/equicordplugins/equicordHelper/index.tsx +++ b/src/equicordplugins/equicordHelper/index.tsx @@ -86,12 +86,6 @@ const settings = definePluginSettings({ restartNeeded: true, default: false, }, - noDefaultHangStatus: { - type: OptionType.BOOLEAN, - description: "Disable the default hang status when joining voice channels", - restartNeeded: true, - default: false, - }, refreshSlashCommands: { type: OptionType.BOOLEAN, description: "Refreshes Slash Commands to show newly added commands without restarting your client.", @@ -210,15 +204,6 @@ export default definePlugin({ replace: "$& && false" } }, - // No Default Hang Status - { - find: ".CHILLING)", - predicate: () => settings.store.noDefaultHangStatus, - replacement: { - match: /{enableHangStatus:(\i),/, - replace: "{_enableHangStatus:$1=false," - } - }, // Force Role Icon { find: "Message Username", diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx index bac0596f4..ba25393b2 100644 --- a/src/equicordplugins/questify/index.tsx +++ b/src/equicordplugins/questify/index.tsx @@ -1126,12 +1126,24 @@ function getLastFilterChoices(): { group: string; filter: string; }[] | null { } function setLastSortChoice(sort: string): void { + if (!sort) sort = "questify"; settings.store.lastQuestPageSort = sort; } -function setLastFilterChoices(filters: { group: string; filter: string; }[]): void { - 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 }), {}); +function setLastFilterChoices(filters: { group: string; filter: string; }[] | null): void { + if (!filters || filters.length === 0) { + 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) { @@ -1247,8 +1259,8 @@ export default definePlugin({ replace: "const shouldHideSponsoredQuestBanner=$self.shouldHideSponsoredQuestBanner();" }, { - match: /(?<=if\(null!=\i\))return(.{0,60}?}\))/, - replace: "if(!shouldHideSponsoredQuestBanner)return $1" + match: /(?<=return null;if\(\i)(\).{0,30}?null!=\i)/, + replace: "&&!shouldHideSponsoredQuestBanner$1&&!shouldHideSponsoredQuestBanner" } ] }, @@ -1533,24 +1545,18 @@ export default definePlugin({ replace: "$self.getLastSortChoice()??$1" }, { - // Set the initial filters. - match: /(get\(\i\)\)\?\?)(\i)/, - 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" + // Set the initial filters and update the filters and sort method when they change. + match: /(get\(\i\)\)\?\?)(\i,\[\i\]\),\i=\i.useCallback\((\i)=>{)(.{0,60}?useCallback\((\i)=>{)/, + replace: "$1$self.getLastFilterChoices()??$2$self.setLastSortChoice($3);$4$self.setLastFilterChoices($5);" }, { // Update the last used sort and filter choices when the toggle setting for either is changed. match: /(?<=ALL,\i.useMemo\(\(\)=>\()({sortMethod:(\i),filters:(\i))/, replace: "$self.setLastSortChoice($2),$self.setLastFilterChoices($3),$1" + }, + { + match: /(?<=resetSortingFiltering:\(\)=>{\i\(\),\i\()\i.\i.SUGGESTED/, + replace: '"questify"' } ] }, diff --git a/src/equicordplugins/voiceRejoin/index.tsx b/src/equicordplugins/voiceRejoin/index.tsx index ad882d0df..12042df43 100644 --- a/src/equicordplugins/voiceRejoin/index.tsx +++ b/src/equicordplugins/voiceRejoin/index.tsx @@ -7,11 +7,14 @@ import * as DataStore from "@api/DataStore"; import { definePluginSettings } from "@api/Settings"; import { EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; import definePlugin, { makeRange, OptionType } from "@utils/types"; +import { VoiceState } from "@vencord/discord-types"; import { ChannelStore, FluxDispatcher, UserStore, VoiceStateStore } from "@webpack/common"; const DATASTORE_KEY = "VCLastVoiceChannel"; const DATASTORE_SESSION_KEY = "VCLastVoiceChannelSession"; +const logger = new Logger("VoiceRejoin"); const settings = definePluginSettings({ rejoinDelay: { @@ -48,12 +51,15 @@ const settings = definePluginSettings({ export default definePlugin({ name: "VoiceRejoin", description: "Rejoins DM/Server call automatically when restarting Discord.", - authors: [EquicordDevs.omaw], + authors: [EquicordDevs.omaw, EquicordDevs.keircn], settings, flux: { - VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: any[]; }) { - const myUserId = UserStore.getCurrentUser().id; + VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { + const currentUser = UserStore.getCurrentUser(); + if (!currentUser) return; + + const myUserId = currentUser.id; const myState = voiceStates.find(s => s.userId === myUserId); if (!myState) return; @@ -63,77 +69,92 @@ export default definePlugin({ channelId: myState.channelId, timestamp: Date.now(), }; - DataStore.set(DATASTORE_KEY, saved); - DataStore.set(DATASTORE_SESSION_KEY, true); + void Promise.all([ + DataStore.set(DATASTORE_KEY, saved), + DataStore.set(DATASTORE_SESSION_KEY, true) + ]).catch(err => logger.error("Failed to persist last voice channel", err)); } 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() { const wasInVC = await DataStore.get(DATASTORE_SESSION_KEY); if (wasInVC === false) { - DataStore.del(DATASTORE_KEY); + await DataStore.del(DATASTORE_KEY); return; } setTimeout(async () => { - const saved = await DataStore.get(DATASTORE_KEY); - if (!saved?.channelId) return; + try { + const saved = await DataStore.get(DATASTORE_KEY); + if (!saved?.channelId) return; - const channel = ChannelStore.getChannel(saved.channelId); - const isDM = channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM(); - const myUserId = UserStore.getCurrentUser().id; - const myVoiceState = VoiceStateStore.getVoiceStateForUser(myUserId); - const preventionMode = settings.store.preventReconnectIfCallEnded; - const timeoutMs = settings.store.rejoinTimeout * 1000; + let channel = ChannelStore.getChannel(saved.channelId); + for (let i = 0; i < 20 && !channel; i++) { + await new Promise(resolve => setTimeout(resolve, 250)); + channel = ChannelStore.getChannel(saved.channelId); + } - 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) { - DataStore.set(DATASTORE_SESSION_KEY, false); + await DataStore.set(DATASTORE_SESSION_KEY, false); return; } - const shouldPrevent = - preventionMode === "both" || - (preventionMode === "dms" && isDM) || - (preventionMode === "servers" && !isDM); + const currentUser = UserStore.getCurrentUser(); + if (!currentUser) return; - if (shouldPrevent) { - const connectedUsers = VoiceStateStore.getVoiceStatesForChannel(saved.channelId); - const othersInCall = Object.values(connectedUsers).filter( - (vs: any) => vs.userId !== UserStore.getCurrentUser().id - ); + const isDM = channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM(); + const myUserId = currentUser.id; + const myVoiceState = VoiceStateStore.getVoiceStateForUser(myUserId); + const preventionMode = settings.store.preventReconnectIfCallEnded; + const timeoutMs = settings.store.rejoinTimeout * 1000; - if (othersInCall.length === 0) { - DataStore.set(DATASTORE_SESSION_KEY, false); - return; + if (saved.timestamp && Date.now() - saved.timestamp > timeoutMs) { + await DataStore.set(DATASTORE_SESSION_KEY, false); + 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; + 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); }, }, diff --git a/src/equicordplugins/whosWatching/index.tsx b/src/equicordplugins/whosWatching/index.tsx index c224a2da4..2412d1827 100644 --- a/src/equicordplugins/whosWatching/index.tsx +++ b/src/equicordplugins/whosWatching/index.tsx @@ -4,12 +4,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import "./styles.css"; + import { definePluginSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Flex } from "@components/Flex"; import { Heading, HeadingSecondary } from "@components/Heading"; import { Paragraph } from "@components/Paragraph"; -import { EquicordDevs } from "@utils/constants"; +import { Devs, EquicordDevs } from "@utils/constants"; import { classNameFactory } from "@utils/css"; import { getIntlMessage, openUserProfile } from "@utils/discord"; import { Margins } from "@utils/margins"; @@ -25,12 +27,49 @@ interface WatchingProps { 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; } +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 ( +
+ {userIds.length ? + ( +
+ {getIntlMessage("SPECTATORS", { numViewers: userIds.length })} + + {users.map(user => ( + + + {getUsername(user)} + + ))} + {missingUsers > 0 && + + {`+${getIntlMessage("NUM_USERS", { num: missingUsers })}`} + + } + +
+ ) + : ( + + No spectators + + ) + } +
+ ); +} + const settings = definePluginSettings({ showPanel: { 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 ( -
- {userIds.length ? - (<> - {getIntlMessage("SPECTATORS", { numViewers: userIds.length })} - - {users.map(user => ( - - - {getUsername(user)} - - ))} - {missingUsers > 0 && {`+${getIntlMessage("NUM_USERS", { num: missingUsers })}`}} - - ) - : (No spectators)} -
- ); -} - -const ApplicationStreamingStore = findStoreLazy("ApplicationStreamingStore"); - -const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers"); -const AvatarStyles = findCssClassesLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar", "avatar"); - export default definePlugin({ name: "WhosWatching", description: "Hover over the screenshare icon to view what users are watching your stream", - authors: [EquicordDevs.Fres], + authors: [EquicordDevs.Fres, Devs.thororen], settings, patches: [ { find: ".Masks.STATUS_SCREENSHARE,width:32", replacement: { - match: /jsx\)\((\i\.\i),{mask:/, - replace: "jsx)($self.component({OriginalComponent:$1}),{mask:" + match: /\((\i\.\i)(?=,{mask:\i\.\i\.Masks\.STATUS_SCREENSHARE)/, + replace: "($self.component({OriginalComponent:$1})" } }, { find: "this.renderEmbeddedActivity()", replacement: { - match: /"div"(?=.{0,50}this.renderActions)/, + match: /"div"(?=.{0,40}?,this.renderActions\(\))/, replace: "$self.WrapperComponent" } } @@ -95,8 +104,8 @@ export default definePlugin({ const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream()); if (!stream) return
{props.children}
; - const userIds: string[] = ApplicationStreamingStore.getViewerIds(stream); 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)); function renderMoreUsers(_label: string, count: number) { @@ -117,14 +126,14 @@ export default definePlugin({ } return ( - <> +
{props.children}
-
- +
+ {getIntlMessage("SPECTATORS", { numViewers: userIds.length })} {users.length ? - <> +
)} /> - - : No spectators +
+ : + No spectators + }
- +
); }), component: function ({ OriginalComponent }) { - return ErrorBoundary.wrap((props: any) => { - const stream = useStateFromStores( - [ApplicationStreamingStore], - () => ApplicationStreamingStore.getCurrentUserActiveStream() - ); + return ErrorBoundary.wrap(props => { + const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream()); if (!stream) return null; + const viewers = ApplicationStreamingStore.getViewerIds(stream); return }> {({ onMouseEnter, onMouseLeave }) => ( diff --git a/src/equicordplugins/whosWatching/styles.css b/src/equicordplugins/whosWatching/styles.css new file mode 100644 index 000000000..66a965fb3 --- /dev/null +++ b/src/equicordplugins/whosWatching/styles.css @@ -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; +} \ No newline at end of file diff --git a/src/plugins/_core/settings.tsx b/src/plugins/_core/settings.tsx index eb87cb137..81efa6e16 100644 --- a/src/plugins/_core/settings.tsx +++ b/src/plugins/_core/settings.tsx @@ -20,7 +20,7 @@ import { gitHashShort } from "@shared/vencordUserAgent"; import { Devs } from "@utils/constants"; import { isTruthy } from "@utils/guards"; import definePlugin, { IconProps, OptionType } from "@utils/types"; -import { findByPropsLazy } from "@webpack"; +import { findByPropsLazy, waitFor } from "@webpack"; import { React } from "@webpack/common"; import type { ComponentType, PropsWithChildren, ReactNode } from "react"; @@ -46,7 +46,14 @@ const enum LayoutType { 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 { HEADER = "HEADER", @@ -175,20 +182,25 @@ export default definePlugin({ key: key + "_panel", type: LayoutTypes.PANEL, useTitle: () => panelTitle, - buildLayout: () => [], - StronglyDiscouragedCustomComponent: () => , - render: () => , + buildLayout: () => [{ + type: LayoutTypes.CATEGORY, + key: key + "_category", + buildLayout: () => [{ + type: LayoutTypes.CUSTOM, + key: key + "_custom", + Component: Component, + useSearchTerms: () => [title] + }] + }] }; - return { + return ({ key, type: LayoutTypes.SIDEBAR_ITEM, - legacySearchKey: title.toUpperCase(), - getLegacySearchKey: () => title.toUpperCase(), useTitle: () => title, icon: () => , buildLayout: () => [panel] - }; + }); }, getSettingsSectionMappings() { diff --git a/src/plugins/betterSettings/index.tsx b/src/plugins/betterSettings/index.tsx index 7360c32a3..fb95d41ba 100644 --- a/src/plugins/betterSettings/index.tsx +++ b/src/plugins/betterSettings/index.tsx @@ -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 // without possibly also catching unrelated errors of children. // diff --git a/src/plugins/noDefaultHangStatus/README.md b/src/plugins/noDefaultHangStatus/README.md new file mode 100644 index 000000000..e6bc9f83a --- /dev/null +++ b/src/plugins/noDefaultHangStatus/README.md @@ -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) diff --git a/src/plugins/noDefaultHangStatus/index.ts b/src/plugins/noDefaultHangStatus/index.ts new file mode 100644 index 000000000..1301f0d25 --- /dev/null +++ b/src/plugins/noDefaultHangStatus/index.ts @@ -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," + } + } + ] +});