diff --git a/plugins/server-drawer/src/index.ts b/plugins/server-drawer/src/index.ts index f9c1840..7605419 100644 --- a/plugins/server-drawer/src/index.ts +++ b/plugins/server-drawer/src/index.ts @@ -1,6 +1,6 @@ import { logger } from "@vendetta"; import { storage } from "@vendetta/plugin"; -import { patchQuestDockContext } from "./patches/questDockContext"; +import { patchFakeQuestDock } from "./patches/fakeQuestDock"; import { patchExpanded, patchEmpty } from "./patches/contentPatch"; import { patchHideGuildsBar } from "./patches/hideGuildsBar"; import { patchCreateElement } from "./lib/createElementIntercept"; @@ -32,7 +32,7 @@ function tryPatch(name: string, fn: () => boolean): boolean { // missing rather than just not loaded yet). function applyAll() { const patchers: Record boolean> = { - questDockContext: () => patchQuestDockContext(cleanups), + ...(storage.fakeQuestDock ? { fakeQuestDock: () => patchFakeQuestDock(cleanups) } : {}), expanded: () => patchExpanded(cleanups), collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups), enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups), @@ -107,6 +107,7 @@ export default { storage.showUnreadBadges ??= true; storage.autoCollapseFolders ??= false; storage.hideFolderIcons ??= false; + storage.fakeQuestDock ??= true; applyAll(); }, diff --git a/plugins/server-drawer/src/patches/fakeQuestDock.ts b/plugins/server-drawer/src/patches/fakeQuestDock.ts new file mode 100644 index 0000000..f2672fa --- /dev/null +++ b/plugins/server-drawer/src/patches/fakeQuestDock.ts @@ -0,0 +1,137 @@ +import { rawFindByFunctionProps, rawFindByStoreName } from "../lib/rawFind"; + +const TAG = "[ServerDrawer]"; + +// Every field here was checked field-by-field against a real, currently-live quest object (Key +// Inspector's Eval console, direct QuestStore.getQuest() call) until this matched its shape +// exactly. That mattered more than it sounds: an empty task map renders nothing (Discord silently +// treats a quest with zero tasks as invalid), and a null asset string crashes +// useQuestGameLogotypeAssetUrl with "Cannot read property 'startsWith' of null" instead of just +// showing no logo - empty string doesn't. The task's `applications` entry only feeds that same +// logotype lookup, and getQuestAsset below is guarded, so it never needs to point at a real +// Discord application - confirmed live with a fake id that the dock still renders fine, just +// without a game logo. +function buildFallbackQuest(userId: string | undefined) { + const now = new Date(); + const nowIso = now.toISOString(); + const farFuture = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 365 * 5).toISOString(); + + return { + id: "server-drawer", + preview: false, + config: { + id: "server-drawer", + configVersion: 2, + startsAt: nowIso, + expiresAt: farFuture, + features: [], + assets: { + hero: "", heroVideo: "", questBarHero: "", questBarHeroBlurhash: "", + questBarHeroVideo: "", gameTile: "", logotype: "", + gameTileLight: "", gameTileDark: "", logotypeLight: "", logotypeDark: "", + }, + colors: { primary: "#5865F2", secondary: "#000000" }, + messages: { + questName: "Server Drawer", + gameTitle: "All quests complete!", + gamePublisher: "ServerDrawer plugin", + }, + taskConfigV2: { + tasks: { + ACHIEVEMENT_IN_ACTIVITY: { + type: "ACHIEVEMENT_IN_ACTIVITY", + target: 1, + eventName: "progress", + messages: { + taskTitle: "Server Drawer is active", + taskDescription: "You've got no active Discord quests right now - enjoy the drawer!", + }, + applications: [{ id: "0" }], + }, + }, + joinOperator: "or", + }, + rewardsConfig: { + assignmentMethod: 1, + rewards: [{ + type: 4, + skuId: "0", + messages: { + redemptionInstructionsByPlatform: {}, + name: "Nothing - just vibes", + nameWithArticle: "Nothing - just vibes", + }, + orbQuantity: 0, + premiumOrbQuantity: 0, + }], + rewardsExpireAt: farFuture, + platforms: [0], + }, + cosponsorMetadata: undefined, + sharePolicy: "shareable_everywhere", + ctaConfig: { android: undefined, ios: undefined, link: "", buttonLabel: "", subtitle: "" }, + }, + userStatus: { + userId: userId ?? null, + questId: "server-drawer", + enrolledAt: nowIso, + completedAt: null, + claimedAt: null, + claimedTier: null, + orbQuantityClaimed: 0, + lastStreamHeartbeatAt: null, + streamProgressSeconds: 0, + dismissedQuestContent: 0, + progress: {}, + }, + targetedContent: [], + trafficMetadataSealed: "", + }; +} + +// Replaces questDockContext.ts's Context-value force entirely - confirmed live that context's +// value shape is no longer {isRendered, isVisibleToUser, quest} at all (Discord restructured it +// into pure scroll/animation coordination: {lastScrollEventSourceId, restingQuestDockMode, +// setRestingQuestDockMode, questDockOffset}), so that predicate could never match again. The real +// gate turned out to be much simpler than the hour spent chasing Flux stores and Reanimated shared +// values suggested: QuestDockWithQuestContext and everything under it (including +// QuestDockContentExpanded, which contentPatch.tsx has been correctly waiting on this whole time) +// only mounts at all once useMobileQuestDock returns a truthy quest. With no active quest, it +// returns null/undefined and Discord's own dock renders nothing - not a bug, just nothing to show. +// Feeding it a real-shaped placeholder here is what lets contentPatch.tsx's swap ever get a chance +// to fire in the first place. +export function patchFakeQuestDock(cleanups: (() => void)[]): boolean { + const mod = rawFindByFunctionProps("useMobileQuestDock"); + if (!mod) { + console.log(TAG, "WARN: useMobileQuestDock not found yet (will retry)"); + return false; + } + + const origUseMobileQuestDock = mod.useMobileQuestDock; + let cachedUserId: string | undefined; + mod.useMobileQuestDock = function (this: unknown, ...args: any[]) { + const real = origUseMobileQuestDock.apply(this, args); + if (real) return real; + if (cachedUserId === undefined) { + cachedUserId = rawFindByStoreName("UserStore")?.getCurrentUser?.()?.id; + } + return buildFallbackQuest(cachedUserId); + }; + cleanups.push(() => { mod.useMobileQuestDock = origUseMobileQuestDock; }); + + const assetMod = rawFindByFunctionProps("getQuestAsset"); + if (assetMod) { + const origGetQuestAsset = assetMod.getQuestAsset; + assetMod.getQuestAsset = function (this: unknown, ...args: any[]) { + try { + return origGetQuestAsset.apply(this, args); + } catch { + return { url: null, isAnimated: false }; + } + }; + cleanups.push(() => { assetMod.getQuestAsset = origGetQuestAsset; }); + } + + console.log(TAG, "PATCH: fake Quest Dock fallback active"); + return true; +} diff --git a/plugins/server-drawer/src/patches/questDockContext.ts b/plugins/server-drawer/src/patches/questDockContext.ts deleted file mode 100644 index 4bedbee..0000000 --- a/plugins/server-drawer/src/patches/questDockContext.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { registerPropsTransform } from "../lib/createElementIntercept"; - -const TAG = "[ServerDrawer]"; - -/** - * Replaces every hook-mutation patch that used to live in questDockRender.ts, questDockBase.ts, - * questDockVisible.ts, mobileQuestDock.ts, and getQuestAsset.ts. All five mutated a hook or utility - * function's exported property (`someModule.useSomeHook = wrapper`) - confirmed live (Key - * Inspector fiber capture) that this never reached the real call site at all: Discord's Quest Dock - * hooks call each other via an internal, same-chunk closure reference, not through the exports - * object property these patches mutated. The proof: with every one of those patches active, the - * live Context value at QuestDockExternalCoordinationContextProviderInner still carried a real - * Discord quest snowflake ID, never this plugin's fake "server-drawer" quest. - * - * QuestDockExternalCoordinationContextProviderInner assembles exactly the state we need - * (isRendered, isVisibleToUser, quest) from those hooks and hands it down through a Context - * Provider - and a Provider element is always created through the one shared createElement/jsx - * runtime, the same interception point GuildsBar's patch already relies on successfully. So instead - * of trying to fake the hooks' return values, this rewrites the Provider's `value` prop directly, - * which reaches every consumer exactly as if the hooks themselves had returned it. - */ -export function patchQuestDockContext(cleanups: (() => void)[]): boolean { - registerPropsTransform( - (props) => { - const v = props?.value; - return !!(v && typeof v === "object" && - "isRendered" in v && "isVisibleToUser" in v && "quest" in v); - }, - (props) => { - const v = props.value; - const quest = v.quest ?? fallbackQuest(); - return { - ...props, - value: { - ...v, - isRendered: true, - isVisibleToUser: true, - quest: sanitizeQuestAssets(quest), - }, - }; - } - ); - cleanups.push(() => { - // No per-call unregister needed - createElementIntercept clears all transforms/intercepts - // together when its own patch unwinds, part of this plugin's normal unload/restart cleanup. - }); - console.log(TAG, "PATCH: watching for the Quest Dock coordination context"); - return true; -} - -function fallbackQuest(): any { - return { - id: "server-drawer", - config: { - quest_content_type: 0, - assets: { questBarHeroVideo: "", questBarHero: "" }, - features: [], - }, - userStatus: { enrolledAt: "2099-01-01", claimedAt: null }, - benefits: { rewards: [] }, - guildId: "0", - tasks: [], - }; -} - -// Same fix as the old mobileQuestDock.ts fallback, but now also applied to REAL quest data - a -// real, currently-active quest that has no hero media at all sets these fields to null too -// (confirmed: the crash reproduced with this plugin's own hook patches active, at a point where -// this quest's real ID was flowing through unmodified - meaning the null came from Discord's own -// quest, not this plugin's fallback). Empty string is falsy for any truthiness check upstream, same -// as null, but is a valid string .startsWith() can't throw on. -function sanitizeQuestAssets(quest: any): any { - const assets = quest?.config?.assets; - if (!assets) return quest; - const hero = assets.questBarHero; - const heroVideo = assets.questBarHeroVideo; - if (hero != null && heroVideo != null) return quest; - return { - ...quest, - config: { - ...quest.config, - assets: { - ...assets, - questBarHero: hero ?? "", - questBarHeroVideo: heroVideo ?? "", - }, - }, - }; -} diff --git a/plugins/server-drawer/src/ui/Settings.tsx b/plugins/server-drawer/src/ui/Settings.tsx index f168ac7..2d725bd 100644 --- a/plugins/server-drawer/src/ui/Settings.tsx +++ b/plugins/server-drawer/src/ui/Settings.tsx @@ -46,6 +46,15 @@ export default function Settings() { value={!!storage.hideDmTile} onValueChange={(v: boolean) => { storage.hideDmTile = v; }} /> + { + storage.fakeQuestDock = v; + restart(); + }} + />