From 70ec33a1c06377bb9bf88c733daf252eaa313e5a Mon Sep 17 00:00:00 2001 From: thororen <78185467+thororen1234@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:24:44 -0500 Subject: [PATCH 01/12] fix ReviewDB/Vencloud auth, NoMosaic, BetterSettings (#4025) Co-authored-by: V --- src/plugins/betterSettings/index.tsx | 78 +++++++++----------------- src/plugins/noDevtoolsWarning/index.ts | 2 +- src/plugins/noMosaic/index.ts | 2 +- src/webpack/common/components.ts | 2 +- 4 files changed, 30 insertions(+), 54 deletions(-) diff --git a/src/plugins/betterSettings/index.tsx b/src/plugins/betterSettings/index.tsx index ab9fac425..8ea90ffd6 100644 --- a/src/plugins/betterSettings/index.tsx +++ b/src/plugins/betterSettings/index.tsx @@ -13,12 +13,10 @@ import { Logger } from "@utils/Logger"; import definePlugin, { OptionType } from "@utils/types"; import { findCssClassesLazy } from "@webpack"; import { ComponentDispatch, FocusLock, Menu, useEffect, useRef } from "@webpack/common"; -import type { HTMLAttributes, ReactElement } from "react"; +import type { HTMLAttributes, ReactNode } from "react"; import fullHeightStyle from "./fullHeightContext.css?managed"; -type SettingsEntry = { section: string, label: string; }; - const cl = classNameFactory(""); const Classes = findCssClassesLazy("animating", "baseLayer", "bg", "layer", "layers"); @@ -43,11 +41,6 @@ const settings = definePluginSettings({ } }); -interface TransformedSettingsEntry { - section: string; - items: SettingsEntry[]; -} - interface LayerProps extends HTMLAttributes { mode: "SHOWN" | "HIDDEN"; baseLayer?: boolean; @@ -158,23 +151,16 @@ export default definePlugin({ }, { // Settings cog context menu find: "#{intl::USER_SETTINGS_ACTIONS_MENU_LABEL}", + predicate: () => settings.store.organizeMenu, replacement: [ { - match: /=\[\];(\i)(?=\.forEach.{0,200}?"logout"===\i.{0,100}?(\i)\.get\(\i\))/, - replace: "=$self.wrapMap([]);$self.transformSettingsEntries($1,$2)", - predicate: () => settings.store.organizeMenu + match: /children:\[(\i),(?<=\1=.{0,30}\.openUserSettings.+?)/, + replace: "children:[$self.transformSettingsEntries($1),", }, - { - match: /case \i\.\i\.DEVELOPER_OPTIONS:return \i;/, - replace: "$&case 'VencordPlugins':return $self.buildPluginMenuEntries(true);$&case 'VencordThemes':return $self.buildThemeMenuEntries();" - } ] }, ], - 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. // @@ -190,44 +176,34 @@ export default definePlugin({ return ; }, - transformSettingsEntries(list: SettingsEntry[], keyMap: Map) { - const items = [] as TransformedSettingsEntry[]; + transformSettingsEntries(list) { + const items: ReactNode[] = []; for (const item of list) { - if (item.section === "HEADER") { - keyMap.set(item.label, item.label); - items.push({ section: item.label, items: [] }); - } else if (item.section !== "DIVIDER" && keyMap.has(item.section)) { - items.at(-1)?.items.push(item); + const { key, props } = item; + if (!props) continue; + + if (key === "vencord_plugins" || key === "vencord_themes") { + const children = key === "vencord_plugins" + ? buildPluginMenuEntries() + : buildThemeMenuEntries(); + + items.push( + + {children} + + ); + } else if (key.endsWith("_section") && props.label) { + items.push( + + {this.transformSettingsEntries(props.children)} + + ); + } else { + items.push(item); } } return items; - }, - - wrapMap(toWrap: TransformedSettingsEntry[]) { - // @ts-expect-error - toWrap.map = function (render: (item: SettingsEntry) => ReactElement) { - return this - .filter(a => a.items.length > 0) - .map(({ section, items }) => { - const children = items.map(render); - if (section) { - return ( - - {children} - - ); - } else { - return children; - } - }); - }; - - return toWrap; } }); diff --git a/src/plugins/noDevtoolsWarning/index.ts b/src/plugins/noDevtoolsWarning/index.ts index e897e5793..5c35fa67b 100644 --- a/src/plugins/noDevtoolsWarning/index.ts +++ b/src/plugins/noDevtoolsWarning/index.ts @@ -26,7 +26,7 @@ export default definePlugin({ patches: [{ find: "setDevtoolsCallbacks", replacement: { - match: /if\(null!=\i&&"0.0.0"===\i\.remoteApp\.getVersion\(\)\)/, + match: /if\(null!=\i&&"0.0.0"===\i\.app\.getVersion\(\)\)/, replace: "if(true)" } }] diff --git a/src/plugins/noMosaic/index.ts b/src/plugins/noMosaic/index.ts index 6ff34a350..4ec64cc39 100644 --- a/src/plugins/noMosaic/index.ts +++ b/src/plugins/noMosaic/index.ts @@ -27,7 +27,7 @@ export default definePlugin({ patches: [ { - find: '=>"IMAGE"===', + find: '"PLAINTEXT_PREVIEW":"OTHER"', replacement: { match: /=>"IMAGE"===\i\|\|"VIDEO"===\i(?:\|\|("VISUAL_PLACEHOLDER"===\i))?;/, replace: (_, visualPlaceholderPred) => visualPlaceholderPred != null ? `=>${visualPlaceholderPred};` : "=>false;" diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts index e830e52ef..2a1ca4efb 100644 --- a/src/webpack/common/components.ts +++ b/src/webpack/common/components.ts @@ -112,7 +112,7 @@ waitFor(m => { export const MaskedLink = waitForComponent("MaskedLink", filters.componentByCode("MASKED_LINK)")); export const Timestamp = waitForComponent("Timestamp", filters.componentByCode("#{intl::MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL}")); -export const OAuth2AuthorizeModal = waitForComponent("OAuth2AuthorizeModal", filters.componentByCode("hasContentBackground", "oauth2_authorize")); +export const OAuth2AuthorizeModal = waitForComponent("OAuth2AuthorizeModal", filters.componentByCode("hasContentBackground", "nextStep", "onClose?.()")); export const Animations = mapMangledModuleLazy(".assign({colorNames:", { Transition: filters.componentByCode('["items","children"]', ",null,"), From fb30985af83fb4b90e2a37f8dc52637c4de2ca98 Mon Sep 17 00:00:00 2001 From: thororen <78185467+thororen1234@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:26:01 -0500 Subject: [PATCH 02/12] PlatformIndicators: support VR status (#4036) --- packages/discord-types/src/stores/PresenceStore.d.ts | 2 +- src/plugins/platformIndicators/index.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) 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/plugins/platformIndicators/index.tsx b/src/plugins/platformIndicators/index.tsx index ea33b3c6a..b0493597c 100644 --- a/src/plugins/platformIndicators/index.tsx +++ b/src/plugins/platformIndicators/index.tsx @@ -46,6 +46,11 @@ const { useStatusFillColor } = mapMangledModuleLazy([".5625*", "translate"], { useStatusFillColor: filters.byCode(".hex") }); +const platformMap = { + embedded: "Console", + vr: "VR" +}; + function Icon(path: string, opts?: { viewBox?: string; width?: number; height?: number; }) { return ({ color, tooltip, small }: { color: string; tooltip: string; small: boolean; }) => ( @@ -69,12 +74,11 @@ const Icons = { web: Icon("M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93Zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39Z"), mobile: Icon("M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z", { viewBox: "0 0 1000 1500", height: 17, width: 17 }), embedded: Icon("M14.8 2.7 9 3.1V47h3.3c1.7 0 6.2.3 10 .7l6.7.6V2l-4.2.2c-2.4.1-6.9.3-10 .5zm1.8 6.4c1 1.7-1.3 3.6-2.7 2.2C12.7 10.1 13.5 8 15 8c.5 0 1.2.5 1.6 1.1zM16 33c0 6-.4 10-1 10s-1-4-1-10 .4-10 1-10 1 4 1 10zm15-8v23.3l3.8-.7c2-.3 4.7-.6 6-.6H43V3h-2.2c-1.3 0-4-.3-6-.6L31 1.7V25z", { viewBox: "0 0 50 50" }), + vr: Icon("M8.46 8.64a1 1 0 0 1 1 1c0 .44-.3.8-.72.92l-.11.07c-.08.06-.2.19-.2.41a.99.99 0 0 1-.98.86h-.06a1 1 0 0 1-.94-1.05l.02-.32c.05-1.06.92-1.9 1.99-1.9ZM15.55 5a5.5 5.5 0 0 1 5.15 3.67h.3a2 2 0 0 1 2 2v3.18a2 2 0 0 1-2 1.99h-.2A4.54 4.54 0 0 1 16.55 19a4.45 4.45 0 0 1-3.6-1.83 1.2 1.2 0 0 0-1.9 0 4.44 4.44 0 0 1-3.9 1.82 4.54 4.54 0 0 1-3.94-3.15H3a2 2 0 0 1-2-2v-3.18c0-1.1.9-1.99 2-1.99h.3A5.5 5.5 0 0 1 8.46 5h7.09Zm-7.1 2C6.6 7 5.06 8.5 4.97 10.41l-.02.66v3.18c0 1.43 1.05 2.66 2.34 2.74.85.06 1.63-.32 2.14-1.01a3.2 3.2 0 0 1 2.57-1.3c1 0 1.97.48 2.57 1.3.5.69 1.3 1.08 2.14 1.01 1.3-.08 2.34-1.31 2.34-2.74l-.02-3.84a3.54 3.54 0 0 0-3.49-3.43H8.45Z", { viewBox: "0 4 24 16", height: 20, width: 20 }), } satisfies Record; function getPlatformTooltip(platform: DiscordPlatform): string { - return platform === "embedded" - ? "Console" - : platform[0].toUpperCase() + platform.slice(1); + return platformMap[platform] ?? platform.charAt(0).toUpperCase() + platform.slice(1); } const PlatformIcon = ({ platform, status, small }: { platform: DiscordPlatform, status: OnlineStatus; small: boolean; }) => { From 6c935c4caa5391c904e96cc0c923b78d0fd1d65b Mon Sep 17 00:00:00 2001 From: Vendicated Date: Mon, 9 Mar 2026 04:32:21 +0100 Subject: [PATCH 03/12] Toolbox: fix icon being too big --- src/plugins/vencordToolbox/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/vencordToolbox/index.tsx b/src/plugins/vencordToolbox/index.tsx index 22dfd948f..5f4f78fc1 100644 --- a/src/plugins/vencordToolbox/index.tsx +++ b/src/plugins/vencordToolbox/index.tsx @@ -88,7 +88,7 @@ export default definePlugin({ { find: '?"BACK_FORWARD_NAVIGATION":', replacement: { - match: /(?<=trailing:.{0,50})\i\.Fragment,\{(?=.+?className:(\i))/, + match: /(?<=trailing:.{0,50})\i\.Fragment,\{(?=.+?className:(\i\.\i))/, replace: "$self.TrailingWrapper,{className:$1," } } From a508367cc77f6283e28ab6bd0962098af1108981 Mon Sep 17 00:00:00 2001 From: Mashiro-chan <11561006+mashirochan@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:42:38 -0400 Subject: [PATCH 04/12] add back NoDefaultHangStatus (#4014) Discord re-added this feature --- src/plugins/noDefaultHangStatus/README.md | 5 +++++ src/plugins/noDefaultHangStatus/index.ts | 24 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/plugins/noDefaultHangStatus/README.md create mode 100644 src/plugins/noDefaultHangStatus/index.ts 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..2245c9101 --- /dev/null +++ b/src/plugins/noDefaultHangStatus/index.ts @@ -0,0 +1,24 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +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," + } + } + ] +}); From 9d98775dc1f0acbb13e08b329f9e8672532eeb1e Mon Sep 17 00:00:00 2001 From: Etorix <92535668+EtorixDev@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:35:21 -0700 Subject: [PATCH 05/12] Questify: Update patches for sorting, filtering, and the sponsored banner. (#899) * Updates. * Types I guess. --- src/equicordplugins/questify/index.tsx | 42 +++++++++++++++----------- 1 file changed, 24 insertions(+), 18 deletions(-) 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"' } ] }, From 48549d3790eac3a8061f16c21f0ecd9f03d1c5c5 Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:17:48 -0400 Subject: [PATCH 06/12] WhosWatching: Update Patches & Formatting --- src/components/Flex.tsx | 4 +- src/equicordplugins/whosWatching/index.tsx | 109 +++++++++++--------- src/equicordplugins/whosWatching/styles.css | 11 ++ 3 files changed, 73 insertions(+), 51 deletions(-) create mode 100644 src/equicordplugins/whosWatching/styles.css 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/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 From dd26c5ed0fb360e33b5aacc3c7ecd271a1496ecf Mon Sep 17 00:00:00 2001 From: Key Date: Tue, 10 Mar 2026 22:04:35 +0000 Subject: [PATCH 07/12] VoiceRejoin: fix stuff (#900) * voiceRejoin: fix potential race condition * fix: localstorage --- src/equicordplugins/voiceRejoin/index.tsx | 125 +++++++++++++--------- 1 file changed, 73 insertions(+), 52 deletions(-) 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); }, }, From 6ccd7e34820b97ac44cacb214684490847374c22 Mon Sep 17 00:00:00 2001 From: creations Date: Tue, 10 Mar 2026 18:08:32 -0400 Subject: [PATCH 08/12] equicloud v2 (#902) * equicloud v2 * make sure v1 works too ( forgot about vencloud ) * lint --- src/api/SettingsSync/cloudSetup.tsx | 13 +- src/api/SettingsSync/cloudSync.ts | 595 +++++++++++++++++++++------- src/api/SettingsSync/types.ts | 47 +++ 3 files changed, 497 insertions(+), 158 deletions(-) create mode 100644 src/api/SettingsSync/types.ts 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[]; +} From b815892bdc0b612d177a116c0b38c25ce2c4da0d Mon Sep 17 00:00:00 2001 From: Vendicated Date: Tue, 10 Mar 2026 23:27:46 +0100 Subject: [PATCH 09/12] fix Settings crash --- src/plugins/_core/settings.tsx | 40 +++++++++++----------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/src/plugins/_core/settings.tsx b/src/plugins/_core/settings.tsx index c1781edfc..01a57f789 100644 --- a/src/plugins/_core/settings.tsx +++ b/src/plugins/_core/settings.tsx @@ -32,9 +32,10 @@ let LayoutTypes = { SECTION: 1, SIDEBAR_ITEM: 2, PANEL: 3, - PANE: 4 + CATEGORY: 5, + CUSTOM: 19, }; -waitFor(["SECTION", "SIDEBAR_ITEM", "PANEL"], v => LayoutTypes = v); +waitFor(["SECTION", "SIDEBAR_ITEM", "PANEL", "CUSTOM"], v => LayoutTypes = v); const FallbackSectionTypes = { HEADER: "HEADER", @@ -157,36 +158,21 @@ export default definePlugin({ key: key + "_panel", type: LayoutTypes.PANEL, useTitle: () => panelTitle, + buildLayout: () => [{ + type: LayoutTypes.CATEGORY, + key: key + "_category", + buildLayout: () => [{ + type: LayoutTypes.CUSTOM, + key: key + "_custom", + Component: Component, + useSearchTerms: () => [title] + }] + }] }; - const render = { - // FIXME - StronglyDiscouragedCustomComponent: () => , - render: () => , - }; - - // FIXME - if (LayoutTypes.PANE) { - panel.buildLayout = () => [ - { - key: key + "_pane", - type: LayoutTypes.PANE, - useTitle: () => panelTitle, - buildLayout: () => [], - ...render - } - ]; - } else { - Object.assign(panel, render); - panel.buildLayout = () => []; - } - return ({ key, type: LayoutTypes.SIDEBAR_ITEM, - // FIXME - legacySearchKey: title.toUpperCase(), - getLegacySearchKey: () => title.toUpperCase(), useTitle: () => title, icon: () => , buildLayout: () => [panel] From fa96ff3b40ad2117f58aa75683f63c2ca475603d Mon Sep 17 00:00:00 2001 From: Vendicated Date: Tue, 10 Mar 2026 23:30:26 +0100 Subject: [PATCH 10/12] 1.14.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aa0e28f36..0061e54ae 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vencord", "private": "true", - "version": "1.14.4", + "version": "1.14.5", "description": "The cutest Discord client mod", "homepage": "https://github.com/Vendicated/Vencord#readme", "bugs": { From 0f60e505461ec4ac8d3c2f6241342c86b0aa1f5e Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:01:08 -0400 Subject: [PATCH 11/12] NoDefaultHangStatus: Migration --- src/api/Settings.ts | 13 +++++++++++++ src/equicordplugins/equicordHelper/index.tsx | 15 --------------- src/plugins/noDefaultHangStatus/index.ts | 2 ++ 3 files changed, 15 insertions(+), 15 deletions(-) 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/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/plugins/noDefaultHangStatus/index.ts b/src/plugins/noDefaultHangStatus/index.ts index 2245c9101..1301f0d25 100644 --- a/src/plugins/noDefaultHangStatus/index.ts +++ b/src/plugins/noDefaultHangStatus/index.ts @@ -4,9 +4,11 @@ * 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", From 4c801820e49d2baf720c24f8361c706f757c27c5 Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:10:16 -0400 Subject: [PATCH 12/12] BetterCommands: Update CMD Description --- src/equicordplugins/betterCommands/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",