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 Did,
8 type RecordKey,
9 type ResourceUri
10 } from '@atcute/lexicons';
11 import { expect, ok } from '$lib/result';
12 import { generateColorForDid } from '$lib/accounts';
13 import ProfilePicture from './ProfilePicture.svelte';
14 import { isBlob } from '@atcute/lexicons/interfaces';
15 import { blob, img } from '$lib/cdn';
16 import BskyPost from './BskyPost.svelte';
17
18 interface Props {
19 client: AtpClient;
20 did: Did;
21 rkey: RecordKey;
22 // replyBacklinks?: Backlinks;
23 record?: AppBskyFeedPost.Main;
24 mini?: boolean;
25 }
26
27 const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props();
28
29 const color = generateColorForDid(did);
30
31 let handle: ActorIdentifier = $state(did);
32 const didDoc = client.resolveDidDoc(did).then((res) => {
33 if (res.ok) handle = res.value.handle;
34 return res;
35 });
36 const post = record
37 ? Promise.resolve(ok(record))
38 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
39 // const replies = replyBacklinks
40 // ? Promise.resolve(ok(replyBacklinks))
41 // : client.getBacklinks(
42 // identifier,
43 // 'app.bsky.feed.post',
44 // rkey,
45 // 'app.bsky.feed.post:reply.parent.uri'
46 // );
47
48 const getEmbedText = (embedType: string) => {
49 switch (embedType) {
50 case 'app.bsky.embed.external':
51 return '🔗 has external link';
52 case 'app.bsky.embed.record':
53 return '💬 has quote';
54 case 'app.bsky.embed.images':
55 return '🖼️ has images';
56 case 'app.bsky.embed.video':
57 return '🎥 has video';
58 case 'app.bsky.embed.recordWithMedia':
59 return '📎 has quote with media';
60 default:
61 return '❓ has unknown embed';
62 }
63 };
64
65 const getRelativeTime = (date: Date) => {
66 const now = new Date();
67 const diff = now.getTime() - date.getTime();
68 const seconds = Math.floor(diff / 1000);
69 const minutes = Math.floor(seconds / 60);
70 const hours = Math.floor(minutes / 60);
71 const days = Math.floor(hours / 24);
72 const months = Math.floor(days / 30);
73 const years = Math.floor(months / 12);
74
75 if (years > 0) return `${years}y`;
76 if (months > 0) return `${months}m`;
77 if (days > 0) return `${days}d`;
78 if (hours > 0) return `${hours}h`;
79 if (minutes > 0) return `${minutes}m`;
80 if (seconds > 0) return `${seconds}s`;
81 return 'just now';
82 };
83</script>
84
85{#snippet embedBadge(record: AppBskyFeedPost.Main)}
86 {#if record.embed}
87 <span
88 class="rounded-full px-2.5 py-0.5 text-xs font-medium"
89 style="background: color-mix(in srgb, {mini
90 ? 'var(--nucleus-fg)'
91 : color} 13%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};"
92 >
93 {getEmbedText(record.embed.$type)}
94 </span>
95 {/if}
96{/snippet}
97
98{#if mini}
99 <div class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60">
100 {#await post}
101 loading...
102 {:then post}
103 {#if post.ok}
104 {@const record = post.value}
105 <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
106 <span title={record.text}>{record.text}</span>
107 {:else}
108 {post.error}
109 {/if}
110 {/await}
111 </div>
112{:else}
113 {#await post}
114 <div
115 class="rounded-sm border-2 p-2 text-center backdrop-blur-sm"
116 style="background: {color}18; border-color: {color}66;"
117 >
118 <div
119 class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) [border-left-color:transparent]"
120 ></div>
121 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
122 </div>
123 {:then post}
124 {#if post.ok}
125 {@const record = post.value}
126 <div
127 class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all"
128 style="background: {color}18; border-color: {color}66;"
129 >
130 <div
131 class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1"
132 style="background: {color}33;"
133 >
134 <ProfilePicture {client} {did} size={8} />
135
136 <span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};">
137 {#await client.getProfile(did)}
138 {handle}
139 {:then profile}
140 {#if profile.ok}
141 {@const profileValue = profile.value}
142 <span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
143 >{profileValue.displayName}</span
144 ><span class="shrink-0 text-nowrap">(@{handle})</span>
145 {:else}
146 {handle}
147 {/if}
148 {/await}
149 </span>
150
151 <!-- <span>·</span>
152 {#await replies}
153 <span style="color: {theme.fg}aa;">… replies</span>
154 {:then replies}
155 {#if replies.ok}
156 {@const repliesValue = replies.value}
157 <span style="color: {theme.fg}aa;">
158 {#if repliesValue.total > 0}
159 {repliesValue.total}
160 {repliesValue.total > 1 ? 'replies' : 'reply'}
161 {:else}
162 no replies
163 {/if}
164 </span>
165 {:else}
166 <span
167 title={`${replies.error}`}
168 class="max-w-[32ch] overflow-hidden text-nowrap"
169 style="color: {theme.fg}aa;">{replies.error}</span
170 >
171 {/if}
172 {/await} -->
173 <span>·</span>
174 <span class="text-nowrap text-(--nucleus-fg)/67"
175 >{getRelativeTime(new Date(record.createdAt))}</span
176 >
177 </div>
178 <p class="leading-relaxed text-wrap">
179 {record.text}
180 </p>
181 {#if record.embed}
182 {@const embed = record.embed}
183 <div class="mt-2">
184 {#snippet embedPost(uri: ResourceUri)}
185 {@const parsedUri = expect(parseCanonicalResourceUri(uri))}
186 <!-- reject recursive quotes -->
187 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
188 <BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} />
189 {:else}
190 <span>you think you're funny with that recursive quote but i'm onto you</span>
191 {/if}
192 {/snippet}
193 {#if embed.$type === 'app.bsky.embed.images'}
194 <!-- todo: improve how images are displayed, and pop out on click -->
195 {#each embed.images as image (image.image)}
196 {#if isBlob(image.image)}
197 <img
198 class="rounded-sm"
199 src={img('feed_thumbnail', did, image.image.ref.$link)}
200 alt={image.alt}
201 />
202 {/if}
203 {/each}
204 {:else if embed.$type === 'app.bsky.embed.video'}
205 {#if isBlob(embed.video)}
206 {#await didDoc then didDoc}
207 {#if didDoc.ok}
208 <!-- svelte-ignore a11y_media_has_caption -->
209 <video
210 class="rounded-sm"
211 src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
212 controls
213 ></video>
214 {/if}
215 {/await}
216 {/if}
217 {:else if embed.$type === 'app.bsky.embed.record'}
218 {@render embedPost(embed.record.uri)}
219 {:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
220 {@render embedPost(embed.record.record.uri)}
221 {/if}
222 <!-- todo: implement external link embeds -->
223 </div>
224 {/if}
225 </div>
226 {:else}
227 <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
228 <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p>
229 </div>
230 {/if}
231 {/await}
232{/if}