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