replies timeline only, appview-less bluesky client

feat: implement a bunch of shit part 2

+6 -4
src/app.css
··· 4 4 .grain:before { 5 5 content: ''; 6 6 background-color: transparent; 7 - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E"); 7 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='4' stitchTiles='stitch' /%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='2' intercept='-0.5' /%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)' /%3E%3C/svg%3E"); 8 8 background-repeat: repeat; 9 - background-size: 364px; 10 - opacity: 0.05; 9 + background-size: 40vmax; 10 + opacity: 0.06; 11 11 top: 0; 12 12 left: 0; 13 - position: absolute; 13 + position: fixed; 14 14 width: 100%; 15 15 height: 100%; 16 + pointer-events: none; 17 + z-index: 1; 16 18 }
+53 -29
src/components/AccountSelector.svelte
··· 4 4 import type { Did, Handle } from '@atcute/lexicons'; 5 5 import { theme } from '$lib/theme.svelte'; 6 6 7 - let { 8 - accounts = [], 9 - selectedDid = $bindable(null), 10 - onAccountSelected, 11 - onLoginSucceed 12 - }: { 7 + interface Props { 13 8 accounts: Array<Account>; 14 9 selectedDid?: Did | null; 15 10 onAccountSelected: (did: Did) => void; 16 11 onLoginSucceed: (did: Did, handle: Handle, password: string) => void; 17 - } = $props(); 12 + onLogout: (did: Did) => void; 13 + } 14 + 15 + let { 16 + accounts = [], 17 + selectedDid = $bindable(null), 18 + onAccountSelected, 19 + onLoginSucceed, 20 + onLogout 21 + }: Props = $props(); 18 22 19 23 let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg); 20 24 ··· 104 108 <div class="relative"> 105 109 <button 106 110 onclick={toggleDropdown} 107 - class="group flex h-full items-center gap-2 rounded-2xl border-2 px-4 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl" 111 + class="group flex h-full items-center gap-2 rounded-sm border-2 px-2 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl" 108 112 style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);" 109 113 > 110 - <span class="text-sm"> 114 + <span class="font-bold"> 111 115 {selectedAccount ? `@${selectedAccount.handle}` : 'select account'} 112 116 </span> 113 117 <svg ··· 125 129 <!-- svelte-ignore a11y_click_events_have_key_events --> 126 130 <!-- svelte-ignore a11y_no_static_element_interactions --> 127 131 <div 128 - class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-2xl border-2 shadow-2xl backdrop-blur-lg" 132 + class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-sm border-2 shadow-2xl backdrop-blur-lg" 129 133 style="border-color: {theme.accent}; background: {theme.bg}f0;" 130 134 onclick={(e) => e.stopPropagation()} 131 135 > ··· 135 139 {@const color = generateColorForDid(account.did)} 136 140 <button 137 141 onclick={() => selectAccount(account.did)} 138 - class="flex w-full items-center gap-3 rounded-xl p-2 text-left text-sm font-medium transition-all {account.did === 139 - selectedDid 140 - ? 'shadow-lg' 141 - : 'hover:scale-[1.02]'}" 142 + class=" 143 + group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 144 + {account.did === selectedDid ? 'shadow-lg' : ''} 145 + " 142 146 style="color: {color}; background: {account.did === selectedDid 143 147 ? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)` 144 148 : 'transparent'};" 145 149 > 146 150 <span>@{account.handle}</span> 151 + <svg 152 + xmlns="http://www.w3.org/2000/svg" 153 + onclick={() => onLogout(account.did)} 154 + class="ml-auto hidden h-5 w-5 transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md" 155 + style="color: {theme.accent};" 156 + width="24" 157 + height="24" 158 + viewBox="0 0 20 20" 159 + ><path 160 + fill="currentColor" 161 + fill-rule="evenodd" 162 + d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443q-1.193.115-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022l.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52l.149.023a.75.75 0 0 0 .23-1.482A41 41 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1zM10 4q1.26 0 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325Q8.74 4 10 4M8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06z" 163 + clip-rule="evenodd" 164 + /></svg 165 + > 166 + 147 167 {#if account.did === selectedDid} 148 168 <svg 149 - class="ml-auto h-5 w-5" 169 + xmlns="http://www.w3.org/2000/svg" 170 + class="ml-auto h-5 w-5 group-hover:hidden" 150 171 style="color: {theme.accent};" 151 - fill="currentColor" 152 - viewBox="0 0 20 20" 153 - > 154 - <path 172 + width="24" 173 + height="24" 174 + viewBox="0 0 24 24" 175 + ><path 176 + fill="currentColor" 155 177 fill-rule="evenodd" 156 - d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" 178 + d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353l8.493-12.74a.75.75 0 0 1 1.04-.207" 157 179 clip-rule="evenodd" 158 - /> 159 - </svg> 180 + stroke-width="1.5" 181 + stroke="currentColor" 182 + /></svg 183 + > 160 184 {/if} 161 185 </button> 162 186 {/each} ··· 168 192 {/if} 169 193 <button 170 194 onclick={openLoginModal} 171 - class="flex w-full items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.02]" 195 + class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.1]" 172 196 style="color: {theme.accent};" 173 197 > 174 198 <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> ··· 197 221 <!-- svelte-ignore a11y_interactive_supports_focus --> 198 222 <!-- svelte-ignore a11y_click_events_have_key_events --> 199 223 <div 200 - class="w-full max-w-md rounded-3xl border-2 p-5 shadow-2xl" 224 + class="w-full max-w-md rounded-sm border-2 p-5 shadow-2xl" 201 225 style="background: {theme.bg}; border-color: {theme.accent};" 202 226 onclick={(e) => e.stopPropagation()} 203 227 role="dialog" ··· 237 261 type="text" 238 262 bind:value={loginHandle} 239 263 placeholder="example.bsky.social" 240 - class="placeholder-opacity-40 w-full rounded-xl border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none" 264 + class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none" 241 265 style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};" 242 266 disabled={isLoggingIn} 243 267 /> ··· 256 280 type="password" 257 281 bind:value={loginPassword} 258 282 placeholder="xxxx-xxxx-xxxx-xxxx" 259 - class="placeholder-opacity-40 w-full rounded-xl border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none" 283 + class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none" 260 284 style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};" 261 285 disabled={isLoggingIn} 262 286 /> ··· 264 288 265 289 {#if loginError} 266 290 <div 267 - class="rounded-xl border-2 p-4" 291 + class="rounded-sm border-2 p-4" 268 292 style="background: #ef444422; border-color: #ef4444;" 269 293 > 270 294 <p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p> ··· 274 298 <div class="flex gap-3 pt-3"> 275 299 <button 276 300 onclick={closeLoginModal} 277 - class="flex-1 rounded-xl border-2 px-5 py-3 font-semibold transition-all hover:scale-105" 301 + class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105" 278 302 style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};" 279 303 disabled={isLoggingIn} 280 304 > ··· 282 306 </button> 283 307 <button 284 308 onclick={handleLogin} 285 - class="flex-1 rounded-xl border-2 px-5 py-3 font-semibold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50" 309 + class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50" 286 310 style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};" 287 311 disabled={isLoggingIn} 288 312 >
+74 -50
src/components/BskyPost.svelte
··· 4 4 import type { ActorIdentifier, RecordKey } from '@atcute/lexicons'; 5 5 import { theme } from '$lib/theme.svelte'; 6 6 import { map, ok } from '$lib/result'; 7 - import type { Backlinks } from '$lib/at/constellation'; 8 7 import { generateColorForDid } from '$lib/accounts'; 9 8 10 9 interface Props { 11 10 client: AtpClient; 12 11 identifier: ActorIdentifier; 13 12 rkey: RecordKey; 14 - replyBacklinks?: Backlinks; 13 + // replyBacklinks?: Backlinks; 15 14 record?: AppBskyFeedPost.Main; 15 + mini?: boolean; 16 16 } 17 17 18 - const { client, identifier, rkey, record, replyBacklinks }: Props = $props(); 18 + const { client, identifier, rkey, record, mini /* replyBacklinks */ }: Props = $props(); 19 19 20 20 const color = generateColorForDid(identifier) ?? theme.accent2; 21 21 ··· 29 29 const post = record 30 30 ? Promise.resolve(ok(record)) 31 31 : client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey); 32 - const replies = replyBacklinks 33 - ? Promise.resolve(ok(replyBacklinks)) 34 - : client.getBacklinks( 35 - identifier, 36 - 'app.bsky.feed.post', 37 - rkey, 38 - 'app.bsky.feed.post:reply.parent.uri' 39 - ); 32 + // const replies = replyBacklinks 33 + // ? Promise.resolve(ok(replyBacklinks)) 34 + // : client.getBacklinks( 35 + // identifier, 36 + // 'app.bsky.feed.post', 37 + // rkey, 38 + // 'app.bsky.feed.post:reply.parent.uri' 39 + // ); 40 40 41 41 const getEmbedText = (embedType: string) => { 42 42 switch (embedType) { ··· 70 70 if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; 71 71 if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; 72 72 if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; 73 - return `${seconds} second${seconds > 1 ? 's' : ''} ago`; 73 + if (seconds > 0) return `${seconds} second${seconds > 1 ? 's' : ''} ago`; 74 + return 'just now'; 74 75 }; 75 76 </script> 76 77 77 - {#await post} 78 + {#snippet embedBadge(record: AppBskyFeedPost.Main)} 79 + {#if record.embed} 80 + <span 81 + class="rounded-full px-2.5 py-0.5 text-xs font-medium" 82 + style="background: {mini ? theme.fg : color}22; color: {mini ? theme.fg : color};" 83 + > 84 + {getEmbedText(record.embed.$type)} 85 + </span> 86 + {/if} 87 + {/snippet} 88 + 89 + {#if mini} 78 90 <div 79 - class="rounded-xl border-2 p-3 text-center backdrop-blur-sm" 80 - style="background: {color}18; border-color: {color}66;" 91 + class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60" 92 + style="color: {theme.fg};" 81 93 > 82 - <div 83 - class="inline-block h-6 w-6 animate-spin rounded-full border-3" 84 - style="border-color: {theme.accent}; border-left-color: transparent;" 85 - ></div> 86 - <p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p> 94 + {#await post} 95 + loading... 96 + {:then post} 97 + {#if post.ok} 98 + {@const record = post.value} 99 + <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)} 100 + {record.text} 101 + {:else} 102 + {post.error} 103 + {/if} 104 + {/await} 87 105 </div> 88 - {:then post} 89 - {#if post.ok} 90 - {@const record = post.value} 106 + {:else} 107 + {#await post} 91 108 <div 92 - class="rounded-xl border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]" 109 + class="rounded-sm border-2 p-3 text-center backdrop-blur-sm" 93 110 style="background: {color}18; border-color: {color}66;" 94 111 > 95 - <div class="mb-3 flex items-center gap-1.5"> 96 - <span class="font-bold" style="color: {color};"> 97 - @{handle} 98 - </span> 99 - <span>·</span> 112 + <div 113 + class="inline-block h-6 w-6 animate-spin rounded-full border-3" 114 + style="border-color: {theme.accent}; border-left-color: transparent;" 115 + ></div> 116 + <p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p> 117 + </div> 118 + {:then post} 119 + {#if post.ok} 120 + {@const record = post.value} 121 + <div 122 + class="rounded-sm border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]" 123 + style="background: {color}18; border-color: {color}66;" 124 + > 125 + <div class="mb-3 flex items-center gap-1.5"> 126 + <span class="font-bold" style="color: {color};"> 127 + @{handle} 128 + </span> 129 + <!-- <span>·</span> 100 130 {#await replies} 101 131 <span style="color: {theme.fg}aa;">… replies</span> 102 132 {:then replies} ··· 117 147 style="color: {theme.fg}aa;">{replies.error}</span 118 148 > 119 149 {/if} 120 - {/await} 121 - <span>·</span> 122 - <span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span> 150 + {/await} --> 151 + <span>·</span> 152 + <span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span> 153 + </div> 154 + <p class="leading-relaxed text-wrap" style="color: {theme.fg};"> 155 + {record.text} 156 + {@render embedBadge(record)} 157 + </p> 123 158 </div> 124 - <p class="leading-relaxed text-wrap" style="color: {theme.fg};"> 125 - {record.text} 126 - {#if record.embed} 127 - <span 128 - class="rounded-full px-2.5 py-0.5 text-xs font-medium" 129 - style="background: {color}22; color: {color};" 130 - > 131 - {getEmbedText(record.embed.$type)} 132 - </span> 133 - {/if} 134 - </p> 135 - </div> 136 - {:else} 137 - <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;"> 138 - <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p> 139 - </div> 140 - {/if} 141 - {/await} 159 + {:else} 160 + <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;"> 161 + <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p> 162 + </div> 163 + {/if} 164 + {/await} 165 + {/if}
+29 -22
src/components/PostComposer.svelte
··· 1 1 <script lang="ts"> 2 2 import type { AtpClient } from '$lib/at/client'; 3 3 import { ok, err, type Result } from '$lib/result'; 4 - import type { ComAtprotoRepoCreateRecord } from '@atcute/atproto'; 5 4 import type { AppBskyFeedPost } from '@atcute/bluesky'; 6 - import type { InferOutput } from '@atcute/lexicons'; 5 + import type { ResourceUri } from '@atcute/lexicons'; 7 6 import { theme } from '$lib/theme.svelte'; 8 7 9 8 interface Props { 10 9 client: AtpClient; 10 + onPostSent: (uri: ResourceUri, post: AppBskyFeedPost.Main) => void; 11 11 } 12 12 13 - const { client }: Props = $props(); 13 + const { client, onPostSent }: Props = $props(); 14 14 15 15 const post = async ( 16 16 text: string 17 - ): Promise< 18 - Result<InferOutput<(typeof ComAtprotoRepoCreateRecord.mainSchema)['output']['schema']>, string> 19 - > => { 17 + ): Promise<Result<{ uri: ResourceUri; record: AppBskyFeedPost.Main }, string>> => { 20 18 const record: AppBskyFeedPost.Main = { 21 19 $type: 'app.bsky.feed.post', 22 20 text, ··· 39 37 return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`); 40 38 } 41 39 42 - return ok(res.data); 40 + return ok({ 41 + uri: res.data.uri, 42 + record 43 + }); 43 44 }; 44 45 45 46 let postText = $state(''); 46 47 let info = $state(''); 48 + 49 + const doPost = () => { 50 + post(postText).then((res) => { 51 + if (res.ok) { 52 + onPostSent(res.value.uri, res.value.record); 53 + postText = ''; 54 + info = 'posted!'; 55 + setTimeout(() => (info = ''), 1000 * 3); 56 + } else { 57 + info = res.error; 58 + } 59 + }); 60 + }; 47 61 </script> 48 62 49 63 <div 50 - class="flex min-h-16 max-w-full items-center rounded-xl border-2 px-1 shadow-lg backdrop-blur-sm" 64 + class="flex min-h-16 max-w-full items-center rounded-sm border-2 px-1 shadow-lg backdrop-blur-sm" 51 65 style="background: {theme.accent}18; border-color: {theme.accent}66;" 52 66 > 53 67 <div class="w-full p-1"> 54 68 {#if info.length > 0} 55 69 <div 56 - class="rounded-lg px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 70 + class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 57 71 style="background: {theme.accent}22; color: {theme.accent};" 58 72 > 59 73 {info} ··· 62 76 <div class="flex gap-2"> 63 77 <input 64 78 bind:value={postText} 79 + onkeydown={(event) => { 80 + if (event.key === 'Enter') doPost(); 81 + }} 65 82 type="text" 66 83 placeholder="what's on your mind?" 67 - class="placeholder-opacity-50 flex-1 rounded-lg border-2 px-3 py-2 text-sm font-medium transition-all focus:scale-[1.01] focus:shadow-lg focus:outline-none" 84 + class="placeholder-opacity-50 flex-1 rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:scale-[1.01] focus:shadow-lg focus:outline-none" 68 85 style="background: {theme.bg}66; border-color: {theme.accent}44; color: {theme.fg};" 69 86 /> 70 87 <button 71 - onclick={() => { 72 - post(postText).then((res) => { 73 - if (res.ok) { 74 - postText = ''; 75 - info = 'posted! aaaaaaaaaasdf asdlfkasl;df kjasdfjalsdkfjaskd fajksdhf'; 76 - setTimeout(() => (info = ''), 1000 * 3); 77 - } else { 78 - info = res.error; 79 - } 80 - }); 81 - }} 82 - class="rounded-lg border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl" 88 + onclick={doPost} 89 + class="rounded-sm border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl" 83 90 style="background: linear-gradient(120deg, {theme.accent}c0, {theme.accent2}c0); color: {theme.fg}f0;" 84 91 > 85 92 post
+20 -2
src/lib/at/client.ts
··· 9 9 import { 10 10 isHandle, 11 11 parseCanonicalResourceUri, 12 + parseResourceUri, 12 13 type ActorIdentifier, 13 14 type AtprotoDid, 14 15 type CanonicalResourceUri, 15 16 type Nsid, 16 - type RecordKey 17 + type RecordKey, 18 + type ResourceUri 17 19 } from '@atcute/lexicons/syntax'; 18 20 import type { 21 + InferInput, 19 22 InferXRPCBodyOutput, 20 23 ObjectSchema, 21 24 RecordKeySchema, ··· 74 77 return ok(null); 75 78 } 76 79 80 + async getRecordUri< 81 + Collection extends Nsid, 82 + TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 83 + TKey extends RecordKeySchema, 84 + Schema extends RecordSchema<TObject, TKey>, 85 + Output extends InferInput<Schema> 86 + >(schema: Schema, uri: ResourceUri): Promise<Result<Output, string>> { 87 + const parsedUri = expect(parseResourceUri(uri)); 88 + if (parsedUri.collection !== schema.object.shape.$type.expected) 89 + return err( 90 + `collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}` 91 + ); 92 + return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!); 93 + } 94 + 77 95 async getRecord< 78 96 Collection extends Nsid, 79 97 TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 80 98 TKey extends RecordKeySchema, 81 99 Schema extends RecordSchema<TObject, TKey>, 82 - Output extends InferOutput<Schema> 100 + Output extends InferInput<Schema> 83 101 >(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise<Result<Output, string>> { 84 102 const collection = schema.object.shape.$type.expected; 85 103 const cacheKey = `${repo}:${collection}:${rkey}`;
+67 -30
src/lib/at/fetch.ts
··· 4 4 import type { Backlinks } from './constellation'; 5 5 import { AppBskyFeedPost } from '@atcute/bluesky'; 6 6 7 - export type PostWithBacklinks = { 8 - post: AppBskyFeedPost.Main; 9 - replies: Backlinks | string; 7 + export type PostWithUri = { uri: CanonicalResourceUri; record: AppBskyFeedPost.Main }; 8 + export type PostWithBacklinks = PostWithUri & { 9 + replies: Result<Backlinks, string>; 10 10 }; 11 - export type PostsWithReplyBacklinks = Map<CanonicalResourceUri, PostWithBacklinks>; 11 + export type PostsWithReplyBacklinks = PostWithBacklinks[]; 12 12 13 - export const fetchPostsWithReplyBacklinks = async ( 13 + export const fetchPostsWithBacklinks = async ( 14 14 client: AtpClient, 15 15 repo: ActorIdentifier, 16 16 cursor?: string, ··· 25 25 records.map((r) => 26 26 client 27 27 .getBacklinksUri(r.uri as CanonicalResourceUri, 'app.bsky.feed.post:reply.parent.uri') 28 - .then((res) => ({ 29 - key: r.uri as CanonicalResourceUri, 30 - value: { 31 - post: r.value as AppBskyFeedPost.Main, 32 - // filter out posts from the same repo 33 - replies: res.ok 34 - ? { ...res.value, records: res.value.records.filter((r) => r.did !== repo) } 35 - : res.error 36 - } 37 - })) 28 + .then( 29 + (res): PostWithBacklinks => ({ 30 + uri: r.uri as CanonicalResourceUri, 31 + record: r.value as AppBskyFeedPost.Main, 32 + replies: res 33 + }) 34 + ) 38 35 ) 39 36 ); 40 37 41 - return ok({ posts: new Map(allBacklinks.map((b) => [b.key, b.value])), cursor }); 38 + return ok({ posts: allBacklinks, cursor }); 42 39 }; 43 40 44 - export const fetchReplies = async (client: AtpClient, data: PostsWithReplyBacklinks) => { 45 - const allReplies = await Promise.all( 46 - Array.from(data.values()).map(async (d) => { 47 - if (typeof d.replies === 'string') return []; 48 - const replies = await Promise.all( 49 - d.replies.records.map((r) => 50 - client 51 - .getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey) 52 - .then((res) => 53 - map(res, (d) => ({ uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}`, record: d })) 41 + export const hydratePosts = async ( 42 + client: AtpClient, 43 + data: PostsWithReplyBacklinks 44 + ): Promise<Map<CanonicalResourceUri, AppBskyFeedPost.Main>> => { 45 + const allPosts = await Promise.all( 46 + data.map(async (post) => { 47 + const result: Result<PostWithUri, string>[] = [ok({ uri: post.uri, record: post.record })]; 48 + if (post.replies.ok) { 49 + const replies = await Promise.all( 50 + 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 + ) 54 59 ) 55 - ) 56 - ); 57 - return replies; 60 + ) 61 + ); 62 + result.push(...replies); 63 + } 64 + return result; 58 65 }) 59 66 ); 67 + const posts = new Map( 68 + allPosts 69 + .flat() 70 + .flatMap((res) => (res.ok ? [res.value] : [])) 71 + .map((post) => [post.uri, post.record]) 72 + ); 60 73 61 - return allReplies.flat(); 74 + // hydrate posts 75 + 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; 79 + while (parent) { 80 + if (posts.has(parent.uri as CanonicalResourceUri)) { 81 + return result; 82 + } 83 + const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri); 84 + if (p.ok) { 85 + result = [{ uri: parent.uri as CanonicalResourceUri, record: p.value }, ...result]; 86 + parent = p.value.reply?.parent; 87 + continue; 88 + } 89 + parent = undefined; 90 + } 91 + return result; 92 + }) 93 + ); 94 + for (const post of missingPosts.flat()) { 95 + posts.set(post.uri, post.record); 96 + } 97 + 98 + return posts; 62 99 };
+1 -1
src/lib/theme.svelte.ts
··· 1 1 export const theme = $state({ 2 - bg: '#0f172a', // slate-900 - deep blue-grey background 2 + bg: '#11001c', // slate-900 - deep blue-grey background 3 3 fg: '#f8fafc', // slate-50 - crisp white foreground 4 4 accent: '#ec4899', // pink-500 - vibrant pink accent 5 5 accent2: '#8b5cf6' // violet-500 - purple secondary accent
+250 -51
src/routes/+page.svelte
··· 4 4 import AccountSelector from '$components/AccountSelector.svelte'; 5 5 import { AtpClient } from '$lib/at/client'; 6 6 import { accounts, addAccount, type Account } from '$lib/accounts'; 7 - import { type Did, type Handle, parseCanonicalResourceUri } from '@atcute/lexicons'; 7 + import { 8 + type Did, 9 + type Handle, 10 + parseCanonicalResourceUri, 11 + type ResourceUri 12 + } from '@atcute/lexicons'; 8 13 import { onMount } from 'svelte'; 9 14 import { theme } from '$lib/theme.svelte'; 10 - import { fetchPostsWithReplyBacklinks, fetchReplies } from '$lib/at/fetch'; 15 + import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch'; 11 16 import { expect } from '$lib/result'; 12 - import { writable } from 'svelte/store'; 13 17 import type { AppBskyFeedPost } from '@atcute/bluesky'; 18 + import { SvelteMap } from 'svelte/reactivity'; 14 19 15 20 let selectedDid = $state<Did | null>(null); 16 - let clients = writable<Map<Did, AtpClient>>(new Map()); 17 - let selectedClient = $derived(selectedDid ? $clients.get(selectedDid) : null); 21 + let clients = new SvelteMap<Did, AtpClient>(); 22 + let selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); 18 23 19 24 let viewClient = $state<AtpClient>(new AtpClient()); 20 25 21 26 onMount(async () => { 22 27 if ($accounts.length > 0) { 23 28 selectedDid = $accounts[0].did; 24 - Promise.all($accounts.map(loginAccount)).then(() => fetchTimeline($accounts)); 29 + Promise.all($accounts.map(loginAccount)).then(() => fetchTimelines($accounts)); 25 30 } 26 31 }); 27 32 28 33 const loginAccount = async (account: Account) => { 29 34 const client = new AtpClient(); 30 35 const result = await client.login(account.handle, account.password); 31 - if (result.ok) { 32 - clients.update((map) => map.set(account.did, client)); 33 - } 36 + if (result.ok) clients.set(account.did, client); 34 37 }; 35 38 36 39 const handleAccountSelected = async (did: Did) => { 37 40 selectedDid = did; 38 41 const account = $accounts.find((acc) => acc.did === did); 39 - if (account && (!$clients.has(account.did) || !$clients.get(account.did)?.atcute)) 42 + if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 40 43 await loginAccount(account); 41 44 }; 42 45 43 - const handleLoginSucceed = (did: Did, handle: Handle, password: string) => { 46 + const handleLogout = async (did: Did) => { 47 + $accounts = $accounts.filter((acc) => acc.did !== did); 48 + clients.delete(did); 49 + posts.delete(did); 50 + }; 51 + 52 + const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => { 44 53 const newAccount: Account = { did, handle, password }; 45 54 addAccount(newAccount); 46 55 selectedDid = did; 47 - loginAccount(newAccount); 56 + loginAccount(newAccount).then(() => fetchTimeline(newAccount)); 48 57 }; 49 58 50 - let timeline = writable<Map<string, AppBskyFeedPost.Main>>(new Map()); 51 - const fetchTimeline = async (newAccounts: Account[]) => { 52 - await Promise.all( 53 - newAccounts.map(async (account) => { 54 - const client = $clients.get(account.did); 55 - if (!client) return; 56 - const accPosts = await fetchPostsWithReplyBacklinks(client, account.did, undefined, 20); 57 - if (!accPosts.ok) { 58 - console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`); 59 - return; 59 + let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>(); 60 + const fetchTimeline = async (account: Account) => { 61 + const client = clients.get(account.did); 62 + if (!client) return; 63 + const accPosts = await fetchPostsWithBacklinks(client, account.did, undefined, 20); 64 + if (!accPosts.ok) { 65 + console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`); 66 + return; 67 + } 68 + const accTimeline = await hydratePosts(client, accPosts.value.posts); 69 + if (!posts.has(account.did)) { 70 + posts.set(account.did, new SvelteMap(accTimeline)); 71 + return; 72 + } 73 + const map = posts.get(account.did)!; 74 + for (const [uri, record] of accTimeline) map.set(uri, record); 75 + }; 76 + const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline)); 77 + 78 + let reverseChronological = $state(true); 79 + let viewOwnPosts = $state(true); 80 + 81 + type ThreadPost = { 82 + uri: ResourceUri; 83 + did: Did; 84 + rkey: string; 85 + record: AppBskyFeedPost.Main; 86 + parentUri: ResourceUri | null; 87 + depth: number; 88 + newestTime: number; 89 + }; 90 + 91 + type Thread = { 92 + rootUri: ResourceUri; 93 + posts: ThreadPost[]; 94 + newestTime: number; 95 + branchParentPost?: ThreadPost; 96 + }; 97 + 98 + const buildThreads = (timelines: Map<Did, Map<ResourceUri, AppBskyFeedPost.Main>>): Thread[] => { 99 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 100 + const threadMap = new Map<ResourceUri, ThreadPost[]>(); 101 + 102 + // Single pass: create posts and group by thread 103 + for (const [, timeline] of timelines) { 104 + for (const [uri, record] of timeline) { 105 + const parsedUri = expect(parseCanonicalResourceUri(uri)); 106 + const rootUri = (record.reply?.root.uri as ResourceUri) || uri; 107 + const parentUri = (record.reply?.parent.uri as ResourceUri) || null; 108 + 109 + const post: ThreadPost = { 110 + uri, 111 + did: parsedUri.repo, 112 + rkey: parsedUri.rkey, 113 + record, 114 + parentUri, 115 + depth: 0, 116 + newestTime: new Date(record.createdAt).getTime() 117 + }; 118 + 119 + if (!threadMap.has(rootUri)) { 120 + threadMap.set(rootUri, []); 121 + } 122 + threadMap.get(rootUri)!.push(post); 123 + } 124 + } 125 + 126 + const threads: Thread[] = []; 127 + 128 + for (const [rootUri, posts] of threadMap) { 129 + const uriToPost = new Map(posts.map((p) => [p.uri, p])); 130 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 131 + const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 132 + 133 + // Calculate depth and group by parent 134 + for (const post of posts) { 135 + let depth = 0; 136 + let currentUri = post.parentUri; 137 + 138 + while (currentUri && uriToPost.has(currentUri)) { 139 + depth++; 140 + currentUri = uriToPost.get(currentUri)!.parentUri; 141 + } 142 + 143 + post.depth = depth; 144 + 145 + if (!childrenMap.has(post.parentUri)) { 146 + childrenMap.set(post.parentUri, []); 60 147 } 61 - const accTimeline = await fetchReplies(client, accPosts.value.posts); 62 - for (const reply of accTimeline) { 63 - if (!reply.ok) { 64 - console.error(`failed to fetch reply: ${reply.error}`); 65 - return; 148 + childrenMap.get(post.parentUri)!.push(post); 149 + } 150 + 151 + // Sort children by time (newest first) 152 + for (const children of childrenMap.values()) { 153 + children.sort((a, b) => b.newestTime - a.newestTime); 154 + } 155 + 156 + // Helper to create a thread from posts 157 + const createThread = ( 158 + posts: ThreadPost[], 159 + rootUri: ResourceUri, 160 + branchParentUri?: ResourceUri 161 + ): Thread => { 162 + return { 163 + rootUri, 164 + posts, 165 + newestTime: Math.max(...posts.map((p) => p.newestTime)), 166 + branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined 167 + }; 168 + }; 169 + 170 + // Helper to collect all posts in a subtree 171 + const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { 172 + const result: ThreadPost[] = []; 173 + const addWithChildren = (post: ThreadPost) => { 174 + result.push(post); 175 + const children = childrenMap.get(post.uri) || []; 176 + for (const child of children) { 177 + addWithChildren(child); 66 178 } 67 - timeline.update((map) => map.set(reply.value.uri, reply.value.record)); 179 + }; 180 + addWithChildren(startPost); 181 + return result; 182 + }; 183 + 184 + // Find branching points (posts with 2+ children) 185 + const branchingPoints = Array.from(childrenMap.entries()) 186 + .filter(([, children]) => children.length > 1) 187 + .map(([uri]) => uri); 188 + 189 + if (branchingPoints.length === 0) { 190 + // No branches - single thread 191 + const roots = childrenMap.get(null) || []; 192 + const allPosts = roots.flatMap((root) => collectSubtree(root)); 193 + threads.push(createThread(allPosts, rootUri)); 194 + } else { 195 + // Has branches - split into separate threads 196 + for (const branchParentUri of branchingPoints) { 197 + const branches = childrenMap.get(branchParentUri) || []; 198 + 199 + // Sort branches oldest to newest for processing 200 + const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); 201 + 202 + sortedBranches.forEach((branchRoot, index) => { 203 + const isOldestBranch = index === 0; 204 + const branchPosts: ThreadPost[] = []; 205 + 206 + // If oldest branch, include parent chain 207 + if (isOldestBranch && branchParentUri !== null) { 208 + const parentChain: ThreadPost[] = []; 209 + let currentUri: ResourceUri | null = branchParentUri; 210 + while (currentUri && uriToPost.has(currentUri)) { 211 + parentChain.unshift(uriToPost.get(currentUri)!); 212 + currentUri = uriToPost.get(currentUri)!.parentUri; 213 + } 214 + branchPosts.push(...parentChain); 215 + } 216 + 217 + // Add branch posts 218 + branchPosts.push(...collectSubtree(branchRoot)); 219 + 220 + // Recalculate depths for display 221 + const minDepth = Math.min(...branchPosts.map((p) => p.depth)); 222 + branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); 223 + 224 + threads.push( 225 + createThread( 226 + branchPosts, 227 + branchRoot.uri, 228 + isOldestBranch ? undefined : (branchParentUri ?? undefined) 229 + ) 230 + ); 231 + }); 68 232 } 69 - }) 70 - ); 71 - }; 72 - accounts.subscribe(fetchTimeline); 233 + } 234 + } 73 235 74 - const getSortedTimeline = (_timeline: Map<string, AppBskyFeedPost.Main>) => { 75 - const sortedTimeline = Array.from(_timeline).sort( 76 - ([_a, post], [_b, post2]) => 77 - new Date(post2.createdAt).getTime() - new Date(post.createdAt).getTime() 78 - ); 79 - return sortedTimeline; 236 + // Sort threads by newest time (descending) so older branches appear first 237 + threads.sort((a, b) => b.newestTime - a.newestTime); 238 + 239 + return threads; 80 240 }; 81 - let sortedTimeline = $derived(getSortedTimeline($timeline)); 241 + 242 + // Filtering functions (now much simpler!) 243 + const isOwnPost = (post: ThreadPost, accounts: Account[]) => 244 + accounts.some((account) => account.did === post.did); 245 + const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => 246 + posts.some((post) => !isOwnPost(post, accounts)); 247 + const filterThreads = (threads: Thread[], accounts: Account[]) => 248 + threads.filter((thread) => { 249 + if (!viewOwnPosts) { 250 + return hasNonOwnPost(thread.posts, accounts); 251 + } 252 + return true; 253 + }); 254 + 255 + // Usage 256 + let threads = $derived(filterThreads(buildThreads(posts), $accounts)); 82 257 </script> 83 258 84 259 <div class="mx-auto max-w-2xl p-4"> ··· 97 272 bind:selectedDid 98 273 onAccountSelected={handleAccountSelected} 99 274 onLoginSucceed={handleLoginSucceed} 275 + onLogout={handleLogout} 100 276 /> 101 277 102 278 {#if selectedClient} 103 279 <div class="flex-1"> 104 - <PostComposer client={selectedClient} /> 280 + <PostComposer 281 + client={selectedClient} 282 + onPostSent={(uri, record) => posts.get(selectedDid!)?.set(uri, record)} 283 + /> 105 284 </div> 106 285 {:else} 107 286 <div 108 - class="flex flex-1 items-center justify-center rounded-xl border-2 px-4 py-2.5 backdrop-blur-sm" 287 + class="flex flex-1 items-center justify-center rounded-sm border-2 px-4 py-2.5 backdrop-blur-sm" 109 288 style="border-color: {theme.accent}33; background: {theme.accent}0a;" 110 289 > 111 290 <p class="text-sm opacity-80" style="color: {theme.fg};"> ··· 116 295 </div> 117 296 118 297 <hr 119 - class="h-[3px] w-full rounded-full border-0" 298 + class="h-[4px] w-full rounded-full border-0" 120 299 style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});" 121 300 /> 122 301 123 - <div class="flex flex-col gap-3"> 124 - {#each sortedTimeline as [postUri, data] (postUri)} 125 - {@const parsedUri = expect(parseCanonicalResourceUri(postUri))} 126 - <BskyPost 127 - client={viewClient} 128 - identifier={parsedUri.repo} 129 - rkey={parsedUri.rkey} 130 - record={data} 131 - /> 302 + <div class="flex flex-col"> 303 + {#each threads as thread (thread.rootUri)} 304 + <div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5"> 305 + {#if thread.branchParentPost} 306 + {@const post = thread.branchParentPost} 307 + <div class="mb-1.5 flex items-center gap-1.5"> 308 + <span class="text-sm opacity-60" style="color: {theme.fg};" 309 + >{reverseChronological ? '↱' : '↳'}</span 310 + > 311 + <BskyPost 312 + mini 313 + client={viewClient} 314 + identifier={post.did} 315 + rkey={post.rkey} 316 + record={post.record} 317 + /> 318 + </div> 319 + {/if} 320 + {#each thread.posts as post (post.uri)} 321 + <div class="mb-1.5"> 322 + <BskyPost 323 + client={viewClient} 324 + identifier={post.did} 325 + rkey={post.rkey} 326 + record={post.record} 327 + /> 328 + </div> 329 + {/each} 330 + </div> 132 331 {/each} 133 332 </div> 134 333 </div>