1<script lang="ts">
2 import type { AtpClient } from '$lib/at/client';
3 import { AppBskyFeedPost } from '@atcute/bluesky';
4 import type { ActorIdentifier, RecordKey } from '@atcute/lexicons';
5 import { theme } from '$lib/theme.svelte';
6 import { map, ok } from '$lib/result';
7 import type { Backlinks } from '$lib/at/constellation';
8 import { generateColorForDid } from '$lib/accounts';
9
10 interface Props {
11 client: AtpClient;
12 identifier: ActorIdentifier;
13 rkey: RecordKey;
14 replyBacklinks?: Backlinks;
15 record?: AppBskyFeedPost.Main;
16 }
17
18 const { client, identifier, rkey, record, replyBacklinks }: Props = $props();
19
20 const color = generateColorForDid(identifier) ?? theme.accent2;
21
22 let handle = $state(identifier);
23 client
24 .resolveDidDoc(identifier)
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, identifier, 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} year${years > 1 ? 's' : ''} ago`;
69 if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`;
70 if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
71 if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
72 if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
73 return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
74 };
75</script>
76
77{#await post}
78 <div
79 class="rounded-xl border-2 p-3 text-center backdrop-blur-sm"
80 style="background: {color}18; border-color: {color}66;"
81 >
82 <div
83 class="inline-block h-6 w-6 animate-spin rounded-full border-3"
84 style="border-color: {theme.accent}; border-left-color: transparent;"
85 ></div>
86 <p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p>
87 </div>
88{:then post}
89 {#if post.ok}
90 {@const record = post.value}
91 <div
92 class="rounded-xl border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]"
93 style="background: {color}18; border-color: {color}66;"
94 >
95 <div class="mb-3 flex items-center gap-1.5">
96 <span class="font-bold" style="color: {color};">
97 @{handle}
98 </span>
99 <span>·</span>
100 {#await replies}
101 <span style="color: {theme.fg}aa;">… replies</span>
102 {:then replies}
103 {#if replies.ok}
104 {@const repliesValue = replies.value}
105 <span style="color: {theme.fg}aa;">
106 {#if repliesValue.total > 0}
107 {repliesValue.total}
108 {repliesValue.total > 1 ? 'replies' : 'reply'}
109 {:else}
110 no replies
111 {/if}
112 </span>
113 {:else}
114 <span
115 title={`${replies.error}`}
116 class="max-w-[32ch] overflow-hidden text-nowrap"
117 style="color: {theme.fg}aa;">{replies.error}</span
118 >
119 {/if}
120 {/await}
121 <span>·</span>
122 <span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span>
123 </div>
124 <p class="leading-relaxed text-wrap" style="color: {theme.fg};">
125 {record.text}
126 {#if record.embed}
127 <span
128 class="rounded-full px-2.5 py-0.5 text-xs font-medium"
129 style="background: {color}22; color: {color};"
130 >
131 {getEmbedText(record.embed.$type)}
132 </span>
133 {/if}
134 </p>
135 </div>
136 {:else}
137 <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
138 <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p>
139 </div>
140 {/if}
141{/await}