replies timeline only, appview-less bluesky client

feat: implement post actions, replying and quoting

+17
deno.lock
··· 6 6 "npm:@atcute/client@^4.0.5": "4.0.5", 7 7 "npm:@atcute/identity@^1.1.1": "1.1.1", 8 8 "npm:@atcute/lexicons@^1.2.2": "1.2.2", 9 + "npm:@atcute/tid@^1.0.3": "1.0.3", 9 10 "npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0", 10 11 "npm:@eslint/js@^9.36.0": "9.37.0", 12 + "npm:@iconify/svelte@^5.0.2": "5.0.2_svelte@5.40.1__acorn@8.15.0", 11 13 "npm:@soffinal/websocket@~0.2.1": "0.2.1_typescript@5.9.3", 12 14 "npm:@sveltejs/adapter-auto@^6.1.0": "6.1.1_@sveltejs+kit@2.47.0__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.40.1____acorn@8.15.0___vite@7.1.10____@types+node@24.8.0____picomatch@4.0.3___@types+node@24.8.0__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.8.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0", 13 15 "npm:@sveltejs/kit@^2.43.2": "2.47.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.8.0", ··· 68 70 "@standard-schema/spec", 69 71 "esm-env" 70 72 ] 73 + }, 74 + "@atcute/tid@1.0.3": { 75 + "integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==" 71 76 }, 72 77 "@badrap/valita@0.4.6": { 73 78 "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" ··· 284 289 }, 285 290 "@humanwhocodes/retry@0.4.3": { 286 291 "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==" 292 + }, 293 + "@iconify/svelte@5.0.2_svelte@5.40.1__acorn@8.15.0": { 294 + "integrity": "sha512-1iWUT+1veS/QOAzKDG0NPgBtJYGoJqEPwF97voTm8jw6PQ6yU0hL73lEwFoTGMrZmatLvh9cjRBmeSHHaltmrg==", 295 + "dependencies": [ 296 + "@iconify/types", 297 + "svelte" 298 + ] 299 + }, 300 + "@iconify/types@2.0.0": { 301 + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==" 287 302 }, 288 303 "@isaacs/fs-minipass@4.0.1": { 289 304 "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", ··· 1752 1767 "npm:@atcute/client@^4.0.5", 1753 1768 "npm:@atcute/identity@^1.1.1", 1754 1769 "npm:@atcute/lexicons@^1.2.2", 1770 + "npm:@atcute/tid@^1.0.3", 1755 1771 "npm:@eslint/compat@^1.4.0", 1756 1772 "npm:@eslint/js@^9.36.0", 1773 + "npm:@iconify/svelte@^5.0.2", 1757 1774 "npm:@soffinal/websocket@~0.2.1", 1758 1775 "npm:@sveltejs/adapter-auto@^6.1.0", 1759 1776 "npm:@sveltejs/kit@^2.43.2",
+2
package.json
··· 19 19 "@atcute/client": "^4.0.5", 20 20 "@atcute/identity": "^1.1.1", 21 21 "@atcute/lexicons": "^1.2.2", 22 + "@atcute/tid": "^1.0.3", 22 23 "@soffinal/websocket": "^0.2.1", 23 24 "@wora/cache-persist": "^2.2.1", 24 25 "hash-wasm": "^4.12.0", ··· 28 29 "devDependencies": { 29 30 "@eslint/compat": "^1.4.0", 30 31 "@eslint/js": "^9.36.0", 32 + "@iconify/svelte": "^5.0.2", 31 33 "@sveltejs/adapter-auto": "^6.1.0", 32 34 "@sveltejs/kit": "^2.43.2", 33 35 "@sveltejs/vite-plugin-svelte": "^6.2.0",
+186 -38
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 - import type { AtpClient } from '$lib/at/client'; 2 + import { type AtpClient } from '$lib/at/client'; 3 3 import { AppBskyFeedPost } from '@atcute/bluesky'; 4 4 import { 5 5 parseCanonicalResourceUri, 6 6 type ActorIdentifier, 7 + type CanonicalResourceUri, 7 8 type Did, 9 + type Nsid, 8 10 type RecordKey, 9 11 type ResourceUri 10 12 } from '@atcute/lexicons'; ··· 14 16 import { isBlob } from '@atcute/lexicons/interfaces'; 15 17 import { blob, img } from '$lib/cdn'; 16 18 import BskyPost from './BskyPost.svelte'; 19 + import Icon from '@iconify/svelte'; 20 + import { type Backlink, type BacklinksSource } from '$lib/at/constellation'; 21 + import { postActions, type PostActions } from '$lib'; 22 + import * as TID from '@atcute/tid'; 23 + import type { PostWithUri } from '$lib/at/fetch'; 24 + import type { Writable } from 'svelte/store'; 25 + import { onMount } from 'svelte'; 17 26 18 27 interface Props { 19 28 client: AtpClient; 29 + selectedDid: Writable<Did | null>; 30 + // post 20 31 did: Did; 21 32 rkey: RecordKey; 22 33 // replyBacklinks?: Backlinks; 23 - record?: AppBskyFeedPost.Main; 34 + data?: PostWithUri; 24 35 mini?: boolean; 36 + isOnPostComposer?: boolean; 37 + onQuote?: (quote: PostWithUri) => void; 38 + onReply?: (reply: PostWithUri) => void; 25 39 } 26 40 27 - const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props(); 41 + const { 42 + client, 43 + selectedDid, 44 + did, 45 + rkey, 46 + data, 47 + mini, 48 + onQuote, 49 + onReply, 50 + isOnPostComposer = false /* replyBacklinks */ 51 + }: Props = $props(); 28 52 53 + const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; 29 54 const color = generateColorForDid(did); 30 55 31 56 let handle: ActorIdentifier = $state(did); ··· 33 58 if (res.ok) handle = res.value.handle; 34 59 return res; 35 60 }); 36 - const post = record 37 - ? Promise.resolve(ok(record)) 61 + const post = data 62 + ? Promise.resolve(ok(data)) 38 63 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 39 64 // const replies = replyBacklinks 40 65 // ? Promise.resolve(ok(replyBacklinks)) ··· 78 103 if (hours > 0) return `${hours}h`; 79 104 if (minutes > 0) return `${minutes}m`; 80 105 if (seconds > 0) return `${seconds}s`; 81 - return 'just now'; 106 + return 'now'; 107 + }; 108 + 109 + const findBacklink = async (source: BacklinksSource) => { 110 + const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source); 111 + if (!backlinks.ok) return null; 112 + return backlinks.value.records.find((r) => r.did === $selectedDid) ?? null; 113 + }; 114 + 115 + let findAllBacklinks = async (did: Did | null) => { 116 + if (!did) return; 117 + if (postActions.has(`${did}:${aturi}`)) return; 118 + const backlinks = await Promise.all([ 119 + findBacklink('app.bsky.feed.like:subject.uri'), 120 + findBacklink('app.bsky.feed.repost:subject.uri') 121 + // findBacklink('app.bsky.feed.post:reply.parent.uri'), 122 + // findBacklink('app.bsky.feed.post:embed.record.uri') 123 + ]); 124 + const actions: PostActions = { 125 + like: backlinks[0], 126 + repost: backlinks[1] 127 + // reply: backlinks[2], 128 + // quote: backlinks[3] 129 + }; 130 + console.log('findAllBacklinks', did, aturi, actions); 131 + postActions.set(`${did}:${aturi}`, actions); 132 + }; 133 + onMount(() => { 134 + // findAllBacklinks($selectedDid); 135 + selectedDid.subscribe(findAllBacklinks); 136 + }); 137 + 138 + const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => { 139 + // console.log('toggleLink', selectedDid, link, collection); 140 + if (!$selectedDid) return null; 141 + const _post = await post; 142 + if (!_post.ok) return null; 143 + if (!link) { 144 + if (_post.value.cid) { 145 + const record = { 146 + $type: collection, 147 + subject: { 148 + cid: _post.value.cid, 149 + uri: aturi 150 + }, 151 + createdAt: new Date().toISOString() 152 + }; 153 + const rkey = TID.now(); 154 + // todo: handle errors 155 + client.atcute?.post('com.atproto.repo.createRecord', { 156 + input: { 157 + repo: $selectedDid, 158 + collection, 159 + record, 160 + rkey 161 + } 162 + }); 163 + return { 164 + collection, 165 + did: $selectedDid, 166 + rkey 167 + }; 168 + } 169 + } else { 170 + // todo: handle errors 171 + client.atcute?.post('com.atproto.repo.deleteRecord', { 172 + input: { 173 + repo: link.did, 174 + collection: link.collection, 175 + rkey: link.rkey 176 + } 177 + }); 178 + return null; 179 + } 180 + return link; 82 181 }; 83 182 </script> 84 183 ··· 88 187 class="rounded-full px-2.5 py-0.5 text-xs font-medium" 89 188 style="background: color-mix(in srgb, {mini 90 189 ? 'var(--nucleus-fg)' 91 - : color} 13%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};" 190 + : color} 10%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};" 92 191 > 93 192 {getEmbedText(record.embed.$type)} 94 193 </span> ··· 96 195 {/snippet} 97 196 98 197 {#if mini} 99 - <div class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60"> 198 + <div class="text-sm opacity-60"> 100 199 {#await post} 101 200 loading... 102 201 {:then post} 103 202 {#if post.ok} 104 - {@const record = post.value} 203 + {@const record = post.value.record} 105 204 <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)} 106 205 <span title={record.text}>{record.text}</span> 107 206 {:else} ··· 122 221 </div> 123 222 {:then post} 124 223 {#if post.ok} 125 - {@const record = post.value} 224 + {@const record = post.value.record} 126 225 <div 127 226 class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all" 128 - style="background: {color}18; border-color: {color}66;" 227 + style="background: {color}{isOnPostComposer 228 + ? '36' 229 + : '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};" 129 230 > 130 231 <div 131 232 class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1" ··· 141 242 {@const profileValue = profile.value} 142 243 <span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 143 244 >{profileValue.displayName}</span 144 - ><span class="shrink-0 text-nowrap">(@{handle})</span> 245 + ><span class="shrink-0 text-nowrap opacity-70">(@{handle})</span> 145 246 {:else} 146 247 {handle} 147 248 {/if} 148 249 {/await} 149 250 </span> 150 - 151 - <!-- <span>·</span> 152 - {#await replies} 153 - <span style="color: {theme.fg}aa;">… replies</span> 154 - {:then replies} 155 - {#if replies.ok} 156 - {@const repliesValue = replies.value} 157 - <span style="color: {theme.fg}aa;"> 158 - {#if repliesValue.total > 0} 159 - {repliesValue.total} 160 - {repliesValue.total > 1 ? 'replies' : 'reply'} 161 - {:else} 162 - no replies 163 - {/if} 164 - </span> 165 - {:else} 166 - <span 167 - title={`${replies.error}`} 168 - class="max-w-[32ch] overflow-hidden text-nowrap" 169 - style="color: {theme.fg}aa;">{replies.error}</span 170 - > 171 - {/if} 172 - {/await} --> 173 251 <span>·</span> 174 252 <span class="text-nowrap text-(--nucleus-fg)/67" 175 253 >{getRelativeTime(new Date(record.createdAt))}</span 176 254 > 177 255 </div> 178 - <p class="leading-relaxed text-wrap"> 256 + <p class="leading-relaxed text-wrap break-words"> 179 257 {record.text} 258 + {#if isOnPostComposer} 259 + {@render embedBadge(record)} 260 + {/if} 180 261 </p> 181 - {#if record.embed} 262 + {#if !isOnPostComposer && record.embed} 182 263 {@const embed = record.embed} 183 264 <div class="mt-2"> 184 265 {#snippet embedPost(uri: ResourceUri)} 185 266 {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 186 267 <!-- reject recursive quotes --> 187 268 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 188 - <BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} /> 269 + <BskyPost 270 + {selectedDid} 271 + {client} 272 + did={parsedUri.repo} 273 + rkey={parsedUri.rkey} 274 + {isOnPostComposer} 275 + {onQuote} 276 + {onReply} 277 + /> 189 278 {:else} 190 279 <span>you think you're funny with that recursive quote but i'm onto you</span> 191 280 {/if} ··· 222 311 <!-- todo: implement external link embeds --> 223 312 </div> 224 313 {/if} 314 + {#if !isOnPostComposer} 315 + {@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)} 316 + {@render postControls(post.value, backlinks)} 317 + {/if} 225 318 </div> 226 319 {:else} 227 320 <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;"> ··· 230 323 {/if} 231 324 {/await} 232 325 {/if} 326 + 327 + {#snippet postControls(post: PostWithUri, backlinks?: PostActions)} 328 + <div 329 + class="group mt-3 flex w-fit max-w-full items-center rounded-sm" 330 + style="background: {color}1f;" 331 + > 332 + {#snippet label( 333 + name: string, 334 + icon: string, 335 + onClick: (link: Backlink | null | undefined) => void, 336 + backlink?: Backlink | null, 337 + hasSolid?: boolean 338 + )} 339 + <button 340 + class="px-2 py-1.5 text-(--nucleus-fg)/90 hover:[backdrop-filter:brightness(120%)]" 341 + onclick={() => onClick(backlink)} 342 + style="color: {backlink ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 343 + title={name} 344 + > 345 + <Icon icon={hasSolid && backlink ? `${icon}-solid` : icon} width={20} /> 346 + </button> 347 + {/snippet} 348 + {@render label('reply', 'heroicons:chat-bubble-left', () => { 349 + onReply?.(post); 350 + })} 351 + {@render label( 352 + 'repost', 353 + 'heroicons:arrow-path-rounded-square-20-solid', 354 + async (link) => { 355 + if (link === undefined) return; 356 + postActions.set(`${$selectedDid!}:${aturi}`, { 357 + ...backlinks!, 358 + repost: await toggleLink(link, 'app.bsky.feed.repost') 359 + }); 360 + }, 361 + backlinks?.repost 362 + )} 363 + {@render label('quote', 'heroicons:paper-clip-20-solid', () => { 364 + onQuote?.(post); 365 + })} 366 + {@render label( 367 + 'like', 368 + 'heroicons:star', 369 + async (link) => { 370 + if (link === undefined) return; 371 + postActions.set(`${$selectedDid!}:${aturi}`, { 372 + ...backlinks!, 373 + like: await toggleLink(link, 'app.bsky.feed.like') 374 + }); 375 + }, 376 + backlinks?.like, 377 + true 378 + )} 379 + </div> 380 + {/snippet}
+1 -1
src/components/PfpPlaceholder.svelte
··· 8 8 </script> 9 9 10 10 <svg 11 - class="rounded-sm" 11 + class="shrink-0 rounded-sm" 12 12 style="background: color-mix(in srgb, {color} 27%, transparent); color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 13 13 xmlns="http://www.w3.org/2000/svg" 14 14 width="24px"
+70 -13
src/components/PostComposer.svelte
··· 1 1 <script lang="ts"> 2 2 import type { AtpClient } from '$lib/at/client'; 3 - import { ok, err, type Result } from '$lib/result'; 3 + import { ok, err, type Result, expect } from '$lib/result'; 4 4 import type { AppBskyFeedPost } from '@atcute/bluesky'; 5 - import type { ResourceUri } from '@atcute/lexicons'; 6 5 import { generateColorForDid } from '$lib/accounts'; 6 + import type { PostWithUri } from '$lib/at/fetch'; 7 + import BskyPost from './BskyPost.svelte'; 8 + import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons'; 9 + import type { ComAtprotoRepoStrongRef } from '@atcute/atproto'; 10 + import type { Writable } from 'svelte/store'; 7 11 8 12 interface Props { 9 13 client: AtpClient; 10 - onPostSent: (uri: ResourceUri, post: AppBskyFeedPost.Main) => void; 14 + selectedDid: Writable<Did | null>; 15 + onPostSent: (post: PostWithUri) => void; 16 + quoting?: PostWithUri; 17 + replying?: PostWithUri; 11 18 } 12 19 13 - const { client, onPostSent }: Props = $props(); 20 + let { 21 + client, 22 + selectedDid, 23 + onPostSent, 24 + quoting = $bindable(undefined), 25 + replying = $bindable(undefined) 26 + }: Props = $props(); 14 27 15 28 let color = $derived( 16 29 client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)' 17 30 ); 18 31 19 - const post = async ( 20 - text: string 21 - ): Promise<Result<{ uri: ResourceUri; record: AppBskyFeedPost.Main }, string>> => { 32 + const post = async (text: string): Promise<Result<PostWithUri, string>> => { 33 + const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ 34 + $type: 'com.atproto.repo.strongRef', 35 + cid: p.cid!, 36 + uri: p.uri 37 + }); 22 38 const record: AppBskyFeedPost.Main = { 23 39 $type: 'app.bsky.feed.post', 24 40 text, 41 + reply: replying 42 + ? { 43 + root: replying.record.reply?.root ?? strongRef(replying), 44 + parent: strongRef(replying) 45 + } 46 + : undefined, 47 + embed: quoting 48 + ? { 49 + $type: 'app.bsky.embed.record', 50 + record: strongRef(quoting) 51 + } 52 + : undefined, 25 53 createdAt: new Date().toISOString() 26 54 }; 27 55 ··· 43 71 44 72 return ok({ 45 73 uri: res.data.uri, 74 + cid: res.data.cid, 46 75 record 47 76 }); 48 77 }; ··· 57 86 58 87 post(postText).then((res) => { 59 88 if (res.ok) { 60 - onPostSent(res.value.uri, res.value.record); 89 + onPostSent(res.value); 61 90 postText = ''; 62 91 info = 'posted!'; 63 - setTimeout(() => (info = ''), 1000 * 3); 92 + isFocused = false; 93 + quoting = undefined; 94 + replying = undefined; 95 + setTimeout(() => (info = ''), 1000 * 0.8); 64 96 } else { 97 + // todo: add a way to clear error 65 98 info = res.error; 66 99 } 67 100 }); ··· 69 102 70 103 $effect(() => { 71 104 if (isFocused && textareaEl) textareaEl.focus(); 105 + if (quoting || replying) isFocused = true; 72 106 }); 73 107 </script> 74 108 ··· 104 138 {:else} 105 139 <div class="flex flex-col gap-2"> 106 140 {#if isFocused} 141 + {#if replying} 142 + {@const parsedUri = expect(parseCanonicalResourceUri(replying.uri))} 143 + <BskyPost 144 + {client} 145 + {selectedDid} 146 + did={parsedUri.repo} 147 + rkey={parsedUri.rkey} 148 + data={replying} 149 + isOnPostComposer={true} 150 + /> 151 + {/if} 107 152 <textarea 108 153 bind:this={textareaEl} 109 154 bind:value={postText} 110 155 onfocus={() => (isFocused = true)} 111 - onblur={() => (isFocused = false)} 156 + onblur={() => { 157 + isFocused = false; 158 + quoting = undefined; 159 + replying = undefined; 160 + }} 112 161 onkeydown={(event) => { 113 162 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 114 163 }} ··· 117 166 class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100" 118 167 style="border-color: color-mix(in srgb, {color} 27%, transparent);" 119 168 ></textarea> 169 + {#if quoting} 170 + {@const parsedUri = expect(parseCanonicalResourceUri(quoting.uri))} 171 + <BskyPost 172 + {client} 173 + {selectedDid} 174 + did={parsedUri.repo} 175 + rkey={parsedUri.rkey} 176 + data={quoting} 177 + isOnPostComposer={true} 178 + /> 179 + {/if} 120 180 <div class="flex items-center gap-2"> 121 181 <div class="grow"></div> 122 182 <span ··· 140 200 <input 141 201 bind:value={postText} 142 202 onfocus={() => (isFocused = true)} 143 - onkeydown={(event) => { 144 - if (event.key === 'Enter') doPost(); 145 - }} 146 203 type="text" 147 204 placeholder="what's on your mind?" 148 205 class="single-line-input flex-1 bg-(--nucleus-bg)/40"
+26 -27
src/lib/accounts.ts
··· 25 25 26 26 export const generateColorForDid = (did: string) => hashColor(did); 27 27 28 - function hashColor(input: string | number): string { 29 - let hash = typeof input === 'string' ? stringToHash(input) : input; 28 + function hashColor(input: string): string { 29 + let hash: number; 30 + 31 + const id = input.split(':').pop() || input; 30 32 33 + hash = 0; 34 + for (let i = 0; i < Math.min(10, id.length); i++) { 35 + hash = (hash << 4) + id.charCodeAt(i); 36 + } 37 + hash = hash >>> 0; 38 + 39 + // magic mixing 31 40 hash ^= hash >>> 16; 32 - hash = Math.imul(hash, 0x85ebca6b); 33 - hash ^= hash >>> 13; 34 - hash = Math.imul(hash, 0xb00b1355); 35 - hash ^= hash >>> 16; 41 + hash = Math.imul(hash, 0x21f0aaad); 42 + hash ^= hash >>> 15; 36 43 hash = hash >>> 0; 37 44 38 45 const hue = hash % 360; 39 - const saturation = 0.7 + ((hash >>> 8) % 30) * 0.01; 40 - const value = 0.6 + ((hash >>> 16) % 40) * 0.01; 46 + const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100% 47 + const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 50-75% 41 48 42 - const rgb = hsvToRgb(hue, saturation, value); 49 + const rgb = hslToRgb(hue, saturation, lightness); 43 50 const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join(''); 44 51 45 52 return `#${hex}`; 46 53 } 47 54 48 - function stringToHash(str: string): number { 49 - let hash = 0; 50 - for (let i = 0; i < str.length; i++) { 51 - hash = (Math.imul(hash << 5, 1) - hash + str.charCodeAt(i)) | 0; 52 - } 53 - return hash >>> 0; 54 - } 55 - 56 - function hsvToRgb(h: number, s: number, v: number): [number, number, number] { 57 - const c = v * s; 58 - const hPrime = h * 0.016666667; 55 + function hslToRgb(h: number, s: number, l: number): [number, number, number] { 56 + const c = (1 - Math.abs(2 * l - 1)) * s; 57 + const hPrime = h / 60; 59 58 const x = c * (1 - Math.abs((hPrime % 2) - 1)); 60 - const m = v - c; 59 + const m = l - c / 2; 61 60 62 61 let r: number, g: number, b: number; 63 62 64 - if (h < 60) { 63 + if (hPrime < 1) { 65 64 r = c; 66 65 g = x; 67 66 b = 0; 68 - } else if (h < 120) { 67 + } else if (hPrime < 2) { 69 68 r = x; 70 69 g = c; 71 70 b = 0; 72 - } else if (h < 180) { 71 + } else if (hPrime < 3) { 73 72 r = 0; 74 73 g = c; 75 74 b = x; 76 - } else if (h < 240) { 75 + } else if (hPrime < 4) { 77 76 r = 0; 78 77 g = x; 79 78 b = c; 80 - } else if (h < 300) { 79 + } else if (hPrime < 5) { 81 80 r = x; 82 81 g = 0; 83 82 b = c; ··· 87 86 b = x; 88 87 } 89 88 90 - return [((r + m) * 255) | 0, ((g + m) * 255) | 0, ((b + m) * 255) | 0]; 89 + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; 91 90 }
+39 -15
src/lib/at/client.ts
··· 13 13 type ActorIdentifier, 14 14 type AtprotoDid, 15 15 type CanonicalResourceUri, 16 + type Cid, 16 17 type Did, 17 18 type Nsid, 18 19 type RecordKey, ··· 66 67 export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 67 68 export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 68 69 70 + export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 71 + 69 72 export class AtpClient { 70 73 public atcute: AtcuteClient | null = null; 71 74 public didDoc: MiniDoc | null = null; ··· 94 97 TKey extends RecordKeySchema, 95 98 Schema extends RecordSchema<TObject, TKey>, 96 99 Output extends InferInput<Schema> 97 - >(schema: Schema, uri: ResourceUri): Promise<Result<Output, string>> { 100 + >(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> { 98 101 const parsedUri = expect(parseResourceUri(uri)); 99 102 if (parsedUri.collection !== schema.object.shape.$type.expected) 100 103 return err( ··· 109 112 TKey extends RecordKeySchema, 110 113 Schema extends RecordSchema<TObject, TKey>, 111 114 Output extends InferInput<Schema> 112 - >(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise<Result<Output, string>> { 115 + >( 116 + schema: Schema, 117 + repo: ActorIdentifier, 118 + rkey: RecordKey 119 + ): Promise<Result<RecordOutput<Output>, string>> { 113 120 const collection = schema.object.shape.$type.expected; 114 121 const cacheKey = `${repo}:${collection}:${rkey}`; 115 122 116 123 const cached = recordCache.get(cacheKey); 117 - if (cached) return ok(cached.value as Output); 124 + if (cached) return ok({ uri: cached.uri, cid: cached.cid, record: cached.value as Output }); 118 125 const cachedSignal = recordCache.getSignal(cacheKey); 119 126 120 127 const result = await Promise.race([ ··· 122 129 repo, 123 130 collection, 124 131 rkey 125 - }).then((result): Result<Output, string> => { 132 + }).then((result): Result<RecordOutput<Output>, string> => { 126 133 if (!result.ok) return result; 127 134 128 135 const parsed = safeParse(schema, result.value.value); ··· 130 137 131 138 recordCache.set(cacheKey, result.value); 132 139 133 - return ok(parsed.value as Output); 140 + return ok({ 141 + uri: result.value.uri, 142 + cid: result.value.cid, 143 + record: parsed.value as Output 144 + }); 134 145 }), 135 - cachedSignal.then((d): Result<Output, string> => ok(d.value as Output)) 146 + cachedSignal.then( 147 + (d): Result<RecordOutput<Output>, string> => 148 + ok({ uri: d.uri, cid: d.cid, record: d.value as Output }) 149 + ) 136 150 ]); 137 151 138 152 if (!result.ok) return result; 139 153 140 - return ok(result.value as Output); 154 + return ok(result.value); 141 155 } 142 156 143 157 async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { 144 158 repo = repo ?? this.didDoc?.did; 145 159 if (!repo) return err('not authenticated'); 146 - return await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'); 160 + return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); 147 161 } 148 162 149 163 async listRecords<Collection extends keyof Records>( ··· 232 246 if (!did.ok) { 233 247 return err(`failed to resolve handle: ${did.error}`); 234 248 } 249 + 235 250 return await fetchMicrocosm(constellationUrl, BacklinksQuery, { 236 251 subject: `at://${did.value}/${collection}/${rkey}`, 237 252 source, ··· 277 292 init?: RequestInit 278 293 ): Promise<Result<Output, string>> => { 279 294 if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); 295 + api.pathname = `/xrpc/${schema.nsid}`; 296 + api.search = params ? `?${new URLSearchParams(params)}` : ''; 280 297 try { 281 - api.pathname = `/xrpc/${schema.nsid}`; 282 - api.search = params ? `?${new URLSearchParams(params)}` : ''; 283 - // console.info(`fetching:`, api.href); 284 - const response = await fetch(api, init); 298 + const body = await fetchJson(api, init); 299 + if (!body.ok) return err(body.error); 300 + const parsed = safeParse(schema.output.schema, body.value); 301 + if (!parsed.ok) return err(parsed.message); 302 + return ok(parsed.value as Output); 303 + } catch (error) { 304 + return err(`FetchError: ${error}`); 305 + } 306 + }; 307 + 308 + const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => { 309 + try { 310 + const response = await fetch(url, init); 285 311 const body = await response.json(); 286 312 if (response.status === 400) return err(`${body.error}: ${body.message}`); 287 313 if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`); 288 - const parsed = safeParse(schema.output.schema, body); 289 - if (!parsed.ok) return err(parsed.message); 290 - return ok(parsed.value as Output); 314 + return ok(body); 291 315 } catch (error) { 292 316 return err(`FetchError: ${error}`); 293 317 }
+27 -33
src/lib/at/fetch.ts
··· 1 - import type { ActorIdentifier, CanonicalResourceUri } from '@atcute/lexicons'; 2 - import type { AtpClient } from './client'; 3 - import { err, map, ok, type Result } from '$lib/result'; 1 + import type { ActorIdentifier, CanonicalResourceUri, Cid, ResourceUri } from '@atcute/lexicons'; 2 + import { recordCache, type AtpClient } from './client'; 3 + import { err, ok, type Result } from '$lib/result'; 4 4 import type { Backlinks } from './constellation'; 5 5 import { AppBskyFeedPost } from '@atcute/bluesky'; 6 6 7 - export type PostWithUri = { uri: CanonicalResourceUri; record: AppBskyFeedPost.Main }; 7 + export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 8 8 export type PostWithBacklinks = PostWithUri & { 9 9 replies: Result<Backlinks, string>; 10 10 }; ··· 22 22 const records = recordsList.value.records; 23 23 24 24 const allBacklinks = await Promise.all( 25 - records.map((r) => 26 - client 27 - .getBacklinksUri(r.uri as CanonicalResourceUri, 'app.bsky.feed.post:reply.parent.uri') 28 - .then( 29 - (res): PostWithBacklinks => ({ 30 - uri: r.uri as CanonicalResourceUri, 31 - record: r.value as AppBskyFeedPost.Main, 32 - replies: res 33 - }) 34 - ) 35 - ) 25 + records.map(async (r) => { 26 + recordCache.set(r.uri, r); 27 + const res = await client.getBacklinksUri( 28 + r.uri as CanonicalResourceUri, 29 + 'app.bsky.feed.post:reply.parent.uri' 30 + ); 31 + return { 32 + uri: r.uri, 33 + cid: r.cid, 34 + record: r.value as AppBskyFeedPost.Main, 35 + replies: res 36 + }; 37 + }) 36 38 ); 37 39 38 40 return ok({ posts: allBacklinks, cursor }); ··· 41 43 export const hydratePosts = async ( 42 44 client: AtpClient, 43 45 data: PostsWithReplyBacklinks 44 - ): Promise<Map<CanonicalResourceUri, AppBskyFeedPost.Main>> => { 46 + ): Promise<Map<ResourceUri, PostWithUri>> => { 45 47 const allPosts = await Promise.all( 46 48 data.map(async (post) => { 47 - const result: Result<PostWithUri, string>[] = [ok({ uri: post.uri, record: post.record })]; 49 + const result: Result<PostWithUri, string>[] = [ok(post)]; 48 50 if (post.replies.ok) { 49 51 const replies = await Promise.all( 50 52 post.replies.value.records.map((r) => 51 - client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey).then((res) => 52 - map( 53 - res, 54 - (d): PostWithUri => ({ 55 - uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}` as CanonicalResourceUri, 56 - record: d 57 - }) 58 - ) 59 - ) 53 + client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey) 60 54 ) 61 55 ); 62 56 result.push(...replies); ··· 68 62 allPosts 69 63 .flat() 70 64 .flatMap((res) => (res.ok ? [res.value] : [])) 71 - .map((post) => [post.uri, post.record]) 65 + .map((post) => [post.uri, post]) 72 66 ); 73 67 74 68 // hydrate posts 75 69 const missingPosts = await Promise.all( 76 - Array.from(posts).map(async ([uri, record]) => { 77 - let result: PostWithUri[] = [{ uri, record }]; 78 - let parent = record.reply?.parent; 70 + Array.from(posts).map(async ([, post]) => { 71 + let result: PostWithUri[] = [post]; 72 + let parent = post.record.reply?.parent; 79 73 while (parent) { 80 74 if (posts.has(parent.uri as CanonicalResourceUri)) { 81 75 return result; 82 76 } 83 77 const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri); 84 78 if (p.ok) { 85 - result = [{ uri: parent.uri as CanonicalResourceUri, record: p.value }, ...result]; 86 - parent = p.value.reply?.parent; 79 + result = [p.value, ...result]; 80 + parent = p.value.record.reply?.parent; 87 81 continue; 88 82 } 89 83 parent = undefined; ··· 92 86 }) 93 87 ); 94 88 for (const post of missingPosts.flat()) { 95 - posts.set(post.uri, post.record); 89 + posts.set(post.uri, post); 96 90 } 97 91 98 92 return posts;
+4 -1
src/lib/cache.ts
··· 61 61 set(key: K, value: V): void { 62 62 this.memory.set(key, value); 63 63 this.storage.set(this.prefixed(key), value); 64 - for (const signal of this.signals.get(key) ?? []) { 64 + const signals = this.signals.get(key); 65 + let signal = signals?.pop(); 66 + while (signal) { 65 67 signal(value); 68 + signal = signals?.pop(); 66 69 } 67 70 this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly) 68 71 }
+13
src/lib/index.ts
··· 1 1 import { writable } from 'svelte/store'; 2 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'; 3 6 // import type { JetstreamSubscription } from '@atcute/jetstream'; 4 7 8 + export const selectedDid = writable<Did | null>(null); 9 + 5 10 export const notificationStream = writable<NotificationsStream | null>(null); 6 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>();
+89 -57
src/routes/+page.svelte
··· 12 12 type ResourceUri 13 13 } from '@atcute/lexicons'; 14 14 import { onMount } from 'svelte'; 15 - import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch'; 15 + import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch'; 16 16 import { expect, ok } from '$lib/result'; 17 17 import { AppBskyFeedPost } from '@atcute/bluesky'; 18 - import { SvelteMap } from 'svelte/reactivity'; 18 + import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 19 19 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 20 - import { notificationStream } from '$lib'; 20 + import { notificationStream, selectedDid } from '$lib'; 21 21 import { get } from 'svelte/store'; 22 + import Icon from '@iconify/svelte'; 22 23 23 24 let loaderState = new LoaderState(); 24 25 let scrollContainer = $state<HTMLDivElement>(); 25 26 26 - let selectedDid = $state<Did | null>(null); 27 27 let clients = new SvelteMap<Did, AtpClient>(); 28 - let selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); 28 + let selectedClient = $derived($selectedDid ? clients.get($selectedDid) : null); 29 29 30 30 let viewClient = $state<AtpClient>(new AtpClient()); 31 31 32 - let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>(); 32 + let posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 33 33 let cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 34 34 35 35 let isSettingsOpen = $state(false); 36 + let reverseChronological = $state(true); 37 + let viewOwnPosts = $state(true); 36 38 37 - const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => { 39 + const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => { 38 40 if (!posts.has(did)) { 39 41 posts.set(did, new SvelteMap(accTimeline)); 40 42 return; ··· 50 52 const cursor = cursors.get(account.did); 51 53 if (cursor && cursor.end) return; 52 54 53 - const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 12); 55 + const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6); 54 56 if (!accPosts.ok) 55 57 throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`; 56 58 ··· 80 82 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 81 83 const hydrated = await hydratePosts(viewClient, [ 82 84 { 83 - record: subjectPost.value, 85 + record: subjectPost.value.record, 84 86 uri: event.data.link.subject, 87 + cid: subjectPost.value.cid, 85 88 replies: ok({ 86 89 cursor: null, 87 90 total: 1, ··· 144 147 // }); 145 148 if ($accounts.length > 0) { 146 149 loaderState.status = 'LOADING'; 147 - selectedDid = $accounts[0].did; 150 + $selectedDid = $accounts[0].did; 148 151 Promise.all($accounts.map(loginAccount)).then(() => { 149 152 loadMore(); 150 153 }); ··· 158 161 }; 159 162 160 163 const handleAccountSelected = async (did: Did) => { 161 - selectedDid = did; 164 + $selectedDid = did; 162 165 const account = $accounts.find((acc) => acc.did === did); 163 166 if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 164 167 await loginAccount(account); ··· 176 179 const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => { 177 180 const newAccount: Account = { did, handle, password }; 178 181 addAccount(newAccount); 179 - selectedDid = did; 182 + $selectedDid = did; 180 183 loginAccount(newAccount).then(() => fetchTimeline(newAccount)); 181 184 }; 182 185 ··· 198 201 } 199 202 }; 200 203 201 - let reverseChronological = $state(true); 202 - let viewOwnPosts = $state(true); 203 - 204 204 type ThreadPost = { 205 - uri: ResourceUri; 205 + data: PostWithUri; 206 206 did: Did; 207 207 rkey: string; 208 - record: AppBskyFeedPost.Main; 209 208 parentUri: ResourceUri | null; 210 209 depth: number; 211 210 newestTime: number; ··· 218 217 branchParentPost?: ThreadPost; 219 218 }; 220 219 221 - const buildThreads = (timelines: Map<Did, Map<ResourceUri, AppBskyFeedPost.Main>>): Thread[] => { 220 + const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => { 222 221 // eslint-disable-next-line svelte/prefer-svelte-reactivity 223 222 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 224 223 225 224 // Single pass: create posts and group by thread 226 225 for (const [, timeline] of timelines) { 227 - for (const [uri, record] of timeline) { 226 + for (const [uri, data] of timeline) { 228 227 const parsedUri = expect(parseCanonicalResourceUri(uri)); 229 - const rootUri = (record.reply?.root.uri as ResourceUri) || uri; 230 - const parentUri = (record.reply?.parent.uri as ResourceUri) || null; 228 + const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 229 + const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 231 230 232 231 const post: ThreadPost = { 233 - uri, 232 + data, 234 233 did: parsedUri.repo, 235 234 rkey: parsedUri.rkey, 236 - record, 237 235 parentUri, 238 236 depth: 0, 239 - newestTime: new Date(record.createdAt).getTime() 237 + newestTime: new Date(data.record.createdAt).getTime() 240 238 }; 241 239 242 240 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); ··· 248 246 const threads: Thread[] = []; 249 247 250 248 for (const [rootUri, posts] of threadMap) { 251 - const uriToPost = new Map(posts.map((p) => [p.uri, p])); 249 + const uriToPost = new Map(posts.map((p) => [p.data.uri, p])); 252 250 // eslint-disable-next-line svelte/prefer-svelte-reactivity 253 251 const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 254 252 ··· 292 290 const result: ThreadPost[] = []; 293 291 const addWithChildren = (post: ThreadPost) => { 294 292 result.push(post); 295 - const children = childrenMap.get(post.uri) || []; 293 + const children = childrenMap.get(post.data.uri) || []; 296 294 children.forEach(addWithChildren); 297 295 }; 298 296 addWithChildren(startPost); ··· 342 340 threads.push( 343 341 createThread( 344 342 branchPosts, 345 - branchRoot.uri, 343 + branchRoot.data.uri, 346 344 isOldestBranch ? undefined : (branchParentUri ?? undefined) 347 345 ) 348 346 ); ··· 371 369 }); 372 370 373 371 let threads = $derived(filterThreads(buildThreads(posts), $accounts)); 372 + 373 + let quoting = $state<PostWithUri | undefined>(undefined); 374 + let replying = $state<PostWithUri | undefined>(undefined); 375 + 376 + let expandedThreads = new SvelteSet<ResourceUri>(); 374 377 </script> 375 378 376 379 <div class="mx-auto flex h-screen max-w-2xl flex-col p-4"> ··· 384 387 </div> 385 388 <button 386 389 onclick={() => (isSettingsOpen = true)} 387 - class="rounded-sm bg-(--nucleus-accent)/7 p-2.5 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg" 388 - aria-label="Settings" 390 + class="group rounded-sm bg-(--nucleus-accent)/7 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg" 391 + aria-label="settings" 389 392 > 390 - <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 391 - <path 392 - stroke-linecap="round" 393 - stroke-linejoin="round" 394 - stroke-width="2" 395 - d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" 396 - /> 397 - <path 398 - stroke-linecap="round" 399 - stroke-linejoin="round" 400 - stroke-width="2" 401 - d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" 402 - /> 403 - </svg> 393 + <Icon class="group-hover:hidden" icon="heroicons:cog-6-tooth" width={28} /> 394 + <Icon class="hidden group-hover:block" icon="heroicons:cog-6-tooth-solid" width={28} /> 404 395 </button> 405 396 </div> 406 397 ··· 409 400 <AccountSelector 410 401 client={viewClient} 411 402 accounts={$accounts} 412 - bind:selectedDid 403 + bind:selectedDid={$selectedDid} 413 404 onAccountSelected={handleAccountSelected} 414 405 onLoginSucceed={handleLoginSucceed} 415 406 onLogout={handleLogout} ··· 419 410 <div class="flex-1"> 420 411 <PostComposer 421 412 client={selectedClient} 422 - onPostSent={(uri, record) => posts.get(selectedDid!)?.set(uri, record)} 413 + {selectedDid} 414 + onPostSent={(post) => posts.get($selectedDid!)?.set(post.uri, post)} 415 + bind:quoting 416 + bind:replying 423 417 /> 424 418 </div> 425 419 {:else} ··· 431 425 {/if} 432 426 </div> 433 427 434 - <hr 428 + <!-- <hr 435 429 class="h-[4px] w-full rounded-full border-0" 436 430 style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 437 - /> 431 + /> --> 438 432 </div> 439 433 440 434 <div ··· 488 482 </InfiniteLoader> 489 483 {/snippet} 490 484 485 + {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 486 + <span 487 + class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap break-words overflow-ellipsis" 488 + > 489 + <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 490 + <BskyPost mini {selectedDid} client={selectedClient ?? viewClient} {...post} /> 491 + </span> 492 + {/snippet} 493 + 491 494 {#snippet threadsView()} 492 - {#each threads as thread ([thread.rootUri, thread.branchParentPost, ...thread.posts.map((post) => post.uri)])} 495 + {#each threads as thread (thread.rootUri)} 493 496 <div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5"> 494 497 {#if thread.branchParentPost} 495 - {@const post = thread.branchParentPost} 496 - <div class="mb-1.5 flex items-center gap-1.5"> 497 - <span class="text-sm text-nowrap opacity-60">{reverseChronological ? '↱' : '↳'}</span> 498 - <BskyPost mini client={viewClient} {...post} /> 499 - </div> 498 + {@render replyPost(thread.branchParentPost)} 500 499 {/if} 501 - {#each thread.posts as post (post.uri)} 502 - <div class="mb-1.5"> 503 - <BskyPost client={viewClient} {...post} /> 504 - </div> 500 + {#each thread.posts as post, idx (post.data.uri)} 501 + {@const mini = 502 + !expandedThreads.has(thread.rootUri) && 503 + thread.posts.length > 4 && 504 + idx > 0 && 505 + idx < thread.posts.length - 2} 506 + {#if !mini} 507 + <div class="mb-1.5"> 508 + <BskyPost 509 + {selectedDid} 510 + client={selectedClient ?? viewClient} 511 + onQuote={(post) => (quoting = post)} 512 + onReply={(post) => (replying = post)} 513 + {...post} 514 + /> 515 + </div> 516 + {:else if mini} 517 + {#if idx === 1} 518 + {@render replyPost(post, !reverseChronological)} 519 + <button 520 + 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)" 521 + onclick={() => expandedThreads.add(thread.rootUri)} 522 + > 523 + <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 524 + <Icon 525 + class="shrink-0" 526 + icon={reverseChronological 527 + ? 'heroicons:bars-arrow-up-solid' 528 + : 'heroicons:bars-arrow-down-solid'} 529 + width={32} 530 + /><span class="shrink-0 pb-1">view full chain</span> 531 + <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 532 + </button> 533 + {:else if idx === thread.posts.length - 3} 534 + {@render replyPost(post)} 535 + {/if} 536 + {/if} 505 537 {/each} 506 538 </div> 507 539 {/each}