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, 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}