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