replies timeline only, appview-less bluesky client

feat: fetch reply chains downwards, add scroll to when clicking on replies, etc

ptr.pet b7dd46e0 5e163a7e

verified
+14
src/app.css
··· 71 71 --picker-height: 8rem; 72 72 --picker-width: 8rem; 73 73 } 74 + 75 + .animate-pulse-highlight { 76 + animation: pulse-highlight 0.6s ease-in-out 3; 77 + } 78 + 79 + @keyframes pulse-highlight { 80 + 0%, 81 + 100% { 82 + box-shadow: 0 0 0 0 var(--nucleus-selected-post); 83 + } 84 + 50% { 85 + box-shadow: 0 0 20px 5px var(--nucleus-selected-post); 86 + } 87 + }
+45 -10
src/components/BskyPost.svelte
··· 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/state.svelte'; 21 + import { postActions, pulsingPostId, type PostActions } from '$lib/state.svelte'; 22 22 import * as TID from '@atcute/tid'; 23 23 import type { PostWithUri } from '$lib/at/fetch'; 24 24 import { onMount } from 'svelte'; 25 25 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 26 + import { derived } from 'svelte/store'; 26 27 27 28 interface Props { 28 29 client: AtpClient; ··· 30 31 did: Did; 31 32 rkey: RecordKey; 32 33 // replyBacklinks?: Backlinks; 33 - depth?: number; 34 + quoteDepth?: number; 34 35 data?: PostWithUri; 35 36 mini?: boolean; 36 37 isOnPostComposer?: boolean; ··· 42 43 client, 43 44 did, 44 45 rkey, 45 - depth = 0, 46 + quoteDepth = 0, 46 47 data, 47 48 mini, 48 49 onQuote, ··· 72 73 // 'app.bsky.feed.post:reply.parent.uri' 73 74 // ); 74 75 76 + const postId = `timeline-post-${aturi}-${quoteDepth}`; 77 + const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 78 + 79 + const scrollToAndPulse = (targetUri: ResourceUri) => { 80 + const targetId = `timeline-post-${targetUri}-0`; 81 + console.log(`Scrolling to ${targetId}`); 82 + const element = document.getElementById(targetId); 83 + if (!element) return; 84 + 85 + // Smooth scroll to the target 86 + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 87 + 88 + // Trigger pulse after scroll completes 89 + setTimeout(() => { 90 + document.documentElement.style.setProperty( 91 + '--nucleus-selected-post', 92 + generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) 93 + ); 94 + pulsingPostId.set(targetId); 95 + // Clear pulse after animation 96 + setTimeout(() => pulsingPostId.set(null), 2000); 97 + }, 500); 98 + }; 99 + 75 100 const getEmbedText = (embedType: string) => { 76 101 switch (embedType) { 77 102 case 'app.bsky.embed.external': ··· 205 230 {:then post} 206 231 {#if post.ok} 207 232 {@const record = post.value.record} 208 - <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)} 209 - <span title={record.text}>{record.text}</span> 233 + <!-- svelte-ignore a11y_click_events_have_key_events --> 234 + <!-- svelte-ignore a11y_no_static_element_interactions --> 235 + <div 236 + onclick={() => scrollToAndPulse(post.value.uri)} 237 + class="select-none hover:cursor-pointer hover:underline" 238 + > 239 + <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)} 240 + <span title={record.text}>{record.text}</span> 241 + </div> 210 242 {:else} 211 243 {post.error} 212 244 {/if} ··· 227 259 {#if post.ok} 228 260 {@const record = post.value.record} 229 261 <div 230 - class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all" 262 + id="timeline-post-{post.value.uri}-{quoteDepth}" 263 + class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all {$isPulsing 264 + ? 'animate-pulse-highlight' 265 + : ''}" 231 266 style="background: {color}{isOnPostComposer 232 267 ? '36' 233 268 : '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};" ··· 267 302 {@const embed = record.embed} 268 303 <div class="mt-2"> 269 304 {#snippet embedPost(uri: ResourceUri)} 270 - {#if depth < 2} 305 + {#if quoteDepth < 2} 271 306 {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 272 307 <!-- reject recursive quotes --> 273 308 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 274 309 <BskyPost 275 310 {client} 276 - depth={depth + 1} 311 + quoteDepth={quoteDepth + 1} 277 312 did={parsedUri.repo} 278 313 rkey={parsedUri.rkey} 279 314 {isOnPostComposer} ··· 325 360 {/if} 326 361 </div> 327 362 {:else} 328 - <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;"> 329 - <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p> 363 + <div class="error-disclaimer"> 364 + <p class="text-sm font-medium">error: {post.error}</p> 330 365 </div> 331 366 {/if} 332 367 {/await}
+60 -61
src/components/PostComposer.svelte
··· 107 107 }); 108 108 </script> 109 109 110 + {#snippet renderPost(post: PostWithUri)} 111 + {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 112 + <BskyPost 113 + {client} 114 + did={parsedUri.repo} 115 + rkey={parsedUri.rkey} 116 + data={post} 117 + isOnPostComposer={true} 118 + /> 119 + {/snippet} 120 + 121 + {#snippet composer()} 122 + {#if replying} 123 + {@render renderPost(replying)} 124 + {/if} 125 + <textarea 126 + bind:this={textareaEl} 127 + bind:value={postText} 128 + onfocus={() => (isFocused = true)} 129 + onblur={unfocus} 130 + onkeydown={(event) => { 131 + if (event.key === 'Escape') unfocus(); 132 + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 133 + }} 134 + placeholder="what's on your mind?" 135 + rows="4" 136 + class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100" 137 + style="border-color: color-mix(in srgb, {color} 27%, transparent);" 138 + ></textarea> 139 + {#if quoting} 140 + {@render renderPost(quoting)} 141 + {/if} 142 + <div class="flex items-center gap-2"> 143 + <div class="grow"></div> 144 + <span 145 + class="text-sm font-medium" 146 + style="color: color-mix(in srgb, {postText.length > 300 147 + ? '#ef4444' 148 + : 'var(--nucleus-fg)'} 53%, transparent);" 149 + > 150 + {postText.length} / 300 151 + </span> 152 + <button 153 + onmousedown={(e) => { 154 + e.preventDefault(); 155 + doPost(); 156 + }} 157 + disabled={postText.length === 0 || postText.length > 300} 158 + class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100" 159 + style="background: color-mix(in srgb, {color} 87%, transparent);" 160 + > 161 + post 162 + </button> 163 + </div> 164 + {/snippet} 165 + 110 166 <div class="relative min-h-16"> 111 167 <!-- Spacer to maintain layout when focused --> 112 168 {#if isFocused} ··· 120 176 e.preventDefault(); 121 177 } 122 178 }} 123 - class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300" 124 - class:min-h-16={!isFocused} 125 - class:items-center={!isFocused} 126 - class:shadow-2xl={isFocused} 127 - class:absolute={isFocused} 128 - class:top-0={isFocused} 129 - class:left-0={isFocused} 130 - class:right-0={isFocused} 131 - class:z-50={isFocused} 179 + class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300 180 + {!isFocused ? 'min-h-16 items-center' : ''} 181 + {isFocused ? 'absolute top-0 right-0 left-0 z-50 shadow-2xl' : ''}" 132 182 style="background: {isFocused 133 183 ? `color-mix(in srgb, var(--nucleus-bg) 80%, ${color} 20%)` 134 184 : `color-mix(in srgb, ${color} 9%, transparent)`}; ··· 144 194 </div> 145 195 {:else} 146 196 <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} 157 197 {#if isFocused} 158 - {#if replying} 159 - {@render renderPost(replying)} 160 - {/if} 161 - <textarea 162 - bind:this={textareaEl} 163 - bind:value={postText} 164 - onfocus={() => (isFocused = true)} 165 - onblur={unfocus} 166 - onkeydown={(event) => { 167 - if (event.key === 'Escape') unfocus(); 168 - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 169 - }} 170 - placeholder="what's on your mind?" 171 - rows="4" 172 - class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100" 173 - style="border-color: color-mix(in srgb, {color} 27%, transparent);" 174 - ></textarea> 175 - {#if quoting} 176 - {@render renderPost(quoting)} 177 - {/if} 178 - <div class="flex items-center gap-2"> 179 - <div class="grow"></div> 180 - <span 181 - class="text-sm font-medium" 182 - style="color: color-mix(in srgb, {postText.length > 300 183 - ? '#ef4444' 184 - : 'var(--nucleus-fg)'} 53%, transparent);" 185 - > 186 - {postText.length} / 300 187 - </span> 188 - <button 189 - onmousedown={(e) => { 190 - e.preventDefault(); 191 - doPost(); 192 - }} 193 - disabled={postText.length === 0 || postText.length > 300} 194 - class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100" 195 - style="background: color-mix(in srgb, {color} 87%, transparent);" 196 - > 197 - post 198 - </button> 199 - </div> 198 + {@render composer()} 200 199 {:else} 201 200 <input 202 201 bind:value={postText}
+11 -12
src/lib/at/client.ts
··· 12 12 parseResourceUri, 13 13 type ActorIdentifier, 14 14 type AtprotoDid, 15 - type CanonicalResourceUri, 16 15 type Cid, 17 16 type Did, 18 17 type Nsid, ··· 199 198 200 199 const mapped = map(res, (data) => data.did as AtprotoDid); 201 200 202 - if (mapped.ok) { 203 - handleCache.set(identifier, mapped.value); 204 - } 201 + if (mapped.ok) handleCache.set(identifier, mapped.value); 205 202 206 203 return mapped; 207 204 } ··· 218 215 cachedSignal.then((d): Result<MiniDoc, string> => ok(d)) 219 216 ]); 220 217 221 - if (result.ok) { 222 - didDocCache.set(handleOrDid, result.value); 223 - } 218 + if (result.ok) didDocCache.set(handleOrDid, result.value); 224 219 225 220 return result; 226 221 } 227 222 228 223 async getBacklinksUri( 229 - uri: CanonicalResourceUri, 224 + uri: ResourceUri, 230 225 source: BacklinksSource 231 226 ): Promise<Result<Backlinks, string>> { 232 227 const parsedResourceUri = expect(parseCanonicalResourceUri(uri)); ··· 245 240 source: BacklinksSource 246 241 ): Promise<Result<Backlinks, string>> { 247 242 const did = await this.resolveHandle(repo); 248 - if (!did.ok) { 249 - return err(`failed to resolve handle: ${did.error}`); 250 - } 243 + if (!did.ok) return err(`cant resolve handle: ${did.error}`); 251 244 252 - return await fetchMicrocosm(constellationUrl, BacklinksQuery, { 245 + const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)); 246 + const query = fetchMicrocosm(constellationUrl, BacklinksQuery, { 253 247 subject: `at://${did.value}/${collection}/${rkey}`, 254 248 source, 255 249 limit: 100 256 250 }); 251 + 252 + const results = await Promise.race([query, timeout]); 253 + if (!results) return err('cant fetch backlinks: timeout'); 254 + 255 + return results; 257 256 } 258 257 259 258 streamNotifications(subjects: Did[], ...sources: BacklinksSource[]): NotificationsStream {
+90 -60
src/lib/at/fetch.ts
··· 1 - import type { ActorIdentifier, CanonicalResourceUri, Cid, ResourceUri } from '@atcute/lexicons'; 1 + import { 2 + parseCanonicalResourceUri, 3 + type CanonicalResourceUri, 4 + type Cid, 5 + type ResourceUri 6 + } from '@atcute/lexicons'; 2 7 import { recordCache, type AtpClient } from './client'; 3 - import { err, ok, type Result } from '$lib/result'; 8 + import { err, expect, ok, type Result } from '$lib/result'; 4 9 import type { Backlinks } from './constellation'; 5 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 6 12 7 13 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 8 14 export type PostWithBacklinks = PostWithUri & { 9 - replies: Result<Backlinks, string>; 15 + replies: Backlinks; 10 16 }; 11 17 export type PostsWithReplyBacklinks = PostWithBacklinks[]; 12 18 19 + const replySource = 'app.bsky.feed.post:reply.parent.uri'; 20 + 13 21 export const fetchPostsWithBacklinks = async ( 14 22 client: AtpClient, 15 - repo: ActorIdentifier, 23 + repo: AtprotoDid, 16 24 cursor?: string, 17 25 limit?: number 18 26 ): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => { ··· 21 29 cursor = recordsList.value.cursor; 22 30 const records = recordsList.value.records; 23 31 24 - const allBacklinks = await Promise.all( 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 - }) 38 - ); 39 - 40 - return ok({ posts: allBacklinks, cursor }); 32 + try { 33 + const allBacklinks = await Promise.all( 34 + records.map(async (r): Promise<PostWithBacklinks> => { 35 + recordCache.set(r.uri, r); 36 + const replies = await client.getBacklinksUri(r.uri, replySource); 37 + if (!replies.ok) throw `cant fetch replies: ${replies.error}`; 38 + return { 39 + uri: r.uri, 40 + cid: r.cid, 41 + record: r.value as AppBskyFeedPost.Main, 42 + replies: replies.value 43 + }; 44 + }) 45 + ); 46 + return ok({ posts: allBacklinks, cursor }); 47 + } catch (error) { 48 + return err(`cant fetch posts backlinks: ${error}`); 49 + } 41 50 }; 42 51 43 52 export const hydratePosts = async ( 44 53 client: AtpClient, 54 + repo: AtprotoDid, 45 55 data: PostsWithReplyBacklinks 46 - ): Promise<Map<ResourceUri, PostWithUri>> => { 47 - const allPosts = await Promise.all( 48 - data.map(async (post) => { 49 - const result: Result<PostWithUri, string>[] = [ok(post)]; 50 - if (post.replies.ok) { 56 + ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 57 + let posts: Map<ResourceUri, PostWithUri> = new Map(); 58 + try { 59 + const allPosts = await Promise.all( 60 + data.map(async (post) => { 61 + const result: PostWithUri[] = [post]; 51 62 const replies = await Promise.all( 52 - post.replies.value.records.map((r) => 53 - client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey) 54 - ) 63 + post.replies.records.map(async (r) => { 64 + const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey); 65 + if (!reply.ok) throw `cant fetch reply: ${reply.error}`; 66 + return reply.value; 67 + }) 55 68 ); 56 69 result.push(...replies); 70 + return result; 71 + }) 72 + ); 73 + posts = new Map(allPosts.flat().map((post) => [post.uri, post])); 74 + } catch (error) { 75 + return err(`cant hydrate immediate replies: ${error}`); 76 + } 77 + 78 + const fetchUpwardsChain = async (post: PostWithUri) => { 79 + let parent = post.record.reply?.parent; 80 + while (parent) { 81 + // if we already have this parent, then we already fetched this chain / are fetching it 82 + if (posts.has(parent.uri as CanonicalResourceUri)) return; 83 + const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri); 84 + if (p.ok) { 85 + posts.set(p.value.uri, p.value); 86 + parent = p.value.record.reply?.parent; 87 + continue; 57 88 } 58 - return result; 59 - }) 60 - ); 61 - const posts = new Map( 62 - allPosts 63 - .flat() 64 - .flatMap((res) => (res.ok ? [res.value] : [])) 65 - .map((post) => [post.uri, post]) 66 - ); 89 + // TODO: handle deleted parent posts 90 + parent = undefined; 91 + } 92 + }; 93 + await Promise.all(posts.values().map(fetchUpwardsChain)); 94 + 95 + try { 96 + const fetchDownwardsChain = async (post: PostWithUri) => { 97 + const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); 98 + if (repo === postRepo) return; 99 + 100 + // get chains that are the same author until we exhaust them 101 + const backlinks = await client.getBacklinksUri(post.uri, replySource); 102 + if (!backlinks.ok) return; 67 103 68 - // hydrate posts 69 - const missingPosts = await Promise.all( 70 - Array.from(posts).map(async ([, post]) => { 71 - let result: PostWithUri[] = [post]; 72 - let parent = post.record.reply?.parent; 73 - while (parent) { 74 - if (posts.has(parent.uri as CanonicalResourceUri)) { 75 - return result; 76 - } 77 - const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri); 78 - if (p.ok) { 79 - result = [p.value, ...result]; 80 - parent = p.value.record.reply?.parent; 81 - continue; 82 - } 83 - parent = undefined; 104 + const promises = []; 105 + for (const reply of backlinks.value.records) { 106 + if (reply.did !== postRepo) continue; 107 + // if we already have this reply, then we already fetched this chain / are fetching it 108 + if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue; 109 + const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey); 110 + if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 111 + posts.set(record.value.uri, record.value); 112 + promises.push(fetchDownwardsChain(record.value)); 84 113 } 85 - return result; 86 - }) 87 - ); 88 - for (const post of missingPosts.flat()) { 89 - posts.set(post.uri, post); 114 + 115 + await Promise.all(promises); 116 + }; 117 + await Promise.all(posts.values().map(fetchDownwardsChain)); 118 + } catch (error) { 119 + return err(`cant fetch post reply chain: ${error}`); 90 120 } 91 121 92 - return posts; 122 + return ok(posts); 93 123 };
+2
src/lib/state.svelte.ts
··· 15 15 // quote: Backlink | null; 16 16 }; 17 17 export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>(); 18 + 19 + export const pulsingPostId = writable<string | null>(null);
+3 -1
src/lib/thread.ts
··· 5 5 6 6 export type ThreadPost = { 7 7 data: PostWithUri; 8 + account: Did; 8 9 did: Did; 9 10 rkey: string; 10 11 parentUri: ResourceUri | null; ··· 23 24 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 24 25 25 26 // group posts by root uri into "thread" chains 26 - for (const [, timeline] of timelines) { 27 + for (const [account, timeline] of timelines) { 27 28 for (const [uri, data] of timeline) { 28 29 const parsedUri = expect(parseCanonicalResourceUri(uri)); 29 30 const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; ··· 31 32 32 33 const post: ThreadPost = { 33 34 data, 35 + account, 34 36 did: parsedUri.repo, 35 37 rkey: parsedUri.rkey, 36 38 parentUri,
+27 -14
src/routes/+page.svelte
··· 8 8 import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons'; 9 9 import { onMount } from 'svelte'; 10 10 import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch'; 11 - import { expect, ok } from '$lib/result'; 11 + import { expect } from '$lib/result'; 12 12 import { AppBskyFeedPost } from '@atcute/bluesky'; 13 13 import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 14 14 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; ··· 98 98 if (cursor && cursor.end) return; 99 99 100 100 const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6); 101 - if (!accPosts.ok) 102 - throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`; 101 + if (!accPosts.ok) throw `cant fetch posts @${account.handle}: ${accPosts.error}`; 103 102 104 103 // if the cursor is undefined, we've reached the end of the timeline 105 104 if (!accPosts.value.cursor) { ··· 108 107 } 109 108 110 109 cursors.set(account.did, { value: accPosts.value.cursor, end: false }); 111 - addPosts(account.did, await hydratePosts(client, accPosts.value.posts)); 110 + const hydrated = await hydratePosts(client, account.did, accPosts.value.posts); 111 + if (!hydrated.ok) throw `cant hydrate posts @${account.handle}: ${hydrated.error}`; 112 + 113 + addPosts(account.did, hydrated.value); 112 114 }; 113 115 114 116 const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline)); ··· 125 127 if (!subjectPost.ok) return; 126 128 127 129 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 128 - const hydrated = await hydratePosts(viewClient, [ 130 + const hydrated = await hydratePosts(viewClient, parsedSubjectUri.repo as AtprotoDid, [ 129 131 { 130 132 record: subjectPost.value.record, 131 133 uri: event.data.link.subject, 132 134 cid: subjectPost.value.cid, 133 - replies: ok({ 135 + replies: { 134 136 cursor: null, 135 137 total: 1, 136 138 records: [ ··· 140 142 rkey: parsedSourceUri.rkey 141 143 } 142 144 ] 143 - }) 145 + } 144 146 } 145 147 ]); 148 + 149 + if (!hydrated.ok) { 150 + errors.push(`cant hydrate posts @${parsedSubjectUri.repo}: ${hydrated.error}`); 151 + return; 152 + } 146 153 147 154 // console.log(hydrated); 148 - addPosts(parsedSubjectUri.repo, hydrated); 155 + addPosts(parsedSubjectUri.repo, hydrated.value); 149 156 } 150 157 }; 151 158 ··· 181 188 loaderState.error(); 182 189 } finally { 183 190 loading = false; 184 - if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 191 + // if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 185 192 } 186 193 }; 187 194 ··· 230 237 </script> 231 238 232 239 <div class="mx-auto max-w-2xl"> 233 - <!-- Sticky header --> 240 + <!-- header --> 234 241 <div class="sticky top-0 z-10 bg-(--nucleus-bg) pb-2"> 235 242 <div class="mb-6 flex items-center justify-between"> 236 243 <div> ··· 250 257 </button> 251 258 </div> 252 259 253 - <!-- Composer and error disclaimer (above thread list, not scrollable) --> 260 + <!-- composer and error disclaimer (above thread list, not scrollable) --> 254 261 <div class="space-y-4"> 255 262 <div class="flex min-h-16 items-stretch gap-2"> 256 263 <AccountSelector ··· 308 315 </div> 309 316 </div> 310 317 311 - <!-- Thread list (page scrolls as a whole) --> 318 + <!-- thread list (page scrolls as a whole) --> 312 319 <div class="mt-4 [scrollbar-color:var(--nucleus-accent)_transparent]" bind:this={scrollContainer}> 313 320 {#if $accounts.length > 0} 314 321 {@render renderThreads()} ··· 407 414 </div> 408 415 {/snippet} 409 416 {#snippet error()} 410 - <div class="flex justify-center py-4"> 417 + <div class="flex flex-col gap-4 py-4"> 411 418 <p class="text-xl opacity-80"> 412 - <span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError} 419 + <span class="text-4xl">x_x</span> <br /> 420 + {loadError} 413 421 </p> 422 + <div> 423 + <button class="flex action-button items-center gap-2" onclick={loadMore}> 424 + <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again 425 + </button> 426 + </div> 414 427 </div> 415 428 {/snippet} 416 429 </InfiniteLoader>
+4 -7
src/routes/+page.ts
··· 1 1 import { replaceState } from '$app/navigation'; 2 2 import { addAccount, loggingIn } from '$lib/accounts'; 3 3 import { AtpClient } from '$lib/at/client'; 4 - import { flow } from '$lib/at/oauth'; 4 + import { flow, sessions } from '$lib/at/oauth'; 5 5 import { err, ok, type Result } from '$lib/result'; 6 6 import type { PageLoad } from './$types'; 7 7 ··· 29 29 } 30 30 31 31 loggingIn.set(null); 32 + await sessions.remove(account.did); 32 33 const agent = await flow.finalize(currentUrl); 33 34 if (!agent.ok || !agent.value) { 34 - if (!agent.ok) { 35 - return err(agent.error); 36 - } 35 + if (!agent.ok) return err(agent.error); 37 36 return err('no session was logged into?!'); 38 37 } 39 38 40 39 const client = new AtpClient(); 41 40 const result = await client.login(account.did, agent.value); 42 - if (!result.ok) { 43 - return err(result.error); 44 - } 41 + if (!result.ok) return err(result.error); 45 42 46 43 addAccount(account); 47 44 return ok(client);