replies timeline only, appview-less bluesky client

Compare changes

Choose any two refs to compare.

+3 -2
src/components/EmbedMedia.svelte
··· 21 21 isBlob(img.image) ? [{ ...img, image: img.image }] : [] 22 22 )} 23 23 {@const images = _images.map((i): GalleryItem => { 24 - const size = i.aspectRatio ?? { width: 400, height: 300 }; 24 + const size = i.aspectRatio; 25 25 const cid = i.image.ref.$link; 26 26 return { 27 27 ...size, ··· 29 29 thumbnail: { 30 30 src: img('feed_thumbnail', did, cid), 31 31 ...size 32 - } 32 + }, 33 + alt: i.alt 33 34 }; 34 35 })} 35 36 {#if images.length > 0}
+7 -14
src/components/FollowingItem.svelte
··· 1 - <script lang="ts" module> 2 - const profileCache = new SvelteMap<string, { displayName?: string; handle: string }>(); 3 - </script> 4 - 5 1 <script lang="ts"> 6 2 import ProfilePicture from './ProfilePicture.svelte'; 7 3 import BlockedUserIndicator from './BlockedUserIndicator.svelte'; ··· 10 6 import type { Did } from '@atcute/lexicons'; 11 7 import type { calculateFollowedUserStats, Sort } from '$lib/following'; 12 8 import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; 13 - import { SvelteMap } from 'svelte/reactivity'; 14 - import { router, getBlockRelationship } from '$lib/state.svelte'; 9 + import { router, getBlockRelationship, profiles, handles } from '$lib/state.svelte'; 15 10 import { map } from '$lib/result'; 16 11 17 12 interface Props { ··· 31 26 ); 32 27 const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget); 33 28 34 - const cached = $derived(profileCache.get(did)); 35 - const displayName = $derived(cached?.displayName); 36 - const handle = $derived(cached?.handle ?? 'handle.invalid'); 29 + const displayName = $derived(profiles.get(did)?.displayName); 30 + const handle = $derived(handles.get(did) ?? 'loading...'); 37 31 38 32 let error = $state(''); 39 33 40 34 const loadProfile = async (targetDid: Did) => { 41 - if (profileCache.has(targetDid)) return; 35 + if (profiles.has(targetDid) && handles.has(targetDid)) return; 42 36 43 37 try { 44 38 const [profileRes, handleRes] = await Promise.all([ ··· 47 41 ]); 48 42 if (did !== targetDid) return; 49 43 50 - profileCache.set(targetDid, { 51 - handle: handleRes.ok ? handleRes.value : handle, 52 - displayName: profileRes.ok ? profileRes.value.displayName : displayName 53 - }); 44 + if (profileRes.ok) profiles.set(targetDid, profileRes.value); 45 + if (handleRes.ok) handles.set(targetDid, handleRes.value); 46 + else handles.set(targetDid, 'handle.invalid'); 54 47 } catch (e) { 55 48 if (did !== targetDid) return; 56 49 console.error(`failed to load profile for ${targetDid}`, e);
+8 -8
src/components/PhotoSwipeGallery.svelte
··· 3 3 src: string; 4 4 thumbnail?: { 5 5 src: string; 6 - width: number; 7 - height: number; 6 + width?: number; 7 + height?: number; 8 8 }; 9 - width: number; 10 - height: number; 11 - cropped?: boolean; 9 + width?: number; 10 + height?: number; 12 11 alt?: string; 13 12 } 14 13 export type GalleryData = Array<GalleryItem>; ··· 23 22 24 23 export let images: GalleryData; 25 24 let element: HTMLDivElement; 25 + let imageElements: { [key: number]: HTMLImageElement } = {}; 26 26 27 27 const options = writable<Partial<PreparedPhotoSwipeOptions> | undefined>(undefined); 28 28 $: { ··· 70 70 <!-- eslint-disable svelte/no-navigation-without-resolve --> 71 71 <a 72 72 href={img.src} 73 - data-pswp-width={img.width} 74 - data-pswp-height={img.height} 73 + data-pswp-width={img.width ?? imageElements[i]?.width} 74 + data-pswp-height={img.height ?? imageElements[i]?.height} 75 75 target="_blank" 76 76 class:hidden-in-grid={isHidden} 77 77 class:overlay-container={isOverlay} 78 78 > 79 - <img src={thumb.src} alt={img.alt ?? ''} width={thumb.width} height={thumb.height} /> 79 + <img bind:this={imageElements[i]} src={thumb.src} title={img.alt ?? ''} alt={img.alt ?? ''} /> 80 80 81 81 {#if isOverlay} 82 82 <div class="more-overlay">
+59 -17
src/components/PostComposer.svelte
··· 43 43 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' 44 44 ); 45 45 46 + const getVideoDimensions = ( 47 + blobUrl: string 48 + ): Promise<Result<{ width: number; height: number }, string>> => 49 + new Promise((resolve) => { 50 + const video = document.createElement('video'); 51 + video.onloadedmetadata = () => { 52 + resolve(ok({ width: video.videoWidth, height: video.videoHeight })); 53 + }; 54 + video.onerror = (e) => resolve(err(String(e))); 55 + video.src = blobUrl; 56 + }); 57 + 46 58 const uploadVideo = async (blobUrl: string, mimeType: string) => { 47 - const blob = await (await fetch(blobUrl)).blob(); 48 - return await client.uploadVideo(blob, mimeType, (status) => { 59 + const file = await (await fetch(blobUrl)).blob(); 60 + return await client.uploadVideo(file, mimeType, (status) => { 49 61 if (status.stage === 'uploading' && status.progress !== undefined) { 50 62 _state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 }); 51 63 } else if (status.stage === 'processing' && status.progress !== undefined) { ··· 56 68 } 57 69 }); 58 70 }; 71 + 72 + const getImageDimensions = ( 73 + blobUrl: string 74 + ): Promise<Result<{ width: number; height: number }, string>> => 75 + new Promise((resolve) => { 76 + const img = new Image(); 77 + img.onload = () => resolve(ok({ width: img.width, height: img.height })); 78 + img.onerror = (e) => resolve(err(String(e))); 79 + img.src = blobUrl; 80 + }); 81 + 59 82 const uploadImage = async (blobUrl: string) => { 60 - const blob = await (await fetch(blobUrl)).blob(); 61 - return await client.uploadBlob(blob, (progress) => { 83 + const file = await (await fetch(blobUrl)).blob(); 84 + return await client.uploadBlob(file, (progress) => { 62 85 _state.blobsState.set(blobUrl, { state: 'uploading', progress }); 63 86 }); 64 87 }; ··· 77 100 const images = _state.attachedMedia.images; 78 101 let uploadedImages: typeof images = []; 79 102 for (const image of images) { 80 - const upload = _state.blobsState.get((image.image as AtpBlob<string>).ref.$link); 103 + const blobUrl = (image.image as AtpBlob<string>).ref.$link; 104 + const upload = _state.blobsState.get(blobUrl); 81 105 if (!upload || upload.state !== 'uploaded') continue; 106 + const size = await getImageDimensions(blobUrl); 107 + if (size.ok) image.aspectRatio = size.value; 82 108 uploadedImages.push({ 83 109 ...image, 84 110 image: upload.blob ··· 91 117 images: uploadedImages 92 118 }; 93 119 } else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { 94 - const upload = _state.blobsState.get( 95 - (_state.attachedMedia.video as AtpBlob<string>).ref.$link 96 - ); 97 - if (upload && upload.state === 'uploaded') 120 + const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; 121 + const upload = _state.blobsState.get(blobUrl); 122 + if (upload && upload.state === 'uploaded') { 123 + const size = await getVideoDimensions(blobUrl); 124 + if (size.ok) _state.attachedMedia.aspectRatio = size.value; 98 125 media = { 99 126 ..._state.attachedMedia, 100 127 $type: 'app.bsky.embed.video', 101 128 video: upload.blob 102 129 }; 130 + } 103 131 } 104 - console.log('media', media); 132 + // console.log('media', media); 105 133 106 134 const record: AppBskyFeedPost.Main = { 107 135 $type: 'app.bsky.feed.post', ··· 156 184 let fileInputEl: HTMLInputElement | undefined = $state(); 157 185 let selectingFile = $state(false); 158 186 187 + const canUpload = $derived( 188 + !( 189 + _state.attachedMedia?.$type === 'app.bsky.embed.video' || 190 + (_state.attachedMedia?.$type === 'app.bsky.embed.images' && 191 + _state.attachedMedia.images.length >= 4) 192 + ) 193 + ); 194 + 159 195 const unfocus = () => (_state.focus = 'null'); 160 196 161 197 const handleFiles = (files: File[]) => { 162 - if (!files || files.length === 0) return; 198 + if (!canUpload || !files || files.length === 0) return; 163 199 164 200 const existingImages = 165 201 _state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : []; ··· 220 256 }; 221 257 } 222 258 223 - const handleUpload = (blobUrl: string, blob: Result<AtpBlob<string>, string>) => { 224 - if (blob.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: blob.value }); 225 - else _state.blobsState.set(blobUrl, { state: 'error', message: blob.error }); 259 + const handleUpload = (blobUrl: string, res: Result<AtpBlob<string>, string>) => { 260 + if (res.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: res.value }); 261 + else _state.blobsState.set(blobUrl, { state: 'error', message: res.error }); 226 262 }; 227 263 228 264 const media = _state.attachedMedia; ··· 269 305 if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { 270 306 const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; 271 307 _state.blobsState.delete(blobUrl); 308 + queueMicrotask(() => URL.revokeObjectURL(blobUrl)); 272 309 } 273 310 _state.attachedMedia = undefined; 274 311 }; ··· 278 315 const imageToRemove = _state.attachedMedia.images[index]; 279 316 const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link; 280 317 _state.blobsState.delete(blobUrl); 318 + queueMicrotask(() => URL.revokeObjectURL(blobUrl)); 281 319 282 320 const images = _state.attachedMedia.images.filter((_, i) => i !== index); 283 321 _state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined; ··· 295 333 _state.text = ''; 296 334 _state.quoting = undefined; 297 335 _state.replying = undefined; 336 + if (_state.attachedMedia?.$type === 'app.bsky.embed.video') 337 + URL.revokeObjectURL((_state.attachedMedia.video as AtpBlob<string>).ref.$link); 338 + else if (_state.attachedMedia?.$type === 'app.bsky.embed.images') 339 + _state.attachedMedia.images.forEach((image) => 340 + URL.revokeObjectURL((image.image as AtpBlob<string>).ref.$link) 341 + ); 298 342 _state.attachedMedia = undefined; 299 343 _state.blobsState.clear(); 300 344 unfocus(); ··· 459 503 fileInputEl?.click(); 460 504 }} 461 505 onmousedown={(e) => e.preventDefault()} 462 - disabled={_state.attachedMedia?.$type === 'app.bsky.embed.video' || 463 - (_state.attachedMedia?.$type === 'app.bsky.embed.images' && 464 - _state.attachedMedia.images.length >= 4)} 506 + disabled={!canUpload} 465 507 class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50" 466 508 style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};" 467 509 title="attach media"
+32 -53
src/components/ProfileView.svelte
··· 1 1 <script lang="ts"> 2 - import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client.svelte'; 3 - import { 4 - isHandle, 5 - type ActorIdentifier, 6 - type AtprotoDid, 7 - type Did, 8 - type Handle 9 - } from '@atcute/lexicons/syntax'; 2 + import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte'; 3 + import { isDid, isHandle, type ActorIdentifier, type Did } from '@atcute/lexicons/syntax'; 10 4 import TimelineView from './TimelineView.svelte'; 11 5 import ProfileInfo from './ProfileInfo.svelte'; 12 6 import type { State as PostComposerState } from './PostComposer.svelte'; ··· 14 8 import { accounts, generateColorForDid } from '$lib/accounts'; 15 9 import { img } from '$lib/cdn'; 16 10 import { isBlob } from '@atcute/lexicons/interfaces'; 17 - import type { AppBskyActorProfile } from '@atcute/bluesky'; 18 11 import { 19 12 handles, 20 13 profiles, ··· 34 27 35 28 let { client, actor, onBack, postComposerState = $bindable() }: Props = $props(); 36 29 37 - let profile = $state<AppBskyActorProfile.Main | null>(profiles.get(actor as Did) ?? null); 30 + const profile = $derived(profiles.get(actor as Did)); 38 31 const displayName = $derived(profile?.displayName ?? ''); 32 + const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did)); 39 33 let loading = $state(true); 40 34 let error = $state<string | null>(null); 41 - let did = $state<AtprotoDid | null>(null); 42 - let handle = $state<Handle | null>(handles.get(actor as Did) ?? null); 35 + let did = $state(isDid(actor) ? actor : null); 43 36 44 37 let userBlocked = $state(false); 45 38 let blockedByTarget = $state(false); ··· 47 40 const loadProfile = async (identifier: ActorIdentifier) => { 48 41 loading = true; 49 42 error = null; 50 - profile = null; 51 - handle = isHandle(identifier) ? identifier : null; 52 43 53 - const resDid = await resolveHandle(identifier); 54 - if (resDid.ok) did = resDid.value; 55 - else { 56 - error = resDid.error; 57 - loading = false; 44 + const docRes = await resolveDidDoc(identifier); 45 + if (docRes.ok) { 46 + did = docRes.value.did; 47 + handles.set(did, docRes.value.handle); 48 + } else { 49 + error = docRes.error; 58 50 return; 59 51 } 60 52 61 - if (!handle) handle = handles.get(did) ?? null; 62 - 63 - if (!handle) { 64 - const resHandle = await resolveDidDoc(did); 65 - if (resHandle.ok) { 66 - handle = resHandle.value.handle; 67 - handles.set(did, resHandle.value.handle); 68 - } 69 - } 70 - 71 53 // check block relationship 72 54 if (client.user?.did) { 73 55 let blockRel = getBlockRelationship(client.user.did, did); 74 56 blockRel = blockFlags.get(client.user.did)?.has(did) 75 57 ? blockRel 76 - : { 77 - userBlocked: await fetchBlocked(client, did, client.user.did), 78 - blockedByTarget: await fetchBlocked(client, client.user.did, did) 79 - }; 58 + : await (async () => { 59 + const [userBlocked, blockedByTarget] = await Promise.all([ 60 + await fetchBlocked(client, did, client.user!.did), 61 + await fetchBlocked(client, client.user!.did, did) 62 + ]); 63 + return { userBlocked, blockedByTarget }; 64 + })(); 80 65 userBlocked = blockRel.userBlocked; 81 66 blockedByTarget = blockRel.blockedByTarget; 82 67 } ··· 87 72 return; 88 73 } 89 74 90 - const res = await client.getProfile(did); 91 - if (res.ok) { 92 - profile = res.value; 93 - profiles.set(did, res.value); 94 - } else error = res.error; 75 + const res = await client.getProfile(did, true); 76 + if (res.ok) profiles.set(did, res.value); 77 + else error = res.error; 95 78 96 79 loading = false; 97 80 }; ··· 122 105 <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 123 106 </button> 124 107 <h2 class="text-xl font-bold"> 125 - {displayName.length > 0 126 - ? displayName 127 - : loading 128 - ? 'loading...' 129 - : (handle ?? actor ?? 'profile')} 108 + {displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')} 130 109 </h2> 131 110 <div class="grow"></div> 132 111 {#if did && client.user && client.user.did !== did} ··· 150 129 </div> 151 130 {:else} 152 131 <!-- banner --> 153 - <div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48"> 154 - {#if bannerUrl} 132 + {#if bannerUrl} 133 + <div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48"> 155 134 <img src={bannerUrl} alt="banner" class="h-full w-full object-cover" /> 156 - {/if} 157 - <div 158 - class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)" 159 - style="opacity: 0.8;" 160 - ></div> 161 - </div> 135 + <div 136 + class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)" 137 + style="opacity: 0.8;" 138 + ></div> 139 + </div> 140 + {/if} 162 141 163 142 {#if did} 164 143 <div class="px-4 pb-4"> 165 - <div class="relative z-10 -mt-12 mb-4"> 166 - <ProfileInfo {client} {did} bind:profile /> 144 + <div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4"> 145 + <ProfileInfo {client} {did} {profile} /> 167 146 </div> 168 147 169 148 <TimelineView
+15 -10
src/components/TimelineView.svelte
··· 15 15 } from '$lib/state.svelte'; 16 16 import Icon from '@iconify/svelte'; 17 17 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 18 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 18 + import type { Did } from '@atcute/lexicons/syntax'; 19 19 import NotLoggedIn from './NotLoggedIn.svelte'; 20 20 21 21 interface Props { 22 22 client?: AtpClient | null; 23 - targetDid?: AtprotoDid; 23 + targetDid?: Did; 24 24 postComposerState: PostComposerState; 25 25 class?: string; 26 26 // whether to show replies that are not the user's own posts ··· 63 63 loaderState.status = 'LOADING'; 64 64 65 65 try { 66 - await fetchTimeline(client, did as AtprotoDid, 7, showReplies); 66 + await fetchTimeline(client, did, 7, showReplies, { 67 + downwards: userDid === did ? 'sameAuthor' : 'none' 68 + }); 67 69 // only fetch interactions if logged in (because if not who is the interactor) 68 - if (client.user) { 70 + if (client.user && userDid) { 69 71 if (!fetchingInteractions) { 70 72 scheduledFetchInteractions = false; 71 73 fetchingInteractions = true; 72 - fetchInteractionsToTimelineEnd(client, did).finally(() => (fetchingInteractions = false)); 74 + await fetchInteractionsToTimelineEnd(client, userDid, did); 75 + fetchingInteractions = false; 73 76 } else { 74 77 scheduledFetchInteractions = true; 75 78 } ··· 83 86 } 84 87 85 88 loading = false; 86 - const cursor = postCursors.get(did as AtprotoDid); 89 + const cursor = postCursors.get(did); 87 90 if (cursor && cursor.end) loaderState.complete(); 88 91 }; 89 92 ··· 92 95 // if we saw all posts dont try to load more. 93 96 // this only really happens if the user has no posts at all 94 97 // but we do have to handle it to not cause an infinite loop 95 - const cursor = did ? postCursors.get(did as AtprotoDid) : undefined; 98 + const cursor = did ? postCursors.get(did) : undefined; 96 99 if (!cursor?.end) loadMore(); 97 100 } 98 101 }); 99 102 100 103 let fetchingInteractions = $state(false); 101 104 let scheduledFetchInteractions = $state(false); 102 - // we want to load interactions when changing logged in user on timelines 105 + // we want to load interactions when changing logged in user 103 106 // only on timelines that arent logged in users, because those are already 104 107 // loaded by loadMore 105 108 $effect(() => { 106 - if (client && did && scheduledFetchInteractions && userDid !== did) { 109 + if (client && scheduledFetchInteractions && userDid && did && did !== userDid) { 107 110 if (!fetchingInteractions) { 108 111 scheduledFetchInteractions = false; 109 112 fetchingInteractions = true; 110 - fetchInteractionsToTimelineEnd(client, did).finally(() => (fetchingInteractions = false)); 113 + fetchInteractionsToTimelineEnd(client, userDid, did).finally( 114 + () => (fetchingInteractions = false) 115 + ); 111 116 } else { 112 117 scheduledFetchInteractions = true; 113 118 }
+31 -15
src/lib/at/client.svelte.ts
··· 30 30 import { MiniDocQuery, type MiniDoc } from './slingshot'; 31 31 import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 32 32 import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient'; 33 - import { cache as rawCache } from '$lib/cache'; 33 + import { cache as rawCache, ttl } from '$lib/cache'; 34 34 import { AppBskyActorProfile } from '@atcute/bluesky'; 35 35 import { WebSocket } from '@soffinal/websocket'; 36 36 import type { Notification } from './stardust'; ··· 77 77 78 78 const cache = cacheWithRecords; 79 79 80 + export const invalidateRecordCache = async (uri: ResourceUri) => { 81 + console.log(`invalidating cached for ${uri}`); 82 + await cache.invalidate('fetchRecord', `fetchRecord~${uri}`); 83 + }; 84 + export const setRecordCache = (uri: ResourceUri, record: unknown) => 85 + cache.set('fetchRecord', `fetchRecord~${uri}`, record, ttl); 86 + 80 87 export const xhrPost = ( 81 88 url: string, 82 89 body: Blob | File, ··· 88 95 const xhr = new XMLHttpRequest(); 89 96 xhr.open('POST', url); 90 97 91 - if (onProgress && xhr.upload) { 98 + if (onProgress && xhr.upload) 92 99 xhr.upload.onprogress = (event: ProgressEvent) => { 93 100 if (event.lengthComputable) onProgress(event.loaded, event.total); 94 101 }; 95 - } 96 102 97 103 Object.keys(headers).forEach((key) => xhr.setRequestHeader(key, headers[key])); 98 104 ··· 145 151 TKey extends RecordKeySchema, 146 152 Schema extends RecordSchema<TObject, TKey>, 147 153 Output extends InferInput<Schema> 148 - >(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> { 154 + >( 155 + schema: Schema, 156 + uri: ResourceUri, 157 + noCache?: boolean 158 + ): Promise<Result<RecordOutput<Output>, string>> { 149 159 const parsedUri = expect(parseResourceUri(uri)); 150 160 if (parsedUri.collection !== schema.object.shape.$type.expected) 151 161 return err( 152 162 `collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}` 153 163 ); 154 - return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!); 164 + return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!, noCache); 155 165 } 156 166 157 167 async getRecord< ··· 163 173 >( 164 174 schema: Schema, 165 175 repo: ActorIdentifier, 166 - rkey: RecordKey 176 + rkey: RecordKey, 177 + noCache?: boolean 167 178 ): Promise<Result<RecordOutput<Output>, string>> { 168 179 const collection = schema.object.shape.$type.expected; 169 180 170 181 try { 171 - const rawValue = await cache.fetchRecord( 172 - toResourceUri({ repo, collection, rkey, fragment: undefined }) 173 - ); 182 + const uri = toResourceUri({ repo, collection, rkey, fragment: undefined }); 183 + if (noCache) await invalidateRecordCache(uri); 184 + const rawValue = await cache.fetchRecord(uri); 174 185 175 186 const parsed = safeParse(schema, rawValue.value); 176 187 if (!parsed.ok) return err(parsed.message); ··· 185 196 } 186 197 } 187 198 188 - async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { 199 + async getProfile( 200 + repo?: ActorIdentifier, 201 + noCache?: boolean 202 + ): Promise<Result<AppBskyActorProfile.Main, string>> { 189 203 repo = repo ?? this.user?.did; 190 204 if (!repo) return err('not authenticated'); 191 - return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); 205 + return map( 206 + await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self', noCache), 207 + (d) => d.record 208 + ); 192 209 } 193 210 194 211 async listRecords<Collection extends keyof Records>( ··· 218 235 }); 219 236 if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 220 237 221 - for (const record of res.data.records) 222 - await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 238 + for (const record of res.data.records) setRecordCache(record.uri, record); 223 239 224 240 return ok(res.data); 225 241 } ··· 327 343 (uploaded, total) => onProgress?.(uploaded / total) 328 344 ); 329 345 if (!result.ok) return err(`upload failed: ${result.error.message}`); 330 - return ok(result.value); 346 + return ok(result.value.blob); 331 347 } 332 348 333 349 async uploadVideo( ··· 359 375 }, 360 376 (uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total }) 361 377 ); 362 - if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error.message}`); 378 + if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error}`); 363 379 const jobStatus = uploadResult.value; 364 380 let videoBlobRef: AtpBlob<string> = jobStatus.blob; 365 381
+32 -25
src/lib/at/fetch.ts
··· 59 59 } 60 60 }; 61 61 62 + export type HydrateOptions = { 63 + downwards: 'sameAuthor' | 'none'; 64 + }; 65 + 62 66 export const hydratePosts = async ( 63 67 client: AtpClient, 64 68 repo: Did, 65 69 data: PostWithBacklinks[], 66 - cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined 70 + cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined, 71 + options?: Partial<HydrateOptions> 67 72 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 68 73 let posts: Map<ResourceUri, PostWithUri> = new Map(); 69 74 try { ··· 115 120 }; 116 121 await Promise.all(posts.values().map(fetchUpwardsChain)); 117 122 118 - try { 119 - const fetchDownwardsChain = async (post: PostWithUri) => { 120 - const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); 121 - if (repo === postRepo) return; 123 + if (options?.downwards !== 'none') { 124 + try { 125 + const fetchDownwardsChain = async (post: PostWithUri) => { 126 + const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); 127 + if (repo === postRepo) return; 122 128 123 - // get chains that are the same author until we exhaust them 124 - const backlinks = await client.getBacklinks(post.uri, replySource); 125 - if (!backlinks.ok) return; 129 + // get chains that are the same author until we exhaust them 130 + const backlinks = await client.getBacklinks(post.uri, replySource); 131 + if (!backlinks.ok) return; 126 132 127 - const promises = []; 128 - for (const reply of backlinks.value.records) { 129 - if (reply.did !== postRepo) continue; 130 - // if we already have this reply, then we already fetched this chain / are fetching it 131 - if (posts.has(toCanonicalUri(reply))) continue; 132 - const record = 133 - cacheFn(reply.did, reply.rkey) ?? 134 - (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey)); 135 - if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 136 - posts.set(record.value.uri, record.value); 137 - promises.push(fetchDownwardsChain(record.value)); 138 - } 133 + const promises = []; 134 + for (const reply of backlinks.value.records) { 135 + if (reply.did !== postRepo) continue; 136 + // if we already have this reply, then we already fetched this chain / are fetching it 137 + if (posts.has(toCanonicalUri(reply))) continue; 138 + const record = 139 + cacheFn(reply.did, reply.rkey) ?? 140 + (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey)); 141 + if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 142 + posts.set(record.value.uri, record.value); 143 + promises.push(fetchDownwardsChain(record.value)); 144 + } 139 145 140 - await Promise.all(promises); 141 - }; 142 - await Promise.all(posts.values().map(fetchDownwardsChain)); 143 - } catch (error) { 144 - return err(`cant fetch post reply chain: ${error}`); 146 + await Promise.all(promises); 147 + }; 148 + await Promise.all(posts.values().map(fetchDownwardsChain)); 149 + } catch (error) { 150 + return err(`cant fetch post reply chain: ${error}`); 151 + } 145 152 } 146 153 147 154 return ok(posts);
+3 -1
src/lib/cache.ts
··· 210 210 } 211 211 } 212 212 213 + export const ttl = 60 * 60 * 3; // 3 hours 214 + 213 215 export const cache = createCache({ 214 216 storage: { 215 217 type: 'custom', ··· 217 219 storage: new IDBStorage() 218 220 } 219 221 }, 220 - ttl: 60 * 60 * 24, // 24 hours 222 + ttl, 221 223 onError: (err) => console.error(err) 222 224 });
+1 -1
src/lib/result.ts
··· 12 12 return { ok: true, value }; 13 13 }; 14 14 export const err = <E>(error: E): Err<E> => { 15 - console.error(error); 15 + // console.error(error); 16 16 return { ok: false, error }; 17 17 }; 18 18 export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
+42 -17
src/lib/state.svelte.ts
··· 1 1 import { writable } from 'svelte/store'; 2 2 import { 3 3 AtpClient, 4 + setRecordCache, 4 5 type NotificationsStream, 5 6 type NotificationsStreamEvent 6 7 } from './at/client.svelte'; 7 8 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 8 9 import type { Did, Handle, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 9 - import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch'; 10 + import { fetchPosts, hydratePosts, type HydrateOptions, type PostWithUri } from './at/fetch'; 10 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 11 12 import { 12 13 AppBskyActorProfile, ··· 405 406 }; 406 407 407 408 export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 409 + export type DeletedPostInfo = { reply?: PostWithUri['record']['reply'] }; 410 + export const deletedPosts = new SvelteMap<ResourceUri, DeletedPostInfo>(); 408 411 // did -> post uris that are replies to that did 409 412 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 410 413 ··· 447 450 } 448 451 }; 449 452 453 + export const deletePost = (uri: ResourceUri) => { 454 + const did = extractDidFromUri(uri)!; 455 + const post = allPosts.get(did)?.get(uri); 456 + if (!post) return; 457 + allPosts.get(did)?.delete(uri); 458 + // remove reply from index 459 + const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? ''); 460 + if (subjectDid) replyIndex.get(subjectDid)?.delete(uri); 461 + deletedPosts.set(uri, { reply: post.record.reply }); 462 + }; 463 + 450 464 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 451 465 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 452 466 ··· 476 490 477 491 export const fetchTimeline = async ( 478 492 client: AtpClient, 479 - subject: AtprotoDid, 493 + subject: Did, 480 494 limit: number = 6, 481 - withBacklinks: boolean = true 495 + withBacklinks: boolean = true, 496 + hydrateOptions?: Partial<HydrateOptions> 482 497 ) => { 483 498 const cursor = postCursors.get(subject); 484 499 if (cursor && cursor.end) return; ··· 489 504 // if the cursor is undefined, we've reached the end of the timeline 490 505 const newCursor = { value: accPosts.value.cursor, end: !accPosts.value.cursor }; 491 506 postCursors.set(subject, newCursor); 492 - const hydrated = await hydratePosts(client, subject, accPosts.value.posts, hydrateCacheFn); 507 + const hydrated = await hydratePosts( 508 + client, 509 + subject, 510 + accPosts.value.posts, 511 + hydrateCacheFn, 512 + hydrateOptions 513 + ); 493 514 if (!hydrated.ok) throw `cant hydrate posts ${subject}: ${hydrated.error}`; 494 515 495 516 addPosts(hydrated.value.values()); ··· 511 532 return newCursor; 512 533 }; 513 534 514 - export const fetchInteractionsToTimelineEnd = async (client: AtpClient, did: Did) => { 515 - const cursor = postCursors.get(did); 535 + export const fetchInteractionsToTimelineEnd = async ( 536 + client: AtpClient, 537 + interactor: Did, 538 + subject: Did 539 + ) => { 540 + const cursor = postCursors.get(subject); 516 541 if (!cursor) return; 517 542 const timestamp = timestampFromCursor(cursor.value); 518 543 await Promise.all( 519 - [likeSource, repostSource].map((s) => fetchLinksUntil(did, client, s, timestamp)) 544 + [likeSource, repostSource].map((s) => fetchLinksUntil(interactor, client, s, timestamp)) 520 545 ); 521 546 }; 522 547 ··· 538 563 const uri: ResourceUri = toCanonicalUri({ did, ...commit }); 539 564 if (commit.collection === 'app.bsky.feed.post') { 540 565 if (commit.operation === 'create') { 566 + const record = commit.record as AppBskyFeedPost.Main; 541 567 const posts = [ 542 568 { 543 - record: commit.record as AppBskyFeedPost.Main, 569 + record, 544 570 uri, 545 571 cid: commit.cid 546 572 } 547 573 ]; 574 + await setRecordCache(uri, record); 548 575 const client = clients.get(did) ?? viewClient; 549 576 const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 550 577 if (!hydrated.ok) { ··· 553 580 } 554 581 addPosts(hydrated.value.values()); 555 582 addTimeline(did, hydrated.value.keys()); 556 - } else if (commit.operation === 'delete') { 557 - const post = allPosts.get(did)?.get(uri); 558 - if (post) { 559 - allPosts.get(did)?.delete(uri); 560 - // remove from timeline 561 - timelines.get(did)?.delete(uri); 562 - // remove reply from index 563 - const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? ''); 564 - if (subjectDid) replyIndex.get(subjectDid)?.delete(uri); 583 + if (record.reply) { 584 + const parentDid = extractDidFromUri(record.reply.parent.uri)!; 585 + addTimeline(parentDid, [uri]); 586 + // const rootDid = extractDidFromUri(record.reply.root.uri)!; 587 + // addTimeline(rootDid, [uri]); 565 588 } 589 + } else if (commit.operation === 'delete') { 590 + deletePost(uri); 566 591 } 567 592 } 568 593 };