1<script lang="ts">
2 import type { AtpClient } from '$lib/at/client';
3 import { AppBskyFeedPost } from '@atcute/bluesky';
4 import type { ActorIdentifier, Did, RecordKey } from '@atcute/lexicons';
5 import { map, ok } from '$lib/result';
6 import { generateColorForDid } from '$lib/accounts';
7 import ProfilePicture from './ProfilePicture.svelte';
8
9 interface Props {
10 client: AtpClient;
11 did: Did;
12 rkey: RecordKey;
13 // replyBacklinks?: Backlinks;
14 record?: AppBskyFeedPost.Main;
15 mini?: boolean;
16 }
17
18 const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props();
19
20 const color = generateColorForDid(did);
21
22 let handle: ActorIdentifier = $state(did);
23 client
24 .resolveDidDoc(did)
25 .then((res) => map(res, (data) => data.handle))
26 .then((res) => {
27 if (res.ok) handle = res.value;
28 });
29 const post = record
30 ? Promise.resolve(ok(record))
31 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
32 // const replies = replyBacklinks
33 // ? Promise.resolve(ok(replyBacklinks))
34 // : client.getBacklinks(
35 // identifier,
36 // 'app.bsky.feed.post',
37 // rkey,
38 // 'app.bsky.feed.post:reply.parent.uri'
39 // );
40
41 const getEmbedText = (embedType: string) => {
42 switch (embedType) {
43 case 'app.bsky.embed.external':
44 return '🔗 has external link';
45 case 'app.bsky.embed.record':
46 return '💬 has quote';
47 case 'app.bsky.embed.images':
48 return '🖼️ has images';
49 case 'app.bsky.embed.video':
50 return '🎥 has video';
51 case 'app.bsky.embed.recordWithMedia':
52 return '📎 has quote with media';
53 default:
54 return '❓ has unknown embed';
55 }
56 };
57
58 const getRelativeTime = (date: Date) => {
59 const now = new Date();
60 const diff = now.getTime() - date.getTime();
61 const seconds = Math.floor(diff / 1000);
62 const minutes = Math.floor(seconds / 60);
63 const hours = Math.floor(minutes / 60);
64 const days = Math.floor(hours / 24);
65 const months = Math.floor(days / 30);
66 const years = Math.floor(months / 12);
67
68 if (years > 0) return `${years}y`;
69 if (months > 0) return `${months}m`;
70 if (days > 0) return `${days}d`;
71 if (hours > 0) return `${hours}h`;
72 if (minutes > 0) return `${minutes}m`;
73 if (seconds > 0) return `${seconds}s`;
74 return 'just now';
75 };
76</script>
77
78{#snippet embedBadge(record: AppBskyFeedPost.Main)}
79 {#if record.embed}
80 <span
81 class="rounded-full px-2.5 py-0.5 text-xs font-medium"
82 style="background: color-mix(in srgb, {mini
83 ? 'var(--nucleus-fg)'
84 : color} 13%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};"
85 >
86 {getEmbedText(record.embed.$type)}
87 </span>
88 {/if}
89{/snippet}
90
91{#if mini}
92 <div class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60">
93 {#await post}
94 loading...
95 {:then post}
96 {#if post.ok}
97 {@const record = post.value}
98 <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
99 <span title={record.text}>{record.text}</span>
100 {:else}
101 {post.error}
102 {/if}
103 {/await}
104 </div>
105{:else}
106 {#await post}
107 <div
108 class="rounded-sm border-2 p-2 text-center backdrop-blur-sm"
109 style="background: {color}18; border-color: {color}66;"
110 >
111 <div
112 class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) [border-left-color:transparent]"
113 ></div>
114 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
115 </div>
116 {:then post}
117 {#if post.ok}
118 {@const record = post.value}
119 <div
120 class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all"
121 style="background: {color}18; border-color: {color}66;"
122 >
123 <div
124 class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1"
125 style="background: {color}33;"
126 >
127 <ProfilePicture {client} {did} size={8} />
128
129 <span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};">
130 {#await client.getProfile(did)}
131 {handle}
132 {:then profile}
133 {#if profile.ok}
134 {@const profileValue = profile.value}
135 <span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
136 >{profileValue.displayName}</span
137 ><span class="shrink-0 text-nowrap">(@{handle})</span>
138 {:else}
139 {handle}
140 {/if}
141 {/await}
142 </span>
143
144 <!-- <span>·</span>
145 {#await replies}
146 <span style="color: {theme.fg}aa;">… replies</span>
147 {:then replies}
148 {#if replies.ok}
149 {@const repliesValue = replies.value}
150 <span style="color: {theme.fg}aa;">
151 {#if repliesValue.total > 0}
152 {repliesValue.total}
153 {repliesValue.total > 1 ? 'replies' : 'reply'}
154 {:else}
155 no replies
156 {/if}
157 </span>
158 {:else}
159 <span
160 title={`${replies.error}`}
161 class="max-w-[32ch] overflow-hidden text-nowrap"
162 style="color: {theme.fg}aa;">{replies.error}</span
163 >
164 {/if}
165 {/await} -->
166 <span>·</span>
167 <span class="text-nowrap text-(--nucleus-fg)/67"
168 >{getRelativeTime(new Date(record.createdAt))}</span
169 >
170 </div>
171 <p class="leading-relaxed text-wrap">
172 {record.text}
173 {@render embedBadge(record)}
174 </p>
175 </div>
176 {:else}
177 <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
178 <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p>
179 </div>
180 {/if}
181 {/await}
182{/if}