appview-less bluesky client
27
fork

Configure Feed

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

show replying to / quoting inside the collapsed composer as well

dawn 2e8fd12e 6059d530

+108 -57
+11 -5
src/components/BskyPost.svelte
··· 9 9 } from '@atcute/bluesky'; 10 10 import { 11 11 parseCanonicalResourceUri, 12 - type ActorIdentifier, 13 12 type Did, 13 + type Handle, 14 14 type RecordKey, 15 15 type ResourceUri 16 16 } from '@atcute/lexicons'; ··· 28 28 findBacklinksBy, 29 29 deletePostBacklink, 30 30 createPostBacklink, 31 - router 31 + router, 32 + profiles, 33 + handles 32 34 } from '$lib/state.svelte'; 33 35 import type { PostWithUri } from '$lib/at/fetch'; 34 36 import { onMount, type Snippet } from 'svelte'; ··· 76 78 const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 77 79 const color = $derived(generateColorForDid(did)); 78 80 79 - let handle: ActorIdentifier = $state('handle.invalid'); 81 + let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); 80 82 const didDoc = resolveDidDoc(did).then((res) => { 81 - if (res.ok) handle = res.value.handle; 83 + if (res.ok) { 84 + handle = res.value.handle; 85 + handles.set(did, handle); 86 + } 82 87 return res; 83 88 }); 84 89 const post = data 85 90 ? Promise.resolve(ok(data)) 86 91 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 87 - let profile: AppBskyActorProfile.Main | null = $state(null); 92 + let profile: AppBskyActorProfile.Main | null = $state(profiles.get(did) ?? null); 88 93 onMount(async () => { 89 94 const p = await client.getProfile(did); 90 95 if (!p.ok) return; 91 96 profile = p.value; 97 + profiles.set(did, profile); 92 98 // console.log(profile.description); 93 99 }); 94 100
+48 -12
src/components/PostComposer.svelte
··· 10 10 import { parseToRichText } from '$lib/richtext'; 11 11 import { tokenize } from '$lib/richtext/parser'; 12 12 import Icon from '@iconify/svelte'; 13 + import ProfilePicture from './ProfilePicture.svelte'; 13 14 14 15 export type FocusState = 'null' | 'focused'; 15 16 export type State = { ··· 40 41 uri: p.uri 41 42 }); 42 43 43 - // Parse rich text (mentions, links, tags) 44 44 const rt = await parseToRichText(text); 45 45 46 46 const record: AppBskyFeedPost.Main = { ··· 120 120 <button 121 121 class="transition-transform hover:scale-150" 122 122 onclick={() => { 123 - if (_state.focus === 'focused') _state[type] = undefined; 123 + _state[type] = undefined; 124 124 }}><Icon width={24} icon="heroicons:x-mark-16-solid" /></button 125 125 > 126 126 {/snippet} 127 127 </BskyPost> 128 128 {/snippet} 129 129 130 + {#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')} 131 + {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 132 + {@const color = generateColorForDid(parsedUri.repo)} 133 + <div 134 + class="flex shrink-0 items-center gap-1.5 rounded-sm border py-0.5 pr-0.5 pl-1 text-xs font-bold transition-all" 135 + style=" 136 + background: color-mix(in srgb, {color} 10%, transparent); 137 + border-color: {color}; 138 + color: {color}; 139 + " 140 + title={type === 'replying' ? `replying to @${parsedUri.repo}` : `quoting @${parsedUri.repo}`} 141 + > 142 + <span class="truncate text-sm font-normal opacity-90"> 143 + {type === 'replying' ? 'replying to' : 'quoting'} 144 + </span> 145 + <div class="shrink-0"> 146 + <ProfilePicture {client} did={parsedUri.repo} size={5} /> 147 + </div> 148 + </div> 149 + {/snippet} 150 + 130 151 {#snippet highlighter(text: string)} 131 152 {#each tokenize(text) as token, idx (idx)} 132 153 {@const highlighted = ··· 216 237 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 217 238 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 218 239 > 219 - <div class="w-full p-1.5 px-2"> 240 + <div class="w-full p-1 px-2"> 220 241 {#if info.length > 0} 221 242 <div 222 243 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" ··· 229 250 {#if _state.focus === 'focused'} 230 251 {@render composer(_state.replying, _state.quoting)} 231 252 {:else} 232 - <input 233 - bind:value={_state.text} 234 - onfocus={() => (_state.focus = 'focused')} 235 - type="text" 236 - placeholder="what's on your mind?" 237 - class="flex-1" 238 - /> 253 + <!-- svelte-ignore a11y_no_static_element_interactions --> 254 + <div 255 + class="composer relative flex cursor-text items-center gap-0 py-0! transition-all hover:brightness-110" 256 + onmousedown={(e) => { 257 + if (e.defaultPrevented) return; 258 + _state.focus = 'focused'; 259 + }} 260 + > 261 + {#if _state.replying} 262 + {@render attachmentIndicator(_state.replying, 'replying')} 263 + {/if} 264 + <input 265 + bind:value={_state.text} 266 + onfocus={() => (_state.focus = 'focused')} 267 + type="text" 268 + placeholder="what's on your mind?" 269 + class="min-w-0 flex-1 border-none bg-transparent outline-none placeholder:text-(--nucleus-fg)/45 focus:ring-0" 270 + /> 271 + {#if _state.quoting} 272 + {@render attachmentIndicator(_state.quoting, 'quoting')} 273 + {/if} 274 + </div> 239 275 {/if} 240 276 </div> 241 277 {/if} ··· 253 289 } 254 290 255 291 .composer { 256 - @apply p-2; 292 + @apply p-1; 257 293 } 258 294 259 295 textarea { ··· 261 297 } 262 298 263 299 input { 264 - @apply p-1 px-2; 300 + @apply p-1.5; 265 301 } 266 302 267 303 .composer {
+15 -5
src/components/ProfileInfo.svelte
··· 1 1 <script lang="ts"> 2 2 import { AtpClient, resolveDidDoc } from '$lib/at/client'; 3 - import type { Did } from '@atcute/lexicons/syntax'; 3 + import type { Did, Handle } from '@atcute/lexicons/syntax'; 4 4 import type { AppBskyActorProfile } from '@atcute/bluesky'; 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import RichText from './RichText.svelte'; 7 7 import { onMount } from 'svelte'; 8 + import { handles, profiles } from '$lib/state.svelte'; 8 9 9 10 interface Props { 10 11 client: AtpClient; 11 12 did: Did; 12 - handle?: string; 13 + handle?: Handle; 13 14 profile?: AppBskyActorProfile.Main | null; 14 15 } 15 16 16 - let { client, did, handle, profile = $bindable(null) }: Props = $props(); 17 + let { 18 + client, 19 + did, 20 + handle = handles.get(did), 21 + profile = $bindable(profiles.get(did) ?? null) 22 + }: Props = $props(); 17 23 18 24 onMount(async () => { 19 25 await Promise.all([ 20 26 (async () => { 21 27 if (profile) return; 22 28 const res = await client.getProfile(did); 23 - if (res.ok) profile = res.value; 29 + if (!res.ok) return; 30 + profile = res.value; 31 + profiles.set(did, res.value); 24 32 })(), 25 33 (async () => { 26 34 if (handle) return; 27 35 const res = await resolveDidDoc(did); 28 - if (res.ok) handle = res.value.handle; 36 + if (!res.ok) return; 37 + handle = res.value.handle; 38 + handles.set(did, res.value.handle); 29 39 })() 30 40 ]); 31 41 });
+14 -25
src/components/ProfilePicture.svelte
··· 1 - <script lang="ts" module> 2 - // we have this to prevent avatars from "flickering" 3 - const avatarCache = new SvelteMap<string, string | null>(); 4 - </script> 5 - 6 1 <script lang="ts"> 7 2 import { generateColorForDid } from '$lib/accounts'; 8 3 import type { AtpClient } from '$lib/at/client'; ··· 10 5 import PfpPlaceholder from './PfpPlaceholder.svelte'; 11 6 import { img } from '$lib/cdn'; 12 7 import type { Did } from '@atcute/lexicons'; 13 - import { SvelteMap } from 'svelte/reactivity'; 8 + import { profiles } from '$lib/state.svelte'; 14 9 15 10 interface Props { 16 11 client: AtpClient; ··· 21 16 let { client, did, size }: Props = $props(); 22 17 23 18 // svelte-ignore state_referenced_locally 24 - let avatarUrl = $state<string | null>(avatarCache.get(did) ?? null); 19 + let avatarBlob = $state(profiles.get(did)?.avatar); 20 + const avatarUrl: string | null = $derived( 21 + isBlob(avatarBlob) ? img('avatar_thumbnail', did, avatarBlob.ref.$link) : null 22 + ); 25 23 26 24 const loadProfile = async (targetDid: Did) => { 27 - avatarUrl = avatarCache.get(targetDid) ?? null; 25 + const cachedBlob = profiles.get(did)?.avatar; 26 + if (cachedBlob) { 27 + avatarBlob = cachedBlob; 28 + return; 29 + } 28 30 29 31 try { 30 32 const profile = await client.getProfile(targetDid); 31 - 32 - if (did !== targetDid) return; 33 - 34 33 if (profile.ok) { 35 - const record = profile.value; 36 - if (isBlob(record.avatar)) { 37 - const url = img('avatar_thumbnail', targetDid, record.avatar.ref.$link); 38 - avatarUrl = url; 39 - avatarCache.set(targetDid, url); 40 - } else { 41 - avatarUrl = null; 42 - avatarCache.set(targetDid, null); 43 - } 44 - } else { 45 - avatarUrl = null; 46 - } 34 + avatarBlob = profile.value.avatar; 35 + profiles.set(did, profile.value); 36 + } else avatarBlob = undefined; 47 37 } catch (e) { 48 - if (did !== targetDid) return; 49 38 console.error(`${targetDid}: failed to load pfp`, e); 50 - avatarUrl = null; 39 + avatarBlob = undefined; 51 40 } 52 41 }; 53 42
+14 -5
src/components/ProfileView.svelte
··· 4 4 isHandle, 5 5 type ActorIdentifier, 6 6 type AtprotoDid, 7 + type Did, 7 8 type Handle 8 9 } from '@atcute/lexicons/syntax'; 9 10 import TimelineView from './TimelineView.svelte'; ··· 15 16 import { isBlob } from '@atcute/lexicons/interfaces'; 16 17 import type { AppBskyActorProfile } from '@atcute/bluesky'; 17 18 import { onMount } from 'svelte'; 19 + import { handles, profiles } from '$lib/state.svelte'; 18 20 19 21 interface Props { 20 22 client: AtpClient; ··· 25 27 26 28 let { client, actor, onBack, postComposerState = $bindable() }: Props = $props(); 27 29 28 - let profile = $state<AppBskyActorProfile.Main | null>(null); 30 + let profile = $state<AppBskyActorProfile.Main | null>(profiles.get(actor as Did) ?? null); 29 31 const displayName = $derived(profile?.displayName ?? ''); 30 32 let loading = $state(true); 31 33 let error = $state<string | null>(null); 32 34 let did = $state<AtprotoDid | null>(null); 33 - let handle = $state<Handle | null>(null); 35 + let handle = $state<Handle | null>(handles.get(actor as Did) ?? null); 34 36 35 37 const loadProfile = async (identifier: ActorIdentifier) => { 36 38 loading = true; ··· 46 48 return; 47 49 } 48 50 51 + if (!handle) handle = handles.get(did) ?? null; 52 + 49 53 if (!handle) { 50 54 const resHandle = await resolveDidDoc(did); 51 - if (resHandle.ok) handle = resHandle.value.handle; 55 + if (resHandle.ok) { 56 + handle = resHandle.value.handle; 57 + handles.set(did, resHandle.value.handle); 58 + } 52 59 } 53 60 54 61 const res = await client.getProfile(did); 55 - if (res.ok) profile = res.value; 56 - else error = res.error; 62 + if (res.ok) { 63 + profile = res.value; 64 + profiles.set(did, res.value); 65 + } else error = res.error; 57 66 58 67 loading = false; 59 68 };
+1 -3
src/lib/cache.ts
··· 43 43 44 44 request.onupgradeneeded = (event) => { 45 45 const db = (event.target as IDBOpenDBRequest).result; 46 - if (!db.objectStoreNames.contains(STORE_NAME)) { 47 - db.createObjectStore(STORE_NAME); 48 - } 46 + if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME); 49 47 }; 50 48 }); 51 49 }
+5 -2
src/lib/state.svelte.ts
··· 6 6 type NotificationsStreamEvent 7 7 } from './at/client'; 8 8 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 9 - import type { Did, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 9 + import type { Did, Handle, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 10 10 import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch'; 11 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 - import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 12 + import { AppBskyActorProfile, AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 13 13 import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 14 14 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 15 15 import { expect, ok } from './result'; ··· 28 28 29 29 export const notificationStream = writable<NotificationsStream | null>(null); 30 30 export const jetstream = writable<JetstreamSubscription | null>(null); 31 + 32 + export const profiles = new SvelteMap<Did, AppBskyActorProfile.Main>(); 33 + export const handles = new SvelteMap<Did, Handle>(); 31 34 32 35 export type BacklinksMap = SvelteMap<BacklinksSource, SvelteSet<Backlink>>; 33 36 export const allBacklinks = new SvelteMap<ResourceUri, BacklinksMap>();