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