···
61
61
target="_blank"
62
62
rel="noopener noreferrer"
63
63
>
64
64
-
Read
64
64
+
View
65
65
</a>
66
66
</div>
67
67
<a class="mt-3 link font-urbanist text-sm link-accent" href="/history/books">
···
5
5
6
6
let {
7
7
publications,
8
8
-
latestPosts
9
9
-
}: { publications: Publication[]; latestPosts: Promise<LatestPost[]> } = $props();
8
8
+
latestPosts,
9
9
+
subscriberCounts
10
10
+
}: {
11
11
+
publications: Publication[];
12
12
+
latestPosts: Promise<LatestPost[]>;
13
13
+
subscriberCounts: Promise<Record<string, number>>;
14
14
+
} = $props();
10
15
11
16
const INITIAL_COUNT = 4;
12
17
const LOAD_STEP = 2;
···
39
44
</figure>
40
45
{/if}
41
46
<div class="card-body">
47
47
+
{#await subscriberCounts then counts}
48
48
+
{#if counts[item.uri] > 0}
49
49
+
<span class="badge font-urbanist badge-secondary"
50
50
+
>♥ {counts[item.uri]} subscribers</span
51
51
+
>
52
52
+
{/if}
53
53
+
{/await}
42
54
<h2 class="card-title font-urbanist text-2xl font-black">{item.name}</h2>
43
55
<p class="text-md font-urbanist font-medium opacity-60">{item.description}</p>
44
56
<div class="mt-2 card-actions justify-end">
···
64
76
</button>
65
77
{/if}
66
78
67
67
-
<h2 class="mt-16 text-center font-urbanist text-xl font-semibold opacity-80 md:mt-24 md:text-3xl">
79
79
+
<h2
80
80
+
class="mt-16 text-center font-urbanist text-xl font-semibold opacity-80 md:mt-24 md:text-3xl"
81
81
+
>
68
82
Latest Posts
69
83
</h2>
70
84
{#await latestPosts}
···
92
106
</figure>
93
107
{/if}
94
108
<div class="card-body">
95
95
-
<span class="badge font-urbanist badge-primary">{post.publicationName}</span>
109
109
+
<div class="flex flex-wrap items-center gap-2">
110
110
+
<span class="badge font-urbanist badge-primary">{post.publicationName}</span>
111
111
+
{#if post.recommendCount > 0}
112
112
+
<span class="badge font-urbanist badge-secondary"
113
113
+
>★ {post.recommendCount} recommendations</span
114
114
+
>
115
115
+
{/if}
116
116
+
</div>
96
117
<h3 class="mt-2 card-title font-urbanist text-2xl font-black">{post.title}</h3>
97
118
{#if post.publishedAt}
98
119
<p class="font-urbanist text-xs opacity-40">{publishedLabel(post.publishedAt)}</p>
···
2
2
import { cache, HOUR_MS } from '$lib/server/cache';
3
3
import { getRepoInfo } from './client';
4
4
import { fetchPublications } from './publications';
5
5
+
import { countDistinctDids } from './interactions';
5
6
6
7
/** Minimal view of a `site.standard.document` record — metadata only, no content. */
7
8
type DocumentRecord = {
···
62
63
}
63
64
}
64
65
65
65
-
const posts: LatestPost[] = [];
66
66
-
for (const [siteUri, doc] of newestByPublication) {
67
67
-
const pub = byUri.get(siteUri)!;
68
68
-
const cid = doc.value.coverImage?.ref.$link;
69
69
-
posts.push({
70
70
-
title: doc.value.title,
71
71
-
description: doc.value.description ?? '',
72
72
-
publishedAt: doc.value.publishedAt ?? null,
73
73
-
postUrl: buildPostUrl(pub.href, doc.value.path),
74
74
-
coverImage: cid
75
75
-
? `${repo.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(repo.did)}&cid=${cid}`
76
76
-
: null,
77
77
-
publicationName: pub.name
78
78
-
});
79
79
-
}
66
66
+
const posts: LatestPost[] = await Promise.all(
67
67
+
[...newestByPublication].map(async ([siteUri, doc]) => {
68
68
+
const pub = byUri.get(siteUri)!;
69
69
+
const cid = doc.value.coverImage?.ref.$link;
70
70
+
return {
71
71
+
uri: doc.uri,
72
72
+
title: doc.value.title,
73
73
+
description: doc.value.description ?? '',
74
74
+
publishedAt: doc.value.publishedAt ?? null,
75
75
+
postUrl: buildPostUrl(pub.href, doc.value.path),
76
76
+
coverImage: cid
77
77
+
? `${repo.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(repo.did)}&cid=${cid}`
78
78
+
: null,
79
79
+
publicationName: pub.name,
80
80
+
recommendCount: await countDistinctDids(
81
81
+
doc.uri,
82
82
+
'site.standard.graph.recommend',
83
83
+
'.document'
84
84
+
)
85
85
+
};
86
86
+
})
87
87
+
);
80
88
81
89
posts.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? ''));
82
90
···
1
1
+
import { cache, HOUR_MS } from '$lib/server/cache';
2
2
+
import { fetchPublications } from './publications';
3
3
+
4
4
+
const CONSTELLATION = 'https://constellation.microcosm.blue';
5
5
+
6
6
+
/**
7
7
+
* Counts the distinct identities (DIDs) that link to `target` via a given
8
8
+
* `collection`/`path` in the Constellation backlink index. Used for engagement
9
9
+
* signals like subscribers and recommends. Cached per-target for 1 hour; returns
10
10
+
* 0 on any failure so callers can simply hide a zero-count chip.
11
11
+
*/
12
12
+
export async function countDistinctDids(
13
13
+
target: string,
14
14
+
collection: string,
15
15
+
path: string
16
16
+
): Promise<number> {
17
17
+
const cacheKey = `constellation:${collection}:${target}`;
18
18
+
const cached = cache.get(cacheKey) as { count: number } | null;
19
19
+
if (cached) {
20
20
+
return cached.count;
21
21
+
}
22
22
+
23
23
+
try {
24
24
+
const url =
25
25
+
`${CONSTELLATION}/links/count/distinct-dids` +
26
26
+
`?target=${encodeURIComponent(target)}` +
27
27
+
`&collection=${encodeURIComponent(collection)}` +
28
28
+
`&path=${encodeURIComponent(path)}`;
29
29
+
console.log(`Fetching Constellation count: ${url}`);
30
30
+
const res = await fetch(url);
31
31
+
if (!res.ok) {
32
32
+
console.warn(`Constellation count failed: ${res.status} ${res.statusText}`);
33
33
+
return 0;
34
34
+
}
35
35
+
const { total } = (await res.json()) as { total?: number };
36
36
+
const count = total ?? 0;
37
37
+
cache.setWithExpiry(cacheKey, { count }, HOUR_MS);
38
38
+
return count;
39
39
+
} catch (error) {
40
40
+
console.warn('Failed to fetch Constellation count:', error);
41
41
+
return 0;
42
42
+
}
43
43
+
}
44
44
+
45
45
+
/**
46
46
+
* Maps each surfaced publication's at-uri to its subscriber count
47
47
+
* (`site.standard.graph.subscription` records pointing at the publication).
48
48
+
*/
49
49
+
export async function fetchSubscriberCounts(): Promise<Record<string, number>> {
50
50
+
const publications = await fetchPublications();
51
51
+
const counts = await Promise.all(
52
52
+
publications.map((pub) =>
53
53
+
countDistinctDids(pub.uri, 'site.standard.graph.subscription', '.publication')
54
54
+
)
55
55
+
);
56
56
+
57
57
+
const byUri: Record<string, number> = {};
58
58
+
publications.forEach((pub, i) => {
59
59
+
byUri[pub.uri] = counts[i];
60
60
+
});
61
61
+
return byUri;
62
62
+
}
···
14
14
};
15
15
16
16
export type LatestPost = {
17
17
+
uri: string; // the document's at-uri, used as the recommend-count target/key
17
18
title: string;
18
19
description: string;
19
20
publishedAt: string | null;
20
21
postUrl: string; // publication.url + document.path
21
22
coverImage: string | null;
22
23
publicationName: string;
24
24
+
recommendCount: number;
23
25
};
24
26
25
27
export type CurrentlyReading = {
···
2
2
import { fetchSponsors } from '$lib/server/github';
3
3
import { fetchPublications } from '$lib/server/atproto/publications';
4
4
import { fetchLatestPosts } from '$lib/server/atproto/documents';
5
5
+
import { fetchSubscriberCounts } from '$lib/server/atproto/interactions';
5
6
import { fetchCurrentlyReading } from '$lib/server/atproto/books';
6
7
import { fetchNowPlaying } from '$lib/server/atproto/music';
7
8
···
14
15
sponsors,
15
16
publications,
16
17
latestPosts: fetchLatestPosts(),
18
18
+
subscriberCounts: fetchSubscriberCounts(),
17
19
currentlyReading: fetchCurrentlyReading(),
18
20
nowPlaying: fetchNowPlaying(fetch)
19
21
};
···
11
11
import type { PageProps } from './$types';
12
12
13
13
let { data }: PageProps = $props();
14
14
-
const { sponsors, publications, latestPosts, currentlyReading, nowPlaying } = data;
14
14
+
const { sponsors, publications, latestPosts, subscriberCounts, currentlyReading, nowPlaying } =
15
15
+
data;
15
16
</script>
16
17
17
18
<svelte:head>
···
26
27
<NavBar />
27
28
<Hero />
28
29
<OpenSourceProjects />
29
29
-
<Writing {publications} {latestPosts} />
30
30
+
<Writing {publications} {latestPosts} {subscriberCounts} />
30
31
<FreeTime {currentlyReading} {nowPlaying} />
31
32
<Sponsors {sponsors} />
32
33
</div>