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