|
| 1 | +const SLINGSHOT_URL = 'https://slingshot.microcosm.blue' |
| 2 | +const CONSTELLATION_URL = 'https://constellation.microcosm.blue' |
| 3 | + |
| 4 | +export interface MicrocosmRecord { |
| 5 | + uri: string |
| 6 | + cid: string |
| 7 | + value: any |
| 8 | +} |
| 9 | + |
| 10 | +export interface ConstellationCounts { |
| 11 | + likeCount: number |
| 12 | + repostCount: number |
| 13 | + replyCount: number |
| 14 | + // might need a saves/bookmark counter |
| 15 | + // bookmarkCount: number |
| 16 | +} |
| 17 | + |
| 18 | +/** |
| 19 | + * Generic helper to fetch from Slingshot proxy |
| 20 | + */ |
| 21 | +async function fetchFromSlingshot<T>( |
| 22 | + endpoint: string, |
| 23 | + params: Record<string, string>, |
| 24 | +): Promise<T | null> { |
| 25 | + try { |
| 26 | + // URLSearchParams handles encoding automatically, no need for manual encodeURIComponent |
| 27 | + const queryString = new URLSearchParams(params).toString() |
| 28 | + |
| 29 | + const res = await fetch(`${SLINGSHOT_URL}/xrpc/${endpoint}?${queryString}`) |
| 30 | + if (!res.ok) return null |
| 31 | + return await res.json() |
| 32 | + } catch (e) { |
| 33 | + console.error(`Slingshot fetch failed (${endpoint}):`, e) |
| 34 | + return null |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +/** |
| 39 | + * Fetch a record directly from PDS via Slingshot proxy |
| 40 | + * |
| 41 | + * Uses the ergonomic `com.bad-example.repo.getUriRecord` endpoint which accepts |
| 42 | + * a full at-uri instead of separate repo/collection/rkey parameters. |
| 43 | + * |
| 44 | + * @see https://slingshot.microcosm.blue/ for full API documentation |
| 45 | + */ |
| 46 | +export async function fetchRecordViaSlingshot( |
| 47 | + atUri: string, |
| 48 | +): Promise<MicrocosmRecord | null> { |
| 49 | + return fetchFromSlingshot<MicrocosmRecord>( |
| 50 | + 'com.bad-example.repo.getUriRecord', |
| 51 | + {at_uri: atUri}, |
| 52 | + ) |
| 53 | +} |
| 54 | + |
| 55 | +/** |
| 56 | + * Resolve identity (DID/handle) via Slingshot |
| 57 | + * |
| 58 | + * Uses `com.bad-example.identity.resolveMiniDoc` which returns a compact identity |
| 59 | + * document with bi-directionally verified DID/handle and the user's PDS URL. |
| 60 | + * This is more convenient than the standard resolveHandle + describeRepo flow. |
| 61 | + * |
| 62 | + * @returns {did, handle, pds} or null if resolution fails |
| 63 | + * @see https://slingshot.microcosm.blue/ for full API documentation |
| 64 | + */ |
| 65 | +export async function resolveIdentityViaSlingshot( |
| 66 | + identifier: string, |
| 67 | +): Promise<{did: string; handle: string; pds: string} | null> { |
| 68 | + return fetchFromSlingshot('com.bad-example.identity.resolveMiniDoc', { |
| 69 | + identifier, |
| 70 | + }) |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Fetch engagement counts from Constellation backlink indexer |
| 75 | + * |
| 76 | + * Constellation indexes all social interactions (likes, reposts, replies) as backlinks |
| 77 | + * to posts. This provides real engagement counts even for AppView-suspended users. |
| 78 | + * |
| 79 | + * Note: Constellation only provides per-post engagement counts, not profile-level |
| 80 | + * aggregates (total followers, following, posts). |
| 81 | + * |
| 82 | + * @see https://constellation.microcosm.blue/ for more about Constellation |
| 83 | + */ |
| 84 | +export async function fetchConstellationCounts( |
| 85 | + atUri: string, |
| 86 | +): Promise<ConstellationCounts> { |
| 87 | + try { |
| 88 | + const res = await fetch( |
| 89 | + `${CONSTELLATION_URL}/links/all?target=${encodeURIComponent(atUri)}`, |
| 90 | + ) |
| 91 | + if (!res.ok) throw new Error('Constellation fetch failed') |
| 92 | + |
| 93 | + const data = await res.json() |
| 94 | + const links = data.links || {} |
| 95 | + |
| 96 | + return { |
| 97 | + likeCount: |
| 98 | + links?.['app.bsky.feed.like']?.['.subject.uri']?.distinct_dids ?? 0, |
| 99 | + repostCount: |
| 100 | + links?.['app.bsky.feed.repost']?.['.subject.uri']?.distinct_dids ?? 0, |
| 101 | + replyCount: |
| 102 | + links?.['app.bsky.feed.post']?.['.reply.parent.uri']?.records ?? 0, |
| 103 | + } |
| 104 | + } catch (e) { |
| 105 | + console.error('Constellation fetch failed:', e) |
| 106 | + return {likeCount: 0, repostCount: 0, replyCount: 0} |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +/** |
| 111 | + * Detect if error is AppView-related (suspended user, not found, etc.) |
| 112 | + */ |
| 113 | +export function isAppViewError(error: any): boolean { |
| 114 | + if (!error) return false |
| 115 | + |
| 116 | + // Check HTTP status codes |
| 117 | + if (error.status === 400 || error.status === 404) return true |
| 118 | + |
| 119 | + // Check error messages |
| 120 | + // TODO: see if there is an easy way to source error messages from the appview |
| 121 | + const msg = error.message?.toLowerCase() || '' |
| 122 | + if (msg.includes('not found')) return true |
| 123 | + if (msg.includes('suspended')) return true |
| 124 | + if (msg.includes('could not locate')) return true |
| 125 | + |
| 126 | + return false |
| 127 | +} |
| 128 | + |
| 129 | +/** |
| 130 | + * Build synthetic ProfileViewDetailed from PDS data |
| 131 | + * |
| 132 | + * Fetches the user's profile record from their PDS via Slingshot and constructs |
| 133 | + * an AppView-compatible ProfileViewDetailed object. |
| 134 | + * |
| 135 | + * LIMITATION: Profile-level aggregate counts (followers, following, posts) are not |
| 136 | + * available from Slingshot or Constellation and are set to undefined. These would |
| 137 | + * require AppView-style indexing infrastructure. |
| 138 | + */ |
| 139 | +export async function buildSyntheticProfileView( |
| 140 | + did: string, |
| 141 | + handle: string, |
| 142 | +): Promise<any> { |
| 143 | + const profileUri = `at://${did}/app.bsky.actor.profile/self` |
| 144 | + const record = await fetchRecordViaSlingshot(profileUri) |
| 145 | + |
| 146 | + return { |
| 147 | + $type: 'app.bsky.actor.defs#profileViewDetailed', |
| 148 | + did, |
| 149 | + handle, |
| 150 | + displayName: record?.value?.displayName || handle, |
| 151 | + description: record?.value?.description || '', |
| 152 | + avatar: record?.value?.avatar |
| 153 | + ? `https://cdn.bsky.app/img/avatar/plain/${did}/${record.value.avatar.ref.$link}@jpeg` |
| 154 | + : undefined, |
| 155 | + banner: record?.value?.banner |
| 156 | + ? `https://cdn.bsky.app/img/banner/plain/${did}/${record.value.banner.ref.$link}@jpeg` |
| 157 | + : undefined, |
| 158 | + followersCount: undefined, // Not available from PDS or Constellation |
| 159 | + followsCount: undefined, // Not available from PDS or Constellation |
| 160 | + postsCount: undefined, // Not available from PDS or Constellation |
| 161 | + indexedAt: new Date().toISOString(), |
| 162 | + viewer: {}, |
| 163 | + labels: [], |
| 164 | + __fallbackMode: true, // Mark as fallback data |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +/** |
| 169 | + * Build synthetic PostView from PDS + Constellation data |
| 170 | + */ |
| 171 | +export async function buildSyntheticPostView( |
| 172 | + atUri: string, |
| 173 | + authorDid: string, |
| 174 | + authorHandle: string, |
| 175 | +): Promise<any> { |
| 176 | + const record = await fetchRecordViaSlingshot(atUri) |
| 177 | + if (!record) return null |
| 178 | + |
| 179 | + const counts = await fetchConstellationCounts(atUri) |
| 180 | + const profileView = await buildSyntheticProfileView(authorDid, authorHandle) |
| 181 | + |
| 182 | + return { |
| 183 | + $type: 'app.bsky.feed.defs#postView', |
| 184 | + uri: atUri, |
| 185 | + cid: record.cid, |
| 186 | + author: profileView, |
| 187 | + record: record.value, |
| 188 | + indexedAt: new Date().toISOString(), |
| 189 | + likeCount: counts.likeCount, |
| 190 | + repostCount: counts.repostCount, |
| 191 | + replyCount: counts.replyCount, |
| 192 | + viewer: {}, |
| 193 | + labels: [], |
| 194 | + __fallbackMode: true, // Mark as fallback data |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +/** |
| 199 | + * Build synthetic feed page from PDS data |
| 200 | + * This is used for infinite queries that need paginated results |
| 201 | + * |
| 202 | + * IMPORTANT: This function bypasses Slingshot and fetches directly from the user's PDS |
| 203 | + * because Slingshot does not support the `com.atproto.repo.listRecords` endpoint needed |
| 204 | + * for bulk record fetching. |
| 205 | + * |
| 206 | + * Trade-off: No caching benefit from Slingshot, but we can still provide author feed |
| 207 | + * functionality for AppView-suspended users. |
| 208 | + * |
| 209 | + * Each post in the feed will trigger: |
| 210 | + * - 1 record fetch via Slingshot (for the full post data, cached) |
| 211 | + * - 1 Constellation request (for engagement counts) |
| 212 | + * - Profile fetch (cached after first request) |
| 213 | + */ |
| 214 | +export async function buildSyntheticFeedPage( |
| 215 | + did: string, |
| 216 | + pdsUrl: string, |
| 217 | + cursor?: string, |
| 218 | +): Promise<any> { |
| 219 | + try { |
| 220 | + const limit = 25 |
| 221 | + const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' |
| 222 | + |
| 223 | + // Fetch posts directly from PDS using com.atproto.repo.listRecords |
| 224 | + // NOTE: This bypasses Slingshot because listRecords is not available there |
| 225 | + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}` |
| 226 | + const res = await fetch(url) |
| 227 | + |
| 228 | + if (!res.ok) { |
| 229 | + console.error( |
| 230 | + '[Fallback] Failed to fetch author feed from PDS:', |
| 231 | + res.statusText, |
| 232 | + ) |
| 233 | + return null |
| 234 | + } |
| 235 | + |
| 236 | + const data = await res.json() |
| 237 | + |
| 238 | + // Build FeedViewPost array from records |
| 239 | + const feed = await Promise.all( |
| 240 | + data.records.map(async (record: any) => { |
| 241 | + const postView = await buildSyntheticPostView( |
| 242 | + record.uri, |
| 243 | + did, |
| 244 | + '', // Handle will be resolved in buildSyntheticPostView |
| 245 | + ) |
| 246 | + |
| 247 | + if (!postView) return null |
| 248 | + |
| 249 | + // Wrap in FeedViewPost format |
| 250 | + return { |
| 251 | + $type: 'app.bsky.feed.defs#feedViewPost', |
| 252 | + post: postView, |
| 253 | + feedContext: undefined, |
| 254 | + } |
| 255 | + }), |
| 256 | + ) |
| 257 | + |
| 258 | + // Filter out null results |
| 259 | + const validFeed = feed.filter(item => item !== null) |
| 260 | + |
| 261 | + return { |
| 262 | + feed: validFeed, |
| 263 | + cursor: data.cursor, |
| 264 | + __fallbackMode: true, // Mark as fallback data |
| 265 | + } |
| 266 | + } catch (e) { |
| 267 | + console.error('[Fallback] Failed to build synthetic feed page:', e) |
| 268 | + return null |
| 269 | + } |
| 270 | +} |
0 commit comments