refactor: unify isDirectMessage and computeVisibility into single function
Deploy Indiekit Server / deploy (push) Successful in 1m18s

computeVisibility() now accepts optional actor context ({ourActorUrl,
followersUrl}) to correctly return "direct" instead of "private" for DMs.
Replaces the separate isDirectMessage() guard with a computeVisibility
check, eliminating the dual-function inconsistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-04-25 14:45:18 +02:00
parent 9fe04a64d9
commit 61a03a164b
+159
View File
@@ -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)`);