+12
-46
live-embed/src/Post.tsx
+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
+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
+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
+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
+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(