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