replies timeline only, appview-less bluesky client
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}