ServerDrawer: feed the Quest Dock a placeholder quest when none is active
This commit is contained in:
parent
9021135dec
commit
1b01c16981
4 changed files with 149 additions and 91 deletions
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
137
plugins/server-drawer/src/patches/fakeQuestDock.ts
Normal file
137
plugins/server-drawer/src/patches/fakeQuestDock.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 ?? "",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue