Merge branch 'dev'
This commit is contained in:
commit
ff31cc0593
17 changed files with 735 additions and 309 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, { status: OnlineStatus; activities: Activity[]; clientStatus: Partial<Record<DiscordPlatform, OnlineStatus>>; }>>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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") {
|
||||
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<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 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)",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
47
src/api/SettingsSync/types.ts
Normal file
47
src/api/SettingsSync/types.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -21,16 +21,18 @@ import type { CSSProperties, HTMLAttributes } from "react";
|
|||
export interface FlexProps extends HTMLAttributes<HTMLDivElement> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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({
|
||||
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 (
|
||||
<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({
|
||||
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 <div {...props}>{props.children}</div>;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className={cl("screenshare-panel")}>
|
||||
<div {...props}>{props.children}</div>
|
||||
<div className={classes(cl("spectators_panel"), Margins.top8)}>
|
||||
<HeadingSecondary style={{ marginTop: 8, marginBottom: 0, textTransform: "uppercase" }}>
|
||||
<div className={classes(cl("spectating-panel"), Margins.top8)}>
|
||||
<HeadingSecondary className={cl("spectating-header")}>
|
||||
{getIntlMessage("SPECTATORS", { numViewers: userIds.length })}
|
||||
</HeadingSecondary>
|
||||
{users.length ?
|
||||
<>
|
||||
<div className={cl("spectating-users")}>
|
||||
<UserSummaryItem
|
||||
users={users}
|
||||
count={userIds.length}
|
||||
|
|
@ -147,20 +156,20 @@ export default definePlugin({
|
|||
</Clickable>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
: <Paragraph>No spectators</Paragraph>
|
||||
</div>
|
||||
: <Paragraph>
|
||||
No spectators
|
||||
</Paragraph>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
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 <Tooltip text={<Watching userIds={viewers} guildId={stream.guildId} />}>
|
||||
{({ onMouseEnter, onMouseLeave }) => (
|
||||
|
|
|
|||
11
src/equicordplugins/whosWatching/styles.css
Normal file
11
src/equicordplugins/whosWatching/styles.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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: () => <Component />,
|
||||
render: () => <Component />,
|
||||
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: () => <Icon width={20} height={20} />,
|
||||
buildLayout: () => [panel]
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getSettingsSectionMappings() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
5
src/plugins/noDefaultHangStatus/README.md
Normal file
5
src/plugins/noDefaultHangStatus/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# NoDefaultHangStatus
|
||||
|
||||
Disable the default hang status when joining voice channels
|
||||
|
||||

|
||||
26
src/plugins/noDefaultHangStatus/index.ts
Normal file
26
src/plugins/noDefaultHangStatus/index.ts
Normal 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,"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue