replies timeline only, appview-less bluesky client

Compare changes

Choose any two refs to compare.

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