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 { 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'; 22 import * as TID from '@atcute/tid'; 23 import type { PostWithUri } from '$lib/at/fetch'; 24 import type { Writable } from 'svelte/store'; 25 import { onMount } from 'svelte'; 26 27 interface Props { 28 client: AtpClient; 29 selectedDid: Writable<Did | null>; 30 // post 31 did: Did; 32 rkey: RecordKey; 33 // replyBacklinks?: Backlinks; 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 selectedDid, 44 did, 45 rkey, 46 data, 47 mini, 48 onQuote, 49 onReply, 50 isOnPostComposer = false /* replyBacklinks */ 51 }: Props = $props(); 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 = async (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 === $selectedDid) ?? null; 113 }; 114 115 let findAllBacklinks = async (did: Did | null) => { 116 if (!did) return; 117 if (postActions.has(`${did}:${aturi}`)) return; 118 const backlinks = await Promise.all([ 119 findBacklink('app.bsky.feed.like:subject.uri'), 120 findBacklink('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 selectedDid.subscribe(findAllBacklinks); 136 }); 137 138 const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => { 139 // console.log('toggleLink', selectedDid, link, collection); 140 if (!$selectedDid) return null; 141 const _post = await post; 142 if (!_post.ok) return null; 143 if (!link) { 144 if (_post.value.cid) { 145 const record = { 146 $type: collection, 147 subject: { 148 cid: _post.value.cid, 149 uri: aturi 150 }, 151 createdAt: new Date().toISOString() 152 }; 153 const rkey = TID.now(); 154 // todo: handle errors 155 client.atcute?.post('com.atproto.repo.createRecord', { 156 input: { 157 repo: $selectedDid, 158 collection, 159 record, 160 rkey 161 } 162 }); 163 return { 164 collection, 165 did: $selectedDid, 166 rkey 167 }; 168 } 169 } else { 170 // todo: handle errors 171 client.atcute?.post('com.atproto.repo.deleteRecord', { 172 input: { 173 repo: link.did, 174 collection: link.collection, 175 rkey: link.rkey 176 } 177 }); 178 return null; 179 } 180 return link; 181 }; 182</script> 183 184{#snippet embedBadge(record: AppBskyFeedPost.Main)} 185 {#if record.embed} 186 <span 187 class="rounded-full px-2.5 py-0.5 text-xs font-medium" 188 style="background: color-mix(in srgb, {mini 189 ? 'var(--nucleus-fg)' 190 : color} 10%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};" 191 > 192 {getEmbedText(record.embed.$type)} 193 </span> 194 {/if} 195{/snippet} 196 197{#if mini} 198 <div class="text-sm opacity-60"> 199 {#await post} 200 loading... 201 {:then post} 202 {#if post.ok} 203 {@const record = post.value.record} 204 <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)} 205 <span title={record.text}>{record.text}</span> 206 {:else} 207 {post.error} 208 {/if} 209 {/await} 210 </div> 211{:else} 212 {#await post} 213 <div 214 class="rounded-sm border-2 p-2 text-center backdrop-blur-sm" 215 style="background: {color}18; border-color: {color}66;" 216 > 217 <div 218 class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) [border-left-color:transparent]" 219 ></div> 220 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p> 221 </div> 222 {:then post} 223 {#if post.ok} 224 {@const record = post.value.record} 225 <div 226 class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all" 227 style="background: {color}{isOnPostComposer 228 ? '36' 229 : '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};" 230 > 231 <div 232 class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1" 233 style="background: {color}33;" 234 > 235 <ProfilePicture {client} {did} size={8} /> 236 237 <span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};"> 238 {#await client.getProfile(did)} 239 {handle} 240 {:then profile} 241 {#if profile.ok} 242 {@const profileValue = profile.value} 243 <span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 244 >{profileValue.displayName}</span 245 ><span class="shrink-0 text-nowrap opacity-70">(@{handle})</span> 246 {:else} 247 {handle} 248 {/if} 249 {/await} 250 </span> 251 <span>·</span> 252 <span class="text-nowrap text-(--nucleus-fg)/67" 253 >{getRelativeTime(new Date(record.createdAt))}</span 254 > 255 </div> 256 <p class="leading-relaxed text-wrap break-words"> 257 {record.text} 258 {#if isOnPostComposer} 259 {@render embedBadge(record)} 260 {/if} 261 </p> 262 {#if !isOnPostComposer && record.embed} 263 {@const embed = record.embed} 264 <div class="mt-2"> 265 {#snippet embedPost(uri: ResourceUri)} 266 {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 267 <!-- reject recursive quotes --> 268 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 269 <BskyPost 270 {selectedDid} 271 {client} 272 did={parsedUri.repo} 273 rkey={parsedUri.rkey} 274 {isOnPostComposer} 275 {onQuote} 276 {onReply} 277 /> 278 {:else} 279 <span>you think you're funny with that recursive quote but i'm onto you</span> 280 {/if} 281 {/snippet} 282 {#if embed.$type === 'app.bsky.embed.images'} 283 <!-- todo: improve how images are displayed, and pop out on click --> 284 {#each embed.images as image (image.image)} 285 {#if isBlob(image.image)} 286 <img 287 class="rounded-sm" 288 src={img('feed_thumbnail', did, image.image.ref.$link)} 289 alt={image.alt} 290 /> 291 {/if} 292 {/each} 293 {:else if embed.$type === 'app.bsky.embed.video'} 294 {#if isBlob(embed.video)} 295 {#await didDoc then didDoc} 296 {#if didDoc.ok} 297 <!-- svelte-ignore a11y_media_has_caption --> 298 <video 299 class="rounded-sm" 300 src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 301 controls 302 ></video> 303 {/if} 304 {/await} 305 {/if} 306 {:else if embed.$type === 'app.bsky.embed.record'} 307 {@render embedPost(embed.record.uri)} 308 {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 309 {@render embedPost(embed.record.record.uri)} 310 {/if} 311 <!-- todo: implement external link embeds --> 312 </div> 313 {/if} 314 {#if !isOnPostComposer} 315 {@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)} 316 {@render postControls(post.value, backlinks)} 317 {/if} 318 </div> 319 {:else} 320 <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;"> 321 <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p> 322 </div> 323 {/if} 324 {/await} 325{/if} 326 327{#snippet postControls(post: PostWithUri, backlinks?: PostActions)} 328 <div 329 class="group mt-3 flex w-fit max-w-full items-center rounded-sm" 330 style="background: {color}1f;" 331 > 332 {#snippet label( 333 name: string, 334 icon: string, 335 onClick: (link: Backlink | null | undefined) => void, 336 backlink?: Backlink | null, 337 hasSolid?: boolean 338 )} 339 <button 340 class="px-2 py-1.5 text-(--nucleus-fg)/90 hover:[backdrop-filter:brightness(120%)]" 341 onclick={() => onClick(backlink)} 342 style="color: {backlink ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 343 title={name} 344 > 345 <Icon icon={hasSolid && backlink ? `${icon}-solid` : icon} width={20} /> 346 </button> 347 {/snippet} 348 {@render label('reply', 'heroicons:chat-bubble-left', () => { 349 onReply?.(post); 350 })} 351 {@render label( 352 'repost', 353 'heroicons:arrow-path-rounded-square-20-solid', 354 async (link) => { 355 if (link === undefined) return; 356 postActions.set(`${$selectedDid!}:${aturi}`, { 357 ...backlinks!, 358 repost: await toggleLink(link, 'app.bsky.feed.repost') 359 }); 360 }, 361 backlinks?.repost 362 )} 363 {@render label('quote', 'heroicons:paper-clip-20-solid', () => { 364 onQuote?.(post); 365 })} 366 {@render label( 367 'like', 368 'heroicons:star', 369 async (link) => { 370 if (link === undefined) return; 371 postActions.set(`${$selectedDid!}:${aturi}`, { 372 ...backlinks!, 373 like: await toggleLink(link, 'app.bsky.feed.like') 374 }); 375 }, 376 backlinks?.like, 377 true 378 )} 379 </div> 380{/snippet}