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