replies timeline only, appview-less bluesky client

add following view, no profile / timeline viewing for now

ptr.pet 7437d892 9569baf1

verified
+60 -1
deno.lock
··· 8 8 "npm:@atcute/client@^4.1.1": "4.1.1", 9 9 "npm:@atcute/identity-resolver@^1.2.1": "1.2.1_@atcute+identity@1.1.3", 10 10 "npm:@atcute/identity@^1.1.3": "1.1.3", 11 + "npm:@atcute/jetstream@^1.1.2": "1.1.2", 11 12 "npm:@atcute/lexicons@^1.2.5": "1.2.5", 12 13 "npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3", 13 14 "npm:@atcute/tid@^1.0.3": "1.0.3", ··· 23 24 "npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3", 24 25 "npm:@types/node@^25.0.3": "25.0.3", 25 26 "npm:@wora/cache-persist@^2.2.1": "2.2.1", 27 + "npm:async-cache-dedupe@^3.4.0": "3.4.0", 26 28 "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.2", 27 29 "npm:eslint-plugin-svelte@^3.13.1": "3.13.1_eslint@9.39.2_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6", 28 30 "npm:eslint@^9.39.2": "9.39.2", ··· 92 94 "dependencies": [ 93 95 "@atcute/lexicons", 94 96 "@badrap/valita" 97 + ] 98 + }, 99 + "@atcute/jetstream@1.1.2": { 100 + "integrity": "sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==", 101 + "dependencies": [ 102 + "@atcute/lexicons", 103 + "@badrap/valita", 104 + "@mary-ext/event-iterator", 105 + "@mary-ext/simple-event-emitter", 106 + "partysocket", 107 + "type-fest", 108 + "yocto-queue@1.2.2" 95 109 ] 96 110 }, 97 111 "@atcute/lexicons@1.2.5": { ··· 405 419 "@jridgewell/sourcemap-codec" 406 420 ] 407 421 }, 422 + "@mary-ext/event-iterator@1.0.0": { 423 + "integrity": "sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==", 424 + "dependencies": [ 425 + "yocto-queue@1.2.2" 426 + ] 427 + }, 428 + "@mary-ext/simple-event-emitter@1.0.0": { 429 + "integrity": "sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==" 430 + }, 408 431 "@polka/url@1.0.0-next.29": { 409 432 "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" 410 433 }, ··· 843 866 "aria-query@5.3.2": { 844 867 "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" 845 868 }, 869 + "async-cache-dedupe@3.4.0": { 870 + "integrity": "sha512-RkQr21CpltqMpbYpRaEAmF1BdUO5jnnS/scZkectmLiuWQ81w8u4lYraipbQf8zQ0yYvb3U0N1ozNAYmI4jQ3g==", 871 + "dependencies": [ 872 + "mnemonist", 873 + "safe-stable-stringify" 874 + ] 875 + }, 846 876 "axobject-query@4.1.0": { 847 877 "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" 848 878 }, ··· 1087 1117 "esutils@2.0.3": { 1088 1118 "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" 1089 1119 }, 1120 + "event-target-polyfill@0.0.4": { 1121 + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==" 1122 + }, 1090 1123 "fast-deep-equal@3.1.3": { 1091 1124 "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 1092 1125 }, ··· 1344 1377 "brace-expansion@2.0.2" 1345 1378 ] 1346 1379 }, 1380 + "mnemonist@0.40.3": { 1381 + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", 1382 + "dependencies": [ 1383 + "obliterator" 1384 + ] 1385 + }, 1347 1386 "mri@1.2.0": { 1348 1387 "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" 1349 1388 }, ··· 1364 1403 "natural-compare@1.4.0": { 1365 1404 "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" 1366 1405 }, 1406 + "obliterator@2.0.5": { 1407 + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==" 1408 + }, 1367 1409 "optionator@0.9.4": { 1368 1410 "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", 1369 1411 "dependencies": [ ··· 1378 1420 "p-limit@3.1.0": { 1379 1421 "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 1380 1422 "dependencies": [ 1381 - "yocto-queue" 1423 + "yocto-queue@0.1.0" 1382 1424 ] 1383 1425 }, 1384 1426 "p-locate@5.0.0": { ··· 1393 1435 "callsites" 1394 1436 ] 1395 1437 }, 1438 + "partysocket@1.1.10": { 1439 + "integrity": "sha512-ACfn0P6lQuj8/AqB4L5ZDFcIEbpnIteNNObrlxqV1Ge80GTGhjuJ2sNKwNQlFzhGi4kI7fP/C1Eqh8TR78HjDQ==", 1440 + "dependencies": [ 1441 + "event-target-polyfill" 1442 + ] 1443 + }, 1396 1444 "path-exists@4.0.0": { 1397 1445 "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" 1398 1446 }, ··· 1513 1561 "dependencies": [ 1514 1562 "mri" 1515 1563 ] 1564 + }, 1565 + "safe-stable-stringify@2.5.0": { 1566 + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==" 1516 1567 }, 1517 1568 "semver@7.7.3": { 1518 1569 "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", ··· 1658 1709 "prelude-ls" 1659 1710 ] 1660 1711 }, 1712 + "type-fest@4.41.0": { 1713 + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" 1714 + }, 1661 1715 "typescript-eslint@8.50.1_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.50.1__eslint@9.39.2__typescript@5.9.3": { 1662 1716 "integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==", 1663 1717 "dependencies": [ ··· 1729 1783 "yocto-queue@0.1.0": { 1730 1784 "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" 1731 1785 }, 1786 + "yocto-queue@1.2.2": { 1787 + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==" 1788 + }, 1732 1789 "zimmerframe@1.1.4": { 1733 1790 "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" 1734 1791 } ··· 1743 1800 "npm:@atcute/client@^4.1.1", 1744 1801 "npm:@atcute/identity-resolver@^1.2.1", 1745 1802 "npm:@atcute/identity@^1.1.3", 1803 + "npm:@atcute/jetstream@^1.1.2", 1746 1804 "npm:@atcute/lexicons@^1.2.5", 1747 1805 "npm:@atcute/oauth-browser-client@^2.0.3", 1748 1806 "npm:@atcute/tid@^1.0.3", ··· 1758 1816 "npm:@tailwindcss/vite@^4.1.18", 1759 1817 "npm:@types/node@^25.0.3", 1760 1818 "npm:@wora/cache-persist@^2.2.1", 1819 + "npm:async-cache-dedupe@^3.4.0", 1761 1820 "npm:eslint-config-prettier@^10.1.8", 1762 1821 "npm:eslint-plugin-svelte@^3.13.1", 1763 1822 "npm:eslint@^9.39.2",
+2
package.json
··· 21 21 "@atcute/client": "^4.1.1", 22 22 "@atcute/identity": "^1.1.3", 23 23 "@atcute/identity-resolver": "^1.2.1", 24 + "@atcute/jetstream": "^1.1.2", 24 25 "@atcute/lexicons": "^1.2.5", 25 26 "@atcute/oauth-browser-client": "^2.0.3", 26 27 "@atcute/tid": "^1.0.3", 27 28 "@floating-ui/dom": "^1.7.4", 28 29 "@soffinal/websocket": "^0.2.1", 29 30 "@wora/cache-persist": "^2.2.1", 31 + "async-cache-dedupe": "^3.4.0", 30 32 "hash-wasm": "^4.12.0", 31 33 "lru-cache": "^11.2.4", 32 34 "svelte-device-info": "^1.0.6",
+2 -2
src/components/AccountSelector.svelte
··· 1 1 <script lang="ts"> 2 2 import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 - import { AtpClient } from '$lib/at/client'; 3 + import { AtpClient, resolveHandle } from '$lib/at/client'; 4 4 import type { Handle } from '@atcute/lexicons'; 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import PfpPlaceholder from './PfpPlaceholder.svelte'; ··· 67 67 if (isHandle(loginHandle)) handle = loginHandle; 68 68 else throw 'handle is invalid'; 69 69 70 - let did = await client.resolveHandle(handle); 70 + let did = await resolveHandle(handle); 71 71 if (!did.ok) throw did.error; 72 72 73 73 await initiateLogin(did.value, handle);
+5 -23
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 - import { type AtpClient } from '$lib/at/client'; 2 + import { resolveDidDoc, type AtpClient } from '$lib/at/client'; 3 3 import { 4 4 AppBskyActorProfile, 5 5 AppBskyEmbedExternal, ··· 35 35 import { type AppBskyEmbeds } from '$lib/at/types'; 36 36 import { settings } from '$lib/settings'; 37 37 import RichText from './RichText.svelte'; 38 + import { getRelativeTime } from '$lib/date'; 38 39 39 40 interface Props { 40 41 client: AtpClient; ··· 69 70 const color = generateColorForDid(did); 70 71 71 72 let handle: ActorIdentifier = $state(did); 72 - const didDoc = client.resolveDidDoc(did).then((res) => { 73 + const didDoc = resolveDidDoc(did).then((res) => { 73 74 if (res.ok) handle = res.value.handle; 74 75 return res; 75 76 }); ··· 131 132 } 132 133 }; 133 134 134 - const getRelativeTime = (date: Date) => { 135 - const now = new Date(); 136 - const diff = now.getTime() - date.getTime(); 137 - const seconds = Math.floor(diff / 1000); 138 - const minutes = Math.floor(seconds / 60); 139 - const hours = Math.floor(minutes / 60); 140 - const days = Math.floor(hours / 24); 141 - const months = Math.floor(days / 30); 142 - const years = Math.floor(months / 12); 143 - 144 - if (years > 0) return `${years}y`; 145 - if (months > 0) return `${months}m`; 146 - if (days > 0) return `${days}d`; 147 - if (hours > 0) return `${hours}h`; 148 - if (minutes > 0) return `${minutes}m`; 149 - if (seconds > 0) return `${seconds}s`; 150 - return 'now'; 151 - }; 152 - 153 135 const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => { 154 136 const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source); 155 137 if (!backlinks.ok) return null; ··· 348 330 349 331 {#if profileDesc.length > 0} 350 332 <p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 351 - <RichText text={profileDesc} {client} /> 333 + <RichText text={profileDesc} /> 352 334 </p> 353 335 {/if} 354 336 </Dropdown> ··· 427 409 </span> 428 410 </div> 429 411 <p class="leading-normal text-wrap wrap-break-word"> 430 - <RichText text={record.text} facets={record.facets ?? []} {client} /> 412 + <RichText text={record.text} facets={record.facets ?? []} /> 431 413 {#if isOnPostComposer && record.embed} 432 414 {@render embedBadge(record.embed)} 433 415 {/if}
+171
src/components/FollowingView.svelte
··· 1 + <script lang="ts"> 2 + import { follows, getClient, posts } from '$lib/state.svelte'; 3 + import type { Did } from '@atcute/lexicons'; 4 + import ProfilePicture from './ProfilePicture.svelte'; 5 + import { type AtpClient, resolveDidDoc } from '$lib/at/client'; 6 + import { getRelativeTime } from '$lib/date'; 7 + import { generateColorForDid } from '$lib/accounts'; 8 + import { type AtprotoDid } from '@atcute/lexicons/syntax'; 9 + 10 + interface Props { 11 + selectedDid: Did; 12 + selectedClient: AtpClient; 13 + } 14 + 15 + const { selectedDid, selectedClient }: Props = $props(); 16 + 17 + const burstTimeframeMs = 1000 * 60 * 60; // 1 hour 18 + 19 + type FollowedAccount = { 20 + did: Did; 21 + lastPostAt: Date; 22 + postsInBurst: number; 23 + }; 24 + 25 + class FollowedUserStats { 26 + did: Did; 27 + constructor(did: Did) { 28 + this.did = did; 29 + } 30 + 31 + data = $derived.by(() => { 32 + const postsMap = posts.get(this.did); 33 + if (!postsMap || postsMap.size === 0) return null; 34 + 35 + let lastPostAtTime = 0; 36 + let postsInBurst = 0; 37 + const now = Date.now(); 38 + const timeframe = now - burstTimeframeMs; 39 + 40 + for (const post of postsMap.values()) { 41 + const t = new Date(post.record.createdAt).getTime(); 42 + if (t > lastPostAtTime) lastPostAtTime = t; 43 + if (t > timeframe) postsInBurst++; 44 + } 45 + 46 + return { 47 + did: this.did, 48 + lastPostAt: new Date(lastPostAtTime), 49 + postsInBurst 50 + }; 51 + }); 52 + } 53 + 54 + type Sort = 'recent' | 'active'; 55 + let followingSort: Sort = $state('active' as Sort); 56 + 57 + const followsMap = $derived(follows.get(selectedDid)); 58 + 59 + const userStatsList = $derived( 60 + followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : [] 61 + ); 62 + 63 + const following: FollowedAccount[] = $derived( 64 + userStatsList.map((u) => u.data).filter((d): d is FollowedAccount => d !== null) 65 + ); 66 + 67 + const sortedFollowing = $derived( 68 + [...following].sort((a, b) => { 69 + if (followingSort === 'recent') { 70 + // Sort by last post time descending, then burst descending 71 + const timeA = a.lastPostAt.getTime(); 72 + const timeB = b.lastPostAt.getTime(); 73 + if (timeA !== timeB) return timeB - timeA; 74 + return b.postsInBurst - a.postsInBurst; 75 + } else { 76 + // Sort by burst descending, then last post time descending 77 + if (b.postsInBurst !== a.postsInBurst) return b.postsInBurst - a.postsInBurst; 78 + return b.lastPostAt.getTime() - a.lastPostAt.getTime(); 79 + } 80 + }) 81 + ); 82 + 83 + let highlightedDid: Did | undefined = $state(undefined); 84 + </script> 85 + 86 + <div class="p-2"> 87 + <div class="mb-4 flex items-center justify-between px-2"> 88 + <div> 89 + <h2 class="text-3xl font-bold">following</h2> 90 + <div class="mt-2 flex gap-2"> 91 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 92 + <div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div> 93 + </div> 94 + </div> 95 + <div class="flex gap-2 text-sm"> 96 + {#each ['recent', 'active'] as Sort[] as type (type)} 97 + <button 98 + class="rounded-sm px-2 py-1 transition-colors {followingSort === type 99 + ? 'bg-(--nucleus-accent) text-(--nucleus-bg)' 100 + : 'bg-(--nucleus-accent)/10 hover:bg-(--nucleus-accent)/20'}" 101 + onclick={() => (followingSort = type)} 102 + > 103 + {type} 104 + </button> 105 + {/each} 106 + </div> 107 + </div> 108 + 109 + <div class="flex flex-col gap-2"> 110 + {#if sortedFollowing.length === 0} 111 + <div class="flex justify-center py-8"> 112 + <div 113 + class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" 114 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 115 + ></div> 116 + </div> 117 + {:else} 118 + {#each sortedFollowing as user (user.did)} 119 + {@const lastPostAt = user.lastPostAt} 120 + {@const relTime = getRelativeTime(lastPostAt)} 121 + {@const color = generateColorForDid(user.did)} 122 + {@const isHighlighted = highlightedDid === user.did} 123 + {@const displayName = getClient(user.did as AtprotoDid) 124 + .then((client) => client.getProfile()) 125 + .then((profile) => { 126 + if (profile.ok) return profile.value.displayName; 127 + return null; 128 + })} 129 + {@const handle = resolveDidDoc(user.did).then((doc) => { 130 + if (doc.ok) return doc.value.handle; 131 + return 'handle.invalid'; 132 + })} 133 + <!-- svelte-ignore a11y_no_static_element_interactions --> 134 + <div 135 + class="flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors" 136 + style={`background-color: ${isHighlighted ? `color-mix(in srgb, ${color} 20%, transparent)` : 'color-mix(in srgb, var(--nucleus-accent) 7%, transparent)'};`} 137 + onmouseenter={() => (highlightedDid = user.did)} 138 + onmouseleave={() => (highlightedDid = undefined)} 139 + > 140 + <ProfilePicture client={selectedClient} did={user.did} size={10} /> 141 + <div class="min-w-0 flex-1"> 142 + <div 143 + class="flex items-baseline gap-2 font-bold transition-colors" 144 + style={`${isHighlighted ? `color: ${color};` : ''}`} 145 + > 146 + {#await Promise.all([displayName, handle]) then [displayName, handle]} 147 + <span class="truncate">{displayName || handle}</span> 148 + <span class="truncate text-sm opacity-60">@{handle}</span> 149 + {/await} 150 + </div> 151 + <div class="flex gap-2 text-xs opacity-70"> 152 + <span 153 + class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 154 + ? 'text-(--nucleus-accent)' 155 + : ''} 156 + > 157 + posted {relTime} 158 + {relTime !== 'now' ? 'ago' : ''} 159 + </span> 160 + {#if user.postsInBurst > 0} 161 + <span class="font-bold text-(--nucleus-accent2)"> 162 + {user.postsInBurst} posts / 1h 163 + </span> 164 + {/if} 165 + </div> 166 + </div> 167 + </div> 168 + {/each} 169 + {/if} 170 + </div> 171 + </div>
+1 -1
src/components/PostComposer.svelte
··· 36 36 }); 37 37 38 38 // Parse rich text (mentions, links, tags) 39 - const rt = await parseToRichText(client, text); 39 + const rt = await parseToRichText(text); 40 40 41 41 const record: AppBskyFeedPost.Main = { 42 42 $type: 'app.bsky.feed.post',
+1
src/components/ProfilePicture.svelte
··· 29 29 {#if isBlob(record.avatar)} 30 30 <img 31 31 class="rounded-sm" 32 + loading="lazy" 32 33 style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 33 34 alt="avatar for {did}" 34 35 src={img('avatar_thumbnail', did, record.avatar.ref.$link)}
+2 -4
src/components/RichText.svelte
··· 1 1 <script lang="ts"> 2 - import type { AtpClient } from '$lib/at/client'; 3 2 import { parseToRichText } from '$lib/richtext'; 4 3 import { settings } from '$lib/settings'; 5 4 import type { BakedRichtext } from '@atcute/bluesky-richtext-builder'; ··· 8 7 interface Props { 9 8 text: string; 10 9 facets?: Facet[]; 11 - client: AtpClient; 12 10 } 13 11 14 - const { text, facets, client }: Props = $props(); 12 + const { text, facets }: Props = $props(); 15 13 16 14 const richtext: Promise<BakedRichtext> = $derived( 17 - facets ? Promise.resolve({ text, facets }) : parseToRichText(client, text) 15 + facets ? Promise.resolve({ text, facets }) : parseToRichText(text) 18 16 ); 19 17 </script> 20 18
+6 -9
src/components/SettingsView.svelte
··· 1 1 <script lang="ts"> 2 2 import { defaultSettings, needsReload, settings } from '$lib/settings'; 3 - import { handleCache, didDocCache, recordCache } from '$lib/at/client'; 4 3 import { get } from 'svelte/store'; 5 4 import ColorPicker from 'svelte-awesome-color-picker'; 6 5 import Tabs from './Tabs.svelte'; 7 6 import { portal } from 'svelte-portal'; 7 + import { cache } from '$lib/cache'; 8 8 9 9 type Tab = 'style' | 'moderation' | 'advanced'; 10 10 let activeTab = $state<Tab>('advanced'); ··· 29 29 }; 30 30 31 31 const handleClearCache = () => { 32 - handleCache.clear(); 33 - didDocCache.clear(); 34 - recordCache.clear(); 32 + cache.clear(); 35 33 alert('cache cleared!'); 36 34 }; 37 35 </script> 38 36 39 - {#snippet divider()} 40 - <div class="h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 41 - {/snippet} 42 - 43 37 {#snippet advancedTab()} 44 38 <div class="space-y-3 p-4"> 45 39 <div> ··· 62 56 {@render _input('slingshot', 'slingshot url (for fetching records & resolving identity)')} 63 57 {@render _input('spacedust', 'spacedust url (for notifications)')} 64 58 {@render _input('constellation', 'constellation url (for backlinks)')} 59 + {@render _input('jetstream', 'jetstream url (for real-time updates)')} 65 60 </div> 66 61 </div> 67 62 ··· 161 156 162 157 <div 163 158 use:portal={'#app-footer'} 164 - class="fixed bottom-[5dvh] z-20 w-full max-w-2xl p-4 pt-2 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)]" 159 + class=" 160 + fixed bottom-[5dvh] z-20 w-full max-w-2xl p-4 pt-2 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)] 161 + " 165 162 > 166 163 <Tabs 167 164 tabs={['style', 'moderation', 'advanced']}
+1 -1
src/components/Tabs.svelte
··· 9 9 </script> 10 10 11 11 <div class="flex rounded border-x-3 border-b-3 border-(--nucleus-accent)/20"> 12 - {#each tabs as tab, idx (tab)} 12 + {#each tabs as tab (tab)} 13 13 {@const isActive = activeTab === tab} 14 14 <button 15 15 onclick={() => onTabChange(tab)}
+137 -123
src/lib/at/client.ts
··· 4 4 ComAtprotoRepoGetRecord, 5 5 ComAtprotoRepoListRecords 6 6 } from '@atcute/atproto'; 7 - import { Client as AtcuteClient } from '@atcute/client'; 7 + import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client'; 8 8 import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons'; 9 9 import { 10 10 isDid, ··· 30 30 import { MiniDocQuery, type MiniDoc } from './slingshot'; 31 31 import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 32 32 import type { Records } from '@atcute/lexicons/ambient'; 33 - import { PersistedLRU } from '$lib/cache'; 33 + import { cache as rawCache } from '$lib/cache'; 34 34 import { AppBskyActorProfile } from '@atcute/bluesky'; 35 35 import { WebSocket } from '@soffinal/websocket'; 36 36 import type { Notification } from './stardust'; 37 37 import { get } from 'svelte/store'; 38 38 import { settings } from '$lib/settings'; 39 39 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 40 - // import { JetstreamSubscription } from '@atcute/jetstream'; 41 - 42 - const cacheTtl = 1000 * 60 * 60 * 24; 43 - export const handleCache = new PersistedLRU<Handle, AtprotoDid>({ 44 - max: 1000, 45 - ttl: cacheTtl, 46 - prefix: 'handle' 47 - }); 48 - export const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({ 49 - max: 1000, 50 - ttl: cacheTtl, 51 - prefix: 'didDoc' 52 - }); 53 - export const recordCache = new PersistedLRU< 54 - string, 55 - InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema> 56 - >({ 57 - max: 5000, 58 - ttl: cacheTtl, 59 - prefix: 'record' 60 - }); 61 40 62 41 export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 63 42 export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); 64 43 export const constellationUrl: URL = new URL(get(settings).endpoints.constellation); 65 44 66 - type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 67 - export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 68 - export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 45 + export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 46 + 47 + const cacheWithHandles = rawCache.define( 48 + 'resolveHandle', 49 + async (handle: Handle): Promise<AtprotoDid> => { 50 + const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 51 + handle 52 + }); 53 + if (!res.ok) throw new Error(res.error); 54 + return res.value.did as AtprotoDid; 55 + } 56 + ); 69 57 70 - export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 58 + const cacheWithDidDocs = cacheWithHandles.define( 59 + 'resolveDidDoc', 60 + async (identifier: ActorIdentifier): Promise<MiniDoc> => { 61 + const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, { 62 + identifier 63 + }); 64 + if (!res.ok) throw new Error(res.error); 65 + return res.value; 66 + } 67 + ); 68 + 69 + const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => { 70 + const parsedUri = parseResourceUri(uri); 71 + if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`); 72 + const { repo, collection, rkey } = parsedUri.value; 73 + const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 74 + repo, 75 + collection: collection!, 76 + rkey: rkey! 77 + }); 78 + if (!res.ok) throw new Error(res.error); 79 + return res.value; 80 + }); 81 + 82 + const cache = cacheWithRecords; 71 83 72 84 export class AtpClient { 73 85 public atcute: AtcuteClient | null = null; ··· 117 129 rkey: RecordKey 118 130 ): Promise<Result<RecordOutput<Output>, string>> { 119 131 const collection = schema.object.shape.$type.expected; 120 - const cacheKey = `${repo}:${collection}:${rkey}`; 121 132 122 - const cached = recordCache.get(cacheKey); 123 - if (cached) return ok({ uri: cached.uri, cid: cached.cid, record: cached.value as Output }); 124 - const cachedSignal = recordCache.getSignal(cacheKey); 125 - 126 - const result = await Promise.race([ 127 - fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 128 - repo, 129 - collection, 130 - rkey 131 - }).then((result): Result<RecordOutput<Output>, string> => { 132 - if (!result.ok) return result; 133 - 134 - const parsed = safeParse(schema, result.value.value); 135 - if (!parsed.ok) return err(parsed.message); 133 + try { 134 + // Call the cached function 135 + const rawValue = await cache.fetchRecord(`at://${repo}/${collection}/${rkey}`); 136 136 137 - recordCache.set(cacheKey, result.value); 137 + const parsed = safeParse(schema, rawValue.value); 138 + if (!parsed.ok) return err(parsed.message); 138 139 139 - return ok({ 140 - uri: result.value.uri, 141 - cid: result.value.cid, 142 - record: parsed.value as Output 143 - }); 144 - }), 145 - cachedSignal.then( 146 - (d): Result<RecordOutput<Output>, string> => 147 - ok({ uri: d.uri, cid: d.cid, record: d.value as Output }) 148 - ) 149 - ]); 150 - 151 - if (!result.ok) return result; 152 - 153 - return ok(result.value); 140 + return ok({ 141 + uri: rawValue.uri, 142 + cid: rawValue.cid, 143 + record: parsed.value as Output 144 + }); 145 + } catch (e) { 146 + return err(String(e)); 147 + } 154 148 } 155 149 156 150 async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { ··· 161 155 162 156 async listRecords<Collection extends keyof Records>( 163 157 collection: Collection, 164 - repo: ActorIdentifier, 165 158 cursor?: string, 166 - limit?: number 159 + limit: number = 100 167 160 ): Promise< 168 161 Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 169 162 > { 170 - if (!this.atcute) return err('not authenticated'); 163 + if (!this.atcute || !this.user) return err('not authenticated'); 171 164 const res = await this.atcute.get('com.atproto.repo.listRecords', { 172 165 params: { 173 - repo, 166 + repo: this.user.did, 174 167 collection, 175 168 cursor, 176 169 limit 177 170 } 178 171 }); 179 172 if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 180 - return ok(res.data); 181 - } 182 - 183 - async resolveHandle(identifier: ActorIdentifier): Promise<Result<AtprotoDid, string>> { 184 - if (isDid(identifier)) return ok(identifier as AtprotoDid); 185 173 186 - const cached = handleCache.get(identifier); 187 - if (cached) return ok(cached); 188 - const cachedSignal = handleCache.getSignal(identifier); 189 - 190 - const res = await Promise.race([ 191 - fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 192 - handle: identifier 193 - }), 194 - cachedSignal.then((d): Result<{ did: Did }, string> => ok({ did: d })) 195 - ]); 196 - 197 - const mapped = map(res, (data) => data.did as AtprotoDid); 198 - 199 - if (mapped.ok) handleCache.set(identifier, mapped.value); 200 - 201 - return mapped; 174 + for (const record of res.data.records) { 175 + await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 176 + } 177 + return ok(res.data); 202 178 } 203 179 204 - async resolveDidDoc(handleOrDid: ActorIdentifier): Promise<Result<MiniDoc, string>> { 205 - const cached = didDocCache.get(handleOrDid); 206 - if (cached) return ok(cached); 207 - const cachedSignal = didDocCache.getSignal(handleOrDid); 180 + async listRecordsAll<Collection extends keyof Records>( 181 + collection: Collection 182 + ): Promise<ReturnType<typeof this.listRecords>> { 183 + const data: InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']> = { 184 + records: [] 185 + }; 208 186 209 - const result = await Promise.race([ 210 - fetchMicrocosm(slingshotUrl, MiniDocQuery, { 211 - identifier: handleOrDid 212 - }), 213 - cachedSignal.then((d): Result<MiniDoc, string> => ok(d)) 214 - ]); 187 + let end = false; 188 + while (!end) { 189 + const res = await this.listRecords(collection, data.cursor); 190 + if (!res.ok) return res; 191 + data.cursor = res.value.cursor; 192 + data.records.push(...res.value.records); 193 + end = !res.value.cursor; 194 + } 215 195 216 - if (result.ok) didDocCache.set(handleOrDid, result.value); 217 - 218 - return result; 196 + return ok(data); 219 197 } 220 198 221 199 async getBacklinksUri( ··· 235 213 repo: ActorIdentifier, 236 214 collection: Nsid, 237 215 rkey: RecordKey, 238 - source: BacklinksSource 216 + source: BacklinksSource, 217 + limit?: number 239 218 ): Promise<Result<Backlinks, string>> { 240 - const did = await this.resolveHandle(repo); 219 + const did = await resolveHandle(repo); 241 220 if (!did.ok) return err(`cant resolve handle: ${did.error}`); 242 221 243 222 const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)); 244 223 const query = fetchMicrocosm(constellationUrl, BacklinksQuery, { 245 224 subject: `at://${did.value}/${collection}/${rkey}`, 246 225 source, 247 - limit: 100 226 + limit: limit || 100 248 227 }); 249 228 250 229 const results = await Promise.race([query, timeout]); ··· 252 231 253 232 return results; 254 233 } 234 + } 255 235 256 - streamNotifications(subjects: Did[], ...sources: BacklinksSource[]): NotificationsStream { 257 - const url = new URL(spacedustUrl); 258 - url.protocol = 'wss:'; 259 - url.pathname = '/subscribe'; 260 - const searchParams = []; 261 - sources.every((source) => searchParams.push(['wantedSources', source])); 262 - subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject])); 263 - subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`])); 264 - searchParams.push(['instant', 'true']); 265 - url.search = `?${new URLSearchParams(searchParams)}`; 266 - // console.log(`streaming notifications: ${url}`); 267 - const encoder = WebSocket.getDefaultEncoder<undefined, Notification>(); 268 - const ws = new WebSocket<typeof encoder>(url.toString(), { 269 - encoder 270 - }); 271 - return ws; 236 + export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => { 237 + const atp = new AtpClient(); 238 + const didDoc = await resolveDidDoc(ident); 239 + if (!didDoc.ok) { 240 + console.error('failed to resolve did doc', didDoc.error); 241 + return atp; 272 242 } 243 + atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) }); 244 + atp.user = { did: didDoc.value.did, handle: didDoc.value.handle }; 245 + return atp; 246 + }; 247 + 248 + // Wrappers that use the cache 249 + 250 + export const resolveHandle = async ( 251 + identifier: ActorIdentifier 252 + ): Promise<Result<AtprotoDid, string>> => { 253 + if (isDid(identifier)) return ok(identifier as AtprotoDid); 254 + 255 + try { 256 + const did = await cache.resolveHandle(identifier); 257 + return ok(did); 258 + } catch (e) { 259 + return err(String(e)); 260 + } 261 + }; 262 + 263 + export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => { 264 + try { 265 + const doc = await cache.resolveDidDoc(ident); 266 + return ok(doc); 267 + } catch (e) { 268 + return err(String(e)); 269 + } 270 + }; 273 271 274 - // streamJetstream(subjects: Did[], ...collections: Nsid[]) { 275 - // return new JetstreamSubscription({ 276 - // url: 'wss://jetstream2.fr.hose.cam', 277 - // wantedCollections: collections, 278 - // wantedDids: subjects 279 - // }); 280 - // } 281 - } 272 + type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 273 + export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 274 + export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 275 + 276 + export const streamNotifications = ( 277 + subjects: Did[], 278 + ...sources: BacklinksSource[] 279 + ): NotificationsStream => { 280 + const url = new URL(spacedustUrl); 281 + url.protocol = 'wss:'; 282 + url.pathname = '/subscribe'; 283 + const searchParams = []; 284 + sources.every((source) => searchParams.push(['wantedSources', source])); 285 + subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject])); 286 + subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`])); 287 + searchParams.push(['instant', 'true']); 288 + url.search = `?${new URLSearchParams(searchParams)}`; 289 + // console.log(`streaming notifications: ${url}`); 290 + const encoder = WebSocket.getDefaultEncoder<undefined, Notification>(); 291 + const ws = new WebSocket<typeof encoder>(url.toString(), { 292 + encoder 293 + }); 294 + return ws; 295 + }; 282 296 283 297 const fetchMicrocosm = async < 284 298 Schema extends XRPCQueryMetadata,
+2 -4
src/lib/at/fetch.ts
··· 4 4 type Cid, 5 5 type ResourceUri 6 6 } from '@atcute/lexicons'; 7 - import { recordCache, type AtpClient } from './client'; 7 + import { type AtpClient } from './client'; 8 8 import { err, expect, ok, type Result } from '$lib/result'; 9 9 import type { Backlinks } from './constellation'; 10 10 import { AppBskyFeedPost } from '@atcute/bluesky'; ··· 20 20 21 21 export const fetchPostsWithBacklinks = async ( 22 22 client: AtpClient, 23 - repo: AtprotoDid, 24 23 cursor?: string, 25 24 limit?: number 26 25 ): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => { 27 - const recordsList = await client.listRecords('app.bsky.feed.post', repo, cursor, limit); 26 + const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit); 28 27 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 29 28 cursor = recordsList.value.cursor; 30 29 const records = recordsList.value.records; ··· 32 31 try { 33 32 const allBacklinks = await Promise.all( 34 33 records.map(async (r): Promise<PostWithBacklinks> => { 35 - recordCache.set(r.uri, r); 36 34 const replies = await client.getBacklinksUri(r.uri, replySource); 37 35 if (!replies.ok) throw `cant fetch replies: ${replies.error}`; 38 36 return {
+193 -88
src/lib/cache.ts
··· 1 - import { Cache, type CacheOptions } from '@wora/cache-persist'; 2 - import { LRUCache } from 'lru-cache'; 1 + import { createCache } from 'async-cache-dedupe'; 2 + 3 + const DB_NAME = 'nucleus-cache'; 4 + const STORE_NAME = 'keyvalue'; 5 + const DB_VERSION = 1; 6 + 7 + type WriteOp = 8 + | { 9 + type: 'put'; 10 + key: string; 11 + value: { value: unknown; expires: number }; 12 + resolve: () => void; 13 + reject: (err: unknown) => void; 14 + } 15 + | { type: 'delete'; key: string; resolve: () => void; reject: (err: unknown) => void }; 16 + type ReadOp = { 17 + key: string; 18 + resolve: (val: unknown) => void; 19 + reject: (err: unknown) => void; 20 + }; 21 + 22 + class IDBStorage { 23 + private dbPromise: Promise<IDBDatabase> | null = null; 24 + 25 + private getBatch: ReadOp[] = []; 26 + private writeBatch: WriteOp[] = []; 27 + 28 + private getFlushScheduled = false; 29 + private writeFlushScheduled = false; 30 + 31 + constructor() { 32 + if (typeof indexedDB === 'undefined') { 33 + return; 34 + } 35 + 36 + this.dbPromise = new Promise((resolve, reject) => { 37 + const request = indexedDB.open(DB_NAME, DB_VERSION); 3 38 4 - export interface PersistedLRUOptions { 5 - prefix?: string; 6 - max: number; 7 - ttl?: number; 8 - persistOptions?: CacheOptions; 9 - } 39 + request.onerror = () => { 40 + console.error('IDB open error:', request.error); 41 + reject(request.error); 42 + }; 10 43 11 - interface PersistedEntry<V> { 12 - value: V; 13 - addedAt: number; 14 - } 44 + request.onsuccess = () => resolve(request.result); 45 + 46 + request.onupgradeneeded = (event) => { 47 + const db = (event.target as IDBOpenDBRequest).result; 48 + if (!db.objectStoreNames.contains(STORE_NAME)) { 49 + db.createObjectStore(STORE_NAME); 50 + } 51 + }; 52 + }); 53 + } 15 54 16 - // eslint-disable-next-line @typescript-eslint/no-empty-object-type 17 - export class PersistedLRU<K extends string, V extends {}> { 18 - private memory: LRUCache<K, V>; 19 - private storage: Cache; 20 - private signals: Map<K, ((data: V) => void)[]>; 55 + async get(key: string): Promise<unknown> { 56 + // checking in-flight writes 57 + for (let i = this.writeBatch.length - 1; i >= 0; i--) { 58 + const op = this.writeBatch[i]; 59 + if (op.key === key) { 60 + if (op.type === 'delete') return undefined; 61 + if (op.type === 'put') { 62 + // if expired we dont want it 63 + if (op.value.expires < Date.now()) return undefined; 64 + return op.value.value; 65 + } 66 + } 67 + } 21 68 22 - private prefix = ''; 69 + if (!this.dbPromise) return undefined; 23 70 24 - constructor(opts: PersistedLRUOptions) { 25 - this.memory = new LRUCache<K, V>({ 26 - max: opts.max, 27 - ttl: opts.ttl 71 + return new Promise((resolve, reject) => { 72 + this.getBatch.push({ key, resolve, reject }); 73 + this.scheduleGetFlush(); 28 74 }); 29 - this.storage = new Cache(opts.persistOptions); 30 - this.prefix = opts.prefix ? `${opts.prefix}%` : ''; 31 - this.signals = new Map(); 75 + } 32 76 33 - this.init(); 77 + private scheduleGetFlush() { 78 + if (this.getFlushScheduled) return; 79 + this.getFlushScheduled = true; 80 + queueMicrotask(() => this.flushGetBatch()); 34 81 } 35 82 36 - async init(): Promise<void> { 37 - await this.storage.restore(); 83 + private async flushGetBatch() { 84 + this.getFlushScheduled = false; 85 + const batch = this.getBatch; 86 + this.getBatch = []; 38 87 39 - const state = this.storage.getState(); 40 - for (const [key, val] of Object.entries(state)) { 41 - try { 42 - const k = this.unprefix(key) as unknown as K; 88 + if (batch.length === 0) return; 89 + 90 + try { 91 + const db = await this.dbPromise; 92 + if (!db) throw new Error('DB not available'); 43 93 44 - if (this.isPersistedEntry(val)) { 45 - const entry = val as PersistedEntry<V>; 46 - this.memory.set(k, entry.value, { start: entry.addedAt }); 47 - } else { 48 - // Handle legacy data (before this update) 49 - this.memory.set(k, val as V); 94 + const transaction = db.transaction(STORE_NAME, 'readonly'); 95 + const store = transaction.objectStore(STORE_NAME); 96 + 97 + batch.forEach(({ key, resolve }) => { 98 + try { 99 + const request = store.get(key); 100 + request.onsuccess = () => { 101 + const result = request.result; 102 + if (!result) { 103 + resolve(undefined); 104 + return; 105 + } 106 + if (result.expires < Date.now()) { 107 + // Fire-and-forget removal for expired items 108 + this.remove(key).catch(() => {}); 109 + resolve(undefined); 110 + return; 111 + } 112 + resolve(result.value); 113 + }; 114 + request.onerror = () => resolve(undefined); 115 + } catch { 116 + resolve(undefined); 50 117 } 51 - } catch (err) { 52 - console.warn('skipping invalid persisted entry', key, err); 53 - } 118 + }); 119 + } catch (error) { 120 + batch.forEach(({ reject }) => reject(error)); 54 121 } 55 122 } 56 123 57 - get(key: K): V | undefined { 58 - return this.memory.get(key); 124 + async set(key: string, value: unknown, ttl: number): Promise<void> { 125 + if (!this.dbPromise) return; 126 + 127 + const expires = Date.now() + ttl * 1000; 128 + const storageValue = { value, expires }; 129 + 130 + return new Promise((resolve, reject) => { 131 + this.writeBatch.push({ type: 'put', key, value: storageValue, resolve, reject }); 132 + this.scheduleWriteFlush(); 133 + }); 59 134 } 60 135 61 - getSignal(key: K): Promise<V> { 62 - return new Promise<V>((resolve) => { 63 - if (!this.signals.has(key)) { 64 - this.signals.set(key, [resolve]); 65 - return; 66 - } 67 - const signals = this.signals.get(key)!; 68 - signals.push(resolve); 69 - this.signals.set(key, signals); 136 + async remove(key: string): Promise<void> { 137 + if (!this.dbPromise) return; 138 + 139 + return new Promise((resolve, reject) => { 140 + this.writeBatch.push({ type: 'delete', key, resolve, reject }); 141 + this.scheduleWriteFlush(); 70 142 }); 71 143 } 72 144 73 - set(key: K, value: V): void { 74 - const addedAt = performance.now(); 75 - this.memory.set(key, value, { start: addedAt }); 145 + private scheduleWriteFlush() { 146 + if (this.writeFlushScheduled) return; 147 + this.writeFlushScheduled = true; 148 + queueMicrotask(() => this.flushWriteBatch()); 149 + } 150 + 151 + private async flushWriteBatch() { 152 + this.writeFlushScheduled = false; 153 + const batch = this.writeBatch; 154 + this.writeBatch = []; 155 + 156 + if (batch.length === 0) return; 157 + 158 + try { 159 + const db = await this.dbPromise; 160 + if (!db) throw new Error('DB not available'); 76 161 77 - const entry: PersistedEntry<V> = { value, addedAt }; 78 - this.storage.set(this.prefixed(key), entry); 162 + const transaction = db.transaction(STORE_NAME, 'readwrite'); 163 + const store = transaction.objectStore(STORE_NAME); 79 164 80 - const signals = this.signals.get(key); 81 - let signal = signals?.pop(); 82 - while (signal) { 83 - signal(value); 84 - signal = signals?.pop(); 165 + batch.forEach((op) => { 166 + try { 167 + let request: IDBRequest; 168 + if (op.type === 'put') { 169 + request = store.put(op.value, op.key); 170 + } else { 171 + request = store.delete(op.key); 172 + } 173 + 174 + request.onsuccess = () => op.resolve(); 175 + request.onerror = () => op.reject(request.error); 176 + } catch (err) { 177 + op.reject(err); 178 + } 179 + }); 180 + } catch (error) { 181 + batch.forEach(({ reject }) => reject(error)); 85 182 } 86 - this.storage.flush(); 87 183 } 88 184 89 - has(key: K): boolean { 90 - return this.memory.has(key); 91 - } 185 + async clear(): Promise<void> { 186 + if (!this.dbPromise) return; 187 + try { 188 + const db = await this.dbPromise; 189 + return new Promise<void>((resolve, reject) => { 190 + const transaction = db.transaction(STORE_NAME, 'readwrite'); 191 + const store = transaction.objectStore(STORE_NAME); 192 + const request = store.clear(); 92 193 93 - delete(key: K): void { 94 - this.memory.delete(key); 95 - this.storage.delete(this.prefixed(key)); 96 - this.storage.flush(); 194 + request.onerror = () => reject(request.error); 195 + request.onsuccess = () => resolve(); 196 + }); 197 + } catch (e) { 198 + console.error('IDB clear error', e); 199 + } 97 200 } 98 201 99 - clear(): void { 100 - this.memory.clear(); 101 - this.storage.purge(); 102 - this.storage.flush(); 202 + async exists(key: string): Promise<boolean> { 203 + return (await this.get(key)) !== undefined; 103 204 } 104 205 105 - private prefixed(key: K): string { 106 - return this.prefix + key; 206 + async invalidate(key: string): Promise<void> { 207 + return this.remove(key); 107 208 } 108 209 109 - private unprefix(prefixed: string): string { 110 - return prefixed.slice(this.prefix.length); 210 + // noops 211 + async getTTL(key: string): Promise<void> { 212 + return; 111 213 } 112 - 113 - // Type guard to check if data is our new PersistedEntry format 114 - private isPersistedEntry(data: unknown): data is PersistedEntry<V> { 115 - return ( 116 - data !== null && 117 - typeof data === 'object' && 118 - 'value' in data && 119 - 'addedAt' in data && 120 - typeof data.addedAt === 'number' 121 - ); 214 + async refresh(): Promise<void> { 215 + return; 122 216 } 123 217 } 218 + 219 + export const cache = createCache({ 220 + storage: { 221 + type: 'custom', 222 + options: { 223 + storage: new IDBStorage() 224 + } 225 + }, 226 + ttl: 60 * 60 * 24, // 24 hours 227 + onError: (err) => console.error(err) 228 + });
+18
src/lib/date.ts
··· 1 + export const getRelativeTime = (date: Date) => { 2 + const now = new Date(); 3 + const diff = now.getTime() - date.getTime(); 4 + const seconds = Math.floor(diff / 1000); 5 + const minutes = Math.floor(seconds / 60); 6 + const hours = Math.floor(minutes / 60); 7 + const days = Math.floor(hours / 24); 8 + const months = Math.floor(days / 30); 9 + const years = Math.floor(months / 12); 10 + 11 + if (years > 0) return `${years}y`; 12 + if (months > 0) return `${months}m`; 13 + if (days > 0) return `${days}d`; 14 + if (hours > 0) return `${hours}h`; 15 + if (minutes > 0) return `${minutes}m`; 16 + if (seconds > 0) return `${seconds}s`; 17 + return 'now'; 18 + };
+5 -10
src/lib/richtext/index.ts
··· 1 1 import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder'; 2 2 import { tokenize, type Token } from '$lib/richtext/parser'; 3 3 import type { Did, GenericUri, Handle } from '@atcute/lexicons'; 4 - import type { AtpClient } from '$lib/at/client'; 4 + import { resolveHandle } from '$lib/at/client'; 5 5 6 - export const parseToRichText = ( 7 - client: AtpClient, 8 - text: string 9 - ): ReturnType<typeof processTokens> => { 10 - const tokens = tokenize(text); 11 - return processTokens(client, tokens); 12 - }; 6 + export const parseToRichText = (text: string): ReturnType<typeof processTokens> => 7 + processTokens(tokenize(text)); 13 8 14 - const processTokens = async (client: AtpClient, tokens: Token[]): Promise<BakedRichtext> => { 9 + const processTokens = async (tokens: Token[]): Promise<BakedRichtext> => { 15 10 const rt = new RichtextBuilder(); 16 11 17 12 for (const token of tokens) { ··· 23 18 let did: Did | undefined = token.did as Did | undefined; 24 19 if (!did) { 25 20 const handle = token.handle as Handle; 26 - const result = await client.resolveHandle(handle); 21 + const result = await resolveHandle(handle); 27 22 if (result.ok) did = result.value; 28 23 } 29 24 if (did) rt.addMention(token.raw, did);
+7 -4
src/lib/settings.ts
··· 5 5 slingshot: string; 6 6 spacedust: string; 7 7 constellation: string; 8 + jetstream: string; 8 9 }; 9 10 export type Settings = { 10 11 endpoints: ApiEndpoints; ··· 16 17 endpoints: { 17 18 slingshot: 'https://slingshot.microcosm.blue', 18 19 spacedust: 'https://spacedust.microcosm.blue', 19 - constellation: 'https://constellation.microcosm.blue' 20 + constellation: 'https://constellation.microcosm.blue', 21 + jetstream: 'wss://jetstream2.fr.hose.cam' 20 22 }, 21 23 theme: defaultTheme, 22 24 socialAppUrl: 'https://bsky.app' ··· 26 28 const stored = localStorage.getItem('settings'); 27 29 28 30 const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings; 29 - initial.endpoints = initial.endpoints ?? defaultSettings.endpoints; 30 - initial.theme = initial.theme ?? defaultSettings.theme; 31 + initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints }; 32 + initial.theme = { ...defaultSettings.theme, ...initial.theme }; 31 33 initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl; 32 34 33 35 const { subscribe, set, update } = writable<Settings>(initial as Settings); ··· 66 68 return ( 67 69 current.endpoints.slingshot !== other.endpoints.slingshot || 68 70 current.endpoints.spacedust !== other.endpoints.spacedust || 69 - current.endpoints.constellation !== other.endpoints.constellation 71 + current.endpoints.constellation !== other.endpoints.constellation || 72 + current.endpoints.jetstream !== other.endpoints.jetstream 70 73 ); 71 74 };
+113 -5
src/lib/state.svelte.ts
··· 1 1 import { writable } from 'svelte/store'; 2 - import { AtpClient, type NotificationsStream } from './at/client'; 2 + import { AtpClient, newPublicClient, type NotificationsStream } from './at/client'; 3 3 import { SvelteMap } from 'svelte/reactivity'; 4 - import type { Did, ResourceUri } from '@atcute/lexicons'; 4 + import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons'; 5 5 import type { Backlink } from './at/constellation'; 6 - import type { PostWithUri } from './at/fetch'; 6 + import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; 7 7 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 8 - // import type { JetstreamSubscription } from '@atcute/jetstream'; 8 + import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 9 + import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 10 + import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 9 11 10 12 export const notificationStream = writable<NotificationsStream | null>(null); 11 - // export const jetstream = writable<JetstreamSubscription | null>(null); 13 + export const jetstream = writable<JetstreamSubscription | null>(null); 12 14 13 15 export type PostActions = { 14 16 like: Backlink | null; ··· 22 24 23 25 export const viewClient = new AtpClient(); 24 26 export const clients = new SvelteMap<AtprotoDid, AtpClient>(); 27 + export const getClient = async (did: AtprotoDid): Promise<AtpClient> => { 28 + if (!clients.has(did)) clients.set(did, await newPublicClient(did)); 29 + return clients.get(did)!; 30 + }; 31 + 32 + export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 33 + 34 + export const addFollows = ( 35 + did: Did, 36 + followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]> 37 + ) => { 38 + if (!follows.has(did)) { 39 + follows.set(did, new SvelteMap(followMap)); 40 + return; 41 + } 42 + const map = follows.get(did)!; 43 + for (const [uri, record] of followMap) map.set(uri, record); 44 + }; 45 + 46 + export const fetchFollows = async (did: AtprotoDid) => { 47 + const client = await getClient(did); 48 + const res = await client.listRecordsAll('app.bsky.graph.follow'); 49 + if (!res.ok) return; 50 + addFollows( 51 + did, 52 + res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) 53 + ); 54 + }; 55 + 56 + export const fetchFollowPosts = async (did: AtprotoDid) => { 57 + const client = await getClient(did); 58 + const res = await client.listRecords('app.bsky.feed.post'); 59 + if (!res.ok) return; 60 + addPostsRaw(did, res.value); 61 + }; 25 62 26 63 export const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 27 64 export const cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 65 + 66 + export const addPostsRaw = ( 67 + did: Did, 68 + _posts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 69 + ) => { 70 + const postsWithUri = new SvelteMap( 71 + _posts.records.map((post) => [ 72 + post.uri, 73 + { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 74 + ]) 75 + ); 76 + addPosts(did, postsWithUri); 77 + cursors.set(did, { value: _posts.cursor, end: _posts.cursor === undefined }); 78 + }; 79 + 80 + export const addPosts = (did: Did, _posts: Iterable<[ResourceUri, PostWithUri]>) => { 81 + if (!posts.has(did)) { 82 + posts.set(did, new SvelteMap(_posts)); 83 + return; 84 + } 85 + const map = posts.get(did)!; 86 + for (const [uri, record] of _posts) map.set(uri, record); 87 + }; 88 + 89 + export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { 90 + const client = await getClient(did); 91 + 92 + const cursor = cursors.get(did); 93 + if (cursor && cursor.end) return; 94 + 95 + const accPosts = await fetchPostsWithBacklinks(client, cursor?.value, limit); 96 + if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; 97 + 98 + // if the cursor is undefined, we've reached the end of the timeline 99 + if (!accPosts.value.cursor) { 100 + cursors.set(did, { ...cursor, end: true }); 101 + return; 102 + } 103 + 104 + cursors.set(did, { value: accPosts.value.cursor, end: false }); 105 + const hydrated = await hydratePosts(client, did, accPosts.value.posts); 106 + if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 107 + 108 + addPosts(did, hydrated.value); 109 + }; 110 + 111 + export const handleJetstreamEvent = (event: JetstreamEvent) => { 112 + if (event.kind !== 'commit') return; 113 + 114 + const { did, commit } = event; 115 + if (commit.collection !== 'app.bsky.feed.post') return; 116 + 117 + const uri: ResourceUri = `at://${did}/${commit.collection}/${commit.rkey}`; 118 + 119 + if (commit.operation === 'create') { 120 + const { cid, record } = commit; 121 + 122 + const post: PostWithUri = { 123 + uri, 124 + cid, 125 + // assume record is valid, we trust the jetstream 126 + record: record as AppBskyFeedPost.Main 127 + }; 128 + 129 + addPosts(did, [[uri, post]]); 130 + } else if (commit.operation === 'delete') { 131 + if (posts.has(did)) { 132 + posts.get(did)?.delete(uri); 133 + } 134 + } 135 + };
+7 -2
src/lib/thread.ts
··· 20 20 branchParentPost?: ThreadPost; 21 21 }; 22 22 23 - export const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => { 23 + export const buildThreads = ( 24 + accounts: Did[], 25 + posts: Map<Did, Map<ResourceUri, PostWithUri>> 26 + ): Thread[] => { 24 27 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 25 28 26 29 // group posts by root uri into "thread" chains 27 - for (const [account, timeline] of timelines) { 30 + for (const account of accounts) { 31 + const timeline = posts.get(account); 32 + if (!timeline) continue; 28 33 for (const [uri, data] of timeline) { 29 34 const parsedUri = expect(parseCanonicalResourceUri(uri)); 30 35 const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
+107 -79
src/routes/+page.svelte
··· 4 4 import AccountSelector from '$components/AccountSelector.svelte'; 5 5 import SettingsView from '$components/SettingsView.svelte'; 6 6 import NotificationsView from '$components/NotificationsView.svelte'; 7 - import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client'; 8 - import { accounts, generateColorForDid, type Account } from '$lib/accounts'; 9 - import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons'; 7 + import FollowingView from '$components/FollowingView.svelte'; 8 + import { AtpClient, streamNotifications, type NotificationsStreamEvent } from '$lib/at/client'; 9 + import { accounts, type Account } from '$lib/accounts'; 10 + import { parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons'; 10 11 import { onMount, tick } from 'svelte'; 11 - import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch'; 12 + import { hydratePosts } from '$lib/at/fetch'; 12 13 import { expect } from '$lib/result'; 13 14 import { AppBskyFeedPost } from '@atcute/bluesky'; 14 15 import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 15 16 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 16 - import { clients, cursors, notificationStream, posts, viewClient } from '$lib/state.svelte'; 17 + import { 18 + addPosts, 19 + clients, 20 + cursors, 21 + fetchFollowPosts, 22 + fetchFollows, 23 + fetchTimeline, 24 + follows, 25 + getClient, 26 + notificationStream, 27 + posts, 28 + viewClient, 29 + jetstream, 30 + handleJetstreamEvent 31 + } from '$lib/state.svelte'; 17 32 import { get } from 'svelte/store'; 18 33 import Icon from '@iconify/svelte'; 19 34 import { sessions } from '$lib/at/oauth'; 20 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 35 + import type { AtprotoDid, Did } from '@atcute/lexicons/syntax'; 21 36 import type { PageProps } from './+page'; 22 37 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 38 + import { JetstreamSubscription } from '@atcute/jetstream'; 39 + import { settings } from '$lib/settings'; 23 40 24 41 const { data: loadData }: PageProps = $props(); 25 42 26 43 // svelte-ignore state_referenced_locally 27 44 let errors = $state(loadData.client.ok ? [] : [loadData.client.error]); 28 45 let errorsOpen = $state(false); 29 - 30 46 let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null); 31 47 $effect(() => { 32 - if (selectedDid) { 33 - localStorage.setItem('selectedDid', selectedDid); 34 - } else { 35 - localStorage.removeItem('selectedDid'); 36 - } 48 + if (selectedDid) localStorage.setItem('selectedDid', selectedDid); 49 + else localStorage.removeItem('selectedDid'); 37 50 }); 38 - 39 51 const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); 40 52 41 53 const loginAccount = async (account: Account) => { ··· 48 60 } 49 61 clients.set(account.did, client); 50 62 }; 51 - 52 63 const handleAccountSelected = async (did: AtprotoDid) => { 53 64 selectedDid = did; 54 65 const account = $accounts.find((acc) => acc.did === did); ··· 66 77 handleAccountSelected(newAccounts[0]?.did); 67 78 }; 68 79 69 - type View = 'timeline' | 'notifications' | 'settings'; 80 + type View = 'timeline' | 'notifications' | 'following' | 'settings'; 70 81 let currentView = $state<View>('timeline'); 71 82 let animClass = $state('animate-fade-in-scale'); 72 - let timelineScrollPosition = $state(0); 83 + let scrollPositions = new SvelteMap<View, number>(); 73 84 74 85 const viewOrder: Record<View, number> = { 75 86 timeline: 0, 76 - notifications: 1, 77 - settings: 2 87 + following: 1, 88 + notifications: 2, 89 + settings: 3 78 90 }; 79 91 80 92 const switchView = async (newView: View) => { 81 93 if (currentView === newView) return; 82 - if (currentView === 'timeline') timelineScrollPosition = window.scrollY; 94 + scrollPositions.set(currentView, window.scrollY); 83 95 84 96 const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left'; 85 97 animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; ··· 87 99 88 100 await tick(); 89 101 90 - if (newView !== 'timeline') window.scrollTo({ top: 0, behavior: 'instant' }); 91 - else window.scrollTo({ top: timelineScrollPosition, behavior: 'instant' }); 102 + window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' }); 92 103 }; 93 - 94 104 let reverseChronological = $state(true); 95 105 let viewOwnPosts = $state(true); 96 106 97 - const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts })); 98 - 107 + const threads = $derived( 108 + filterThreads( 109 + buildThreads( 110 + $accounts.map((account) => account.did), 111 + posts 112 + ), 113 + $accounts, 114 + { viewOwnPosts } 115 + ) 116 + ); 99 117 let postComposerState = $state<PostComposerState>({ type: 'null' }); 100 118 101 119 const expandedThreads = new SvelteSet<ResourceUri>(); 102 120 103 - const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => { 104 - if (!posts.has(did)) { 105 - posts.set(did, new SvelteMap(accTimeline)); 106 - return; 107 - } 108 - const map = posts.get(did)!; 109 - for (const [uri, record] of accTimeline) map.set(uri, record); 110 - }; 111 - 112 - const fetchTimeline = async (account: Account) => { 113 - const client = clients.get(account.did); 114 - if (!client) return; 115 - 116 - const cursor = cursors.get(account.did); 117 - if (cursor && cursor.end) return; 118 - 119 - const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6); 120 - if (!accPosts.ok) throw `cant fetch posts @${account.handle}: ${accPosts.error}`; 121 - 122 - // if the cursor is undefined, we've reached the end of the timeline 123 - if (!accPosts.value.cursor) { 124 - cursors.set(account.did, { ...cursor, end: true }); 125 - return; 126 - } 127 - 128 - cursors.set(account.did, { value: accPosts.value.cursor, end: false }); 129 - const hydrated = await hydratePosts(client, account.did, accPosts.value.posts); 130 - if (!hydrated.ok) throw `cant hydrate posts @${account.handle}: ${hydrated.error}`; 131 - 132 - addPosts(account.did, hydrated.value); 133 - }; 134 - 135 - const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline)); 121 + const fetchTimelines = (newAccounts: Account[]) => 122 + Promise.all(newAccounts.map((acc) => fetchTimeline(acc.did))); 136 123 137 124 const handleNotification = async (event: NotificationsStreamEvent) => { 138 125 if (event.type === 'message') { 139 - // console.log(event.data); 140 126 const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 141 - const subjectPost = await viewClient.getRecord( 127 + const did = parsedSubjectUri.repo as AtprotoDid; 128 + const client = await getClient(did); 129 + const subjectPost = await client.getRecord( 142 130 AppBskyFeedPost.mainSchema, 143 - parsedSubjectUri.repo, 131 + did, 144 132 parsedSubjectUri.rkey 145 133 ); 146 134 if (!subjectPost.ok) return; 147 135 148 136 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 149 - const hydrated = await hydratePosts(viewClient, parsedSubjectUri.repo as AtprotoDid, [ 137 + const hydrated = await hydratePosts(client, did, [ 150 138 { 151 139 record: subjectPost.value.record, 152 140 uri: event.data.link.subject, ··· 164 152 } 165 153 } 166 154 ]); 167 - 168 155 if (!hydrated.ok) { 169 - errors.push(`cant hydrate posts @${parsedSubjectUri.repo}: ${hydrated.error}`); 156 + errors.push(`cant hydrate posts ${did}: ${hydrated.error}`); 170 157 return; 171 158 } 172 159 173 160 // console.log(hydrated); 174 - addPosts(parsedSubjectUri.repo, hydrated.value); 161 + addPosts(did, hydrated.value); 175 162 } 176 163 }; 177 164 ··· 185 172 const handleScroll = () => { 186 173 if (currentView === 'timeline') showScrollToTop = window.scrollY > 300; 187 174 }; 188 - 189 175 const scrollToTop = () => { 190 176 window.scrollTo({ top: 0, behavior: 'smooth' }); 191 177 }; ··· 218 204 // jetstream.set(null); 219 205 if (newAccounts.length === 0) return; 220 206 notificationStream.set( 221 - viewClient.streamNotifications( 207 + streamNotifications( 222 208 newAccounts.map((account) => account.did), 223 - 'app.bsky.feed.post:reply.parent.uri' 209 + 'app.bsky.feed.post:reply.parent.uri', 210 + 'app.bsky.feed.post:embed.record.record.uri', 211 + 'app.bsky.feed.post:embed.record.uri' 224 212 ) 225 213 ); 226 214 }); ··· 228 216 if (!stream) return; 229 217 stream.listen(handleNotification); 230 218 }); 219 + 220 + console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`); 221 + const jetstreamSub = new JetstreamSubscription({ 222 + url: $settings.endpoints.jetstream, 223 + wantedCollections: ['app.bsky.feed.post'], 224 + wantedDids: ['did:web:guestbook.gaze.systems'] // initially contain sentinel 225 + }); 226 + jetstream.set(jetstreamSub); 227 + 228 + (async () => { 229 + console.log('polling for jetstream...'); 230 + for await (const event of jetstreamSub) handleJetstreamEvent(event); 231 + })(); 232 + 231 233 if ($accounts.length > 0) { 232 234 loaderState.status = 'LOADING'; 233 235 if (loadData.client.ok && loadData.client.value) { ··· 236 238 clients.set(loggedInDid, loadData.client.value); 237 239 } 238 240 if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did; 239 - console.log('onMount selectedDid', selectedDid); 241 + // console.log('onMount selectedDid', selectedDid); 240 242 Promise.all($accounts.map(loginAccount)).then(() => { 243 + $accounts.forEach((account) => 244 + fetchFollows(account.did).then(() => 245 + follows 246 + .get(account.did) 247 + ?.forEach((follow) => fetchFollowPosts(follow.subject as AtprotoDid)) 248 + ) 249 + ); 241 250 loadMore(); 242 251 }); 243 252 } else { 244 253 selectedDid = null; 245 254 } 246 255 247 - return () => { 248 - window.removeEventListener('scroll', handleScroll); 249 - }; 256 + return () => window.removeEventListener('scroll', handleScroll); 257 + }); 258 + 259 + $effect(() => { 260 + const wantedDids: Did[] = ['did:web:guestbook.gaze.systems']; 261 + 262 + for (const followMap of follows.values()) 263 + for (const follow of followMap.values()) wantedDids.push(follow.subject); 264 + for (const account of $accounts) wantedDids.push(account.did); 265 + 266 + console.log('updating jetstream options:', wantedDids); 267 + $jetstream?.updateOptions({ wantedDids }); 250 268 }); 251 269 </script> 252 270 ··· 271 289 {/snippet} 272 290 273 291 <div class="mx-auto flex min-h-dvh max-w-2xl flex-col"> 274 - <!-- Views Container --> 275 292 <div class="flex-1"> 276 293 <!-- timeline --> 277 294 <div 278 295 id="app-thread-list" 279 - class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {currentView === 280 - 'timeline' 281 - ? `block ${animClass}` 282 - : 'hidden'}" 296 + class=" 297 + min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] 298 + {currentView === 'timeline' ? `${animClass}` : 'hidden'} 299 + " 283 300 bind:this={scrollContainer} 284 301 > 285 302 {#if $accounts.length > 0} ··· 292 309 </div> 293 310 {/if} 294 311 </div> 295 - 296 - <!-- other views --> 297 312 {#if currentView === 'settings'} 298 313 <div class={animClass}> 299 314 <SettingsView /> 300 315 </div> 301 - {:else if currentView === 'notifications'} 316 + {/if} 317 + {#if currentView === 'notifications'} 302 318 <div class={animClass}> 303 319 <NotificationsView /> 320 + </div> 321 + {/if} 322 + {#if currentView === 'following'} 323 + <div class={animClass}> 324 + <FollowingView selectedClient={selectedClient!} selectedDid={selectedDid!} /> 304 325 </div> 305 326 {/if} 306 327 </div> ··· 386 407 'timeline', 387 408 currentView === 'timeline', 388 409 'heroicons:home-solid' 410 + )} 411 + {@render appButton( 412 + () => switchView('following'), 413 + 'heroicons:users', 414 + 'following', 415 + currentView === 'following', 416 + 'heroicons:users-solid' 389 417 )} 390 418 {@render appButton( 391 419 () => switchView('notifications'),