diff --git a/deno.lock b/deno.lock
index 64bed51..aed1b07 100644
--- a/deno.lock
+++ b/deno.lock
@@ -33,8 +33,8 @@
"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-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",
@@ -1661,18 +1661,15 @@
"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-portal@2.2.1": {
+ "integrity": "sha512-uF7is5sM4aq5iN7QF/67XLnTUvQCf2iiG/B1BHTqLwYVY1dsVmTeXZ/LeEyU6dLjApOQdbEG9lkqHzxiQtOLEQ=="
+ },
"svelte@5.43.2_acorn@8.15.0": {
"integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==",
"dependencies": [
@@ -1839,8 +1836,8 @@
"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-portal@^2.2.1",
"npm:svelte@^5.43.2",
"npm:tailwindcss@^4.1.16",
"npm:typescript-eslint@^8.46.3",
diff --git a/package.json b/package.json
index 9088ee3..daef808 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,8 @@
"hash-wasm": "^4.12.0",
"lru-cache": "^11.2.2",
"svelte-device-info": "^1.0.6",
- "svelte-infinite": "^0.5.1"
+ "svelte-infinite": "^0.5.1",
+ "svelte-portal": "^2.2.1"
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
diff --git a/src/components/AccountSelector.svelte b/src/components/AccountSelector.svelte
index 1c45898..7f30668 100644
--- a/src/components/AccountSelector.svelte
+++ b/src/components/AccountSelector.svelte
@@ -90,7 +90,10 @@
};
-
+
{#snippet trigger()}
diff --git a/src/components/BskyPost.svelte b/src/components/BskyPost.svelte
index 015c605..0c140b2 100644
--- a/src/components/BskyPost.svelte
+++ b/src/components/BskyPost.svelte
@@ -31,6 +31,8 @@
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;
@@ -215,20 +217,25 @@
};
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();
+ };
-{#snippet embedBadge(record: AppBskyFeedPost.Main)}
- {#if record.embed}
-
- {getEmbedText(record.embed.$type)}
-
- {/if}
+{#snippet embedBadge(embed: AppBskyEmbeds)}
+
+ {getEmbedText(embed.$type!)}
+
{/snippet}
{#if mini}
@@ -244,7 +251,10 @@
onclick={() => scrollToAndPulse(post.value.uri)}
class="select-none hover:cursor-pointer hover:underline"
>
- @{handle}: {@render embedBadge(record)}
+ @{handle}:
+ {#if record.embed}
+ {@render embedBadge(record.embed)}
+ {/if}
{record.text}
{:else}
@@ -269,32 +279,45 @@
{:then post}
{#if post.ok}
{@const record = post.value.record}
+
-
+
{#await client.getProfile(did)}
{handle}
{:then profile}
{#if profile.ok}
{@const profileValue = profile.value}
- {profileValue.displayName}(@{handle})
+ >(@{handle})
{:else}
{handle}
{/if}
@@ -305,76 +328,16 @@
>{getRelativeTime(new Date(record.createdAt))}
-
+
{record.text}
- {#if isOnPostComposer}
- {@render embedBadge(record)}
+ {#if isOnPostComposer && record.embed}
+ {@render embedBadge(record.embed)}
{/if}
{#if !isOnPostComposer && record.embed}
{@const embed = record.embed}
- {#snippet embedMedia(
- embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
- )}
- {#if embed.$type === 'app.bsky.embed.images'}
-
- {#each embed.images as image (image.image)}
- {#if isBlob(image.image)}
-

- {/if}
- {/each}
- {:else if embed.$type === 'app.bsky.embed.video'}
- {#if isBlob(embed.video)}
- {#await didDoc then didDoc}
- {#if didDoc.ok}
-
-
- {/if}
- {/await}
- {/if}
- {/if}
- {/snippet}
- {#snippet embedPost(uri: ResourceUri)}
- {#if quoteDepth < 2}
- {@const parsedUri = expect(parseCanonicalResourceUri(uri))}
-
- {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
-
- {:else}
-
you think you're funny with that recursive quote but i'm onto you
- {/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'}
-
- {@render embedPost(embed.record.record.uri)}
- {@render embedMedia(embed.media)}
-
- {/if}
-
+ {@render postEmbed(embed)}
{/if}
{#if !isOnPostComposer}
@@ -390,6 +353,70 @@
{/await}
{/if}
+{#snippet postEmbed(embed: AppBskyEmbeds)}
+ {#snippet embedMedia(
+ embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
+ )}
+ {#if embed.$type === 'app.bsky.embed.images'}
+
+ {#each embed.images as image (image.image)}
+ {#if isBlob(image.image)}
+

+ {/if}
+ {/each}
+ {:else if embed.$type === 'app.bsky.embed.video'}
+ {#if isBlob(embed.video)}
+ {#await didDoc then didDoc}
+ {#if didDoc.ok}
+
+
+ {/if}
+ {/await}
+ {/if}
+ {/if}
+ {/snippet}
+ {#snippet embedPost(uri: ResourceUri)}
+ {#if quoteDepth < 2}
+ {@const parsedUri = expect(parseCanonicalResourceUri(uri))}
+
+ {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
+
+ {:else}
+
you think you're funny with that recursive quote but i'm onto you
+ {/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'}
+
+ {@render embedPost(embed.record.record.uri)}
+ {@render embedMedia(embed.media)}
+
+ {/if}
+
+{/snippet}
+
{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
{#snippet control(
name: string,
@@ -453,24 +480,55 @@
true
)}
-
-
- {#snippet trigger()}
+ {@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)
+ )}
+
+ {@render dropdownItem('heroicons:clipboard', 'copy post text', () =>
+ navigator.clipboard.writeText(post.record.text)
+ )}
+
+ {#snippet trigger()}
+
{@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => {
e.stopPropagation();
actionsOpen = !actionsOpen;
+ actionsPos = { x: 0, y: 0 };
})}
- {/snippet}
-
- woof
-
-
+
+ {/snippet}
+
{/snippet}
+
+{#snippet dropdownItem(icon: string, label: string, onClick: () => void)}
+ {
+ onClick();
+ actionsOpen = false;
+ }}
+ >
+ {label}
+
+
+{/snippet}
diff --git a/src/components/Dropdown.svelte b/src/components/Dropdown.svelte
index e24bd0b..053d22c 100644
--- a/src/components/Dropdown.svelte
+++ b/src/components/Dropdown.svelte
@@ -8,13 +8,18 @@
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 {
@@ -22,7 +27,9 @@
trigger,
children,
placement = 'bottom-start',
- offsetDistance = 8
+ offsetDistance = 2,
+ position = $bindable(),
+ ...restProps
}: Props = $props();
let triggerRef: HTMLElement | undefined = $state();
@@ -86,7 +93,14 @@
{#if isOpen}
-
+
{@render children?.()}
{/if}
diff --git a/src/components/Popup.svelte b/src/components/Popup.svelte
index 2090324..da57c10 100644
--- a/src/components/Popup.svelte
+++ b/src/components/Popup.svelte
@@ -1,5 +1,6 @@
{#if isOpen}
e.stopPropagation()}
role="dialog"
diff --git a/src/components/SettingsPopup.svelte b/src/components/SettingsPopup.svelte
index e658b16..ce4fb34 100644
--- a/src/components/SettingsPopup.svelte
+++ b/src/components/SettingsPopup.svelte
@@ -88,6 +88,21 @@
{@render divider()}
+
+
+
+
+
+ {@render divider()}
+
{@render settingHeader(
'cache management',
diff --git a/src/lib/at/types.ts b/src/lib/at/types.ts
new file mode 100644
index 0000000..a7de67d
--- /dev/null
+++ b/src/lib/at/types.ts
@@ -0,0 +1,14 @@
+import type {
+ AppBskyEmbedExternal,
+ AppBskyEmbedImages,
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedVideo
+} from '@atcute/bluesky';
+
+export type AppBskyEmbeds =
+ | AppBskyEmbedExternal.Main
+ | AppBskyEmbedImages.Main
+ | AppBskyEmbedRecord.Main
+ | AppBskyEmbedRecordWithMedia.Main
+ | AppBskyEmbedVideo.Main;
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 6fe89aa..744239c 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -9,6 +9,7 @@ export type ApiEndpoints = Record
& {
export type Settings = {
endpoints: ApiEndpoints;
theme: Theme;
+ socialAppUrl: string;
};
export const defaultSettings: Settings = {
@@ -17,7 +18,8 @@ export const defaultSettings: Settings = {
spacedust: 'https://spacedust.microcosm.blue',
constellation: 'https://constellation.microcosm.blue'
},
- theme: defaultTheme
+ theme: defaultTheme,
+ socialAppUrl: 'https://bsky.app'
};
const createSettingsStore = () => {
@@ -26,6 +28,7 @@ const createSettingsStore = () => {
const initial: Partial = 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(initial as Settings);
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 3ccccc4..e0270d8 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -9,6 +9,9 @@
-
+
{@render children?.()}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 102277c..b9e72b7 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -272,6 +272,7 @@
@@ -285,6 +286,7 @@
{/if}
+
{#if errors.length > 0}