diff --git a/scripts/patch-ap-unify-dm-visibility.mjs b/scripts/patch-ap-unify-dm-visibility.mjs new file mode 100644 index 00000000..82155397 --- /dev/null +++ b/scripts/patch-ap-unify-dm-visibility.mjs @@ -0,0 +1,159 @@ +/** + * Patch: Unify isDirectMessage() and computeVisibility() in inbox-handlers.js. + * + * Problem: Two overlapping functions for DM detection. computeVisibility() + * returns "private" for DMs because it lacks actor context. isDirectMessage() + * correctly detects DMs but is separate. + * + * Fix: Extend computeVisibility() to accept optional {ourActorUrl, followersUrl}. + * When provided and DM conditions are met, return "direct" instead of "private". + * Replace the standalone isDirectMessage() guard with computeVisibility() check. + * Update all call sites inside handleCreate() to pass actor context. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const AP_BASE = "@rmdes/indiekit-endpoint-activitypub"; +const AP_ROOTS = [ + `node_modules/${AP_BASE}`, + `node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`, +]; + +function apPath(rel) { + return AP_ROOTS.map(r => `${r}/${rel}`); +} + +async function fileExists(p) { + try { await access(p); return true; } catch { return false; } +} + +const SCRIPT = "patch-ap-unify-dm-visibility"; +const MARKER = "// [patch] unify-dm-visibility"; +const CANDIDATES = apPath("lib/inbox-handlers.js"); +let total = 0; + +// ── Patch 1: Replace computeVisibility to accept actor context ────────────── + +const OLD_COMPUTE_VISIBILITY = `function computeVisibility(object) { + const to = new Set((object.toIds || []).map((u) => u.href)); + const cc = new Set((object.ccIds || []).map((u) => u.href)); + + if (to.has(PUBLIC)) return "public"; + if (cc.has(PUBLIC)) return "unlisted"; + // Without knowing the remote actor's followers URL, we can't distinguish + // "private" (followers-only) from "direct". Both are non-public. + if (to.size > 0 || cc.size > 0) return "private"; + return "direct"; +}`; + +const NEW_COMPUTE_VISIBILITY = `function computeVisibility(object, actorContext) { ${MARKER} + const to = new Set((object.toIds || []).map((u) => u.href)); ${MARKER} + const cc = new Set((object.ccIds || []).map((u) => u.href)); ${MARKER} + ${MARKER} + if (to.has(PUBLIC)) return "public"; ${MARKER} + if (cc.has(PUBLIC)) return "unlisted"; ${MARKER} + ${MARKER} + // When actor context is available, use isDirectMessage logic to distinguish ${MARKER} + // "direct" (addressed to specific actors only) from "private" (followers-only). ${MARKER} + if (actorContext?.ourActorUrl) { ${MARKER} + const allAddressed = [ ${MARKER} + ...to, ...cc, ${MARKER} + ...(object.btoIds || []).map((u) => u.href), ${MARKER} + ...(object.bccIds || []).map((u) => u.href), ${MARKER} + ]; ${MARKER} + const hasPublic = allAddressed.some((u) => u === PUBLIC || u === "as:Public"); ${MARKER} + const hasFollowers = actorContext.followersUrl && allAddressed.includes(actorContext.followersUrl); ${MARKER} + if (!hasPublic && !hasFollowers && allAddressed.includes(actorContext.ourActorUrl)) { ${MARKER} + return "direct"; ${MARKER} + } ${MARKER} + } ${MARKER} + ${MARKER} + // Without actor context, can't distinguish "private" from "direct". ${MARKER} + if (to.size > 0 || cc.size > 0) return "private"; ${MARKER} + return "direct"; ${MARKER} +}`; + +// ── Patch 2: Replace isDirectMessage() guard with computeVisibility() check ─ + +const OLD_DM_GUARD = ` if (isDirectMessage(object, ourActorUrl, followersUrl)) {`; + +const NEW_DM_GUARD = ` if (computeVisibility(object, { ourActorUrl, followersUrl }) === "direct") { ${MARKER}`; + +// ── Patch 3: Update computeVisibility call sites inside handleCreate ──────── +// These are after the DM guard, so the object is NOT a DM. But passing context +// lets computeVisibility correctly return "private" vs "direct" for edge cases. +// The 4 call sites all follow the pattern: timelineItem.visibility = computeVisibility(object); +// We need to add actor context only to those inside handleCreate (lines 812, 842, 866) +// and handleAnnounce (line 586). The fetchReplyChain call (line 212) has no context. + +// For handleCreate calls — ourActorUrl and followersUrl are in scope +const OLD_VISIBILITY_CALL = `timelineItem.visibility = computeVisibility(object);`; +const NEW_VISIBILITY_CALL_WITH_CTX = `timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl }); ${MARKER}`; + +for (const f of CANDIDATES) { + if (!(await fileExists(f))) continue; + const src = await readFile(f, "utf8"); + if (src.includes(MARKER)) { + console.log(`[postinstall] ${SCRIPT}: already applied in ${f}`); + break; + } + + let updated = src; + let changed = false; + + // Patch 1: Replace computeVisibility function + if (updated.includes(OLD_COMPUTE_VISIBILITY)) { + updated = updated.replace(OLD_COMPUTE_VISIBILITY, NEW_COMPUTE_VISIBILITY); + changed = true; + console.log(`[postinstall] ${SCRIPT}: replaced computeVisibility()`); + } else { + console.warn(`[postinstall] ${SCRIPT}: computeVisibility() anchor not found in ${f}`); + continue; + } + + // Patch 2: Replace isDirectMessage guard + if (updated.includes(OLD_DM_GUARD)) { + updated = updated.replace(OLD_DM_GUARD, NEW_DM_GUARD); + console.log(`[postinstall] ${SCRIPT}: replaced isDirectMessage() guard`); + } else { + console.warn(`[postinstall] ${SCRIPT}: isDirectMessage() guard not found in ${f}`); + } + + // Patch 3: Update computeVisibility call sites with actor context + // We need to be selective — only replace calls inside handleCreate/handleAnnounce + // where ourActorUrl and followersUrl are in scope. + // The fetchReplyChain call (line ~212) does NOT have these in scope — leave it. + // + // Strategy: split file at the handleCreate function boundary and only replace + // within that section. handleAnnounce also has the vars via ctx but through a + // different scope — safer to leave those unchanged since boosts aren't DMs. + + // Find the handleCreate function start + const handleCreateStart = updated.indexOf("export async function handleCreate("); + if (handleCreateStart > -1) { + const before = updated.substring(0, handleCreateStart); + const after = updated.substring(handleCreateStart); + + // Replace all computeVisibility(object) calls within handleCreate + const afterPatched = after.replaceAll(OLD_VISIBILITY_CALL, NEW_VISIBILITY_CALL_WITH_CTX); + if (afterPatched !== after) { + updated = before + afterPatched; + const count = (afterPatched.match(/unify-dm-visibility/g) || []).length - + (after.match(/unify-dm-visibility/g) || []).length; + console.log(`[postinstall] ${SCRIPT}: updated ${count} computeVisibility() call site(s) in handleCreate`); + } + } + + if (changed && updated !== src) { + await writeFile(f, updated, "utf8"); + console.log(`[postinstall] ${SCRIPT}: applied to ${f}`); + total++; + break; + } +} + +if (total === 0) { + console.log(`[postinstall] ${SCRIPT}: no target file found or no changes needed`); +} + +console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);