appview-less bluesky client
24
fork

Configure Feed

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

feat: add dropdown context menu for posts with a few actions

dusk 7bc65983 6dbd17ca

+275 -185
+5 -8
deno.lock
··· 33 33 "npm:svelte-awesome-color-picker@^4.1.0": "4.1.0_svelte@5.43.2__acorn@8.15.0", 34 34 "npm:svelte-check@^4.3.3": "4.3.3_svelte@5.43.2__acorn@8.15.0_typescript@5.9.3", 35 35 "npm:svelte-device-info@^1.0.6": "1.0.6", 36 - "npm:svelte-floating-ui@^1.6.2": "1.6.2", 37 36 "npm:svelte-infinite@~0.5.1": "0.5.1_svelte@5.43.2__acorn@8.15.0", 37 + "npm:svelte-portal@^2.2.1": "2.2.1", 38 38 "npm:svelte@^5.43.2": "5.43.2_acorn@8.15.0", 39 39 "npm:tailwindcss@^4.1.16": "4.1.16", 40 40 "npm:typescript-eslint@^8.46.3": "8.46.3_eslint@9.39.0_typescript@5.9.3_@typescript-eslint+parser@8.46.3__eslint@9.39.0__typescript@5.9.3", ··· 1661 1661 "svelte" 1662 1662 ] 1663 1663 }, 1664 - "svelte-floating-ui@1.6.2": { 1665 - "integrity": "sha512-EC+DZtBey50P6l3NSzNQWon3cip8a1bzwdpmCdc45kymqEWL4BKhPemAq7SQ9QLebDPaMECW6YodxFbs2d+O/w==", 1666 - "dependencies": [ 1667 - "@floating-ui/dom" 1668 - ] 1669 - }, 1670 1664 "svelte-infinite@0.5.1_svelte@5.43.2__acorn@8.15.0": { 1671 1665 "integrity": "sha512-NvpYWrHPcLHZQMnqUXgKGpOSMq9kMQ6sa8+WO80jLrgBFX+LWoKvAsrc1d1g+eiaagNAE9HalWWJ4KDtYi/+sw==", 1672 1666 "dependencies": [ 1673 1667 "svelte" 1674 1668 ] 1669 + }, 1670 + "svelte-portal@2.2.1": { 1671 + "integrity": "sha512-uF7is5sM4aq5iN7QF/67XLnTUvQCf2iiG/B1BHTqLwYVY1dsVmTeXZ/LeEyU6dLjApOQdbEG9lkqHzxiQtOLEQ==" 1675 1672 }, 1676 1673 "svelte@5.43.2_acorn@8.15.0": { 1677 1674 "integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==", ··· 1839 1836 "npm:svelte-awesome-color-picker@^4.1.0", 1840 1837 "npm:svelte-check@^4.3.3", 1841 1838 "npm:svelte-device-info@^1.0.6", 1842 - "npm:svelte-floating-ui@^1.6.2", 1843 1839 "npm:svelte-infinite@~0.5.1", 1840 + "npm:svelte-portal@^2.2.1", 1844 1841 "npm:svelte@^5.43.2", 1845 1842 "npm:tailwindcss@^4.1.16", 1846 1843 "npm:typescript-eslint@^8.46.3",
+2 -1
package.json
··· 28 28 "hash-wasm": "^4.12.0", 29 29 "lru-cache": "^11.2.2", 30 30 "svelte-device-info": "^1.0.6", 31 - "svelte-infinite": "^0.5.1" 31 + "svelte-infinite": "^0.5.1", 32 + "svelte-portal": "^2.2.1" 32 33 }, 33 34 "devDependencies": { 34 35 "@eslint/compat": "^1.4.1",
+52 -53
src/components/AccountSelector.svelte
··· 90 90 }; 91 91 </script> 92 92 93 - <Dropdown bind:isOpen={isDropdownOpen}> 93 + <Dropdown 94 + class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" 95 + bind:isOpen={isDropdownOpen} 96 + > 94 97 {#snippet trigger()} 95 98 <button 96 99 onclick={toggleDropdown} ··· 104 107 </button> 105 108 {/snippet} 106 109 107 - <div 108 - class="min-w-52 animate-fade-in-scale-fast overflow-hidden rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg)/94 shadow-2xl backdrop-blur-lg transition-all" 109 - > 110 - {#if accounts.length > 0} 111 - <div class="p-2"> 112 - {#each accounts as account (account.did)} 113 - {@const color = generateColorForDid(account.did)} 114 - {#snippet action(name: string, icon: string, onClick: () => void)} 115 - <!-- svelte-ignore a11y_click_events_have_key_events --> 116 - <!-- svelte-ignore a11y_no_static_element_interactions --> 117 - <div 118 - title={name} 119 - onclick={onClick} 120 - class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 121 - > 122 - <Icon class="h-5 w-5" {icon} /> 123 - </div> 124 - {/snippet} 125 - <button 126 - onclick={() => selectAccount(account.did)} 127 - class=" 128 - group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 129 - {account.did === selectedDid ? 'shadow-lg' : ''} 130 - " 131 - style="color: {color}; background: {account.did === selectedDid 132 - ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 133 - : 'transparent'};" 110 + {#if accounts.length > 0} 111 + <div class="p-2"> 112 + {#each accounts as account (account.did)} 113 + {@const color = generateColorForDid(account.did)} 114 + {#snippet action(name: string, icon: string, onClick: () => void)} 115 + <!-- svelte-ignore a11y_click_events_have_key_events --> 116 + <!-- svelte-ignore a11y_no_static_element_interactions --> 117 + <div 118 + title={name} 119 + onclick={onClick} 120 + class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 134 121 > 135 - <span>@{account.handle}</span> 122 + <Icon class="h-5 w-5" {icon} /> 123 + </div> 124 + {/snippet} 125 + <button 126 + onclick={() => selectAccount(account.did)} 127 + class=" 128 + group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 129 + {account.did === selectedDid ? 'shadow-lg' : ''} 130 + " 131 + style="color: {color}; background: {account.did === selectedDid 132 + ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 133 + : 'transparent'};" 134 + > 135 + <span>@{account.handle}</span> 136 136 137 - <div class="grow"></div> 137 + <div class="grow"></div> 138 138 139 - {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => 140 - initiateLogin(account.did, account.handle) 141 - )} 142 - {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} 139 + {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => 140 + initiateLogin(account.did, account.handle) 141 + )} 142 + {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} 143 143 144 - {#if account.did === selectedDid} 145 - <Icon 146 - icon="heroicons:check-16-solid" 147 - class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" 148 - /> 149 - {/if} 150 - </button> 151 - {/each} 152 - </div> 153 - <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 154 - {/if} 155 - <button 156 - onclick={openLoginModal} 157 - class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]" 158 - > 159 - <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> 160 - <span>add account</span> 161 - </button> 162 - </div> 144 + {#if account.did === selectedDid} 145 + <Icon 146 + icon="heroicons:check-16-solid" 147 + class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" 148 + /> 149 + {/if} 150 + </button> 151 + {/each} 152 + </div> 153 + <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 154 + {/if} 155 + <button 156 + onclick={openLoginModal} 157 + class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]" 158 + > 159 + <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> 160 + <span>add account</span> 161 + </button> 163 162 </Dropdown> 164 163 165 164 <Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account">
+154 -96
src/components/BskyPost.svelte
··· 31 31 import { derived } from 'svelte/store'; 32 32 import Device from 'svelte-device-info'; 33 33 import Dropdown from './Dropdown.svelte'; 34 + import { type AppBskyEmbeds } from '$lib/at/types'; 35 + import { settings } from '$lib/settings'; 34 36 35 37 interface Props { 36 38 client: AtpClient; ··· 215 217 }; 216 218 217 219 let actionsOpen = $state(false); 220 + let actionsPos = $state({ x: 0, y: 0 }); 221 + 222 + const handleRightClick = (event: MouseEvent) => { 223 + actionsOpen = true; 224 + actionsPos = { x: event.clientX, y: event.clientY }; 225 + event.preventDefault(); 226 + }; 218 227 </script> 219 228 220 - {#snippet embedBadge(record: AppBskyFeedPost.Main)} 221 - {#if record.embed} 222 - <span 223 - class="rounded-full px-2.5 py-0.5 text-xs font-medium" 224 - style=" 225 - background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent); 226 - color: {mini ? 'var(--nucleus-fg)' : color}; 227 - " 228 - > 229 - {getEmbedText(record.embed.$type)} 230 - </span> 231 - {/if} 229 + {#snippet embedBadge(embed: AppBskyEmbeds)} 230 + <span 231 + class="rounded-full px-2.5 py-0.5 text-xs font-medium" 232 + style=" 233 + background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent); 234 + color: {mini ? 'var(--nucleus-fg)' : color}; 235 + " 236 + > 237 + {getEmbedText(embed.$type!)} 238 + </span> 232 239 {/snippet} 233 240 234 241 {#if mini} ··· 244 251 onclick={() => scrollToAndPulse(post.value.uri)} 245 252 class="select-none hover:cursor-pointer hover:underline" 246 253 > 247 - <span style="color: {color};">@{handle}</span>: {@render embedBadge(record)} 254 + <span style="color: {color};">@{handle}</span>: 255 + {#if record.embed} 256 + {@render embedBadge(record.embed)} 257 + {/if} 248 258 <span title={record.text}>{record.text}</span> 249 259 </div> 250 260 {:else} ··· 269 279 {:then post} 270 280 {#if post.ok} 271 281 {@const record = post.value.record} 282 + <!-- svelte-ignore a11y_no_static_element_interactions --> 272 283 <div 273 284 id="timeline-post-{post.value.uri}-{quoteDepth}" 285 + oncontextmenu={handleRightClick} 274 286 class=" 275 287 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 276 288 {$isPulsing ? 'animate-pulse-highlight' : ''} 289 + {isOnPostComposer ? 'backdrop-brightness-20' : ''} 277 290 " 278 291 style=" 279 - background: {color}{isOnPostComposer ? '36' : '18'}; 292 + background: {color}{isOnPostComposer 293 + ? '36' 294 + : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; 280 295 border-color: {color}{isOnPostComposer ? '99' : '66'}; 281 296 " 282 297 > 283 298 <div 284 - class="mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1" 299 + class=" 300 + mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1 301 + " 285 302 style="background: {color}33;" 286 303 > 287 304 <ProfilePicture {client} {did} size={8} /> 288 305 289 - <span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};"> 306 + <span 307 + class=" 308 + flex min-w-0 items-center gap-2 font-bold 309 + {isOnPostComposer ? 'contrast-200' : ''} 310 + " 311 + style="color: {color};" 312 + > 290 313 {#await client.getProfile(did)} 291 314 {handle} 292 315 {:then profile} 293 316 {#if profile.ok} 294 317 {@const profileValue = profile.value} 295 - <span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 318 + <span class="w-min min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 296 319 >{profileValue.displayName}</span 297 - ><span class="shrink-0 text-nowrap opacity-70">(@{handle})</span> 320 + ><span class="text-nowrap opacity-70">(@{handle})</span> 298 321 {:else} 299 322 {handle} 300 323 {/if} ··· 305 328 >{getRelativeTime(new Date(record.createdAt))}</span 306 329 > 307 330 </div> 308 - <p class="leading-relaxed text-wrap wrap-break-word"> 331 + <p class="leading-normal text-wrap wrap-break-word"> 309 332 {record.text} 310 - {#if isOnPostComposer} 311 - {@render embedBadge(record)} 333 + {#if isOnPostComposer && record.embed} 334 + {@render embedBadge(record.embed)} 312 335 {/if} 313 336 </p> 314 337 {#if !isOnPostComposer && record.embed} 315 338 {@const embed = record.embed} 316 339 <div class="mt-2"> 317 - {#snippet embedMedia( 318 - embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main 319 - )} 320 - {#if embed.$type === 'app.bsky.embed.images'} 321 - <!-- todo: improve how images are displayed, and pop out on click --> 322 - {#each embed.images as image (image.image)} 323 - {#if isBlob(image.image)} 324 - <img 325 - class="rounded-sm" 326 - src={img('feed_thumbnail', did, image.image.ref.$link)} 327 - alt={image.alt} 328 - /> 329 - {/if} 330 - {/each} 331 - {:else if embed.$type === 'app.bsky.embed.video'} 332 - {#if isBlob(embed.video)} 333 - {#await didDoc then didDoc} 334 - {#if didDoc.ok} 335 - <!-- svelte-ignore a11y_media_has_caption --> 336 - <video 337 - class="rounded-sm" 338 - src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 339 - controls 340 - ></video> 341 - {/if} 342 - {/await} 343 - {/if} 344 - {/if} 345 - {/snippet} 346 - {#snippet embedPost(uri: ResourceUri)} 347 - {#if quoteDepth < 2} 348 - {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 349 - <!-- reject recursive quotes --> 350 - {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 351 - <BskyPost 352 - {client} 353 - quoteDepth={quoteDepth + 1} 354 - did={parsedUri.repo} 355 - rkey={parsedUri.rkey} 356 - {isOnPostComposer} 357 - {onQuote} 358 - {onReply} 359 - /> 360 - {:else} 361 - <span>you think you're funny with that recursive quote but i'm onto you</span> 362 - {/if} 363 - {:else} 364 - {@render embedBadge(record)} 365 - {/if} 366 - {/snippet} 367 - {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 368 - {@render embedMedia(embed)} 369 - {:else if embed.$type === 'app.bsky.embed.record'} 370 - {@render embedPost(embed.record.uri)} 371 - {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 372 - <div class="space-y-1.5"> 373 - {@render embedPost(embed.record.record.uri)} 374 - {@render embedMedia(embed.media)} 375 - </div> 376 - {/if} 377 - <!-- todo: implement external link embeds --> 340 + {@render postEmbed(embed)} 378 341 </div> 379 342 {/if} 380 343 {#if !isOnPostComposer} ··· 390 353 {/await} 391 354 {/if} 392 355 356 + {#snippet postEmbed(embed: AppBskyEmbeds)} 357 + {#snippet embedMedia( 358 + embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main 359 + )} 360 + {#if embed.$type === 'app.bsky.embed.images'} 361 + <!-- todo: improve how images are displayed, and pop out on click --> 362 + {#each embed.images as image (image.image)} 363 + {#if isBlob(image.image)} 364 + <img 365 + class="rounded-sm" 366 + src={img('feed_thumbnail', did, image.image.ref.$link)} 367 + alt={image.alt} 368 + /> 369 + {/if} 370 + {/each} 371 + {:else if embed.$type === 'app.bsky.embed.video'} 372 + {#if isBlob(embed.video)} 373 + {#await didDoc then didDoc} 374 + {#if didDoc.ok} 375 + <!-- svelte-ignore a11y_media_has_caption --> 376 + <video 377 + class="rounded-sm" 378 + src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 379 + controls 380 + ></video> 381 + {/if} 382 + {/await} 383 + {/if} 384 + {/if} 385 + {/snippet} 386 + {#snippet embedPost(uri: ResourceUri)} 387 + {#if quoteDepth < 2} 388 + {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 389 + <!-- reject recursive quotes --> 390 + {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 391 + <BskyPost 392 + {client} 393 + quoteDepth={quoteDepth + 1} 394 + did={parsedUri.repo} 395 + rkey={parsedUri.rkey} 396 + {isOnPostComposer} 397 + {onQuote} 398 + {onReply} 399 + /> 400 + {:else} 401 + <span>you think you're funny with that recursive quote but i'm onto you</span> 402 + {/if} 403 + {:else} 404 + {@render embedBadge(embed)} 405 + {/if} 406 + {/snippet} 407 + {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 408 + {@render embedMedia(embed)} 409 + {:else if embed.$type === 'app.bsky.embed.record'} 410 + {@render embedPost(embed.record.uri)} 411 + {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 412 + <div class="space-y-1.5"> 413 + {@render embedPost(embed.record.record.uri)} 414 + {@render embedMedia(embed.media)} 415 + </div> 416 + {/if} 417 + <!-- todo: implement external link embeds --> 418 + {/snippet} 419 + 393 420 {#snippet postControls(post: PostWithUri, backlinks?: PostActions)} 394 421 {#snippet control( 395 422 name: string, ··· 453 480 true 454 481 )} 455 482 </div> 456 - <div 457 - class=" 458 - w-fit items-center rounded-sm transition-opacity 459 - duration-100 ease-in-out group-hover:opacity-100 460 - {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''} 461 - " 462 - style="background: {color}1f;" 483 + <Dropdown 484 + class="flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60" 485 + style="background: {color}36; border-color: {color}99;" 486 + bind:isOpen={actionsOpen} 487 + bind:position={actionsPos} 463 488 > 464 - <Dropdown bind:isOpen={actionsOpen}> 465 - {#snippet trigger()} 489 + {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 490 + navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) 491 + )} 492 + {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () => 493 + navigator.clipboard.writeText(post.uri) 494 + )} 495 + <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div> 496 + {@render dropdownItem('heroicons:clipboard', 'copy post text', () => 497 + navigator.clipboard.writeText(post.record.text) 498 + )} 499 + 500 + {#snippet trigger()} 501 + <div 502 + class=" 503 + w-fit items-center rounded-sm transition-opacity 504 + duration-100 ease-in-out group-hover:opacity-100 505 + {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''} 506 + " 507 + style="background: {color}1f;" 508 + > 466 509 {@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => { 467 510 e.stopPropagation(); 468 511 actionsOpen = !actionsOpen; 512 + actionsPos = { x: 0, y: 0 }; 469 513 })} 470 - {/snippet} 514 + </div> 515 + {/snippet} 516 + </Dropdown> 517 + </div> 518 + {/snippet} 471 519 472 - woof 473 - </Dropdown> 474 - </div> 475 - </div> 520 + {#snippet dropdownItem(icon: string, label: string, onClick: () => void)} 521 + <button 522 + class=" 523 + flex items-center justify-between rounded-sm px-2 py-1.5 524 + transition-all duration-100 hover:[backdrop-filter:brightness(120%)] 525 + " 526 + onclick={() => { 527 + onClick(); 528 + actionsOpen = false; 529 + }} 530 + > 531 + <span class="font-bold">{label}</span> 532 + <Icon class="h-6 w-6" {icon} /> 533 + </button> 476 534 {/snippet}
+16 -2
src/components/Dropdown.svelte
··· 8 8 type Placement 9 9 } from '@floating-ui/dom'; 10 10 import { onMount } from 'svelte'; 11 + import { portal } from 'svelte-portal'; 12 + import type { ClassValue } from 'svelte/elements'; 11 13 12 14 interface Props { 15 + class?: ClassValue; 16 + style?: string; 13 17 isOpen?: boolean; 14 18 trigger?: import('svelte').Snippet; 15 19 children?: import('svelte').Snippet; 16 20 placement?: Placement; 17 21 offsetDistance?: number; 22 + position?: { x: number; y: number }; 18 23 } 19 24 20 25 let { ··· 22 27 trigger, 23 28 children, 24 29 placement = 'bottom-start', 25 - offsetDistance = 8 30 + offsetDistance = 2, 31 + position = $bindable(), 32 + ...restProps 26 33 }: Props = $props(); 27 34 28 35 let triggerRef: HTMLElement | undefined = $state(); ··· 86 93 </div> 87 94 88 95 {#if isOpen} 89 - <div bind:this={contentRef} class="fixed! z-9999!" role="menu" tabindex="-1"> 96 + <div 97 + use:portal={'#app-root'} 98 + bind:this={contentRef} 99 + class="fixed z-9999 animate-fade-in-scale-fast overflow-hidden {restProps.class ?? ''}" 100 + style={restProps.style} 101 + role="menu" 102 + tabindex="-1" 103 + > 90 104 {@render children?.()} 91 105 </div> 92 106 {/if}
+7 -23
src/components/Popup.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Snippet } from 'svelte'; 3 + import { portal } from 'svelte-portal'; 3 4 4 5 interface Props { 5 6 isOpen: boolean; ··· 31 32 if (event.key === 'Escape') onClose(); 32 33 }; 33 34 34 - let popupElement: HTMLDivElement | undefined = $state(); 35 - 36 - // this sucks probably idk 37 35 $effect(() => { 38 - if (!isOpen) return; 39 - 40 - const preventDefault = (e: Event) => { 41 - if (popupElement && popupElement.contains(e.target as Node)) return; 42 - e.preventDefault(); 43 - }; 44 - 45 - document.addEventListener('wheel', preventDefault, { passive: false }); 46 - document.addEventListener('touchmove', preventDefault, { passive: false }); 47 - 48 - return () => { 49 - document.removeEventListener('wheel', preventDefault); 50 - document.removeEventListener('touchmove', preventDefault); 51 - }; 36 + document.body.style.overflow = isOpen ? 'hidden' : 'auto'; 52 37 }); 53 38 </script> 54 39 55 40 {#if isOpen} 56 41 <div 42 + use:portal={'#app-root'} 57 43 class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm" 58 44 onclick={onClose} 59 45 onkeydown={handleKeydown} ··· 63 49 <!-- svelte-ignore a11y_interactive_supports_focus --> 64 50 <!-- svelte-ignore a11y_click_events_have_key_events --> 65 51 <div 66 - bind:this={popupElement} 67 - class="flex {height === 'auto' 68 - ? '' 69 - : 'h-[' + 70 - height + 71 - ']'} {width} shrink animate-fade-in-scale flex-col rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all" 52 + class=" 53 + flex {height === 'auto' ? '' : `h-[${height}]`} {width} shrink animate-fade-in-scale flex-col 54 + rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all 55 + " 72 56 style={height !== 'auto' ? `height: ${height}` : ''} 73 57 onclick={(e) => e.stopPropagation()} 74 58 role="dialog"
+15
src/components/SettingsPopup.svelte
··· 89 89 {@render divider()} 90 90 91 91 <div> 92 + <label for="social-app-url" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 93 + social-app url (for when copying links to posts / profiles) 94 + </label> 95 + <input 96 + id="social-app-url" 97 + type="url" 98 + bind:value={localSettings.socialAppUrl} 99 + placeholder={defaultSettings.socialAppUrl} 100 + class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 101 + /> 102 + </div> 103 + 104 + {@render divider()} 105 + 106 + <div> 92 107 {@render settingHeader( 93 108 'cache management', 94 109 'clears cached data (records, DID documents, handles, etc.)'
+14
src/lib/at/types.ts
··· 1 + import type { 2 + AppBskyEmbedExternal, 3 + AppBskyEmbedImages, 4 + AppBskyEmbedRecord, 5 + AppBskyEmbedRecordWithMedia, 6 + AppBskyEmbedVideo 7 + } from '@atcute/bluesky'; 8 + 9 + export type AppBskyEmbeds = 10 + | AppBskyEmbedExternal.Main 11 + | AppBskyEmbedImages.Main 12 + | AppBskyEmbedRecord.Main 13 + | AppBskyEmbedRecordWithMedia.Main 14 + | AppBskyEmbedVideo.Main;
+4 -1
src/lib/settings.ts
··· 9 9 export type Settings = { 10 10 endpoints: ApiEndpoints; 11 11 theme: Theme; 12 + socialAppUrl: string; 12 13 }; 13 14 14 15 export const defaultSettings: Settings = { ··· 17 18 spacedust: 'https://spacedust.microcosm.blue', 18 19 constellation: 'https://constellation.microcosm.blue' 19 20 }, 20 - theme: defaultTheme 21 + theme: defaultTheme, 22 + socialAppUrl: 'https://bsky.app' 21 23 }; 22 24 23 25 const createSettingsStore = () => { ··· 26 28 const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings; 27 29 initial.endpoints = initial.endpoints ?? defaultSettings.endpoints; 28 30 initial.theme = initial.theme ?? defaultSettings.theme; 31 + initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl; 29 32 30 33 const { subscribe, set, update } = writable<Settings>(initial as Settings); 31 34
+4 -1
src/routes/+layout.svelte
··· 9 9 <link rel="icon" href={favicon} /> 10 10 </svelte:head> 11 11 12 - <div class="min-h-screen bg-(--nucleus-bg) text-(--nucleus-fg) transition-colors duration-300"> 12 + <div 13 + id="app-root" 14 + class="min-h-screen bg-(--nucleus-bg) text-(--nucleus-fg) transition-colors duration-300" 15 + > 13 16 {@render children?.()} 14 17 </div>
+2
src/routes/+page.svelte
··· 272 272 <div class="mx-auto max-w-2xl"> 273 273 <!-- thread list (page scrolls as a whole) --> 274 274 <div 275 + id="app-thread-list" 275 276 class="mb-4 min-h-screen p-2 [scrollbar-color:var(--nucleus-accent)_transparent]" 276 277 bind:this={scrollContainer} 277 278 > ··· 285 286 </div> 286 287 {/if} 287 288 </div> 289 + 288 290 <!-- header --> 289 291 <div class="sticky bottom-0 z-10"> 290 292 {#if errors.length > 0}