From a3e2ffd1cea2985ab117789e1dbb4055af3d4bef Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 12 Jun 2026 19:25:49 +0000 Subject: [PATCH] Fix thread stats/replies: resolve handle-form uris to DID for Constellation Root cause: Constellation indexes posts by their DID-form at-uri, but when a post is opened the thread receives a handle-form anchor uri (at://handle/...) from the route. Every Constellation backlink lookup against the handle form returns 0 (verified live: handle -> total 0, DID -> total 14), so the anchor had zero counts and reply backlinks found nothing -> no stats, no comments. Feeds were unaffected because their uris come from records already in DID form. Fixes: - hydratePost and buildPostThread now resolve a handle authority to a DID (via MiniDoc) before any Constellation/record lookup. - Thread replies and ancestors hydrate in full (not lite) so they show their own like/reply/repost counts and viewer state, like the AppView. Reply volume is bounded by branchingFactor. - Dropped the User-Agent header from the Constellation/Slingshot fetch clients (browsers forbid setting it; harmless cleanup). --- src/lib/microcosm/constellation.ts | 6 ++++-- src/lib/microcosm/hydrate.ts | 15 ++++++++++++++- src/lib/microcosm/slingshot.ts | 10 +++++----- src/lib/microcosm/thread.ts | 27 +++++++++++++++++++++------ 4 file(s) changed, 44 insertion(s)(+), 14 deletion(s)(-) diff --git a/src/lib/microcosm/constellation.ts b/src/lib/microcosm/constellation.ts --- a/src/lib/microcosm/constellation.ts +++ b/src/lib/microcosm/constellation.ts @@ -10,7 +10,7 @@ * Response shapes here were verified against the live public instance. * @see constellation.openapi.yaml at the repo root */ -import {CONSTELLATION_URL, MICROCOSM_USER_AGENT} from '#/env' +import {CONSTELLATION_URL} from '#/env' /** A target being linked to: a DID, an at-uri, or a plain URL. */ export type Target = string @@ -121,9 +121,11 @@ } } const res = await fetch(url.toString(), { + // NOTE: do not set a User-Agent header. Browsers forbid setting it on fetch + // and including a forbidden header can break the request; the server doesn't + // require it. Native gets the default UA. headers: { Accept: 'application/json', - 'User-Agent': MICROCOSM_USER_AGENT, }, signal, }) diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts --- a/src/lib/microcosm/hydrate.ts +++ b/src/lib/microcosm/hydrate.ts @@ -611,8 +611,21 @@ lite?: boolean }, ): Promise { - const parsed = parseAtUri(postUri) + let parsed = parseAtUri(postUri) if (!parsed) return undefined + + // Constellation indexes by DID, and record reads expect a DID authority. If + // the uri came in with a handle authority (e.g. from a route param), resolve + // it to a DID first, otherwise every Constellation backlink lookup returns 0. + if (!parsed.did.startsWith('did:')) { + const did = await resolveMiniDoc(parsed.did, signal) + .then(m => m.did) + .catch(() => undefined) + if (did) { + postUri = `at://${did}/${parsed.collection}/${parsed.rkey}` + parsed = {did, collection: parsed.collection, rkey: parsed.rkey} + } + } const lite = opts?.lite ?? false const [recordRes, author, likes, reposts, replies, quotes, viewer] = diff --git a/src/lib/microcosm/slingshot.ts b/src/lib/microcosm/slingshot.ts --- a/src/lib/microcosm/slingshot.ts +++ b/src/lib/microcosm/slingshot.ts @@ -6,7 +6,7 @@ * * @see slingshot.json at the repo root */ -import {MICROCOSM_USER_AGENT, SLINGSHOT_URL} from '#/env' +import {SLINGSHOT_URL} from '#/env' /** A record as returned by getRecord — `value` is the raw record JSON. */ export type FoundRecord = { @@ -79,9 +79,10 @@ if (value !== undefined) url.searchParams.set(key, value) } const res = await fetch(url.toString(), { + // No User-Agent: browsers forbid setting it on fetch and a forbidden header + // can break the request; the server doesn't require it. headers: { Accept: 'application/json', - 'User-Agent': MICROCOSM_USER_AGENT, }, signal, }) @@ -147,7 +148,6 @@ headers: { 'Content-Type': 'application/json', Accept: 'application/json', - 'User-Agent': MICROCOSM_USER_AGENT, }, body: JSON.stringify(payload), signal, @@ -286,7 +286,7 @@ if (args.reverse != null) url.searchParams.set('reverse', String(args.reverse)) const res = await fetch(url.toString(), { - headers: {Accept: 'application/json', 'User-Agent': MICROCOSM_USER_AGENT}, + headers: {Accept: 'application/json'}, signal, }) if (!res.ok) { @@ -321,7 +321,7 @@ url.searchParams.set('limit', '100') if (cursor) url.searchParams.set('cursor', cursor) const res = await fetch(url.toString(), { - headers: {Accept: 'application/json', 'User-Agent': MICROCOSM_USER_AGENT}, + headers: {Accept: 'application/json'}, signal, }) if (!res.ok) break diff --git a/src/lib/microcosm/thread.ts b/src/lib/microcosm/thread.ts --- a/src/lib/microcosm/thread.ts +++ b/src/lib/microcosm/thread.ts @@ -23,6 +23,7 @@ import * as constellation from '#/lib/microcosm/constellation' import {hydratePost} from '#/lib/microcosm/hydrate' +import {resolveMiniDoc} from '#/lib/microcosm/slingshot' type Sort = 'newest' | 'oldest' | 'top' | (string & {}) @@ -85,9 +86,12 @@ truncated = true break } - const parent = await hydratePost(currentParentUri, viewerDid, signal, 1, { - lite: true, - }).catch(() => undefined) + const parent = await hydratePost( + currentParentUri, + viewerDid, + signal, + 1, + ).catch(() => undefined) if (!parent) break posts.push(parent) currentParentUri = parentUriOf(parent) @@ -123,10 +127,11 @@ const uris = page.records.map(refToUri) const hydrated = ( await Promise.all( + // Full hydration (not lite): thread replies show their own like/reply/ + // repost counts and viewer state, same as the AppView. Reply volume is + // bounded by branchingFactor, so this stays affordable. uris.map(uri => - hydratePost(uri, viewerDid, signal, 1, {lite: true}).catch( - () => undefined, - ), + hydratePost(uri, viewerDid, signal, 1).catch(() => undefined), ), ) ).filter((p): p is AppBskyFeedDefs.PostView => !!p) @@ -230,6 +235,16 @@ const branchingFactor = opts.branchingFactor ?? DEFAULT_BRANCHING const sort = opts.sort ?? 'newest' const includeAbove = opts.above ?? true + + // Constellation indexes by DID. The anchor uri often arrives with a handle + // authority (route param), which would make every reply backlink lookup + // return nothing - resolve it to a DID first. + if (!parsed.did.startsWith('did:')) { + const did = await resolveMiniDoc(parsed.did, signal) + .then(m => m.did) + .catch(() => undefined) + if (did) anchorUri = `at://${did}/app.bsky.feed.post/${parsed.rkey}` + } // Hydrate the anchor with full counts/viewer state - it's the focal post. const anchor = await hydratePost( -- tangled.sh