···11+import type { CanonicalResourceUri, RecordKey } from '@atcute/lexicons';
22+import type { BacklinksSource } from './constellation';
33+44+export type Notification = {
55+ kind: 'link';
66+ origin: string;
77+ link: LinkNotification;
88+};
99+1010+export type LinkNotification = {
1111+ operation: 'create' | 'update' | 'delete';
1212+ source: BacklinksSource;
1313+ source_record: CanonicalResourceUri;
1414+ source_rev: RecordKey;
1515+ subject: CanonicalResourceUri;
1616+};
+1-1
src/lib/cache.ts
···3434 const state = this.storage.getState();
3535 for (const [key, val] of Object.entries(state)) {
3636 try {
3737- console.log('restoring', key);
3737+ // console.log('restoring', key);
3838 const k = this.unprefix(key) as unknown as K;
3939 const v = val as V;
4040 this.memory.set(k, v);
+5-2
src/lib/index.ts
···11-import { AtpClient } from './at/client';
11+import { writable } from 'svelte/store';
22+import { type NotificationsStream } from './at/client';
33+// import type { JetstreamSubscription } from '@atcute/jetstream';
2433-export const client = new AtpClient();
55+export const notificationStream = writable<NotificationsStream | null>(null);
66+// export const jetstream = writable<JetstreamSubscription | null>(null);
+122-44
src/routes/+page.svelte
···22 import BskyPost from '$components/BskyPost.svelte';
33 import PostComposer from '$components/PostComposer.svelte';
44 import AccountSelector from '$components/AccountSelector.svelte';
55- import { AtpClient } from '$lib/at/client';
55+ import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client';
66 import { accounts, addAccount, type Account } from '$lib/accounts';
77 import {
88 type Did,
···1313 import { onMount } from 'svelte';
1414 import { theme } from '$lib/theme.svelte';
1515 import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch';
1616- import { expect } from '$lib/result';
1717- import type { AppBskyFeedPost } from '@atcute/bluesky';
1616+ import { expect, ok } from '$lib/result';
1717+ import { AppBskyFeedPost } from '@atcute/bluesky';
1818 import { SvelteMap } from 'svelte/reactivity';
1919 import { InfiniteLoader, LoaderState } from 'svelte-infinite';
2020+ import { notificationStream } from '$lib';
2121+ import { get } from 'svelte/store';
20222123 let loaderState = new LoaderState();
2224 let scrollContainer = $state<HTMLDivElement>();
···27292830 let viewClient = $state<AtpClient>(new AtpClient());
29313232+ let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
3333+ let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
3434+3535+ const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => {
3636+ if (!posts.has(did)) {
3737+ posts.set(did, new SvelteMap(accTimeline));
3838+ return;
3939+ }
4040+ const map = posts.get(did)!;
4141+ for (const [uri, record] of accTimeline) map.set(uri, record);
4242+ };
4343+4444+ const fetchTimeline = async (account: Account) => {
4545+ const client = clients.get(account.did);
4646+ if (!client) return;
4747+4848+ const cursor = cursors.get(account.did);
4949+ if (cursor && cursor.end) return;
5050+5151+ const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 12);
5252+ if (!accPosts.ok)
5353+ throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
5454+5555+ // if the cursor is undefined, we've reached the end of the timeline
5656+ if (!accPosts.value.cursor) {
5757+ cursors.set(account.did, { ...cursor, end: true });
5858+ return;
5959+ }
6060+6161+ cursors.set(account.did, { value: accPosts.value.cursor, end: false });
6262+ addPosts(account.did, await hydratePosts(client, accPosts.value.posts));
6363+ };
6464+6565+ const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
6666+6767+ const handleNotification = async (event: NotificationsStreamEvent) => {
6868+ if (event.type === 'message') {
6969+ // console.log(event.data);
7070+ const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
7171+ const subjectPost = await viewClient.getRecord(
7272+ AppBskyFeedPost.mainSchema,
7373+ parsedSubjectUri.repo,
7474+ parsedSubjectUri.rkey
7575+ );
7676+ if (!subjectPost.ok) return;
7777+7878+ const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
7979+ const hydrated = await hydratePosts(viewClient, [
8080+ {
8181+ record: subjectPost.value,
8282+ uri: event.data.link.subject,
8383+ replies: ok({
8484+ cursor: null,
8585+ total: 1,
8686+ records: [
8787+ {
8888+ did: parsedSourceUri.repo,
8989+ collection: parsedSourceUri.collection,
9090+ rkey: parsedSourceUri.rkey
9191+ }
9292+ ]
9393+ })
9494+ }
9595+ ]);
9696+9797+ // console.log(hydrated);
9898+ addPosts(parsedSubjectUri.repo, hydrated);
9999+ }
100100+ };
101101+102102+ // const handleJetstream = async (subscription: JetstreamSubscription) => {
103103+ // for await (const event of subscription) {
104104+ // if (event.kind !== 'commit') continue;
105105+ // const commit = event.commit;
106106+ // if (commit.operation === 'delete') {
107107+ // continue;
108108+ // }
109109+ // const record = commit.record as AppBskyFeedPost.Main;
110110+ // addPosts(
111111+ // event.did,
112112+ // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]])
113113+ // );
114114+ // }
115115+ // };
116116+30117 onMount(async () => {
118118+ accounts.subscribe((newAccounts) => {
119119+ get(notificationStream)?.stop();
120120+ // jetstream.set(null);
121121+ if (newAccounts.length === 0) return;
122122+ notificationStream.set(
123123+ viewClient.streamNotifications(
124124+ newAccounts.map((account) => account.did),
125125+ 'app.bsky.feed.post:reply.parent.uri'
126126+ )
127127+ );
128128+ // jetstream.set(
129129+ // viewClient.streamJetstream(
130130+ // newAccounts.map((account) => account.did),
131131+ // 'app.bsky.feed.post'
132132+ // )
133133+ // );
134134+ });
135135+ notificationStream.subscribe((stream) => {
136136+ if (!stream) return;
137137+ stream.listen(handleNotification);
138138+ });
139139+ // jetstream.subscribe((stream) => {
140140+ // if (!stream) return;
141141+ // handleJetstream(stream);
142142+ // });
31143 if ($accounts.length > 0) {
32144 loaderState.status = 'LOADING';
33145 selectedDid = $accounts[0].did;
···66178 loginAccount(newAccount).then(() => fetchTimeline(newAccount));
67179 };
681806969- let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
7070- let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
7171-7272- const fetchTimeline = async (account: Account) => {
7373- const client = clients.get(account.did);
7474- if (!client) return;
7575-7676- const cursor = cursors.get(account.did);
7777- if (cursor && cursor.end) return;
7878-7979- const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
8080- if (!accPosts.ok) {
8181- throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
8282- }
8383-8484- // if the cursor is undefined, we've reached the end of the timeline
8585- if (!accPosts.value.cursor) {
8686- cursors.set(account.did, { ...cursor, end: true });
8787- return;
8888- }
8989-9090- cursors.set(account.did, { value: accPosts.value.cursor, end: false });
9191- const accTimeline = await hydratePosts(client, accPosts.value.posts);
9292- if (!posts.has(account.did)) {
9393- posts.set(account.did, new SvelteMap(accTimeline));
9494- return;
9595- }
9696- const map = posts.get(account.did)!;
9797- for (const [uri, record] of accTimeline) map.set(uri, record);
9898- };
9999-100100- const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
101101-102181 let loading = $state(false);
103182 let loadError = $state('');
104183 const loadMore = async () => {
···118197 };
119198120199 let reverseChronological = $state(true);
121121- let viewOwnPosts = $state(false);
200200+ let viewOwnPosts = $state(true);
122201123202 type ThreadPost = {
124203 uri: ResourceUri;
···158237 newestTime: new Date(record.createdAt).getTime()
159238 };
160239161161- if (!threadMap.has(rootUri)) {
162162- threadMap.set(rootUri, []);
163163- }
240240+ if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
241241+164242 threadMap.get(rootUri)!.push(post);
165243 }
166244 }
···274352 // Sort threads by newest time (descending) so older branches appear first
275353 threads.sort((a, b) => b.newestTime - a.newestTime);
276354355355+ // console.log(threads);
356356+277357 return threads;
278358 };
279359···284364 posts.some((post) => !isOwnPost(post, accounts));
285365 const filterThreads = (threads: Thread[], accounts: Account[]) =>
286366 threads.filter((thread) => {
287287- if (!viewOwnPosts) {
288288- return hasNonOwnPost(thread.posts, accounts);
289289- }
367367+ if (!viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
290368 return true;
291369 });
292370···385463{/snippet}
386464387465{#snippet threadsView()}
388388- {#each threads as thread (thread.rootUri)}
466466+ {#each threads as thread ([thread.rootUri, thread.branchParentPost, ...thread.posts.map((post) => post.uri)])}
389467 <div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5">
390468 {#if thread.branchParentPost}
391469 {@const post = thread.branchParentPost}