replies timeline only, appview-less bluesky client

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

ptr.pet 7bc65983 6dbd17ca

verified
+5 -8
deno.lock
···
"npm:svelte-awesome-color-picker@^4.1.0": "4.1.0_svelte@5.43.2__acorn@8.15.0",
"npm:svelte-check@^4.3.3": "4.3.3_svelte@5.43.2__acorn@8.15.0_typescript@5.9.3",
"npm:svelte-device-info@^1.0.6": "1.0.6",
-
"npm:svelte-floating-ui@^1.6.2": "1.6.2",
"npm:svelte-infinite@~0.5.1": "0.5.1_svelte@5.43.2__acorn@8.15.0",
"npm:svelte@^5.43.2": "5.43.2_acorn@8.15.0",
"npm:tailwindcss@^4.1.16": "4.1.16",
"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",
···
"svelte"
]
},
-
"svelte-floating-ui@1.6.2": {
-
"integrity": "sha512-EC+DZtBey50P6l3NSzNQWon3cip8a1bzwdpmCdc45kymqEWL4BKhPemAq7SQ9QLebDPaMECW6YodxFbs2d+O/w==",
-
"dependencies": [
-
"@floating-ui/dom"
-
]
-
},
"svelte-infinite@0.5.1_svelte@5.43.2__acorn@8.15.0": {
"integrity": "sha512-NvpYWrHPcLHZQMnqUXgKGpOSMq9kMQ6sa8+WO80jLrgBFX+LWoKvAsrc1d1g+eiaagNAE9HalWWJ4KDtYi/+sw==",
"dependencies": [
"svelte"
]
},
"svelte@5.43.2_acorn@8.15.0": {
"integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==",
···
"npm:svelte-awesome-color-picker@^4.1.0",
"npm:svelte-check@^4.3.3",
"npm:svelte-device-info@^1.0.6",
-
"npm:svelte-floating-ui@^1.6.2",
"npm:svelte-infinite@~0.5.1",
"npm:svelte@^5.43.2",
"npm:tailwindcss@^4.1.16",
"npm:typescript-eslint@^8.46.3",
···
"npm:svelte-awesome-color-picker@^4.1.0": "4.1.0_svelte@5.43.2__acorn@8.15.0",
"npm:svelte-check@^4.3.3": "4.3.3_svelte@5.43.2__acorn@8.15.0_typescript@5.9.3",
"npm:svelte-device-info@^1.0.6": "1.0.6",
"npm:svelte-infinite@~0.5.1": "0.5.1_svelte@5.43.2__acorn@8.15.0",
+
"npm:svelte-portal@^2.2.1": "2.2.1",
"npm:svelte@^5.43.2": "5.43.2_acorn@8.15.0",
"npm:tailwindcss@^4.1.16": "4.1.16",
"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",
···
"svelte"
]
},
"svelte-infinite@0.5.1_svelte@5.43.2__acorn@8.15.0": {
"integrity": "sha512-NvpYWrHPcLHZQMnqUXgKGpOSMq9kMQ6sa8+WO80jLrgBFX+LWoKvAsrc1d1g+eiaagNAE9HalWWJ4KDtYi/+sw==",
"dependencies": [
"svelte"
]
+
},
+
"svelte-portal@2.2.1": {
+
"integrity": "sha512-uF7is5sM4aq5iN7QF/67XLnTUvQCf2iiG/B1BHTqLwYVY1dsVmTeXZ/LeEyU6dLjApOQdbEG9lkqHzxiQtOLEQ=="
},
"svelte@5.43.2_acorn@8.15.0": {
"integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==",
···
"npm:svelte-awesome-color-picker@^4.1.0",
"npm:svelte-check@^4.3.3",
"npm:svelte-device-info@^1.0.6",
"npm:svelte-infinite@~0.5.1",
+
"npm:svelte-portal@^2.2.1",
"npm:svelte@^5.43.2",
"npm:tailwindcss@^4.1.16",
"npm:typescript-eslint@^8.46.3",
+2 -1
package.json
···
"hash-wasm": "^4.12.0",
"lru-cache": "^11.2.2",
"svelte-device-info": "^1.0.6",
-
"svelte-infinite": "^0.5.1"
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
···
"hash-wasm": "^4.12.0",
"lru-cache": "^11.2.2",
"svelte-device-info": "^1.0.6",
+
"svelte-infinite": "^0.5.1",
+
"svelte-portal": "^2.2.1"
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
+52 -53
src/components/AccountSelector.svelte
···
};
</script>
-
<Dropdown bind:isOpen={isDropdownOpen}>
{#snippet trigger()}
<button
onclick={toggleDropdown}
···
</button>
{/snippet}
-
<div
-
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"
-
>
-
{#if accounts.length > 0}
-
<div class="p-2">
-
{#each accounts as account (account.did)}
-
{@const color = generateColorForDid(account.did)}
-
{#snippet action(name: string, icon: string, onClick: () => void)}
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
-
<div
-
title={name}
-
onclick={onClick}
-
class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md"
-
>
-
<Icon class="h-5 w-5" {icon} />
-
</div>
-
{/snippet}
-
<button
-
onclick={() => selectAccount(account.did)}
-
class="
-
group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
-
{account.did === selectedDid ? 'shadow-lg' : ''}
-
"
-
style="color: {color}; background: {account.did === selectedDid
-
? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))`
-
: 'transparent'};"
>
-
<span>@{account.handle}</span>
-
<div class="grow"></div>
-
{@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () =>
-
initiateLogin(account.did, account.handle)
-
)}
-
{@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))}
-
{#if account.did === selectedDid}
-
<Icon
-
icon="heroicons:check-16-solid"
-
class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden"
-
/>
-
{/if}
-
</button>
-
{/each}
-
</div>
-
<div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
-
{/if}
-
<button
-
onclick={openLoginModal}
-
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]"
-
>
-
<Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" />
-
<span>add account</span>
-
</button>
-
</div>
</Dropdown>
<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account">
···
};
</script>
+
<Dropdown
+
class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl"
+
bind:isOpen={isDropdownOpen}
+
>
{#snippet trigger()}
<button
onclick={toggleDropdown}
···
</button>
{/snippet}
+
{#if accounts.length > 0}
+
<div class="p-2">
+
{#each accounts as account (account.did)}
+
{@const color = generateColorForDid(account.did)}
+
{#snippet action(name: string, icon: string, onClick: () => void)}
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div
+
title={name}
+
onclick={onClick}
+
class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md"
>
+
<Icon class="h-5 w-5" {icon} />
+
</div>
+
{/snippet}
+
<button
+
onclick={() => selectAccount(account.did)}
+
class="
+
group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
+
{account.did === selectedDid ? 'shadow-lg' : ''}
+
"
+
style="color: {color}; background: {account.did === selectedDid
+
? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))`
+
: 'transparent'};"
+
>
+
<span>@{account.handle}</span>
+
<div class="grow"></div>
+
{@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () =>
+
initiateLogin(account.did, account.handle)
+
)}
+
{@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))}
+
{#if account.did === selectedDid}
+
<Icon
+
icon="heroicons:check-16-solid"
+
class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden"
+
/>
+
{/if}
+
</button>
+
{/each}
+
</div>
+
<div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
+
{/if}
+
<button
+
onclick={openLoginModal}
+
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]"
+
>
+
<Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" />
+
<span>add account</span>
+
</button>
</Dropdown>
<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account">
+154 -96
src/components/BskyPost.svelte
···
import { derived } from 'svelte/store';
import Device from 'svelte-device-info';
import Dropdown from './Dropdown.svelte';
interface Props {
client: AtpClient;
···
};
let actionsOpen = $state(false);
</script>
-
{#snippet embedBadge(record: AppBskyFeedPost.Main)}
-
{#if record.embed}
-
<span
-
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
-
style="
-
background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent);
-
color: {mini ? 'var(--nucleus-fg)' : color};
-
"
-
>
-
{getEmbedText(record.embed.$type)}
-
</span>
-
{/if}
{/snippet}
{#if mini}
···
onclick={() => scrollToAndPulse(post.value.uri)}
class="select-none hover:cursor-pointer hover:underline"
>
-
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
<span title={record.text}>{record.text}</span>
</div>
{:else}
···
{:then post}
{#if post.ok}
{@const record = post.value.record}
<div
id="timeline-post-{post.value.uri}-{quoteDepth}"
class="
group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all
{$isPulsing ? 'animate-pulse-highlight' : ''}
"
style="
-
background: {color}{isOnPostComposer ? '36' : '18'};
border-color: {color}{isOnPostComposer ? '99' : '66'};
"
>
<div
-
class="mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1"
style="background: {color}33;"
>
<ProfilePicture {client} {did} size={8} />
-
<span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};">
{#await client.getProfile(did)}
{handle}
{:then profile}
{#if profile.ok}
{@const profileValue = profile.value}
-
<span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
>{profileValue.displayName}</span
-
><span class="shrink-0 text-nowrap opacity-70">(@{handle})</span>
{:else}
{handle}
{/if}
···
>{getRelativeTime(new Date(record.createdAt))}</span
>
</div>
-
<p class="leading-relaxed text-wrap wrap-break-word">
{record.text}
-
{#if isOnPostComposer}
-
{@render embedBadge(record)}
{/if}
</p>
{#if !isOnPostComposer && record.embed}
{@const embed = record.embed}
<div class="mt-2">
-
{#snippet embedMedia(
-
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
-
)}
-
{#if embed.$type === 'app.bsky.embed.images'}
-
<!-- todo: improve how images are displayed, and pop out on click -->
-
{#each embed.images as image (image.image)}
-
{#if isBlob(image.image)}
-
<img
-
class="rounded-sm"
-
src={img('feed_thumbnail', did, image.image.ref.$link)}
-
alt={image.alt}
-
/>
-
{/if}
-
{/each}
-
{:else if embed.$type === 'app.bsky.embed.video'}
-
{#if isBlob(embed.video)}
-
{#await didDoc then didDoc}
-
{#if didDoc.ok}
-
<!-- svelte-ignore a11y_media_has_caption -->
-
<video
-
class="rounded-sm"
-
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
-
controls
-
></video>
-
{/if}
-
{/await}
-
{/if}
-
{/if}
-
{/snippet}
-
{#snippet embedPost(uri: ResourceUri)}
-
{#if quoteDepth < 2}
-
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
-
<!-- reject recursive quotes -->
-
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
-
<BskyPost
-
{client}
-
quoteDepth={quoteDepth + 1}
-
did={parsedUri.repo}
-
rkey={parsedUri.rkey}
-
{isOnPostComposer}
-
{onQuote}
-
{onReply}
-
/>
-
{:else}
-
<span>you think you're funny with that recursive quote but i'm onto you</span>
-
{/if}
-
{:else}
-
{@render embedBadge(record)}
-
{/if}
-
{/snippet}
-
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
-
{@render embedMedia(embed)}
-
{:else if embed.$type === 'app.bsky.embed.record'}
-
{@render embedPost(embed.record.uri)}
-
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
-
<div class="space-y-1.5">
-
{@render embedPost(embed.record.record.uri)}
-
{@render embedMedia(embed.media)}
-
</div>
-
{/if}
-
<!-- todo: implement external link embeds -->
</div>
{/if}
{#if !isOnPostComposer}
···
{/await}
{/if}
{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
{#snippet control(
name: string,
···
true
)}
</div>
-
<div
-
class="
-
w-fit items-center rounded-sm transition-opacity
-
duration-100 ease-in-out group-hover:opacity-100
-
{!actionsOpen && !Device.isMobile ? 'opacity-0' : ''}
-
"
-
style="background: {color}1f;"
>
-
<Dropdown bind:isOpen={actionsOpen}>
-
{#snippet trigger()}
{@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => {
e.stopPropagation();
actionsOpen = !actionsOpen;
})}
-
{/snippet}
-
woof
-
</Dropdown>
-
</div>
-
</div>
{/snippet}
···
import { derived } from 'svelte/store';
import Device from 'svelte-device-info';
import Dropdown from './Dropdown.svelte';
+
import { type AppBskyEmbeds } from '$lib/at/types';
+
import { settings } from '$lib/settings';
interface Props {
client: AtpClient;
···
};
let actionsOpen = $state(false);
+
let actionsPos = $state({ x: 0, y: 0 });
+
+
const handleRightClick = (event: MouseEvent) => {
+
actionsOpen = true;
+
actionsPos = { x: event.clientX, y: event.clientY };
+
event.preventDefault();
+
};
</script>
+
{#snippet embedBadge(embed: AppBskyEmbeds)}
+
<span
+
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
+
style="
+
background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent);
+
color: {mini ? 'var(--nucleus-fg)' : color};
+
"
+
>
+
{getEmbedText(embed.$type!)}
+
</span>
{/snippet}
{#if mini}
···
onclick={() => scrollToAndPulse(post.value.uri)}
class="select-none hover:cursor-pointer hover:underline"
>
+
<span style="color: {color};">@{handle}</span>:
+
{#if record.embed}
+
{@render embedBadge(record.embed)}
+
{/if}
<span title={record.text}>{record.text}</span>
</div>
{:else}
···
{:then post}
{#if post.ok}
{@const record = post.value.record}
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
id="timeline-post-{post.value.uri}-{quoteDepth}"
+
oncontextmenu={handleRightClick}
class="
group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all
{$isPulsing ? 'animate-pulse-highlight' : ''}
+
{isOnPostComposer ? 'backdrop-brightness-20' : ''}
"
style="
+
background: {color}{isOnPostComposer
+
? '36'
+
: Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)};
border-color: {color}{isOnPostComposer ? '99' : '66'};
"
>
<div
+
class="
+
mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1
+
"
style="background: {color}33;"
>
<ProfilePicture {client} {did} size={8} />
+
<span
+
class="
+
flex min-w-0 items-center gap-2 font-bold
+
{isOnPostComposer ? 'contrast-200' : ''}
+
"
+
style="color: {color};"
+
>
{#await client.getProfile(did)}
{handle}
{:then profile}
{#if profile.ok}
{@const profileValue = profile.value}
+
<span class="w-min min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
>{profileValue.displayName}</span
+
><span class="text-nowrap opacity-70">(@{handle})</span>
{:else}
{handle}
{/if}
···
>{getRelativeTime(new Date(record.createdAt))}</span
>
</div>
+
<p class="leading-normal text-wrap wrap-break-word">
{record.text}
+
{#if isOnPostComposer && record.embed}
+
{@render embedBadge(record.embed)}
{/if}
</p>
{#if !isOnPostComposer && record.embed}
{@const embed = record.embed}
<div class="mt-2">
+
{@render postEmbed(embed)}
</div>
{/if}
{#if !isOnPostComposer}
···
{/await}
{/if}
+
{#snippet postEmbed(embed: AppBskyEmbeds)}
+
{#snippet embedMedia(
+
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
+
)}
+
{#if embed.$type === 'app.bsky.embed.images'}
+
<!-- todo: improve how images are displayed, and pop out on click -->
+
{#each embed.images as image (image.image)}
+
{#if isBlob(image.image)}
+
<img
+
class="rounded-sm"
+
src={img('feed_thumbnail', did, image.image.ref.$link)}
+
alt={image.alt}
+
/>
+
{/if}
+
{/each}
+
{:else if embed.$type === 'app.bsky.embed.video'}
+
{#if isBlob(embed.video)}
+
{#await didDoc then didDoc}
+
{#if didDoc.ok}
+
<!-- svelte-ignore a11y_media_has_caption -->
+
<video
+
class="rounded-sm"
+
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
+
controls
+
></video>
+
{/if}
+
{/await}
+
{/if}
+
{/if}
+
{/snippet}
+
{#snippet embedPost(uri: ResourceUri)}
+
{#if quoteDepth < 2}
+
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
+
<!-- reject recursive quotes -->
+
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
+
<BskyPost
+
{client}
+
quoteDepth={quoteDepth + 1}
+
did={parsedUri.repo}
+
rkey={parsedUri.rkey}
+
{isOnPostComposer}
+
{onQuote}
+
{onReply}
+
/>
+
{:else}
+
<span>you think you're funny with that recursive quote but i'm onto you</span>
+
{/if}
+
{:else}
+
{@render embedBadge(embed)}
+
{/if}
+
{/snippet}
+
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
+
{@render embedMedia(embed)}
+
{:else if embed.$type === 'app.bsky.embed.record'}
+
{@render embedPost(embed.record.uri)}
+
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
+
<div class="space-y-1.5">
+
{@render embedPost(embed.record.record.uri)}
+
{@render embedMedia(embed.media)}
+
</div>
+
{/if}
+
<!-- todo: implement external link embeds -->
+
{/snippet}
+
{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
{#snippet control(
name: string,
···
true
)}
</div>
+
<Dropdown
+
class="flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60"
+
style="background: {color}36; border-color: {color}99;"
+
bind:isOpen={actionsOpen}
+
bind:position={actionsPos}
>
+
{@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () =>
+
navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`)
+
)}
+
{@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () =>
+
navigator.clipboard.writeText(post.uri)
+
)}
+
<div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
+
{@render dropdownItem('heroicons:clipboard', 'copy post text', () =>
+
navigator.clipboard.writeText(post.record.text)
+
)}
+
+
{#snippet trigger()}
+
<div
+
class="
+
w-fit items-center rounded-sm transition-opacity
+
duration-100 ease-in-out group-hover:opacity-100
+
{!actionsOpen && !Device.isMobile ? 'opacity-0' : ''}
+
"
+
style="background: {color}1f;"
+
>
{@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => {
e.stopPropagation();
actionsOpen = !actionsOpen;
+
actionsPos = { x: 0, y: 0 };
})}
+
</div>
+
{/snippet}
+
</Dropdown>
+
</div>
+
{/snippet}
+
{#snippet dropdownItem(icon: string, label: string, onClick: () => void)}
+
<button
+
class="
+
flex items-center justify-between rounded-sm px-2 py-1.5
+
transition-all duration-100 hover:[backdrop-filter:brightness(120%)]
+
"
+
onclick={() => {
+
onClick();
+
actionsOpen = false;
+
}}
+
>
+
<span class="font-bold">{label}</span>
+
<Icon class="h-6 w-6" {icon} />
+
</button>
{/snippet}
+16 -2
src/components/Dropdown.svelte
···
type Placement
} from '@floating-ui/dom';
import { onMount } from 'svelte';
interface Props {
isOpen?: boolean;
trigger?: import('svelte').Snippet;
children?: import('svelte').Snippet;
placement?: Placement;
offsetDistance?: number;
}
let {
···
trigger,
children,
placement = 'bottom-start',
-
offsetDistance = 8
}: Props = $props();
let triggerRef: HTMLElement | undefined = $state();
···
</div>
{#if isOpen}
-
<div bind:this={contentRef} class="fixed! z-9999!" role="menu" tabindex="-1">
{@render children?.()}
</div>
{/if}
···
type Placement
} from '@floating-ui/dom';
import { onMount } from 'svelte';
+
import { portal } from 'svelte-portal';
+
import type { ClassValue } from 'svelte/elements';
interface Props {
+
class?: ClassValue;
+
style?: string;
isOpen?: boolean;
trigger?: import('svelte').Snippet;
children?: import('svelte').Snippet;
placement?: Placement;
offsetDistance?: number;
+
position?: { x: number; y: number };
}
let {
···
trigger,
children,
placement = 'bottom-start',
+
offsetDistance = 2,
+
position = $bindable(),
+
...restProps
}: Props = $props();
let triggerRef: HTMLElement | undefined = $state();
···
</div>
{#if isOpen}
+
<div
+
use:portal={'#app-root'}
+
bind:this={contentRef}
+
class="fixed z-9999 animate-fade-in-scale-fast overflow-hidden {restProps.class ?? ''}"
+
style={restProps.style}
+
role="menu"
+
tabindex="-1"
+
>
{@render children?.()}
</div>
{/if}
+7 -23
src/components/Popup.svelte
···
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
isOpen: boolean;
···
if (event.key === 'Escape') onClose();
};
-
let popupElement: HTMLDivElement | undefined = $state();
-
-
// this sucks probably idk
$effect(() => {
-
if (!isOpen) return;
-
-
const preventDefault = (e: Event) => {
-
if (popupElement && popupElement.contains(e.target as Node)) return;
-
e.preventDefault();
-
};
-
-
document.addEventListener('wheel', preventDefault, { passive: false });
-
document.addEventListener('touchmove', preventDefault, { passive: false });
-
-
return () => {
-
document.removeEventListener('wheel', preventDefault);
-
document.removeEventListener('touchmove', preventDefault);
-
};
});
</script>
{#if isOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm"
onclick={onClose}
onkeydown={handleKeydown}
···
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
-
bind:this={popupElement}
-
class="flex {height === 'auto'
-
? ''
-
: 'h-[' +
-
height +
-
']'} {width} shrink animate-fade-in-scale flex-col rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all"
style={height !== 'auto' ? `height: ${height}` : ''}
onclick={(e) => e.stopPropagation()}
role="dialog"
···
<script lang="ts">
import type { Snippet } from 'svelte';
+
import { portal } from 'svelte-portal';
interface Props {
isOpen: boolean;
···
if (event.key === 'Escape') onClose();
};
$effect(() => {
+
document.body.style.overflow = isOpen ? 'hidden' : 'auto';
});
</script>
{#if isOpen}
<div
+
use:portal={'#app-root'}
class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm"
onclick={onClose}
onkeydown={handleKeydown}
···
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
+
class="
+
flex {height === 'auto' ? '' : `h-[${height}]`} {width} shrink animate-fade-in-scale flex-col
+
rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all
+
"
style={height !== 'auto' ? `height: ${height}` : ''}
onclick={(e) => e.stopPropagation()}
role="dialog"
+15
src/components/SettingsPopup.svelte
···
{@render divider()}
<div>
{@render settingHeader(
'cache management',
'clears cached data (records, DID documents, handles, etc.)'
···
{@render divider()}
<div>
+
<label for="social-app-url" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
+
social-app url (for when copying links to posts / profiles)
+
</label>
+
<input
+
id="social-app-url"
+
type="url"
+
bind:value={localSettings.socialAppUrl}
+
placeholder={defaultSettings.socialAppUrl}
+
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
+
/>
+
</div>
+
+
{@render divider()}
+
+
<div>
{@render settingHeader(
'cache management',
'clears cached data (records, DID documents, handles, etc.)'
+14
src/lib/at/types.ts
···
···
+
import type {
+
AppBskyEmbedExternal,
+
AppBskyEmbedImages,
+
AppBskyEmbedRecord,
+
AppBskyEmbedRecordWithMedia,
+
AppBskyEmbedVideo
+
} from '@atcute/bluesky';
+
+
export type AppBskyEmbeds =
+
| AppBskyEmbedExternal.Main
+
| AppBskyEmbedImages.Main
+
| AppBskyEmbedRecord.Main
+
| AppBskyEmbedRecordWithMedia.Main
+
| AppBskyEmbedVideo.Main;
+4 -1
src/lib/settings.ts
···
export type Settings = {
endpoints: ApiEndpoints;
theme: Theme;
};
export const defaultSettings: Settings = {
···
spacedust: 'https://spacedust.microcosm.blue',
constellation: 'https://constellation.microcosm.blue'
},
-
theme: defaultTheme
};
const createSettingsStore = () => {
···
const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings;
initial.endpoints = initial.endpoints ?? defaultSettings.endpoints;
initial.theme = initial.theme ?? defaultSettings.theme;
const { subscribe, set, update } = writable<Settings>(initial as Settings);
···
export type Settings = {
endpoints: ApiEndpoints;
theme: Theme;
+
socialAppUrl: string;
};
export const defaultSettings: Settings = {
···
spacedust: 'https://spacedust.microcosm.blue',
constellation: 'https://constellation.microcosm.blue'
},
+
theme: defaultTheme,
+
socialAppUrl: 'https://bsky.app'
};
const createSettingsStore = () => {
···
const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings;
initial.endpoints = initial.endpoints ?? defaultSettings.endpoints;
initial.theme = initial.theme ?? defaultSettings.theme;
+
initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl;
const { subscribe, set, update } = writable<Settings>(initial as Settings);
+4 -1
src/routes/+layout.svelte
···
<link rel="icon" href={favicon} />
</svelte:head>
-
<div class="min-h-screen bg-(--nucleus-bg) text-(--nucleus-fg) transition-colors duration-300">
{@render children?.()}
</div>
···
<link rel="icon" href={favicon} />
</svelte:head>
+
<div
+
id="app-root"
+
class="min-h-screen bg-(--nucleus-bg) text-(--nucleus-fg) transition-colors duration-300"
+
>
{@render children?.()}
</div>
+2
src/routes/+page.svelte
···
<div class="mx-auto max-w-2xl">
<!-- thread list (page scrolls as a whole) -->
<div
class="mb-4 min-h-screen p-2 [scrollbar-color:var(--nucleus-accent)_transparent]"
bind:this={scrollContainer}
>
···
</div>
{/if}
</div>
<!-- header -->
<div class="sticky bottom-0 z-10">
{#if errors.length > 0}
···
<div class="mx-auto max-w-2xl">
<!-- thread list (page scrolls as a whole) -->
<div
+
id="app-thread-list"
class="mb-4 min-h-screen p-2 [scrollbar-color:var(--nucleus-accent)_transparent]"
bind:this={scrollContainer}
>
···
</div>
{/if}
</div>
+
<!-- header -->
<div class="sticky bottom-0 z-10">
{#if errors.length > 0}