appview-less bluesky client
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add stardust to be notified of new replies

+220 -70
+15
deno.lock
··· 8 8 "npm:@atcute/lexicons@^1.2.2": "1.2.2", 9 9 "npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0", 10 10 "npm:@eslint/js@^9.36.0": "9.37.0", 11 + "npm:@soffinal/websocket@~0.2.1": "0.2.1_typescript@5.9.3", 11 12 "npm:@sveltejs/adapter-auto@^6.1.0": "6.1.1_@sveltejs+kit@2.47.0__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.40.1____acorn@8.15.0___vite@7.1.10____@types+node@24.8.0____picomatch@4.0.3___@types+node@24.8.0__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.8.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0", 12 13 "npm:@sveltejs/kit@^2.43.2": "2.47.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.8.0", 13 14 "npm:@sveltejs/vite-plugin-svelte@^6.2.0": "6.2.1_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0", ··· 445 446 "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", 446 447 "os": ["win32"], 447 448 "cpu": ["x64"] 449 + }, 450 + "@soffinal/stream@0.2.3_typescript@5.9.3": { 451 + "integrity": "sha512-B0xWaDsVa6/HxttZmKqD7BmsveQQzuEoY9wztwGIuLF+nsVW1DW2V0kOJZIwTxp1wP4iKPalje1uZaZ+cYv7fg==", 452 + "dependencies": [ 453 + "typescript" 454 + ] 455 + }, 456 + "@soffinal/websocket@0.2.1_typescript@5.9.3": { 457 + "integrity": "sha512-OvBZCtWLRT3gZpseHdd7qBsKNTVYnZsMUwk1aF5m/hZ632MOhaumi4WS/D/hasTHYQFh1XZXy7To+rMVWwubCw==", 458 + "dependencies": [ 459 + "@soffinal/stream", 460 + "typescript" 461 + ] 448 462 }, 449 463 "@standard-schema/spec@1.0.0": { 450 464 "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" ··· 1722 1736 "npm:@atcute/lexicons@^1.2.2", 1723 1737 "npm:@eslint/compat@^1.4.0", 1724 1738 "npm:@eslint/js@^9.36.0", 1739 + "npm:@soffinal/websocket@~0.2.1", 1725 1740 "npm:@sveltejs/adapter-auto@^6.1.0", 1726 1741 "npm:@sveltejs/kit@^2.43.2", 1727 1742 "npm:@sveltejs/vite-plugin-svelte@^6.2.0",
+1
package.json
··· 19 19 "@atcute/client": "^4.0.5", 20 20 "@atcute/identity": "^1.1.1", 21 21 "@atcute/lexicons": "^1.2.2", 22 + "@soffinal/websocket": "^0.2.1", 22 23 "@wora/cache-persist": "^2.2.1", 23 24 "hash-wasm": "^4.12.0", 24 25 "lru-cache": "^11.2.2",
+60 -23
src/lib/at/client.ts
··· 7 7 import { Client as AtcuteClient, CredentialManager } from '@atcute/client'; 8 8 import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons'; 9 9 import { 10 - isHandle, 10 + isDid, 11 11 parseCanonicalResourceUri, 12 12 parseResourceUri, 13 13 type ActorIdentifier, ··· 32 32 import type { Records } from '@atcute/lexicons/ambient'; 33 33 import { PersistedLRU } from '$lib/cache'; 34 34 import { AppBskyActorProfile } from '@atcute/bluesky'; 35 + import { WebSocket } from '@soffinal/websocket'; 36 + import type { Notification } from './stardust'; 37 + // import { JetstreamSubscription } from '@atcute/jetstream'; 35 38 36 39 const cacheTtl = 1000 * 60 * 60 * 24; 37 40 const handleCache = new PersistedLRU<Handle, AtprotoDid>({ ··· 53 56 prefix: 'record' 54 57 }); 55 58 59 + export let slingshotUrl: URL = new URL( 60 + localStorage.getItem('slingshotUrl') ?? 'https://slingshot.microcosm.blue' 61 + ); 62 + export let spacedustUrl: URL = new URL( 63 + localStorage.getItem('spacedustUrl') ?? 'https://spacedust.microcosm.blue' 64 + ); 65 + export let constellationUrl: URL = new URL( 66 + localStorage.getItem('constellationUrl') ?? 'https://constellation.microcosm.blue' 67 + ); 68 + 69 + type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 70 + export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 71 + export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 72 + 56 73 export class AtpClient { 57 74 public atcute: AtcuteClient | null = null; 58 75 public didDoc: MiniDoc | null = null; 59 76 60 - private slingshotUrl: URL = new URL('https://slingshot.microcosm.blue'); 61 - private spacedustUrl: URL = new URL('https://spacedust.microcosm.blue'); 62 - private constellationUrl: URL = new URL('https://constellation.microcosm.blue'); 63 - 64 77 async login(handle: Handle, password: string): Promise<Result<null, string>> { 65 78 const didDoc = await this.resolveDidDoc(handle); 66 79 if (!didDoc.ok) return err(didDoc.error); ··· 109 122 const cachedSignal = recordCache.getSignal(cacheKey); 110 123 111 124 const result = await Promise.race([ 112 - fetchMicrocosm(this.slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 125 + fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 113 126 repo, 114 127 collection, 115 128 rkey ··· 158 171 return ok(res.data); 159 172 } 160 173 161 - async resolveHandle(handle: Handle): Promise<Result<AtprotoDid, string>> { 162 - const cached = handleCache.get(handle); 174 + async resolveHandle(identifier: ActorIdentifier): Promise<Result<AtprotoDid, string>> { 175 + if (isDid(identifier)) return ok(identifier as AtprotoDid); 176 + 177 + const cached = handleCache.get(identifier); 163 178 if (cached) return ok(cached); 164 - const cachedSignal = handleCache.getSignal(handle); 179 + const cachedSignal = handleCache.getSignal(identifier); 165 180 166 181 const res = await Promise.race([ 167 - fetchMicrocosm(this.slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 168 - handle 182 + fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 183 + handle: identifier 169 184 }), 170 185 cachedSignal.then((d): Result<{ did: Did }, string> => ok({ did: d })) 171 186 ]); ··· 173 188 const mapped = map(res, (data) => data.did as AtprotoDid); 174 189 175 190 if (mapped.ok) { 176 - handleCache.set(handle, mapped.value); 191 + handleCache.set(identifier, mapped.value); 177 192 } 178 193 179 194 return mapped; ··· 185 200 const cachedSignal = didDocCache.getSignal(handleOrDid); 186 201 187 202 const result = await Promise.race([ 188 - fetchMicrocosm(this.slingshotUrl, MiniDocQuery, { 203 + fetchMicrocosm(slingshotUrl, MiniDocQuery, { 189 204 identifier: handleOrDid 190 205 }), 191 206 cachedSignal.then((d): Result<MiniDoc, string> => ok(d)) ··· 217 232 rkey: RecordKey, 218 233 source: BacklinksSource 219 234 ): Promise<Result<Backlinks, string>> { 220 - let did = repo; 221 - if (isHandle(did)) { 222 - const resolvedDid = await this.resolveHandle(did); 223 - if (!resolvedDid.ok) { 224 - return err(`failed to resolve handle: ${resolvedDid.error}`); 225 - } 226 - did = resolvedDid.value; 235 + const did = await this.resolveHandle(repo); 236 + if (!did.ok) { 237 + return err(`failed to resolve handle: ${did.error}`); 227 238 } 228 - return await fetchMicrocosm(this.constellationUrl, BacklinksQuery, { 229 - subject: `at://${did}/${collection}/${rkey}`, 239 + return await fetchMicrocosm(constellationUrl, BacklinksQuery, { 240 + subject: `at://${did.value}/${collection}/${rkey}`, 230 241 source, 231 242 limit: 100 232 243 }); 233 244 } 245 + 246 + streamNotifications(subjects: Did[], ...sources: BacklinksSource[]): NotificationsStream { 247 + const url = new URL(spacedustUrl); 248 + url.protocol = 'wss:'; 249 + url.pathname = '/subscribe'; 250 + const searchParams = []; 251 + sources.every((source) => searchParams.push(['wantedSources', source])); 252 + subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject])); 253 + subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`])); 254 + searchParams.push(['instant', 'true']); 255 + url.search = `?${new URLSearchParams(searchParams)}`; 256 + // console.log(`streaming notifications: ${url}`); 257 + const encoder = WebSocket.getDefaultEncoder<undefined, Notification>(); 258 + const ws = new WebSocket<typeof encoder>(url.toString(), { 259 + encoder 260 + }); 261 + return ws; 262 + } 263 + 264 + // streamJetstream(subjects: Did[], ...collections: Nsid[]) { 265 + // return new JetstreamSubscription({ 266 + // url: 'wss://jetstream2.fr.hose.cam', 267 + // wantedCollections: collections, 268 + // wantedDids: subjects 269 + // }); 270 + // } 234 271 } 235 272 236 273 const fetchMicrocosm = async < ··· 247 284 try { 248 285 api.pathname = `/xrpc/${schema.nsid}`; 249 286 api.search = params ? `?${new URLSearchParams(params)}` : ''; 250 - console.info(`fetching:`, api.href); 287 + // console.info(`fetching:`, api.href); 251 288 const response = await fetch(api, init); 252 289 const body = await response.json(); 253 290 if (response.status === 400) return err(`${body.error}: ${body.message}`);
+16
src/lib/at/stardust.ts
··· 1 + import type { CanonicalResourceUri, RecordKey } from '@atcute/lexicons'; 2 + import type { BacklinksSource } from './constellation'; 3 + 4 + export type Notification = { 5 + kind: 'link'; 6 + origin: string; 7 + link: LinkNotification; 8 + }; 9 + 10 + export type LinkNotification = { 11 + operation: 'create' | 'update' | 'delete'; 12 + source: BacklinksSource; 13 + source_record: CanonicalResourceUri; 14 + source_rev: RecordKey; 15 + subject: CanonicalResourceUri; 16 + };
+1 -1
src/lib/cache.ts
··· 34 34 const state = this.storage.getState(); 35 35 for (const [key, val] of Object.entries(state)) { 36 36 try { 37 - console.log('restoring', key); 37 + // console.log('restoring', key); 38 38 const k = this.unprefix(key) as unknown as K; 39 39 const v = val as V; 40 40 this.memory.set(k, v);
+5 -2
src/lib/index.ts
··· 1 - import { AtpClient } from './at/client'; 1 + import { writable } from 'svelte/store'; 2 + import { type NotificationsStream } from './at/client'; 3 + // import type { JetstreamSubscription } from '@atcute/jetstream'; 2 4 3 - export const client = new AtpClient(); 5 + export const notificationStream = writable<NotificationsStream | null>(null); 6 + // export const jetstream = writable<JetstreamSubscription | null>(null);
+122 -44
src/routes/+page.svelte
··· 2 2 import BskyPost from '$components/BskyPost.svelte'; 3 3 import PostComposer from '$components/PostComposer.svelte'; 4 4 import AccountSelector from '$components/AccountSelector.svelte'; 5 - import { AtpClient } from '$lib/at/client'; 5 + import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client'; 6 6 import { accounts, addAccount, type Account } from '$lib/accounts'; 7 7 import { 8 8 type Did, ··· 13 13 import { onMount } from 'svelte'; 14 14 import { theme } from '$lib/theme.svelte'; 15 15 import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch'; 16 - import { expect } from '$lib/result'; 17 - import type { AppBskyFeedPost } from '@atcute/bluesky'; 16 + import { expect, ok } from '$lib/result'; 17 + import { AppBskyFeedPost } from '@atcute/bluesky'; 18 18 import { SvelteMap } from 'svelte/reactivity'; 19 19 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 20 + import { notificationStream } from '$lib'; 21 + import { get } from 'svelte/store'; 20 22 21 23 let loaderState = new LoaderState(); 22 24 let scrollContainer = $state<HTMLDivElement>(); ··· 27 29 28 30 let viewClient = $state<AtpClient>(new AtpClient()); 29 31 32 + let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>(); 33 + let cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 34 + 35 + const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => { 36 + if (!posts.has(did)) { 37 + posts.set(did, new SvelteMap(accTimeline)); 38 + return; 39 + } 40 + const map = posts.get(did)!; 41 + for (const [uri, record] of accTimeline) map.set(uri, record); 42 + }; 43 + 44 + const fetchTimeline = async (account: Account) => { 45 + const client = clients.get(account.did); 46 + if (!client) return; 47 + 48 + const cursor = cursors.get(account.did); 49 + if (cursor && cursor.end) return; 50 + 51 + const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 12); 52 + if (!accPosts.ok) 53 + throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`; 54 + 55 + // if the cursor is undefined, we've reached the end of the timeline 56 + if (!accPosts.value.cursor) { 57 + cursors.set(account.did, { ...cursor, end: true }); 58 + return; 59 + } 60 + 61 + cursors.set(account.did, { value: accPosts.value.cursor, end: false }); 62 + addPosts(account.did, await hydratePosts(client, accPosts.value.posts)); 63 + }; 64 + 65 + const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline)); 66 + 67 + const handleNotification = async (event: NotificationsStreamEvent) => { 68 + if (event.type === 'message') { 69 + // console.log(event.data); 70 + const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 71 + const subjectPost = await viewClient.getRecord( 72 + AppBskyFeedPost.mainSchema, 73 + parsedSubjectUri.repo, 74 + parsedSubjectUri.rkey 75 + ); 76 + if (!subjectPost.ok) return; 77 + 78 + const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 79 + const hydrated = await hydratePosts(viewClient, [ 80 + { 81 + record: subjectPost.value, 82 + uri: event.data.link.subject, 83 + replies: ok({ 84 + cursor: null, 85 + total: 1, 86 + records: [ 87 + { 88 + did: parsedSourceUri.repo, 89 + collection: parsedSourceUri.collection, 90 + rkey: parsedSourceUri.rkey 91 + } 92 + ] 93 + }) 94 + } 95 + ]); 96 + 97 + // console.log(hydrated); 98 + addPosts(parsedSubjectUri.repo, hydrated); 99 + } 100 + }; 101 + 102 + // const handleJetstream = async (subscription: JetstreamSubscription) => { 103 + // for await (const event of subscription) { 104 + // if (event.kind !== 'commit') continue; 105 + // const commit = event.commit; 106 + // if (commit.operation === 'delete') { 107 + // continue; 108 + // } 109 + // const record = commit.record as AppBskyFeedPost.Main; 110 + // addPosts( 111 + // event.did, 112 + // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]]) 113 + // ); 114 + // } 115 + // }; 116 + 30 117 onMount(async () => { 118 + accounts.subscribe((newAccounts) => { 119 + get(notificationStream)?.stop(); 120 + // jetstream.set(null); 121 + if (newAccounts.length === 0) return; 122 + notificationStream.set( 123 + viewClient.streamNotifications( 124 + newAccounts.map((account) => account.did), 125 + 'app.bsky.feed.post:reply.parent.uri' 126 + ) 127 + ); 128 + // jetstream.set( 129 + // viewClient.streamJetstream( 130 + // newAccounts.map((account) => account.did), 131 + // 'app.bsky.feed.post' 132 + // ) 133 + // ); 134 + }); 135 + notificationStream.subscribe((stream) => { 136 + if (!stream) return; 137 + stream.listen(handleNotification); 138 + }); 139 + // jetstream.subscribe((stream) => { 140 + // if (!stream) return; 141 + // handleJetstream(stream); 142 + // }); 31 143 if ($accounts.length > 0) { 32 144 loaderState.status = 'LOADING'; 33 145 selectedDid = $accounts[0].did; ··· 66 178 loginAccount(newAccount).then(() => fetchTimeline(newAccount)); 67 179 }; 68 180 69 - let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>(); 70 - let cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 71 - 72 - const fetchTimeline = async (account: Account) => { 73 - const client = clients.get(account.did); 74 - if (!client) return; 75 - 76 - const cursor = cursors.get(account.did); 77 - if (cursor && cursor.end) return; 78 - 79 - const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6); 80 - if (!accPosts.ok) { 81 - throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`; 82 - } 83 - 84 - // if the cursor is undefined, we've reached the end of the timeline 85 - if (!accPosts.value.cursor) { 86 - cursors.set(account.did, { ...cursor, end: true }); 87 - return; 88 - } 89 - 90 - cursors.set(account.did, { value: accPosts.value.cursor, end: false }); 91 - const accTimeline = await hydratePosts(client, accPosts.value.posts); 92 - if (!posts.has(account.did)) { 93 - posts.set(account.did, new SvelteMap(accTimeline)); 94 - return; 95 - } 96 - const map = posts.get(account.did)!; 97 - for (const [uri, record] of accTimeline) map.set(uri, record); 98 - }; 99 - 100 - const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline)); 101 - 102 181 let loading = $state(false); 103 182 let loadError = $state(''); 104 183 const loadMore = async () => { ··· 118 197 }; 119 198 120 199 let reverseChronological = $state(true); 121 - let viewOwnPosts = $state(false); 200 + let viewOwnPosts = $state(true); 122 201 123 202 type ThreadPost = { 124 203 uri: ResourceUri; ··· 158 237 newestTime: new Date(record.createdAt).getTime() 159 238 }; 160 239 161 - if (!threadMap.has(rootUri)) { 162 - threadMap.set(rootUri, []); 163 - } 240 + if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 241 + 164 242 threadMap.get(rootUri)!.push(post); 165 243 } 166 244 } ··· 274 352 // Sort threads by newest time (descending) so older branches appear first 275 353 threads.sort((a, b) => b.newestTime - a.newestTime); 276 354 355 + // console.log(threads); 356 + 277 357 return threads; 278 358 }; 279 359 ··· 284 364 posts.some((post) => !isOwnPost(post, accounts)); 285 365 const filterThreads = (threads: Thread[], accounts: Account[]) => 286 366 threads.filter((thread) => { 287 - if (!viewOwnPosts) { 288 - return hasNonOwnPost(thread.posts, accounts); 289 - } 367 + if (!viewOwnPosts) return hasNonOwnPost(thread.posts, accounts); 290 368 return true; 291 369 }); 292 370 ··· 385 463 {/snippet} 386 464 387 465 {#snippet threadsView()} 388 - {#each threads as thread (thread.rootUri)} 466 + {#each threads as thread ([thread.rootUri, thread.branchParentPost, ...thread.posts.map((post) => post.uri)])} 389 467 <div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5"> 390 468 {#if thread.branchParentPost} 391 469 {@const post = thread.branchParentPost}