pull out constellation and use it to rank posts

+12 -46
live-embed/src/Post.tsx
··· 1 1 import { useEffect, useState } from 'react'; 2 - 3 - const nicerSource = source => ({ 4 - 'app.bsky.feed.like:subject.uri': 'like', // likes 5 - 'app.bsky.feed.repost:subject.uri': 'repost', // reposts 6 - 'app.bsky.feed.post:embed.record.uri': 'quote', // normal quotes 7 - 'app.bsky.feed.post:embed.record.record.uri': 'quote', // RecordWithMedia quotes 8 - 'app.bsky.feed.post:reply.root.uri': 'reply', // all child replies 9 - }[source]); 2 + import { getPostStats } from './constellation'; 3 + import linkSources from './linkSources'; 10 4 11 5 export function Post({ atUri, updatedLinks }) { 12 6 const [baseStats, setBaseStats] = useState({}); 13 - 14 - const nicerStats = {}; 15 - 16 - for (const [collection, paths] of Object.entries(baseStats)) { 17 - for (const [oldStylePath, counts] of Object.entries(paths)) { 18 - const newStylePath = `${collection}:${oldStylePath.slice(1)}`; 19 - const name = nicerSource(newStylePath); 20 - if (!name) continue; // perils of constellation's (soon-deprecated) /all 21 - if (name === 'like') { 22 - nicerStats[name] = counts.distinct_dids; 23 - } else { 24 - nicerStats[name] = counts.records; 25 - } 26 - } 27 - } 7 + const liveStats = { ...baseStats }; 28 8 29 9 for (const [key, val] of Object.entries(updatedLinks)) { 30 - const name = nicerSource(key); 31 - if (!nicerStats[name]) nicerStats[name] = 0; 32 - nicerStats[name] += val; 10 + const name = linkSources[key]; 11 + if (!liveStats[name]) liveStats[name] = 0; 12 + liveStats[name] += val; 33 13 } 34 14 35 15 useEffect(() => { 36 - let cancel = false; 37 - 38 - (async () => { 39 - try { 40 - const url = new URL('/links/all', 'https://constellation.microcosm.blue'); 41 - url.searchParams.set('target', atUri); 42 - const res = await fetch(url); 43 - if (!res.ok) throw new Error(res); 44 - const { links } = await res.json(); 45 - setBaseStats(links); 46 - } catch (e) { 47 - console.warn('fetching base stats failed', e); 48 - } 49 - })(); 50 - 51 - return () => cancel = true; 16 + let alive = true; 17 + getPostStats(atUri).then( 18 + stats => alive && setBaseStats(stats), 19 + e => console.warn('fetching base stats failed', e)); 20 + return () => alive = false; 52 21 }, [atUri]); 53 22 54 - useState() 55 - 56 - 57 23 return ( 58 24 <div> 59 25 <p> 60 26 {atUri}<br/> 61 - {JSON.stringify(nicerStats)} 27 + {JSON.stringify(liveStats)} 62 28 </p> 63 29 </div> 64 30 );
+39
live-embed/src/constellation.ts
··· 1 + import linkSources from './linkSources'; 2 + 3 + /** 4 + * get nice historical counts from constellation 5 + * 6 + * constellation's api still uses separated collection/path sources, and 7 + * likes should be distinct where everything else is record counts. 8 + * 9 + * constellation still can only specify one link source per request or /all 10 + * 11 + * handles stuff like that 12 + **/ 13 + export async function getPostStats( 14 + atUri: string, 15 + endpoint: string = 'https://constellation.microcosm.blue' 16 + ) { 17 + const url = new URL('/links/all', endpoint); 18 + url.searchParams.set('target', atUri); 19 + const res = await fetch(url); 20 + if (!res.ok) throw new Error(res); 21 + const { links } = await res.json(); 22 + 23 + const niceLinks = {}; 24 + 25 + for (const [collection, paths] of Object.entries(links)) { 26 + for (const [oldStylePath, counts] of Object.entries(paths)) { 27 + const newStylePath = `${collection}:${oldStylePath.slice(1)}`; 28 + const name = linkSources[newStylePath]; 29 + if (!name) continue; // perils of constellation's (soon-deprecated) /all 30 + if (name === 'like') { 31 + niceLinks[name] = counts.distinct_dids; 32 + } else { 33 + niceLinks[name] = counts.records; 34 + } 35 + } 36 + } 37 + 38 + return niceLinks; 39 + }
+9
live-embed/src/linkSources.ts
··· 1 + const linkSources = { 2 + 'app.bsky.feed.like:subject.uri': 'like', 3 + 'app.bsky.feed.repost:subject.uri': 'repost', // actual repost 4 + 'app.bsky.feed.post:embed.record.uri': 'repost', // normal quote (grouped for count) 5 + 'app.bsky.feed.post:embed.record.record.uri': 'repost', // RecordWithMedia quote (grouped for count) 6 + 'app.bsky.feed.post:reply.root.uri': 'reply', // root: count all descendent replies 7 + }; 8 + 9 + export { linkSources as default };
+23 -6
live-embed/src/samplePosts.ts
··· 1 + import { getPostStats } from './constellation'; 1 2 2 3 const SEKELETON_API = 'https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton'; 3 4 const FEED = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'; 4 5 const POLL_DELAY = 9000; 5 - const POST_LIMIT = 10; 6 + const POST_LIMIT = 5; 6 7 7 8 async function getFeed() { 8 9 const url = new URL(SEKELETON_API); ··· 31 32 const { feed } = await getFeed(); 32 33 if (dying) return; 33 34 35 + const withStats = await Promise.all(feed.map(async ({ post }) => { 36 + if (seen.has(post)) return { post, total: 0 }; 37 + let stats = {}; 38 + try { 39 + stats = await getPostStats(post); 40 + } catch (e) { 41 + console.warn('failed to get stats from constellation', e); 42 + } 43 + const total = Array.from(Object.values(stats)).reduce((a, b) => a + b, 0); 44 + return ({ post, total }) 45 + })) 46 + if (dying) return; 47 + 48 + // idk if sorting by most interactions yields more-interactive posts but eh 49 + withStats.sort(({ total: a }, { total: b }) => b - a); 50 + 34 51 // special case: first load 35 52 if (A === null && B === null) { 36 - if (feed.length < 2) throw new Error('feed returned fewer than two posts to start'); 37 - seen.add(A = feed[0].post); 38 - seen.add(B = feed[1].post); 53 + if (withStats.length < 2) throw new Error('withStats returned fewer than two posts to start'); 54 + seen.add(A = withStats[0].post); 55 + seen.add(B = withStats[1].post); 39 56 } else { 40 - for (const { post } of feed) { 57 + for (const { post } of withStats) { 41 58 if (seen.has(post)) { 42 59 continue; 43 60 } ··· 53 70 } 54 71 onRotate([A, B]); 55 72 } catch (e) { 56 - console.error('hmm, failed to get feed', e); 73 + console.error('hmm, failed to get withStats', e); 57 74 } 58 75 timer = setTimeout(next, POLL_DELAY); 59 76 }
+2 -7
live-embed/src/spacedust.ts
··· 1 + import linkSources from './linkSources'; 1 2 2 3 type SpacedustStatus = 'disconnected' | 'connecting' | 'connected'; 3 4 ··· 22 23 23 24 #subjects: string[]; 24 25 #subjectsDirty: boolean = false; // in case we try to update while disconnected 25 - #sources: string[] = [ // hard-coding for demo 26 - "app.bsky.feed.like:subject.uri", // likes 27 - "app.bsky.feed.repost:subject.uri", // reposts 28 - "app.bsky.feed.post:embed.record.uri", // normal quotes 29 - "app.bsky.feed.post:embed.record.record.uri", // RecordWithMedia quotes 30 - "app.bsky.feed.post:reply.root.uri", // all child replies 31 - ]; 26 + #sources: string[] = Object.keys(linkSources); // hard-coding for demo 32 27 #eol: boolean = false; // flag: we should shut down 33 28 34 29 constructor(