appview-less bluesky client
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

dawn 375937b9 49cfbb91

+59 -46
+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>