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 + }
+2 -2
nix/modules.nix
··· 14 14 ]; 15 15 }; 16 16 17 - outputHash = "sha256-oZKRCeIxjkv8Iujo82FaumsEnpt8pSqoYDbibmPgmZA="; 17 + outputHash = "sha256-1AkU6eV0uIUZohotHhd8E5eAwc4E4wwg2SjHVUdX8LE="; 18 18 outputHashAlgo = "sha256"; 19 19 outputHashMode = "recursive"; 20 20 21 - nativeBuildInputs = [deno]; 21 + nativeBuildInputs = [ deno ]; 22 22 23 23 dontConfigure = true; 24 24 dontCheck = true;
+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>
+168 -249
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 - import { resolveDidDoc, type AtpClient } from '$lib/at/client'; 3 - import { 4 - AppBskyActorProfile, 5 - AppBskyEmbedExternal, 6 - AppBskyEmbedImages, 7 - AppBskyEmbedVideo, 8 - AppBskyFeedPost 9 - } from '@atcute/bluesky'; 2 + import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; 3 + import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; 10 4 import { 11 5 parseCanonicalResourceUri, 12 6 type Did, ··· 17 11 import { expect, ok } from '$lib/result'; 18 12 import { accounts, generateColorForDid } from '$lib/accounts'; 19 13 import ProfilePicture from './ProfilePicture.svelte'; 20 - import { isBlob } from '@atcute/lexicons/interfaces'; 21 - import { blob, img } from '$lib/cdn'; 22 14 import BskyPost from './BskyPost.svelte'; 23 15 import Icon from '@iconify/svelte'; 24 16 import { 25 17 allPosts, 26 18 pulsingPostId, 27 19 currentTime, 28 - findBacklinksBy, 29 20 deletePostBacklink, 30 21 createPostBacklink, 31 22 router, 32 23 profiles, 33 - handles 24 + handles, 25 + hasBacklink, 26 + getBlockRelationship, 27 + clients 34 28 } from '$lib/state.svelte'; 35 29 import type { PostWithUri } from '$lib/at/fetch'; 36 30 import { onMount, type Snippet } from 'svelte'; 37 31 import { derived } from 'svelte/store'; 38 - import Device from 'svelte-device-info'; 39 32 import Dropdown from './Dropdown.svelte'; 40 - import { type AppBskyEmbeds } from '$lib/at/types'; 41 33 import { settings } from '$lib/settings'; 42 34 import RichText from './RichText.svelte'; 43 35 import { getRelativeTime } from '$lib/date'; 44 36 import { likeSource, repostSource, toCanonicalUri } from '$lib'; 45 37 import ProfileInfo from './ProfileInfo.svelte'; 46 - import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte'; 38 + import EmbedBadge from './EmbedBadge.svelte'; 39 + import EmbedMedia from './EmbedMedia.svelte'; 47 40 48 41 interface Props { 49 42 client: AtpClient; ··· 58 51 onQuote?: (quote: PostWithUri) => void; 59 52 onReply?: (reply: PostWithUri) => void; 60 53 cornerFragment?: Snippet; 54 + isBlocked?: boolean; 61 55 } 62 56 63 57 const { ··· 70 64 onQuote, 71 65 onReply, 72 66 isOnPostComposer = false /* replyBacklinks */, 73 - cornerFragment 67 + cornerFragment, 68 + isBlocked = false 74 69 }: Props = $props(); 75 70 76 - const selectedDid = $derived(client.user?.did ?? null); 71 + const user = $derived(client.user); 77 72 const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did)); 78 73 79 74 const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 80 75 const color = $derived(generateColorForDid(did)); 81 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 + 82 87 let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); 83 - const didDoc = resolveDidDoc(did).then((res) => { 84 - if (res.ok) { 85 - handle = res.value.handle; 86 - handles.set(did, handle); 87 - } 88 - return res; 88 + onMount(() => { 89 + resolveDidDoc(did).then((res) => { 90 + if (res.ok) { 91 + handle = res.value.handle; 92 + handles.set(did, handle); 93 + } 94 + return res; 95 + }); 89 96 }); 90 97 const post = data 91 98 ? Promise.resolve(ok(data)) ··· 120 127 }, 400); 121 128 }; 122 129 123 - const getEmbedText = (embedType: string) => { 124 - switch (embedType) { 125 - case 'app.bsky.embed.external': 126 - return '๐Ÿ”— has external link'; 127 - case 'app.bsky.embed.record': 128 - return '๐Ÿ’ฌ has quote'; 129 - case 'app.bsky.embed.images': 130 - return '๐Ÿ–ผ๏ธ has images'; 131 - case 'app.bsky.embed.video': 132 - return '๐ŸŽฅ has video'; 133 - case 'app.bsky.embed.recordWithMedia': 134 - return '๐Ÿ“Ž has quote with media'; 135 - default: 136 - return 'โ“ has unknown embed'; 137 - } 138 - }; 139 - 140 130 let actionsOpen = $state(false); 141 131 let actionsPos = $state({ x: 0, y: 0 }); 142 132 ··· 159 149 return; 160 150 } 161 151 162 - client?.atcute 163 - ?.post('com.atproto.repo.deleteRecord', { 152 + clients 153 + .get(did) 154 + ?.user?.atcute.post('com.atproto.repo.deleteRecord', { 164 155 input: { 165 156 collection: 'app.bsky.feed.post', 166 157 repo: did, ··· 178 169 let profileOpen = $state(false); 179 170 </script> 180 171 181 - {#snippet embedBadge(embed: AppBskyEmbeds)} 182 - <span 183 - class="rounded-full px-2.5 py-0.5 text-xs font-medium" 184 - style=" 185 - background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent); 186 - color: {mini ? 'var(--nucleus-fg)' : color}; 187 - " 188 - > 189 - {getEmbedText(embed.$type!)} 190 - </span> 191 - {/snippet} 192 - 193 172 {#snippet profileInline()} 194 173 <button 195 174 class=" ··· 197 176 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 198 177 " 199 178 style="color: {color};" 200 - onclick={() => router.navigate(`/profile/${did}`)} 179 + onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))} 201 180 > 202 181 <ProfilePicture {client} {did} size={8} /> 203 182 ··· 231 210 {:then post} 232 211 {#if post.ok} 233 212 {@const record = post.value.record} 234 - <!-- svelte-ignore a11y_click_events_have_key_events --> 235 - <!-- svelte-ignore a11y_no_static_element_interactions --> 236 - <div 237 - onclick={() => scrollToAndPulse(post.value.uri)} 238 - class="select-none hover:cursor-pointer hover:underline" 239 - > 240 - <span style="color: {color};">@{handle}</span>: 241 - {#if record.embed} 242 - {@render embedBadge(record.embed)} 243 - {/if} 244 - <span title={record.text}>{record.text}</span> 245 - </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} 246 234 {:else} 247 235 {post.error} 248 236 {/if} ··· 265 253 {:then post} 266 254 {#if post.ok} 267 255 {@const record = post.value.record} 268 - <!-- svelte-ignore a11y_no_static_element_interactions --> 269 - <div 270 - id="timeline-post-{post.value.uri}-{quoteDepth}" 271 - oncontextmenu={handleRightClick} 272 - 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=" 273 276 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 274 277 {$isPulsing ? 'animate-pulse-highlight' : ''} 275 278 {isOnPostComposer ? 'backdrop-brightness-20' : ''} 276 279 " 277 - style=" 280 + style=" 278 281 background: {color}{isOnPostComposer 279 - ? '36' 280 - : 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)}; 281 284 border-color: {color}{isOnPostComposer ? '99' : '66'}; 282 285 " 283 - > 284 - <div class="mb-3 flex max-w-full items-center justify-between"> 285 - <div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;"> 286 - {@render profilePopout()} 287 - <span>ยท</span> 288 - <span 289 - title={new Date(record.createdAt).toLocaleString()} 290 - class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 291 - > 292 - {getRelativeTime(new Date(record.createdAt), currentTime)} 293 - </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?.()} 294 299 </div> 295 - {@render cornerFragment?.()} 296 - </div> 297 300 298 - <p class="leading-normal text-wrap wrap-break-word"> 299 - <RichText text={record.text} facets={record.facets ?? []} /> 300 - {#if isOnPostComposer && record.embed} 301 - {@render embedBadge(record.embed)} 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> 302 321 {/if} 303 - </p> 304 - {#if !isOnPostComposer && record.embed} 305 - {@const embed = record.embed} 306 - <div class="mt-2"> 307 - {@render postEmbed(embed)} 308 - </div> 309 - {/if} 310 - {#if !isOnPostComposer} 311 - {@render postControls(post.value)} 312 - {/if} 313 - </div> 322 + {#if !isOnPostComposer} 323 + {@render postControls(post.value)} 324 + {/if} 325 + </div> 326 + {/if} 314 327 {:else} 315 328 <div class="error-disclaimer"> 316 329 <p class="text-sm font-medium">error: {post.error}</p> ··· 319 332 {/await} 320 333 {/if} 321 334 322 - {#snippet postEmbed(embed: AppBskyEmbeds)} 323 - {#snippet embedMedia( 324 - embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main 325 - )} 326 - <!-- svelte-ignore a11y_no_static_element_interactions --> 327 - <div oncontextmenu={(e) => e.stopPropagation()}> 328 - {#if embed.$type === 'app.bsky.embed.images'} 329 - {@const _images = embed.images.flatMap((img) => 330 - isBlob(img.image) ? [{ ...img, image: img.image }] : [] 331 - )} 332 - {@const images = _images.map((i): GalleryItem => { 333 - const sizeFactor = 200; 334 - const size = { 335 - width: (i.aspectRatio?.width ?? 4) * sizeFactor, 336 - height: (i.aspectRatio?.height ?? 3) * sizeFactor 337 - }; 338 - return { 339 - ...size, 340 - src: img('feed_fullsize', did, i.image.ref.$link), 341 - thumbnail: { 342 - src: img('feed_thumbnail', did, i.image.ref.$link), 343 - ...size 344 - } 345 - }; 346 - })} 347 - <PhotoSwipeGallery {images} /> 348 - {:else if embed.$type === 'app.bsky.embed.video'} 349 - {#if isBlob(embed.video)} 350 - {#await didDoc then didDoc} 351 - {#if didDoc.ok} 352 - <!-- svelte-ignore a11y_media_has_caption --> 353 - <video 354 - class="rounded-sm" 355 - src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 356 - controls 357 - ></video> 358 - {/if} 359 - {/await} 360 - {/if} 361 - {/if} 362 - </div> 363 - {/snippet} 364 - {#snippet embedPost(uri: ResourceUri)} 365 - {#if quoteDepth < 2} 366 - {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 367 - <!-- reject recursive quotes --> 368 - {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 335 + {#snippet embedPost(uri: ResourceUri)} 336 + {#if quoteDepth < 2} 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 + 344 + <!-- reject recursive quotes --> 345 + {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 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} 369 356 <BskyPost 370 357 {client} 371 358 quoteDepth={quoteDepth + 1} ··· 375 362 {onQuote} 376 363 {onReply} 377 364 /> 378 - {:else} 379 - <span>you think you're funny with that recursive quote but i'm onto you</span> 380 365 {/if} 381 366 {:else} 382 - {@render embedBadge(embed)} 367 + <span>you think you're funny with that recursive quote but i'm onto you</span> 383 368 {/if} 384 - {/snippet} 385 - {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 386 - {@render embedMedia(embed)} 387 - {:else if embed.$type === 'app.bsky.embed.record'} 388 - {@render embedPost(embed.record.uri)} 389 - {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 390 - <div class="space-y-1.5"> 391 - {@render embedPost(embed.record.record.uri)} 392 - {@render embedMedia(embed.media)} 393 - </div> 369 + {:else} 370 + <EmbedBadge embed={{ $type: 'app.bsky.embed.record' } as AppBskyEmbedRecord.Main} /> 394 371 {/if} 395 372 {/snippet} 396 373 397 374 {#snippet postControls(post: PostWithUri)} 398 - {@const myRepost = findBacklinksBy(post.uri, repostSource, selectedDid!).length > 0} 399 - {@const myLike = findBacklinksBy(post.uri, likeSource, selectedDid!).length > 0} 375 + {@const myRepost = user ? hasBacklink(post.uri, repostSource, user.did) : false} 376 + {@const myLike = user ? hasBacklink(post.uri, likeSource, user.did) : false} 400 377 {#snippet control({ 401 378 name, 402 379 icon, 403 380 onClick, 404 381 isFull, 405 382 hasSolid, 406 - canBeDisabled = true 383 + canBeDisabled = true, 384 + iconColor = color 407 385 }: { 408 386 name: string; 409 387 icon: string; ··· 411 389 isFull?: boolean; 412 390 hasSolid?: boolean; 413 391 canBeDisabled?: boolean; 392 + iconColor?: string; 414 393 })} 415 394 <button 416 395 class=" 417 - px-2 py-1.5 text-(--nucleus-fg)/90 transition-all 396 + px-1.75 py-1.5 text-(--nucleus-fg)/90 transition-all 418 397 duration-100 not-disabled:hover:[backdrop-filter:brightness(120%)] 419 398 disabled:cursor-not-allowed! 420 399 " 421 400 onclick={(e) => onClick(e)} 422 - style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 401 + style="color: {isFull ? iconColor : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 423 402 title={name} 424 - disabled={canBeDisabled ? selectedDid === null : false} 403 + disabled={canBeDisabled ? user?.did === undefined : false} 425 404 > 426 405 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 427 406 </button> ··· 438 417 name: 'repost', 439 418 icon: 'heroicons:arrow-path-rounded-square-20-solid', 440 419 onClick: () => { 441 - if (!selectedDid) return; 420 + if (!user?.did) return; 442 421 if (myRepost) deletePostBacklink(client, post, repostSource); 443 422 else createPostBacklink(client, post, repostSource); 444 423 }, ··· 453 432 name: 'like', 454 433 icon: 'heroicons:star', 455 434 onClick: () => { 456 - if (!selectedDid) return; 435 + if (!user?.did) return; 457 436 if (myLike) deletePostBacklink(client, post, likeSource); 458 437 else createPostBacklink(client, post, likeSource); 459 438 }, ··· 471 450 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 472 451 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) 473 452 )} 474 - {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () => 453 + {@render dropdownItem(undefined, 'copy at uri', () => 475 454 navigator.clipboard.writeText(post.uri) 476 455 )} 477 456 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => ··· 489 468 {/if} 490 469 491 470 {#snippet trigger()} 492 - <div 493 - class=" 494 - w-fit items-center rounded-sm transition-opacity 495 - duration-100 ease-in-out group-hover:opacity-100 496 - {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''} 497 - " 498 - style="background: {color}1f;" 499 - > 500 - {@render control({ 501 - name: 'actions', 502 - icon: 'heroicons:ellipsis-horizontal-16-solid', 503 - onClick: (e: MouseEvent) => { 504 - e.stopPropagation(); 505 - actionsOpen = !actionsOpen; 506 - actionsPos = { x: 0, y: 0 }; 507 - }, 508 - canBeDisabled: false 509 - })} 510 - </div> 471 + {@render control({ 472 + name: 'actions', 473 + icon: 'heroicons:ellipsis-horizontal-16-solid', 474 + onClick: (e: MouseEvent) => { 475 + e.stopPropagation(); 476 + actionsOpen = !actionsOpen; 477 + actionsPos = { x: 0, y: 0 }; 478 + }, 479 + canBeDisabled: false, 480 + isFull: true, 481 + iconColor: 'color-mix(in srgb, var(--nucleus-fg) 70%, transparent)' 482 + })} 511 483 {/snippet} 512 484 </Dropdown> 513 485 </div> 514 486 {/snippet} 515 487 516 488 {#snippet dropdownItem( 517 - icon: string, 489 + icon: string | undefined, 518 490 label: string, 519 491 onClick: () => void, 520 492 autoClose: boolean = true, ··· 531 503 if (autoClose) actionsOpen = false; 532 504 }} 533 505 > 534 - <span class="font-bold">{label}</span> 535 - <Icon class="h-6 w-6" {icon} /> 506 + <span class="font-semibold opacity-85">{label}</span> 507 + {#if icon} 508 + <Icon class="h-6 w-6" {icon} /> 509 + {/if} 536 510 </button> 537 511 {/snippet} 538 - 539 - <style> 540 - @reference "../app.css"; 541 - 542 - :global(.post-dropdown) { 543 - @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 544 - } 545 - 546 - .image-grid { 547 - display: grid; 548 - gap: 2px; 549 - border-radius: 0.375rem; 550 - overflow: hidden; 551 - max-height: 500px; 552 - } 553 - 554 - /* 1 image: full width */ 555 - .image-grid.count-1 { 556 - grid-template-columns: 1fr; 557 - } 558 - 559 - /* 2 images: side by side */ 560 - .image-grid.count-2 { 561 - grid-template-columns: repeat(2, 1fr); 562 - } 563 - 564 - /* 3 images: first spans left, two stack on right */ 565 - .image-grid.count-3 { 566 - grid-template-columns: repeat(2, 1fr); 567 - grid-template-rows: repeat(2, 1fr); 568 - } 569 - .image-grid.count-3 a:first-child { 570 - grid-row: 1 / 3; 571 - } 572 - 573 - /* 4+ images: 2x2 grid */ 574 - .image-grid.count-4, 575 - .image-grid.count-5 { 576 - grid-template-columns: repeat(2, 1fr); 577 - grid-template-rows: repeat(2, 1fr); 578 - } 579 - 580 - .image-item { 581 - width: 100%; 582 - height: 100%; 583 - object-fit: cover; 584 - display: block; 585 - cursor: pointer; 586 - transition: opacity 0.2s; 587 - } 588 - 589 - .image-item:hover { 590 - opacity: 0.9; 591 - } 592 - </style>
+37
src/components/EmbedBadge.svelte
··· 1 + <script lang="ts"> 2 + import type { AppBskyEmbeds } from '$lib/at/types'; 3 + 4 + interface Props { 5 + embed: AppBskyEmbeds; 6 + color?: string; 7 + } 8 + 9 + let { embed, color = 'var(--nucleus-fg)' }: Props = $props(); 10 + 11 + const embedText = $derived.by(() => { 12 + switch (embed.$type) { 13 + case 'app.bsky.embed.external': 14 + return '๐Ÿ”— has external link'; 15 + case 'app.bsky.embed.record': 16 + return '๐Ÿ’ฌ has quote'; 17 + case 'app.bsky.embed.images': 18 + return '๐Ÿ–ผ๏ธ has images'; 19 + case 'app.bsky.embed.video': 20 + return '๐ŸŽฅ has video'; 21 + case 'app.bsky.embed.recordWithMedia': 22 + return '๐Ÿ“Ž has quote with media'; 23 + default: 24 + return 'โ“ has unknown embed'; 25 + } 26 + }); 27 + </script> 28 + 29 + <span 30 + class="rounded-full px-2.5 py-0.5 text-xs font-medium" 31 + style=" 32 + background: color-mix(in srgb, {color} 10%, transparent); 33 + color: {color}; 34 + " 35 + > 36 + {embedText} 37 + </span>
+53
src/components/EmbedMedia.svelte
··· 1 + <script lang="ts"> 2 + import { isBlob } from '@atcute/lexicons/interfaces'; 3 + import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte'; 4 + import { blob, img } from '$lib/cdn'; 5 + import { type Did } from '@atcute/lexicons'; 6 + import { resolveDidDoc } from '$lib/at/client.svelte'; 7 + import type { AppBskyEmbedMedia } from '$lib/at/types'; 8 + 9 + interface Props { 10 + did: Did; 11 + embed: AppBskyEmbedMedia; 12 + } 13 + 14 + let { did, embed }: Props = $props(); 15 + </script> 16 + 17 + <!-- svelte-ignore a11y_no_static_element_interactions --> 18 + <div oncontextmenu={(e) => e.stopPropagation()}> 19 + {#if embed.$type === 'app.bsky.embed.images'} 20 + {@const _images = embed.images.flatMap((img) => 21 + isBlob(img.image) ? [{ ...img, image: img.image }] : [] 22 + )} 23 + {@const images = _images.map((i): GalleryItem => { 24 + const size = i.aspectRatio; 25 + const cid = i.image.ref.$link; 26 + return { 27 + ...size, 28 + src: img('feed_fullsize', did, cid), 29 + thumbnail: { 30 + src: img('feed_thumbnail', did, cid), 31 + ...size 32 + }, 33 + alt: i.alt 34 + }; 35 + })} 36 + {#if images.length > 0} 37 + <PhotoSwipeGallery {images} /> 38 + {/if} 39 + {:else if embed.$type === 'app.bsky.embed.video'} 40 + {#if isBlob(embed.video)} 41 + {#await resolveDidDoc(did) then didDoc} 42 + {#if didDoc.ok} 43 + <!-- svelte-ignore a11y_media_has_caption --> 44 + <video 45 + class="rounded-sm" 46 + src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 47 + controls 48 + ></video> 49 + {/if} 50 + {/await} 51 + {/if} 52 + {/if} 53 + </div>
+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 -9
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: 100%; 104 104 } 105 105 106 106 .gallery.styling-twitter > a {
+483 -35
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 - import type { AppBskyFeedPost } from '@atcute/bluesky'; 4 + import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky'; 5 5 import { generateColorForDid } from '$lib/accounts'; 6 6 import type { PostWithUri } from '$lib/at/fetch'; 7 7 import BskyPost from './BskyPost.svelte'; 8 - import { parseCanonicalResourceUri } from '@atcute/lexicons'; 8 + import { parseCanonicalResourceUri, type Blob as AtpBlob } from '@atcute/lexicons'; 9 9 import type { ComAtprotoRepoStrongRef } from '@atcute/atproto'; 10 10 import { parseToRichText } from '$lib/richtext'; 11 11 import { tokenize } from '$lib/richtext/parser'; 12 12 import Icon from '@iconify/svelte'; 13 13 import ProfilePicture from './ProfilePicture.svelte'; 14 + import type { AppBskyEmbedMedia } from '$lib/at/types'; 15 + import { SvelteMap } from 'svelte/reactivity'; 16 + import { handles } from '$lib/state.svelte'; 14 17 18 + type UploadState = 19 + | { state: 'uploading'; progress: number } 20 + | { state: 'uploaded'; blob: AtpBlob<string> } 21 + | { state: 'error'; message: string }; 15 22 export type FocusState = 'null' | 'focused'; 16 23 export type State = { 17 24 focus: FocusState; 18 25 text: string; 19 26 quoting?: PostWithUri; 20 27 replying?: PostWithUri; 28 + attachedMedia?: AppBskyEmbedMedia; 29 + blobsState: SvelteMap<string, UploadState>; 21 30 }; 22 31 23 32 interface Props { ··· 26 35 _state: State; 27 36 } 28 37 29 - let { client, onPostSent, _state = $bindable({ focus: 'null', text: '' }) }: Props = $props(); 38 + let { client, onPostSent, _state = $bindable() }: Props = $props(); 30 39 31 40 const isFocused = $derived(_state.focus === 'focused'); 32 41 ··· 34 43 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' 35 44 ); 36 45 46 + const getVideoDimensions = ( 47 + blobUrl: string 48 + ): Promise<Result<{ width: number; height: number }, string>> => 49 + new Promise((resolve) => { 50 + const video = document.createElement('video'); 51 + video.onloadedmetadata = () => { 52 + resolve(ok({ width: video.videoWidth, height: video.videoHeight })); 53 + }; 54 + video.onerror = (e) => resolve(err(String(e))); 55 + video.src = blobUrl; 56 + }); 57 + 58 + const uploadVideo = async (blobUrl: string, mimeType: string) => { 59 + const file = await (await fetch(blobUrl)).blob(); 60 + return await client.uploadVideo(file, mimeType, (status) => { 61 + if (status.stage === 'uploading' && status.progress !== undefined) { 62 + _state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 }); 63 + } else if (status.stage === 'processing' && status.progress !== undefined) { 64 + _state.blobsState.set(blobUrl, { 65 + state: 'uploading', 66 + progress: 0.5 + status.progress * 0.5 67 + }); 68 + } 69 + }); 70 + }; 71 + 72 + const getImageDimensions = ( 73 + blobUrl: string 74 + ): Promise<Result<{ width: number; height: number }, string>> => 75 + new Promise((resolve) => { 76 + const img = new Image(); 77 + img.onload = () => resolve(ok({ width: img.width, height: img.height })); 78 + img.onerror = (e) => resolve(err(String(e))); 79 + img.src = blobUrl; 80 + }); 81 + 82 + const uploadImage = async (blobUrl: string) => { 83 + const file = await (await fetch(blobUrl)).blob(); 84 + return await client.uploadBlob(file, (progress) => { 85 + _state.blobsState.set(blobUrl, { state: 'uploading', progress }); 86 + }); 87 + }; 88 + 37 89 const post = async (text: string): Promise<Result<PostWithUri, string>> => { 38 90 const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ 39 91 $type: 'com.atproto.repo.strongRef', ··· 43 95 44 96 const rt = await parseToRichText(text); 45 97 98 + let media: AppBskyEmbedMedia | undefined = _state.attachedMedia; 99 + if (_state.attachedMedia?.$type === 'app.bsky.embed.images') { 100 + const images = _state.attachedMedia.images; 101 + let uploadedImages: typeof images = []; 102 + for (const image of images) { 103 + const blobUrl = (image.image as AtpBlob<string>).ref.$link; 104 + const upload = _state.blobsState.get(blobUrl); 105 + if (!upload || upload.state !== 'uploaded') continue; 106 + const size = await getImageDimensions(blobUrl); 107 + if (size.ok) image.aspectRatio = size.value; 108 + uploadedImages.push({ 109 + ...image, 110 + image: upload.blob 111 + }); 112 + } 113 + if (uploadedImages.length > 0) 114 + media = { 115 + ..._state.attachedMedia, 116 + $type: 'app.bsky.embed.images', 117 + images: uploadedImages 118 + }; 119 + } else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { 120 + const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; 121 + const upload = _state.blobsState.get(blobUrl); 122 + if (upload && upload.state === 'uploaded') { 123 + const size = await getVideoDimensions(blobUrl); 124 + if (size.ok) _state.attachedMedia.aspectRatio = size.value; 125 + media = { 126 + ..._state.attachedMedia, 127 + $type: 'app.bsky.embed.video', 128 + video: upload.blob 129 + }; 130 + } 131 + } 132 + // console.log('media', media); 133 + 46 134 const record: AppBskyFeedPost.Main = { 47 135 $type: 'app.bsky.feed.post', 48 136 text: rt.text, ··· 56 144 : undefined, 57 145 embed: 58 146 _state.focus === 'focused' && _state.quoting 59 - ? { 60 - $type: 'app.bsky.embed.record', 61 - record: strongRef(_state.quoting) 62 - } 63 - : undefined, 147 + ? media 148 + ? { 149 + $type: 'app.bsky.embed.recordWithMedia', 150 + record: { record: strongRef(_state.quoting) }, 151 + media: media as AppBskyEmbedRecordWithMedia.Main['media'] 152 + } 153 + : { 154 + $type: 'app.bsky.embed.record', 155 + record: strongRef(_state.quoting) 156 + } 157 + : (media as AppBskyFeedPost.Main['embed']), 64 158 createdAt: new Date().toISOString() 65 159 }; 66 160 67 - const res = await client.atcute?.post('com.atproto.repo.createRecord', { 161 + const res = await client.user?.atcute.post('com.atproto.repo.createRecord', { 68 162 input: { 69 163 collection: 'app.bsky.feed.post', 70 164 repo: client.user!.did, ··· 84 178 }); 85 179 }; 86 180 87 - let info = $state(''); 181 + let posting = $state(false); 182 + let postError = $state(''); 88 183 let textareaEl: HTMLTextAreaElement | undefined = $state(); 184 + let fileInputEl: HTMLInputElement | undefined = $state(); 185 + let selectingFile = $state(false); 186 + 187 + const canUpload = $derived( 188 + !( 189 + _state.attachedMedia?.$type === 'app.bsky.embed.video' || 190 + (_state.attachedMedia?.$type === 'app.bsky.embed.images' && 191 + _state.attachedMedia.images.length >= 4) 192 + ) 193 + ); 89 194 90 195 const unfocus = () => (_state.focus = 'null'); 91 196 197 + const handleFiles = (files: File[]) => { 198 + if (!canUpload || !files || files.length === 0) return; 199 + 200 + const existingImages = 201 + _state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : []; 202 + 203 + let newImages = [...existingImages]; 204 + let hasVideo = false; 205 + 206 + for (let i = 0; i < files.length; i++) { 207 + const file = files[i]; 208 + const isVideo = file.type.startsWith('video/'); 209 + const isImage = file.type.startsWith('image/'); 210 + 211 + if (!isVideo && !isImage) { 212 + postError = 'unsupported file type'; 213 + continue; 214 + } 215 + 216 + if (isVideo) { 217 + if (existingImages.length > 0 || newImages.length > 0) { 218 + postError = 'cannot mix images and video'; 219 + continue; 220 + } 221 + const blobUrl = URL.createObjectURL(file); 222 + _state.attachedMedia = { 223 + $type: 'app.bsky.embed.video', 224 + video: { 225 + $type: 'blob', 226 + ref: { $link: blobUrl }, 227 + mimeType: file.type, 228 + size: file.size 229 + } 230 + }; 231 + hasVideo = true; 232 + break; 233 + } else if (isImage) { 234 + if (newImages.length >= 4) { 235 + postError = 'max 4 images allowed'; 236 + break; 237 + } 238 + const blobUrl = URL.createObjectURL(file); 239 + newImages.push({ 240 + image: { 241 + $type: 'blob', 242 + ref: { $link: blobUrl }, 243 + mimeType: file.type, 244 + size: file.size 245 + }, 246 + alt: '', 247 + aspectRatio: undefined 248 + }); 249 + } 250 + } 251 + 252 + if (!hasVideo && newImages.length > 0) { 253 + _state.attachedMedia = { 254 + $type: 'app.bsky.embed.images', 255 + images: newImages 256 + }; 257 + } 258 + 259 + const handleUpload = (blobUrl: string, res: Result<AtpBlob<string>, string>) => { 260 + if (res.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: res.value }); 261 + else _state.blobsState.set(blobUrl, { state: 'error', message: res.error }); 262 + }; 263 + 264 + const media = _state.attachedMedia; 265 + if (media?.$type == 'app.bsky.embed.images') { 266 + for (const image of media.images) { 267 + const blobUrl = (image.image as AtpBlob<string>).ref.$link; 268 + uploadImage(blobUrl).then((r) => handleUpload(blobUrl, r)); 269 + } 270 + } else if (media?.$type === 'app.bsky.embed.video') { 271 + const blobUrl = (media.video as AtpBlob<string>).ref.$link; 272 + uploadVideo(blobUrl, media.video.mimeType).then((r) => handleUpload(blobUrl, r)); 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)); 300 + 301 + input.value = ''; 302 + }; 303 + 304 + const removeMedia = () => { 305 + if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { 306 + const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; 307 + _state.blobsState.delete(blobUrl); 308 + queueMicrotask(() => URL.revokeObjectURL(blobUrl)); 309 + } 310 + _state.attachedMedia = undefined; 311 + }; 312 + 313 + const removeMediaAtIndex = (index: number) => { 314 + if (_state.attachedMedia?.$type !== 'app.bsky.embed.images') return; 315 + const imageToRemove = _state.attachedMedia.images[index]; 316 + const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link; 317 + _state.blobsState.delete(blobUrl); 318 + queueMicrotask(() => URL.revokeObjectURL(blobUrl)); 319 + 320 + const images = _state.attachedMedia.images.filter((_, i) => i !== index); 321 + _state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined; 322 + }; 323 + 92 324 const doPost = () => { 93 325 if (_state.text.length === 0 || _state.text.length > 300) return; 94 326 95 - post(_state.text).then((res) => { 96 - if (res.ok) { 97 - onPostSent(res.value); 98 - _state.text = ''; 99 - info = 'posted!'; 100 - unfocus(); 101 - setTimeout(() => (info = ''), 800); 102 - } else { 103 - info = res.error; 104 - setTimeout(() => (info = ''), 3000); 105 - } 106 - }); 327 + postError = ''; 328 + posting = true; 329 + post(_state.text) 330 + .then((res) => { 331 + if (res.ok) { 332 + onPostSent(res.value); 333 + _state.text = ''; 334 + _state.quoting = undefined; 335 + _state.replying = undefined; 336 + if (_state.attachedMedia?.$type === 'app.bsky.embed.video') 337 + URL.revokeObjectURL((_state.attachedMedia.video as AtpBlob<string>).ref.$link); 338 + else if (_state.attachedMedia?.$type === 'app.bsky.embed.images') 339 + _state.attachedMedia.images.forEach((image) => 340 + URL.revokeObjectURL((image.image as AtpBlob<string>).ref.$link) 341 + ); 342 + _state.attachedMedia = undefined; 343 + _state.blobsState.clear(); 344 + unfocus(); 345 + } else { 346 + postError = res.error; 347 + } 348 + }) 349 + .finally(() => { 350 + posting = false; 351 + }); 107 352 }; 108 353 109 354 $effect(() => { 110 - if (!client.atcute) info = 'not logged in'; 111 355 document.documentElement.style.setProperty('--acc-color', color); 112 356 if (isFocused && textareaEl) textareaEl.focus(); 113 357 }); ··· 130 374 {#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')} 131 375 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 132 376 {@const color = generateColorForDid(parsedUri.repo)} 377 + {@const id = handles.get(parsedUri.repo) ?? parsedUri.repo} 133 378 <div 134 379 class="flex shrink-0 items-center gap-1.5 rounded-sm border py-0.5 pr-0.5 pl-1 text-xs font-bold transition-all" 135 380 style=" ··· 137 382 border-color: {color}; 138 383 color: {color}; 139 384 " 140 - title={type === 'replying' ? `replying to @${parsedUri.repo}` : `quoting @${parsedUri.repo}`} 385 + title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`} 141 386 > 142 387 <span class="truncate text-sm font-normal opacity-90"> 143 388 {type === 'replying' ? 'replying to' : 'quoting'} ··· 162 407 {/if} 163 408 {/snippet} 164 409 410 + {#snippet uploadControls(blobUrl: string, remove: () => void)} 411 + {@const upload = _state.blobsState.get(blobUrl)} 412 + {#if upload !== undefined && upload.state === 'uploading'} 413 + <div 414 + class="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 p-1.5 text-sm backdrop-blur-sm" 415 + > 416 + <div class="flex justify-center"> 417 + <div 418 + class="h-5 w-5 animate-spin rounded-full border-4 border-t-transparent" 419 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 420 + ></div> 421 + </div> 422 + <span class="font-medium">{Math.round(upload.progress * 100)}%</span> 423 + </div> 424 + {:else} 425 + <div class="absolute top-2 right-2 z-10 flex items-center gap-1"> 426 + {#if upload !== undefined && upload.state === 'error'} 427 + <span 428 + class="rounded-sm bg-black/70 p-1.5 px-1 text-sm font-bold text-red-500 backdrop-blur-sm" 429 + >{upload.message}</span 430 + > 431 + {/if} 432 + <button 433 + onclick={(e) => { 434 + e.preventDefault(); 435 + e.stopPropagation(); 436 + remove(); 437 + }} 438 + onmousedown={(e) => e.preventDefault()} 439 + class="rounded-sm bg-black/70 p-1.5 backdrop-blur-sm {upload?.state !== 'error' 440 + ? 'opacity-0 transition-opacity group-hover:opacity-100' 441 + : ''}" 442 + > 443 + {#if upload?.state === 'error'} 444 + <Icon 445 + class="text-red-500 group-hover:hidden" 446 + icon="heroicons:exclamation-circle-16-solid" 447 + width={20} 448 + /> 449 + {/if} 450 + <Icon 451 + class={upload?.state === 'error' ? 'hidden group-hover:block' : ''} 452 + icon="heroicons:x-mark-16-solid" 453 + width={20} 454 + /> 455 + </button> 456 + </div> 457 + {/if} 458 + {/snippet} 459 + 460 + {#snippet mediaPreview(embed: AppBskyEmbedMedia)} 461 + {#if embed.$type === 'app.bsky.embed.images'} 462 + <div class="image-preview-grid" data-total={embed.images.length}> 463 + {#each embed.images as image, idx (idx)} 464 + {@const blobUrl = (image.image as AtpBlob<string>).ref.$link} 465 + <div class="image-preview-item group"> 466 + <img src={blobUrl} alt="" /> 467 + {@render uploadControls(blobUrl, () => removeMediaAtIndex(idx))} 468 + </div> 469 + {/each} 470 + </div> 471 + {:else if embed.$type === 'app.bsky.embed.video'} 472 + {@const blobUrl = (embed.video as AtpBlob<string>).ref.$link} 473 + <div 474 + class="group relative max-h-[30vh] overflow-hidden rounded-sm" 475 + style="aspect-ratio: 16/10;" 476 + > 477 + <!-- svelte-ignore a11y_media_has_caption --> 478 + <video src={blobUrl} controls class="h-full w-full"></video> 479 + {@render uploadControls(blobUrl, removeMedia)} 480 + </div> 481 + {/if} 482 + {/snippet} 483 + 165 484 {#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)} 485 + {@const hasIncompleteUpload = _state.blobsState 486 + .values() 487 + .some((s) => s.state === 'uploading' || s.state === 'error')} 166 488 <div class="flex items-center gap-2"> 489 + <input 490 + bind:this={fileInputEl} 491 + type="file" 492 + accept="image/*,video/*" 493 + multiple 494 + onchange={handleFileSelect} 495 + oncancel={() => (selectingFile = false)} 496 + class="hidden" 497 + /> 498 + <button 499 + onclick={(e) => { 500 + e.preventDefault(); 501 + e.stopPropagation(); 502 + selectingFile = true; 503 + fileInputEl?.click(); 504 + }} 505 + onmousedown={(e) => e.preventDefault()} 506 + disabled={!canUpload} 507 + class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50" 508 + style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};" 509 + title="attach media" 510 + > 511 + <Icon icon="heroicons:photo-16-solid" width={20} /> 512 + </button> 513 + {#if postError.length > 0} 514 + <div class="group flex items-center gap-2 truncate rounded-sm bg-red-500 p-1.5"> 515 + <button onclick={() => (postError = '')}> 516 + <Icon 517 + class="group-hover:hidden" 518 + icon="heroicons:exclamation-circle-16-solid" 519 + width={20} 520 + /> 521 + <Icon class="hidden group-hover:block" icon="heroicons:x-mark-16-solid" width={20} /> 522 + </button> 523 + <span title={postError} class="truncate text-sm font-bold">{postError}</span> 524 + </div> 525 + {/if} 167 526 <div class="grow"></div> 527 + {#if posting} 528 + <div 529 + class="h-6 w-6 animate-spin rounded-full border-4 border-t-transparent" 530 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 531 + ></div> 532 + {/if} 168 533 <span 169 - class="text-sm font-medium" 534 + class="text-sm font-medium text-nowrap" 170 535 style="color: color-mix(in srgb, {_state.text.length > 300 171 536 ? '#ef4444' 172 537 : 'var(--nucleus-fg)'} 53%, transparent);" ··· 174 539 {_state.text.length} / 300 175 540 </span> 176 541 <button 542 + onmousedown={(e) => e.preventDefault()} 177 543 onclick={doPost} 178 - disabled={_state.text.length === 0 || _state.text.length > 300} 179 - class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100" 544 + disabled={(!_state.attachedMedia && _state.text.length === 0) || 545 + _state.text.length > 300 || 546 + hasIncompleteUpload} 547 + class="action-button border-none px-4 py-1.5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100" 180 548 style="background: color-mix(in srgb, {color} 87%, transparent);" 181 549 > 182 550 post ··· 185 553 {#if replying} 186 554 {@render attachedPost(replying, 'replying')} 187 555 {/if} 188 - <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 + > 189 563 <div class="relative grid"> 190 564 <!-- todo: replace this with a proper rich text editor --> 191 565 <div ··· 199 573 bind:this={textareaEl} 200 574 bind:value={_state.text} 201 575 onfocus={() => (_state.focus = 'focused')} 202 - onblur={unfocus} 576 + onblur={() => (!selectingFile ? unfocus() : null)} 203 577 onkeydown={(event) => { 204 578 if (event.key === 'Escape') unfocus(); 205 579 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); ··· 209 583 class="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45" 210 584 ></textarea> 211 585 </div> 586 + {#if _state.attachedMedia} 587 + {@render mediaPreview(_state.attachedMedia)} 588 + {/if} 212 589 {#if quoting} 213 590 {@render attachedPost(quoting, 'quoting')} 214 591 {/if} ··· 234 611 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 235 612 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 236 613 > 237 - <div class="w-full p-1 px-2"> 238 - {#if info.length > 0} 614 + <div class="w-full p-1"> 615 + {#if !client.user} 239 616 <div 240 617 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 241 618 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 242 619 > 243 - {info} 620 + not logged in 244 621 </div> 245 622 {:else} 246 - <div class="flex flex-col gap-2"> 623 + <div class="flex flex-col gap-1"> 247 624 {#if _state.focus === 'focused'} 248 625 {@render composer(_state.replying, _state.quoting)} 249 626 {:else} ··· 281 658 282 659 input, 283 660 .composer { 284 - @apply single-line-input bg-(--nucleus-bg)/35; 661 + @apply single-line-input rounded-xs bg-(--nucleus-bg)/35; 285 662 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); 286 663 } 287 664 ··· 307 684 308 685 textarea:focus { 309 686 @apply border-none! [box-shadow:none]! outline-none!; 687 + } 688 + 689 + /* Image preview grid - based on PhotoSwipeGallery */ 690 + .image-preview-grid { 691 + display: grid; 692 + gap: 2px; 693 + border-radius: 4px; 694 + overflow: hidden; 695 + width: 100%; 696 + max-height: 30vh; 697 + } 698 + 699 + .image-preview-item { 700 + width: 100%; 701 + height: 100%; 702 + display: block; 703 + position: relative; 704 + overflow: hidden; 705 + border-radius: 4px; 706 + } 707 + 708 + .image-preview-item > img { 709 + width: 100%; 710 + height: 100%; 711 + object-fit: cover; 712 + } 713 + 714 + /* Single image: natural aspect ratio */ 715 + .image-preview-grid[data-total='1'] { 716 + display: block; 717 + height: auto; 718 + width: 100%; 719 + border-radius: 0; 720 + } 721 + 722 + .image-preview-grid[data-total='1'] .image-preview-item { 723 + width: 100%; 724 + height: auto; 725 + display: block; 726 + border-radius: 4px; 727 + } 728 + 729 + .image-preview-grid[data-total='1'] .image-preview-item > img { 730 + width: 100%; 731 + height: auto; 732 + max-height: 60vh; 733 + object-fit: contain; 734 + } 735 + 736 + /* 2 Images: Split vertically */ 737 + .image-preview-grid[data-total='2'] { 738 + grid-template-columns: 1fr 1fr; 739 + grid-template-rows: 1fr; 740 + aspect-ratio: 16/9; 741 + } 742 + 743 + /* 3 Images: 1 Big (left), 2 Small (stacked right) */ 744 + .image-preview-grid[data-total='3'] { 745 + grid-template-columns: 1fr 1fr; 746 + grid-template-rows: 1fr 1fr; 747 + aspect-ratio: 16/9; 748 + } 749 + .image-preview-grid[data-total='3'] .image-preview-item:first-child { 750 + grid-row: span 2; 751 + } 752 + 753 + /* 4 Images: 2x2 Grid */ 754 + .image-preview-grid[data-total='4'] { 755 + grid-template-columns: 1fr 1fr; 756 + grid-template-rows: 1fr 1fr; 757 + aspect-ratio: 16/9; 310 758 } 311 759 </style>
+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 + };
-353
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 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 } 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 { get } from 'svelte/store'; 38 - import { settings } from '$lib/settings'; 39 - import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 40 - import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib'; 41 - 42 - export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 43 - export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); 44 - export const constellationUrl: URL = new URL(get(settings).endpoints.constellation); 45 - 46 - export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 47 - 48 - const cacheWithHandles = rawCache.define( 49 - 'resolveHandle', 50 - async (handle: Handle): Promise<AtprotoDid> => { 51 - const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 52 - handle 53 - }); 54 - if (!res.ok) throw new Error(res.error); 55 - return res.value.did as AtprotoDid; 56 - } 57 - ); 58 - 59 - const cacheWithDidDocs = cacheWithHandles.define( 60 - 'resolveDidDoc', 61 - async (identifier: ActorIdentifier): Promise<MiniDoc> => { 62 - const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, { 63 - identifier 64 - }); 65 - if (!res.ok) throw new Error(res.error); 66 - return res.value; 67 - } 68 - ); 69 - 70 - const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => { 71 - const parsedUri = parseResourceUri(uri); 72 - if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`); 73 - const { repo, collection, rkey } = parsedUri.value; 74 - const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 75 - repo, 76 - collection: collection!, 77 - rkey: rkey! 78 - }); 79 - if (!res.ok) throw new Error(res.error); 80 - return res.value; 81 - }); 82 - 83 - const cache = cacheWithRecords; 84 - 85 - export class AtpClient { 86 - public atcute: AtcuteClient | null = null; 87 - public user: { did: Did; handle: Handle } | null = null; 88 - 89 - async login(agent: OAuthUserAgent): Promise<Result<null, string>> { 90 - try { 91 - const rpc = new AtcuteClient({ handler: agent }); 92 - const res = await rpc.get('com.atproto.server.getSession'); 93 - if (!res.ok) throw res.data.error; 94 - this.user = { 95 - did: res.data.did, 96 - handle: res.data.handle 97 - }; 98 - this.atcute = rpc; 99 - } catch (error) { 100 - return err(`failed to login: ${error}`); 101 - } 102 - 103 - return ok(null); 104 - } 105 - 106 - async getRecordUri< 107 - Collection extends Nsid, 108 - TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 109 - TKey extends RecordKeySchema, 110 - Schema extends RecordSchema<TObject, TKey>, 111 - Output extends InferInput<Schema> 112 - >(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> { 113 - const parsedUri = expect(parseResourceUri(uri)); 114 - if (parsedUri.collection !== schema.object.shape.$type.expected) 115 - return err( 116 - `collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}` 117 - ); 118 - return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!); 119 - } 120 - 121 - async getRecord< 122 - Collection extends Nsid, 123 - TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 124 - TKey extends RecordKeySchema, 125 - Schema extends RecordSchema<TObject, TKey>, 126 - Output extends InferInput<Schema> 127 - >( 128 - schema: Schema, 129 - repo: ActorIdentifier, 130 - rkey: RecordKey 131 - ): Promise<Result<RecordOutput<Output>, string>> { 132 - const collection = schema.object.shape.$type.expected; 133 - 134 - try { 135 - const rawValue = await cache.fetchRecord( 136 - toResourceUri({ repo, collection, rkey, fragment: undefined }) 137 - ); 138 - 139 - const parsed = safeParse(schema, rawValue.value); 140 - if (!parsed.ok) return err(parsed.message); 141 - 142 - return ok({ 143 - uri: rawValue.uri, 144 - cid: rawValue.cid, 145 - record: parsed.value as Output 146 - }); 147 - } catch (e) { 148 - return err(String(e)); 149 - } 150 - } 151 - 152 - async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { 153 - repo = repo ?? this.user?.did; 154 - if (!repo) return err('not authenticated'); 155 - return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); 156 - } 157 - 158 - async listRecords<Collection extends keyof Records>( 159 - collection: Collection, 160 - cursor?: string, 161 - limit: number = 100 162 - ): Promise< 163 - Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 164 - > { 165 - if (!this.atcute || !this.user) return err('not authenticated'); 166 - const res = await this.atcute.get('com.atproto.repo.listRecords', { 167 - params: { 168 - repo: this.user.did, 169 - collection, 170 - cursor, 171 - limit, 172 - reverse: false 173 - } 174 - }); 175 - if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 176 - 177 - for (const record of res.data.records) 178 - await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 179 - 180 - return ok(res.data); 181 - } 182 - 183 - async listRecordsUntil<Collection extends keyof Records>( 184 - collection: Collection, 185 - cursor?: string, 186 - timestamp: number = -1 187 - ): Promise<ReturnType<typeof this.listRecords>> { 188 - const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = { 189 - records: [], 190 - cursor 191 - }; 192 - 193 - let end = false; 194 - while (!end) { 195 - const res = await this.listRecords(collection, data.cursor); 196 - if (!res.ok) return res; 197 - data.cursor = res.value.cursor; 198 - data.records.push(...res.value.records); 199 - end = data.records.length === 0 || !data.cursor; 200 - if (!end && timestamp > 0) { 201 - const cursorTimestamp = timestampFromCursor(data.cursor); 202 - if (cursorTimestamp === undefined) { 203 - console.warn( 204 - 'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:', 205 - data.cursor 206 - ); 207 - end = true; 208 - } else if (cursorTimestamp <= timestamp) { 209 - end = true; 210 - } else { 211 - console.info( 212 - `${this.user?.did}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}` 213 - ); 214 - } 215 - } 216 - } 217 - 218 - return ok(data); 219 - } 220 - 221 - async getBacklinksUri( 222 - uri: ResourceUri, 223 - source: BacklinksSource 224 - ): Promise<Result<Backlinks, string>> { 225 - const parsedResourceUri = expect(parseCanonicalResourceUri(uri)); 226 - return await this.getBacklinks( 227 - parsedResourceUri.repo, 228 - parsedResourceUri.collection, 229 - parsedResourceUri.rkey, 230 - source 231 - ); 232 - } 233 - 234 - async getBacklinks( 235 - repo: ActorIdentifier, 236 - collection: Nsid, 237 - rkey: RecordKey, 238 - source: BacklinksSource, 239 - limit?: number 240 - ): Promise<Result<Backlinks, string>> { 241 - const did = await resolveHandle(repo); 242 - if (!did.ok) return err(`cant resolve handle: ${did.error}`); 243 - 244 - const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)); 245 - const query = fetchMicrocosm(constellationUrl, BacklinksQuery, { 246 - subject: toCanonicalUri({ did: did.value, collection, rkey }), 247 - source, 248 - limit: limit || 100 249 - }); 250 - 251 - const results = await Promise.race([query, timeout]); 252 - if (!results) return err('cant fetch backlinks: timeout'); 253 - 254 - return results; 255 - } 256 - } 257 - 258 - export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => { 259 - const atp = new AtpClient(); 260 - const didDoc = await resolveDidDoc(ident); 261 - if (!didDoc.ok) { 262 - console.error('failed to resolve did doc', didDoc.error); 263 - return atp; 264 - } 265 - atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) }); 266 - atp.user = { did: didDoc.value.did, handle: didDoc.value.handle }; 267 - return atp; 268 - }; 269 - 270 - // Wrappers that use the cache 271 - 272 - export const resolveHandle = async ( 273 - identifier: ActorIdentifier 274 - ): Promise<Result<AtprotoDid, string>> => { 275 - if (isDid(identifier)) return ok(identifier as AtprotoDid); 276 - 277 - try { 278 - const did = await cache.resolveHandle(identifier); 279 - return ok(did); 280 - } catch (e) { 281 - return err(String(e)); 282 - } 283 - }; 284 - 285 - export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => { 286 - try { 287 - const doc = await cache.resolveDidDoc(ident); 288 - return ok(doc); 289 - } catch (e) { 290 - return err(String(e)); 291 - } 292 - }; 293 - 294 - type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 295 - export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 296 - export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 297 - 298 - export const streamNotifications = ( 299 - subjects: Did[], 300 - ...sources: BacklinksSource[] 301 - ): NotificationsStream => { 302 - const url = new URL(spacedustUrl); 303 - url.protocol = 'wss:'; 304 - url.pathname = '/subscribe'; 305 - const searchParams = []; 306 - sources.every((source) => searchParams.push(['wantedSources', source])); 307 - subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject])); 308 - subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`])); 309 - searchParams.push(['instant', 'true']); 310 - url.search = `?${new URLSearchParams(searchParams)}`; 311 - // console.log(`streaming notifications: ${url}`); 312 - const encoder = WebSocket.getDefaultEncoder<undefined, Notification>(); 313 - const ws = new WebSocket<typeof encoder>(url.toString(), { 314 - encoder 315 - }); 316 - return ws; 317 - }; 318 - 319 - const fetchMicrocosm = async < 320 - Schema extends XRPCQueryMetadata, 321 - Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined, 322 - Output extends InferXRPCBodyOutput<Schema['output']> 323 - >( 324 - api: URL, 325 - schema: Schema, 326 - params: Input, 327 - init?: RequestInit 328 - ): Promise<Result<Output, string>> => { 329 - if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); 330 - api.pathname = `/xrpc/${schema.nsid}`; 331 - api.search = params ? `?${new URLSearchParams(params)}` : ''; 332 - try { 333 - const body = await fetchJson(api, init); 334 - if (!body.ok) return err(body.error); 335 - const parsed = safeParse(schema.output.schema, body.value); 336 - if (!parsed.ok) return err(parsed.message); 337 - return ok(parsed.value as Output); 338 - } catch (error) { 339 - return err(`FetchError: ${error}`); 340 - } 341 - }; 342 - 343 - const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => { 344 - try { 345 - const response = await fetch(url, init); 346 - const body = await response.json(); 347 - if (response.status === 400) return err(`${body.error}: ${body.message}`); 348 - if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`); 349 - return ok(body); 350 - } catch (error) { 351 - return err(`FetchError: ${error}`); 352 - } 353 - };
+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);
+9
src/lib/at/index.ts
··· 1 + import { settings } from '$lib/settings'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { get } from 'svelte/store'; 4 + 5 + export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 6 + export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); 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}`;
+1 -1
src/lib/at/oauth.ts
··· 14 14 WebDidDocumentResolver, 15 15 XrpcHandleResolver 16 16 } from '@atcute/identity-resolver'; 17 - import { slingshotUrl } from './client'; 18 17 import type { ActorIdentifier } from '@atcute/lexicons'; 19 18 import { err, ok, type Result } from '$lib/result'; 20 19 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 21 20 import { clientId, oauthMetadata, redirectUri } from '$lib/oauth'; 21 + import { slingshotUrl } from '.'; 22 22 23 23 configureOAuth({ 24 24 metadata: {
+5
src/lib/at/types.ts
··· 12 12 | AppBskyEmbedRecord.Main 13 13 | AppBskyEmbedRecordWithMedia.Main 14 14 | AppBskyEmbedVideo.Main; 15 + 16 + export type AppBskyEmbedMedia = 17 + | AppBskyEmbedImages.Main 18 + | AppBskyEmbedVideo.Main 19 + | AppBskyEmbedExternal.Main;
+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 });
+32 -48
src/lib/following.ts
··· 1 1 import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons'; 2 2 import type { PostWithUri } from './at/fetch'; 3 - import type { Backlink, BacklinksSource } from './at/constellation'; 3 + import type { BacklinksSource } from './at/constellation'; 4 4 import { extractDidFromUri, repostSource } from '$lib'; 5 5 import type { AppBskyGraphFollow } from '@atcute/bluesky'; 6 6 ··· 13 13 ) => { 14 14 if (sort === 'conversational') { 15 15 if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1) 16 - // sort based on conversational score 17 16 return statsB.conversationalScore! - statsA.conversationalScore!; 18 17 } else { 19 18 if (sort === 'active') 20 19 if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001) 21 - // sort based on activity 22 20 return statsB.activeScore! - statsA.activeScore!; 23 21 } 24 - // use recent if scores are similar / we are using recent mode 25 22 return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime(); 26 23 }; 27 24 28 - // Caching to prevent re-calculating stats for every render frame if data is stable 29 25 const userStatsCache = new Map< 30 26 Did, 31 27 { timestamp: number; stats: ReturnType<typeof _calculateStats> } 32 28 >(); 33 - const STATS_CACHE_TTL = 60 * 1000; // 1 minute 29 + const STATS_CACHE_TTL = 60 * 1000; 34 30 35 31 export const calculateFollowedUserStats = ( 36 32 sort: Sort, ··· 39 35 interactionScores: Map<ActorIdentifier, number> | null, 40 36 now: number 41 37 ) => { 42 - // For 'active' sort which is computationally heavy, use cache 43 38 if (sort === 'active') { 44 39 const cached = userStatsCache.get(did); 45 40 if (cached && now - cached.timestamp < STATS_CACHE_TTL) { 46 41 const postsMap = posts.get(did); 47 - // Simple invalidation check: if post count matches, assume cache is valid enough 48 - // This avoids iterating the map just to check contents. 49 - // Ideally we'd have a version/hash on the map. 50 42 if (postsMap && postsMap.size > 0) return { ...cached.stats, did }; 51 43 } 52 44 } ··· 81 73 if (ageMs < quarterPosts) recentPostCount++; 82 74 if (sort === 'active') { 83 75 const ageHours = ageMs / (1000 * 60 * 60); 84 - // score = 1 / t^G 85 76 activeScore += 1 / Math.pow(ageHours + 1, gravity); 86 77 } 87 78 } ··· 99 90 }; 100 91 }; 101 92 102 - // weights 103 93 const quoteWeight = 4; 104 94 const replyWeight = 6; 105 95 const repostWeight = 2; ··· 108 98 const halfLifeMs = 3 * oneDay; 109 99 const decayLambda = 0.693 / halfLifeMs; 110 100 111 - // normalization constants 112 101 const rateBaseline = 1; 113 102 const ratePower = 0.5; 114 103 const windowSize = 7 * oneDay; 115 104 116 - // Cache for post rates to avoid iterating every user's timeline every time 117 105 const rateCache = new Map<Did, { rate: number; calculatedAt: number; postCount: number }>(); 118 106 119 107 const getPostRate = (did: Did, posts: Map<ResourceUri, PostWithUri>, now: number): number => { 120 108 const cached = rateCache.get(did); 121 - // If cached and number of posts hasn't changed, return cached rate 122 109 if (cached && cached.postCount === posts.size && now - cached.calculatedAt < 5 * 60 * 1000) 123 110 return cached.rate; 124 111 ··· 151 138 user: Did, 152 139 followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>, 153 140 allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, 154 - backlinks_: Map<ResourceUri, Map<BacklinksSource, Set<Backlink>>>, 155 - replyIndex: Map<Did, Set<ResourceUri>>, // NEW: Inverted Index 141 + allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>, 142 + replyIndex: Map<Did, Set<ResourceUri>>, 156 143 now: number 157 144 ) => { 158 145 const scores = new Map<Did, number>(); ··· 162 149 return Math.exp(-decayLambda * age); 163 150 }; 164 151 165 - // Helper to add score 166 152 const addScore = (did: Did, weight: number, time: number) => { 167 153 const current = scores.get(did) ?? 0; 168 154 scores.set(did, current + weight * decay(time)); 169 155 }; 170 156 171 - // 1. Process MY posts (Me -> Others) 172 - // This is relatively cheap as "my posts" are few compared to "everyone's posts" 157 + // 1. process my posts (me -> others) 173 158 const myPosts = allPosts.get(user); 174 159 if (myPosts) { 175 160 const seenRoots = new Set<ResourceUri>(); 176 161 for (const post of myPosts.values()) { 177 162 const t = new Date(post.record.createdAt).getTime(); 178 163 179 - // If I replied to someone 180 164 if (post.record.reply) { 181 165 const parentUri = post.record.reply.parent.uri; 182 166 const rootUri = post.record.reply.root.uri; ··· 191 175 } 192 176 } 193 177 194 - // If I quoted someone 195 178 if (post.record.embed?.$type === 'app.bsky.embed.record') { 196 179 const targetDid = extractDidFromUri(post.record.embed.record.uri); 197 180 if (targetDid && targetDid !== user) addScore(targetDid, quoteWeight, t); ··· 199 182 } 200 183 } 201 184 202 - // 2. Process OTHERS -> ME (using Index) 203 - // Optimized: Use replyIndex instead of iterating all follows 185 + // 2. process others -> me (using reply index) 204 186 const repliesToMe = replyIndex.get(user); 205 187 if (repliesToMe) { 206 188 for (const uri of repliesToMe) { 207 189 const authorDid = extractDidFromUri(uri); 208 - if (!authorDid || authorDid === user) continue; // Self-reply 190 + if (!authorDid || authorDid === user) continue; 209 191 210 192 const postsMap = allPosts.get(authorDid); 211 193 const post = postsMap?.get(uri); 212 - if (!post) continue; // Post data not loaded? 194 + if (!post) continue; 213 195 214 196 const t = new Date(post.record.createdAt).getTime(); 215 197 addScore(authorDid, replyWeight, t); 216 198 } 217 199 } 218 200 219 - for (const [uri, backlinks] of backlinks_) { 220 - const targetDid = extractDidFromUri(uri); 221 - if (!targetDid || targetDid !== user) continue; // Only care about interactions on MY posts 201 + // 3. process reposts on my posts 202 + const repostBacklinks = allBacklinks.get(repostSource); 203 + if (repostBacklinks && myPosts) { 204 + for (const [uri, myPost] of myPosts) { 205 + const didMap = repostBacklinks.get(uri); 206 + if (!didMap) continue; 222 207 223 - const reposts = backlinks.get(repostSource); 224 - if (reposts) { 208 + const t = new Date(myPost.record.createdAt).getTime(); 225 209 const adds = new Map<Did, { score: number; repostCount: number }>(); 226 - for (const repost of reposts) { 227 - if (repost.did === user) continue; 228 - const add = adds.get(repost.did) ?? { score: 0, repostCount: 0 }; 210 + 211 + for (const [did, rkeys] of didMap) { 212 + if (did === user) continue; 213 + 214 + let add = adds.get(did) ?? { score: 0, repostCount: 0 }; 229 215 const diminishFactor = 9; 230 - const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor)); 231 - adds.set(repost.did, { 232 - score: add.score + weight, 233 - repostCount: add.repostCount + 1 234 - }); 235 - } 236 216 237 - // Get the timestamp of the post being reposted to calculate decay 238 - // (Interaction timestamp is unknown for backlinks usually, so we use post timestamp as proxy or 'now'? 239 - // Original code used `post.record.createdAt`. 240 - const myPost = myPosts?.get(uri); 241 - if (myPost) { 242 - const t = new Date(myPost.record.createdAt).getTime(); 243 - for (const [did, add] of adds.entries()) addScore(did, add.score, t); 217 + // each rkey is a separate repost record, apply diminishing returns 218 + for (let i = 0; i < rkeys.size; i++) { 219 + const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor)); 220 + add = { 221 + score: add.score + weight, 222 + repostCount: add.repostCount + 1 223 + }; 224 + } 225 + adds.set(did, add); 244 226 } 227 + 228 + for (const [did, add] of adds.entries()) addScore(did, add.score, t); 245 229 } 246 230 } 247 231 248 - // Apply normalization 232 + // normalize by posting rate 249 233 for (const [did, score] of scores) { 250 234 const posts = allPosts.get(did); 251 235 const rate = posts ? getPostRate(did, posts, now) : 0;
+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;
+2 -1
src/lib/oauth.ts
··· 7 7 client_uri: domain, 8 8 logo_uri: `${domain}/favicon.png`, 9 9 redirect_uris: [`${domain}/`], 10 - scope: 'atproto repo:*?action=create&action=update&action=delete blob:*/*', 10 + scope: 11 + 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*', 11 12 grant_types: ['authorization_code', 'refresh_token'], 12 13 response_types: ['code'], 13 14 token_endpoint_auth_method: 'none',
+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 16 return { ok: false, error }; 16 17 }; 17 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));
+305 -99
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); ··· 32 39 export const profiles = new SvelteMap<Did, AppBskyActorProfile.Main>(); 33 40 export const handles = new SvelteMap<Did, Handle>(); 34 41 35 - export type BacklinksMap = SvelteMap<BacklinksSource, SvelteSet<Backlink>>; 36 - export const allBacklinks = new SvelteMap<ResourceUri, BacklinksMap>(); 42 + // source -> subject -> did (who did the interaction) -> rkey 43 + export type BacklinksMap = SvelteMap< 44 + BacklinksSource, 45 + SvelteMap<ResourceUri, SvelteMap<Did, SvelteSet<RecordKey>>> 46 + >; 47 + export const allBacklinks: BacklinksMap = new SvelteMap(); 37 48 38 49 export const addBacklinks = ( 39 50 subject: ResourceUri, 40 51 source: BacklinksSource, 41 52 links: Iterable<Backlink> 42 53 ) => { 43 - let postsMap = allBacklinks.get(subject); 44 - if (!postsMap) { 45 - postsMap = new SvelteMap(); 46 - allBacklinks.set(subject, postsMap); 54 + let subjectMap = allBacklinks.get(source); 55 + if (!subjectMap) { 56 + subjectMap = new SvelteMap(); 57 + allBacklinks.set(source, subjectMap); 47 58 } 48 - let backlinksSet = postsMap.get(source); 49 - if (!backlinksSet) { 50 - backlinksSet = new SvelteSet(); 51 - postsMap.set(source, backlinksSet); 59 + 60 + let didMap = subjectMap.get(subject); 61 + if (!didMap) { 62 + didMap = new SvelteMap(); 63 + subjectMap.set(subject, didMap); 52 64 } 65 + 53 66 for (const link of links) { 54 - backlinksSet.add(link); 55 - // console.log( 56 - // `added backlink at://${link.did}/${link.collection}/${link.rkey} to ${subject} from ${source}` 57 - // ); 67 + let rkeys = didMap.get(link.did); 68 + if (!rkeys) { 69 + rkeys = new SvelteSet(); 70 + didMap.set(link.did, rkeys); 71 + } 72 + rkeys.add(link.rkey); 58 73 } 59 74 }; 60 75 ··· 63 78 source: BacklinksSource, 64 79 links: Iterable<Backlink> 65 80 ) => { 66 - const postsMap = allBacklinks.get(subject); 67 - if (!postsMap) return; 68 - const backlinksSet = postsMap.get(source); 69 - if (!backlinksSet) return; 70 - for (const link of links) backlinksSet.delete(link); 81 + const didMap = allBacklinks.get(source)?.get(subject); 82 + if (!didMap) return; 83 + 84 + for (const link of links) { 85 + const rkeys = didMap.get(link.did); 86 + if (!rkeys) continue; 87 + rkeys.delete(link.rkey); 88 + if (rkeys.size === 0) didMap.delete(link.did); 89 + } 71 90 }; 72 91 73 - export const findBacklinksBy = ( 74 - subject: ResourceUri, 75 - source: BacklinksSource, 76 - did: Did 77 - ): Backlink[] => { 78 - const postsMap = allBacklinks.get(subject); 79 - if (!postsMap) return []; 80 - const backlinksSet = postsMap.get(source); 81 - if (!backlinksSet) return []; 82 - return Array.from(backlinksSet.values().filter((link) => link.did === did)); 92 + export const findBacklinksBy = (subject: ResourceUri, source: BacklinksSource, did: Did) => { 93 + const rkeys = allBacklinks.get(source)?.get(subject)?.get(did) ?? []; 94 + // reconstruct the collection from the source 95 + const collection = source.split(':')[0] as Nsid; 96 + return rkeys.values().map((rkey) => ({ did, collection, rkey })); 97 + }; 98 + 99 + export const hasBacklink = (subject: ResourceUri, source: BacklinksSource, did: Did): boolean => { 100 + return allBacklinks.get(source)?.get(subject)?.has(did) ?? false; 101 + }; 102 + 103 + export const getAllBacklinksFor = (subject: ResourceUri, source: BacklinksSource): Backlink[] => { 104 + const subjectMap = allBacklinks.get(source); 105 + if (!subjectMap) return []; 106 + 107 + const didMap = subjectMap.get(subject); 108 + if (!didMap) return []; 109 + 110 + const collection = source.split(':')[0] as Nsid; 111 + const result: Backlink[] = []; 112 + 113 + for (const [did, rkeys] of didMap) 114 + for (const rkey of rkeys) result.push({ did, collection, rkey }); 115 + 116 + return result; 117 + }; 118 + 119 + export const isBlockedBy = (subject: Did, blocker: Did): boolean => { 120 + return hasBacklink(`at://${subject}`, 'app.bsky.graph.block:subject', blocker); 83 121 }; 84 122 85 123 // eslint-disable-next-line @typescript-eslint/no-explicit-any ··· 103 141 >(); 104 142 105 143 export const fetchLinksUntil = async ( 144 + subject: Did, 106 145 client: AtpClient, 107 146 backlinkSource: BacklinksSource, 108 147 timestamp: number = -1 109 148 ) => { 110 - const did = client.user?.did; 111 - if (!did) return; 112 - 113 - let cursorMap = backlinksCursors.get(did); 149 + let cursorMap = backlinksCursors.get(subject); 114 150 if (!cursorMap) { 115 151 cursorMap = new SvelteMap<BacklinksSource, string | undefined>(); 116 - backlinksCursors.set(did, cursorMap); 152 + backlinksCursors.set(subject, cursorMap); 117 153 } 118 154 119 155 const [_collection, source] = backlinkSource.split(':'); ··· 124 160 const cursorTimestamp = timestampFromCursor(cursor); 125 161 if (cursorTimestamp && cursorTimestamp <= timestamp) return; 126 162 127 - console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp); 128 - 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); 129 165 130 166 if (!result.ok) { 131 167 console.error('failed to fetch links until', result.error); ··· 160 196 removeBacklinks(post.uri, source, links); 161 197 await Promise.allSettled( 162 198 links.map((link) => 163 - client.atcute?.post('com.atproto.repo.deleteRecord', { 199 + client.user?.atcute.post('com.atproto.repo.deleteRecord', { 164 200 input: { repo: did, collection, rkey: link.rkey! } 165 201 }) 166 202 ) ··· 192 228 const subjectPath = subject.split('.'); 193 229 setNestedValue(record, subjectPath, post.uri); 194 230 setNestedValue(record, [...subjectPath.slice(0, -1), 'cid'], post.cid); 195 - await client.atcute?.post('com.atproto.repo.createRecord', { 231 + await client.user?.atcute.post('com.atproto.repo.createRecord', { 196 232 input: { 197 233 repo: did, 198 234 collection, ··· 206 242 207 243 export const viewClient = new AtpClient(); 208 244 export const clients = new SvelteMap<Did, AtpClient>(); 209 - export const getClient = async (did: Did): Promise<AtpClient> => { 210 - if (!clients.has(did)) clients.set(did, await newPublicClient(did)); 211 - return clients.get(did)!; 212 - }; 213 245 214 246 export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 215 247 ··· 217 249 did: Did, 218 250 followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]> 219 251 ) => { 220 - if (!follows.has(did)) { 221 - 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); 222 256 return; 223 257 } 224 - const map = follows.get(did)!; 225 258 for (const [uri, record] of followMap) map.set(uri, record); 226 259 }; 227 260 228 - export const fetchFollows = async (did: AtprotoDid) => { 229 - const client = await getClient(did); 230 - const res = await client.listRecordsUntil('app.bsky.graph.follow'); 231 - 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 + } 232 270 addFollows( 233 - did, 271 + account.did, 234 272 res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) 235 273 ); 274 + return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main); 236 275 }; 237 276 238 277 // this fetches up to three days of posts and interactions for using in following list 239 - export const fetchForInteractions = async (did: AtprotoDid) => { 278 + export const fetchForInteractions = async (client: AtpClient, subject: Did) => { 240 279 const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 241 280 242 - const client = await getClient(did); 243 - const res = await client.listRecordsUntil('app.bsky.feed.post', undefined, threeDaysAgo); 281 + const res = await client.listRecordsUntil(subject, 'app.bsky.feed.post', undefined, threeDaysAgo); 244 282 if (!res.ok) return; 245 - 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); 246 288 247 289 const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; 248 290 const timestamp = Math.min(cursorTimestamp, threeDaysAgo); 249 - console.log(`${did}: fetchForInteractions`, res.value.cursor, timestamp); 250 - 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 + }; 251 406 }; 252 407 253 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>(); 254 411 // did -> post uris that are replies to that did 255 412 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 256 413 ··· 261 418 return cached ? ok(cached) : undefined; 262 419 }; 263 420 264 - export const addPostsRaw = ( 265 - did: AtprotoDid, 266 - newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 267 - ) => { 268 - const postsWithUri = newPosts.records.map( 269 - (post) => 270 - ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri 271 - ); 272 - addPosts(postsWithUri); 273 - }; 274 - 275 421 export const addPosts = (newPosts: Iterable<PostWithUri>) => { 276 422 for (const post of newPosts) { 277 423 const parsedUri = expect(parseCanonicalResourceUri(post.uri)); ··· 282 428 } 283 429 posts.set(post.uri, post); 284 430 if (post.record.reply) { 285 - addBacklinks(post.record.reply.parent.uri, replySource, [ 286 - { 287 - did: parsedUri.repo, 288 - collection: parsedUri.collection, 289 - rkey: parsedUri.rkey 290 - } 291 - ]); 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]); 292 438 293 439 // update reply index 294 440 const parentDid = extractDidFromUri(post.record.reply.parent.uri); ··· 304 450 } 305 451 }; 306 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 + 307 464 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 308 465 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 309 466 ··· 332 489 }; 333 490 334 491 export const fetchTimeline = async ( 335 - did: AtprotoDid, 492 + client: AtpClient, 493 + subject: Did, 336 494 limit: number = 6, 337 - withBacklinks: boolean = true 495 + withBacklinks: boolean = true, 496 + hydrateOptions?: Partial<HydrateOptions> 338 497 ) => { 339 - const targetClient = await getClient(did); 340 - 341 - const cursor = postCursors.get(did); 498 + const cursor = postCursors.get(subject); 342 499 if (cursor && cursor.end) return; 343 500 344 - const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks); 345 - 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}`; 346 503 347 504 // if the cursor is undefined, we've reached the end of the timeline 348 - postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor }); 349 - const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn); 350 - 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}`; 351 515 352 516 addPosts(hydrated.value.values()); 353 - 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 + } 354 530 355 - console.log(`${did}: fetchTimeline`, accPosts.value.cursor); 531 + console.log(`${subject}: fetchTimeline`, accPosts.value.cursor); 532 + return newCursor; 356 533 }; 357 534 358 - export const fetchInteractionsUntil = async (client: AtpClient, did: Did) => { 359 - 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); 360 541 if (!cursor) return; 361 542 const timestamp = timestampFromCursor(cursor.value); 362 - 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 + ]); 363 557 }; 364 558 365 559 export const handleJetstreamEvent = async (event: JetstreamEvent) => { ··· 369 563 const uri: ResourceUri = toCanonicalUri({ did, ...commit }); 370 564 if (commit.collection === 'app.bsky.feed.post') { 371 565 if (commit.operation === 'create') { 566 + const record = commit.record as AppBskyFeedPost.Main; 372 567 const posts = [ 373 568 { 374 - record: commit.record as AppBskyFeedPost.Main, 569 + record, 375 570 uri, 376 571 cid: commit.cid 377 572 } 378 573 ]; 379 - const client = await getClient(did); 574 + await setRecordCache(uri, record); 575 + const client = clients.get(did) ?? viewClient; 380 576 const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 381 577 if (!hydrated.ok) { 382 578 console.error(`cant hydrate posts ${did}: ${hydrated.error}`); ··· 384 580 } 385 581 addPosts(hydrated.value.values()); 386 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 + } 387 589 } else if (commit.operation === 'delete') { 388 - allPosts.get(did)?.delete(uri); 590 + deletePost(uri); 389 591 } 390 592 } 391 593 }; ··· 393 595 const handlePostNotification = async (event: NotificationsStreamEvent & { type: 'message' }) => { 394 596 const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 395 597 const did = parsedSubjectUri.repo as AtprotoDid; 396 - 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 + } 397 603 const subjectPost = await client.getRecord( 398 604 AppBskyFeedPost.mainSchema, 399 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 };
+22 -26
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'; ··· 32 31 import { JetstreamSubscription } from '@atcute/jetstream'; 33 32 import { settings } from '$lib/settings'; 34 33 import type { Sort } from '$lib/following'; 34 + import { SvelteMap } from 'svelte/reactivity'; 35 35 36 36 const { data: loadData }: PageProps = $props(); 37 37 ··· 61 61 const handleAccountSelected = async (did: AtprotoDid) => { 62 62 selectedDid = did; 63 63 const account = $accounts.find((acc) => acc.did === did); 64 - if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 64 + if (account && (!clients.has(account.did) || !clients.get(account.did)?.user)) 65 65 await loginAccount(account); 66 66 }; 67 67 const handleLogout = async (did: AtprotoDid) => { ··· 83 83 else animClass = 'animate-fade-in-scale'; 84 84 }); 85 85 86 - let postComposerState = $state<PostComposerState>({ focus: 'null', text: '' }); 86 + let postComposerState = $state<PostComposerState>({ 87 + focus: 'null', 88 + text: '', 89 + blobsState: new SvelteMap() 90 + }); 87 91 let showScrollToTop = $state(false); 88 92 const handleScroll = () => { 89 93 if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor') ··· 108 112 'app.bsky.feed.post:embed.record.uri', 109 113 'app.bsky.feed.repost:subject.uri', 110 114 'app.bsky.feed.like:subject.uri', 111 - 'app.bsky.graph.follow:subject' 115 + 'app.bsky.graph.follow:subject', 116 + 'app.bsky.graph.block:subject' 112 117 ) 113 118 ); 114 119 }); ··· 139 144 } 140 145 if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did; 141 146 // console.log('onMount selectedDid', selectedDid); 142 - Promise.all($accounts.map(loginAccount)).then(() => { 143 - $accounts.forEach((account) => { 144 - fetchFollows(account.did).then(() => 145 - follows 146 - .get(account.did) 147 - ?.forEach((follow) => fetchForInteractions(follow.subject as AtprotoDid)) 148 - ); 149 - fetchForInteractions(account.did); 150 - }); 151 - }); 147 + Promise.all($accounts.map(loginAccount)).then(() => $accounts.forEach(fetchInitial)); 152 148 } else { 153 149 selectedDid = null; 154 150 } ··· 158 154 159 155 $effect(() => { 160 156 const wantedDids: Did[] = ['did:web:guestbook.gaze.systems']; 161 - 162 - for (const followMap of follows.values()) 163 - for (const follow of followMap.values()) wantedDids.push(follow.subject); 164 - for (const account of $accounts) wantedDids.push(account.did); 165 - 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); 166 162 // console.log('updating jetstream options:', wantedDids); 167 163 $jetstream?.updateOptions({ wantedDids }); 168 164 }); ··· 273 269 <div 274 270 class=" 275 271 {['/', '/following', '/profile/:actor'].includes(router.current.path) ? '' : 'hidden'} 276 - 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 277 273 " 278 274 > 279 275 <!-- composer and error disclaimer (above thread list, not scrollable) --> 280 - <div class="footer-border-bg rounded-sm px-0.5 py-0.5"> 281 - <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"> 282 278 <AccountSelector 283 279 client={viewClient} 284 280 accounts={$accounts} ··· 315 311 316 312 <div id="footer-portal" class="contents"></div> 317 313 318 - <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"> 319 315 <div class="footer-bg rounded-t-sm"> 320 316 <div class="flex items-center gap-1.5 px-2 py-1"> 321 317 <div class="mb-2">
+2 -3
src/routes/[...catchall]/+page.ts
··· 1 - import { replaceState } from '$app/navigation'; 2 1 import { addAccount, loggingIn } from '$lib/accounts'; 3 - import { AtpClient } from '$lib/at/client'; 2 + import { AtpClient } from '$lib/at/client.svelte'; 4 3 import { flow, sessions } from '$lib/at/oauth'; 5 4 import { err, ok, type Result } from '$lib/result'; 6 5 import type { PageLoad } from './$types'; ··· 24 23 const currentUrl = new URL(window.location.href); 25 24 // scrub history so auth state cant be replayed 26 25 try { 27 - replaceState('', '/'); 26 + history.replaceState(null, '', '/'); 28 27 } catch { 29 28 // if router was unitialized then we probably dont need to scrub anyway 30 29 // so its fine