replies timeline only, appview-less bluesky client
1<script lang="ts"> 2 import { type AtpClient } from '$lib/at/client'; 3 import { 4 AppBskyEmbedExternal, 5 AppBskyEmbedImages, 6 AppBskyEmbedVideo, 7 AppBskyFeedPost 8 } from '@atcute/bluesky'; 9 import { 10 parseCanonicalResourceUri, 11 type ActorIdentifier, 12 type CanonicalResourceUri, 13 type Did, 14 type Nsid, 15 type RecordKey, 16 type ResourceUri 17 } from '@atcute/lexicons'; 18 import { expect, ok } from '$lib/result'; 19 import { accounts, generateColorForDid } from '$lib/accounts'; 20 import ProfilePicture from './ProfilePicture.svelte'; 21 import { isBlob } from '@atcute/lexicons/interfaces'; 22 import { blob, img } from '$lib/cdn'; 23 import BskyPost from './BskyPost.svelte'; 24 import Icon from '@iconify/svelte'; 25 import { type Backlink, type BacklinksSource } from '$lib/at/constellation'; 26 import { clients, postActions, posts, pulsingPostId, type PostActions } from '$lib/state.svelte'; 27 import * as TID from '@atcute/tid'; 28 import type { PostWithUri } from '$lib/at/fetch'; 29 import { onMount } from 'svelte'; 30 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 31 import { derived } from 'svelte/store'; 32 import Device from 'svelte-device-info'; 33 import Dropdown from './Dropdown.svelte'; 34 import { type AppBskyEmbeds } from '$lib/at/types'; 35 import { settings } from '$lib/settings'; 36 37 interface Props { 38 client: AtpClient; 39 // post 40 did: Did; 41 rkey: RecordKey; 42 // replyBacklinks?: Backlinks; 43 quoteDepth?: number; 44 data?: PostWithUri; 45 mini?: boolean; 46 isOnPostComposer?: boolean; 47 onQuote?: (quote: PostWithUri) => void; 48 onReply?: (reply: PostWithUri) => void; 49 } 50 51 const { 52 client, 53 did, 54 rkey, 55 quoteDepth = 0, 56 data, 57 mini, 58 onQuote, 59 onReply, 60 isOnPostComposer = false /* replyBacklinks */ 61 }: Props = $props(); 62 63 const selectedDid = $derived(client.user?.did ?? null); 64 const actionClient = $derived(clients.get(did as AtprotoDid)); 65 66 const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; 67 const color = generateColorForDid(did); 68 69 let handle: ActorIdentifier = $state(did); 70 const didDoc = client.resolveDidDoc(did).then((res) => { 71 if (res.ok) handle = res.value.handle; 72 return res; 73 }); 74 const post = data 75 ? Promise.resolve(ok(data)) 76 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 77 // const replies = replyBacklinks 78 // ? Promise.resolve(ok(replyBacklinks)) 79 // : client.getBacklinks( 80 // identifier, 81 // 'app.bsky.feed.post', 82 // rkey, 83 // 'app.bsky.feed.post:reply.parent.uri' 84 // ); 85 86 const postId = `timeline-post-${aturi}-${quoteDepth}`; 87 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 88 89 const scrollToAndPulse = (targetUri: ResourceUri) => { 90 const targetId = `timeline-post-${targetUri}-0`; 91 console.log(`Scrolling to ${targetId}`); 92 const element = document.getElementById(targetId); 93 if (!element) return; 94 95 element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 96 97 setTimeout(() => { 98 document.documentElement.style.setProperty( 99 '--nucleus-selected-post', 100 generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) 101 ); 102 pulsingPostId.set(targetId); 103 // Clear pulse after animation 104 setTimeout(() => pulsingPostId.set(null), 1200); 105 }, 400); 106 }; 107 108 const getEmbedText = (embedType: string) => { 109 switch (embedType) { 110 case 'app.bsky.embed.external': 111 return '🔗 has external link'; 112 case 'app.bsky.embed.record': 113 return '💬 has quote'; 114 case 'app.bsky.embed.images': 115 return '🖼️ has images'; 116 case 'app.bsky.embed.video': 117 return '🎥 has video'; 118 case 'app.bsky.embed.recordWithMedia': 119 return '📎 has quote with media'; 120 default: 121 return '❓ has unknown embed'; 122 } 123 }; 124 125 const getRelativeTime = (date: Date) => { 126 const now = new Date(); 127 const diff = now.getTime() - date.getTime(); 128 const seconds = Math.floor(diff / 1000); 129 const minutes = Math.floor(seconds / 60); 130 const hours = Math.floor(minutes / 60); 131 const days = Math.floor(hours / 24); 132 const months = Math.floor(days / 30); 133 const years = Math.floor(months / 12); 134 135 if (years > 0) return `${years}y`; 136 if (months > 0) return `${months}m`; 137 if (days > 0) return `${days}d`; 138 if (hours > 0) return `${hours}h`; 139 if (minutes > 0) return `${minutes}m`; 140 if (seconds > 0) return `${seconds}s`; 141 return 'now'; 142 }; 143 144 const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => { 145 const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source); 146 if (!backlinks.ok) return null; 147 return backlinks.value.records.find((r) => r.did === toDid) ?? null; 148 }); 149 150 let findAllBacklinks = async (did: AtprotoDid | null) => { 151 if (!did) return; 152 if (postActions.has(`${did}:${aturi}`)) return; 153 const backlinks = await Promise.all([ 154 findBacklink(did, 'app.bsky.feed.like:subject.uri'), 155 findBacklink(did, 'app.bsky.feed.repost:subject.uri') 156 // findBacklink('app.bsky.feed.post:reply.parent.uri'), 157 // findBacklink('app.bsky.feed.post:embed.record.uri') 158 ]); 159 const actions: PostActions = { 160 like: backlinks[0], 161 repost: backlinks[1] 162 // reply: backlinks[2], 163 // quote: backlinks[3] 164 }; 165 console.log('findAllBacklinks', did, aturi, actions); 166 postActions.set(`${did}:${aturi}`, actions); 167 }; 168 onMount(() => { 169 // findAllBacklinks($selectedDid); 170 accounts.subscribe((accs) => { 171 accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did)); 172 }); 173 }); 174 175 const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => { 176 // console.log('toggleLink', selectedDid, link, collection); 177 if (!selectedDid) return null; 178 const _post = await post; 179 if (!_post.ok) return null; 180 if (!link) { 181 if (_post.value.cid) { 182 const record = { 183 $type: collection, 184 subject: { 185 cid: _post.value.cid, 186 uri: aturi 187 }, 188 createdAt: new Date().toISOString() 189 }; 190 const rkey = TID.now(); 191 // todo: handle errors 192 client.atcute?.post('com.atproto.repo.createRecord', { 193 input: { 194 repo: selectedDid, 195 collection, 196 record, 197 rkey 198 } 199 }); 200 return { 201 collection, 202 did: selectedDid, 203 rkey 204 }; 205 } 206 } else { 207 // todo: handle errors 208 client.atcute?.post('com.atproto.repo.deleteRecord', { 209 input: { 210 repo: link.did, 211 collection: link.collection, 212 rkey: link.rkey 213 } 214 }); 215 return null; 216 } 217 return link; 218 }; 219 220 let actionsOpen = $state(false); 221 let actionsPos = $state({ x: 0, y: 0 }); 222 223 const handleRightClick = (event: MouseEvent) => { 224 actionsOpen = true; 225 actionsPos = { x: event.clientX, y: event.clientY }; 226 event.preventDefault(); 227 }; 228 229 let deleteState: 'waiting' | 'confirm' | 'deleted' = $state('waiting'); 230 $effect(() => { 231 if (deleteState === 'confirm' && !actionsOpen) deleteState = 'waiting'; 232 }); 233 234 const deletePost = () => { 235 if (deleteState === 'deleted') return; 236 if (deleteState === 'waiting') { 237 deleteState = 'confirm'; 238 return; 239 } 240 241 actionClient?.atcute 242 ?.post('com.atproto.repo.deleteRecord', { 243 input: { 244 collection: 'app.bsky.feed.post', 245 repo: did, 246 rkey 247 } 248 }) 249 .then((result) => { 250 if (!result.ok) return; 251 posts.get(did)?.delete(aturi); 252 deleteState = 'deleted'; 253 }); 254 actionsOpen = false; 255 }; 256</script> 257 258{#snippet embedBadge(embed: AppBskyEmbeds)} 259 <span 260 class="rounded-full px-2.5 py-0.5 text-xs font-medium" 261 style=" 262 background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent); 263 color: {mini ? 'var(--nucleus-fg)' : color}; 264 " 265 > 266 {getEmbedText(embed.$type!)} 267 </span> 268{/snippet} 269 270{#if mini} 271 <div class="text-sm opacity-60"> 272 {#await post} 273 loading... 274 {:then post} 275 {#if post.ok} 276 {@const record = post.value.record} 277 <!-- svelte-ignore a11y_click_events_have_key_events --> 278 <!-- svelte-ignore a11y_no_static_element_interactions --> 279 <div 280 onclick={() => scrollToAndPulse(post.value.uri)} 281 class="select-none hover:cursor-pointer hover:underline" 282 > 283 <span style="color: {color};">@{handle}</span>: 284 {#if record.embed} 285 {@render embedBadge(record.embed)} 286 {/if} 287 <span title={record.text}>{record.text}</span> 288 </div> 289 {:else} 290 {post.error} 291 {/if} 292 {/await} 293 </div> 294{:else} 295 {#await post} 296 <div 297 class="rounded-sm border-2 p-2 text-center backdrop-blur-sm" 298 style="background: {color}18; border-color: {color}66;" 299 > 300 <div 301 class=" 302 inline-block h-6 w-6 animate-spin rounded-full 303 border-3 border-(--nucleus-accent) border-l-transparent 304 " 305 ></div> 306 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p> 307 </div> 308 {:then post} 309 {#if post.ok} 310 {@const record = post.value.record} 311 <!-- svelte-ignore a11y_no_static_element_interactions --> 312 <div 313 id="timeline-post-{post.value.uri}-{quoteDepth}" 314 oncontextmenu={handleRightClick} 315 class=" 316 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 317 {$isPulsing ? 'animate-pulse-highlight' : ''} 318 {isOnPostComposer ? 'backdrop-brightness-20' : ''} 319 " 320 style=" 321 background: {color}{isOnPostComposer 322 ? '36' 323 : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; 324 border-color: {color}{isOnPostComposer ? '99' : '66'}; 325 " 326 > 327 <div 328 class=" 329 mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1 330 " 331 style="background: {color}33;" 332 > 333 <ProfilePicture {client} {did} size={8} /> 334 335 <span 336 class=" 337 flex min-w-0 items-center gap-2 font-bold 338 {isOnPostComposer ? 'contrast-200' : ''} 339 " 340 style="color: {color};" 341 > 342 {#await client.getProfile(did)} 343 {handle} 344 {:then profile} 345 {#if profile.ok} 346 {@const profileValue = profile.value} 347 <span class="w-min min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 348 >{profileValue.displayName}</span 349 ><span class="text-nowrap opacity-70">(@{handle})</span> 350 {:else} 351 {handle} 352 {/if} 353 {/await} 354 </span> 355 <span>·</span> 356 <span class="text-nowrap text-(--nucleus-fg)/67" 357 >{getRelativeTime(new Date(record.createdAt))}</span 358 > 359 </div> 360 <p class="leading-normal text-wrap wrap-break-word"> 361 {record.text} 362 {#if isOnPostComposer && record.embed} 363 {@render embedBadge(record.embed)} 364 {/if} 365 </p> 366 {#if !isOnPostComposer && record.embed} 367 {@const embed = record.embed} 368 <div class="mt-2"> 369 {@render postEmbed(embed)} 370 </div> 371 {/if} 372 {#if !isOnPostComposer} 373 {@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)} 374 {@render postControls(post.value, backlinks)} 375 {/if} 376 </div> 377 {:else} 378 <div class="error-disclaimer"> 379 <p class="text-sm font-medium">error: {post.error}</p> 380 </div> 381 {/if} 382 {/await} 383{/if} 384 385{#snippet postEmbed(embed: AppBskyEmbeds)} 386 {#snippet embedMedia( 387 embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main 388 )} 389 {#if embed.$type === 'app.bsky.embed.images'} 390 <!-- todo: improve how images are displayed, and pop out on click --> 391 {#each embed.images as image (image.image)} 392 {#if isBlob(image.image)} 393 <img 394 class="rounded-sm" 395 src={img('feed_thumbnail', did, image.image.ref.$link)} 396 alt={image.alt} 397 /> 398 {/if} 399 {/each} 400 {:else if embed.$type === 'app.bsky.embed.video'} 401 {#if isBlob(embed.video)} 402 {#await didDoc then didDoc} 403 {#if didDoc.ok} 404 <!-- svelte-ignore a11y_media_has_caption --> 405 <video 406 class="rounded-sm" 407 src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 408 controls 409 ></video> 410 {/if} 411 {/await} 412 {/if} 413 {/if} 414 {/snippet} 415 {#snippet embedPost(uri: ResourceUri)} 416 {#if quoteDepth < 2} 417 {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 418 <!-- reject recursive quotes --> 419 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 420 <BskyPost 421 {client} 422 quoteDepth={quoteDepth + 1} 423 did={parsedUri.repo} 424 rkey={parsedUri.rkey} 425 {isOnPostComposer} 426 {onQuote} 427 {onReply} 428 /> 429 {:else} 430 <span>you think you're funny with that recursive quote but i'm onto you</span> 431 {/if} 432 {:else} 433 {@render embedBadge(embed)} 434 {/if} 435 {/snippet} 436 {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 437 {@render embedMedia(embed)} 438 {:else if embed.$type === 'app.bsky.embed.record'} 439 {@render embedPost(embed.record.uri)} 440 {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 441 <div class="space-y-1.5"> 442 {@render embedPost(embed.record.record.uri)} 443 {@render embedMedia(embed.media)} 444 </div> 445 {/if} 446 <!-- todo: implement external link embeds --> 447{/snippet} 448 449{#snippet postControls(post: PostWithUri, backlinks?: PostActions)} 450 {#snippet control( 451 name: string, 452 icon: string, 453 onClick: (e: MouseEvent) => void, 454 isFull?: boolean, 455 hasSolid?: boolean 456 )} 457 <button 458 class=" 459 px-2 py-1.5 text-(--nucleus-fg)/90 transition-all 460 duration-100 hover:[backdrop-filter:brightness(120%)] 461 " 462 onclick={(e) => onClick(e)} 463 style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 464 title={name} 465 > 466 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 467 </button> 468 {/snippet} 469 <div class="mt-3 flex w-full items-center justify-between"> 470 <div class="flex w-fit items-center rounded-sm" style="background: {color}1f;"> 471 {#snippet label( 472 name: string, 473 icon: string, 474 onClick: (link: Backlink | null | undefined) => void, 475 backlink?: Backlink | null, 476 hasSolid?: boolean 477 )} 478 {@render control(name, icon, () => onClick(backlink), backlink ? true : false, hasSolid)} 479 {/snippet} 480 {@render label('reply', 'heroicons:chat-bubble-left', () => { 481 onReply?.(post); 482 })} 483 {@render label( 484 'repost', 485 'heroicons:arrow-path-rounded-square-20-solid', 486 async (link) => { 487 if (link === undefined) return; 488 postActions.set(`${selectedDid!}:${aturi}`, { 489 ...backlinks!, 490 repost: await toggleLink(link, 'app.bsky.feed.repost') 491 }); 492 }, 493 backlinks?.repost 494 )} 495 {@render label('quote', 'heroicons:paper-clip-20-solid', () => { 496 onQuote?.(post); 497 })} 498 {@render label( 499 'like', 500 'heroicons:star', 501 async (link) => { 502 if (link === undefined) return; 503 postActions.set(`${selectedDid!}:${aturi}`, { 504 ...backlinks!, 505 like: await toggleLink(link, 'app.bsky.feed.like') 506 }); 507 }, 508 backlinks?.like, 509 true 510 )} 511 </div> 512 <Dropdown 513 class="flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60" 514 style="background: {color}36; border-color: {color}99;" 515 bind:isOpen={actionsOpen} 516 bind:position={actionsPos} 517 > 518 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 519 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) 520 )} 521 {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () => 522 navigator.clipboard.writeText(post.uri) 523 )} 524 {@render dropdownItem('heroicons:clipboard', 'copy post text', () => 525 navigator.clipboard.writeText(post.record.text) 526 )} 527 {#if actionClient} 528 <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div> 529 {@render dropdownItem( 530 deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid', 531 deleteState === 'confirm' ? 'are you sure?' : 'delete post', 532 deletePost, 533 false, 534 deleteState === 'confirm' ? 'text-red-500' : '' 535 )} 536 {/if} 537 538 {#snippet trigger()} 539 <div 540 class=" 541 w-fit items-center rounded-sm transition-opacity 542 duration-100 ease-in-out group-hover:opacity-100 543 {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''} 544 " 545 style="background: {color}1f;" 546 > 547 {@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => { 548 e.stopPropagation(); 549 actionsOpen = !actionsOpen; 550 actionsPos = { x: 0, y: 0 }; 551 })} 552 </div> 553 {/snippet} 554 </Dropdown> 555 </div> 556{/snippet} 557 558{#snippet dropdownItem( 559 icon: string, 560 label: string, 561 onClick: () => void, 562 autoClose: boolean = true, 563 extraClass: string = '' 564)} 565 <button 566 class=" 567 flex items-center justify-between rounded-sm px-2 py-1.5 568 transition-all duration-100 hover:[backdrop-filter:brightness(120%)] 569 {extraClass} 570 " 571 onclick={() => { 572 onClick(); 573 if (autoClose) actionsOpen = false; 574 }} 575 > 576 <span class="font-bold">{label}</span> 577 <Icon class="h-6 w-6" {icon} /> 578 </button> 579{/snippet}