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