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 { 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';
22 import * as TID from '@atcute/tid';
23 import type { PostWithUri } from '$lib/at/fetch';
24 import type { Writable } from 'svelte/store';
25 import { onMount } from 'svelte';
26
27 interface Props {
28 client: AtpClient;
29 selectedDid: Writable<Did | null>;
30 // post
31 did: Did;
32 rkey: RecordKey;
33 // replyBacklinks?: Backlinks;
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 selectedDid,
44 did,
45 rkey,
46 data,
47 mini,
48 onQuote,
49 onReply,
50 isOnPostComposer = false /* replyBacklinks */
51 }: Props = $props();
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 = async (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 === $selectedDid) ?? null;
113 };
114
115 let findAllBacklinks = async (did: Did | null) => {
116 if (!did) return;
117 if (postActions.has(`${did}:${aturi}`)) return;
118 const backlinks = await Promise.all([
119 findBacklink('app.bsky.feed.like:subject.uri'),
120 findBacklink('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 selectedDid.subscribe(findAllBacklinks);
136 });
137
138 const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
139 // console.log('toggleLink', selectedDid, link, collection);
140 if (!$selectedDid) return null;
141 const _post = await post;
142 if (!_post.ok) return null;
143 if (!link) {
144 if (_post.value.cid) {
145 const record = {
146 $type: collection,
147 subject: {
148 cid: _post.value.cid,
149 uri: aturi
150 },
151 createdAt: new Date().toISOString()
152 };
153 const rkey = TID.now();
154 // todo: handle errors
155 client.atcute?.post('com.atproto.repo.createRecord', {
156 input: {
157 repo: $selectedDid,
158 collection,
159 record,
160 rkey
161 }
162 });
163 return {
164 collection,
165 did: $selectedDid,
166 rkey
167 };
168 }
169 } else {
170 // todo: handle errors
171 client.atcute?.post('com.atproto.repo.deleteRecord', {
172 input: {
173 repo: link.did,
174 collection: link.collection,
175 rkey: link.rkey
176 }
177 });
178 return null;
179 }
180 return link;
181 };
182</script>
183
184{#snippet embedBadge(record: AppBskyFeedPost.Main)}
185 {#if record.embed}
186 <span
187 class="rounded-full px-2.5 py-0.5 text-xs font-medium"
188 style="background: color-mix(in srgb, {mini
189 ? 'var(--nucleus-fg)'
190 : color} 10%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};"
191 >
192 {getEmbedText(record.embed.$type)}
193 </span>
194 {/if}
195{/snippet}
196
197{#if mini}
198 <div class="text-sm opacity-60">
199 {#await post}
200 loading...
201 {:then post}
202 {#if post.ok}
203 {@const record = post.value.record}
204 <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
205 <span title={record.text}>{record.text}</span>
206 {:else}
207 {post.error}
208 {/if}
209 {/await}
210 </div>
211{:else}
212 {#await post}
213 <div
214 class="rounded-sm border-2 p-2 text-center backdrop-blur-sm"
215 style="background: {color}18; border-color: {color}66;"
216 >
217 <div
218 class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) [border-left-color:transparent]"
219 ></div>
220 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
221 </div>
222 {:then post}
223 {#if post.ok}
224 {@const record = post.value.record}
225 <div
226 class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all"
227 style="background: {color}{isOnPostComposer
228 ? '36'
229 : '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};"
230 >
231 <div
232 class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1"
233 style="background: {color}33;"
234 >
235 <ProfilePicture {client} {did} size={8} />
236
237 <span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};">
238 {#await client.getProfile(did)}
239 {handle}
240 {:then profile}
241 {#if profile.ok}
242 {@const profileValue = profile.value}
243 <span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
244 >{profileValue.displayName}</span
245 ><span class="shrink-0 text-nowrap opacity-70">(@{handle})</span>
246 {:else}
247 {handle}
248 {/if}
249 {/await}
250 </span>
251 <span>·</span>
252 <span class="text-nowrap text-(--nucleus-fg)/67"
253 >{getRelativeTime(new Date(record.createdAt))}</span
254 >
255 </div>
256 <p class="leading-relaxed text-wrap break-words">
257 {record.text}
258 {#if isOnPostComposer}
259 {@render embedBadge(record)}
260 {/if}
261 </p>
262 {#if !isOnPostComposer && record.embed}
263 {@const embed = record.embed}
264 <div class="mt-2">
265 {#snippet embedPost(uri: ResourceUri)}
266 {@const parsedUri = expect(parseCanonicalResourceUri(uri))}
267 <!-- reject recursive quotes -->
268 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
269 <BskyPost
270 {selectedDid}
271 {client}
272 did={parsedUri.repo}
273 rkey={parsedUri.rkey}
274 {isOnPostComposer}
275 {onQuote}
276 {onReply}
277 />
278 {:else}
279 <span>you think you're funny with that recursive quote but i'm onto you</span>
280 {/if}
281 {/snippet}
282 {#if embed.$type === 'app.bsky.embed.images'}
283 <!-- todo: improve how images are displayed, and pop out on click -->
284 {#each embed.images as image (image.image)}
285 {#if isBlob(image.image)}
286 <img
287 class="rounded-sm"
288 src={img('feed_thumbnail', did, image.image.ref.$link)}
289 alt={image.alt}
290 />
291 {/if}
292 {/each}
293 {:else if embed.$type === 'app.bsky.embed.video'}
294 {#if isBlob(embed.video)}
295 {#await didDoc then didDoc}
296 {#if didDoc.ok}
297 <!-- svelte-ignore a11y_media_has_caption -->
298 <video
299 class="rounded-sm"
300 src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
301 controls
302 ></video>
303 {/if}
304 {/await}
305 {/if}
306 {:else if embed.$type === 'app.bsky.embed.record'}
307 {@render embedPost(embed.record.uri)}
308 {:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
309 {@render embedPost(embed.record.record.uri)}
310 {/if}
311 <!-- todo: implement external link embeds -->
312 </div>
313 {/if}
314 {#if !isOnPostComposer}
315 {@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)}
316 {@render postControls(post.value, backlinks)}
317 {/if}
318 </div>
319 {:else}
320 <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
321 <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p>
322 </div>
323 {/if}
324 {/await}
325{/if}
326
327{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
328 <div
329 class="group mt-3 flex w-fit max-w-full items-center rounded-sm"
330 style="background: {color}1f;"
331 >
332 {#snippet label(
333 name: string,
334 icon: string,
335 onClick: (link: Backlink | null | undefined) => void,
336 backlink?: Backlink | null,
337 hasSolid?: boolean
338 )}
339 <button
340 class="px-2 py-1.5 text-(--nucleus-fg)/90 hover:[backdrop-filter:brightness(120%)]"
341 onclick={() => onClick(backlink)}
342 style="color: {backlink ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
343 title={name}
344 >
345 <Icon icon={hasSolid && backlink ? `${icon}-solid` : icon} width={20} />
346 </button>
347 {/snippet}
348 {@render label('reply', 'heroicons:chat-bubble-left', () => {
349 onReply?.(post);
350 })}
351 {@render label(
352 'repost',
353 'heroicons:arrow-path-rounded-square-20-solid',
354 async (link) => {
355 if (link === undefined) return;
356 postActions.set(`${$selectedDid!}:${aturi}`, {
357 ...backlinks!,
358 repost: await toggleLink(link, 'app.bsky.feed.repost')
359 });
360 },
361 backlinks?.repost
362 )}
363 {@render label('quote', 'heroicons:paper-clip-20-solid', () => {
364 onQuote?.(post);
365 })}
366 {@render label(
367 'like',
368 'heroicons:star',
369 async (link) => {
370 if (link === undefined) return;
371 postActions.set(`${$selectedDid!}:${aturi}`, {
372 ...backlinks!,
373 like: await toggleLink(link, 'app.bsky.feed.like')
374 });
375 },
376 backlinks?.like,
377 true
378 )}
379 </div>
380{/snippet}