ServerDrawer: feed the Quest Dock a placeholder quest when none is active

This commit is contained in:
Ally Founder 2026-08-10 23:02:37 -04:00
parent 9021135dec
commit 1b01c16981
4 changed files with 149 additions and 91 deletions

View file

@ -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<string, () => 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();
},

View file

@ -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<any>("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<any>("UserStore")?.getCurrentUser?.()?.id;
}
return buildFallbackQuest(cachedUserId);
};
cleanups.push(() => { mod.useMobileQuestDock = origUseMobileQuestDock; });
const assetMod = rawFindByFunctionProps<any>("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;
}

View file

@ -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 ?? "",
},
},
};
}

View file

@ -46,6 +46,15 @@ export default function Settings() {
value={!!storage.hideDmTile}
onValueChange={(v: boolean) => { storage.hideDmTile = v; }}
/>
<TableSwitchRow
label="Show drawer with no active quest"
subLabel="Discord only mounts the Quest Dock (and this drawer along with it) when a real quest is active - this feeds it a placeholder so the drawer still shows up otherwise"
value={storage.fakeQuestDock !== false}
onValueChange={(v: boolean) => {
storage.fakeQuestDock = v;
restart();
}}
/>
</TableRowGroup>
<TableRowGroup title="Folders">
<TableSwitchRow