replies timeline only, appview-less bluesky client
1<script lang="ts"> 2 import { type AtpClient } from '$lib/at/client'; 3 import { AppBskyFeedPost } from '@atcute/bluesky'; 4 import { 5 parseCanonicalResourceUri, 6 type ActorIdentifier, 7 type CanonicalResourceUri, 8 type Did, 9 type Nsid, 10 type RecordKey, 11 type ResourceUri 12 } from '@atcute/lexicons'; 13 import { expect, ok } from '$lib/result'; 14 import { accounts, generateColorForDid } from '$lib/accounts'; 15 import ProfilePicture from './ProfilePicture.svelte'; 16 import { isBlob } from '@atcute/lexicons/interfaces'; 17 import { blob, img } from '$lib/cdn'; 18 import BskyPost from './BskyPost.svelte'; 19 import Icon from '@iconify/svelte'; 20 import { type Backlink, type BacklinksSource } from '$lib/at/constellation'; 21 import { postActions, pulsingPostId, type PostActions } from '$lib/state.svelte'; 22 import * as TID from '@atcute/tid'; 23 import type { PostWithUri } from '$lib/at/fetch'; 24 import { onMount } from 'svelte'; 25 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 26 import { derived } from 'svelte/store'; 27 28 interface Props { 29 client: AtpClient; 30 // post 31 did: Did; 32 rkey: RecordKey; 33 // replyBacklinks?: Backlinks; 34 quoteDepth?: number; 35 data?: PostWithUri; 36 mini?: boolean; 37 isOnPostComposer?: boolean; 38 onQuote?: (quote: PostWithUri) => void; 39 onReply?: (reply: PostWithUri) => void; 40 } 41 42 const { 43 client, 44 did, 45 rkey, 46 quoteDepth = 0, 47 data, 48 mini, 49 onQuote, 50 onReply, 51 isOnPostComposer = false /* replyBacklinks */ 52 }: Props = $props(); 53 54 const selectedDid = $derived(client.didDoc?.did ?? null); 55 56 const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; 57 const color = generateColorForDid(did); 58 59 let handle: ActorIdentifier = $state(did); 60 const didDoc = client.resolveDidDoc(did).then((res) => { 61 if (res.ok) handle = res.value.handle; 62 return res; 63 }); 64 const post = data 65 ? Promise.resolve(ok(data)) 66 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 67 // const replies = replyBacklinks 68 // ? Promise.resolve(ok(replyBacklinks)) 69 // : client.getBacklinks( 70 // identifier, 71 // 'app.bsky.feed.post', 72 // rkey, 73 // 'app.bsky.feed.post:reply.parent.uri' 74 // ); 75 76 const postId = `timeline-post-${aturi}-${quoteDepth}`; 77 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 78 79 const scrollToAndPulse = (targetUri: ResourceUri) => { 80 const targetId = `timeline-post-${targetUri}-0`; 81 console.log(`Scrolling to ${targetId}`); 82 const element = document.getElementById(targetId); 83 if (!element) return; 84 85 element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 86 87 setTimeout(() => { 88 document.documentElement.style.setProperty( 89 '--nucleus-selected-post', 90 generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) 91 ); 92 pulsingPostId.set(targetId); 93 // Clear pulse after animation 94 setTimeout(() => pulsingPostId.set(null), 1200); 95 }, 400); 96 }; 97 98 const getEmbedText = (embedType: string) => { 99 switch (embedType) { 100 case 'app.bsky.embed.external': 101 return '🔗 has external link'; 102 case 'app.bsky.embed.record': 103 return '💬 has quote'; 104 case 'app.bsky.embed.images': 105 return '🖼️ has images'; 106 case 'app.bsky.embed.video': 107 return '🎥 has video'; 108 case 'app.bsky.embed.recordWithMedia': 109 return '📎 has quote with media'; 110 default: 111 return '❓ has unknown embed'; 112 } 113 }; 114 115 const getRelativeTime = (date: Date) => { 116 const now = new Date(); 117 const diff = now.getTime() - date.getTime(); 118 const seconds = Math.floor(diff / 1000); 119 const minutes = Math.floor(seconds / 60); 120 const hours = Math.floor(minutes / 60); 121 const days = Math.floor(hours / 24); 122 const months = Math.floor(days / 30); 123 const years = Math.floor(months / 12); 124 125 if (years > 0) return `${years}y`; 126 if (months > 0) return `${months}m`; 127 if (days > 0) return `${days}d`; 128 if (hours > 0) return `${hours}h`; 129 if (minutes > 0) return `${minutes}m`; 130 if (seconds > 0) return `${seconds}s`; 131 return 'now'; 132 }; 133 134 const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => { 135 const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source); 136 if (!backlinks.ok) return null; 137 return backlinks.value.records.find((r) => r.did === toDid) ?? null; 138 }); 139 140 let findAllBacklinks = async (did: AtprotoDid | null) => { 141 if (!did) return; 142 if (postActions.has(`${did}:${aturi}`)) return; 143 const backlinks = await Promise.all([ 144 findBacklink(did, 'app.bsky.feed.like:subject.uri'), 145 findBacklink(did, 'app.bsky.feed.repost:subject.uri') 146 // findBacklink('app.bsky.feed.post:reply.parent.uri'), 147 // findBacklink('app.bsky.feed.post:embed.record.uri') 148 ]); 149 const actions: PostActions = { 150 like: backlinks[0], 151 repost: backlinks[1] 152 // reply: backlinks[2], 153 // quote: backlinks[3] 154 }; 155 console.log('findAllBacklinks', did, aturi, actions); 156 postActions.set(`${did}:${aturi}`, actions); 157 }; 158 onMount(() => { 159 // findAllBacklinks($selectedDid); 160 accounts.subscribe((accs) => { 161 accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did)); 162 }); 163 }); 164 165 const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => { 166 // console.log('toggleLink', selectedDid, link, collection); 167 if (!selectedDid) return null; 168 const _post = await post; 169 if (!_post.ok) return null; 170 if (!link) { 171 if (_post.value.cid) { 172 const record = { 173 $type: collection, 174 subject: { 175 cid: _post.value.cid, 176 uri: aturi 177 }, 178 createdAt: new Date().toISOString() 179 }; 180 const rkey = TID.now(); 181 // todo: handle errors 182 client.atcute?.post('com.atproto.repo.createRecord', { 183 input: { 184 repo: selectedDid, 185 collection, 186 record, 187 rkey 188 } 189 }); 190 return { 191 collection, 192 did: selectedDid, 193 rkey 194 }; 195 } 196 } else { 197 // todo: handle errors 198 client.atcute?.post('com.atproto.repo.deleteRecord', { 199 input: { 200 repo: link.did, 201 collection: link.collection, 202 rkey: link.rkey 203 } 204 }); 205 return null; 206 } 207 return link; 208 }; 209</script> 210 211{#snippet embedBadge(record: AppBskyFeedPost.Main)} 212 {#if record.embed} 213 <span 214 class="rounded-full px-2.5 py-0.5 text-xs font-medium" 215 style="background: color-mix(in srgb, {mini 216 ? 'var(--nucleus-fg)' 217 : color} 10%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};" 218 > 219 {getEmbedText(record.embed.$type)} 220 </span> 221 {/if} 222{/snippet} 223 224{#if mini} 225 <div class="text-sm opacity-60"> 226 {#await post} 227 loading... 228 {:then post} 229 {#if post.ok} 230 {@const record = post.value.record} 231 <!-- svelte-ignore a11y_click_events_have_key_events --> 232 <!-- svelte-ignore a11y_no_static_element_interactions --> 233 <div 234 onclick={() => scrollToAndPulse(post.value.uri)} 235 class="select-none hover:cursor-pointer hover:underline" 236 > 237 <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)} 238 <span title={record.text}>{record.text}</span> 239 </div> 240 {:else} 241 {post.error} 242 {/if} 243 {/await} 244 </div> 245{:else} 246 {#await post} 247 <div 248 class="rounded-sm border-2 p-2 text-center backdrop-blur-sm" 249 style="background: {color}18; border-color: {color}66;" 250 > 251 <div 252 class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) border-l-transparent" 253 ></div> 254 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p> 255 </div> 256 {:then post} 257 {#if post.ok} 258 {@const record = post.value.record} 259 <div 260 id="timeline-post-{post.value.uri}-{quoteDepth}" 261 class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all {$isPulsing 262 ? 'animate-pulse-highlight' 263 : ''}" 264 style="background: {color}{isOnPostComposer 265 ? '36' 266 : '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};" 267 > 268 <div 269 class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1" 270 style="background: {color}33;" 271 > 272 <ProfilePicture {client} {did} size={8} /> 273 274 <span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};"> 275 {#await client.getProfile(did)} 276 {handle} 277 {:then profile} 278 {#if profile.ok} 279 {@const profileValue = profile.value} 280 <span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 281 >{profileValue.displayName}</span 282 ><span class="shrink-0 text-nowrap opacity-70">(@{handle})</span> 283 {:else} 284 {handle} 285 {/if} 286 {/await} 287 </span> 288 <span>·</span> 289 <span class="text-nowrap text-(--nucleus-fg)/67" 290 >{getRelativeTime(new Date(record.createdAt))}</span 291 > 292 </div> 293 <p class="leading-relaxed text-wrap wrap-break-word"> 294 {record.text} 295 {#if isOnPostComposer} 296 {@render embedBadge(record)} 297 {/if} 298 </p> 299 {#if !isOnPostComposer && record.embed} 300 {@const embed = record.embed} 301 <div class="mt-2"> 302 {#snippet embedPost(uri: ResourceUri)} 303 {#if quoteDepth < 2} 304 {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 305 <!-- reject recursive quotes --> 306 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 307 <BskyPost 308 {client} 309 quoteDepth={quoteDepth + 1} 310 did={parsedUri.repo} 311 rkey={parsedUri.rkey} 312 {isOnPostComposer} 313 {onQuote} 314 {onReply} 315 /> 316 {:else} 317 <span>you think you're funny with that recursive quote but i'm onto you</span> 318 {/if} 319 {:else} 320 {@render embedBadge(record)} 321 {/if} 322 {/snippet} 323 {#if embed.$type === 'app.bsky.embed.images'} 324 <!-- todo: improve how images are displayed, and pop out on click --> 325 {#each embed.images as image (image.image)} 326 {#if isBlob(image.image)} 327 <img 328 class="rounded-sm" 329 src={img('feed_thumbnail', did, image.image.ref.$link)} 330 alt={image.alt} 331 /> 332 {/if} 333 {/each} 334 {:else if embed.$type === 'app.bsky.embed.video'} 335 {#if isBlob(embed.video)} 336 {#await didDoc then didDoc} 337 {#if didDoc.ok} 338 <!-- svelte-ignore a11y_media_has_caption --> 339 <video 340 class="rounded-sm" 341 src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 342 controls 343 ></video> 344 {/if} 345 {/await} 346 {/if} 347 {:else if embed.$type === 'app.bsky.embed.record'} 348 {@render embedPost(embed.record.uri)} 349 {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 350 {@render embedPost(embed.record.record.uri)} 351 {/if} 352 <!-- todo: implement external link embeds --> 353 </div> 354 {/if} 355 {#if !isOnPostComposer} 356 {@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)} 357 {@render postControls(post.value, backlinks)} 358 {/if} 359 </div> 360 {:else} 361 <div class="error-disclaimer"> 362 <p class="text-sm font-medium">error: {post.error}</p> 363 </div> 364 {/if} 365 {/await} 366{/if} 367 368{#snippet postControls(post: PostWithUri, backlinks?: PostActions)} 369 <div 370 class="group mt-3 flex w-fit max-w-full items-center rounded-sm" 371 style="background: {color}1f;" 372 > 373 {#snippet label( 374 name: string, 375 icon: string, 376 onClick: (link: Backlink | null | undefined) => void, 377 backlink?: Backlink | null, 378 hasSolid?: boolean 379 )} 380 <button 381 class="px-2 py-1.5 text-(--nucleus-fg)/90 hover:[backdrop-filter:brightness(120%)]" 382 onclick={() => onClick(backlink)} 383 style="color: {backlink ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 384 title={name} 385 > 386 <Icon icon={hasSolid && backlink ? `${icon}-solid` : icon} width={20} /> 387 </button> 388 {/snippet} 389 {@render label('reply', 'heroicons:chat-bubble-left', () => { 390 onReply?.(post); 391 })} 392 {@render label( 393 'repost', 394 'heroicons:arrow-path-rounded-square-20-solid', 395 async (link) => { 396 if (link === undefined) return; 397 postActions.set(`${selectedDid!}:${aturi}`, { 398 ...backlinks!, 399 repost: await toggleLink(link, 'app.bsky.feed.repost') 400 }); 401 }, 402 backlinks?.repost 403 )} 404 {@render label('quote', 'heroicons:paper-clip-20-solid', () => { 405 onQuote?.(post); 406 })} 407 {@render label( 408 'like', 409 'heroicons:star', 410 async (link) => { 411 if (link === undefined) return; 412 postActions.set(`${selectedDid!}:${aturi}`, { 413 ...backlinks!, 414 like: await toggleLink(link, 'app.bsky.feed.like') 415 }); 416 }, 417 backlinks?.like, 418 true 419 )} 420 </div> 421{/snippet}