replies timeline only, appview-less bluesky client

Compare changes

Choose any two refs to compare.

+34
deno.lock
···
"npm:@atcute/tid@^1.0.3": "1.0.3",
"npm:@eslint/compat@^1.4.1": "1.4.1_eslint@9.39.0",
"npm:@eslint/js@^9.39.0": "9.39.0",
+
"npm:@floating-ui/dom@^1.7.4": "1.7.4",
"npm:@iconify/svelte@^5.1.0": "5.1.0_svelte@5.43.2__acorn@8.15.0",
"npm:@soffinal/websocket@~0.2.1": "0.2.1_typescript@5.9.3",
"npm:@sveltejs/adapter-static@^3.0.10": "3.0.10_@sveltejs+kit@2.48.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.43.2____acorn@8.15.0___vite@7.1.12____@types+node@24.10.0____picomatch@4.0.3___@types+node@24.10.0__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.10.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__@types+node@24.10.0_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0",
···
"npm:prettier@^3.6.2": "3.6.2",
"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",
···
"@eslint/core",
"levn"
]
+
},
+
"@floating-ui/core@1.7.3": {
+
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+
"dependencies": [
+
"@floating-ui/utils"
+
]
+
},
+
"@floating-ui/dom@1.7.4": {
+
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+
"dependencies": [
+
"@floating-ui/core",
+
"@floating-ui/utils"
+
]
+
},
+
"@floating-ui/utils@0.2.10": {
+
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"@humanfs/core@0.19.1": {
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="
···
],
"bin": true
},
+
"svelte-device-info@1.0.6": {
+
"integrity": "sha512-G13YYkxnlz5AryOps8KFHFt8+5Ne7JiZgTxtYEXLVBF4UAwu9I1F+Xcd9rfhTZqUUtF9fm4qJpSi3I6p1JUt6Q==",
+
"dependencies": [
+
"tslib"
+
]
+
},
"svelte-eslint-parser@1.4.0_svelte@5.43.2__acorn@8.15.0_postcss@8.5.6": {
"integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==",
"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": [
···
"dependencies": [
"typescript"
+
},
+
"tslib@2.8.1": {
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"type-check@0.4.0": {
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
···
"npm:@atcute/tid@^1.0.3",
"npm:@eslint/compat@^1.4.1",
"npm:@eslint/js@^9.39.0",
+
"npm:@floating-ui/dom@^1.7.4",
"npm:@iconify/svelte@^5.1.0",
"npm:@soffinal/websocket@~0.2.1",
"npm:@sveltejs/adapter-static@^3.0.10",
···
"npm:prettier@^3.6.2",
"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",
+1 -1
nix/modules.nix
···
];
};
-
outputHash = "sha256-glfh3vNy9U0w7CjgFADAwb0qg/tzC0H97EY7zSjTgaQ=";
+
outputHash = "sha256-s5rq8htDjR0I8MxPtLq1NYIywXGEdYbZZvE7I5+TCIU=";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
+4 -1
package.json
···
"@atcute/lexicons": "^1.2.2",
"@atcute/oauth-browser-client": "^2.0.1",
"@atcute/tid": "^1.0.3",
+
"@floating-ui/dom": "^1.7.4",
"@soffinal/websocket": "^0.2.1",
"@wora/cache-persist": "^2.2.1",
"hash-wasm": "^4.12.0",
"lru-cache": "^11.2.2",
-
"svelte-infinite": "^0.5.1"
+
"svelte-device-info": "^1.0.6",
+
"svelte-infinite": "^0.5.1",
+
"svelte-portal": "^2.2.1"
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
+10 -1
src/app.css
···
}
@utility single-line-input {
-
@apply w-full rounded-sm border-2 px-3 py-2 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none;
+
@apply w-full rounded-sm border-2 border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3 px-3 py-2 font-medium transition-all;
+
&:focus {
+
@apply scale-[1.02] border-(--nucleus-accent)/80 bg-(--nucleus-accent)/10 [box-shadow:none] outline-none;
+
}
}
@utility action-button {
···
button {
@apply hover:cursor-pointer;
+
}
+
+
a {
+
&:hover {
+
@apply cursor-pointer underline;
+
}
}
.grain:before {
+70 -78
src/components/AccountSelector.svelte
···
import ProfilePicture from './ProfilePicture.svelte';
import PfpPlaceholder from './PfpPlaceholder.svelte';
import Popup from './Popup.svelte';
+
import Dropdown from './Dropdown.svelte';
import { flow } from '$lib/at/oauth';
import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
import Icon from '@iconify/svelte';
···
let loginError = $state('');
let isLoggingIn = $state(false);
-
const toggleDropdown = (e: MouseEvent) => {
-
e.stopPropagation();
-
isDropdownOpen = !isDropdownOpen;
-
};
+
const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen);
+
const closeDropdown = () => (isDropdownOpen = false);
const selectAccount = (did: AtprotoDid) => {
onAccountSelected(did);
-
isDropdownOpen = false;
+
closeDropdown();
};
const openLoginModal = () => {
isLoginModalOpen = true;
-
isDropdownOpen = false;
+
closeDropdown();
loginHandle = '';
loginError = '';
// HACK: i hate this but it works so it doesnt really matter
···
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !isLoggingIn) handleLogin();
};
-
-
const closeDropdown = () => {
-
isDropdownOpen = false;
-
};
</script>
-
<svelte:window onclick={closeDropdown} />
-
-
<div class="relative">
-
<button
-
onclick={toggleDropdown}
-
class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150"
-
>
-
{#if selectedDid}
-
<ProfilePicture {client} did={selectedDid} size={13} />
-
{:else}
-
<PfpPlaceholder color="var(--nucleus-accent)" size={13} />
-
{/if}
-
</button>
-
-
{#if isDropdownOpen}
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
-
<div
-
class="absolute bottom-full z-20 mb-1 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"
-
onclick={(e) => e.stopPropagation()}
+
<Dropdown
+
class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl"
+
bind:isOpen={isDropdownOpen}
+
placement="top-start"
+
>
+
{#snippet trigger()}
+
<button
+
onclick={toggleDropdown}
+
class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150"
>
-
{#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)}
-
<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>
+
{#if selectedDid}
+
<ProfilePicture {client} did={selectedDid} size={13} />
+
{:else}
+
<PfpPlaceholder color="var(--nucleus-accent)" size={13} />
+
{/if}
+
</button>
+
{/snippet}
-
<div class="grow"></div>
+
{#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>
-
{@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () =>
-
initiateLogin(account.did, account.handle)
-
)}
-
{@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))}
+
<div class="grow"></div>
-
{#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>
+
{@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}
-
</div>
+
<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">
<!-- svelte-ignore a11y_no_static_element_interactions -->
+404 -146
src/components/BskyPost.svelte
···
<script lang="ts">
import { type AtpClient } from '$lib/at/client';
-
import { AppBskyFeedPost } from '@atcute/bluesky';
+
import {
+
AppBskyActorProfile,
+
AppBskyEmbedExternal,
+
AppBskyEmbedImages,
+
AppBskyEmbedVideo,
+
AppBskyFeedPost
+
} from '@atcute/bluesky';
import {
parseCanonicalResourceUri,
type ActorIdentifier,
···
import BskyPost from './BskyPost.svelte';
import Icon from '@iconify/svelte';
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
-
import { postActions, pulsingPostId, type PostActions } from '$lib/state.svelte';
+
import { clients, postActions, posts, pulsingPostId, type PostActions } from '$lib/state.svelte';
import * as TID from '@atcute/tid';
import type { PostWithUri } from '$lib/at/fetch';
import { onMount } from 'svelte';
-
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import { isActorIdentifier, type AtprotoDid } from '@atcute/lexicons/syntax';
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;
···
isOnPostComposer = false /* replyBacklinks */
}: Props = $props();
-
const selectedDid = $derived(client.didDoc?.did ?? null);
+
const selectedDid = $derived(client.user?.did ?? null);
+
const actionClient = $derived(clients.get(did as AtprotoDid));
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
const color = generateColorForDid(did);
···
const post = data
? Promise.resolve(ok(data))
: client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
+
let profile: AppBskyActorProfile.Main | null = $state(null);
+
onMount(async () => {
+
const p = await client.getProfile(did);
+
if (!p.ok) return;
+
profile = p.value;
+
console.log(profile.description);
+
});
// const replies = replyBacklinks
// ? Promise.resolve(ok(replyBacklinks))
// : client.getBacklinks(
···
}
return link;
};
+
+
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();
+
event.stopPropagation();
+
};
+
+
let deleteState: 'waiting' | 'confirm' | 'deleted' = $state('waiting');
+
$effect(() => {
+
if (deleteState === 'confirm' && !actionsOpen) deleteState = 'waiting';
+
});
+
+
const deletePost = () => {
+
if (deleteState === 'deleted') return;
+
if (deleteState === 'waiting') {
+
deleteState = 'confirm';
+
return;
+
}
+
+
actionClient?.atcute
+
?.post('com.atproto.repo.deleteRecord', {
+
input: {
+
collection: 'app.bsky.feed.post',
+
repo: did,
+
rkey
+
}
+
})
+
.then((result) => {
+
if (!result.ok) return;
+
posts.get(did)?.delete(aturi);
+
deleteState = 'deleted';
+
});
+
actionsOpen = false;
+
};
+
+
let profileOpen = $state(false);
+
let profilePopoutShowDid = $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 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}
+
+
{#snippet profileInline()}
+
<button
+
class="
+
flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''}
+
rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
+
"
+
style="color: {color};"
+
onclick={() => (profileOpen = !profileOpen)}
+
>
+
<ProfilePicture {client} {did} size={8} />
+
+
{#if profile}
+
<span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
+
>{profile.displayName}</span
+
><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span>
+
{:else}
+
{handle}
+
{/if}
+
</button>
+
{/snippet}
+
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
+
{#snippet profilePopout()}
+
{@const profileDesc = profile?.description?.trim() ?? ''}
+
<Dropdown
+
class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!"
+
style="background: {color}36; border-color: {color}99;"
+
bind:isOpen={profileOpen}
+
trigger={profileInline}
+
>
+
<div class="flex items-center gap-2">
+
<ProfilePicture {client} {did} size={20} />
+
+
<div class="flex flex-col items-start overflow-hidden overflow-ellipsis">
+
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
+
{profile?.displayName ?? handle}
+
{#if profile?.pronouns}
+
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
+
{/if}
+
</span>
+
<button
+
oncontextmenu={(e) => {
+
const node = e.target as Node;
+
const selection = window.getSelection() ?? new Selection();
+
const range = document.createRange();
+
range.selectNodeContents(node);
+
selection.removeAllRanges();
+
selection.addRange(range);
+
e.stopPropagation();
+
}}
+
onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)}
+
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
+
>
+
{profilePopoutShowDid ? did : `@${handle}`}
+
</button>
+
{#if profile?.website}
+
<a
+
target="_blank"
+
rel="noopener noreferrer"
+
href={profile.website}
+
class="text-sm text-nowrap opacity-60">{profile.website}</a
+
>
+
{/if}
+
</div>
+
</div>
+
+
{#if profileDesc.length > 0}
+
<p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
+
{#each profileDesc.split(/(\s)/) as line, idx (idx)}
+
{#if line === '\n'}
+
<br />
+
{:else if isActorIdentifier(line.replace(/^@/, ''))}
+
<a
+
target="_blank"
+
rel="noopener noreferrer"
+
class="text-(--nucleus-accent2)"
+
href={`${$settings.socialAppUrl}/profile/${line.replace(/^@/, '')}`}>{line}</a
+
>
+
{:else if line.startsWith('https://')}
+
<a
+
target="_blank"
+
rel="noopener noreferrer"
+
class="text-(--nucleus-accent2)"
+
href={line}>{line.replace(/https?:\/\//, '')}</a
+
>
+
{:else}
+
{line}
+
{/if}
+
{/each}
+
</p>
+
{/if}
+
</Dropdown>
{/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 style="color: {color};">@{handle}</span>:
+
{#if record.embed}
+
{@render embedBadge(record.embed)}
+
{/if}
<span title={record.text}>{record.text}</span>
</div>
{:else}
···
style="background: {color}18; border-color: {color}66;"
>
<div
-
class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) border-l-transparent"
+
class="
+
inline-block h-6 w-6 animate-spin rounded-full
+
border-3 border-(--nucleus-accent) border-l-transparent
+
"
></div>
<p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
</div>
{: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}"
-
class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all {$isPulsing
-
? 'animate-pulse-highlight'
-
: ''}"
-
style="background: {color}{isOnPostComposer
+
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'
-
: '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};"
+
: Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)};
+
border-color: {color}{isOnPostComposer ? '99' : '66'};
+
"
>
<div
-
class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1"
+
class="
+
mb-3 flex w-fit max-w-full items-center gap-1 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}
-
{/await}
-
</span>
+
{@render profilePopout()}
<span>ยท</span>
-
<span class="text-nowrap text-(--nucleus-fg)/67"
-
>{getRelativeTime(new Date(record.createdAt))}</span
+
<span
+
title={new Date(record.createdAt).toLocaleString()}
+
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
>
+
{getRelativeTime(new Date(record.createdAt))}
+
</span>
</div>
-
<p class="leading-relaxed text-wrap wrap-break-word">
+
<p class="leading-normal text-wrap wrap-break-word">
{record.text}
-
{#if isOnPostComposer}
-
{@render embedBadge(record)}
+
{#if isOnPostComposer && record.embed}
+
{@render embedBadge(record.embed)}
{/if}
</p>
{#if !isOnPostComposer && record.embed}
{@const embed = record.embed}
<div class="mt-2">
-
{#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'}
-
<!-- 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}
-
{: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)}
-
{/if}
-
<!-- todo: implement external link embeds -->
+
{@render postEmbed(embed)}
</div>
{/if}
{#if !isOnPostComposer}
···
{/await}
{/if}
+
{#snippet postEmbed(embed: AppBskyEmbeds)}
+
{#snippet embedMedia(
+
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
+
)}
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div oncontextmenu={(e) => e.stopPropagation()}>
+
{#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="w-full 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}
+
</div>
+
{/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)}
-
<div
-
class="group mt-3 flex w-fit max-w-full items-center rounded-sm"
-
style="background: {color}1f;"
+
{#snippet control(
+
name: string,
+
icon: string,
+
onClick: (e: MouseEvent) => void,
+
isFull?: boolean,
+
hasSolid?: boolean
+
)}
+
<button
+
class="
+
px-2 py-1.5 text-(--nucleus-fg)/90 transition-all
+
duration-100 hover:[backdrop-filter:brightness(120%)]
+
"
+
onclick={(e) => onClick(e)}
+
style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
+
title={name}
+
>
+
<Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} />
+
</button>
+
{/snippet}
+
<div class="mt-3 flex w-full items-center justify-between">
+
<div class="flex w-fit items-center rounded-sm" style="background: {color}1f;">
+
{#snippet label(
+
name: string,
+
icon: string,
+
onClick: (link: Backlink | null | undefined) => void,
+
backlink?: Backlink | null,
+
hasSolid?: boolean
+
)}
+
{@render control(name, icon, () => onClick(backlink), backlink ? true : false, hasSolid)}
+
{/snippet}
+
{@render label('reply', 'heroicons:chat-bubble-left', () => {
+
onReply?.(post);
+
})}
+
{@render label(
+
'repost',
+
'heroicons:arrow-path-rounded-square-20-solid',
+
async (link) => {
+
if (link === undefined) return;
+
postActions.set(`${selectedDid!}:${aturi}`, {
+
...backlinks!,
+
repost: await toggleLink(link, 'app.bsky.feed.repost')
+
});
+
},
+
backlinks?.repost
+
)}
+
{@render label('quote', 'heroicons:paper-clip-20-solid', () => {
+
onQuote?.(post);
+
})}
+
{@render label(
+
'like',
+
'heroicons:star',
+
async (link) => {
+
if (link === undefined) return;
+
postActions.set(`${selectedDid!}:${aturi}`, {
+
...backlinks!,
+
like: await toggleLink(link, 'app.bsky.feed.like')
+
});
+
},
+
backlinks?.like,
+
true
+
)}
+
</div>
+
<Dropdown
+
class="post-dropdown"
+
style="background: {color}36; border-color: {color}99;"
+
bind:isOpen={actionsOpen}
+
bind:position={actionsPos}
+
placement="bottom-end"
+
>
+
{@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-20-solid', 'copy post text', () =>
+
navigator.clipboard.writeText(post.record.text)
+
)}
+
{#if actionClient}
+
<div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
+
{@render dropdownItem(
+
deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
+
deleteState === 'confirm' ? 'are you sure?' : 'delete post',
+
deletePost,
+
false,
+
deleteState === 'confirm' ? 'text-red-500' : ''
+
)}
+
{/if}
+
+
{#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,
+
autoClose: boolean = true,
+
extraClass: string = ''
+
)}
+
<button
+
class="
+
flex items-center justify-between rounded-sm px-2 py-1.5
+
transition-all duration-100 hover:[backdrop-filter:brightness(120%)]
+
{extraClass}
+
"
+
onclick={() => {
+
onClick();
+
if (autoClose) actionsOpen = false;
+
}}
>
-
{#snippet label(
-
name: string,
-
icon: string,
-
onClick: (link: Backlink | null | undefined) => void,
-
backlink?: Backlink | null,
-
hasSolid?: boolean
-
)}
-
<button
-
class="px-2 py-1.5 text-(--nucleus-fg)/90 hover:[backdrop-filter:brightness(120%)]"
-
onclick={() => onClick(backlink)}
-
style="color: {backlink ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
-
title={name}
-
>
-
<Icon icon={hasSolid && backlink ? `${icon}-solid` : icon} width={20} />
-
</button>
-
{/snippet}
-
{@render label('reply', 'heroicons:chat-bubble-left', () => {
-
onReply?.(post);
-
})}
-
{@render label(
-
'repost',
-
'heroicons:arrow-path-rounded-square-20-solid',
-
async (link) => {
-
if (link === undefined) return;
-
postActions.set(`${selectedDid!}:${aturi}`, {
-
...backlinks!,
-
repost: await toggleLink(link, 'app.bsky.feed.repost')
-
});
-
},
-
backlinks?.repost
-
)}
-
{@render label('quote', 'heroicons:paper-clip-20-solid', () => {
-
onQuote?.(post);
-
})}
-
{@render label(
-
'like',
-
'heroicons:star',
-
async (link) => {
-
if (link === undefined) return;
-
postActions.set(`${selectedDid!}:${aturi}`, {
-
...backlinks!,
-
like: await toggleLink(link, 'app.bsky.feed.like')
-
});
-
},
-
backlinks?.like,
-
true
-
)}
-
</div>
+
<span class="font-bold">{label}</span>
+
<Icon class="h-6 w-6" {icon} />
+
</button>
{/snippet}
+
+
<style>
+
@reference "../app.css";
+
+
:global(.post-dropdown) {
+
@apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;
+
}
+
</style>
+106
src/components/Dropdown.svelte
···
+
<script lang="ts">
+
import {
+
computePosition,
+
autoUpdate,
+
offset,
+
flip,
+
shift,
+
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 {
+
isOpen = $bindable(false),
+
trigger,
+
children,
+
placement = 'bottom-start',
+
offsetDistance = 2,
+
position = $bindable(),
+
...restProps
+
}: Props = $props();
+
+
let triggerRef: HTMLElement | undefined = $state();
+
let contentRef: HTMLElement | undefined = $state();
+
let cleanup: (() => void) | null = null;
+
+
const updatePosition = async () => {
+
const { x, y } = await computePosition(triggerRef!, contentRef!, {
+
placement,
+
middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })],
+
strategy: 'fixed'
+
});
+
+
Object.assign(contentRef!.style, {
+
left: `${x}px`,
+
top: `${y}px`
+
});
+
};
+
+
const handleClose = () => (isOpen = false);
+
+
const isEventInElement = (event: MouseEvent, element: HTMLElement) => {
+
let rect = element.getBoundingClientRect();
+
let x = event.clientX;
+
let y = event.clientY;
+
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
+
};
+
+
const handleClickOutside = (event: MouseEvent) => {
+
if (!isOpen) return;
+
if (!isEventInElement(event, triggerRef!) && !isEventInElement(event, contentRef!))
+
handleClose();
+
};
+
+
const handleEscape = (event: KeyboardEvent) => {
+
if (event.key === 'Escape') handleClose();
+
};
+
+
const handleScroll = handleClose;
+
+
$effect(() => {
+
if (isOpen) {
+
cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
+
} else if (cleanup) {
+
cleanup();
+
cleanup = null;
+
}
+
});
+
+
onMount(() => {
+
return () => {
+
if (cleanup) cleanup();
+
};
+
});
+
</script>
+
+
<svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} />
+
+
<div role="button" tabindex="0" bind:this={triggerRef}>
+
{@render trigger?.()}
+
</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';
+
import { portal } from 'svelte-portal';
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);
-
};
+
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
-
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"
+
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"
+35 -20
src/components/PostComposer.svelte
···
}: Props = $props();
let color = $derived(
-
client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent2)'
+
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
);
const post = async (text: string): Promise<Result<PostWithUri, string>> => {
···
const res = await client.atcute?.post('com.atproto.repo.createRecord', {
input: {
collection: 'app.bsky.feed.post',
-
repo: client.didDoc!.did,
+
repo: client.user!.did,
record
}
});
···
{#if replying}
{@render renderPost(replying)}
{/if}
-
<textarea
-
bind:this={textareaEl}
-
bind:value={postText}
-
onfocus={() => (isFocused = true)}
-
onblur={unfocus}
-
onkeydown={(event) => {
-
if (event.key === 'Escape') unfocus();
-
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
-
}}
-
placeholder="what's on your mind?"
-
rows="4"
-
class="field-sizing-content resize-none"
-
></textarea>
-
{#if quoting}
-
{@render renderPost(quoting)}
-
{/if}
+
<div class="composer space-y-2">
+
<textarea
+
bind:this={textareaEl}
+
bind:value={postText}
+
onfocus={() => (isFocused = true)}
+
onblur={unfocus}
+
onkeydown={(event) => {
+
if (event.key === 'Escape') unfocus();
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
+
}}
+
placeholder="what's on your mind?"
+
rows="4"
+
class="field-sizing-content resize-none"
+
></textarea>
+
{#if quoting}
+
{@render renderPost(quoting)}
+
{/if}
+
</div>
{/snippet}
<div class="relative min-h-13">
···
</div>
</div>
+
<!-- TODO: this fucking blows -->
<style>
@reference "../app.css";
input,
-
textarea {
+
.composer {
@apply single-line-input bg-(--nucleus-bg)/35;
border-color: color-mix(in srgb, var(--acc-color) 30%, transparent);
}
+
.composer {
+
@apply p-2;
+
}
+
+
textarea {
+
@apply w-full bg-transparent p-0;
+
}
+
input {
@apply p-1 px-2;
}
-
textarea {
+
.composer {
@apply focus:scale-100;
}
input::placeholder,
textarea::placeholder {
color: color-mix(in srgb, var(--acc-color) 45%, var(--nucleus-bg));
+
}
+
+
textarea:focus {
+
@apply border-none! [box-shadow:none]! outline-none!;
}
</style>
+16 -1
src/components/SettingsPopup.svelte
···
type="url"
bind:value={localSettings.endpoints[name]}
placeholder={defaultSettings.endpoints[name]}
-
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
+
class="single-line-input"
/>
</div>
{/snippet}
···
{@render _input('spacedust', 'spacedust url (for notifications)')}
{@render _input('constellation', 'constellation url (for backlinks)')}
</div>
+
</div>
+
+
{@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"
+
/>
</div>
{@render divider()}
+9 -11
src/lib/at/client.ts
···
export class AtpClient {
public atcute: AtcuteClient | null = null;
-
public didDoc: MiniDoc | null = null;
-
-
async login(identifier: ActorIdentifier, agent: OAuthUserAgent): Promise<Result<null, string>> {
-
if ((agent.session.token.expires_at ?? 0) < Date.now()) {
-
return err('token expired, relogin');
-
}
-
-
const didDoc = await this.resolveDidDoc(identifier);
-
if (!didDoc.ok) return err(didDoc.error);
-
this.didDoc = didDoc.value;
+
public user: { did: Did; handle: Handle } | null = null;
+
async login(agent: OAuthUserAgent): Promise<Result<null, string>> {
try {
const rpc = new AtcuteClient({ handler: agent });
+
const res = await rpc.get('com.atproto.server.getSession');
+
if (!res.ok) throw res.data.error;
+
this.user = {
+
did: res.data.did,
+
handle: res.data.handle
+
};
this.atcute = rpc;
} catch (error) {
return err(`failed to login: ${error}`);
···
}
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
-
repo = repo ?? this.didDoc?.did;
+
repo = repo ?? this.user?.did;
if (!repo) return err('not authenticated');
return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record);
}
+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;
+
socialAppUrl: string;
};
export const defaultSettings: Settings = {
···
spacedust: 'https://spacedust.microcosm.blue',
constellation: 'https://constellation.microcosm.blue'
},
-
theme: defaultTheme
+
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);
+9 -1
src/lib/state.svelte.ts
···
import { writable } from 'svelte/store';
-
import { type NotificationsStream } from './at/client';
+
import { AtpClient, type NotificationsStream } from './at/client';
import { SvelteMap } from 'svelte/reactivity';
import type { Did, ResourceUri } from '@atcute/lexicons';
import type { Backlink } from './at/constellation';
+
import type { PostWithUri } from './at/fetch';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
// import type { JetstreamSubscription } from '@atcute/jetstream';
export const notificationStream = writable<NotificationsStream | null>(null);
···
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
export const pulsingPostId = writable<string | null>(null);
+
+
export const viewClient = new AtpClient();
+
export const clients = new SvelteMap<AtprotoDid, AtpClient>();
+
+
export const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
+
export const cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
+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">
+
<div
+
id="app-root"
+
class="min-h-screen bg-(--nucleus-bg) text-(--nucleus-fg) transition-colors duration-300"
+
>
{@render children?.()}
</div>
+5 -9
src/routes/+page.svelte
···
import { AppBskyFeedPost } from '@atcute/bluesky';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
-
import { notificationStream } from '$lib/state.svelte';
+
import { clients, cursors, notificationStream, posts, viewClient } from '$lib/state.svelte';
import { get } from 'svelte/store';
import Icon from '@iconify/svelte';
import { sessions } from '$lib/at/oauth';
···
}
});
-
const clients = new SvelteMap<AtprotoDid, AtpClient>();
const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
const loginAccount = async (account: Account) => {
if (clients.has(account.did)) return;
const client = new AtpClient();
-
const result = await client.login(account.did, await sessions.get(account.did));
+
const result = await client.login(await sessions.get(account.did));
if (!result.ok) {
errors.push(`failed to login into @${account.handle ?? account.did}: ${result.error}`);
return;
···
cursors.delete(did);
handleAccountSelected(newAccounts[0]?.did);
};
-
-
const viewClient = new AtpClient();
-
-
const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
-
const cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
let isSettingsOpen = $state(false);
let isNotificationsOpen = $state(false);
···
if ($accounts.length > 0) {
loaderState.status = 'LOADING';
if (loadData.client.ok && loadData.client.value) {
-
const loggedInDid = loadData.client.value.didDoc!.did as AtprotoDid;
+
const loggedInDid = loadData.client.value.user!.did as AtprotoDid;
selectedDid = loggedInDid;
clients.set(loggedInDid, loadData.client.value);
}
···
<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}
+1 -1
src/routes/+page.ts
···
}
const client = new AtpClient();
-
const result = await client.login(account.did, agent.value);
+
const result = await client.login(agent.value);
if (!result.ok) return err(result.error);
addAccount(account);