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