replies timeline only, appview-less bluesky client

feat: implement oauth, fix some state issues

ptr.pet 607db7f8 156c793e

verified
+45 -1
deno.lock
··· 4 4 "npm:@atcute/atproto@^3.1.7": "3.1.8", 5 5 "npm:@atcute/bluesky@^3.2.7": "3.2.9", 6 6 "npm:@atcute/client@^4.0.5": "4.0.5", 7 + "npm:@atcute/identity-resolver@^1.1.4": "1.1.4_@atcute+identity@1.1.1", 7 8 "npm:@atcute/identity@^1.1.1": "1.1.1", 8 9 "npm:@atcute/lexicons@^1.2.2": "1.2.2", 10 + "npm:@atcute/oauth-browser-client@^2.0.1": "2.0.1_@atcute+identity@1.1.1", 9 11 "npm:@atcute/tid@^1.0.3": "1.0.3", 10 12 "npm:@eslint/compat@^1.4.0": "1.4.1_eslint@9.38.0", 11 13 "npm:@eslint/js@^9.36.0": "9.38.0", ··· 57 59 "@atcute/lexicons" 58 60 ] 59 61 }, 62 + "@atcute/identity-resolver@1.1.4_@atcute+identity@1.1.1": { 63 + "integrity": "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==", 64 + "dependencies": [ 65 + "@atcute/identity", 66 + "@atcute/lexicons", 67 + "@atcute/util-fetch", 68 + "@badrap/valita" 69 + ] 70 + }, 60 71 "@atcute/identity@1.1.1": { 61 72 "integrity": "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==", 62 73 "dependencies": [ ··· 71 82 "esm-env" 72 83 ] 73 84 }, 85 + "@atcute/multibase@1.1.6": { 86 + "integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==", 87 + "dependencies": [ 88 + "@atcute/uint8array" 89 + ] 90 + }, 91 + "@atcute/oauth-browser-client@2.0.1_@atcute+identity@1.1.1": { 92 + "integrity": "sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg==", 93 + "dependencies": [ 94 + "@atcute/client", 95 + "@atcute/identity", 96 + "@atcute/identity-resolver", 97 + "@atcute/lexicons", 98 + "@atcute/multibase", 99 + "@atcute/uint8array", 100 + "nanoid@5.1.6" 101 + ] 102 + }, 74 103 "@atcute/tid@1.0.3": { 75 104 "integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==" 105 + }, 106 + "@atcute/uint8array@1.0.5": { 107 + "integrity": "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q==" 108 + }, 109 + "@atcute/util-fetch@1.0.3": { 110 + "integrity": "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ==", 111 + "dependencies": [ 112 + "@badrap/valita" 113 + ] 76 114 }, 77 115 "@badrap/valita@0.4.6": { 78 116 "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" ··· 1354 1392 "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1355 1393 "bin": true 1356 1394 }, 1395 + "nanoid@5.1.6": { 1396 + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", 1397 + "bin": true 1398 + }, 1357 1399 "natural-compare@1.4.0": { 1358 1400 "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" 1359 1401 }, ··· 1434 1476 "postcss@8.5.6": { 1435 1477 "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", 1436 1478 "dependencies": [ 1437 - "nanoid", 1479 + "nanoid@3.3.11", 1438 1480 "picocolors", 1439 1481 "source-map-js" 1440 1482 ] ··· 1740 1782 "npm:@atcute/atproto@^3.1.7", 1741 1783 "npm:@atcute/bluesky@^3.2.7", 1742 1784 "npm:@atcute/client@^4.0.5", 1785 + "npm:@atcute/identity-resolver@^1.1.4", 1743 1786 "npm:@atcute/identity@^1.1.1", 1744 1787 "npm:@atcute/lexicons@^1.2.2", 1788 + "npm:@atcute/oauth-browser-client@^2.0.1", 1745 1789 "npm:@atcute/tid@^1.0.3", 1746 1790 "npm:@eslint/compat@^1.4.0", 1747 1791 "npm:@eslint/js@^9.36.0",
+3 -4
flake.lock
··· 17 17 }, 18 18 "nixpkgs": { 19 19 "locked": { 20 - "lastModified": 1761656231, 21 - "narHash": "sha256-EiED5k6gXTWoAIS8yQqi5mAX6ojnzpHwAQTS3ykeYMg=", 20 + "lastModified": 1761850514, 21 + "narHash": "sha256-qmg1yC6ybzH0/w4Bupx1hpgTS5MTl2qBMoD+DFx3hWM=", 22 22 "owner": "nixos", 23 23 "repo": "nixpkgs", 24 - "rev": "e99366c665bdd53b7b500ccdc5226675cfc51f45", 24 + "rev": "1c3d5f4e01f0b18b508be644d9d6a196fb7ed1f5", 25 25 "type": "github" 26 26 }, 27 27 "original": { 28 28 "owner": "nixos", 29 - "ref": "nixpkgs-unstable", 30 29 "repo": "nixpkgs", 31 30 "type": "github" 32 31 }
+2 -2
flake.nix
··· 1 1 { 2 2 inputs.parts.url = "github:hercules-ci/flake-parts"; 3 - inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 3 + inputs.nixpkgs.url = "github:nixos/nixpkgs"; 4 4 inputs.naked-shell.url = "github:90-008/mk-naked-shell"; 5 5 6 6 outputs = inp: ··· 17 17 devShells.default = config.mk-naked-shell.lib.mkNakedShell { 18 18 name = "nucleus-devshell"; 19 19 packages = with pkgs; [ 20 - nodejs-slim_latest deno 20 + nodejs-slim_latest deno biome 21 21 ]; 22 22 shellHook = '' 23 23 export PATH="$PATH:$PWD/node_modules/.bin"
+2
package.json
··· 18 18 "@atcute/bluesky": "^3.2.7", 19 19 "@atcute/client": "^4.0.5", 20 20 "@atcute/identity": "^1.1.1", 21 + "@atcute/identity-resolver": "^1.1.4", 21 22 "@atcute/lexicons": "^1.2.2", 23 + "@atcute/oauth-browser-client": "^2.0.1", 22 24 "@atcute/tid": "^1.0.3", 23 25 "@soffinal/websocket": "^0.2.1", 24 26 "@wora/cache-persist": "^2.2.1",
+7
src/app.css
··· 30 30 @apply rounded-sm border-2 border-(--nucleus-accent) px-3 py-2 font-semibold text-(--nucleus-accent) transition-all hover:scale-105 hover:bg-(--nucleus-accent)/20; 31 31 } 32 32 33 + @utility error-disclaimer { 34 + @apply rounded-sm border-2 border-red-500 bg-red-500/8 p-2; 35 + p { 36 + @apply text-base text-wrap wrap-break-word text-red-500; 37 + } 38 + } 39 + 33 40 :root { 34 41 scrollbar-width: thin; 35 42 scrollbar-color: var(--nucleus-accent) var(--nucleus-bg);
+29 -56
src/components/AccountSelector.svelte
··· 1 1 <script lang="ts"> 2 - import { generateColorForDid, type Account } from '$lib/accounts'; 2 + import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 3 import { AtpClient } from '$lib/at/client'; 4 - import type { Did, Handle } from '@atcute/lexicons'; 4 + import type { Handle } from '@atcute/lexicons'; 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import PfpPlaceholder from './PfpPlaceholder.svelte'; 7 + import { flow } from '$lib/at/oauth'; 8 + import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 9 + import Icon from '@iconify/svelte'; 7 10 8 11 interface Props { 9 12 client: AtpClient; 10 13 accounts: Array<Account>; 11 - selectedDid?: Did | null; 12 - onAccountSelected: (did: Did) => void; 13 - onLoginSucceed: (did: Did, handle: Handle, password: string) => void; 14 - onLogout: (did: Did) => void; 14 + selectedDid?: AtprotoDid | null; 15 + onAccountSelected: (did: AtprotoDid) => void; 16 + onLogout: (did: AtprotoDid) => void; 15 17 } 16 18 17 19 let { ··· 19 21 accounts = [], 20 22 selectedDid = $bindable(null), 21 23 onAccountSelected, 22 - onLoginSucceed, 23 24 onLogout 24 25 }: Props = $props(); 25 26 26 27 let isDropdownOpen = $state(false); 27 28 let isLoginModalOpen = $state(false); 28 29 let loginHandle = $state(''); 29 - let loginPassword = $state(''); 30 30 let loginError = $state(''); 31 31 let isLoggingIn = $state(false); 32 32 ··· 35 35 isDropdownOpen = !isDropdownOpen; 36 36 }; 37 37 38 - const selectAccount = (did: Did) => { 38 + const selectAccount = (did: AtprotoDid) => { 39 39 onAccountSelected(did); 40 40 isDropdownOpen = false; 41 41 }; ··· 44 44 isLoginModalOpen = true; 45 45 isDropdownOpen = false; 46 46 loginHandle = ''; 47 - loginPassword = ''; 48 47 loginError = ''; 49 48 }; 50 49 51 50 const closeLoginModal = () => { 52 51 isLoginModalOpen = false; 53 52 loginHandle = ''; 54 - loginPassword = ''; 55 53 loginError = ''; 56 54 }; 57 55 58 56 const handleLogin = async () => { 59 - if (!loginHandle || !loginPassword) { 60 - loginError = 'please enter both handle and password'; 61 - return; 62 - } 57 + try { 58 + if (!loginHandle) throw 'please enter handle'; 63 59 64 - isLoggingIn = true; 65 - loginError = ''; 60 + isLoggingIn = true; 61 + loginError = ''; 66 62 67 - try { 68 - const client = new AtpClient(); 69 - const result = await client.login(loginHandle as Handle, loginPassword); 63 + let handle: Handle; 64 + if (isHandle(loginHandle)) handle = loginHandle; 65 + else throw 'handle is invalid'; 70 66 71 - if (!result.ok) { 72 - loginError = result.error; 73 - isLoggingIn = false; 74 - return; 75 - } 67 + let did = await client.resolveHandle(handle); 68 + if (!did.ok) throw did.error; 76 69 77 - if (!client.didDoc) { 78 - loginError = 'failed to get did document'; 79 - isLoggingIn = false; 80 - return; 81 - } 82 - 83 - onLoginSucceed(client.didDoc.did, loginHandle as Handle, loginPassword); 84 - closeLoginModal(); 70 + loggingIn.set({ did: did.value, handle }); 71 + const result = await flow.start(handle); 72 + if (!result.ok) throw result.error; 85 73 } catch (error) { 86 74 loginError = `login failed: ${error}`; 75 + loggingIn.set(null); 87 76 } finally { 88 77 isLoggingIn = false; 89 78 } ··· 141 130 <svg 142 131 xmlns="http://www.w3.org/2000/svg" 143 132 onclick={() => onLogout(account.did)} 144 - class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md" 133 + class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 145 134 width="24" 146 135 height="24" 147 136 viewBox="0 0 20 20" ··· 173 162 </button> 174 163 {/each} 175 164 </div> 176 - <div 177 - class="mx-2 h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)" 178 - ></div> 165 + <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 179 166 {/if} 180 167 <button 181 168 onclick={openLoginModal} ··· 249 236 /> 250 237 </div> 251 238 252 - <div> 253 - <label for="password" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 254 - app password 255 - </label> 256 - <input 257 - id="password" 258 - type="password" 259 - bind:value={loginPassword} 260 - placeholder="xxxx-xxxx-xxxx-xxxx" 261 - class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 262 - disabled={isLoggingIn} 263 - /> 264 - </div> 265 - 266 239 {#if loginError} 267 - <div 268 - class="rounded-sm border-2 p-4" 269 - style="background: #ef444422; border-color: #ef4444;" 270 - > 271 - <p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p> 240 + <div class="error-disclaimer"> 241 + <p> 242 + <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 243 + {loginError} 244 + </p> 272 245 </div> 273 246 {/if} 274 247
+22 -21
src/components/BskyPost.svelte
··· 11 11 type ResourceUri 12 12 } from '@atcute/lexicons'; 13 13 import { expect, ok } from '$lib/result'; 14 - import { generateColorForDid } from '$lib/accounts'; 14 + import { accounts, generateColorForDid } from '$lib/accounts'; 15 15 import ProfilePicture from './ProfilePicture.svelte'; 16 16 import { isBlob } from '@atcute/lexicons/interfaces'; 17 17 import { blob, img } from '$lib/cdn'; 18 18 import BskyPost from './BskyPost.svelte'; 19 19 import Icon from '@iconify/svelte'; 20 20 import { type Backlink, type BacklinksSource } from '$lib/at/constellation'; 21 - import { postActions, type PostActions } from '$lib'; 21 + import { postActions, type PostActions } from '$lib/state.svelte'; 22 22 import * as TID from '@atcute/tid'; 23 23 import type { PostWithUri } from '$lib/at/fetch'; 24 - import type { Writable } from 'svelte/store'; 25 24 import { onMount } from 'svelte'; 25 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 26 26 27 27 interface Props { 28 28 client: AtpClient; 29 - selectedDid: Writable<Did | null>; 30 29 // post 31 30 did: Did; 32 31 rkey: RecordKey; ··· 40 39 41 40 const { 42 41 client, 43 - selectedDid, 44 42 did, 45 43 rkey, 46 44 data, ··· 49 47 onReply, 50 48 isOnPostComposer = false /* replyBacklinks */ 51 49 }: Props = $props(); 50 + 51 + const selectedDid = $derived(client.didDoc?.did ?? null); 52 52 53 53 const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; 54 54 const color = generateColorForDid(did); ··· 106 106 return 'now'; 107 107 }; 108 108 109 - const findBacklink = async (source: BacklinksSource) => { 109 + const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => { 110 110 const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source); 111 111 if (!backlinks.ok) return null; 112 - return backlinks.value.records.find((r) => r.did === $selectedDid) ?? null; 113 - }; 112 + return backlinks.value.records.find((r) => r.did === toDid) ?? null; 113 + }); 114 114 115 - let findAllBacklinks = async (did: Did | null) => { 115 + let findAllBacklinks = async (did: AtprotoDid | null) => { 116 116 if (!did) return; 117 117 if (postActions.has(`${did}:${aturi}`)) return; 118 118 const backlinks = await Promise.all([ 119 - findBacklink('app.bsky.feed.like:subject.uri'), 120 - findBacklink('app.bsky.feed.repost:subject.uri') 119 + findBacklink(did, 'app.bsky.feed.like:subject.uri'), 120 + findBacklink(did, 'app.bsky.feed.repost:subject.uri') 121 121 // findBacklink('app.bsky.feed.post:reply.parent.uri'), 122 122 // findBacklink('app.bsky.feed.post:embed.record.uri') 123 123 ]); ··· 132 132 }; 133 133 onMount(() => { 134 134 // findAllBacklinks($selectedDid); 135 - selectedDid.subscribe(findAllBacklinks); 135 + accounts.subscribe((accs) => { 136 + accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did)); 137 + }); 136 138 }); 137 139 138 140 const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => { 139 141 // console.log('toggleLink', selectedDid, link, collection); 140 - if (!$selectedDid) return null; 142 + if (!selectedDid) return null; 141 143 const _post = await post; 142 144 if (!_post.ok) return null; 143 145 if (!link) { ··· 154 156 // todo: handle errors 155 157 client.atcute?.post('com.atproto.repo.createRecord', { 156 158 input: { 157 - repo: $selectedDid, 159 + repo: selectedDid, 158 160 collection, 159 161 record, 160 162 rkey ··· 162 164 }); 163 165 return { 164 166 collection, 165 - did: $selectedDid, 167 + did: selectedDid, 166 168 rkey 167 169 }; 168 170 } ··· 215 217 style="background: {color}18; border-color: {color}66;" 216 218 > 217 219 <div 218 - class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) [border-left-color:transparent]" 220 + class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) border-l-transparent" 219 221 ></div> 220 222 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p> 221 223 </div> ··· 253 255 >{getRelativeTime(new Date(record.createdAt))}</span 254 256 > 255 257 </div> 256 - <p class="leading-relaxed text-wrap break-words"> 258 + <p class="leading-relaxed text-wrap wrap-break-word"> 257 259 {record.text} 258 260 {#if isOnPostComposer} 259 261 {@render embedBadge(record)} ··· 267 269 <!-- reject recursive quotes --> 268 270 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 269 271 <BskyPost 270 - {selectedDid} 271 272 {client} 272 273 did={parsedUri.repo} 273 274 rkey={parsedUri.rkey} ··· 312 313 </div> 313 314 {/if} 314 315 {#if !isOnPostComposer} 315 - {@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)} 316 + {@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)} 316 317 {@render postControls(post.value, backlinks)} 317 318 {/if} 318 319 </div> ··· 353 354 'heroicons:arrow-path-rounded-square-20-solid', 354 355 async (link) => { 355 356 if (link === undefined) return; 356 - postActions.set(`${$selectedDid!}:${aturi}`, { 357 + postActions.set(`${selectedDid!}:${aturi}`, { 357 358 ...backlinks!, 358 359 repost: await toggleLink(link, 'app.bsky.feed.repost') 359 360 }); ··· 368 369 'heroicons:star', 369 370 async (link) => { 370 371 if (link === undefined) return; 371 - postActions.set(`${$selectedDid!}:${aturi}`, { 372 + postActions.set(`${selectedDid!}:${aturi}`, { 372 373 ...backlinks!, 373 374 like: await toggleLink(link, 'app.bsky.feed.like') 374 375 });
+14 -23
src/components/PostComposer.svelte
··· 5 5 import { generateColorForDid } from '$lib/accounts'; 6 6 import type { PostWithUri } from '$lib/at/fetch'; 7 7 import BskyPost from './BskyPost.svelte'; 8 - import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons'; 8 + import { parseCanonicalResourceUri } from '@atcute/lexicons'; 9 9 import type { ComAtprotoRepoStrongRef } from '@atcute/atproto'; 10 - import type { Writable } from 'svelte/store'; 11 10 12 11 interface Props { 13 12 client: AtpClient; 14 - selectedDid: Writable<Did | null>; 15 13 onPostSent: (post: PostWithUri) => void; 16 14 quoting?: PostWithUri; 17 15 replying?: PostWithUri; ··· 19 17 20 18 let { 21 19 client, 22 - selectedDid, 23 20 onPostSent, 24 21 quoting = $bindable(undefined), 25 22 replying = $bindable(undefined) ··· 147 144 </div> 148 145 {:else} 149 146 <div class="flex flex-col gap-2"> 147 + {#snippet renderPost(post: PostWithUri)} 148 + {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 149 + <BskyPost 150 + {client} 151 + did={parsedUri.repo} 152 + rkey={parsedUri.rkey} 153 + data={post} 154 + isOnPostComposer={true} 155 + /> 156 + {/snippet} 150 157 {#if isFocused} 151 158 {#if replying} 152 - {@const parsedUri = expect(parseCanonicalResourceUri(replying.uri))} 153 - <BskyPost 154 - {client} 155 - {selectedDid} 156 - did={parsedUri.repo} 157 - rkey={parsedUri.rkey} 158 - data={replying} 159 - isOnPostComposer={true} 160 - /> 159 + {@render renderPost(replying)} 161 160 {/if} 162 161 <textarea 163 162 bind:this={textareaEl} ··· 170 169 }} 171 170 placeholder="what's on your mind?" 172 171 rows="4" 173 - class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100" 172 + class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100" 174 173 style="border-color: color-mix(in srgb, {color} 27%, transparent);" 175 174 ></textarea> 176 175 {#if quoting} 177 - {@const parsedUri = expect(parseCanonicalResourceUri(quoting.uri))} 178 - <BskyPost 179 - {client} 180 - {selectedDid} 181 - did={parsedUri.repo} 182 - rkey={parsedUri.rkey} 183 - data={quoting} 184 - isOnPostComposer={true} 185 - /> 176 + {@render renderPost(quoting)} 186 177 {/if} 187 178 <div class="flex items-center gap-2"> 188 179 <div class="grow"></div>
+1 -1
src/components/SettingsPopup.svelte
··· 56 56 </script> 57 57 58 58 {#snippet divider()} 59 - <div class="h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 59 + <div class="h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 60 60 {/snippet} 61 61 62 62 {#snippet settingHeader(name: string, desc: string)}
+19 -5
src/lib/accounts.ts
··· 1 - import type { Did, Handle } from '@atcute/lexicons'; 1 + import type { Handle } from '@atcute/lexicons'; 2 2 import { writable } from 'svelte/store'; 3 - import { hashColor } from './theme.svelte'; 3 + import { hashColor } from './theme'; 4 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 4 5 5 6 export type Account = { 6 - did: Did; 7 - handle: Handle; 8 - password: string; 7 + did: AtprotoDid; 8 + handle: Handle | null; 9 9 }; 10 10 11 11 let _accounts: Account[] = []; ··· 22 22 23 23 export const addAccount = (account: Account): void => { 24 24 accounts.update((accounts) => [...accounts, account]); 25 + }; 26 + 27 + export const loggingIn = { 28 + set: (account: Account | null) => { 29 + if (!account) { 30 + localStorage.removeItem('loggingIn'); 31 + } else { 32 + localStorage.setItem('loggingIn', JSON.stringify(account)); 33 + } 34 + }, 35 + get: (): Account | null => { 36 + const raw = localStorage.getItem('loggingIn'); 37 + return raw ? JSON.parse(raw) : null; 38 + } 25 39 }; 26 40 27 41 export const generateColorForDid = (did: string) => hashColor(did);
+5 -7
src/lib/at/client.ts
··· 4 4 ComAtprotoRepoGetRecord, 5 5 ComAtprotoRepoListRecords 6 6 } from '@atcute/atproto'; 7 - import { Client as AtcuteClient, CredentialManager } from '@atcute/client'; 7 + import { Client as AtcuteClient } from '@atcute/client'; 8 8 import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons'; 9 9 import { 10 10 isDid, ··· 37 37 import type { Notification } from './stardust'; 38 38 import { get } from 'svelte/store'; 39 39 import { settings } from '$lib/settings'; 40 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 40 41 // import { JetstreamSubscription } from '@atcute/jetstream'; 41 42 42 43 const cacheTtl = 1000 * 60 * 60 * 24; ··· 73 74 public atcute: AtcuteClient | null = null; 74 75 public didDoc: MiniDoc | null = null; 75 76 76 - async login(handle: Handle, password: string): Promise<Result<null, string>> { 77 - const didDoc = await this.resolveDidDoc(handle); 77 + async login(identifier: ActorIdentifier, agent: OAuthUserAgent): Promise<Result<null, string>> { 78 + const didDoc = await this.resolveDidDoc(identifier); 78 79 if (!didDoc.ok) return err(didDoc.error); 79 80 this.didDoc = didDoc.value; 80 81 81 82 try { 82 - const handler = new CredentialManager({ service: didDoc.value.pds }); 83 - const rpc = new AtcuteClient({ handler }); 84 - await handler.login({ identifier: didDoc.value.did, password }); 85 - 83 + const rpc = new AtcuteClient({ handler: agent }); 86 84 this.atcute = rpc; 87 85 } catch (error) { 88 86 return err(`failed to login: ${error}`);
+91
src/lib/at/oauth.ts
··· 1 + import { 2 + configureOAuth, 3 + defaultIdentityResolver, 4 + createAuthorizationUrl, 5 + finalizeAuthorization, 6 + OAuthUserAgent, 7 + getSession, 8 + deleteStoredSession 9 + } from '@atcute/oauth-browser-client'; 10 + 11 + import { 12 + CompositeDidDocumentResolver, 13 + PlcDidDocumentResolver, 14 + WebDidDocumentResolver, 15 + XrpcHandleResolver 16 + } from '@atcute/identity-resolver'; 17 + import { slingshotUrl } from './client'; 18 + import type { ActorIdentifier } from '@atcute/lexicons'; 19 + import { err, ok, type Result } from '$lib/result'; 20 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 21 + import { clientId, redirectUri } from '$lib/oauth'; 22 + 23 + configureOAuth({ 24 + metadata: { 25 + client_id: clientId, 26 + redirect_uri: redirectUri 27 + }, 28 + identityResolver: defaultIdentityResolver({ 29 + handleResolver: new XrpcHandleResolver({ serviceUrl: slingshotUrl.href }), 30 + 31 + didDocumentResolver: new CompositeDidDocumentResolver({ 32 + methods: { 33 + plc: new PlcDidDocumentResolver(), 34 + web: new WebDidDocumentResolver() 35 + } 36 + }) 37 + }) 38 + }); 39 + 40 + export const sessions = { 41 + get: async (did: AtprotoDid) => { 42 + const session = await getSession(did, { allowStale: true }); 43 + return new OAuthUserAgent(session); 44 + }, 45 + remove: async (did: AtprotoDid) => { 46 + try { 47 + const agent = await sessions.get(did); 48 + await agent.signOut(); 49 + } catch { 50 + deleteStoredSession(did); 51 + } 52 + } 53 + }; 54 + 55 + export const flow = { 56 + start: async (identifier: ActorIdentifier): Promise<Result<null, string>> => { 57 + try { 58 + const authUrl = await createAuthorizationUrl({ 59 + target: { type: 'account', identifier }, 60 + scope: 'atproto transition:generic' 61 + }); 62 + // recommended to wait for the browser to persist local storage before proceeding 63 + await new Promise((resolve) => setTimeout(resolve, 200)); 64 + // redirect the user to sign in and authorize the app 65 + window.location.assign(authUrl); 66 + // if this is on an async function, ideally the function should never ever resolve. 67 + // the only way it should resolve at this point is if the user aborted the authorization 68 + // by returning back to this page (thanks to back-forward page caching) 69 + await new Promise((_resolve, reject) => { 70 + const listener = () => { 71 + reject(new Error(`user aborted the login request`)); 72 + }; 73 + window.addEventListener('pageshow', listener, { once: true }); 74 + }); 75 + return ok(null); 76 + } catch (error) { 77 + return err(`login error: ${error}`); 78 + } 79 + }, 80 + finalize: async (url: URL): Promise<Result<OAuthUserAgent | null, string>> => { 81 + try { 82 + // createAuthorizationUrl asks server to put the params in the hash 83 + const params = new URLSearchParams(url.hash.slice(1)); 84 + if (!params.has('code')) return ok(null); 85 + const { session } = await finalizeAuthorization(params); 86 + return ok(new OAuthUserAgent(session)); 87 + } catch (error) { 88 + return err(`login error: ${error}`); 89 + } 90 + } 91 + };
+6
src/lib/domain.ts
··· 1 + import { dev } from '$app/environment'; 2 + import { env } from '$env/dynamic/public'; 3 + 4 + export const domain = dev ? 'http://127.0.0.1:5173' : env.PUBLIC_DOMAIN!; 5 + 6 + export default domain;
-19
src/lib/index.ts
··· 1 - import { writable } from 'svelte/store'; 2 - import { type NotificationsStream } from './at/client'; 3 - import { SvelteMap } from 'svelte/reactivity'; 4 - import type { Did, ResourceUri } from '@atcute/lexicons'; 5 - import type { Backlink } from './at/constellation'; 6 - // import type { JetstreamSubscription } from '@atcute/jetstream'; 7 - 8 - export const selectedDid = writable<Did | null>(null); 9 - 10 - export const notificationStream = writable<NotificationsStream | null>(null); 11 - // export const jetstream = writable<JetstreamSubscription | null>(null); 12 - 13 - export type PostActions = { 14 - like: Backlink | null; 15 - repost: Backlink | null; 16 - // reply: Backlink | null; 17 - // quote: Backlink | null; 18 - }; 19 - export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+23
src/lib/oauth.ts
··· 1 + import domain from '$lib/domain'; 2 + import { dev } from '$app/environment'; 3 + 4 + export const oauthMetadata = { 5 + client_id: `${domain}/oauth-client-metadata.json`, 6 + client_name: 'nucleus', 7 + client_uri: domain, 8 + logo_uri: `${domain}/favicon.png`, 9 + redirect_uris: [`${domain}/`], 10 + scope: 'atproto transition:generic', 11 + grant_types: ['authorization_code', 'refresh_token'], 12 + response_types: ['code'], 13 + token_endpoint_auth_method: 'none', 14 + application_type: 'web', 15 + dpop_bound_access_tokens: true 16 + }; 17 + 18 + export const redirectUri = domain; 19 + export const clientId = dev 20 + ? `http://localhost` + 21 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 22 + `&scope=${encodeURIComponent(oauthMetadata.scope)}` 23 + : oauthMetadata.client_id;
+1 -1
src/lib/settings.ts
··· 1 1 import { writable } from 'svelte/store'; 2 - import { defaultTheme, type Theme } from './theme.svelte'; 2 + import { defaultTheme, type Theme } from './theme'; 3 3 4 4 export type ApiEndpoints = Record<string, string> & { 5 5 slingshot: string;
+17
src/lib/state.svelte.ts
··· 1 + import { writable } from 'svelte/store'; 2 + import { type NotificationsStream } from './at/client'; 3 + import { SvelteMap } from 'svelte/reactivity'; 4 + import type { Did, ResourceUri } from '@atcute/lexicons'; 5 + import type { Backlink } from './at/constellation'; 6 + // import type { JetstreamSubscription } from '@atcute/jetstream'; 7 + 8 + export const notificationStream = writable<NotificationsStream | null>(null); 9 + // export const jetstream = writable<JetstreamSubscription | null>(null); 10 + 11 + export type PostActions = { 12 + like: Backlink | null; 13 + repost: Backlink | null; 14 + // reply: Backlink | null; 15 + // quote: Backlink | null; 16 + }; 17 + export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+1 -1
src/lib/theme.svelte.ts src/lib/theme.ts
··· 31 31 32 32 const hue = hash % 360; 33 33 const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100% 34 - const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 50-75% 34 + const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 45-80% 35 35 36 36 const rgb = hslToRgb(hue, saturation, lightness); 37 37 const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join('');
+166
src/lib/thread.ts
··· 1 + import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons'; 2 + import type { Account } from './accounts'; 3 + import { expect } from './result'; 4 + import type { PostWithUri } from './at/fetch'; 5 + 6 + export type ThreadPost = { 7 + data: PostWithUri; 8 + did: Did; 9 + rkey: string; 10 + parentUri: ResourceUri | null; 11 + depth: number; 12 + newestTime: number; 13 + }; 14 + 15 + export type Thread = { 16 + rootUri: ResourceUri; 17 + posts: ThreadPost[]; 18 + newestTime: number; 19 + branchParentPost?: ThreadPost; 20 + }; 21 + 22 + export const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => { 23 + const threadMap = new Map<ResourceUri, ThreadPost[]>(); 24 + 25 + // group posts by root uri into "thread" chains 26 + for (const [, timeline] of timelines) { 27 + for (const [uri, data] of timeline) { 28 + const parsedUri = expect(parseCanonicalResourceUri(uri)); 29 + const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 30 + const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 31 + 32 + const post: ThreadPost = { 33 + data, 34 + did: parsedUri.repo, 35 + rkey: parsedUri.rkey, 36 + parentUri, 37 + depth: 0, 38 + newestTime: new Date(data.record.createdAt).getTime() 39 + }; 40 + 41 + if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 42 + 43 + threadMap.get(rootUri)!.push(post); 44 + } 45 + } 46 + 47 + const threads: Thread[] = []; 48 + 49 + for (const [rootUri, posts] of threadMap) { 50 + const uriToPost = new Map(posts.map((p) => [p.data.uri, p])); 51 + const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 52 + 53 + // calculate depths 54 + for (const post of posts) { 55 + let depth = 0; 56 + let currentUri = post.parentUri; 57 + 58 + while (currentUri && uriToPost.has(currentUri)) { 59 + depth++; 60 + currentUri = uriToPost.get(currentUri)!.parentUri; 61 + } 62 + 63 + post.depth = depth; 64 + 65 + if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []); 66 + childrenMap.get(post.parentUri)!.push(post); 67 + } 68 + 69 + childrenMap 70 + .values() 71 + .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); 72 + 73 + const createThread = ( 74 + posts: ThreadPost[], 75 + rootUri: ResourceUri, 76 + branchParentUri?: ResourceUri 77 + ): Thread => { 78 + return { 79 + rootUri, 80 + posts, 81 + newestTime: Math.max(...posts.map((p) => p.newestTime)), 82 + branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined 83 + }; 84 + }; 85 + 86 + const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { 87 + const result: ThreadPost[] = []; 88 + const addWithChildren = (post: ThreadPost) => { 89 + result.push(post); 90 + const children = childrenMap.get(post.data.uri) || []; 91 + children.forEach(addWithChildren); 92 + }; 93 + addWithChildren(startPost); 94 + return result; 95 + }; 96 + 97 + // find posts with >2 children to split them into separate chains 98 + const branchingPoints = Array.from(childrenMap.entries()) 99 + .filter(([, children]) => children.length > 1) 100 + .map(([uri]) => uri); 101 + 102 + if (branchingPoints.length === 0) { 103 + const roots = childrenMap.get(null) || []; 104 + const allPosts = roots.flatMap((root) => collectSubtree(root)); 105 + threads.push(createThread(allPosts, rootUri)); 106 + } else { 107 + for (const branchParentUri of branchingPoints) { 108 + const branches = childrenMap.get(branchParentUri) || []; 109 + 110 + const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); 111 + 112 + sortedBranches.forEach((branchRoot, index) => { 113 + const isOldestBranch = index === 0; 114 + const branchPosts: ThreadPost[] = []; 115 + 116 + // the oldest branch has the full context 117 + // todo: consider letting the user decide this..? 118 + if (isOldestBranch && branchParentUri !== null) { 119 + const parentChain: ThreadPost[] = []; 120 + let currentUri: ResourceUri | null = branchParentUri; 121 + while (currentUri && uriToPost.has(currentUri)) { 122 + parentChain.unshift(uriToPost.get(currentUri)!); 123 + currentUri = uriToPost.get(currentUri)!.parentUri; 124 + } 125 + branchPosts.push(...parentChain); 126 + } 127 + 128 + branchPosts.push(...collectSubtree(branchRoot)); 129 + 130 + const minDepth = Math.min(...branchPosts.map((p) => p.depth)); 131 + branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); 132 + 133 + threads.push( 134 + createThread( 135 + branchPosts, 136 + branchRoot.data.uri, 137 + isOldestBranch ? undefined : (branchParentUri ?? undefined) 138 + ) 139 + ); 140 + }); 141 + } 142 + } 143 + } 144 + 145 + threads.sort((a, b) => b.newestTime - a.newestTime); 146 + 147 + // console.log(threads); 148 + 149 + return threads; 150 + }; 151 + 152 + export const isOwnPost = (post: ThreadPost, accounts: Account[]) => 153 + accounts.some((account) => account.did === post.did); 154 + export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => 155 + posts.some((post) => !isOwnPost(post, accounts)); 156 + 157 + // todo: add more filtering options 158 + export type FilterOptions = { 159 + viewOwnPosts: boolean; 160 + }; 161 + 162 + export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) => 163 + threads.filter((thread) => { 164 + if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts); 165 + return true; 166 + });
+133 -273
src/routes/+page.svelte
··· 4 4 import AccountSelector from '$components/AccountSelector.svelte'; 5 5 import SettingsPopup from '$components/SettingsPopup.svelte'; 6 6 import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client'; 7 - import { accounts, addAccount, type Account } from '$lib/accounts'; 8 - import { 9 - type Did, 10 - type Handle, 11 - parseCanonicalResourceUri, 12 - type ResourceUri 13 - } from '@atcute/lexicons'; 7 + import { accounts, type Account } from '$lib/accounts'; 8 + import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons'; 14 9 import { onMount } from 'svelte'; 15 10 import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch'; 16 11 import { expect, ok } from '$lib/result'; 17 12 import { AppBskyFeedPost } from '@atcute/bluesky'; 18 13 import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 19 14 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 20 - import { notificationStream, selectedDid } from '$lib'; 15 + import { notificationStream } from '$lib/state.svelte'; 21 16 import { get } from 'svelte/store'; 22 17 import Icon from '@iconify/svelte'; 18 + import { sessions } from '$lib/at/oauth'; 19 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 20 + import type { PageProps } from './+page'; 21 + import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 23 22 24 - let loaderState = new LoaderState(); 25 - let scrollContainer = $state<HTMLDivElement>(); 23 + const { data: loadData }: PageProps = $props(); 24 + 25 + let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null); 26 + $effect(() => { 27 + if (selectedDid) { 28 + localStorage.setItem('selectedDid', selectedDid); 29 + } else { 30 + localStorage.removeItem('selectedDid'); 31 + } 32 + }); 26 33 27 - let clients = new SvelteMap<Did, AtpClient>(); 28 - let selectedClient = $derived($selectedDid ? clients.get($selectedDid) : null); 34 + const clients = new SvelteMap<AtprotoDid, AtpClient>(); 35 + const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); 29 36 30 - let viewClient = $state<AtpClient>(new AtpClient()); 37 + const loginAccount = async (account: Account) => { 38 + if (clients.has(account.did)) return; 39 + const client = new AtpClient(); 40 + const result = await client.login(account.did, await sessions.get(account.did)); 41 + if (result.ok) clients.set(account.did, client); 42 + }; 31 43 32 - let posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 33 - let cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 44 + const handleAccountSelected = async (did: AtprotoDid) => { 45 + selectedDid = did; 46 + const account = $accounts.find((acc) => acc.did === did); 47 + if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 48 + await loginAccount(account); 49 + }; 50 + 51 + const handleLogout = async (did: AtprotoDid) => { 52 + await sessions.remove(did); 53 + const newAccounts = $accounts.filter((acc) => acc.did !== did); 54 + $accounts = newAccounts; 55 + clients.delete(did); 56 + posts.delete(did); 57 + cursors.delete(did); 58 + handleAccountSelected(newAccounts[0]?.did); 59 + }; 60 + 61 + const viewClient = new AtpClient(); 62 + 63 + const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 64 + const cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 34 65 35 66 let isSettingsOpen = $state(false); 36 67 let reverseChronological = $state(true); 37 68 let viewOwnPosts = $state(true); 69 + 70 + const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts })); 71 + 72 + let quoting = $state<PostWithUri | undefined>(undefined); 73 + let replying = $state<PostWithUri | undefined>(undefined); 74 + 75 + const expandedThreads = new SvelteSet<ResourceUri>(); 38 76 39 77 const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => { 40 78 if (!posts.has(did)) { ··· 119 157 // } 120 158 // }; 121 159 160 + const loaderState = new LoaderState(); 161 + let scrollContainer = $state<HTMLDivElement>(); 162 + 163 + let loading = $state(false); 164 + let loadError = $state(''); 165 + const loadMore = async () => { 166 + if (loading || $accounts.length === 0) return; 167 + 168 + loading = true; 169 + try { 170 + await fetchTimelines($accounts); 171 + loaderState.loaded(); 172 + } catch (error) { 173 + loadError = `${error}`; 174 + loaderState.error(); 175 + } finally { 176 + loading = false; 177 + if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 178 + } 179 + }; 180 + 122 181 onMount(async () => { 123 182 accounts.subscribe((newAccounts) => { 124 183 get(notificationStream)?.stop(); ··· 147 206 // }); 148 207 if ($accounts.length > 0) { 149 208 loaderState.status = 'LOADING'; 150 - $selectedDid = $accounts[0].did; 209 + if (loadData.client.ok && loadData.client.value) { 210 + const loggedInDid = loadData.client.value.didDoc!.did as AtprotoDid; 211 + selectedDid = loggedInDid; 212 + clients.set(loggedInDid, loadData.client.value); 213 + } 214 + if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did; 215 + console.log('onMount selectedDid', selectedDid); 151 216 Promise.all($accounts.map(loginAccount)).then(() => { 152 217 loadMore(); 153 218 }); 219 + } else { 220 + selectedDid = null; 154 221 } 155 222 }); 156 - 157 - const loginAccount = async (account: Account) => { 158 - const client = new AtpClient(); 159 - const result = await client.login(account.handle, account.password); 160 - if (result.ok) clients.set(account.did, client); 161 - }; 162 - 163 - const handleAccountSelected = async (did: Did) => { 164 - $selectedDid = did; 165 - const account = $accounts.find((acc) => acc.did === did); 166 - if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 167 - await loginAccount(account); 168 - }; 169 - 170 - const handleLogout = async (did: Did) => { 171 - const newAccounts = $accounts.filter((acc) => acc.did !== did); 172 - $accounts = newAccounts; 173 - clients.delete(did); 174 - posts.delete(did); 175 - cursors.delete(did); 176 - handleAccountSelected(newAccounts[0]?.did); 177 - }; 178 - 179 - const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => { 180 - const newAccount: Account = { did, handle, password }; 181 - addAccount(newAccount); 182 - $selectedDid = did; 183 - loginAccount(newAccount).then(() => fetchTimeline(newAccount)); 184 - }; 185 - 186 - let loading = $state(false); 187 - let loadError = $state(''); 188 - const loadMore = async () => { 189 - if (loading || $accounts.length === 0) return; 190 - 191 - loading = true; 192 - try { 193 - await fetchTimelines($accounts); 194 - loaderState.loaded(); 195 - } catch (error) { 196 - loadError = `${error}`; 197 - loaderState.error(); 198 - } finally { 199 - loading = false; 200 - if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 201 - } 202 - }; 203 - 204 - type ThreadPost = { 205 - data: PostWithUri; 206 - did: Did; 207 - rkey: string; 208 - parentUri: ResourceUri | null; 209 - depth: number; 210 - newestTime: number; 211 - }; 212 - 213 - type Thread = { 214 - rootUri: ResourceUri; 215 - posts: ThreadPost[]; 216 - newestTime: number; 217 - branchParentPost?: ThreadPost; 218 - }; 219 - 220 - const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => { 221 - // eslint-disable-next-line svelte/prefer-svelte-reactivity 222 - const threadMap = new Map<ResourceUri, ThreadPost[]>(); 223 - 224 - // group posts by root uri into "thread" chains 225 - for (const [, timeline] of timelines) { 226 - for (const [uri, data] of timeline) { 227 - const parsedUri = expect(parseCanonicalResourceUri(uri)); 228 - const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 229 - const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 230 - 231 - const post: ThreadPost = { 232 - data, 233 - did: parsedUri.repo, 234 - rkey: parsedUri.rkey, 235 - parentUri, 236 - depth: 0, 237 - newestTime: new Date(data.record.createdAt).getTime() 238 - }; 239 - 240 - if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 241 - 242 - threadMap.get(rootUri)!.push(post); 243 - } 244 - } 245 - 246 - const threads: Thread[] = []; 247 - 248 - for (const [rootUri, posts] of threadMap) { 249 - const uriToPost = new Map(posts.map((p) => [p.data.uri, p])); 250 - // eslint-disable-next-line svelte/prefer-svelte-reactivity 251 - const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 252 - 253 - // calculate depths 254 - for (const post of posts) { 255 - let depth = 0; 256 - let currentUri = post.parentUri; 257 - 258 - while (currentUri && uriToPost.has(currentUri)) { 259 - depth++; 260 - currentUri = uriToPost.get(currentUri)!.parentUri; 261 - } 262 - 263 - post.depth = depth; 264 - 265 - if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []); 266 - childrenMap.get(post.parentUri)!.push(post); 267 - } 268 - 269 - childrenMap 270 - .values() 271 - .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); 272 - 273 - const createThread = ( 274 - posts: ThreadPost[], 275 - rootUri: ResourceUri, 276 - branchParentUri?: ResourceUri 277 - ): Thread => { 278 - return { 279 - rootUri, 280 - posts, 281 - newestTime: Math.max(...posts.map((p) => p.newestTime)), 282 - branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined 283 - }; 284 - }; 285 - 286 - const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { 287 - const result: ThreadPost[] = []; 288 - const addWithChildren = (post: ThreadPost) => { 289 - result.push(post); 290 - const children = childrenMap.get(post.data.uri) || []; 291 - children.forEach(addWithChildren); 292 - }; 293 - addWithChildren(startPost); 294 - return result; 295 - }; 296 - 297 - // find posts with >2 children to split them into separate chains 298 - const branchingPoints = Array.from(childrenMap.entries()) 299 - .filter(([, children]) => children.length > 1) 300 - .map(([uri]) => uri); 301 - 302 - if (branchingPoints.length === 0) { 303 - const roots = childrenMap.get(null) || []; 304 - const allPosts = roots.flatMap((root) => collectSubtree(root)); 305 - threads.push(createThread(allPosts, rootUri)); 306 - } else { 307 - for (const branchParentUri of branchingPoints) { 308 - const branches = childrenMap.get(branchParentUri) || []; 309 - 310 - const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); 311 - 312 - sortedBranches.forEach((branchRoot, index) => { 313 - const isOldestBranch = index === 0; 314 - const branchPosts: ThreadPost[] = []; 315 - 316 - // the oldest branch has the full context 317 - // todo: consider letting the user decide this..? 318 - if (isOldestBranch && branchParentUri !== null) { 319 - const parentChain: ThreadPost[] = []; 320 - let currentUri: ResourceUri | null = branchParentUri; 321 - while (currentUri && uriToPost.has(currentUri)) { 322 - parentChain.unshift(uriToPost.get(currentUri)!); 323 - currentUri = uriToPost.get(currentUri)!.parentUri; 324 - } 325 - branchPosts.push(...parentChain); 326 - } 327 - 328 - branchPosts.push(...collectSubtree(branchRoot)); 329 - 330 - const minDepth = Math.min(...branchPosts.map((p) => p.depth)); 331 - branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); 332 - 333 - threads.push( 334 - createThread( 335 - branchPosts, 336 - branchRoot.data.uri, 337 - isOldestBranch ? undefined : (branchParentUri ?? undefined) 338 - ) 339 - ); 340 - }); 341 - } 342 - } 343 - } 344 - 345 - threads.sort((a, b) => b.newestTime - a.newestTime); 346 - 347 - // console.log(threads); 348 - 349 - return threads; 350 - }; 351 - 352 - // todo: add more filtering options 353 - const isOwnPost = (post: ThreadPost, accounts: Account[]) => 354 - accounts.some((account) => account.did === post.did); 355 - const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => 356 - posts.some((post) => !isOwnPost(post, accounts)); 357 - const filterThreads = (threads: Thread[], accounts: Account[]) => 358 - threads.filter((thread) => { 359 - if (!viewOwnPosts) return hasNonOwnPost(thread.posts, accounts); 360 - return true; 361 - }); 362 - 363 - let threads = $derived(filterThreads(buildThreads(posts), $accounts)); 364 - 365 - let quoting = $state<PostWithUri | undefined>(undefined); 366 - let replying = $state<PostWithUri | undefined>(undefined); 367 - 368 - let expandedThreads = new SvelteSet<ResourceUri>(); 369 223 </script> 370 224 371 225 <div class="mx-auto flex h-screen max-w-2xl flex-col p-4"> 372 - <div class="mb-6 flex flex-shrink-0 items-center justify-between"> 226 + <div class="mb-6 flex shrink-0 items-center justify-between"> 373 227 <div> 374 228 <h1 class="text-3xl font-bold tracking-tight">nucleus</h1> 375 229 <div class="mt-1 flex gap-2"> ··· 387 241 </button> 388 242 </div> 389 243 390 - <div class="flex-shrink-0 space-y-4"> 244 + <div class="shrink-0 space-y-4"> 391 245 <div class="flex min-h-16 items-stretch gap-2"> 392 246 <AccountSelector 393 247 client={viewClient} 394 248 accounts={$accounts} 395 - bind:selectedDid={$selectedDid} 249 + bind:selectedDid 396 250 onAccountSelected={handleAccountSelected} 397 - onLoginSucceed={handleLoginSucceed} 398 251 onLogout={handleLogout} 399 252 /> 400 253 ··· 402 255 <div class="flex-1"> 403 256 <PostComposer 404 257 client={selectedClient} 405 - {selectedDid} 406 - onPostSent={(post) => posts.get($selectedDid!)?.set(post.uri, post)} 258 + onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)} 407 259 bind:quoting 408 260 bind:replying 409 261 /> ··· 416 268 </div> 417 269 {/if} 418 270 </div> 271 + 272 + {#if !loadData.client.ok} 273 + <div class="error-disclaimer"> 274 + <p> 275 + <Icon class="inline h-12 w-12" icon="heroicons:exclamation-triangle-16-solid" /> 276 + {loadData.client.error} 277 + </p> 278 + </div> 279 + {/if} 419 280 420 281 <!-- <hr 421 282 class="h-[4px] w-full rounded-full border-0" ··· 441 302 442 303 <SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} /> 443 304 444 - {#snippet renderThreads()} 445 - <InfiniteLoader 446 - {loaderState} 447 - triggerLoad={loadMore} 448 - loopDetectionTimeout={0} 449 - intersectionOptions={{ root: scrollContainer }} 450 - > 451 - {@render threadsView()} 452 - {#snippet noData()} 453 - <div class="flex justify-center py-4"> 454 - <p class="text-xl opacity-80"> 455 - all posts seen! <span class="text-2xl">:o</span> 456 - </p> 457 - </div> 458 - {/snippet} 459 - {#snippet loading()} 460 - <div class="flex justify-center"> 461 - <div 462 - class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 463 - style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 464 - ></div> 465 - </div> 466 - {/snippet} 467 - {#snippet error()} 468 - <div class="flex justify-center py-4"> 469 - <p class="text-xl opacity-80"> 470 - <span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError} 471 - </p> 472 - </div> 473 - {/snippet} 474 - </InfiniteLoader> 475 - {/snippet} 476 - 477 305 {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 478 306 <span 479 - class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap break-words overflow-ellipsis" 307 + class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 480 308 > 481 309 <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 482 - <BskyPost mini {selectedDid} client={selectedClient ?? viewClient} {...post} /> 310 + <BskyPost mini client={selectedClient ?? viewClient} {...post} /> 483 311 </span> 484 312 {/snippet} 485 313 ··· 498 326 {#if !mini} 499 327 <div class="mb-1.5"> 500 328 <BskyPost 501 - {selectedDid} 502 329 client={selectedClient ?? viewClient} 503 330 onQuote={(post) => (quoting = post)} 504 331 onReply={(post) => (replying = post)} ··· 509 336 {#if idx === 1} 510 337 {@render replyPost(post, !reverseChronological)} 511 338 <button 512 - class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,_var(--nucleus-fg)_50%,_var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)" 339 + class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)" 513 340 onclick={() => expandedThreads.add(thread.rootUri)} 514 341 > 515 342 <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> ··· 529 356 {/each} 530 357 </div> 531 358 <div 532 - class="mx-8 mt-3 mb-4 h-px bg-gradient-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 359 + class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 533 360 ></div> 534 361 {/each} 535 362 {/snippet} 363 + 364 + {#snippet renderThreads()} 365 + <InfiniteLoader 366 + {loaderState} 367 + triggerLoad={loadMore} 368 + loopDetectionTimeout={0} 369 + intersectionOptions={{ root: scrollContainer }} 370 + > 371 + {@render threadsView()} 372 + {#snippet noData()} 373 + <div class="flex justify-center py-4"> 374 + <p class="text-xl opacity-80"> 375 + all posts seen! <span class="text-2xl">:o</span> 376 + </p> 377 + </div> 378 + {/snippet} 379 + {#snippet loading()} 380 + <div class="flex justify-center"> 381 + <div 382 + class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 383 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 384 + ></div> 385 + </div> 386 + {/snippet} 387 + {#snippet error()} 388 + <div class="flex justify-center py-4"> 389 + <p class="text-xl opacity-80"> 390 + <span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError} 391 + </p> 392 + </div> 393 + {/snippet} 394 + </InfiniteLoader> 395 + {/snippet}
+48
src/routes/+page.ts
··· 1 + import { replaceState } from '$app/navigation'; 2 + import { addAccount, loggingIn } from '$lib/accounts'; 3 + import { AtpClient } from '$lib/at/client'; 4 + import { flow } from '$lib/at/oauth'; 5 + import { err, ok, type Result } from '$lib/result'; 6 + import type { PageLoad } from './$types'; 7 + 8 + export type PageProps = { 9 + data: { 10 + client: Result<AtpClient | null, string>; 11 + }; 12 + }; 13 + 14 + export const load: PageLoad = async (): Promise<PageProps['data']> => { 15 + return { client: await handleLogin() }; 16 + }; 17 + 18 + const handleLogin = async (): Promise<Result<AtpClient | null, string>> => { 19 + const account = loggingIn.get(); 20 + if (!account) return ok(null); 21 + 22 + const currentUrl = new URL(window.location.href); 23 + // scrub history so auth state cant be replayed 24 + try { 25 + replaceState('', '/'); 26 + } catch { 27 + // if router was unitialized then we probably dont need to scrub anyway 28 + // so its fine 29 + } 30 + 31 + loggingIn.set(null); 32 + const agent = await flow.finalize(currentUrl); 33 + if (!agent.ok || !agent.value) { 34 + if (!agent.ok) { 35 + return err(agent.error); 36 + } 37 + return err('no session was logged into?!'); 38 + } 39 + 40 + const client = new AtpClient(); 41 + const result = await client.login(account.did, agent.value); 42 + if (!result.ok) { 43 + return err(result.error); 44 + } 45 + 46 + addAccount(account); 47 + return ok(client); 48 + };
+11
src/routes/oauth-client-metadata.json/+server.ts
··· 1 + import { clientId, oauthMetadata } from '$lib/oauth'; 2 + import { domain } from '$lib/domain'; 3 + import { json } from '@sveltejs/kit'; 4 + 5 + export const GET = () => { 6 + return json({ 7 + ...oauthMetadata, 8 + client_id: clientId, 9 + client_uri: domain 10 + }); 11 + };
+1 -7
tsconfig.json
··· 10 10 "sourceMap": true, 11 11 "strict": true, 12 12 "moduleResolution": "bundler", 13 - "jsx": "react-jsx", 14 - "paths": { 15 - "$components": ["./src/components"], 16 - "$components/*": ["./src/components/*"], 17 - "$lib": ["./src/lib"], 18 - "$lib/*": ["./src/lib/*"] 19 - } 13 + "jsx": "react-jsx" 20 14 } 21 15 // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 22 16 // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files