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