replies timeline only, appview-less bluesky client

dont forget to hydrate posts from the jetstream, save composer text to the global state

ptr.pet 375937b9 49cfbb91

verified
+1 -1
src/components/BskyPost.svelte
··· 185 185 {#snippet profileInline()} 186 186 <button 187 187 class=" 188 - flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''} 188 + flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-125' : ''} 189 189 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 190 190 " 191 191 style="color: {color};"
+1 -1
src/components/FollowingView.svelte
··· 80 80 isLongCalculation = false; 81 81 }; 82 82 83 - // todo: there is a bug where 83 + // todo: there is a bug where the view doesn't update and just gets stuck being loaded 84 84 $effect(() => { 85 85 // Dependencies that trigger a re-sort 86 86 // eslint-disable-next-line @typescript-eslint/no-unused-vars
+31 -25
src/components/PostComposer.svelte
··· 10 10 import { parseToRichText } from '$lib/richtext'; 11 11 import { tokenize } from '$lib/richtext/parser'; 12 12 13 - export type State = 13 + export type FocusState = 14 14 | { type: 'null' } 15 15 | { type: 'focused'; quoting?: PostWithUri; replying?: PostWithUri }; 16 + export type State = { 17 + focus: FocusState; 18 + text: string; 19 + }; 16 20 17 21 interface Props { 18 22 client: AtpClient; ··· 20 24 _state: State; 21 25 } 22 26 23 - let { client, onPostSent, _state = $bindable({ type: 'null' }) }: Props = $props(); 27 + let { 28 + client, 29 + onPostSent, 30 + _state = $bindable({ focus: { type: 'null' }, text: '' }) 31 + }: Props = $props(); 24 32 25 - const isFocused = $derived(_state.type === 'focused'); 33 + const isFocused = $derived(_state.focus.type === 'focused'); 26 34 27 35 const color = $derived( 28 36 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' ··· 38 46 // Parse rich text (mentions, links, tags) 39 47 const rt = await parseToRichText(text); 40 48 49 + const focus = _state.focus; 41 50 const record: AppBskyFeedPost.Main = { 42 51 $type: 'app.bsky.feed.post', 43 52 text: rt.text, 44 53 facets: rt.facets, 45 54 reply: 46 - _state.type === 'focused' && _state.replying 55 + focus.type === 'focused' && focus.replying 47 56 ? { 48 - root: _state.replying.record.reply?.root ?? strongRef(_state.replying), 49 - parent: strongRef(_state.replying) 57 + root: focus.replying.record.reply?.root ?? strongRef(focus.replying), 58 + parent: strongRef(focus.replying) 50 59 } 51 60 : undefined, 52 61 embed: 53 - _state.type === 'focused' && _state.quoting 62 + focus.type === 'focused' && focus.quoting 54 63 ? { 55 64 $type: 'app.bsky.embed.record', 56 - record: strongRef(_state.quoting) 65 + record: strongRef(focus.quoting) 57 66 } 58 67 : undefined, 59 68 createdAt: new Date().toISOString() ··· 79 88 }); 80 89 }; 81 90 82 - let postText = $state(''); 83 91 let info = $state(''); 84 92 let textareaEl: HTMLTextAreaElement | undefined = $state(); 85 93 86 - const unfocus = () => { 87 - _state.type = 'null'; 88 - }; 94 + const unfocus = () => (_state.focus.type = 'null'); 89 95 90 96 const doPost = () => { 91 - if (postText.length === 0 || postText.length > 300) return; 97 + if (_state.text.length === 0 || _state.text.length > 300) return; 92 98 93 - post(postText).then((res) => { 99 + post(_state.text).then((res) => { 94 100 if (res.ok) { 95 101 onPostSent(res.value); 96 - postText = ''; 102 + _state.text = ''; 97 103 info = 'posted!'; 98 104 unfocus(); 99 105 setTimeout(() => (info = ''), 800); ··· 141 147 <div class="grow"></div> 142 148 <span 143 149 class="text-sm font-medium" 144 - style="color: color-mix(in srgb, {postText.length > 300 150 + style="color: color-mix(in srgb, {_state.text.length > 300 145 151 ? '#ef4444' 146 152 : 'var(--nucleus-fg)'} 53%, transparent);" 147 153 > 148 - {postText.length} / 300 154 + {_state.text.length} / 300 149 155 </span> 150 156 <button 151 157 onmousedown={(e) => { 152 158 e.preventDefault(); 153 159 doPost(); 154 160 }} 155 - disabled={postText.length === 0 || postText.length > 300} 161 + disabled={_state.text.length === 0 || _state.text.length > 300} 156 162 class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100" 157 163 style="background: color-mix(in srgb, {color} 87%, transparent);" 158 164 > ··· 169 175 class="pointer-events-none col-start-1 row-start-1 min-h-[5lh] w-full bg-transparent text-wrap break-all whitespace-pre-wrap text-(--nucleus-fg)" 170 176 aria-hidden="true" 171 177 > 172 - {@render highlighter(postText)} 178 + {@render highlighter(_state.text)} 173 179 </div> 174 180 175 181 <textarea 176 182 bind:this={textareaEl} 177 - bind:value={postText} 178 - onfocus={() => (_state.type = 'focused')} 183 + bind:value={_state.text} 184 + onfocus={() => (_state.focus.type = 'focused')} 179 185 onblur={unfocus} 180 186 onkeydown={(event) => { 181 187 if (event.key === 'Escape') unfocus(); ··· 222 228 </div> 223 229 {:else} 224 230 <div class="flex flex-col gap-2"> 225 - {#if _state.type === 'focused'} 226 - {@render composer(_state.replying, _state.quoting)} 231 + {#if _state.focus.type === 'focused'} 232 + {@render composer(_state.focus.replying, _state.focus.quoting)} 227 233 {:else} 228 234 <input 229 - bind:value={postText} 230 - onfocus={() => (_state = { type: 'focused' })} 235 + bind:value={_state.text} 236 + onfocus={() => (_state.focus.type = 'focused')} 231 237 type="text" 232 238 placeholder="what's on your mind?" 233 239 class="flex-1"
+2 -2
src/components/ProfileView.svelte
··· 20 20 client: AtpClient; 21 21 actor: string; 22 22 onBack: () => void; 23 - postComposerState?: PostComposerState; 23 + postComposerState: PostComposerState; 24 24 } 25 25 26 - let { client, actor, onBack, postComposerState = $bindable({ type: 'null' }) }: Props = $props(); 26 + let { client, actor, onBack, postComposerState = $bindable() }: Props = $props(); 27 27 28 28 let profile = $state<AppBskyActorProfile.Main | null>(null); 29 29 const displayName = $derived(profile?.displayName ?? '');
+2 -2
src/components/TimelineView.svelte
··· 120 120 <div class="mb-1.5"> 121 121 <BskyPost 122 122 client={client!} 123 - onQuote={(post) => (postComposerState = { type: 'focused', quoting: post })} 124 - onReply={(post) => (postComposerState = { type: 'focused', replying: post })} 123 + onQuote={(post) => (postComposerState.focus = { type: 'focused', quoting: post })} 124 + onReply={(post) => (postComposerState.focus = { type: 'focused', replying: post })} 125 125 {...post} 126 126 /> 127 127 </div>
+2 -2
src/lib/at/fetch.ts
··· 8 8 import { err, expect, ok, type Ok, type Result } from '$lib/result'; 9 9 import type { Backlinks } from './constellation'; 10 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 - import type { AtprotoDid, Did, RecordKey } from '@atcute/lexicons/syntax'; 11 + import type { Did, RecordKey } from '@atcute/lexicons/syntax'; 12 12 import { replySource, toCanonicalUri } from '$lib'; 13 13 14 14 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; ··· 60 60 61 61 export const hydratePosts = async ( 62 62 client: AtpClient, 63 - repo: AtprotoDid, 63 + repo: Did, 64 64 data: PostWithBacklinks[], 65 65 cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined 66 66 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
+16 -10
src/lib/state.svelte.ts
··· 359 359 await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 360 360 }; 361 361 362 - export const handleJetstreamEvent = (event: JetstreamEvent) => { 362 + export const handleJetstreamEvent = async (event: JetstreamEvent) => { 363 363 if (event.kind !== 'commit') return; 364 364 365 365 const { did, commit } = event; 366 366 const uri: ResourceUri = toCanonicalUri({ did, ...commit }); 367 367 if (commit.collection === 'app.bsky.feed.post') { 368 368 if (commit.operation === 'create') { 369 - const { cid, record } = commit; 370 - const post: PostWithUri = { 371 - uri, 372 - cid, 373 - // assume record is valid, we trust the jetstream 374 - record: record as AppBskyFeedPost.Main 375 - }; 376 - addPosts([post]); 377 - addTimeline(did, [uri]); 369 + const posts = [ 370 + { 371 + record: commit.record as AppBskyFeedPost.Main, 372 + uri, 373 + cid: commit.cid 374 + } 375 + ]; 376 + const client = await getClient(did); 377 + const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 378 + if (!hydrated.ok) { 379 + console.error(`cant hydrate posts ${did}: ${hydrated.error}`); 380 + return; 381 + } 382 + addPosts(hydrated.value.values()); 383 + addTimeline(did, hydrated.value.keys()); 378 384 } else if (commit.operation === 'delete') { 379 385 allPosts.get(did)?.delete(uri); 380 386 }
+4 -3
src/routes/[...catchall]/+page.svelte
··· 83 83 else animClass = 'animate-fade-in-scale'; 84 84 }); 85 85 86 - let postComposerState = $state<PostComposerState>({ type: 'null' }); 86 + let postComposerState = $state<PostComposerState>({ focus: { type: 'null' }, text: '' }); 87 87 let showScrollToTop = $state(false); 88 88 const handleScroll = () => { 89 - if (router.current.path === '/') showScrollToTop = window.scrollY > 300; 89 + if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor') 90 + showScrollToTop = window.scrollY > 300; 90 91 }; 91 92 const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }); 92 93 ··· 305 306 </div> 306 307 {/if} 307 308 308 - {#if postComposerState.type === 'null' && showScrollToTop} 309 + {#if postComposerState.focus.type === 'null' && showScrollToTop} 309 310 {@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)} 310 311 {/if} 311 312 </div>