replies timeline only, appview-less bluesky client

Compare changes

Choose any two refs to compare.

+13
.zed/settings.json
··· 1 + { 2 + "lsp": { 3 + "vtsls": { 4 + "settings": { 5 + "typescript": { 6 + "suggestionActions": { 7 + "enabled": false 8 + } 9 + } 10 + } 11 + } 12 + } 13 + }
+4
src/app.css
··· 125 125 .animate-slide-in-left { 126 126 animation: slide-in-from-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 127 127 } 128 + 129 + .post-dropdown { 130 + @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 131 + }
+1 -1
src/components/AccountSelector.svelte
··· 1 1 <script lang="ts"> 2 2 import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 - import { AtpClient, resolveHandle } from '$lib/at/client'; 3 + import { AtpClient, resolveHandle } from '$lib/at/client.svelte'; 4 4 import type { Handle } from '@atcute/lexicons'; 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import PfpPlaceholder from './PfpPlaceholder.svelte';
+36
src/components/BlockedUserIndicator.svelte
··· 1 + <script lang="ts"> 2 + import type { Did } from '@atcute/lexicons'; 3 + import ProfilePicture from './ProfilePicture.svelte'; 4 + import type { AtpClient } from '$lib/at/client.svelte'; 5 + import { generateColorForDid } from '$lib/accounts'; 6 + 7 + interface Props { 8 + client: AtpClient; 9 + did: Did; 10 + reason: 'blocked' | 'blocks-you'; 11 + size?: 'small' | 'normal' | 'large'; 12 + } 13 + 14 + let { client, did, reason, size = 'normal' }: Props = $props(); 15 + 16 + const color = $derived(generateColorForDid(did)); 17 + const text = $derived(reason === 'blocked' ? 'user blocked' : 'user blocks you'); 18 + const pfpSize = $derived(size === 'small' ? 8 : size === 'large' ? 16 : 10); 19 + </script> 20 + 21 + <div 22 + class="flex items-center gap-2 rounded-sm border-2 p-2 {size === 'small' ? 'text-sm' : ''}" 23 + style="background: {color}11; border-color: {color}44;" 24 + > 25 + <div class="blocked-pfp"> 26 + <ProfilePicture {client} {did} size={pfpSize} /> 27 + </div> 28 + <span class="opacity-80">{text}</span> 29 + </div> 30 + 31 + <style> 32 + .blocked-pfp { 33 + filter: blur(8px) grayscale(100%); 34 + opacity: 0.4; 35 + } 36 + </style>
+139 -88
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 - import { resolveDidDoc, type AtpClient } from '$lib/at/client'; 2 + import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; 3 3 import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; 4 4 import { 5 5 parseCanonicalResourceUri, ··· 22 22 router, 23 23 profiles, 24 24 handles, 25 - hasBacklink 25 + hasBacklink, 26 + getBlockRelationship, 27 + clients 26 28 } from '$lib/state.svelte'; 27 29 import type { PostWithUri } from '$lib/at/fetch'; 28 30 import { onMount, type Snippet } from 'svelte'; ··· 49 51 onQuote?: (quote: PostWithUri) => void; 50 52 onReply?: (reply: PostWithUri) => void; 51 53 cornerFragment?: Snippet; 54 + isBlocked?: boolean; 52 55 } 53 56 54 57 const { ··· 61 64 onQuote, 62 65 onReply, 63 66 isOnPostComposer = false /* replyBacklinks */, 64 - cornerFragment 67 + cornerFragment, 68 + isBlocked = false 65 69 }: Props = $props(); 66 70 67 - const selectedDid = $derived(client.user?.did ?? null); 71 + const user = $derived(client.user); 68 72 const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did)); 69 73 70 74 const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 71 75 const color = $derived(generateColorForDid(did)); 72 76 77 + let expandBlocked = $state(false); 78 + const blockRel = $derived( 79 + user && !isOnPostComposer 80 + ? getBlockRelationship(user.did, did) 81 + : { userBlocked: false, blockedByTarget: false } 82 + ); 83 + const showAsBlocked = $derived( 84 + (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandBlocked 85 + ); 86 + 73 87 let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); 74 88 onMount(() => { 75 89 resolveDidDoc(did).then((res) => { ··· 135 149 return; 136 150 } 137 151 138 - client?.atcute 139 - ?.post('com.atproto.repo.deleteRecord', { 152 + clients 153 + .get(did) 154 + ?.user?.atcute.post('com.atproto.repo.deleteRecord', { 140 155 input: { 141 156 collection: 'app.bsky.feed.post', 142 157 repo: did, ··· 195 210 {:then post} 196 211 {#if post.ok} 197 212 {@const record = post.value.record} 198 - <!-- svelte-ignore a11y_click_events_have_key_events --> 199 - <!-- svelte-ignore a11y_no_static_element_interactions --> 200 - <div 201 - onclick={() => scrollToAndPulse(post.value.uri)} 202 - class="select-none hover:cursor-pointer hover:underline" 203 - > 204 - <span style="color: {color};">@{handle}</span>: 205 - {#if record.embed} 206 - <EmbedBadge embed={record.embed} /> 207 - {/if} 208 - <span title={record.text}>{record.text}</span> 209 - </div> 213 + {#if showAsBlocked} 214 + <button 215 + onclick={() => (expandBlocked = true)} 216 + class="text-left hover:cursor-pointer hover:underline" 217 + > 218 + <span style="color: {color};">post from blocked user</span> (click to show) 219 + </button> 220 + {:else} 221 + <!-- svelte-ignore a11y_click_events_have_key_events --> 222 + <!-- svelte-ignore a11y_no_static_element_interactions --> 223 + <div 224 + onclick={() => scrollToAndPulse(post.value.uri)} 225 + class="hover:cursor-pointer hover:underline" 226 + > 227 + <span style="color: {color};">@{handle}</span>: 228 + {#if record.embed} 229 + <EmbedBadge embed={record.embed} /> 230 + {/if} 231 + <span title={record.text}>{record.text}</span> 232 + </div> 233 + {/if} 210 234 {:else} 211 235 {post.error} 212 236 {/if} ··· 229 253 {:then post} 230 254 {#if post.ok} 231 255 {@const record = post.value.record} 232 - <!-- svelte-ignore a11y_no_static_element_interactions --> 233 - <div 234 - id="timeline-post-{post.value.uri}-{quoteDepth}" 235 - oncontextmenu={handleRightClick} 236 - class=" 256 + {#if showAsBlocked} 257 + <button 258 + onclick={() => (expandBlocked = true)} 259 + class=" 260 + group w-full rounded-sm border-2 p-3 text-left shadow-lg 261 + backdrop-blur-sm transition-all hover:border-(--nucleus-accent) 262 + " 263 + style="background: {color}18; border-color: {color}66;" 264 + > 265 + <div class="flex items-center gap-2"> 266 + <span class="opacity-80">post from blocked user</span> 267 + <span class="text-sm opacity-60">(click to show)</span> 268 + </div> 269 + </button> 270 + {:else} 271 + <!-- svelte-ignore a11y_no_static_element_interactions --> 272 + <div 273 + id="timeline-post-{post.value.uri}-{quoteDepth}" 274 + oncontextmenu={handleRightClick} 275 + class=" 237 276 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 238 277 {$isPulsing ? 'animate-pulse-highlight' : ''} 239 278 {isOnPostComposer ? 'backdrop-brightness-20' : ''} 240 279 " 241 - style=" 280 + style=" 242 281 background: {color}{isOnPostComposer 243 - ? '36' 244 - : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; 282 + ? '36' 283 + : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; 245 284 border-color: {color}{isOnPostComposer ? '99' : '66'}; 246 285 " 247 - > 248 - <div class="mb-3 flex max-w-full items-center justify-between"> 249 - <div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;"> 250 - {@render profilePopout()} 251 - <span>ยท</span> 252 - <span 253 - title={new Date(record.createdAt).toLocaleString()} 254 - class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 255 - > 256 - {getRelativeTime(new Date(record.createdAt), currentTime)} 257 - </span> 286 + > 287 + <div class="mb-3 flex max-w-full items-center justify-between"> 288 + <div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;"> 289 + {@render profilePopout()} 290 + <span>ยท</span> 291 + <span 292 + title={new Date(record.createdAt).toLocaleString()} 293 + class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 294 + > 295 + {getRelativeTime(new Date(record.createdAt), currentTime)} 296 + </span> 297 + </div> 298 + {@render cornerFragment?.()} 258 299 </div> 259 - {@render cornerFragment?.()} 260 - </div> 261 300 262 - <p class="leading-normal text-wrap wrap-break-word"> 263 - <RichText text={record.text} facets={record.facets ?? []} /> 264 - {#if isOnPostComposer && record.embed} 265 - <EmbedBadge embed={record.embed} {color} /> 301 + <p class="leading-normal text-wrap wrap-break-word"> 302 + <RichText text={record.text} facets={record.facets ?? []} /> 303 + {#if isOnPostComposer && record.embed} 304 + <EmbedBadge embed={record.embed} {color} /> 305 + {/if} 306 + </p> 307 + {#if !isOnPostComposer && record.embed} 308 + {@const embed = record.embed} 309 + <div class="mt-2"> 310 + {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 311 + <EmbedMedia {did} {embed} /> 312 + {:else if embed.$type === 'app.bsky.embed.record'} 313 + {@render embedPost(embed.record.uri)} 314 + {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 315 + <div class="space-y-1.5"> 316 + <EmbedMedia {did} embed={embed.media} /> 317 + {@render embedPost(embed.record.record.uri)} 318 + </div> 319 + {/if} 320 + </div> 321 + {/if} 322 + {#if !isOnPostComposer} 323 + {@render postControls(post.value)} 266 324 {/if} 267 - </p> 268 - {#if !isOnPostComposer && record.embed} 269 - {@const embed = record.embed} 270 - <div class="mt-2"> 271 - {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 272 - <EmbedMedia {did} {embed} /> 273 - {:else if embed.$type === 'app.bsky.embed.record'} 274 - {@render embedPost(embed.record.uri)} 275 - {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 276 - <div class="space-y-1.5"> 277 - <EmbedMedia {did} embed={embed.media} /> 278 - {@render embedPost(embed.record.record.uri)} 279 - </div> 280 - {/if} 281 - </div> 282 - {/if} 283 - {#if !isOnPostComposer} 284 - {@render postControls(post.value)} 285 - {/if} 286 - </div> 325 + </div> 326 + {/if} 287 327 {:else} 288 328 <div class="error-disclaimer"> 289 329 <p class="text-sm font-medium">error: {post.error}</p> ··· 295 335 {#snippet embedPost(uri: ResourceUri)} 296 336 {#if quoteDepth < 2} 297 337 {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 338 + {@const embedBlockRel = 339 + user?.did && !isOnPostComposer 340 + ? getBlockRelationship(user.did, parsedUri.repo) 341 + : { userBlocked: false, blockedByTarget: false }} 342 + {@const embedIsBlocked = embedBlockRel.userBlocked || embedBlockRel.blockedByTarget} 343 + 298 344 <!-- reject recursive quotes --> 299 345 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 300 - <BskyPost 301 - {client} 302 - quoteDepth={quoteDepth + 1} 303 - did={parsedUri.repo} 304 - rkey={parsedUri.rkey} 305 - {isOnPostComposer} 306 - {onQuote} 307 - {onReply} 308 - /> 346 + {#if embedIsBlocked} 347 + <div 348 + class="rounded-sm border-2 p-2 text-sm opacity-70" 349 + style="background: {generateColorForDid( 350 + parsedUri.repo 351 + )}11; border-color: {generateColorForDid(parsedUri.repo)}44;" 352 + > 353 + quoted post from blocked user 354 + </div> 355 + {:else} 356 + <BskyPost 357 + {client} 358 + quoteDepth={quoteDepth + 1} 359 + did={parsedUri.repo} 360 + rkey={parsedUri.rkey} 361 + {isOnPostComposer} 362 + {onQuote} 363 + {onReply} 364 + /> 365 + {/if} 309 366 {:else} 310 367 <span>you think you're funny with that recursive quote but i'm onto you</span> 311 368 {/if} ··· 315 372 {/snippet} 316 373 317 374 {#snippet postControls(post: PostWithUri)} 318 - {@const myRepost = hasBacklink(post.uri, repostSource, selectedDid!)} 319 - {@const myLike = hasBacklink(post.uri, likeSource, selectedDid!)} 375 + {@const myRepost = user ? hasBacklink(post.uri, repostSource, user.did) : false} 376 + {@const myLike = user ? hasBacklink(post.uri, likeSource, user.did) : false} 320 377 {#snippet control({ 321 378 name, 322 379 icon, ··· 343 400 onclick={(e) => onClick(e)} 344 401 style="color: {isFull ? iconColor : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 345 402 title={name} 346 - disabled={canBeDisabled ? selectedDid === null : false} 403 + disabled={canBeDisabled ? user?.did === undefined : false} 347 404 > 348 405 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 349 406 </button> ··· 360 417 name: 'repost', 361 418 icon: 'heroicons:arrow-path-rounded-square-20-solid', 362 419 onClick: () => { 363 - if (!selectedDid) return; 420 + if (!user?.did) return; 364 421 if (myRepost) deletePostBacklink(client, post, repostSource); 365 422 else createPostBacklink(client, post, repostSource); 366 423 }, ··· 375 432 name: 'like', 376 433 icon: 'heroicons:star', 377 434 onClick: () => { 378 - if (!selectedDid) return; 435 + if (!user?.did) return; 379 436 if (myLike) deletePostBacklink(client, post, likeSource); 380 437 else createPostBacklink(client, post, likeSource); 381 438 }, ··· 393 450 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 394 451 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) 395 452 )} 396 - {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () => 453 + {@render dropdownItem(undefined, 'copy at uri', () => 397 454 navigator.clipboard.writeText(post.uri) 398 455 )} 399 456 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => ··· 429 486 {/snippet} 430 487 431 488 {#snippet dropdownItem( 432 - icon: string, 489 + icon: string | undefined, 433 490 label: string, 434 491 onClick: () => void, 435 492 autoClose: boolean = true, ··· 447 504 }} 448 505 > 449 506 <span class="font-semibold opacity-85">{label}</span> 450 - <Icon class="h-6 w-6" {icon} /> 507 + {#if icon} 508 + <Icon class="h-6 w-6" {icon} /> 509 + {/if} 451 510 </button> 452 511 {/snippet} 453 - 454 - <style> 455 - @reference "../app.css"; 456 - 457 - :global(.post-dropdown) { 458 - @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 459 - } 460 - </style>
+4 -7
src/components/EmbedMedia.svelte
··· 3 3 import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte'; 4 4 import { blob, img } from '$lib/cdn'; 5 5 import { type Did } from '@atcute/lexicons'; 6 - import { resolveDidDoc } from '$lib/at/client'; 6 + import { resolveDidDoc } from '$lib/at/client.svelte'; 7 7 import type { AppBskyEmbedMedia } from '$lib/at/types'; 8 8 9 9 interface Props { ··· 21 21 isBlob(img.image) ? [{ ...img, image: img.image }] : [] 22 22 )} 23 23 {@const images = _images.map((i): GalleryItem => { 24 - const sizeFactor = 200; 25 - const size = { 26 - width: (i.aspectRatio?.width ?? 4) * sizeFactor, 27 - height: (i.aspectRatio?.height ?? 3) * sizeFactor 28 - }; 24 + const size = i.aspectRatio; 29 25 const cid = i.image.ref.$link; 30 26 return { 31 27 ...size, ··· 33 29 thumbnail: { 34 30 src: img('feed_thumbnail', did, cid), 35 31 ...size 36 - } 32 + }, 33 + alt: i.alt 37 34 }; 38 35 })} 39 36 {#if images.length > 0}
+77 -95
src/components/FollowingItem.svelte
··· 1 - <script lang="ts" module> 2 - // Cache for synchronous access during component recycling 3 - const profileCache = new SvelteMap<string, { displayName?: string; handle: string }>(); 4 - </script> 5 - 6 1 <script lang="ts"> 7 2 import ProfilePicture from './ProfilePicture.svelte'; 3 + import BlockedUserIndicator from './BlockedUserIndicator.svelte'; 8 4 import { getRelativeTime } from '$lib/date'; 9 5 import { generateColorForDid } from '$lib/accounts'; 10 6 import type { Did } from '@atcute/lexicons'; 11 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 12 7 import type { calculateFollowedUserStats, Sort } from '$lib/following'; 13 - import type { AtpClient } from '$lib/at/client'; 14 - import { SvelteMap } from 'svelte/reactivity'; 15 - import { clients, getClient, router } from '$lib/state.svelte'; 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'; 16 11 17 12 interface Props { 18 13 style: string; ··· 25 20 26 21 let { style, did, stats, client, sort, currentTime }: Props = $props(); 27 22 28 - // svelte-ignore state_referenced_locally 29 - const cached = profileCache.get(did); 30 - let displayName = $state<string | undefined>(cached?.displayName); 31 - let handle = $state<string>(cached?.handle ?? 'handle.invalid'); 32 - 33 - const loadProfile = async (targetDid: Did) => { 34 - if (profileCache.has(targetDid)) { 35 - const c = profileCache.get(targetDid)!; 36 - displayName = c.displayName; 37 - handle = c.handle; 38 - } else { 39 - const existingClient = clients.get(targetDid as AtprotoDid); 40 - if (existingClient?.user?.handle) { 41 - handle = existingClient.user.handle; 42 - } else { 43 - handle = 'handle.invalid'; 44 - displayName = undefined; 45 - } 46 - } 47 - 48 - try { 49 - // Optimization: Check clients map first to avoid async overhead if possible 50 - // but we need to ensure we have the profile data, not just client existence. 51 - const userClient = await getClient(targetDid as AtprotoDid); 52 - 53 - // Check if the component has been recycled for a different user while we were awaiting 54 - if (did !== targetDid) return; 23 + const userDid = $derived(client.user?.did); 24 + const blockRel = $derived( 25 + userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false } 26 + ); 27 + const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget); 55 28 56 - let newHandle = handle; 57 - let newDisplayName = displayName; 29 + const displayName = $derived(profiles.get(did)?.displayName); 30 + const handle = $derived(handles.get(did) ?? 'loading...'); 58 31 59 - if (userClient.user?.handle) { 60 - newHandle = userClient.user.handle; 61 - handle = newHandle; 62 - } else { 63 - newHandle = targetDid; 64 - handle = newHandle; 65 - } 32 + let error = $state(''); 66 33 67 - const profileRes = await userClient.getProfile(); 34 + const loadProfile = async (targetDid: Did) => { 35 + if (profiles.has(targetDid) && handles.has(targetDid)) return; 68 36 37 + try { 38 + const [profileRes, handleRes] = await Promise.all([ 39 + client.getProfile(targetDid), 40 + resolveDidDoc(targetDid).then((r) => map(r, (doc) => doc.handle)) 41 + ]); 69 42 if (did !== targetDid) return; 70 43 71 - if (profileRes.ok) { 72 - newDisplayName = profileRes.value.displayName; 73 - displayName = newDisplayName; 74 - } 75 - 76 - // Update cache 77 - profileCache.set(targetDid, { 78 - handle: newHandle, 79 - displayName: newDisplayName 80 - }); 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'); 81 47 } catch (e) { 82 48 if (did !== targetDid) return; 83 49 console.error(`failed to load profile for ${targetDid}`, e); 84 - handle = 'error'; 50 + error = String(e); 85 51 } 86 52 }; 87 53 88 - // Re-run whenever `did` changes 89 54 $effect(() => { 90 55 loadProfile(did); 91 56 }); ··· 94 59 const relTime = $derived(getRelativeTime(lastPostAt, currentTime)); 95 60 const color = $derived(generateColorForDid(did)); 96 61 97 - const goToProfile = () => { 98 - router.navigate(`/profile/${did}`); 99 - }; 62 + const goToProfile = () => router.navigate(`/profile/${did}`); 100 63 </script> 101 64 102 65 <div {style} class="box-border w-full pb-2"> 103 - <!-- svelte-ignore a11y_click_events_have_key_events --> 104 - <!-- svelte-ignore a11y_no_static_element_interactions --> 105 - <div 106 - onclick={goToProfile} 107 - class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 108 - style={`--post-color: ${color};`} 109 - > 110 - <ProfilePicture {client} {did} size={10} /> 111 - <div class="min-w-0 flex-1 space-y-1"> 112 - <div 113 - class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 114 - style={`--post-color: ${color};`} 115 - > 116 - <span class="truncate">{displayName || handle}</span> 117 - <span class="truncate text-sm opacity-60">@{handle}</span> 118 - </div> 119 - <div class="flex gap-2 text-xs opacity-70"> 120 - <span 121 - class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 122 - ? 'text-(--nucleus-accent)' 123 - : ''} 124 - > 125 - posted {relTime} 126 - {relTime !== 'now' ? 'ago' : ''} 127 - </span> 128 - {#if stats?.recentPostCount && stats.recentPostCount > 0} 129 - <span class="text-(--nucleus-accent2)"> 130 - {stats.recentPostCount} posts / 6h 131 - </span> 66 + {#if isBlocked} 67 + <!-- svelte-ignore a11y_click_events_have_key_events --> 68 + <!-- svelte-ignore a11y_no_static_element_interactions --> 69 + <div onclick={goToProfile} class="cursor-pointer"> 70 + <BlockedUserIndicator 71 + {client} 72 + {did} 73 + reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'} 74 + size="small" 75 + /> 76 + </div> 77 + {:else} 78 + <!-- svelte-ignore a11y_click_events_have_key_events --> 79 + <!-- svelte-ignore a11y_no_static_element_interactions --> 80 + <div 81 + onclick={goToProfile} 82 + class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 83 + style={`--post-color: ${color};`} 84 + > 85 + <ProfilePicture {client} {did} size={10} /> 86 + <div class="min-w-0 flex-1 space-y-1"> 87 + {#if error.length === 0} 88 + <div 89 + class="flex items-baseline gap-2 truncate font-bold transition-colors group-hover:text-(--post-color)" 90 + style={`--post-color: ${color};`} 91 + > 92 + <span class="truncate">{displayName || handle}</span> 93 + <span class="truncate text-sm opacity-60">@{handle}</span> 94 + </div> 95 + {:else} 96 + <div class="flex items-baseline truncate text-sm text-red-500"> 97 + error: {error} 98 + </div> 132 99 {/if} 133 - {#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0} 134 - <span class="ml-auto font-bold text-(--nucleus-accent)"> 135 - โ˜… {stats.conversationalScore.toFixed(1)} 100 + <div class="flex gap-2 text-xs opacity-70"> 101 + <span 102 + class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 103 + ? 'text-(--nucleus-accent)' 104 + : ''} 105 + > 106 + posted {relTime} 107 + {relTime !== 'now' ? 'ago' : ''} 136 108 </span> 137 - {/if} 109 + {#if stats?.recentPostCount && stats.recentPostCount > 0} 110 + <span class="text-(--nucleus-accent2)"> 111 + {stats.recentPostCount} posts / 6h 112 + </span> 113 + {/if} 114 + {#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0} 115 + <span class="ml-auto font-bold text-(--nucleus-accent)"> 116 + โ˜… {stats.conversationalScore.toFixed(1)} 117 + </span> 118 + {/if} 119 + </div> 138 120 </div> 139 121 </div> 140 - </div> 122 + {/if} 141 123 </div>
+2 -2
src/components/FollowingView.svelte
··· 1 1 <script lang="ts"> 2 2 import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; 3 3 import type { Did } from '@atcute/lexicons'; 4 - import { type AtpClient } from '$lib/at/client'; 4 + import { type AtpClient } from '$lib/at/client.svelte'; 5 5 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 6 6 import { 7 7 calculateFollowedUserStats, ··· 139 139 </div> 140 140 141 141 <div class="min-h-0 flex-1" bind:this={listContainer}> 142 - {#if !client} 142 + {#if !client || !client.user} 143 143 <NotLoggedIn /> 144 144 {:else if sortedFollowing.length === 0 || isLongCalculation} 145 145 <div class="flex justify-center py-8">
+9 -10
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 $: { ··· 67 67 {@const isHidden = i > 3} 68 68 {@const isOverlay = i === 3 && images.length > 4} 69 69 70 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 70 71 <a 71 72 href={img.src} 72 - data-pswp-width={img.width} 73 - data-pswp-height={img.height} 73 + data-pswp-width={img.width ?? imageElements[i]?.width} 74 + data-pswp-height={img.height ?? imageElements[i]?.height} 74 75 target="_blank" 75 76 class:hidden-in-grid={isHidden} 76 77 class:overlay-container={isOverlay} 77 78 > 78 - <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 ?? ''} /> 79 80 80 81 {#if isOverlay} 81 82 <div class="more-overlay"> ··· 100 101 gap: 2px; 101 102 border-radius: 4px; 102 103 overflow: hidden; 103 - width: fit-content; 104 104 } 105 105 106 106 .gallery.styling-twitter > a { ··· 125 125 .gallery.styling-twitter[data-total='1'] { 126 126 display: block; /* Remove grid constraints */ 127 127 height: auto; 128 - width: fit-content; 129 128 aspect-ratio: auto; /* Remove 16:9 ratio */ 130 129 border-radius: 0; 131 130 }
+100 -28
src/components/PostComposer.svelte
··· 1 1 <script lang="ts"> 2 - import type { AtpClient } from '$lib/at/client'; 2 + import type { AtpClient } from '$lib/at/client.svelte'; 3 3 import { ok, err, type Result, expect } from '$lib/result'; 4 4 import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky'; 5 5 import { generateColorForDid } from '$lib/accounts'; ··· 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', ··· 130 158 createdAt: new Date().toISOString() 131 159 }; 132 160 133 - const res = await client.atcute?.post('com.atproto.repo.createRecord', { 161 + const res = await client.user?.atcute.post('com.atproto.repo.createRecord', { 134 162 input: { 135 163 collection: 'app.bsky.feed.post', 136 164 repo: client.user!.did, ··· 156 184 let fileInputEl: HTMLInputElement | undefined = $state(); 157 185 let selectingFile = $state(false); 158 186 159 - const unfocus = () => (_state.focus = 'null'); 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 + ); 160 194 161 - const handleFileSelect = (event: Event) => { 162 - selectingFile = false; 195 + const unfocus = () => (_state.focus = 'null'); 163 196 164 - const input = event.target as HTMLInputElement; 165 - const files = input.files; 166 - if (!files || files.length === 0) return; 197 + const handleFiles = (files: File[]) => { 198 + if (!canUpload || !files || files.length === 0) return; 167 199 168 200 const existingImages = 169 201 _state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : []; ··· 224 256 }; 225 257 } 226 258 227 - const handleUpload = (blobUrl: string, blob: Result<AtpBlob<string>, string>) => { 228 - if (blob.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: blob.value }); 229 - 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 }); 230 262 }; 231 263 232 264 const media = _state.attachedMedia; ··· 239 271 const blobUrl = (media.video as AtpBlob<string>).ref.$link; 240 272 uploadVideo(blobUrl, media.video.mimeType).then((r) => handleUpload(blobUrl, r)); 241 273 } 274 + }; 275 + 276 + const handlePaste = (e: ClipboardEvent) => { 277 + const files = Array.from(e.clipboardData?.items ?? []) 278 + .filter((item) => item.kind === 'file') 279 + .map((item) => item.getAsFile()) 280 + .filter((file): file is File => file !== null); 281 + 282 + if (files.length > 0) { 283 + e.preventDefault(); 284 + handleFiles(files); 285 + } 286 + }; 287 + 288 + const handleDrop = (e: DragEvent) => { 289 + e.preventDefault(); 290 + const files = Array.from(e.dataTransfer?.files ?? []); 291 + if (files.length > 0) handleFiles(files); 292 + }; 293 + 294 + const handleFileSelect = (e: Event) => { 295 + e.preventDefault(); 296 + selectingFile = false; 297 + 298 + const input = e.target as HTMLInputElement; 299 + if (input.files) handleFiles(Array.from(input.files)); 242 300 243 301 input.value = ''; 244 302 }; ··· 247 305 if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { 248 306 const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; 249 307 _state.blobsState.delete(blobUrl); 308 + queueMicrotask(() => URL.revokeObjectURL(blobUrl)); 250 309 } 251 310 _state.attachedMedia = undefined; 252 311 }; ··· 256 315 const imageToRemove = _state.attachedMedia.images[index]; 257 316 const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link; 258 317 _state.blobsState.delete(blobUrl); 318 + queueMicrotask(() => URL.revokeObjectURL(blobUrl)); 259 319 260 320 const images = _state.attachedMedia.images.filter((_, i) => i !== index); 261 321 _state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined; ··· 271 331 if (res.ok) { 272 332 onPostSent(res.value); 273 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 + ); 274 342 _state.attachedMedia = undefined; 275 343 _state.blobsState.clear(); 276 344 unfocus(); ··· 435 503 fileInputEl?.click(); 436 504 }} 437 505 onmousedown={(e) => e.preventDefault()} 438 - disabled={_state.attachedMedia?.$type === 'app.bsky.embed.video' || 439 - (_state.attachedMedia?.$type === 'app.bsky.embed.images' && 440 - _state.attachedMedia.images.length >= 4)} 506 + disabled={!canUpload} 441 507 class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50" 442 508 style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};" 443 509 title="attach media" ··· 487 553 {#if replying} 488 554 {@render attachedPost(replying, 'replying')} 489 555 {/if} 490 - <div class="composer space-y-2"> 556 + <!-- svelte-ignore a11y_no_static_element_interactions --> 557 + <div 558 + class="composer space-y-2" 559 + onpaste={handlePaste} 560 + ondrop={handleDrop} 561 + ondragover={(e) => e.preventDefault()} 562 + > 491 563 <div class="relative grid"> 492 564 <!-- todo: replace this with a proper rich text editor --> 493 565 <div ··· 539 611 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 540 612 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 541 613 > 542 - <div class="w-full p-1 px-2"> 543 - {#if !client.atcute} 614 + <div class="w-full p-1"> 615 + {#if !client.user} 544 616 <div 545 617 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 546 618 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" ··· 586 658 587 659 input, 588 660 .composer { 589 - @apply single-line-input bg-(--nucleus-bg)/35; 661 + @apply single-line-input rounded-xs bg-(--nucleus-bg)/35; 590 662 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); 591 663 } 592 664
+145
src/components/ProfileActions.svelte
··· 1 + <script lang="ts"> 2 + import type { AtpClient } from '$lib/at/client.svelte'; 3 + import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons'; 4 + import Dropdown from './Dropdown.svelte'; 5 + import Icon from '@iconify/svelte'; 6 + import { createBlock, deleteBlock, follows } from '$lib/state.svelte'; 7 + import { generateColorForDid } from '$lib/accounts'; 8 + import { now as tidNow } from '@atcute/tid'; 9 + import type { AppBskyGraphFollow } from '@atcute/bluesky'; 10 + import { toCanonicalUri } from '$lib'; 11 + import { SvelteMap } from 'svelte/reactivity'; 12 + 13 + interface Props { 14 + client: AtpClient; 15 + targetDid: Did; 16 + userBlocked: boolean; 17 + blockedByTarget: boolean; 18 + } 19 + 20 + let { client, targetDid, userBlocked = $bindable(), blockedByTarget }: Props = $props(); 21 + 22 + const userDid = $derived(client.user?.did); 23 + const color = $derived(generateColorForDid(targetDid)); 24 + 25 + let actionsOpen = $state(false); 26 + let actionsPos = $state({ x: 0, y: 0 }); 27 + 28 + const followsMap = $derived(userDid ? follows.get(userDid) : undefined); 29 + const follow = $derived( 30 + followsMap 31 + ? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid) 32 + : undefined 33 + ); 34 + 35 + const handleFollow = async () => { 36 + if (!userDid || !client.user) return; 37 + 38 + if (follow) { 39 + const [uri] = follow; 40 + followsMap?.delete(uri); 41 + 42 + // extract rkey from uri 43 + const parsedUri = parseCanonicalResourceUri(uri); 44 + if (!parsedUri.ok) return; 45 + const rkey = parsedUri.value.rkey; 46 + 47 + await client.user.atcute.post('com.atproto.repo.deleteRecord', { 48 + input: { 49 + repo: userDid, 50 + collection: 'app.bsky.graph.follow', 51 + rkey 52 + } 53 + }); 54 + } else { 55 + // follow 56 + const rkey = tidNow(); 57 + const record: AppBskyGraphFollow.Main = { 58 + $type: 'app.bsky.graph.follow', 59 + subject: targetDid, 60 + createdAt: new Date().toISOString() 61 + }; 62 + 63 + const uri = toCanonicalUri({ 64 + did: userDid, 65 + collection: 'app.bsky.graph.follow', 66 + rkey 67 + }); 68 + 69 + if (!followsMap) follows.set(userDid, new SvelteMap([[uri, record]])); 70 + else followsMap.set(uri, record); 71 + 72 + await client.user.atcute.post('com.atproto.repo.createRecord', { 73 + input: { 74 + repo: userDid, 75 + collection: 'app.bsky.graph.follow', 76 + rkey, 77 + record 78 + } 79 + }); 80 + } 81 + 82 + actionsOpen = false; 83 + }; 84 + 85 + const handleBlock = async () => { 86 + if (!userDid) return; 87 + 88 + if (userBlocked) { 89 + await deleteBlock(client, targetDid); 90 + userBlocked = false; 91 + } else { 92 + await createBlock(client, targetDid); 93 + userBlocked = true; 94 + } 95 + 96 + actionsOpen = false; 97 + }; 98 + </script> 99 + 100 + {#snippet dropdownItem(icon: string, label: string, onClick: () => void, disabled: boolean = false)} 101 + <button 102 + class="flex items-center justify-between rounded-sm px-2 py-1.5 transition-all duration-100 103 + {disabled ? 'cursor-not-allowed opacity-50' : 'hover:[backdrop-filter:brightness(120%)]'}" 104 + onclick={onClick} 105 + {disabled} 106 + > 107 + <span class="font-semibold opacity-85">{label}</span> 108 + <Icon class="h-6 w-6" {icon} /> 109 + </button> 110 + {/snippet} 111 + 112 + <Dropdown 113 + class="post-dropdown" 114 + style="background: {color}36; border-color: {color}99;" 115 + bind:isOpen={actionsOpen} 116 + bind:position={actionsPos} 117 + placement="bottom-end" 118 + > 119 + {#if !blockedByTarget} 120 + {@render dropdownItem( 121 + follow ? 'heroicons:user-minus-20-solid' : 'heroicons:user-plus-20-solid', 122 + follow ? 'unfollow' : 'follow', 123 + handleFollow 124 + )} 125 + {/if} 126 + {@render dropdownItem( 127 + userBlocked ? 'heroicons:eye-20-solid' : 'heroicons:eye-slash-20-solid', 128 + userBlocked ? 'unblock' : 'block', 129 + handleBlock 130 + )} 131 + 132 + {#snippet trigger()} 133 + <button 134 + class="rounded-sm p-1.5 transition-all hover:bg-white/10" 135 + onclick={(e: MouseEvent) => { 136 + e.stopPropagation(); 137 + actionsOpen = !actionsOpen; 138 + actionsPos = { x: 0, y: 0 }; 139 + }} 140 + title="profile actions" 141 + > 142 + <Icon icon="heroicons:ellipsis-horizontal-16-solid" width={24} /> 143 + </button> 144 + {/snippet} 145 + </Dropdown>
+66 -46
src/components/ProfileInfo.svelte
··· 1 1 <script lang="ts"> 2 - import { AtpClient, resolveDidDoc } from '$lib/at/client'; 2 + import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte'; 3 3 import type { Did, Handle } from '@atcute/lexicons/syntax'; 4 4 import type { AppBskyActorProfile } from '@atcute/bluesky'; 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import RichText from './RichText.svelte'; 7 7 import { onMount } from 'svelte'; 8 - import { handles, profiles } from '$lib/state.svelte'; 8 + import { getBlockRelationship, handles, profiles } from '$lib/state.svelte'; 9 + import BlockedUserIndicator from './BlockedUserIndicator.svelte'; 9 10 10 11 interface Props { 11 12 client: AtpClient; ··· 21 22 profile = $bindable(profiles.get(did) ?? null) 22 23 }: Props = $props(); 23 24 25 + const userDid = $derived(client.user?.did); 26 + const blockRel = $derived( 27 + userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false } 28 + ); 29 + const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget); 30 + 24 31 onMount(async () => { 32 + // don't load profile info if blocked 33 + if (isBlocked) return; 34 + 25 35 await Promise.all([ 26 36 (async () => { 27 37 if (profile) return; ··· 46 56 let showDid = $state(false); 47 57 </script> 48 58 49 - <div class="flex flex-col gap-2"> 50 - <div class="flex items-center gap-2"> 51 - <ProfilePicture {client} {did} size={20} /> 59 + {#if isBlocked} 60 + <BlockedUserIndicator 61 + {client} 62 + {did} 63 + reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'} 64 + size="normal" 65 + /> 66 + {:else} 67 + <div class="flex flex-col gap-2"> 68 + <div class="flex items-center gap-2"> 69 + <ProfilePicture {client} {did} size={20} /> 52 70 53 - <div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis"> 54 - <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 55 - {profileDisplayName.length > 0 ? profileDisplayName : displayHandle} 56 - {#if profile?.pronouns} 57 - <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 58 - {/if} 59 - </span> 60 - <button 61 - oncontextmenu={(e) => { 62 - e.stopPropagation(); 63 - const node = e.target as Node; 64 - const selection = window.getSelection() ?? new Selection(); 65 - const range = document.createRange(); 66 - range.selectNodeContents(node); 67 - selection.removeAllRanges(); 68 - selection.addRange(range); 69 - }} 70 - onmousedown={(e) => { 71 - // disable double clicks to disable "double click to select text" 72 - // since it doesnt work with us toggling did vs handle 73 - if (e.detail > 1) e.preventDefault(); 74 - }} 75 - onclick={() => (showDid = !showDid)} 76 - class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 77 - > 78 - {showDid ? did : `@${displayHandle}`} 79 - </button> 80 - {#if profile?.website} 81 - <a 82 - target="_blank" 83 - rel="noopener noreferrer" 84 - href={profile.website} 85 - class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a 71 + <div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis"> 72 + <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 73 + {profileDisplayName.length > 0 ? profileDisplayName : displayHandle} 74 + {#if profile?.pronouns} 75 + <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 76 + {/if} 77 + </span> 78 + <button 79 + oncontextmenu={(e) => { 80 + e.stopPropagation(); 81 + const node = e.target as Node; 82 + const selection = window.getSelection() ?? new Selection(); 83 + const range = document.createRange(); 84 + range.selectNodeContents(node); 85 + selection.removeAllRanges(); 86 + selection.addRange(range); 87 + }} 88 + onmousedown={(e) => { 89 + // disable double clicks to disable "double click to select text" 90 + // since it doesnt work with us toggling did vs handle 91 + if (e.detail > 1) e.preventDefault(); 92 + }} 93 + onclick={() => (showDid = !showDid)} 94 + class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 86 95 > 87 - {/if} 96 + {showDid ? did : `@${displayHandle}`} 97 + </button> 98 + {#if profile?.website} 99 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 100 + <a 101 + target="_blank" 102 + rel="noopener noreferrer" 103 + href={profile.website} 104 + class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a 105 + > 106 + {/if} 107 + </div> 88 108 </div> 89 - </div> 90 109 91 - {#if profileDesc.length > 0} 92 - <div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 93 - <RichText text={profileDesc} /> 94 - </div> 95 - {/if} 96 - </div> 110 + {#if profileDesc.length > 0} 111 + <div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 112 + <RichText text={profileDesc} /> 113 + </div> 114 + {/if} 115 + </div> 116 + {/if}
+1 -1
src/components/ProfilePicture.svelte
··· 1 1 <script lang="ts"> 2 2 import { generateColorForDid } from '$lib/accounts'; 3 - import type { AtpClient } from '$lib/at/client'; 3 + import type { AtpClient } from '$lib/at/client.svelte'; 4 4 import { isBlob } from '@atcute/lexicons/interfaces'; 5 5 import PfpPlaceholder from './PfpPlaceholder.svelte'; 6 6 import { img } from '$lib/cdn';
+95 -75
src/components/ProfileView.svelte
··· 1 1 <script lang="ts"> 2 - import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client'; 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'; 13 7 import Icon from '@iconify/svelte'; 14 - import { generateColorForDid } from '$lib/accounts'; 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 - import { onMount } from 'svelte'; 19 - import { handles, profiles } from '$lib/state.svelte'; 11 + import { 12 + handles, 13 + profiles, 14 + getBlockRelationship, 15 + fetchBlocked, 16 + blockFlags 17 + } from '$lib/state.svelte'; 18 + import BlockedUserIndicator from './BlockedUserIndicator.svelte'; 19 + import ProfileActions from './ProfileActions.svelte'; 20 20 21 21 interface Props { 22 22 client: AtpClient; ··· 27 27 28 28 let { client, actor, onBack, postComposerState = $bindable() }: Props = $props(); 29 29 30 - let profile = $state<AppBskyActorProfile.Main | null>(profiles.get(actor as Did) ?? null); 30 + const profile = $derived(profiles.get(actor as Did)); 31 31 const displayName = $derived(profile?.displayName ?? ''); 32 + const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did)); 32 33 let loading = $state(true); 33 34 let error = $state<string | null>(null); 34 - let did = $state<AtprotoDid | null>(null); 35 - let handle = $state<Handle | null>(handles.get(actor as Did) ?? null); 35 + let did = $state(isDid(actor) ? actor : null); 36 + 37 + let userBlocked = $state(false); 38 + let blockedByTarget = $state(false); 36 39 37 40 const loadProfile = async (identifier: ActorIdentifier) => { 38 41 loading = true; 39 42 error = null; 40 - profile = null; 41 - handle = isHandle(identifier) ? identifier : null; 42 43 43 - const resDid = await resolveHandle(identifier); 44 - if (resDid.ok) did = resDid.value; 45 - else { 46 - error = resDid.error; 47 - 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; 48 50 return; 49 51 } 50 52 51 - if (!handle) handle = handles.get(did) ?? null; 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 + } 52 68 53 - if (!handle) { 54 - const resHandle = await resolveDidDoc(did); 55 - if (resHandle.ok) { 56 - handle = resHandle.value.handle; 57 - handles.set(did, resHandle.value.handle); 58 - } 69 + // don't load profile if blocked 70 + if (userBlocked || blockedByTarget) { 71 + loading = false; 72 + return; 59 73 } 60 74 61 - const res = await client.getProfile(did); 62 - if (res.ok) { 63 - profile = res.value; 64 - profiles.set(did, res.value); 65 - } 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; 66 78 67 79 loading = false; 68 80 }; 69 81 70 - onMount(async () => { 71 - await loadProfile(actor as ActorIdentifier); 82 + $effect(() => { 83 + // if we have accounts, wait until we are logged in to load the profile 84 + if (!($accounts.length > 0 && !client.user?.did)) loadProfile(actor as ActorIdentifier); 72 85 }); 73 86 74 - const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-fg)'); 87 + const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-accent)'); 75 88 const bannerUrl = $derived( 76 89 did && profile && isBlob(profile.banner) 77 90 ? img('feed_fullsize', did, profile.banner.ref.$link) ··· 82 95 <div class="flex min-h-dvh flex-col"> 83 96 <!-- header --> 84 97 <div 85 - class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-4 backdrop-blur-md" 86 - style="border-color: {color}40;" 98 + class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-2 backdrop-blur-md" 99 + style="border-color: {color};" 87 100 > 88 101 <button 89 102 onclick={onBack} 90 - class="rounded-full p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10" 103 + class="rounded-sm p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10" 91 104 > 92 105 <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 93 106 </button> 94 107 <h2 class="text-xl font-bold"> 95 - {displayName.length > 0 96 - ? displayName 97 - : loading 98 - ? 'loading...' 99 - : (handle ?? actor ?? 'profile')} 108 + {displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')} 100 109 </h2> 110 + <div class="grow"></div> 111 + {#if did && client.user && client.user.did !== did} 112 + <ProfileActions {client} targetDid={did} bind:userBlocked {blockedByTarget} /> 113 + {/if} 101 114 </div> 102 115 103 - {#if error} 104 - <div class="p-8 text-center text-red-500"> 105 - <p>failed to load profile: {error}</p> 106 - </div> 107 - {:else} 108 - <!-- banner --> 109 - <div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48"> 116 + {#if !loading} 117 + {#if error} 118 + <div class="p-8 text-center text-red-500"> 119 + <p>failed to load profile: {error}</p> 120 + </div> 121 + {:else if userBlocked || blockedByTarget} 122 + <div class="p-8"> 123 + <BlockedUserIndicator 124 + {client} 125 + did={did!} 126 + reason={userBlocked ? 'blocked' : 'blocks-you'} 127 + size="large" 128 + /> 129 + </div> 130 + {:else} 131 + <!-- banner --> 110 132 {#if bannerUrl} 111 - <img src={bannerUrl} alt="banner" class="h-full w-full object-cover" /> 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> 112 140 {/if} 113 - <div 114 - class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)" 115 - style="opacity: 0.8;" 116 - ></div> 117 - </div> 118 - 119 - <div class="px-4 pb-4"> 120 - <div class="relative z-10 -mt-12 mb-4"> 121 - {#if did} 122 - <ProfileInfo {client} {did} bind:profile /> 123 - {/if} 124 - </div> 125 - 126 - <div class="my-4 h-px bg-white/10"></div> 127 141 128 142 {#if did} 129 - <TimelineView 130 - showReplies={false} 131 - {client} 132 - targetDid={did} 133 - bind:postComposerState 134 - class="min-h-[50vh]" 135 - /> 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 149 + showReplies={false} 150 + {client} 151 + targetDid={did} 152 + bind:postComposerState 153 + class="min-h-[50vh]" 154 + /> 155 + </div> 136 156 {/if} 137 - </div> 157 + {/if} 138 158 {/if} 139 159 </div>
+1
src/components/RichText.svelte
··· 37 37 {@const { text, features: _features } = segment} 38 38 {@const features = _features ?? []} 39 39 {#if features.length > 0} 40 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 40 41 {#each features as feature, idx (idx)} 41 42 {#if feature.$type === 'app.bsky.richtext.facet#mention'} 42 43 <a
+41 -16
src/components/TimelineView.svelte
··· 1 1 <script lang="ts"> 2 2 import BskyPost from './BskyPost.svelte'; 3 3 import { type State as PostComposerState } from './PostComposer.svelte'; 4 - import { AtpClient } from '$lib/at/client'; 4 + import { AtpClient } from '$lib/at/client.svelte'; 5 5 import { accounts } from '$lib/accounts'; 6 6 import { type ResourceUri } from '@atcute/lexicons'; 7 7 import { SvelteSet } from 'svelte/reactivity'; ··· 11 11 fetchTimeline, 12 12 allPosts, 13 13 timelines, 14 - fetchInteractionsUntil 14 + fetchInteractionsToTimelineEnd 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 ··· 39 39 let viewOwnPosts = $state(true); 40 40 const expandedThreads = new SvelteSet<ResourceUri>(); 41 41 42 - const did = $derived(targetDid ?? client?.user?.did); 42 + const userDid = $derived(client?.user?.did); 43 + const did = $derived(targetDid ?? userDid); 43 44 44 45 const threads = $derived( 45 46 // todo: apply showReplies here ··· 53 54 const loaderState = new LoaderState(); 54 55 let scrollContainer = $state<HTMLDivElement>(); 55 56 let loading = $state(false); 56 - let fetchMoreInteractions: boolean | undefined = $state(false); 57 57 let loadError = $state(''); 58 58 59 59 const loadMore = async () => { ··· 63 63 loaderState.status = 'LOADING'; 64 64 65 65 try { 66 - await fetchTimeline(did as AtprotoDid, 7, showReplies); 67 - // interaction fetching is done lazily so we dont block loading posts 68 - fetchMoreInteractions = true; 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 + } 79 + } 69 80 loaderState.loaded(); 70 81 } catch (error) { 71 82 loadError = `${error}`; ··· 75 86 } 76 87 77 88 loading = false; 78 - const cursor = postCursors.get(did as AtprotoDid); 89 + const cursor = postCursors.get(did); 79 90 if (cursor && cursor.end) loaderState.complete(); 80 91 }; 81 92 82 93 $effect(() => { 83 - if (threads.length === 0 && !loading && did) { 94 + if (threads.length === 0 && !loading && userDid && did) { 84 95 // if we saw all posts dont try to load more. 85 96 // this only really happens if the user has no posts at all 86 97 // but we do have to handle it to not cause an infinite loop 87 - const cursor = did ? postCursors.get(did as AtprotoDid) : undefined; 98 + const cursor = did ? postCursors.get(did) : undefined; 88 99 if (!cursor?.end) loadMore(); 89 100 } 90 - if (client && did && fetchMoreInteractions) { 91 - // set to false so it doesnt attempt to fetch again while its already fetching 92 - fetchMoreInteractions = false; 93 - fetchInteractionsUntil(client, did).then(() => (fetchMoreInteractions = undefined)); 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 + } 94 119 } 95 120 }); 96 121 </script>
+506
src/lib/at/client.svelte.ts
··· 1 + /* eslint-disable svelte/prefer-svelte-reactivity */ 2 + import { err, expect, map, ok, type OkType, type Result } from '$lib/result'; 3 + import { 4 + ComAtprotoIdentityResolveHandle, 5 + ComAtprotoRepoGetRecord, 6 + ComAtprotoRepoListRecords 7 + } from '@atcute/atproto'; 8 + import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client'; 9 + import { safeParse, type Blob as AtpBlob, type Handle, type InferOutput } from '@atcute/lexicons'; 10 + import { 11 + isDid, 12 + parseResourceUri, 13 + type ActorIdentifier, 14 + type AtprotoDid, 15 + type Cid, 16 + type Did, 17 + type Nsid, 18 + type RecordKey, 19 + type ResourceUri 20 + } from '@atcute/lexicons/syntax'; 21 + import type { 22 + InferInput, 23 + InferXRPCBodyOutput, 24 + ObjectSchema, 25 + RecordKeySchema, 26 + RecordSchema, 27 + XRPCQueryMetadata 28 + } from '@atcute/lexicons/validations'; 29 + import * as v from '@atcute/lexicons/validations'; 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'; 37 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 38 + import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib'; 39 + import { constellationUrl, httpToDidWeb, slingshotUrl, spacedustUrl } from '.'; 40 + 41 + export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 42 + 43 + const cacheWithHandles = rawCache.define( 44 + 'resolveHandle', 45 + async (handle: Handle): Promise<AtprotoDid> => { 46 + const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 47 + handle 48 + }); 49 + if (!res.ok) throw new Error(res.error); 50 + return res.value.did as AtprotoDid; 51 + } 52 + ); 53 + 54 + const cacheWithDidDocs = cacheWithHandles.define( 55 + 'resolveDidDoc', 56 + async (identifier: ActorIdentifier): Promise<MiniDoc> => { 57 + const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, { 58 + identifier 59 + }); 60 + if (!res.ok) throw new Error(res.error); 61 + return res.value; 62 + } 63 + ); 64 + 65 + const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => { 66 + const parsedUri = parseResourceUri(uri); 67 + if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`); 68 + const { repo, collection, rkey } = parsedUri.value; 69 + const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 70 + repo, 71 + collection: collection!, 72 + rkey: rkey! 73 + }); 74 + if (!res.ok) throw new Error(res.error); 75 + return res.value; 76 + }); 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, 90 + headers: Record<string, string> = {}, 91 + onProgress?: (uploaded: number, total: number) => void 92 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 93 + ): Promise<Result<any, { error: string; message: string }>> => { 94 + return new Promise((resolve) => { 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 + 105 + xhr.onload = () => { 106 + if (xhr.status >= 200 && xhr.status < 300) resolve(ok(JSON.parse(xhr.responseText))); 107 + else resolve(err(JSON.parse(xhr.responseText))); 108 + }; 109 + 110 + xhr.onerror = () => resolve(err({ error: 'xhr_error', message: 'network error' })); 111 + xhr.onabort = () => resolve(err({ error: 'xhr_error', message: 'upload aborted' })); 112 + xhr.send(body); 113 + }); 114 + }; 115 + 116 + export type UploadStatus = 117 + | { stage: 'auth' } 118 + | { stage: 'uploading'; progress?: number } 119 + | { stage: 'processing'; progress?: number } 120 + | { stage: 'complete' }; 121 + 122 + export type Auth = { 123 + atcute: AtcuteClient; 124 + } & MiniDoc; 125 + 126 + export class AtpClient { 127 + public user: Auth | null = $state(null); 128 + 129 + async login(agent: OAuthUserAgent): Promise<Result<null, string>> { 130 + try { 131 + const rpc = new AtcuteClient({ handler: agent }); 132 + const res = await rpc.get('com.atproto.server.getSession'); 133 + if (!res.ok) throw res.data.error; 134 + this.user = { 135 + atcute: rpc, 136 + did: res.data.did, 137 + handle: res.data.handle, 138 + pds: agent.session.info.aud as `${string}:${string}`, 139 + signing_key: '' 140 + }; 141 + } catch (error) { 142 + return err(`failed to login: ${error}`); 143 + } 144 + 145 + return ok(null); 146 + } 147 + 148 + async getRecordUri< 149 + Collection extends Nsid, 150 + TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 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< 168 + Collection extends Nsid, 169 + TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 170 + TKey extends RecordKeySchema, 171 + Schema extends RecordSchema<TObject, TKey>, 172 + Output extends InferInput<Schema> 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); 188 + 189 + return ok({ 190 + uri: rawValue.uri, 191 + cid: rawValue.cid, 192 + record: parsed.value as Output 193 + }); 194 + } catch (e) { 195 + return err(String(e)); 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>( 212 + ident: ActorIdentifier, 213 + collection: Collection, 214 + cursor?: string, 215 + limit: number = 100 216 + ): Promise< 217 + Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 218 + > { 219 + const auth = this.user; 220 + if (!auth) return err('not authenticated'); 221 + const docRes = await resolveDidDoc(ident); 222 + if (!docRes.ok) return docRes; 223 + const atp = 224 + auth.did === docRes.value.did 225 + ? auth.atcute 226 + : new AtcuteClient({ handler: simpleFetchHandler({ service: docRes.value.pds }) }); 227 + const res = await atp.get('com.atproto.repo.listRecords', { 228 + params: { 229 + repo: docRes.value.did, 230 + collection, 231 + cursor, 232 + limit, 233 + reverse: false 234 + } 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 + } 242 + 243 + async listRecordsUntil<Collection extends keyof Records>( 244 + ident: ActorIdentifier, 245 + collection: Collection, 246 + cursor?: string, 247 + timestamp: number = -1 248 + ): Promise<ReturnType<typeof this.listRecords>> { 249 + const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = { 250 + records: [], 251 + cursor 252 + }; 253 + 254 + let end = false; 255 + while (!end) { 256 + const res = await this.listRecords(ident, collection, data.cursor); 257 + if (!res.ok) return res; 258 + data.cursor = res.value.cursor; 259 + data.records.push(...res.value.records); 260 + end = data.records.length === 0 || !data.cursor; 261 + if (!end && timestamp > 0) { 262 + const cursorTimestamp = timestampFromCursor(data.cursor); 263 + if (cursorTimestamp === undefined) { 264 + console.warn( 265 + 'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:', 266 + data.cursor 267 + ); 268 + end = true; 269 + } else if (cursorTimestamp <= timestamp) { 270 + end = true; 271 + } else { 272 + console.info( 273 + `${ident}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}` 274 + ); 275 + } 276 + } 277 + } 278 + 279 + return ok(data); 280 + } 281 + 282 + async getBacklinks( 283 + subject: ResourceUri, 284 + source: BacklinksSource, 285 + filterBy?: Did[], 286 + limit?: number 287 + ): Promise<Result<Backlinks, string>> { 288 + const { repo, collection, rkey } = expect(parseResourceUri(subject)); 289 + const did = await resolveHandle(repo); 290 + if (!did.ok) return err(`cant resolve handle: ${did.error}`); 291 + 292 + const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)); 293 + const query = fetchMicrocosm(constellationUrl, BacklinksQuery, { 294 + subject: collection ? toCanonicalUri({ did: did.value, collection, rkey: rkey! }) : did.value, 295 + source, 296 + limit: limit || 100, 297 + did: filterBy 298 + }); 299 + 300 + const results = await Promise.race([query, timeout]); 301 + if (!results) return err('cant fetch backlinks: timeout'); 302 + 303 + return results; 304 + } 305 + 306 + async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> { 307 + const auth = this.user; 308 + if (!auth) return err('not authenticated'); 309 + const serviceAuthUrl = new URL(`${auth.pds}xrpc/com.atproto.server.getServiceAuth`); 310 + serviceAuthUrl.searchParams.append('aud', httpToDidWeb(auth.pds)); 311 + serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob'); 312 + serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes 313 + 314 + const serviceAuthResponse = await auth.atcute.handler( 315 + `${serviceAuthUrl.pathname}${serviceAuthUrl.search}`, 316 + { 317 + method: 'GET' 318 + } 319 + ); 320 + if (!serviceAuthResponse.ok) { 321 + const error = await serviceAuthResponse.text(); 322 + return err(`failed to get service auth: ${error}`); 323 + } 324 + const serviceAuth = await serviceAuthResponse.json(); 325 + return ok(serviceAuth.token); 326 + } 327 + 328 + async uploadBlob( 329 + blob: Blob, 330 + onProgress?: (progress: number) => void 331 + ): Promise<Result<AtpBlob<string>, string>> { 332 + const auth = this.user; 333 + if (!auth) return err('not authenticated'); 334 + const tokenResult = await this.getServiceAuth( 335 + 'com.atproto.repo.uploadBlob', 336 + Math.floor(Date.now() / 1000) + 60 337 + ); 338 + if (!tokenResult.ok) return tokenResult; 339 + const result = await xhrPost( 340 + `${auth.pds}xrpc/com.atproto.repo.uploadBlob`, 341 + blob, 342 + { authorization: `Bearer ${tokenResult.value}` }, 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( 350 + blob: Blob, 351 + mimeType: string, 352 + onStatus?: (status: UploadStatus) => void 353 + ): Promise<Result<AtpBlob<string>, string>> { 354 + const auth = this.user; 355 + if (!auth) return err('not authenticated'); 356 + 357 + onStatus?.({ stage: 'auth' }); 358 + const tokenResult = await this.getServiceAuth( 359 + 'com.atproto.repo.uploadBlob', 360 + Math.floor(Date.now() / 1000) + 60 * 30 361 + ); 362 + if (!tokenResult.ok) return tokenResult; 363 + 364 + onStatus?.({ stage: 'uploading' }); 365 + const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); 366 + uploadUrl.searchParams.append('did', auth.did); 367 + uploadUrl.searchParams.append('name', 'video'); 368 + 369 + const uploadResult = await xhrPost( 370 + uploadUrl.toString(), 371 + blob, 372 + { 373 + Authorization: `Bearer ${tokenResult.value}`, 374 + 'Content-Type': mimeType 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 + 382 + onStatus?.({ stage: 'processing' }); 383 + while (!videoBlobRef) { 384 + await new Promise((resolve) => setTimeout(resolve, 1000)); 385 + 386 + const statusResponse = await fetch( 387 + `https://video.bsky.app/xrpc/app.bsky.video.getJobStatus?jobId=${jobStatus.jobId}` 388 + ); 389 + 390 + if (!statusResponse.ok) { 391 + const error = await statusResponse.json(); 392 + // reuse blob 393 + if (error.error === 'already_exists' && error.blob) { 394 + videoBlobRef = error.blob; 395 + break; 396 + } 397 + return err(`failed to get job status: ${error.message || error.error}`); 398 + } 399 + 400 + const status = await statusResponse.json(); 401 + if (status.jobStatus.blob) { 402 + videoBlobRef = status.jobStatus.blob; 403 + } else if (status.jobStatus.state === 'JOB_STATE_FAILED') { 404 + return err(`video processing failed: ${status.jobStatus.error || 'unknown error'}`); 405 + } else if (status.jobStatus.progress !== undefined) { 406 + onStatus?.({ 407 + stage: 'processing', 408 + progress: status.jobStatus.progress / 100 409 + }); 410 + } 411 + } 412 + 413 + onStatus?.({ stage: 'complete' }); 414 + return ok(videoBlobRef); 415 + } 416 + } 417 + 418 + // export const newPublicClient = async (ident: ActorIdentifier) => { 419 + // const atp = new AtpClient(); 420 + // const didDoc = await resolveDidDoc(ident); 421 + // if (!didDoc.ok) { 422 + // console.error('failed to resolve did doc', didDoc.error); 423 + // return atp; 424 + // } 425 + // atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) }); 426 + // atp.user = didDoc.value; 427 + // return atp; 428 + // }; 429 + 430 + export const resolveHandle = (identifier: ActorIdentifier) => { 431 + if (isDid(identifier)) return Promise.resolve(ok(identifier as AtprotoDid)); 432 + 433 + return cache 434 + .resolveHandle(identifier) 435 + .then((did) => ok(did)) 436 + .catch((e) => err(String(e))); 437 + }; 438 + 439 + export const resolveDidDoc = (ident: ActorIdentifier) => 440 + cache 441 + .resolveDidDoc(ident) 442 + .then((doc) => ok(doc)) 443 + .catch((e) => err(String(e))); 444 + 445 + type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 446 + export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 447 + export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 448 + 449 + export const streamNotifications = ( 450 + subjects: Did[], 451 + ...sources: BacklinksSource[] 452 + ): NotificationsStream => { 453 + const url = new URL(spacedustUrl); 454 + url.protocol = 'wss:'; 455 + url.pathname = '/subscribe'; 456 + const searchParams = []; 457 + sources.every((source) => searchParams.push(['wantedSources', source])); 458 + subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject])); 459 + subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`])); 460 + searchParams.push(['instant', 'true']); 461 + url.search = `?${new URLSearchParams(searchParams)}`; 462 + // console.log(`streaming notifications: ${url}`); 463 + const encoder = WebSocket.getDefaultEncoder<undefined, Notification>(); 464 + const ws = new WebSocket<typeof encoder>(url.toString(), { 465 + encoder 466 + }); 467 + return ws; 468 + }; 469 + 470 + const fetchMicrocosm = async < 471 + Schema extends XRPCQueryMetadata, 472 + Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined, 473 + Output extends InferXRPCBodyOutput<Schema['output']> 474 + >( 475 + api: URL, 476 + schema: Schema, 477 + params: Input, 478 + init?: RequestInit 479 + ): Promise<Result<Output, string>> => { 480 + if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); 481 + api.pathname = `/xrpc/${schema.nsid}`; 482 + api.search = params 483 + ? `?${new URLSearchParams(Object.entries(params).flatMap(([k, v]) => (v === undefined ? [] : [[k, String(v)]])))}` 484 + : ''; 485 + try { 486 + const body = await fetchJson(api, init); 487 + if (!body.ok) return err(body.error); 488 + const parsed = safeParse(schema.output.schema, body.value); 489 + if (!parsed.ok) return err(parsed.message); 490 + return ok(parsed.value as Output); 491 + } catch (error) { 492 + return err(`FetchError: ${error}`); 493 + } 494 + }; 495 + 496 + const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => { 497 + try { 498 + const response = await fetch(url, init); 499 + const body = await response.json(); 500 + if (response.status === 400) return err(`${body.error}: ${body.message}`); 501 + if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`); 502 + return ok(body); 503 + } catch (error) { 504 + return err(`FetchError: ${error}`); 505 + } 506 + };
-510
src/lib/at/client.ts
··· 1 - import { err, expect, map, ok, type OkType, type Result } from '$lib/result'; 2 - import { 3 - ComAtprotoIdentityResolveHandle, 4 - ComAtprotoRepoGetRecord, 5 - ComAtprotoRepoListRecords 6 - } from '@atcute/atproto'; 7 - import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client'; 8 - import { safeParse, type Blob as AtpBlob, type Handle, type InferOutput } from '@atcute/lexicons'; 9 - import { 10 - isDid, 11 - parseCanonicalResourceUri, 12 - parseResourceUri, 13 - type ActorIdentifier, 14 - type AtprotoDid, 15 - type Cid, 16 - type Did, 17 - type Nsid, 18 - type RecordKey, 19 - type ResourceUri 20 - } from '@atcute/lexicons/syntax'; 21 - import type { 22 - InferInput, 23 - InferXRPCBodyOutput, 24 - ObjectSchema, 25 - RecordKeySchema, 26 - RecordSchema, 27 - XRPCQueryMetadata 28 - } from '@atcute/lexicons/validations'; 29 - import * as v from '@atcute/lexicons/validations'; 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'; 37 - import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 38 - import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib'; 39 - import { constellationUrl, slingshotUrl, spacedustUrl } from '.'; 40 - 41 - export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 42 - 43 - const cacheWithHandles = rawCache.define( 44 - 'resolveHandle', 45 - async (handle: Handle): Promise<AtprotoDid> => { 46 - const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 47 - handle 48 - }); 49 - if (!res.ok) throw new Error(res.error); 50 - return res.value.did as AtprotoDid; 51 - } 52 - ); 53 - 54 - const cacheWithDidDocs = cacheWithHandles.define( 55 - 'resolveDidDoc', 56 - async (identifier: ActorIdentifier): Promise<MiniDoc> => { 57 - const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, { 58 - identifier 59 - }); 60 - if (!res.ok) throw new Error(res.error); 61 - return res.value; 62 - } 63 - ); 64 - 65 - const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => { 66 - const parsedUri = parseResourceUri(uri); 67 - if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`); 68 - const { repo, collection, rkey } = parsedUri.value; 69 - const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 70 - repo, 71 - collection: collection!, 72 - rkey: rkey! 73 - }); 74 - if (!res.ok) throw new Error(res.error); 75 - return res.value; 76 - }); 77 - 78 - const cache = cacheWithRecords; 79 - 80 - export const xhrPost = ( 81 - url: string, 82 - body: Blob | File, 83 - headers: Record<string, string> = {}, 84 - onProgress?: (uploaded: number, total: number) => void 85 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 86 - ): Promise<Result<any, { error: string; message: string }>> => { 87 - return new Promise((resolve) => { 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) { 94 - onProgress(event.loaded, event.total); 95 - } 96 - }; 97 - } 98 - 99 - Object.keys(headers).forEach((key) => { 100 - xhr.setRequestHeader(key, headers[key]); 101 - }); 102 - 103 - xhr.onload = () => { 104 - if (xhr.status >= 200 && xhr.status < 300) { 105 - resolve(ok(JSON.parse(xhr.responseText))); 106 - } else { 107 - resolve(err(JSON.parse(xhr.responseText))); 108 - } 109 - }; 110 - 111 - xhr.onerror = () => { 112 - resolve(err({ error: 'xhr_error', message: 'network error' })); 113 - }; 114 - 115 - xhr.onabort = () => { 116 - resolve(err({ error: 'xhr_error', message: 'upload aborted' })); 117 - }; 118 - 119 - xhr.send(body); 120 - }); 121 - }; 122 - 123 - export type UploadStatus = 124 - | { stage: 'auth' } 125 - | { stage: 'uploading'; progress?: number } 126 - | { stage: 'processing'; progress?: number } 127 - | { stage: 'complete' }; 128 - 129 - export class AtpClient { 130 - public atcute: AtcuteClient | null = null; 131 - public user: MiniDoc | null = null; 132 - 133 - async login(agent: OAuthUserAgent): Promise<Result<null, string>> { 134 - try { 135 - const rpc = new AtcuteClient({ handler: agent }); 136 - const res = await rpc.get('com.atproto.server.getSession'); 137 - if (!res.ok) throw res.data.error; 138 - this.user = { 139 - did: res.data.did, 140 - handle: res.data.handle, 141 - pds: agent.session.info.aud as `${string}:${string}`, 142 - signing_key: '' 143 - }; 144 - this.atcute = rpc; 145 - } catch (error) { 146 - return err(`failed to login: ${error}`); 147 - } 148 - 149 - return ok(null); 150 - } 151 - 152 - async getRecordUri< 153 - Collection extends Nsid, 154 - TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 155 - TKey extends RecordKeySchema, 156 - Schema extends RecordSchema<TObject, TKey>, 157 - Output extends InferInput<Schema> 158 - >(schema: Schema, uri: ResourceUri): 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!); 165 - } 166 - 167 - async getRecord< 168 - Collection extends Nsid, 169 - TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 170 - TKey extends RecordKeySchema, 171 - Schema extends RecordSchema<TObject, TKey>, 172 - Output extends InferInput<Schema> 173 - >( 174 - schema: Schema, 175 - repo: ActorIdentifier, 176 - rkey: RecordKey 177 - ): Promise<Result<RecordOutput<Output>, string>> { 178 - const collection = schema.object.shape.$type.expected; 179 - 180 - try { 181 - const rawValue = await cache.fetchRecord( 182 - toResourceUri({ repo, collection, rkey, fragment: undefined }) 183 - ); 184 - 185 - const parsed = safeParse(schema, rawValue.value); 186 - if (!parsed.ok) return err(parsed.message); 187 - 188 - return ok({ 189 - uri: rawValue.uri, 190 - cid: rawValue.cid, 191 - record: parsed.value as Output 192 - }); 193 - } catch (e) { 194 - return err(String(e)); 195 - } 196 - } 197 - 198 - async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { 199 - repo = repo ?? this.user?.did; 200 - if (!repo) return err('not authenticated'); 201 - return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); 202 - } 203 - 204 - async listRecords<Collection extends keyof Records>( 205 - collection: Collection, 206 - cursor?: string, 207 - limit: number = 100 208 - ): Promise< 209 - Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 210 - > { 211 - if (!this.atcute || !this.user) return err('not authenticated'); 212 - const res = await this.atcute.get('com.atproto.repo.listRecords', { 213 - params: { 214 - repo: this.user.did, 215 - collection, 216 - cursor, 217 - limit, 218 - reverse: false 219 - } 220 - }); 221 - if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 222 - 223 - for (const record of res.data.records) 224 - await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 225 - 226 - return ok(res.data); 227 - } 228 - 229 - async listRecordsUntil<Collection extends keyof Records>( 230 - collection: Collection, 231 - cursor?: string, 232 - timestamp: number = -1 233 - ): Promise<ReturnType<typeof this.listRecords>> { 234 - const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = { 235 - records: [], 236 - cursor 237 - }; 238 - 239 - let end = false; 240 - while (!end) { 241 - const res = await this.listRecords(collection, data.cursor); 242 - if (!res.ok) return res; 243 - data.cursor = res.value.cursor; 244 - data.records.push(...res.value.records); 245 - end = data.records.length === 0 || !data.cursor; 246 - if (!end && timestamp > 0) { 247 - const cursorTimestamp = timestampFromCursor(data.cursor); 248 - if (cursorTimestamp === undefined) { 249 - console.warn( 250 - 'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:', 251 - data.cursor 252 - ); 253 - end = true; 254 - } else if (cursorTimestamp <= timestamp) { 255 - end = true; 256 - } else { 257 - console.info( 258 - `${this.user?.did}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}` 259 - ); 260 - } 261 - } 262 - } 263 - 264 - return ok(data); 265 - } 266 - 267 - async getBacklinksUri( 268 - uri: ResourceUri, 269 - source: BacklinksSource 270 - ): Promise<Result<Backlinks, string>> { 271 - const parsedResourceUri = expect(parseCanonicalResourceUri(uri)); 272 - return await this.getBacklinks( 273 - parsedResourceUri.repo, 274 - parsedResourceUri.collection, 275 - parsedResourceUri.rkey, 276 - source 277 - ); 278 - } 279 - 280 - async getBacklinks( 281 - repo: ActorIdentifier, 282 - collection: Nsid, 283 - rkey: RecordKey, 284 - source: BacklinksSource, 285 - limit?: number 286 - ): Promise<Result<Backlinks, string>> { 287 - const did = await resolveHandle(repo); 288 - if (!did.ok) return err(`cant resolve handle: ${did.error}`); 289 - 290 - const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)); 291 - const query = fetchMicrocosm(constellationUrl, BacklinksQuery, { 292 - subject: toCanonicalUri({ did: did.value, collection, rkey }), 293 - source, 294 - limit: limit || 100 295 - }); 296 - 297 - const results = await Promise.race([query, timeout]); 298 - if (!results) return err('cant fetch backlinks: timeout'); 299 - 300 - return results; 301 - } 302 - 303 - async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> { 304 - if (!this.atcute || !this.user) return err('not authenticated'); 305 - const serviceAuthUrl = new URL(`${this.user.pds}xrpc/com.atproto.server.getServiceAuth`); 306 - serviceAuthUrl.searchParams.append( 307 - 'aud', 308 - this.user.pds.replace('https://', 'did:web:').slice(0, -1) 309 - ); 310 - serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob'); 311 - serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes 312 - 313 - const serviceAuthResponse = await this.atcute.handler( 314 - `${serviceAuthUrl.pathname}${serviceAuthUrl.search}`, 315 - { 316 - method: 'GET' 317 - } 318 - ); 319 - if (!serviceAuthResponse.ok) { 320 - const error = await serviceAuthResponse.text(); 321 - return err(`failed to get service auth: ${error}`); 322 - } 323 - const serviceAuth = await serviceAuthResponse.json(); 324 - return ok(serviceAuth.token); 325 - } 326 - 327 - async uploadBlob( 328 - blob: Blob, 329 - onProgress?: (progress: number) => void 330 - ): Promise<Result<AtpBlob<string>, string>> { 331 - if (!this.atcute || !this.user) return err('not authenticated'); 332 - const tokenResult = await this.getServiceAuth( 333 - 'com.atproto.repo.uploadBlob', 334 - Math.floor(Date.now() / 1000) + 60 335 - ); 336 - if (!tokenResult.ok) return tokenResult; 337 - const result = await xhrPost( 338 - `${this.user.pds}xrpc/com.atproto.repo.uploadBlob`, 339 - blob, 340 - { authorization: `Bearer ${tokenResult.value}` }, 341 - (uploaded, total) => onProgress?.(uploaded / total) 342 - ); 343 - if (!result.ok) return err(`upload failed: ${result.error.message}`); 344 - return ok(result.value); 345 - } 346 - 347 - async uploadVideo( 348 - blob: Blob, 349 - mimeType: string, 350 - onStatus?: (status: UploadStatus) => void 351 - ): Promise<Result<AtpBlob<string>, string>> { 352 - if (!this.atcute || !this.user) return err('not authenticated'); 353 - 354 - onStatus?.({ stage: 'auth' }); 355 - const tokenResult = await this.getServiceAuth( 356 - 'com.atproto.repo.uploadBlob', 357 - Math.floor(Date.now() / 1000) + 60 * 30 358 - ); 359 - if (!tokenResult.ok) return tokenResult; 360 - 361 - onStatus?.({ stage: 'uploading' }); 362 - const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); 363 - uploadUrl.searchParams.append('did', this.user.did); 364 - uploadUrl.searchParams.append('name', 'video'); 365 - 366 - const uploadResult = await xhrPost( 367 - uploadUrl.toString(), 368 - blob, 369 - { 370 - Authorization: `Bearer ${tokenResult.value}`, 371 - 'Content-Type': mimeType 372 - }, 373 - (uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total }) 374 - ); 375 - if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error.message}`); 376 - const jobStatus = uploadResult.value; 377 - let videoBlobRef: AtpBlob<string> = jobStatus.blob; 378 - 379 - onStatus?.({ stage: 'processing' }); 380 - while (!videoBlobRef) { 381 - await new Promise((resolve) => setTimeout(resolve, 1000)); 382 - 383 - const statusResponse = await fetch( 384 - `https://video.bsky.app/xrpc/app.bsky.video.getJobStatus?jobId=${jobStatus.jobId}` 385 - ); 386 - 387 - if (!statusResponse.ok) { 388 - const error = await statusResponse.json(); 389 - // reuse blob 390 - if (error.error === 'already_exists' && error.blob) { 391 - videoBlobRef = error.blob; 392 - break; 393 - } 394 - return err(`failed to get job status: ${error.message || error.error}`); 395 - } 396 - 397 - const status = await statusResponse.json(); 398 - if (status.jobStatus.blob) { 399 - videoBlobRef = status.jobStatus.blob; 400 - } else if (status.jobStatus.state === 'JOB_STATE_FAILED') { 401 - return err(`video processing failed: ${status.jobStatus.error || 'unknown error'}`); 402 - } else if (status.jobStatus.progress !== undefined) { 403 - onStatus?.({ 404 - stage: 'processing', 405 - progress: status.jobStatus.progress / 100 406 - }); 407 - } 408 - } 409 - 410 - onStatus?.({ stage: 'complete' }); 411 - return ok(videoBlobRef); 412 - } 413 - } 414 - 415 - export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => { 416 - const atp = new AtpClient(); 417 - const didDoc = await resolveDidDoc(ident); 418 - if (!didDoc.ok) { 419 - console.error('failed to resolve did doc', didDoc.error); 420 - return atp; 421 - } 422 - atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) }); 423 - atp.user = didDoc.value; 424 - return atp; 425 - }; 426 - 427 - // Wrappers that use the cache 428 - 429 - export const resolveHandle = async ( 430 - identifier: ActorIdentifier 431 - ): Promise<Result<AtprotoDid, string>> => { 432 - if (isDid(identifier)) return ok(identifier as AtprotoDid); 433 - 434 - try { 435 - const did = await cache.resolveHandle(identifier); 436 - return ok(did); 437 - } catch (e) { 438 - return err(String(e)); 439 - } 440 - }; 441 - 442 - export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => { 443 - try { 444 - const doc = await cache.resolveDidDoc(ident); 445 - return ok(doc); 446 - } catch (e) { 447 - return err(String(e)); 448 - } 449 - }; 450 - 451 - type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 452 - export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 453 - export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 454 - 455 - export const streamNotifications = ( 456 - subjects: Did[], 457 - ...sources: BacklinksSource[] 458 - ): NotificationsStream => { 459 - const url = new URL(spacedustUrl); 460 - url.protocol = 'wss:'; 461 - url.pathname = '/subscribe'; 462 - const searchParams = []; 463 - sources.every((source) => searchParams.push(['wantedSources', source])); 464 - subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject])); 465 - subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`])); 466 - searchParams.push(['instant', 'true']); 467 - url.search = `?${new URLSearchParams(searchParams)}`; 468 - // console.log(`streaming notifications: ${url}`); 469 - const encoder = WebSocket.getDefaultEncoder<undefined, Notification>(); 470 - const ws = new WebSocket<typeof encoder>(url.toString(), { 471 - encoder 472 - }); 473 - return ws; 474 - }; 475 - 476 - const fetchMicrocosm = async < 477 - Schema extends XRPCQueryMetadata, 478 - Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined, 479 - Output extends InferXRPCBodyOutput<Schema['output']> 480 - >( 481 - api: URL, 482 - schema: Schema, 483 - params: Input, 484 - init?: RequestInit 485 - ): Promise<Result<Output, string>> => { 486 - if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); 487 - api.pathname = `/xrpc/${schema.nsid}`; 488 - api.search = params ? `?${new URLSearchParams(params)}` : ''; 489 - try { 490 - const body = await fetchJson(api, init); 491 - if (!body.ok) return err(body.error); 492 - const parsed = safeParse(schema.output.schema, body.value); 493 - if (!parsed.ok) return err(parsed.message); 494 - return ok(parsed.value as Output); 495 - } catch (error) { 496 - return err(`FetchError: ${error}`); 497 - } 498 - }; 499 - 500 - const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => { 501 - try { 502 - const response = await fetch(url, init); 503 - const body = await response.json(); 504 - if (response.status === 400) return err(`${body.error}: ${body.message}`); 505 - if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`); 506 - return ok(body); 507 - } catch (error) { 508 - return err(`FetchError: ${error}`); 509 - } 510 - };
+1 -1
src/lib/at/constellation.ts
··· 9 9 }); 10 10 export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', { 11 11 params: v.object({ 12 - subject: v.resourceUriString(), 12 + subject: v.string(), 13 13 source: v.string(), 14 14 did: v.optional(v.array(v.didString())), 15 15 limit: v.optional(v.integer())
+36 -28
src/lib/at/fetch.ts
··· 4 4 type Cid, 5 5 type ResourceUri 6 6 } from '@atcute/lexicons'; 7 - import { type AtpClient } from './client'; 7 + import { type AtpClient } from './client.svelte'; 8 8 import { err, expect, ok, type Ok, type Result } from '$lib/result'; 9 9 import type { Backlinks } from './constellation'; 10 10 import { AppBskyFeedPost } from '@atcute/bluesky'; ··· 17 17 }; 18 18 19 19 export const fetchPosts = async ( 20 + subject: Did, 20 21 client: AtpClient, 21 22 cursor?: string, 22 23 limit?: number, 23 24 withBacklinks: boolean = true 24 25 ): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => { 25 - const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit); 26 + const recordsList = await client.listRecords(subject, 'app.bsky.feed.post', cursor, limit); 26 27 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 27 28 cursor = recordsList.value.cursor; 28 29 const records = recordsList.value.records; ··· 41 42 try { 42 43 const allBacklinks = await Promise.all( 43 44 records.map(async (r): Promise<PostWithBacklinks> => { 44 - const result = await client.getBacklinksUri(r.uri, replySource); 45 + const result = await client.getBacklinks(r.uri, replySource); 45 46 if (!result.ok) throw `cant fetch replies: ${result.error}`; 46 47 const replies = result.value; 47 48 return { ··· 58 59 } 59 60 }; 60 61 62 + export type HydrateOptions = { 63 + downwards: 'sameAuthor' | 'none'; 64 + }; 65 + 61 66 export const hydratePosts = async ( 62 67 client: AtpClient, 63 68 repo: Did, 64 69 data: PostWithBacklinks[], 65 - cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined 70 + cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined, 71 + options?: Partial<HydrateOptions> 66 72 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 67 73 let posts: Map<ResourceUri, PostWithUri> = new Map(); 68 74 try { ··· 114 120 }; 115 121 await Promise.all(posts.values().map(fetchUpwardsChain)); 116 122 117 - try { 118 - const fetchDownwardsChain = async (post: PostWithUri) => { 119 - const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); 120 - 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; 121 128 122 - // get chains that are the same author until we exhaust them 123 - const backlinks = await client.getBacklinksUri(post.uri, replySource); 124 - 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; 125 132 126 - const promises = []; 127 - for (const reply of backlinks.value.records) { 128 - if (reply.did !== postRepo) continue; 129 - // if we already have this reply, then we already fetched this chain / are fetching it 130 - if (posts.has(toCanonicalUri(reply))) continue; 131 - const record = 132 - cacheFn(reply.did, reply.rkey) ?? 133 - (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey)); 134 - if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 135 - posts.set(record.value.uri, record.value); 136 - promises.push(fetchDownwardsChain(record.value)); 137 - } 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 + } 138 145 139 - await Promise.all(promises); 140 - }; 141 - await Promise.all(posts.values().map(fetchDownwardsChain)); 142 - } catch (error) { 143 - 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 + } 144 152 } 145 153 146 154 return ok(posts);
+3
src/lib/at/index.ts
··· 1 1 import { settings } from '$lib/settings'; 2 + import type { Did } from '@atcute/lexicons'; 2 3 import { get } from 'svelte/store'; 3 4 4 5 export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 5 6 export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); 6 7 export const constellationUrl: URL = new URL(get(settings).endpoints.constellation); 8 + 9 + export const httpToDidWeb = (url: string): Did => `did:web:${new URL(url).hostname}`;
+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 });
+2
src/lib/index.ts
··· 28 28 export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri'; 29 29 export const repostSource: BacklinksSource = 'app.bsky.feed.repost:subject.uri'; 30 30 export const replySource: BacklinksSource = 'app.bsky.feed.post:reply.parent.uri'; 31 + export const replyRootSource: BacklinksSource = 'app.bsky.feed.post:reply.root.uri'; 32 + export const blockSource: BacklinksSource = 'app.bsky.graph.block:subject'; 31 33 32 34 export const timestampFromCursor = (cursor: string | undefined) => { 33 35 if (!cursor) return undefined;
+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:') => {
+1 -1
src/lib/richtext/index.ts
··· 1 1 import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder'; 2 2 import { tokenize, type Token } from '$lib/richtext/parser'; 3 3 import type { Did, GenericUri, Handle } from '@atcute/lexicons'; 4 - import { resolveHandle } from '$lib/at/client'; 4 + import { resolveHandle } from '$lib/at/client.svelte'; 5 5 6 6 export const parseToRichText = (text: string): ReturnType<typeof processTokens> => 7 7 processTokens(tokenize(text));
+245 -70
src/lib/state.svelte.ts
··· 1 1 import { writable } from 'svelte/store'; 2 2 import { 3 3 AtpClient, 4 - newPublicClient, 4 + setRecordCache, 5 5 type NotificationsStream, 6 6 type NotificationsStreamEvent 7 - } from './at/client'; 7 + } from './at/client.svelte'; 8 8 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 9 - import type { Did, Handle, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 10 - import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch'; 9 + import type { Did, Handle, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 10 + import { fetchPosts, hydratePosts, type HydrateOptions, type PostWithUri } from './at/fetch'; 11 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 - import { AppBskyActorProfile, AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 13 - import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 12 + import { 13 + AppBskyActorProfile, 14 + AppBskyFeedPost, 15 + AppBskyGraphBlock, 16 + type AppBskyGraphFollow 17 + } from '@atcute/bluesky'; 14 18 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 15 19 import { expect, ok } from './result'; 16 20 import type { Backlink, BacklinksSource } from './at/constellation'; 17 21 import { now as tidNow } from '@atcute/tid'; 18 22 import type { Records } from '@atcute/lexicons/ambient'; 19 23 import { 24 + blockSource, 20 25 extractDidFromUri, 21 26 likeSource, 27 + replyRootSource, 22 28 replySource, 23 29 repostSource, 24 30 timestampFromCursor, 25 31 toCanonicalUri 26 32 } from '$lib'; 27 33 import { Router } from './router.svelte'; 34 + import type { Account } from './accounts'; 28 35 29 36 export const notificationStream = writable<NotificationsStream | null>(null); 30 37 export const jetstream = writable<JetstreamSubscription | null>(null); ··· 134 141 >(); 135 142 136 143 export const fetchLinksUntil = async ( 144 + subject: Did, 137 145 client: AtpClient, 138 146 backlinkSource: BacklinksSource, 139 147 timestamp: number = -1 140 148 ) => { 141 - const did = client.user?.did; 142 - if (!did) return; 143 - 144 - let cursorMap = backlinksCursors.get(did); 149 + let cursorMap = backlinksCursors.get(subject); 145 150 if (!cursorMap) { 146 151 cursorMap = new SvelteMap<BacklinksSource, string | undefined>(); 147 - backlinksCursors.set(did, cursorMap); 152 + backlinksCursors.set(subject, cursorMap); 148 153 } 149 154 150 155 const [_collection, source] = backlinkSource.split(':'); ··· 155 160 const cursorTimestamp = timestampFromCursor(cursor); 156 161 if (cursorTimestamp && cursorTimestamp <= timestamp) return; 157 162 158 - console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp); 159 - const result = await client.listRecordsUntil(collection, cursor, timestamp); 163 + console.log(`${subject}: fetchLinksUntil`, backlinkSource, cursor, timestamp); 164 + const result = await client.listRecordsUntil(subject, collection, cursor, timestamp); 160 165 161 166 if (!result.ok) { 162 167 console.error('failed to fetch links until', result.error); ··· 191 196 removeBacklinks(post.uri, source, links); 192 197 await Promise.allSettled( 193 198 links.map((link) => 194 - client.atcute?.post('com.atproto.repo.deleteRecord', { 199 + client.user?.atcute.post('com.atproto.repo.deleteRecord', { 195 200 input: { repo: did, collection, rkey: link.rkey! } 196 201 }) 197 202 ) ··· 223 228 const subjectPath = subject.split('.'); 224 229 setNestedValue(record, subjectPath, post.uri); 225 230 setNestedValue(record, [...subjectPath.slice(0, -1), 'cid'], post.cid); 226 - await client.atcute?.post('com.atproto.repo.createRecord', { 231 + await client.user?.atcute.post('com.atproto.repo.createRecord', { 227 232 input: { 228 233 repo: did, 229 234 collection, ··· 237 242 238 243 export const viewClient = new AtpClient(); 239 244 export const clients = new SvelteMap<Did, AtpClient>(); 240 - export const getClient = async (did: Did): Promise<AtpClient> => { 241 - if (!clients.has(did)) clients.set(did, await newPublicClient(did)); 242 - return clients.get(did)!; 243 - }; 244 245 245 246 export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 246 247 ··· 248 249 did: Did, 249 250 followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]> 250 251 ) => { 251 - if (!follows.has(did)) { 252 - follows.set(did, new SvelteMap(followMap)); 252 + let map = follows.get(did)!; 253 + if (!map) { 254 + map = new SvelteMap(followMap); 255 + follows.set(did, map); 253 256 return; 254 257 } 255 - const map = follows.get(did)!; 256 258 for (const [uri, record] of followMap) map.set(uri, record); 257 259 }; 258 260 259 - export const fetchFollows = async (did: AtprotoDid) => { 260 - const client = await getClient(did); 261 - const res = await client.listRecordsUntil('app.bsky.graph.follow'); 262 - if (!res.ok) return; 261 + export const fetchFollows = async ( 262 + account: Account 263 + ): Promise<IteratorObject<AppBskyGraphFollow.Main>> => { 264 + const client = clients.get(account.did)!; 265 + const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.follow'); 266 + if (!res.ok) { 267 + console.error("can't fetch follows:", res.error); 268 + return [].values(); 269 + } 263 270 addFollows( 264 - did, 271 + account.did, 265 272 res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) 266 273 ); 274 + return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main); 267 275 }; 268 276 269 277 // this fetches up to three days of posts and interactions for using in following list 270 - export const fetchForInteractions = async (did: AtprotoDid) => { 278 + export const fetchForInteractions = async (client: AtpClient, subject: Did) => { 271 279 const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 272 280 273 - const client = await getClient(did); 274 - const res = await client.listRecordsUntil('app.bsky.feed.post', undefined, threeDaysAgo); 281 + const res = await client.listRecordsUntil(subject, 'app.bsky.feed.post', undefined, threeDaysAgo); 275 282 if (!res.ok) return; 276 - addPostsRaw(did, res.value); 283 + const postsWithUri = res.value.records.map( 284 + (post) => 285 + ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri 286 + ); 287 + addPosts(postsWithUri); 277 288 278 289 const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; 279 290 const timestamp = Math.min(cursorTimestamp, threeDaysAgo); 280 - console.log(`${did}: fetchForInteractions`, res.value.cursor, timestamp); 281 - await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 291 + console.log(`${subject}: fetchForInteractions`, res.value.cursor, timestamp); 292 + await Promise.all([repostSource].map((s) => fetchLinksUntil(subject, client, s, timestamp))); 293 + }; 294 + 295 + // if did is in set, we have fetched blocks for them already (against logged in users) 296 + export const blockFlags = new SvelteMap<Did, SvelteSet<Did>>(); 297 + 298 + export const fetchBlocked = async (client: AtpClient, subject: Did, blocker: Did) => { 299 + const subjectUri = `at://${subject}` as ResourceUri; 300 + const res = await client.getBacklinks(subjectUri, blockSource, [blocker], 1); 301 + if (!res.ok) return false; 302 + if (res.value.total > 0) addBacklinks(subjectUri, blockSource, res.value.records); 303 + 304 + // mark as fetched 305 + let flags = blockFlags.get(subject); 306 + if (!flags) { 307 + flags = new SvelteSet(); 308 + blockFlags.set(subject, flags); 309 + } 310 + flags.add(blocker); 311 + 312 + return res.value.total > 0; 313 + }; 314 + 315 + export const fetchBlocks = async (account: Account) => { 316 + const client = clients.get(account.did)!; 317 + const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.block'); 318 + if (!res.ok) return; 319 + for (const block of res.value.records) { 320 + const record = block.value as AppBskyGraphBlock.Main; 321 + const parsedUri = expect(parseCanonicalResourceUri(block.uri)); 322 + addBacklinks(`at://${record.subject}`, blockSource, [ 323 + { 324 + did: parsedUri.repo, 325 + collection: parsedUri.collection, 326 + rkey: parsedUri.rkey 327 + } 328 + ]); 329 + } 330 + }; 331 + 332 + export const createBlock = async (client: AtpClient, targetDid: Did) => { 333 + const userDid = client.user?.did; 334 + if (!userDid) return; 335 + 336 + const rkey = tidNow(); 337 + const targetUri = `at://${targetDid}` as ResourceUri; 338 + 339 + addBacklinks(targetUri, blockSource, [ 340 + { 341 + did: userDid, 342 + collection: 'app.bsky.graph.block', 343 + rkey 344 + } 345 + ]); 346 + 347 + const record: AppBskyGraphBlock.Main = { 348 + $type: 'app.bsky.graph.block', 349 + subject: targetDid, 350 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 351 + createdAt: new Date().toISOString() 352 + }; 353 + 354 + await client.user?.atcute.post('com.atproto.repo.createRecord', { 355 + input: { 356 + repo: userDid, 357 + collection: 'app.bsky.graph.block', 358 + rkey, 359 + record 360 + } 361 + }); 362 + }; 363 + 364 + export const deleteBlock = async (client: AtpClient, targetDid: Did) => { 365 + const userDid = client.user?.did; 366 + if (!userDid) return; 367 + 368 + const targetUri = `at://${targetDid}` as ResourceUri; 369 + const links = findBacklinksBy(targetUri, blockSource, userDid); 370 + 371 + removeBacklinks(targetUri, blockSource, links); 372 + 373 + await Promise.allSettled( 374 + links.map((link) => 375 + client.user?.atcute.post('com.atproto.repo.deleteRecord', { 376 + input: { 377 + repo: userDid, 378 + collection: 'app.bsky.graph.block', 379 + rkey: link.rkey 380 + } 381 + }) 382 + ) 383 + ); 384 + }; 385 + 386 + export const isBlockedByUser = (targetDid: Did, userDid: Did): boolean => { 387 + return isBlockedBy(targetDid, userDid); 388 + }; 389 + 390 + export const isUserBlockedBy = (userDid: Did, targetDid: Did): boolean => { 391 + return isBlockedBy(userDid, targetDid); 392 + }; 393 + 394 + export const hasBlockRelationship = (did1: Did, did2: Did): boolean => { 395 + return isBlockedBy(did1, did2) || isBlockedBy(did2, did1); 396 + }; 397 + 398 + export const getBlockRelationship = ( 399 + userDid: Did, 400 + targetDid: Did 401 + ): { userBlocked: boolean; blockedByTarget: boolean } => { 402 + return { 403 + userBlocked: isBlockedBy(targetDid, userDid), 404 + blockedByTarget: isBlockedBy(userDid, targetDid) 405 + }; 282 406 }; 283 407 284 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>(); 285 411 // did -> post uris that are replies to that did 286 412 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 287 413 ··· 292 418 return cached ? ok(cached) : undefined; 293 419 }; 294 420 295 - export const addPostsRaw = ( 296 - did: AtprotoDid, 297 - newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 298 - ) => { 299 - const postsWithUri = newPosts.records.map( 300 - (post) => 301 - ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri 302 - ); 303 - addPosts(postsWithUri); 304 - }; 305 - 306 421 export const addPosts = (newPosts: Iterable<PostWithUri>) => { 307 422 for (const post of newPosts) { 308 423 const parsedUri = expect(parseCanonicalResourceUri(post.uri)); ··· 313 428 } 314 429 posts.set(post.uri, post); 315 430 if (post.record.reply) { 316 - addBacklinks(post.record.reply.parent.uri, replySource, [ 317 - { 318 - did: parsedUri.repo, 319 - collection: parsedUri.collection, 320 - rkey: parsedUri.rkey 321 - } 322 - ]); 431 + const link = { 432 + did: parsedUri.repo, 433 + collection: parsedUri.collection, 434 + rkey: parsedUri.rkey 435 + }; 436 + addBacklinks(post.record.reply.parent.uri, replySource, [link]); 437 + addBacklinks(post.record.reply.root.uri, replyRootSource, [link]); 323 438 324 439 // update reply index 325 440 const parentDid = extractDidFromUri(post.record.reply.parent.uri); ··· 335 450 } 336 451 }; 337 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 + 338 464 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 339 465 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 340 466 ··· 363 489 }; 364 490 365 491 export const fetchTimeline = async ( 366 - did: AtprotoDid, 492 + client: AtpClient, 493 + subject: Did, 367 494 limit: number = 6, 368 - withBacklinks: boolean = true 495 + withBacklinks: boolean = true, 496 + hydrateOptions?: Partial<HydrateOptions> 369 497 ) => { 370 - const targetClient = await getClient(did); 371 - 372 - const cursor = postCursors.get(did); 498 + const cursor = postCursors.get(subject); 373 499 if (cursor && cursor.end) return; 374 500 375 - const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks); 376 - if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; 501 + const accPosts = await fetchPosts(subject, client, cursor?.value, limit, withBacklinks); 502 + if (!accPosts.ok) throw `cant fetch posts ${subject}: ${accPosts.error}`; 377 503 378 504 // if the cursor is undefined, we've reached the end of the timeline 379 - postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor }); 380 - const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn); 381 - if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 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}`; 382 515 383 516 addPosts(hydrated.value.values()); 384 - addTimeline(did, hydrated.value.keys()); 517 + addTimeline(subject, hydrated.value.keys()); 518 + 519 + if (client.user?.did) { 520 + const userDid = client.user.did; 521 + // check if any of the post authors block the user 522 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 523 + let distinctDids = new Set(hydrated.value.keys().map((uri) => extractDidFromUri(uri)!)); 524 + distinctDids.delete(userDid); // dont need to check if user blocks themselves 525 + const alreadyFetched = blockFlags.get(userDid); 526 + if (alreadyFetched) distinctDids = distinctDids.difference(alreadyFetched); 527 + if (distinctDids.size > 0) 528 + await Promise.all(distinctDids.values().map((did) => fetchBlocked(client, userDid, did))); 529 + } 385 530 386 - console.log(`${did}: fetchTimeline`, accPosts.value.cursor); 531 + console.log(`${subject}: fetchTimeline`, accPosts.value.cursor); 532 + return newCursor; 387 533 }; 388 534 389 - export const fetchInteractionsUntil = async (client: AtpClient, did: Did) => { 390 - 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); 391 541 if (!cursor) return; 392 542 const timestamp = timestampFromCursor(cursor.value); 393 - await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 543 + await Promise.all( 544 + [likeSource, repostSource].map((s) => fetchLinksUntil(interactor, client, s, timestamp)) 545 + ); 546 + }; 547 + 548 + export const fetchInitial = async (account: Account) => { 549 + const client = clients.get(account.did)!; 550 + await Promise.all([ 551 + fetchBlocks(account), 552 + fetchForInteractions(client, account.did), 553 + fetchFollows(account).then((follows) => 554 + Promise.all(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? []) 555 + ) 556 + ]); 394 557 }; 395 558 396 559 export const handleJetstreamEvent = async (event: JetstreamEvent) => { ··· 400 563 const uri: ResourceUri = toCanonicalUri({ did, ...commit }); 401 564 if (commit.collection === 'app.bsky.feed.post') { 402 565 if (commit.operation === 'create') { 566 + const record = commit.record as AppBskyFeedPost.Main; 403 567 const posts = [ 404 568 { 405 - record: commit.record as AppBskyFeedPost.Main, 569 + record, 406 570 uri, 407 571 cid: commit.cid 408 572 } 409 573 ]; 410 - const client = await getClient(did); 574 + await setRecordCache(uri, record); 575 + const client = clients.get(did) ?? viewClient; 411 576 const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 412 577 if (!hydrated.ok) { 413 578 console.error(`cant hydrate posts ${did}: ${hydrated.error}`); ··· 415 580 } 416 581 addPosts(hydrated.value.values()); 417 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 + } 418 589 } else if (commit.operation === 'delete') { 419 - allPosts.get(did)?.delete(uri); 590 + deletePost(uri); 420 591 } 421 592 } 422 593 }; ··· 424 595 const handlePostNotification = async (event: NotificationsStreamEvent & { type: 'message' }) => { 425 596 const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 426 597 const did = parsedSubjectUri.repo as AtprotoDid; 427 - const client = await getClient(did); 598 + const client = clients.get(did); 599 + if (!client) { 600 + console.error(`${did}: cant handle post notification, client not found !?`); 601 + return; 602 + } 428 603 const subjectPost = await client.getRecord( 429 604 AppBskyFeedPost.mainSchema, 430 605 did,
+6 -3
src/lib/thread.ts
··· 1 + // updated src/lib/thread.ts 2 + 1 3 import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons'; 2 4 import type { Account } from './accounts'; 3 5 import { expect } from './result'; 4 6 import type { PostWithUri } from './at/fetch'; 7 + import { isBlockedBy } from './state.svelte'; 5 8 6 9 export type ThreadPost = { 7 10 data: PostWithUri; ··· 11 14 parentUri: ResourceUri | null; 12 15 depth: number; 13 16 newestTime: number; 17 + isBlocked?: boolean; 14 18 }; 15 19 16 20 export type Thread = { ··· 43 47 rkey: parsedUri.rkey, 44 48 parentUri, 45 49 depth: 0, 46 - newestTime: new Date(data.record.createdAt).getTime() 50 + newestTime: new Date(data.record.createdAt).getTime(), 51 + isBlocked: isBlockedBy(parsedUri.repo, account) 47 52 }; 48 53 49 54 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); ··· 150 155 } 151 156 152 157 threads.sort((a, b) => b.newestTime - a.newestTime); 153 - 154 - // console.log(threads); 155 158 156 159 return threads; 157 160 };
+16 -25
src/routes/[...catchall]/+page.svelte
··· 6 6 import FollowingView from '$components/FollowingView.svelte'; 7 7 import TimelineView from '$components/TimelineView.svelte'; 8 8 import ProfileView from '$components/ProfileView.svelte'; 9 - import { AtpClient, streamNotifications } from '$lib/at/client'; 9 + import { AtpClient, streamNotifications } from '$lib/at/client.svelte'; 10 10 import { accounts, type Account } from '$lib/accounts'; 11 11 import { onMount } from 'svelte'; 12 12 import { 13 13 clients, 14 14 postCursors, 15 - fetchForInteractions, 16 - fetchFollows, 17 15 follows, 18 16 notificationStream, 19 17 viewClient, ··· 22 20 handleNotification, 23 21 addPosts, 24 22 addTimeline, 25 - router 23 + router, 24 + fetchInitial 26 25 } from '$lib/state.svelte'; 27 26 import { get } from 'svelte/store'; 28 27 import Icon from '@iconify/svelte'; ··· 62 61 const handleAccountSelected = async (did: AtprotoDid) => { 63 62 selectedDid = did; 64 63 const account = $accounts.find((acc) => acc.did === did); 65 - if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 64 + if (account && (!clients.has(account.did) || !clients.get(account.did)?.user)) 66 65 await loginAccount(account); 67 66 }; 68 67 const handleLogout = async (did: AtprotoDid) => { ··· 113 112 'app.bsky.feed.post:embed.record.uri', 114 113 'app.bsky.feed.repost:subject.uri', 115 114 'app.bsky.feed.like:subject.uri', 116 - 'app.bsky.graph.follow:subject' 115 + 'app.bsky.graph.follow:subject', 116 + 'app.bsky.graph.block:subject' 117 117 ) 118 118 ); 119 119 }); ··· 144 144 } 145 145 if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did; 146 146 // console.log('onMount selectedDid', selectedDid); 147 - Promise.all($accounts.map(loginAccount)).then(() => { 148 - $accounts.forEach((account) => { 149 - fetchFollows(account.did).then(() => 150 - follows 151 - .get(account.did) 152 - ?.forEach((follow) => fetchForInteractions(follow.subject as AtprotoDid)) 153 - ); 154 - fetchForInteractions(account.did); 155 - }); 156 - }); 147 + Promise.all($accounts.map(loginAccount)).then(() => $accounts.forEach(fetchInitial)); 157 148 } else { 158 149 selectedDid = null; 159 150 } ··· 163 154 164 155 $effect(() => { 165 156 const wantedDids: Did[] = ['did:web:guestbook.gaze.systems']; 166 - 167 - for (const followMap of follows.values()) 168 - for (const follow of followMap.values()) wantedDids.push(follow.subject); 169 - for (const account of $accounts) wantedDids.push(account.did); 170 - 157 + const followDids = follows 158 + .values() 159 + .flatMap((followMap) => followMap.values().map((follow) => follow.subject)); 160 + const accountDids = $accounts.values().map((account) => account.did); 161 + wantedDids.push(...followDids, ...accountDids); 171 162 // console.log('updating jetstream options:', wantedDids); 172 163 $jetstream?.updateOptions({ wantedDids }); 173 164 }); ··· 278 269 <div 279 270 class=" 280 271 {['/', '/following', '/profile/:actor'].includes(router.current.path) ? '' : 'hidden'} 281 - z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all 272 + z-20 w-full max-w-2xl p-2.5 px-4 pb-1.25 transition-all 282 273 " 283 274 > 284 275 <!-- composer and error disclaimer (above thread list, not scrollable) --> 285 - <div class="footer-border-bg rounded-sm px-0.5 py-0.5"> 286 - <div class="footer-bg flex gap-2 rounded-sm p-1.5 shadow-2xl"> 276 + <div class="footer-border-bg rounded-sm p-0.5"> 277 + <div class="footer-bg flex gap-2 rounded-sm p-1.5"> 287 278 <AccountSelector 288 279 client={viewClient} 289 280 accounts={$accounts} ··· 320 311 321 312 <div id="footer-portal" class="contents"></div> 322 313 323 - <div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5"> 314 + <div class="footer-border-bg rounded-t-sm px-0.75 pt-0.75"> 324 315 <div class="footer-bg rounded-t-sm"> 325 316 <div class="flex items-center gap-1.5 px-2 py-1"> 326 317 <div class="mb-2">
+1 -1
src/routes/[...catchall]/+page.ts
··· 1 1 import { addAccount, loggingIn } from '$lib/accounts'; 2 - import { AtpClient } from '$lib/at/client'; 2 + import { AtpClient } from '$lib/at/client.svelte'; 3 3 import { flow, sessions } from '$lib/at/oauth'; 4 4 import { err, ok, type Result } from '$lib/result'; 5 5 import type { PageLoad } from './$types';