diff --git a/src/lib/microcosm/constellation.ts b/src/lib/microcosm/constellation.ts index db82b23..2c38feb 100644 --- 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 @@ async function get( } } 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 index bfff34f..ccdf1aa 100644 --- a/src/lib/microcosm/hydrate.ts +++ b/src/lib/microcosm/hydrate.ts @@ -611,9 +611,22 @@ export async function hydratePost( 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] = await Promise.all([ diff --git a/src/lib/microcosm/slingshot.ts b/src/lib/microcosm/slingshot.ts index 8ff15da..e64c3fd 100644 --- 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 @@ async function get( 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 @@ export async function hydrateFeedSkeleton( headers: { 'Content-Type': 'application/json', Accept: 'application/json', - 'User-Agent': MICROCOSM_USER_AGENT, }, body: JSON.stringify(payload), signal, @@ -286,7 +286,7 @@ export async function listRecords( 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 @@ export async function countRecords( 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 index a87f96a..ff3de96 100644 --- a/src/lib/microcosm/thread.ts +++ b/src/lib/microcosm/thread.ts @@ -23,6 +23,7 @@ import { 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 @@ async function collectAncestors( 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 @@ async function fetchReplies( 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) @@ -231,6 +236,16 @@ export async function buildPostThread( 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( anchorUri,