replies timeline only, appview-less bluesky client

refactor some embeds out of bsky pos

ptr.pet 3389d4f6 c0ff5074

verified
+41 -167
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 2 import { resolveDidDoc, type AtpClient } from '$lib/at/client'; 3 - import { 4 - AppBskyActorProfile, 5 - AppBskyEmbedExternal, 6 - AppBskyEmbedImages, 7 - AppBskyEmbedVideo, 8 - AppBskyFeedPost 9 - } from '@atcute/bluesky'; 3 + import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; 10 4 import { 11 5 parseCanonicalResourceUri, 12 6 type Did, ··· 17 11 import { expect, ok } from '$lib/result'; 18 12 import { accounts, generateColorForDid } from '$lib/accounts'; 19 13 import ProfilePicture from './ProfilePicture.svelte'; 20 - import { isBlob } from '@atcute/lexicons/interfaces'; 21 - import { blob, img } from '$lib/cdn'; 22 14 import BskyPost from './BskyPost.svelte'; 23 15 import Icon from '@iconify/svelte'; 24 16 import { ··· 37 29 import { derived } from 'svelte/store'; 38 30 import Device from 'svelte-device-info'; 39 31 import Dropdown from './Dropdown.svelte'; 40 - import { type AppBskyEmbeds } from '$lib/at/types'; 41 32 import { settings } from '$lib/settings'; 42 33 import RichText from './RichText.svelte'; 43 34 import { getRelativeTime } from '$lib/date'; 44 35 import { likeSource, repostSource, toCanonicalUri } from '$lib'; 45 36 import ProfileInfo from './ProfileInfo.svelte'; 46 - import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte'; 37 + import EmbedBadge from './EmbedBadge.svelte'; 38 + import EmbedMedia from './EmbedMedia.svelte'; 47 39 48 40 interface Props { 49 41 client: AtpClient; ··· 80 72 const color = $derived(generateColorForDid(did)); 81 73 82 74 let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); 83 - const didDoc = resolveDidDoc(did).then((res) => { 84 - if (res.ok) { 85 - handle = res.value.handle; 86 - handles.set(did, handle); 87 - } 88 - return res; 75 + onMount(() => { 76 + resolveDidDoc(did).then((res) => { 77 + if (res.ok) { 78 + handle = res.value.handle; 79 + handles.set(did, handle); 80 + } 81 + return res; 82 + }); 89 83 }); 90 84 const post = data 91 85 ? Promise.resolve(ok(data)) ··· 120 114 }, 400); 121 115 }; 122 116 123 - const getEmbedText = (embedType: string) => { 124 - switch (embedType) { 125 - case 'app.bsky.embed.external': 126 - return '🔗 has external link'; 127 - case 'app.bsky.embed.record': 128 - return '💬 has quote'; 129 - case 'app.bsky.embed.images': 130 - return '🖼️ has images'; 131 - case 'app.bsky.embed.video': 132 - return '🎥 has video'; 133 - case 'app.bsky.embed.recordWithMedia': 134 - return '📎 has quote with media'; 135 - default: 136 - return '❓ has unknown embed'; 137 - } 138 - }; 139 - 140 117 let actionsOpen = $state(false); 141 118 let actionsPos = $state({ x: 0, y: 0 }); 142 119 ··· 178 155 let profileOpen = $state(false); 179 156 </script> 180 157 181 - {#snippet embedBadge(embed: AppBskyEmbeds)} 182 - <span 183 - class="rounded-full px-2.5 py-0.5 text-xs font-medium" 184 - style=" 185 - background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent); 186 - color: {mini ? 'var(--nucleus-fg)' : color}; 187 - " 188 - > 189 - {getEmbedText(embed.$type!)} 190 - </span> 191 - {/snippet} 192 - 193 158 {#snippet profileInline()} 194 159 <button 195 160 class=" ··· 239 204 > 240 205 <span style="color: {color};">@{handle}</span>: 241 206 {#if record.embed} 242 - {@render embedBadge(record.embed)} 207 + <EmbedBadge embed={record.embed} /> 243 208 {/if} 244 209 <span title={record.text}>{record.text}</span> 245 210 </div> ··· 298 263 <p class="leading-normal text-wrap wrap-break-word"> 299 264 <RichText text={record.text} facets={record.facets ?? []} /> 300 265 {#if isOnPostComposer && record.embed} 301 - {@render embedBadge(record.embed)} 266 + <EmbedBadge embed={record.embed} {color} /> 302 267 {/if} 303 268 </p> 304 269 {#if !isOnPostComposer && record.embed} 305 270 {@const embed = record.embed} 306 271 <div class="mt-2"> 307 - {@render postEmbed(embed)} 272 + {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 273 + <EmbedMedia {did} {embed} /> 274 + {:else if embed.$type === 'app.bsky.embed.record'} 275 + {@render embedPost(embed.record.uri)} 276 + {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 277 + <div class="space-y-1.5"> 278 + <EmbedMedia {did} embed={embed.media} /> 279 + {@render embedPost(embed.record.record.uri)} 280 + </div> 281 + {/if} 308 282 </div> 309 283 {/if} 310 284 {#if !isOnPostComposer} ··· 319 293 {/await} 320 294 {/if} 321 295 322 - {#snippet postEmbed(embed: AppBskyEmbeds)} 323 - {#snippet embedMedia( 324 - embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main 325 - )} 326 - <!-- svelte-ignore a11y_no_static_element_interactions --> 327 - <div oncontextmenu={(e) => e.stopPropagation()}> 328 - {#if embed.$type === 'app.bsky.embed.images'} 329 - {@const _images = embed.images.flatMap((img) => 330 - isBlob(img.image) ? [{ ...img, image: img.image }] : [] 331 - )} 332 - {@const images = _images.map((i): GalleryItem => { 333 - const sizeFactor = 200; 334 - const size = { 335 - width: (i.aspectRatio?.width ?? 4) * sizeFactor, 336 - height: (i.aspectRatio?.height ?? 3) * sizeFactor 337 - }; 338 - return { 339 - ...size, 340 - src: img('feed_fullsize', did, i.image.ref.$link), 341 - thumbnail: { 342 - src: img('feed_thumbnail', did, i.image.ref.$link), 343 - ...size 344 - } 345 - }; 346 - })} 347 - <PhotoSwipeGallery {images} /> 348 - {:else if embed.$type === 'app.bsky.embed.video'} 349 - {#if isBlob(embed.video)} 350 - {#await didDoc then didDoc} 351 - {#if didDoc.ok} 352 - <!-- svelte-ignore a11y_media_has_caption --> 353 - <video 354 - class="rounded-sm" 355 - src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 356 - controls 357 - ></video> 358 - {/if} 359 - {/await} 360 - {/if} 361 - {/if} 362 - </div> 363 - {/snippet} 364 - {#snippet embedPost(uri: ResourceUri)} 365 - {#if quoteDepth < 2} 366 - {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 367 - <!-- reject recursive quotes --> 368 - {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 369 - <BskyPost 370 - {client} 371 - quoteDepth={quoteDepth + 1} 372 - did={parsedUri.repo} 373 - rkey={parsedUri.rkey} 374 - {isOnPostComposer} 375 - {onQuote} 376 - {onReply} 377 - /> 378 - {:else} 379 - <span>you think you're funny with that recursive quote but i'm onto you</span> 380 - {/if} 296 + {#snippet embedPost(uri: ResourceUri)} 297 + {#if quoteDepth < 2} 298 + {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 299 + <!-- reject recursive quotes --> 300 + {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 301 + <BskyPost 302 + {client} 303 + quoteDepth={quoteDepth + 1} 304 + did={parsedUri.repo} 305 + rkey={parsedUri.rkey} 306 + {isOnPostComposer} 307 + {onQuote} 308 + {onReply} 309 + /> 381 310 {:else} 382 - {@render embedBadge(embed)} 311 + <span>you think you're funny with that recursive quote but i'm onto you</span> 383 312 {/if} 384 - {/snippet} 385 - {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 386 - {@render embedMedia(embed)} 387 - {:else if embed.$type === 'app.bsky.embed.record'} 388 - {@render embedPost(embed.record.uri)} 389 - {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 390 - <div class="space-y-1.5"> 391 - {@render embedPost(embed.record.record.uri)} 392 - {@render embedMedia(embed.media)} 393 - </div> 313 + {:else} 314 + <EmbedBadge embed={{ $type: 'app.bsky.embed.record' } as AppBskyEmbedRecord.Main} /> 394 315 {/if} 395 316 {/snippet} 396 317 ··· 531 452 if (autoClose) actionsOpen = false; 532 453 }} 533 454 > 534 - <span class="font-bold">{label}</span> 455 + <span class="font-semibold opacity-85">{label}</span> 535 456 <Icon class="h-6 w-6" {icon} /> 536 457 </button> 537 458 {/snippet} ··· 541 462 542 463 :global(.post-dropdown) { 543 464 @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 544 - } 545 - 546 - .image-grid { 547 - display: grid; 548 - gap: 2px; 549 - border-radius: 0.375rem; 550 - overflow: hidden; 551 - max-height: 500px; 552 - } 553 - 554 - /* 1 image: full width */ 555 - .image-grid.count-1 { 556 - grid-template-columns: 1fr; 557 - } 558 - 559 - /* 2 images: side by side */ 560 - .image-grid.count-2 { 561 - grid-template-columns: repeat(2, 1fr); 562 - } 563 - 564 - /* 3 images: first spans left, two stack on right */ 565 - .image-grid.count-3 { 566 - grid-template-columns: repeat(2, 1fr); 567 - grid-template-rows: repeat(2, 1fr); 568 - } 569 - .image-grid.count-3 a:first-child { 570 - grid-row: 1 / 3; 571 - } 572 - 573 - /* 4+ images: 2x2 grid */ 574 - .image-grid.count-4, 575 - .image-grid.count-5 { 576 - grid-template-columns: repeat(2, 1fr); 577 - grid-template-rows: repeat(2, 1fr); 578 - } 579 - 580 - .image-item { 581 - width: 100%; 582 - height: 100%; 583 - object-fit: cover; 584 - display: block; 585 - cursor: pointer; 586 - transition: opacity 0.2s; 587 - } 588 - 589 - .image-item:hover { 590 - opacity: 0.9; 591 465 } 592 466 </style>
+37
src/components/EmbedBadge.svelte
··· 1 + <script lang="ts"> 2 + import type { AppBskyEmbeds } from '$lib/at/types'; 3 + 4 + interface Props { 5 + embed: AppBskyEmbeds; 6 + color?: string; 7 + } 8 + 9 + let { embed, color = 'var(--nucleus-fg)' }: Props = $props(); 10 + 11 + const embedText = $derived.by(() => { 12 + switch (embed.$type) { 13 + case 'app.bsky.embed.external': 14 + return '🔗 has external link'; 15 + case 'app.bsky.embed.record': 16 + return '💬 has quote'; 17 + case 'app.bsky.embed.images': 18 + return '🖼️ has images'; 19 + case 'app.bsky.embed.video': 20 + return '🎥 has video'; 21 + case 'app.bsky.embed.recordWithMedia': 22 + return '📎 has quote with media'; 23 + default: 24 + return '❓ has unknown embed'; 25 + } 26 + }); 27 + </script> 28 + 29 + <span 30 + class="rounded-full px-2.5 py-0.5 text-xs font-medium" 31 + style=" 32 + background: color-mix(in srgb, {color} 10%, transparent); 33 + color: {color}; 34 + " 35 + > 36 + {embedText} 37 + </span>
+53
src/components/EmbedMedia.svelte
··· 1 + <script lang="ts"> 2 + import { AppBskyEmbedExternal, AppBskyEmbedImages, AppBskyEmbedVideo } from '@atcute/bluesky'; 3 + import { isBlob } from '@atcute/lexicons/interfaces'; 4 + import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte'; 5 + import { blob, img } from '$lib/cdn'; 6 + import { type Did } from '@atcute/lexicons'; 7 + import { resolveDidDoc } from '$lib/at/client'; 8 + 9 + interface Props { 10 + did: Did; 11 + embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main; 12 + } 13 + 14 + let { did, embed }: Props = $props(); 15 + </script> 16 + 17 + <!-- svelte-ignore a11y_no_static_element_interactions --> 18 + <div oncontextmenu={(e) => e.stopPropagation()}> 19 + {#if embed.$type === 'app.bsky.embed.images'} 20 + {@const _images = embed.images.flatMap((img) => 21 + isBlob(img.image) ? [{ ...img, image: img.image }] : [] 22 + )} 23 + {@const images = _images.map((i): GalleryItem => { 24 + const sizeFactor = 200; 25 + const size = { 26 + width: (i.aspectRatio?.width ?? 4) * sizeFactor, 27 + height: (i.aspectRatio?.height ?? 3) * sizeFactor 28 + }; 29 + return { 30 + ...size, 31 + src: img('feed_fullsize', did, i.image.ref.$link), 32 + thumbnail: { 33 + src: img('feed_thumbnail', did, i.image.ref.$link), 34 + ...size 35 + } 36 + }; 37 + })} 38 + <PhotoSwipeGallery {images} /> 39 + {:else if embed.$type === 'app.bsky.embed.video'} 40 + {#if isBlob(embed.video)} 41 + {#await resolveDidDoc(did) then didDoc} 42 + {#if didDoc.ok} 43 + <!-- svelte-ignore a11y_media_has_caption --> 44 + <video 45 + class="rounded-sm" 46 + src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 47 + controls 48 + ></video> 49 + {/if} 50 + {/await} 51 + {/if} 52 + {/if} 53 + </div>
+2 -1
src/components/PhotoSwipeGallery.svelte
··· 100 100 gap: 2px; 101 101 border-radius: 4px; 102 102 overflow: hidden; 103 - width: 100%; 103 + width: fit-content; 104 104 } 105 105 106 106 .gallery.styling-twitter > a { ··· 125 125 .gallery.styling-twitter[data-total='1'] { 126 126 display: block; /* Remove grid constraints */ 127 127 height: auto; 128 + width: fit-content; 128 129 aspect-ratio: auto; /* Remove 16:9 ratio */ 129 130 border-radius: 0; 130 131 }