1<script lang="ts">
2 import { type AtpClient } from '$lib/at/client';
3 import {
4 AppBskyActorProfile,
5 AppBskyEmbedExternal,
6 AppBskyEmbedImages,
7 AppBskyEmbedVideo,
8 AppBskyFeedPost
9 } from '@atcute/bluesky';
10 import {
11 parseCanonicalResourceUri,
12 type ActorIdentifier,
13 type CanonicalResourceUri,
14 type Did,
15 type Nsid,
16 type RecordKey,
17 type ResourceUri
18 } from '@atcute/lexicons';
19 import { expect, ok } from '$lib/result';
20 import { accounts, generateColorForDid } from '$lib/accounts';
21 import ProfilePicture from './ProfilePicture.svelte';
22 import { isBlob } from '@atcute/lexicons/interfaces';
23 import { blob, img } from '$lib/cdn';
24 import BskyPost from './BskyPost.svelte';
25 import Icon from '@iconify/svelte';
26 import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
27 import { clients, postActions, posts, pulsingPostId, type PostActions } from '$lib/state.svelte';
28 import * as TID from '@atcute/tid';
29 import type { PostWithUri } from '$lib/at/fetch';
30 import { onMount } from 'svelte';
31 import { isActorIdentifier, type AtprotoDid } from '@atcute/lexicons/syntax';
32 import { derived } from 'svelte/store';
33 import Device from 'svelte-device-info';
34 import Dropdown from './Dropdown.svelte';
35 import { type AppBskyEmbeds } from '$lib/at/types';
36 import { settings } from '$lib/settings';
37
38 interface Props {
39 client: AtpClient;
40 // post
41 did: Did;
42 rkey: RecordKey;
43 // replyBacklinks?: Backlinks;
44 quoteDepth?: number;
45 data?: PostWithUri;
46 mini?: boolean;
47 isOnPostComposer?: boolean;
48 onQuote?: (quote: PostWithUri) => void;
49 onReply?: (reply: PostWithUri) => void;
50 }
51
52 const {
53 client,
54 did,
55 rkey,
56 quoteDepth = 0,
57 data,
58 mini,
59 onQuote,
60 onReply,
61 isOnPostComposer = false /* replyBacklinks */
62 }: Props = $props();
63
64 const selectedDid = $derived(client.user?.did ?? null);
65 const actionClient = $derived(clients.get(did as AtprotoDid));
66
67 const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
68 const color = generateColorForDid(did);
69
70 let handle: ActorIdentifier = $state(did);
71 const didDoc = client.resolveDidDoc(did).then((res) => {
72 if (res.ok) handle = res.value.handle;
73 return res;
74 });
75 const post = data
76 ? Promise.resolve(ok(data))
77 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
78 let profile: AppBskyActorProfile.Main | null = $state(null);
79 onMount(async () => {
80 const p = await client.getProfile(did);
81 if (!p.ok) return;
82 profile = p.value;
83 console.log(profile.description);
84 });
85 // const replies = replyBacklinks
86 // ? Promise.resolve(ok(replyBacklinks))
87 // : client.getBacklinks(
88 // identifier,
89 // 'app.bsky.feed.post',
90 // rkey,
91 // 'app.bsky.feed.post:reply.parent.uri'
92 // );
93
94 const postId = `timeline-post-${aturi}-${quoteDepth}`;
95 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
96
97 const scrollToAndPulse = (targetUri: ResourceUri) => {
98 const targetId = `timeline-post-${targetUri}-0`;
99 console.log(`Scrolling to ${targetId}`);
100 const element = document.getElementById(targetId);
101 if (!element) return;
102
103 element.scrollIntoView({ behavior: 'smooth', block: 'center' });
104
105 setTimeout(() => {
106 document.documentElement.style.setProperty(
107 '--nucleus-selected-post',
108 generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo)
109 );
110 pulsingPostId.set(targetId);
111 // Clear pulse after animation
112 setTimeout(() => pulsingPostId.set(null), 1200);
113 }, 400);
114 };
115
116 const getEmbedText = (embedType: string) => {
117 switch (embedType) {
118 case 'app.bsky.embed.external':
119 return '🔗 has external link';
120 case 'app.bsky.embed.record':
121 return '💬 has quote';
122 case 'app.bsky.embed.images':
123 return '🖼️ has images';
124 case 'app.bsky.embed.video':
125 return '🎥 has video';
126 case 'app.bsky.embed.recordWithMedia':
127 return '📎 has quote with media';
128 default:
129 return '❓ has unknown embed';
130 }
131 };
132
133 const getRelativeTime = (date: Date) => {
134 const now = new Date();
135 const diff = now.getTime() - date.getTime();
136 const seconds = Math.floor(diff / 1000);
137 const minutes = Math.floor(seconds / 60);
138 const hours = Math.floor(minutes / 60);
139 const days = Math.floor(hours / 24);
140 const months = Math.floor(days / 30);
141 const years = Math.floor(months / 12);
142
143 if (years > 0) return `${years}y`;
144 if (months > 0) return `${months}m`;
145 if (days > 0) return `${days}d`;
146 if (hours > 0) return `${hours}h`;
147 if (minutes > 0) return `${minutes}m`;
148 if (seconds > 0) return `${seconds}s`;
149 return 'now';
150 };
151
152 const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => {
153 const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
154 if (!backlinks.ok) return null;
155 return backlinks.value.records.find((r) => r.did === toDid) ?? null;
156 });
157
158 let findAllBacklinks = async (did: AtprotoDid | null) => {
159 if (!did) return;
160 if (postActions.has(`${did}:${aturi}`)) return;
161 const backlinks = await Promise.all([
162 findBacklink(did, 'app.bsky.feed.like:subject.uri'),
163 findBacklink(did, 'app.bsky.feed.repost:subject.uri')
164 // findBacklink('app.bsky.feed.post:reply.parent.uri'),
165 // findBacklink('app.bsky.feed.post:embed.record.uri')
166 ]);
167 const actions: PostActions = {
168 like: backlinks[0],
169 repost: backlinks[1]
170 // reply: backlinks[2],
171 // quote: backlinks[3]
172 };
173 console.log('findAllBacklinks', did, aturi, actions);
174 postActions.set(`${did}:${aturi}`, actions);
175 };
176 onMount(() => {
177 // findAllBacklinks($selectedDid);
178 accounts.subscribe((accs) => {
179 accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did));
180 });
181 });
182
183 const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
184 // console.log('toggleLink', selectedDid, link, collection);
185 if (!selectedDid) return null;
186 const _post = await post;
187 if (!_post.ok) return null;
188 if (!link) {
189 if (_post.value.cid) {
190 const record = {
191 $type: collection,
192 subject: {
193 cid: _post.value.cid,
194 uri: aturi
195 },
196 createdAt: new Date().toISOString()
197 };
198 const rkey = TID.now();
199 // todo: handle errors
200 client.atcute?.post('com.atproto.repo.createRecord', {
201 input: {
202 repo: selectedDid,
203 collection,
204 record,
205 rkey
206 }
207 });
208 return {
209 collection,
210 did: selectedDid,
211 rkey
212 };
213 }
214 } else {
215 // todo: handle errors
216 client.atcute?.post('com.atproto.repo.deleteRecord', {
217 input: {
218 repo: link.did,
219 collection: link.collection,
220 rkey: link.rkey
221 }
222 });
223 return null;
224 }
225 return link;
226 };
227
228 let actionsOpen = $state(false);
229 let actionsPos = $state({ x: 0, y: 0 });
230
231 const handleRightClick = (event: MouseEvent) => {
232 actionsOpen = true;
233 actionsPos = { x: event.clientX, y: event.clientY };
234 event.preventDefault();
235 event.stopPropagation();
236 };
237
238 let deleteState: 'waiting' | 'confirm' | 'deleted' = $state('waiting');
239 $effect(() => {
240 if (deleteState === 'confirm' && !actionsOpen) deleteState = 'waiting';
241 });
242
243 const deletePost = () => {
244 if (deleteState === 'deleted') return;
245 if (deleteState === 'waiting') {
246 deleteState = 'confirm';
247 return;
248 }
249
250 actionClient?.atcute
251 ?.post('com.atproto.repo.deleteRecord', {
252 input: {
253 collection: 'app.bsky.feed.post',
254 repo: did,
255 rkey
256 }
257 })
258 .then((result) => {
259 if (!result.ok) return;
260 posts.get(did)?.delete(aturi);
261 deleteState = 'deleted';
262 });
263 actionsOpen = false;
264 };
265
266 let profileOpen = $state(false);
267 let profilePopoutShowDid = $state(false);
268</script>
269
270{#snippet embedBadge(embed: AppBskyEmbeds)}
271 <span
272 class="rounded-full px-2.5 py-0.5 text-xs font-medium"
273 style="
274 background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent);
275 color: {mini ? 'var(--nucleus-fg)' : color};
276 "
277 >
278 {getEmbedText(embed.$type!)}
279 </span>
280{/snippet}
281
282{#snippet profileInline()}
283 <button
284 class="
285 flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''}
286 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
287 "
288 style="color: {color};"
289 onclick={() => (profileOpen = !profileOpen)}
290 >
291 <ProfilePicture {client} {did} size={8} />
292
293 {#if profile}
294 <span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
295 >{profile.displayName}</span
296 ><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span>
297 {:else}
298 {handle}
299 {/if}
300 </button>
301{/snippet}
302
303<!-- eslint-disable svelte/no-navigation-without-resolve -->
304{#snippet profilePopout()}
305 {@const profileDesc = profile?.description?.trim() ?? ''}
306 <Dropdown
307 class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!"
308 style="background: {color}36; border-color: {color}99;"
309 bind:isOpen={profileOpen}
310 trigger={profileInline}
311 >
312 <div class="flex items-center gap-2">
313 <ProfilePicture {client} {did} size={20} />
314
315 <div class="flex flex-col items-start overflow-hidden overflow-ellipsis">
316 <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
317 {profile?.displayName ?? handle}
318 {#if profile?.pronouns}
319 <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
320 {/if}
321 </span>
322 <button
323 oncontextmenu={(e) => {
324 const node = e.target as Node;
325 const selection = window.getSelection() ?? new Selection();
326 const range = document.createRange();
327 range.selectNodeContents(node);
328 selection.removeAllRanges();
329 selection.addRange(range);
330 e.stopPropagation();
331 }}
332 onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)}
333 class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
334 >
335 {profilePopoutShowDid ? did : `@${handle}`}
336 </button>
337 {#if profile?.website}
338 <a
339 target="_blank"
340 rel="noopener noreferrer"
341 href={profile.website}
342 class="text-sm text-nowrap opacity-60">{profile.website}</a
343 >
344 {/if}
345 </div>
346 </div>
347
348 {#if profileDesc.length > 0}
349 <p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
350 {#each profileDesc.split(/(\s)/) as line, idx (idx)}
351 {#if line === '\n'}
352 <br />
353 {:else if isActorIdentifier(line.replace(/^@/, ''))}
354 <a
355 target="_blank"
356 rel="noopener noreferrer"
357 class="text-(--nucleus-accent2)"
358 href={`${$settings.socialAppUrl}/profile/${line.replace(/^@/, '')}`}>{line}</a
359 >
360 {:else if line.startsWith('https://')}
361 <a
362 target="_blank"
363 rel="noopener noreferrer"
364 class="text-(--nucleus-accent2)"
365 href={line}>{line.replace(/https?:\/\//, '')}</a
366 >
367 {:else}
368 {line}
369 {/if}
370 {/each}
371 </p>
372 {/if}
373 </Dropdown>
374{/snippet}
375
376{#if mini}
377 <div class="text-sm opacity-60">
378 {#await post}
379 loading...
380 {:then post}
381 {#if post.ok}
382 {@const record = post.value.record}
383 <!-- svelte-ignore a11y_click_events_have_key_events -->
384 <!-- svelte-ignore a11y_no_static_element_interactions -->
385 <div
386 onclick={() => scrollToAndPulse(post.value.uri)}
387 class="select-none hover:cursor-pointer hover:underline"
388 >
389 <span style="color: {color};">@{handle}</span>:
390 {#if record.embed}
391 {@render embedBadge(record.embed)}
392 {/if}
393 <span title={record.text}>{record.text}</span>
394 </div>
395 {:else}
396 {post.error}
397 {/if}
398 {/await}
399 </div>
400{:else}
401 {#await post}
402 <div
403 class="rounded-sm border-2 p-2 text-center backdrop-blur-sm"
404 style="background: {color}18; border-color: {color}66;"
405 >
406 <div
407 class="
408 inline-block h-6 w-6 animate-spin rounded-full
409 border-3 border-(--nucleus-accent) border-l-transparent
410 "
411 ></div>
412 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
413 </div>
414 {:then post}
415 {#if post.ok}
416 {@const record = post.value.record}
417 <!-- svelte-ignore a11y_no_static_element_interactions -->
418 <div
419 id="timeline-post-{post.value.uri}-{quoteDepth}"
420 oncontextmenu={handleRightClick}
421 class="
422 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all
423 {$isPulsing ? 'animate-pulse-highlight' : ''}
424 {isOnPostComposer ? 'backdrop-brightness-20' : ''}
425 "
426 style="
427 background: {color}{isOnPostComposer
428 ? '36'
429 : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)};
430 border-color: {color}{isOnPostComposer ? '99' : '66'};
431 "
432 >
433 <div
434 class="
435 mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1
436 "
437 style="background: {color}33;"
438 >
439 {@render profilePopout()}
440 <span>·</span>
441 <span
442 title={new Date(record.createdAt).toLocaleString()}
443 class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
444 >
445 {getRelativeTime(new Date(record.createdAt))}
446 </span>
447 </div>
448 <p class="leading-normal text-wrap wrap-break-word">
449 {record.text}
450 {#if isOnPostComposer && record.embed}
451 {@render embedBadge(record.embed)}
452 {/if}
453 </p>
454 {#if !isOnPostComposer && record.embed}
455 {@const embed = record.embed}
456 <div class="mt-2">
457 {@render postEmbed(embed)}
458 </div>
459 {/if}
460 {#if !isOnPostComposer}
461 {@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)}
462 {@render postControls(post.value, backlinks)}
463 {/if}
464 </div>
465 {:else}
466 <div class="error-disclaimer">
467 <p class="text-sm font-medium">error: {post.error}</p>
468 </div>
469 {/if}
470 {/await}
471{/if}
472
473{#snippet postEmbed(embed: AppBskyEmbeds)}
474 {#snippet embedMedia(
475 embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
476 )}
477 <!-- svelte-ignore a11y_no_static_element_interactions -->
478 <div oncontextmenu={(e) => e.stopPropagation()}>
479 {#if embed.$type === 'app.bsky.embed.images'}
480 <!-- todo: improve how images are displayed, and pop out on click -->
481 {#each embed.images as image (image.image)}
482 {#if isBlob(image.image)}
483 <img
484 class="w-full rounded-sm"
485 src={img('feed_thumbnail', did, image.image.ref.$link)}
486 alt={image.alt}
487 />
488 {/if}
489 {/each}
490 {:else if embed.$type === 'app.bsky.embed.video'}
491 {#if isBlob(embed.video)}
492 {#await didDoc then didDoc}
493 {#if didDoc.ok}
494 <!-- svelte-ignore a11y_media_has_caption -->
495 <video
496 class="rounded-sm"
497 src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
498 controls
499 ></video>
500 {/if}
501 {/await}
502 {/if}
503 {/if}
504 </div>
505 {/snippet}
506 {#snippet embedPost(uri: ResourceUri)}
507 {#if quoteDepth < 2}
508 {@const parsedUri = expect(parseCanonicalResourceUri(uri))}
509 <!-- reject recursive quotes -->
510 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
511 <BskyPost
512 {client}
513 quoteDepth={quoteDepth + 1}
514 did={parsedUri.repo}
515 rkey={parsedUri.rkey}
516 {isOnPostComposer}
517 {onQuote}
518 {onReply}
519 />
520 {:else}
521 <span>you think you're funny with that recursive quote but i'm onto you</span>
522 {/if}
523 {:else}
524 {@render embedBadge(embed)}
525 {/if}
526 {/snippet}
527 {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
528 {@render embedMedia(embed)}
529 {:else if embed.$type === 'app.bsky.embed.record'}
530 {@render embedPost(embed.record.uri)}
531 {:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
532 <div class="space-y-1.5">
533 {@render embedPost(embed.record.record.uri)}
534 {@render embedMedia(embed.media)}
535 </div>
536 {/if}
537 <!-- todo: implement external link embeds -->
538{/snippet}
539
540{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
541 {#snippet control(
542 name: string,
543 icon: string,
544 onClick: (e: MouseEvent) => void,
545 isFull?: boolean,
546 hasSolid?: boolean
547 )}
548 <button
549 class="
550 px-2 py-1.5 text-(--nucleus-fg)/90 transition-all
551 duration-100 hover:[backdrop-filter:brightness(120%)]
552 "
553 onclick={(e) => onClick(e)}
554 style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
555 title={name}
556 >
557 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} />
558 </button>
559 {/snippet}
560 <div class="mt-3 flex w-full items-center justify-between">
561 <div class="flex w-fit items-center rounded-sm" style="background: {color}1f;">
562 {#snippet label(
563 name: string,
564 icon: string,
565 onClick: (link: Backlink | null | undefined) => void,
566 backlink?: Backlink | null,
567 hasSolid?: boolean
568 )}
569 {@render control(name, icon, () => onClick(backlink), backlink ? true : false, hasSolid)}
570 {/snippet}
571 {@render label('reply', 'heroicons:chat-bubble-left', () => {
572 onReply?.(post);
573 })}
574 {@render label(
575 'repost',
576 'heroicons:arrow-path-rounded-square-20-solid',
577 async (link) => {
578 if (link === undefined) return;
579 postActions.set(`${selectedDid!}:${aturi}`, {
580 ...backlinks!,
581 repost: await toggleLink(link, 'app.bsky.feed.repost')
582 });
583 },
584 backlinks?.repost
585 )}
586 {@render label('quote', 'heroicons:paper-clip-20-solid', () => {
587 onQuote?.(post);
588 })}
589 {@render label(
590 'like',
591 'heroicons:star',
592 async (link) => {
593 if (link === undefined) return;
594 postActions.set(`${selectedDid!}:${aturi}`, {
595 ...backlinks!,
596 like: await toggleLink(link, 'app.bsky.feed.like')
597 });
598 },
599 backlinks?.like,
600 true
601 )}
602 </div>
603 <Dropdown
604 class="post-dropdown"
605 style="background: {color}36; border-color: {color}99;"
606 bind:isOpen={actionsOpen}
607 bind:position={actionsPos}
608 placement="bottom-end"
609 >
610 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () =>
611 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`)
612 )}
613 {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () =>
614 navigator.clipboard.writeText(post.uri)
615 )}
616 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () =>
617 navigator.clipboard.writeText(post.record.text)
618 )}
619 {#if actionClient}
620 <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
621 {@render dropdownItem(
622 deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
623 deleteState === 'confirm' ? 'are you sure?' : 'delete post',
624 deletePost,
625 false,
626 deleteState === 'confirm' ? 'text-red-500' : ''
627 )}
628 {/if}
629
630 {#snippet trigger()}
631 <div
632 class="
633 w-fit items-center rounded-sm transition-opacity
634 duration-100 ease-in-out group-hover:opacity-100
635 {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''}
636 "
637 style="background: {color}1f;"
638 >
639 {@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => {
640 e.stopPropagation();
641 actionsOpen = !actionsOpen;
642 actionsPos = { x: 0, y: 0 };
643 })}
644 </div>
645 {/snippet}
646 </Dropdown>
647 </div>
648{/snippet}
649
650{#snippet dropdownItem(
651 icon: string,
652 label: string,
653 onClick: () => void,
654 autoClose: boolean = true,
655 extraClass: string = ''
656)}
657 <button
658 class="
659 flex items-center justify-between rounded-sm px-2 py-1.5
660 transition-all duration-100 hover:[backdrop-filter:brightness(120%)]
661 {extraClass}
662 "
663 onclick={() => {
664 onClick();
665 if (autoClose) actionsOpen = false;
666 }}
667 >
668 <span class="font-bold">{label}</span>
669 <Icon class="h-6 w-6" {icon} />
670 </button>
671{/snippet}
672
673<style>
674 @reference "../app.css";
675
676 :global(.post-dropdown) {
677 @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;
678 }
679</style>