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