appview-less bluesky client
27
fork

Configure Feed

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

feat: profile pictures, post composer now takes upon account color better, improve fetching efficiency

dusk d33e12ba 1da41596

+259 -111
+10 -18
src/components/AccountSelector.svelte
··· 3 3 import { AtpClient } from '$lib/at/client'; 4 4 import type { Did, Handle } from '@atcute/lexicons'; 5 5 import { theme } from '$lib/theme.svelte'; 6 + import ProfilePicture from './ProfilePicture.svelte'; 7 + import PfpPlaceholder from './PfpPlaceholder.svelte'; 6 8 7 9 interface Props { 10 + client: AtpClient; 8 11 accounts: Array<Account>; 9 12 selectedDid?: Did | null; 10 13 onAccountSelected: (did: Did) => void; ··· 13 16 } 14 17 15 18 let { 19 + client, 16 20 accounts = [], 17 21 selectedDid = $bindable(null), 18 22 onAccountSelected, 19 23 onLoginSucceed, 20 24 onLogout 21 25 }: Props = $props(); 22 - 23 - let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg); 24 26 25 27 let isDropdownOpen = $state(false); 26 28 let isLoginModalOpen = $state(false); ··· 99 101 const closeDropdown = () => { 100 102 isDropdownOpen = false; 101 103 }; 102 - 103 - let selectedAccount = $derived(accounts.find((acc) => acc.did === selectedDid)); 104 104 </script> 105 105 106 106 <svelte:window onclick={closeDropdown} /> ··· 108 108 <div class="relative"> 109 109 <button 110 110 onclick={toggleDropdown} 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" 112 - style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);" 111 + class="flex h-16 w-16 items-center justify-center rounded-sm shadow-lg transition-all hover:scale-105 hover:shadow-xl" 113 112 > 114 - <span class="font-bold"> 115 - {selectedAccount ? `@${selectedAccount.handle}` : 'select account'} 116 - </span> 117 - <svg 118 - class="h-4 w-4 transition-transform {isDropdownOpen ? 'rotate-180' : ''}" 119 - style="color: {theme.accent};" 120 - fill="none" 121 - stroke="currentColor" 122 - viewBox="0 0 24 24" 123 - > 124 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" /> 125 - </svg> 113 + {#if selectedDid} 114 + <ProfilePicture {client} did={selectedDid} size={15} /> 115 + {:else} 116 + <PfpPlaceholder color={theme.accent} size={15} /> 117 + {/if} 126 118 </button> 127 119 128 120 {#if isDropdownOpen}
+39 -19
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 2 import type { AtpClient } from '$lib/at/client'; 3 3 import { AppBskyFeedPost } from '@atcute/bluesky'; 4 - import type { ActorIdentifier, RecordKey } from '@atcute/lexicons'; 4 + import type { ActorIdentifier, Did, RecordKey } from '@atcute/lexicons'; 5 5 import { theme } from '$lib/theme.svelte'; 6 6 import { map, ok } from '$lib/result'; 7 7 import { generateColorForDid } from '$lib/accounts'; 8 + import ProfilePicture from './ProfilePicture.svelte'; 8 9 9 10 interface Props { 10 11 client: AtpClient; 11 - identifier: ActorIdentifier; 12 + did: Did; 12 13 rkey: RecordKey; 13 14 // replyBacklinks?: Backlinks; 14 15 record?: AppBskyFeedPost.Main; 15 16 mini?: boolean; 16 17 } 17 18 18 - const { client, identifier, rkey, record, mini /* replyBacklinks */ }: Props = $props(); 19 + const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props(); 19 20 20 - const color = generateColorForDid(identifier) ?? theme.accent2; 21 + const color = generateColorForDid(did) ?? theme.accent2; 21 22 22 - let handle = $state(identifier); 23 + let handle: ActorIdentifier = $state(did); 23 24 client 24 - .resolveDidDoc(identifier) 25 + .resolveDidDoc(did) 25 26 .then((res) => map(res, (data) => data.handle)) 26 27 .then((res) => { 27 28 if (res.ok) handle = res.value; 28 29 }); 29 30 const post = record 30 31 ? Promise.resolve(ok(record)) 31 - : client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey); 32 + : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 32 33 // const replies = replyBacklinks 33 34 // ? Promise.resolve(ok(replyBacklinks)) 34 35 // : client.getBacklinks( ··· 65 66 const months = Math.floor(days / 30); 66 67 const years = Math.floor(months / 12); 67 68 68 - if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`; 69 - if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`; 70 - if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; 71 - if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; 72 - if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; 73 - if (seconds > 0) return `${seconds} second${seconds > 1 ? 's' : ''} ago`; 69 + if (years > 0) return `${years}y`; 70 + if (months > 0) return `${months}m`; 71 + if (days > 0) return `${days}d`; 72 + if (hours > 0) return `${hours}h`; 73 + if (minutes > 0) return `${minutes}m`; 74 + if (seconds > 0) return `${seconds}s`; 74 75 return 'just now'; 75 76 }; 76 77 </script> ··· 106 107 {:else} 107 108 {#await post} 108 109 <div 109 - class="rounded-sm border-2 p-3 text-center backdrop-blur-sm" 110 + class="rounded-sm border-2 p-2 text-center backdrop-blur-sm" 110 111 style="background: {color}18; border-color: {color}66;" 111 112 > 112 113 <div ··· 119 120 {#if post.ok} 120 121 {@const record = post.value} 121 122 <div 122 - class="rounded-sm border-2 p-3 shadow-lg backdrop-blur-sm transition-all" 123 + class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all" 123 124 style="background: {color}18; border-color: {color}66;" 124 125 > 125 - <div class="mb-3 flex items-center gap-1.5"> 126 - <span class="font-bold" style="color: {color};"> 127 - @{handle} 126 + <div 127 + class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1" 128 + style="background: {color}33;" 129 + > 130 + <ProfilePicture {client} {did} size={8} /> 131 + 132 + <span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};"> 133 + {#await client.getProfile(did)} 134 + {handle} 135 + {:then profile} 136 + {#if profile.ok} 137 + {@const profileValue = profile.value} 138 + <span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 139 + >{profileValue.displayName}</span 140 + ><span class="shrink-0 text-nowrap">(@{handle})</span> 141 + {:else} 142 + {handle} 143 + {/if} 144 + {/await} 128 145 </span> 146 + 129 147 <!-- <span>·</span> 130 148 {#await replies} 131 149 <span style="color: {theme.fg}aa;">… replies</span> ··· 149 167 {/if} 150 168 {/await} --> 151 169 <span>·</span> 152 - <span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span> 170 + <span class="text-nowrap" style="color: {theme.fg}aa;" 171 + >{getRelativeTime(new Date(record.createdAt))}</span 172 + > 153 173 </div> 154 174 <p class="leading-relaxed text-wrap" style="color: {theme.fg};"> 155 175 {record.text}
+21
src/components/PfpPlaceholder.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + color: string; 4 + size: number; 5 + } 6 + 7 + let { color, size }: Props = $props(); 8 + </script> 9 + 10 + <svg 11 + class="rounded-sm" 12 + style="background: {color}44; color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 13 + xmlns="http://www.w3.org/2000/svg" 14 + width="24px" 15 + height="24px" 16 + viewBox="0 0 16 16" 17 + ><path 18 + fill="currentColor" 19 + d="M8 8a3 3 0 1 0 0-6a3 3 0 0 0 0 6m4.735 6c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139z" 20 + /></svg 21 + >
+15 -21
src/components/PostComposer.svelte
··· 4 4 import type { AppBskyFeedPost } from '@atcute/bluesky'; 5 5 import type { ResourceUri } from '@atcute/lexicons'; 6 6 import { theme } from '$lib/theme.svelte'; 7 + import { generateColorForDid } from '$lib/accounts'; 7 8 8 9 interface Props { 9 10 client: AtpClient; ··· 11 12 } 12 13 13 14 const { client, onPostSent }: Props = $props(); 15 + 16 + let color = $derived( 17 + client.didDoc?.did ? (generateColorForDid(client.didDoc?.did) ?? theme.accent) : theme.accent 18 + ); 14 19 15 20 const post = async ( 16 21 text: string ··· 64 69 }; 65 70 66 71 $effect(() => { 67 - if (isFocused && textareaEl) { 68 - textareaEl.focus(); 69 - } 72 + if (isFocused && textareaEl) textareaEl.focus(); 70 73 }); 71 74 </script> 72 75 ··· 77 80 {/if} 78 81 79 82 <div 80 - class="flex max-w-full rounded-sm border-2 shadow-lg backdrop-blur-lg transition-all duration-300" 83 + class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300" 81 84 class:min-h-16={!isFocused} 82 85 class:items-center={!isFocused} 83 86 class:shadow-2xl={isFocused} ··· 87 90 class:right-0={isFocused} 88 91 class:z-50={isFocused} 89 92 style="background: {isFocused 90 - ? `${theme.bg}f0` 91 - : `${theme.accent}18`}; border-color: {theme.accent}{isFocused ? '' : '66'};" 93 + ? `color-mix(in srgb, ${theme.bg} 80%, ${color} 20%)` 94 + : `${color}18`}; border-color: {color}{isFocused ? '' : '66'};" 92 95 > 93 96 <div class="w-full p-2" class:py-3={isFocused}> 94 97 {#if info.length > 0} 95 98 <div 96 99 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 97 - style="background: {theme.accent}22; color: {theme.accent};" 100 + style="background: {color}22; color: {color};" 98 101 > 99 102 {info} 100 103 </div> ··· 106 109 bind:value={postText} 107 110 onfocus={() => (isFocused = true)} 108 111 onblur={() => (isFocused = false)} 109 - oninput={(e) => { 110 - const target = e.currentTarget; 111 - if (target.value.length > 300) { 112 - target.value = target.value.slice(0, 300); 113 - postText = target.value; 114 - } 115 - }} 116 112 onkeydown={(event) => { 117 - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { 118 - doPost(); 119 - } 113 + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 120 114 }} 121 115 placeholder="what's on your mind?" 122 116 rows="4" 123 - class="placeholder-opacity-50 w-full resize-none rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none" 124 - style="background: {theme.bg}66; border-color: {theme.accent}44; color: {theme.fg};" 117 + class="placeholder-opacity-50 [field-sizing:content] w-full resize-none rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none" 118 + style="background: {theme.bg}66; border-color: {color}44; color: {theme.fg};" 125 119 ></textarea> 126 120 <div class="flex items-center gap-2"> 127 121 <div class="grow"></div> ··· 135 129 onclick={doPost} 136 130 disabled={postText.length === 0 || postText.length > 300} 137 131 class="rounded-sm border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100" 138 - style="background: linear-gradient(120deg, {theme.accent}c0, {theme.accent2}c0); color: {theme.fg}f0;" 132 + style="background: {color}dd; color: {theme.fg}f0;" 139 133 > 140 134 post 141 135 </button> ··· 150 144 type="text" 151 145 placeholder="what's on your mind?" 152 146 class="placeholder-opacity-50 flex-1 rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none" 153 - style="background: {theme.bg}66; border-color: {theme.accent}44; color: {theme.fg};" 147 + style="background: {theme.bg}66; border-color: {color}44; color: {theme.fg};" 154 148 /> 155 149 {/if} 156 150 </div>
+42
src/components/ProfilePicture.svelte
··· 1 + <script lang="ts"> 2 + import { generateColorForDid } from '$lib/accounts'; 3 + import type { AtpClient } from '$lib/at/client'; 4 + import { isBlob } from '@atcute/lexicons/interfaces'; 5 + import PfpPlaceholder from './PfpPlaceholder.svelte'; 6 + import { img } from '$lib/cdn'; 7 + import type { Did } from '@atcute/lexicons'; 8 + 9 + interface Props { 10 + client: AtpClient; 11 + did: Did; 12 + size: number; 13 + } 14 + 15 + let { client, did, size }: Props = $props(); 16 + 17 + let color = $derived(generateColorForDid(did)); 18 + </script> 19 + 20 + {#snippet missingPfp()} 21 + <PfpPlaceholder {color} {size} /> 22 + {/snippet} 23 + 24 + {#await client.getProfile(did)} 25 + {@render missingPfp()} 26 + {:then profile} 27 + {#if profile.ok} 28 + {@const record = profile.value} 29 + {#if isBlob(record.avatar)} 30 + <img 31 + class="rounded-sm" 32 + style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 33 + alt="avatar for {did}" 34 + src={img('avatar_thumbnail', did, record.avatar.ref.$link)} 35 + /> 36 + {:else} 37 + {@render missingPfp()} 38 + {/if} 39 + {:else} 40 + {@render missingPfp()} 41 + {/if} 42 + {/await}
+65 -15
src/lib/accounts.ts
··· 1 1 import type { Did, Handle } from '@atcute/lexicons'; 2 2 import { writable } from 'svelte/store'; 3 - import { createXXHash3, type IHasher } from 'hash-wasm'; 4 3 5 4 export type Account = { 6 5 did: Did; ··· 24 23 accounts.update((accounts) => [...accounts, account]); 25 24 }; 26 25 27 - // fucked up and evil (i hate promises :3) 28 - const _initHasher = () => { 29 - createXXHash3(90001, 8008135).then((s) => (hasher = s)); 30 - return null; 31 - }; 32 - let hasher: IHasher | null = _initHasher(); 26 + export const generateColorForDid = (did: string) => hashColor(did); 27 + 28 + function hashColor(input: string | number): string { 29 + let hash = typeof input === 'string' ? stringToHash(input) : input; 33 30 34 - export const generateColorForDid = (did: string): string | null => { 35 - const h = hasher!; 36 - h.init(); 37 - h.update(did); 38 - const hex = h.digest(); 39 - const color = hex.slice(-6); 40 - return `#${color}`; 41 - }; 31 + hash ^= hash >>> 16; 32 + hash = Math.imul(hash, 0x85ebca6b); 33 + hash ^= hash >>> 13; 34 + hash = Math.imul(hash, 0xb00b1355); 35 + hash ^= hash >>> 16; 36 + hash = hash >>> 0; 37 + 38 + const hue = hash % 360; 39 + const saturation = 0.7 + ((hash >>> 8) % 30) * 0.01; 40 + const value = 0.6 + ((hash >>> 16) % 40) * 0.01; 41 + 42 + const rgb = hsvToRgb(hue, saturation, value); 43 + const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join(''); 44 + 45 + return `#${hex}`; 46 + } 47 + 48 + function stringToHash(str: string): number { 49 + let hash = 0; 50 + for (let i = 0; i < str.length; i++) { 51 + hash = (Math.imul(hash << 5, 1) - hash + str.charCodeAt(i)) | 0; 52 + } 53 + return hash >>> 0; 54 + } 55 + 56 + function hsvToRgb(h: number, s: number, v: number): [number, number, number] { 57 + const c = v * s; 58 + const hPrime = h * 0.016666667; 59 + const x = c * (1 - Math.abs((hPrime % 2) - 1)); 60 + const m = v - c; 61 + 62 + let r: number, g: number, b: number; 63 + 64 + if (h < 60) { 65 + r = c; 66 + g = x; 67 + b = 0; 68 + } else if (h < 120) { 69 + r = x; 70 + g = c; 71 + b = 0; 72 + } else if (h < 180) { 73 + r = 0; 74 + g = c; 75 + b = x; 76 + } else if (h < 240) { 77 + r = 0; 78 + g = x; 79 + b = c; 80 + } else if (h < 300) { 81 + r = x; 82 + g = 0; 83 + b = c; 84 + } else { 85 + r = c; 86 + g = 0; 87 + b = x; 88 + } 89 + 90 + return [((r + m) * 255) | 0, ((g + m) * 255) | 0, ((b + m) * 255) | 0]; 91 + }
+39 -20
src/lib/at/client.ts
··· 13 13 type ActorIdentifier, 14 14 type AtprotoDid, 15 15 type CanonicalResourceUri, 16 + type Did, 16 17 type Nsid, 17 18 type RecordKey, 18 19 type ResourceUri ··· 30 31 import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 31 32 import type { Records } from '@atcute/lexicons/ambient'; 32 33 import { PersistedLRU } from '$lib/cache'; 34 + import { AppBskyActorProfile } from '@atcute/bluesky'; 33 35 34 36 const cacheTtl = 1000 * 60 * 60 * 24; 35 37 const handleCache = new PersistedLRU<Handle, AtprotoDid>({ ··· 104 106 105 107 const cached = recordCache.get(cacheKey); 106 108 if (cached) return ok(cached.value as Output); 109 + const cachedSignal = recordCache.getSignal(cacheKey); 107 110 108 - const result = await fetchMicrocosm(this.slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 109 - repo, 110 - collection, 111 - rkey 112 - }); 111 + const result = await Promise.race([ 112 + fetchMicrocosm(this.slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 113 + repo, 114 + collection, 115 + rkey 116 + }).then((result): Result<Output, string> => { 117 + if (!result.ok) return result; 118 + 119 + const parsed = safeParse(schema, result.value.value); 120 + if (!parsed.ok) return err(parsed.message); 121 + 122 + recordCache.set(cacheKey, result.value); 123 + 124 + return ok(parsed.value as Output); 125 + }), 126 + cachedSignal.then((d): Result<Output, string> => ok(d.value as Output)) 127 + ]); 113 128 114 129 if (!result.ok) return result; 115 - // console.info(`fetched record:`, result.value); 116 130 117 - const parsed = safeParse(schema, result.value.value); 118 - if (!parsed.ok) return err(parsed.message); 131 + return ok(result.value as Output); 132 + } 119 133 120 - recordCache.set(cacheKey, result.value); 121 - 122 - return ok(parsed.value as Output); 134 + async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { 135 + repo = repo ?? this.didDoc?.did; 136 + if (!repo) return err('not authenticated'); 137 + return await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'); 123 138 } 124 139 125 140 async listRecords<Collection extends keyof Records>( ··· 146 161 async resolveHandle(handle: Handle): Promise<Result<AtprotoDid, string>> { 147 162 const cached = handleCache.get(handle); 148 163 if (cached) return ok(cached); 164 + const cachedSignal = handleCache.getSignal(handle); 149 165 150 - const res = await fetchMicrocosm( 151 - this.slingshotUrl, 152 - ComAtprotoIdentityResolveHandle.mainSchema, 153 - { 166 + const res = await Promise.race([ 167 + fetchMicrocosm(this.slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 154 168 handle 155 - } 156 - ); 169 + }), 170 + cachedSignal.then((d): Result<{ did: Did }, string> => ok({ did: d })) 171 + ]); 157 172 158 173 const mapped = map(res, (data) => data.did as AtprotoDid); 159 174 ··· 167 182 async resolveDidDoc(handleOrDid: ActorIdentifier): Promise<Result<MiniDoc, string>> { 168 183 const cached = didDocCache.get(handleOrDid); 169 184 if (cached) return ok(cached); 185 + const cachedSignal = didDocCache.getSignal(handleOrDid); 170 186 171 - const result = await fetchMicrocosm(this.slingshotUrl, MiniDocQuery, { 172 - identifier: handleOrDid 173 - }); 187 + const result = await Promise.race([ 188 + fetchMicrocosm(this.slingshotUrl, MiniDocQuery, { 189 + identifier: handleOrDid 190 + }), 191 + cachedSignal.then((d): Result<MiniDoc, string> => ok(d)) 192 + ]); 174 193 175 194 if (result.ok) { 176 195 didDocCache.set(handleOrDid, result.value);
+13 -3
src/lib/cache.ts
··· 11 11 // eslint-disable-next-line @typescript-eslint/no-empty-object-type 12 12 export class PersistedLRU<K extends string, V extends {}> { 13 13 private memory: LRUCache<K, V>; 14 - private storage: Cache; // from wora/cache-persist 14 + private storage: Cache; 15 + private signals: Map<K, (data: V) => void>; 15 16 16 - private prefix = ''; // or derive from options 17 + private prefix = ''; 17 18 18 19 constructor(opts: PersistedLRUOptions) { 19 20 this.memory = new LRUCache<K, V>({ ··· 22 23 }); 23 24 this.storage = new Cache(opts.persistOptions); 24 25 this.prefix = opts.prefix ? `${opts.prefix}%` : ''; 26 + this.signals = new Map(); 25 27 26 28 this.init(); 27 29 } ··· 45 47 get(key: K): V | undefined { 46 48 return this.memory.get(key); 47 49 } 50 + getSignal(key: K): Promise<V> { 51 + return new Promise<V>((resolve) => { 52 + this.signals.set(key, resolve); 53 + }); 54 + } 48 55 set(key: K, value: V): void { 49 56 this.memory.set(key, value); 50 57 this.storage.set(this.prefixed(key), value); 58 + this.signals.get(key)?.(value); 51 59 this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly) 52 60 } 53 61 has(key: K): boolean { ··· 56 64 delete(key: K): void { 57 65 this.memory.delete(key); 58 66 this.storage.delete(this.prefixed(key)); 67 + this.storage.flush(); 59 68 } 60 69 clear(): void { 61 70 this.memory.clear(); 62 - this.storage.purge(); // clears stored state 71 + this.storage.purge(); 72 + this.storage.flush(); 63 73 } 64 74 65 75 private prefixed(key: K): string {
+9
src/lib/cdn.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + 3 + export const cdn = `https://cdn.bsky.app`; 4 + 5 + export type ImageKind = 'avatar_thumbnail' | 'avatar' | 'feed_thumbnail' | 'feed_fullsize'; 6 + export type ImageFormat = 'webp' | 'png' | 'jpg'; 7 + 8 + export const img = (kind: ImageKind, did: Did, blob: string, format: ImageFormat = 'webp') => 9 + `${cdn}/img/${kind}/plain/${did}/${blob}@${format}`;
+6 -15
src/routes/+page.svelte
··· 51 51 }; 52 52 53 53 const handleLogout = async (did: Did) => { 54 - $accounts = $accounts.filter((acc) => acc.did !== did); 54 + const newAccounts = $accounts.filter((acc) => acc.did !== did); 55 + $accounts = newAccounts; 55 56 clients.delete(did); 56 57 posts.delete(did); 57 58 cursors.delete(did); 58 - selectedDid = $accounts[0]?.did; 59 + handleAccountSelected(newAccounts[0]?.did); 59 60 }; 60 61 61 62 const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => { ··· 304 305 <div class="flex-shrink-0 space-y-4"> 305 306 <div class="flex min-h-16 items-stretch gap-2"> 306 307 <AccountSelector 308 + client={viewClient} 307 309 accounts={$accounts} 308 310 bind:selectedDid 309 311 onAccountSelected={handleAccountSelected} ··· 391 393 <span class="text-sm text-nowrap opacity-60" style="color: {theme.fg};" 392 394 >{reverseChronological ? '↱' : '↳'}</span 393 395 > 394 - <BskyPost 395 - mini 396 - client={viewClient} 397 - identifier={post.did} 398 - rkey={post.rkey} 399 - record={post.record} 400 - /> 396 + <BskyPost mini client={viewClient} {...post} /> 401 397 </div> 402 398 {/if} 403 399 {#each thread.posts as post (post.uri)} 404 400 <div class="mb-1.5"> 405 - <BskyPost 406 - client={viewClient} 407 - identifier={post.did} 408 - rkey={post.rkey} 409 - record={post.record} 410 - /> 401 + <BskyPost client={viewClient} {...post} /> 411 402 </div> 412 403 {/each} 413 404 </div>