appview-less bluesky client
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: profile popout

dusk a4f73517 cadf8f49

+156 -49
+6
src/app.css
··· 51 51 @apply hover:cursor-pointer; 52 52 } 53 53 54 + a { 55 + &:hover { 56 + @apply cursor-pointer underline; 57 + } 58 + } 59 + 54 60 .grain:before { 55 61 content: ''; 56 62 background-color: transparent;
+1
src/components/AccountSelector.svelte
··· 93 93 <Dropdown 94 94 class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" 95 95 bind:isOpen={isDropdownOpen} 96 + placement="top-start" 96 97 > 97 98 {#snippet trigger()} 98 99 <button
+149 -49
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 2 import { type AtpClient } from '$lib/at/client'; 3 3 import { 4 + AppBskyActorProfile, 4 5 AppBskyEmbedExternal, 5 6 AppBskyEmbedImages, 6 7 AppBskyEmbedVideo, ··· 27 28 import * as TID from '@atcute/tid'; 28 29 import type { PostWithUri } from '$lib/at/fetch'; 29 30 import { onMount } from 'svelte'; 30 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 31 + import { isActorIdentifier, type AtprotoDid } from '@atcute/lexicons/syntax'; 31 32 import { derived } from 'svelte/store'; 32 33 import Device from 'svelte-device-info'; 33 34 import Dropdown from './Dropdown.svelte'; ··· 74 75 const post = data 75 76 ? Promise.resolve(ok(data)) 76 77 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 78 + let profile: AppBskyActorProfile.Main | null = $state(null); 79 + onMount(async () => { 80 + const p = await client.getProfile(did); 81 + if (!p.ok) return; 82 + profile = p.value; 83 + console.log(profile.description); 84 + }); 77 85 // const replies = replyBacklinks 78 86 // ? Promise.resolve(ok(replyBacklinks)) 79 87 // : client.getBacklinks( ··· 224 232 actionsOpen = true; 225 233 actionsPos = { x: event.clientX, y: event.clientY }; 226 234 event.preventDefault(); 235 + event.stopPropagation(); 227 236 }; 228 237 229 238 let deleteState: 'waiting' | 'confirm' | 'deleted' = $state('waiting'); ··· 253 262 }); 254 263 actionsOpen = false; 255 264 }; 265 + 266 + let profileOpen = $state(false); 267 + let profilePopoutShowDid = $state(false); 256 268 </script> 257 269 258 270 {#snippet embedBadge(embed: AppBskyEmbeds)} ··· 267 279 </span> 268 280 {/snippet} 269 281 282 + {#snippet profileInline()} 283 + <button 284 + class=" 285 + flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''} 286 + rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 287 + " 288 + style="color: {color};" 289 + onclick={() => (profileOpen = !profileOpen)} 290 + > 291 + <ProfilePicture {client} {did} size={8} /> 292 + 293 + {#if profile} 294 + <span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 295 + >{profile.displayName}</span 296 + ><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span> 297 + {:else} 298 + {handle} 299 + {/if} 300 + </button> 301 + {/snippet} 302 + 303 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 304 + {#snippet profilePopout()} 305 + {@const profileDesc = profile?.description?.trim() ?? ''} 306 + <Dropdown 307 + class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!" 308 + style="background: {color}36; border-color: {color}99;" 309 + bind:isOpen={profileOpen} 310 + trigger={profileInline} 311 + > 312 + <div class="flex items-center gap-2"> 313 + <ProfilePicture {client} {did} size={20} /> 314 + 315 + <div class="flex flex-col items-start overflow-hidden overflow-ellipsis"> 316 + <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 317 + {profile?.displayName ?? handle} 318 + {#if profile?.pronouns} 319 + <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 320 + {/if} 321 + </span> 322 + <button 323 + oncontextmenu={(e) => { 324 + const node = e.target as Node; 325 + const selection = window.getSelection() ?? new Selection(); 326 + const range = document.createRange(); 327 + range.selectNodeContents(node); 328 + selection.removeAllRanges(); 329 + selection.addRange(range); 330 + e.stopPropagation(); 331 + }} 332 + onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)} 333 + class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 334 + > 335 + {profilePopoutShowDid ? did : `@${handle}`} 336 + </button> 337 + {#if profile?.website} 338 + <a 339 + target="_blank" 340 + rel="noopener noreferrer" 341 + href={profile.website} 342 + class="text-sm text-nowrap opacity-60">{profile.website}</a 343 + > 344 + {/if} 345 + </div> 346 + </div> 347 + 348 + {#if profileDesc.length > 0} 349 + <p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 350 + {#each profileDesc.split(/(\s)/) as line, idx (idx)} 351 + {#if line === '\n'} 352 + <br /> 353 + {:else if isActorIdentifier(line.replace(/^@/, ''))} 354 + <a 355 + target="_blank" 356 + rel="noopener noreferrer" 357 + class="text-(--nucleus-accent2)" 358 + href={`${$settings.socialAppUrl}/profile/${line.replace(/^@/, '')}`}>{line}</a 359 + > 360 + {:else if line.startsWith('https://')} 361 + <a 362 + target="_blank" 363 + rel="noopener noreferrer" 364 + class="text-(--nucleus-accent2)" 365 + href={line}>{line.replace(/https?:\/\//, '')}</a 366 + > 367 + {:else} 368 + {line} 369 + {/if} 370 + {/each} 371 + </p> 372 + {/if} 373 + </Dropdown> 374 + {/snippet} 375 + 270 376 {#if mini} 271 377 <div class="text-sm opacity-60"> 272 378 {#await post} ··· 326 432 > 327 433 <div 328 434 class=" 329 - mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1 435 + mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1 330 436 " 331 437 style="background: {color}33;" 332 438 > 333 - <ProfilePicture {client} {did} size={8} /> 334 - 439 + {@render profilePopout()} 440 + <span>·</span> 335 441 <span 336 - class=" 337 - flex min-w-0 items-center gap-2 font-bold 338 - {isOnPostComposer ? 'contrast-200' : ''} 339 - " 340 - style="color: {color};" 442 + title={new Date(record.createdAt).toLocaleString()} 443 + class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 341 444 > 342 - {#await client.getProfile(did)} 343 - {handle} 344 - {:then profile} 345 - {#if profile.ok} 346 - {@const profileValue = profile.value} 347 - <span class="w-min min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 348 - >{profileValue.displayName}</span 349 - ><span class="text-nowrap opacity-70">(@{handle})</span> 350 - {:else} 351 - {handle} 352 - {/if} 353 - {/await} 445 + {getRelativeTime(new Date(record.createdAt))} 354 446 </span> 355 - <span>·</span> 356 - <span class="text-nowrap text-(--nucleus-fg)/67" 357 - >{getRelativeTime(new Date(record.createdAt))}</span 358 - > 359 447 </div> 360 448 <p class="leading-normal text-wrap wrap-break-word"> 361 449 {record.text} ··· 386 474 {#snippet embedMedia( 387 475 embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main 388 476 )} 389 - {#if embed.$type === 'app.bsky.embed.images'} 390 - <!-- todo: improve how images are displayed, and pop out on click --> 391 - {#each embed.images as image (image.image)} 392 - {#if isBlob(image.image)} 393 - <img 394 - class="rounded-sm" 395 - src={img('feed_thumbnail', did, image.image.ref.$link)} 396 - alt={image.alt} 397 - /> 398 - {/if} 399 - {/each} 400 - {:else if embed.$type === 'app.bsky.embed.video'} 401 - {#if isBlob(embed.video)} 402 - {#await didDoc then didDoc} 403 - {#if didDoc.ok} 404 - <!-- svelte-ignore a11y_media_has_caption --> 405 - <video 406 - class="rounded-sm" 407 - src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 408 - controls 409 - ></video> 477 + <!-- svelte-ignore a11y_no_static_element_interactions --> 478 + <div oncontextmenu={(e) => e.stopPropagation()}> 479 + {#if embed.$type === 'app.bsky.embed.images'} 480 + <!-- todo: improve how images are displayed, and pop out on click --> 481 + {#each embed.images as image (image.image)} 482 + {#if isBlob(image.image)} 483 + <img 484 + class="w-full rounded-sm" 485 + src={img('feed_thumbnail', did, image.image.ref.$link)} 486 + alt={image.alt} 487 + /> 410 488 {/if} 411 - {/await} 489 + {/each} 490 + {:else if embed.$type === 'app.bsky.embed.video'} 491 + {#if isBlob(embed.video)} 492 + {#await didDoc then didDoc} 493 + {#if didDoc.ok} 494 + <!-- svelte-ignore a11y_media_has_caption --> 495 + <video 496 + class="rounded-sm" 497 + src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 498 + controls 499 + ></video> 500 + {/if} 501 + {/await} 502 + {/if} 412 503 {/if} 413 - {/if} 504 + </div> 414 505 {/snippet} 415 506 {#snippet embedPost(uri: ResourceUri)} 416 507 {#if quoteDepth < 2} ··· 510 601 )} 511 602 </div> 512 603 <Dropdown 513 - class="flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60" 604 + class="post-dropdown" 514 605 style="background: {color}36; border-color: {color}99;" 515 606 bind:isOpen={actionsOpen} 516 607 bind:position={actionsPos} 608 + placement="bottom-end" 517 609 > 518 610 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 519 611 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) ··· 577 669 <Icon class="h-6 w-6" {icon} /> 578 670 </button> 579 671 {/snippet} 672 + 673 + <style> 674 + @reference "../app.css"; 675 + 676 + :global(.post-dropdown) { 677 + @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 678 + } 679 + </style>