replies timeline only, appview-less bluesky client

feat: implement oauth, fix some state issues

ptr.pet 607db7f8 156c793e

verified
+45 -1
deno.lock
···
"npm:@atcute/atproto@^3.1.7": "3.1.8",
"npm:@atcute/bluesky@^3.2.7": "3.2.9",
"npm:@atcute/client@^4.0.5": "4.0.5",
"npm:@atcute/identity@^1.1.1": "1.1.1",
"npm:@atcute/lexicons@^1.2.2": "1.2.2",
"npm:@atcute/tid@^1.0.3": "1.0.3",
"npm:@eslint/compat@^1.4.0": "1.4.1_eslint@9.38.0",
"npm:@eslint/js@^9.36.0": "9.38.0",
···
"@atcute/lexicons"
]
},
"@atcute/identity@1.1.1": {
"integrity": "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==",
"dependencies": [
···
"esm-env"
]
},
"@atcute/tid@1.0.3": {
"integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w=="
},
"@badrap/valita@0.4.6": {
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
···
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"bin": true
},
"natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
···
"postcss@8.5.6": {
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dependencies": [
-
"nanoid",
"picocolors",
"source-map-js"
]
···
"npm:@atcute/atproto@^3.1.7",
"npm:@atcute/bluesky@^3.2.7",
"npm:@atcute/client@^4.0.5",
"npm:@atcute/identity@^1.1.1",
"npm:@atcute/lexicons@^1.2.2",
"npm:@atcute/tid@^1.0.3",
"npm:@eslint/compat@^1.4.0",
"npm:@eslint/js@^9.36.0",
···
"npm:@atcute/atproto@^3.1.7": "3.1.8",
"npm:@atcute/bluesky@^3.2.7": "3.2.9",
"npm:@atcute/client@^4.0.5": "4.0.5",
+
"npm:@atcute/identity-resolver@^1.1.4": "1.1.4_@atcute+identity@1.1.1",
"npm:@atcute/identity@^1.1.1": "1.1.1",
"npm:@atcute/lexicons@^1.2.2": "1.2.2",
+
"npm:@atcute/oauth-browser-client@^2.0.1": "2.0.1_@atcute+identity@1.1.1",
"npm:@atcute/tid@^1.0.3": "1.0.3",
"npm:@eslint/compat@^1.4.0": "1.4.1_eslint@9.38.0",
"npm:@eslint/js@^9.36.0": "9.38.0",
···
"@atcute/lexicons"
]
},
+
"@atcute/identity-resolver@1.1.4_@atcute+identity@1.1.1": {
+
"integrity": "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==",
+
"dependencies": [
+
"@atcute/identity",
+
"@atcute/lexicons",
+
"@atcute/util-fetch",
+
"@badrap/valita"
+
]
+
},
"@atcute/identity@1.1.1": {
"integrity": "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==",
"dependencies": [
···
"esm-env"
]
},
+
"@atcute/multibase@1.1.6": {
+
"integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==",
+
"dependencies": [
+
"@atcute/uint8array"
+
]
+
},
+
"@atcute/oauth-browser-client@2.0.1_@atcute+identity@1.1.1": {
+
"integrity": "sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg==",
+
"dependencies": [
+
"@atcute/client",
+
"@atcute/identity",
+
"@atcute/identity-resolver",
+
"@atcute/lexicons",
+
"@atcute/multibase",
+
"@atcute/uint8array",
+
"nanoid@5.1.6"
+
]
+
},
"@atcute/tid@1.0.3": {
"integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w=="
+
},
+
"@atcute/uint8array@1.0.5": {
+
"integrity": "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q=="
+
},
+
"@atcute/util-fetch@1.0.3": {
+
"integrity": "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ==",
+
"dependencies": [
+
"@badrap/valita"
+
]
},
"@badrap/valita@0.4.6": {
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
···
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"bin": true
},
+
"nanoid@5.1.6": {
+
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
+
"bin": true
+
},
"natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
···
"postcss@8.5.6": {
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dependencies": [
+
"nanoid@3.3.11",
"picocolors",
"source-map-js"
]
···
"npm:@atcute/atproto@^3.1.7",
"npm:@atcute/bluesky@^3.2.7",
"npm:@atcute/client@^4.0.5",
+
"npm:@atcute/identity-resolver@^1.1.4",
"npm:@atcute/identity@^1.1.1",
"npm:@atcute/lexicons@^1.2.2",
+
"npm:@atcute/oauth-browser-client@^2.0.1",
"npm:@atcute/tid@^1.0.3",
"npm:@eslint/compat@^1.4.0",
"npm:@eslint/js@^9.36.0",
+3 -4
flake.lock
···
},
"nixpkgs": {
"locked": {
-
"lastModified": 1761656231,
-
"narHash": "sha256-EiED5k6gXTWoAIS8yQqi5mAX6ojnzpHwAQTS3ykeYMg=",
"owner": "nixos",
"repo": "nixpkgs",
-
"rev": "e99366c665bdd53b7b500ccdc5226675cfc51f45",
"type": "github"
},
"original": {
"owner": "nixos",
-
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
···
},
"nixpkgs": {
"locked": {
+
"lastModified": 1761850514,
+
"narHash": "sha256-qmg1yC6ybzH0/w4Bupx1hpgTS5MTl2qBMoD+DFx3hWM=",
"owner": "nixos",
"repo": "nixpkgs",
+
"rev": "1c3d5f4e01f0b18b508be644d9d6a196fb7ed1f5",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
+2 -2
flake.nix
···
{
inputs.parts.url = "github:hercules-ci/flake-parts";
-
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
inputs.naked-shell.url = "github:90-008/mk-naked-shell";
outputs = inp:
···
devShells.default = config.mk-naked-shell.lib.mkNakedShell {
name = "nucleus-devshell";
packages = with pkgs; [
-
nodejs-slim_latest deno
];
shellHook = ''
export PATH="$PATH:$PWD/node_modules/.bin"
···
{
inputs.parts.url = "github:hercules-ci/flake-parts";
+
inputs.nixpkgs.url = "github:nixos/nixpkgs";
inputs.naked-shell.url = "github:90-008/mk-naked-shell";
outputs = inp:
···
devShells.default = config.mk-naked-shell.lib.mkNakedShell {
name = "nucleus-devshell";
packages = with pkgs; [
+
nodejs-slim_latest deno biome
];
shellHook = ''
export PATH="$PATH:$PWD/node_modules/.bin"
+2
package.json
···
"@atcute/bluesky": "^3.2.7",
"@atcute/client": "^4.0.5",
"@atcute/identity": "^1.1.1",
"@atcute/lexicons": "^1.2.2",
"@atcute/tid": "^1.0.3",
"@soffinal/websocket": "^0.2.1",
"@wora/cache-persist": "^2.2.1",
···
"@atcute/bluesky": "^3.2.7",
"@atcute/client": "^4.0.5",
"@atcute/identity": "^1.1.1",
+
"@atcute/identity-resolver": "^1.1.4",
"@atcute/lexicons": "^1.2.2",
+
"@atcute/oauth-browser-client": "^2.0.1",
"@atcute/tid": "^1.0.3",
"@soffinal/websocket": "^0.2.1",
"@wora/cache-persist": "^2.2.1",
+7
src/app.css
···
@apply rounded-sm border-2 border-(--nucleus-accent) px-3 py-2 font-semibold text-(--nucleus-accent) transition-all hover:scale-105 hover:bg-(--nucleus-accent)/20;
}
:root {
scrollbar-width: thin;
scrollbar-color: var(--nucleus-accent) var(--nucleus-bg);
···
@apply rounded-sm border-2 border-(--nucleus-accent) px-3 py-2 font-semibold text-(--nucleus-accent) transition-all hover:scale-105 hover:bg-(--nucleus-accent)/20;
}
+
@utility error-disclaimer {
+
@apply rounded-sm border-2 border-red-500 bg-red-500/8 p-2;
+
p {
+
@apply text-base text-wrap wrap-break-word text-red-500;
+
}
+
}
+
:root {
scrollbar-width: thin;
scrollbar-color: var(--nucleus-accent) var(--nucleus-bg);
+29 -56
src/components/AccountSelector.svelte
···
<script lang="ts">
-
import { generateColorForDid, type Account } from '$lib/accounts';
import { AtpClient } from '$lib/at/client';
-
import type { Did, Handle } from '@atcute/lexicons';
import ProfilePicture from './ProfilePicture.svelte';
import PfpPlaceholder from './PfpPlaceholder.svelte';
interface Props {
client: AtpClient;
accounts: Array<Account>;
-
selectedDid?: Did | null;
-
onAccountSelected: (did: Did) => void;
-
onLoginSucceed: (did: Did, handle: Handle, password: string) => void;
-
onLogout: (did: Did) => void;
}
let {
···
accounts = [],
selectedDid = $bindable(null),
onAccountSelected,
-
onLoginSucceed,
onLogout
}: Props = $props();
let isDropdownOpen = $state(false);
let isLoginModalOpen = $state(false);
let loginHandle = $state('');
-
let loginPassword = $state('');
let loginError = $state('');
let isLoggingIn = $state(false);
···
isDropdownOpen = !isDropdownOpen;
};
-
const selectAccount = (did: Did) => {
onAccountSelected(did);
isDropdownOpen = false;
};
···
isLoginModalOpen = true;
isDropdownOpen = false;
loginHandle = '';
-
loginPassword = '';
loginError = '';
};
const closeLoginModal = () => {
isLoginModalOpen = false;
loginHandle = '';
-
loginPassword = '';
loginError = '';
};
const handleLogin = async () => {
-
if (!loginHandle || !loginPassword) {
-
loginError = 'please enter both handle and password';
-
return;
-
}
-
isLoggingIn = true;
-
loginError = '';
-
try {
-
const client = new AtpClient();
-
const result = await client.login(loginHandle as Handle, loginPassword);
-
if (!result.ok) {
-
loginError = result.error;
-
isLoggingIn = false;
-
return;
-
}
-
if (!client.didDoc) {
-
loginError = 'failed to get did document';
-
isLoggingIn = false;
-
return;
-
}
-
-
onLoginSucceed(client.didDoc.did, loginHandle as Handle, loginPassword);
-
closeLoginModal();
} catch (error) {
loginError = `login failed: ${error}`;
} finally {
isLoggingIn = false;
}
···
<svg
xmlns="http://www.w3.org/2000/svg"
onclick={() => onLogout(account.did)}
-
class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md"
width="24"
height="24"
viewBox="0 0 20 20"
···
</button>
{/each}
</div>
-
<div
-
class="mx-2 h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"
-
></div>
{/if}
<button
onclick={openLoginModal}
···
/>
</div>
-
<div>
-
<label for="password" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
-
app password
-
</label>
-
<input
-
id="password"
-
type="password"
-
bind:value={loginPassword}
-
placeholder="xxxx-xxxx-xxxx-xxxx"
-
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
-
disabled={isLoggingIn}
-
/>
-
</div>
-
{#if loginError}
-
<div
-
class="rounded-sm border-2 p-4"
-
style="background: #ef444422; border-color: #ef4444;"
-
>
-
<p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p>
</div>
{/if}
···
<script lang="ts">
+
import { generateColorForDid, loggingIn, type Account } from '$lib/accounts';
import { AtpClient } from '$lib/at/client';
+
import type { Handle } from '@atcute/lexicons';
import ProfilePicture from './ProfilePicture.svelte';
import PfpPlaceholder from './PfpPlaceholder.svelte';
+
import { flow } from '$lib/at/oauth';
+
import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
+
import Icon from '@iconify/svelte';
interface Props {
client: AtpClient;
accounts: Array<Account>;
+
selectedDid?: AtprotoDid | null;
+
onAccountSelected: (did: AtprotoDid) => void;
+
onLogout: (did: AtprotoDid) => void;
}
let {
···
accounts = [],
selectedDid = $bindable(null),
onAccountSelected,
onLogout
}: Props = $props();
let isDropdownOpen = $state(false);
let isLoginModalOpen = $state(false);
let loginHandle = $state('');
let loginError = $state('');
let isLoggingIn = $state(false);
···
isDropdownOpen = !isDropdownOpen;
};
+
const selectAccount = (did: AtprotoDid) => {
onAccountSelected(did);
isDropdownOpen = false;
};
···
isLoginModalOpen = true;
isDropdownOpen = false;
loginHandle = '';
loginError = '';
};
const closeLoginModal = () => {
isLoginModalOpen = false;
loginHandle = '';
loginError = '';
};
const handleLogin = async () => {
+
try {
+
if (!loginHandle) throw 'please enter handle';
+
isLoggingIn = true;
+
loginError = '';
+
let handle: Handle;
+
if (isHandle(loginHandle)) handle = loginHandle;
+
else throw 'handle is invalid';
+
let did = await client.resolveHandle(handle);
+
if (!did.ok) throw did.error;
+
loggingIn.set({ did: did.value, handle });
+
const result = await flow.start(handle);
+
if (!result.ok) throw result.error;
} catch (error) {
loginError = `login failed: ${error}`;
+
loggingIn.set(null);
} finally {
isLoggingIn = false;
}
···
<svg
xmlns="http://www.w3.org/2000/svg"
onclick={() => onLogout(account.did)}
+
class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md"
width="24"
height="24"
viewBox="0 0 20 20"
···
</button>
{/each}
</div>
+
<div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
{/if}
<button
onclick={openLoginModal}
···
/>
</div>
{#if loginError}
+
<div class="error-disclaimer">
+
<p>
+
<Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
+
{loginError}
+
</p>
</div>
{/if}
+22 -21
src/components/BskyPost.svelte
···
type ResourceUri
} from '@atcute/lexicons';
import { expect, ok } from '$lib/result';
-
import { generateColorForDid } from '$lib/accounts';
import ProfilePicture from './ProfilePicture.svelte';
import { isBlob } from '@atcute/lexicons/interfaces';
import { blob, img } from '$lib/cdn';
import BskyPost from './BskyPost.svelte';
import Icon from '@iconify/svelte';
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
-
import { postActions, type PostActions } from '$lib';
import * as TID from '@atcute/tid';
import type { PostWithUri } from '$lib/at/fetch';
-
import type { Writable } from 'svelte/store';
import { onMount } from 'svelte';
interface Props {
client: AtpClient;
-
selectedDid: Writable<Did | null>;
// post
did: Did;
rkey: RecordKey;
···
const {
client,
-
selectedDid,
did,
rkey,
data,
···
onReply,
isOnPostComposer = false /* replyBacklinks */
}: Props = $props();
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
const color = generateColorForDid(did);
···
return 'now';
};
-
const findBacklink = async (source: BacklinksSource) => {
const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
if (!backlinks.ok) return null;
-
return backlinks.value.records.find((r) => r.did === $selectedDid) ?? null;
-
};
-
let findAllBacklinks = async (did: Did | null) => {
if (!did) return;
if (postActions.has(`${did}:${aturi}`)) return;
const backlinks = await Promise.all([
-
findBacklink('app.bsky.feed.like:subject.uri'),
-
findBacklink('app.bsky.feed.repost:subject.uri')
// findBacklink('app.bsky.feed.post:reply.parent.uri'),
// findBacklink('app.bsky.feed.post:embed.record.uri')
]);
···
};
onMount(() => {
// findAllBacklinks($selectedDid);
-
selectedDid.subscribe(findAllBacklinks);
});
const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
// console.log('toggleLink', selectedDid, link, collection);
-
if (!$selectedDid) return null;
const _post = await post;
if (!_post.ok) return null;
if (!link) {
···
// todo: handle errors
client.atcute?.post('com.atproto.repo.createRecord', {
input: {
-
repo: $selectedDid,
collection,
record,
rkey
···
});
return {
collection,
-
did: $selectedDid,
rkey
};
}
···
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-left-color:transparent]"
></div>
<p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
</div>
···
>{getRelativeTime(new Date(record.createdAt))}</span
>
</div>
-
<p class="leading-relaxed text-wrap break-words">
{record.text}
{#if isOnPostComposer}
{@render embedBadge(record)}
···
<!-- reject recursive quotes -->
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
<BskyPost
-
{selectedDid}
{client}
did={parsedUri.repo}
rkey={parsedUri.rkey}
···
</div>
{/if}
{#if !isOnPostComposer}
-
{@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)}
{@render postControls(post.value, backlinks)}
{/if}
</div>
···
'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')
});
···
'heroicons:star',
async (link) => {
if (link === undefined) return;
-
postActions.set(`${$selectedDid!}:${aturi}`, {
...backlinks!,
like: await toggleLink(link, 'app.bsky.feed.like')
});
···
type ResourceUri
} from '@atcute/lexicons';
import { expect, ok } from '$lib/result';
+
import { accounts, generateColorForDid } from '$lib/accounts';
import ProfilePicture from './ProfilePicture.svelte';
import { isBlob } from '@atcute/lexicons/interfaces';
import { blob, img } from '$lib/cdn';
import BskyPost from './BskyPost.svelte';
import Icon from '@iconify/svelte';
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
+
import { postActions, 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';
interface Props {
client: AtpClient;
// post
did: Did;
rkey: RecordKey;
···
const {
client,
did,
rkey,
data,
···
onReply,
isOnPostComposer = false /* replyBacklinks */
}: Props = $props();
+
+
const selectedDid = $derived(client.didDoc?.did ?? null);
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
const color = generateColorForDid(did);
···
return 'now';
};
+
const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => {
const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
if (!backlinks.ok) return null;
+
return backlinks.value.records.find((r) => r.did === toDid) ?? null;
+
});
+
let findAllBacklinks = async (did: AtprotoDid | null) => {
if (!did) return;
if (postActions.has(`${did}:${aturi}`)) return;
const backlinks = await Promise.all([
+
findBacklink(did, 'app.bsky.feed.like:subject.uri'),
+
findBacklink(did, 'app.bsky.feed.repost:subject.uri')
// findBacklink('app.bsky.feed.post:reply.parent.uri'),
// findBacklink('app.bsky.feed.post:embed.record.uri')
]);
···
};
onMount(() => {
// findAllBacklinks($selectedDid);
+
accounts.subscribe((accs) => {
+
accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did));
+
});
});
const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
// console.log('toggleLink', selectedDid, link, collection);
+
if (!selectedDid) return null;
const _post = await post;
if (!_post.ok) return null;
if (!link) {
···
// todo: handle errors
client.atcute?.post('com.atproto.repo.createRecord', {
input: {
+
repo: selectedDid,
collection,
record,
rkey
···
});
return {
collection,
+
did: selectedDid,
rkey
};
}
···
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"
></div>
<p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
</div>
···
>{getRelativeTime(new Date(record.createdAt))}</span
>
</div>
+
<p class="leading-relaxed text-wrap wrap-break-word">
{record.text}
{#if isOnPostComposer}
{@render embedBadge(record)}
···
<!-- reject recursive quotes -->
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
<BskyPost
{client}
did={parsedUri.repo}
rkey={parsedUri.rkey}
···
</div>
{/if}
{#if !isOnPostComposer}
+
{@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)}
{@render postControls(post.value, backlinks)}
{/if}
</div>
···
'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')
});
···
'heroicons:star',
async (link) => {
if (link === undefined) return;
+
postActions.set(`${selectedDid!}:${aturi}`, {
...backlinks!,
like: await toggleLink(link, 'app.bsky.feed.like')
});
+14 -23
src/components/PostComposer.svelte
···
import { generateColorForDid } from '$lib/accounts';
import type { PostWithUri } from '$lib/at/fetch';
import BskyPost from './BskyPost.svelte';
-
import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons';
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
-
import type { Writable } from 'svelte/store';
interface Props {
client: AtpClient;
-
selectedDid: Writable<Did | null>;
onPostSent: (post: PostWithUri) => void;
quoting?: PostWithUri;
replying?: PostWithUri;
···
let {
client,
-
selectedDid,
onPostSent,
quoting = $bindable(undefined),
replying = $bindable(undefined)
···
</div>
{:else}
<div class="flex flex-col gap-2">
{#if isFocused}
{#if replying}
-
{@const parsedUri = expect(parseCanonicalResourceUri(replying.uri))}
-
<BskyPost
-
{client}
-
{selectedDid}
-
did={parsedUri.repo}
-
rkey={parsedUri.rkey}
-
data={replying}
-
isOnPostComposer={true}
-
/>
{/if}
<textarea
bind:this={textareaEl}
···
}}
placeholder="what's on your mind?"
rows="4"
-
class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
></textarea>
{#if quoting}
-
{@const parsedUri = expect(parseCanonicalResourceUri(quoting.uri))}
-
<BskyPost
-
{client}
-
{selectedDid}
-
did={parsedUri.repo}
-
rkey={parsedUri.rkey}
-
data={quoting}
-
isOnPostComposer={true}
-
/>
{/if}
<div class="flex items-center gap-2">
<div class="grow"></div>
···
import { generateColorForDid } from '$lib/accounts';
import type { PostWithUri } from '$lib/at/fetch';
import BskyPost from './BskyPost.svelte';
+
import { parseCanonicalResourceUri } from '@atcute/lexicons';
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
interface Props {
client: AtpClient;
onPostSent: (post: PostWithUri) => void;
quoting?: PostWithUri;
replying?: PostWithUri;
···
let {
client,
onPostSent,
quoting = $bindable(undefined),
replying = $bindable(undefined)
···
</div>
{:else}
<div class="flex flex-col gap-2">
+
{#snippet renderPost(post: PostWithUri)}
+
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
+
<BskyPost
+
{client}
+
did={parsedUri.repo}
+
rkey={parsedUri.rkey}
+
data={post}
+
isOnPostComposer={true}
+
/>
+
{/snippet}
{#if isFocused}
{#if replying}
+
{@render renderPost(replying)}
{/if}
<textarea
bind:this={textareaEl}
···
}}
placeholder="what's on your mind?"
rows="4"
+
class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
></textarea>
{#if quoting}
+
{@render renderPost(quoting)}
{/if}
<div class="flex items-center gap-2">
<div class="grow"></div>
+1 -1
src/components/SettingsPopup.svelte
···
</script>
{#snippet divider()}
-
<div class="h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
{/snippet}
{#snippet settingHeader(name: string, desc: string)}
···
</script>
{#snippet divider()}
+
<div class="h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
{/snippet}
{#snippet settingHeader(name: string, desc: string)}
+19 -5
src/lib/accounts.ts
···
-
import type { Did, Handle } from '@atcute/lexicons';
import { writable } from 'svelte/store';
-
import { hashColor } from './theme.svelte';
export type Account = {
-
did: Did;
-
handle: Handle;
-
password: string;
};
let _accounts: Account[] = [];
···
export const addAccount = (account: Account): void => {
accounts.update((accounts) => [...accounts, account]);
};
export const generateColorForDid = (did: string) => hashColor(did);
···
+
import type { Handle } from '@atcute/lexicons';
import { writable } from 'svelte/store';
+
import { hashColor } from './theme';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
export type Account = {
+
did: AtprotoDid;
+
handle: Handle | null;
};
let _accounts: Account[] = [];
···
export const addAccount = (account: Account): void => {
accounts.update((accounts) => [...accounts, account]);
+
};
+
+
export const loggingIn = {
+
set: (account: Account | null) => {
+
if (!account) {
+
localStorage.removeItem('loggingIn');
+
} else {
+
localStorage.setItem('loggingIn', JSON.stringify(account));
+
}
+
},
+
get: (): Account | null => {
+
const raw = localStorage.getItem('loggingIn');
+
return raw ? JSON.parse(raw) : null;
+
}
};
export const generateColorForDid = (did: string) => hashColor(did);
+5 -7
src/lib/at/client.ts
···
ComAtprotoRepoGetRecord,
ComAtprotoRepoListRecords
} from '@atcute/atproto';
-
import { Client as AtcuteClient, CredentialManager } from '@atcute/client';
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
import {
isDid,
···
import type { Notification } from './stardust';
import { get } from 'svelte/store';
import { settings } from '$lib/settings';
// import { JetstreamSubscription } from '@atcute/jetstream';
const cacheTtl = 1000 * 60 * 60 * 24;
···
public atcute: AtcuteClient | null = null;
public didDoc: MiniDoc | null = null;
-
async login(handle: Handle, password: string): Promise<Result<null, string>> {
-
const didDoc = await this.resolveDidDoc(handle);
if (!didDoc.ok) return err(didDoc.error);
this.didDoc = didDoc.value;
try {
-
const handler = new CredentialManager({ service: didDoc.value.pds });
-
const rpc = new AtcuteClient({ handler });
-
await handler.login({ identifier: didDoc.value.did, password });
-
this.atcute = rpc;
} catch (error) {
return err(`failed to login: ${error}`);
···
ComAtprotoRepoGetRecord,
ComAtprotoRepoListRecords
} from '@atcute/atproto';
+
import { Client as AtcuteClient } from '@atcute/client';
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
import {
isDid,
···
import type { Notification } from './stardust';
import { get } from 'svelte/store';
import { settings } from '$lib/settings';
+
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
// import { JetstreamSubscription } from '@atcute/jetstream';
const cacheTtl = 1000 * 60 * 60 * 24;
···
public atcute: AtcuteClient | null = null;
public didDoc: MiniDoc | null = null;
+
async login(identifier: ActorIdentifier, agent: OAuthUserAgent): Promise<Result<null, string>> {
+
const didDoc = await this.resolveDidDoc(identifier);
if (!didDoc.ok) return err(didDoc.error);
this.didDoc = didDoc.value;
try {
+
const rpc = new AtcuteClient({ handler: agent });
this.atcute = rpc;
} catch (error) {
return err(`failed to login: ${error}`);
+91
src/lib/at/oauth.ts
···
···
+
import {
+
configureOAuth,
+
defaultIdentityResolver,
+
createAuthorizationUrl,
+
finalizeAuthorization,
+
OAuthUserAgent,
+
getSession,
+
deleteStoredSession
+
} from '@atcute/oauth-browser-client';
+
+
import {
+
CompositeDidDocumentResolver,
+
PlcDidDocumentResolver,
+
WebDidDocumentResolver,
+
XrpcHandleResolver
+
} from '@atcute/identity-resolver';
+
import { slingshotUrl } from './client';
+
import type { ActorIdentifier } from '@atcute/lexicons';
+
import { err, ok, type Result } from '$lib/result';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import { clientId, redirectUri } from '$lib/oauth';
+
+
configureOAuth({
+
metadata: {
+
client_id: clientId,
+
redirect_uri: redirectUri
+
},
+
identityResolver: defaultIdentityResolver({
+
handleResolver: new XrpcHandleResolver({ serviceUrl: slingshotUrl.href }),
+
+
didDocumentResolver: new CompositeDidDocumentResolver({
+
methods: {
+
plc: new PlcDidDocumentResolver(),
+
web: new WebDidDocumentResolver()
+
}
+
})
+
})
+
});
+
+
export const sessions = {
+
get: async (did: AtprotoDid) => {
+
const session = await getSession(did, { allowStale: true });
+
return new OAuthUserAgent(session);
+
},
+
remove: async (did: AtprotoDid) => {
+
try {
+
const agent = await sessions.get(did);
+
await agent.signOut();
+
} catch {
+
deleteStoredSession(did);
+
}
+
}
+
};
+
+
export const flow = {
+
start: async (identifier: ActorIdentifier): Promise<Result<null, string>> => {
+
try {
+
const authUrl = await createAuthorizationUrl({
+
target: { type: 'account', identifier },
+
scope: 'atproto transition:generic'
+
});
+
// recommended to wait for the browser to persist local storage before proceeding
+
await new Promise((resolve) => setTimeout(resolve, 200));
+
// redirect the user to sign in and authorize the app
+
window.location.assign(authUrl);
+
// if this is on an async function, ideally the function should never ever resolve.
+
// the only way it should resolve at this point is if the user aborted the authorization
+
// by returning back to this page (thanks to back-forward page caching)
+
await new Promise((_resolve, reject) => {
+
const listener = () => {
+
reject(new Error(`user aborted the login request`));
+
};
+
window.addEventListener('pageshow', listener, { once: true });
+
});
+
return ok(null);
+
} catch (error) {
+
return err(`login error: ${error}`);
+
}
+
},
+
finalize: async (url: URL): Promise<Result<OAuthUserAgent | null, string>> => {
+
try {
+
// createAuthorizationUrl asks server to put the params in the hash
+
const params = new URLSearchParams(url.hash.slice(1));
+
if (!params.has('code')) return ok(null);
+
const { session } = await finalizeAuthorization(params);
+
return ok(new OAuthUserAgent(session));
+
} catch (error) {
+
return err(`login error: ${error}`);
+
}
+
}
+
};
+6
src/lib/domain.ts
···
···
+
import { dev } from '$app/environment';
+
import { env } from '$env/dynamic/public';
+
+
export const domain = dev ? 'http://127.0.0.1:5173' : env.PUBLIC_DOMAIN!;
+
+
export default domain;
-19
src/lib/index.ts
···
-
import { writable } from 'svelte/store';
-
import { 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 { JetstreamSubscription } from '@atcute/jetstream';
-
-
export const selectedDid = writable<Did | null>(null);
-
-
export const notificationStream = writable<NotificationsStream | null>(null);
-
// export const jetstream = writable<JetstreamSubscription | null>(null);
-
-
export type PostActions = {
-
like: Backlink | null;
-
repost: Backlink | null;
-
// reply: Backlink | null;
-
// quote: Backlink | null;
-
};
-
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
···
+23
src/lib/oauth.ts
···
···
+
import domain from '$lib/domain';
+
import { dev } from '$app/environment';
+
+
export const oauthMetadata = {
+
client_id: `${domain}/oauth-client-metadata.json`,
+
client_name: 'nucleus',
+
client_uri: domain,
+
logo_uri: `${domain}/favicon.png`,
+
redirect_uris: [`${domain}/`],
+
scope: 'atproto transition:generic',
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
token_endpoint_auth_method: 'none',
+
application_type: 'web',
+
dpop_bound_access_tokens: true
+
};
+
+
export const redirectUri = domain;
+
export const clientId = dev
+
? `http://localhost` +
+
`?redirect_uri=${encodeURIComponent(redirectUri)}` +
+
`&scope=${encodeURIComponent(oauthMetadata.scope)}`
+
: oauthMetadata.client_id;
+1 -1
src/lib/settings.ts
···
import { writable } from 'svelte/store';
-
import { defaultTheme, type Theme } from './theme.svelte';
export type ApiEndpoints = Record<string, string> & {
slingshot: string;
···
import { writable } from 'svelte/store';
+
import { defaultTheme, type Theme } from './theme';
export type ApiEndpoints = Record<string, string> & {
slingshot: string;
+17
src/lib/state.svelte.ts
···
···
+
import { writable } from 'svelte/store';
+
import { 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 { JetstreamSubscription } from '@atcute/jetstream';
+
+
export const notificationStream = writable<NotificationsStream | null>(null);
+
// export const jetstream = writable<JetstreamSubscription | null>(null);
+
+
export type PostActions = {
+
like: Backlink | null;
+
repost: Backlink | null;
+
// reply: Backlink | null;
+
// quote: Backlink | null;
+
};
+
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+1 -1
src/lib/theme.svelte.ts src/lib/theme.ts
···
const hue = hash % 360;
const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100%
-
const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 50-75%
const rgb = hslToRgb(hue, saturation, lightness);
const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join('');
···
const hue = hash % 360;
const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100%
+
const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 45-80%
const rgb = hslToRgb(hue, saturation, lightness);
const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join('');
+166
src/lib/thread.ts
···
···
+
import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons';
+
import type { Account } from './accounts';
+
import { expect } from './result';
+
import type { PostWithUri } from './at/fetch';
+
+
export type ThreadPost = {
+
data: PostWithUri;
+
did: Did;
+
rkey: string;
+
parentUri: ResourceUri | null;
+
depth: number;
+
newestTime: number;
+
};
+
+
export type Thread = {
+
rootUri: ResourceUri;
+
posts: ThreadPost[];
+
newestTime: number;
+
branchParentPost?: ThreadPost;
+
};
+
+
export const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => {
+
const threadMap = new Map<ResourceUri, ThreadPost[]>();
+
+
// group posts by root uri into "thread" chains
+
for (const [, timeline] of timelines) {
+
for (const [uri, data] of timeline) {
+
const parsedUri = expect(parseCanonicalResourceUri(uri));
+
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
+
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
+
+
const post: ThreadPost = {
+
data,
+
did: parsedUri.repo,
+
rkey: parsedUri.rkey,
+
parentUri,
+
depth: 0,
+
newestTime: new Date(data.record.createdAt).getTime()
+
};
+
+
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
+
+
threadMap.get(rootUri)!.push(post);
+
}
+
}
+
+
const threads: Thread[] = [];
+
+
for (const [rootUri, posts] of threadMap) {
+
const uriToPost = new Map(posts.map((p) => [p.data.uri, p]));
+
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
+
+
// calculate depths
+
for (const post of posts) {
+
let depth = 0;
+
let currentUri = post.parentUri;
+
+
while (currentUri && uriToPost.has(currentUri)) {
+
depth++;
+
currentUri = uriToPost.get(currentUri)!.parentUri;
+
}
+
+
post.depth = depth;
+
+
if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []);
+
childrenMap.get(post.parentUri)!.push(post);
+
}
+
+
childrenMap
+
.values()
+
.forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
+
+
const createThread = (
+
posts: ThreadPost[],
+
rootUri: ResourceUri,
+
branchParentUri?: ResourceUri
+
): Thread => {
+
return {
+
rootUri,
+
posts,
+
newestTime: Math.max(...posts.map((p) => p.newestTime)),
+
branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
+
};
+
};
+
+
const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
+
const result: ThreadPost[] = [];
+
const addWithChildren = (post: ThreadPost) => {
+
result.push(post);
+
const children = childrenMap.get(post.data.uri) || [];
+
children.forEach(addWithChildren);
+
};
+
addWithChildren(startPost);
+
return result;
+
};
+
+
// find posts with >2 children to split them into separate chains
+
const branchingPoints = Array.from(childrenMap.entries())
+
.filter(([, children]) => children.length > 1)
+
.map(([uri]) => uri);
+
+
if (branchingPoints.length === 0) {
+
const roots = childrenMap.get(null) || [];
+
const allPosts = roots.flatMap((root) => collectSubtree(root));
+
threads.push(createThread(allPosts, rootUri));
+
} else {
+
for (const branchParentUri of branchingPoints) {
+
const branches = childrenMap.get(branchParentUri) || [];
+
+
const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
+
+
sortedBranches.forEach((branchRoot, index) => {
+
const isOldestBranch = index === 0;
+
const branchPosts: ThreadPost[] = [];
+
+
// the oldest branch has the full context
+
// todo: consider letting the user decide this..?
+
if (isOldestBranch && branchParentUri !== null) {
+
const parentChain: ThreadPost[] = [];
+
let currentUri: ResourceUri | null = branchParentUri;
+
while (currentUri && uriToPost.has(currentUri)) {
+
parentChain.unshift(uriToPost.get(currentUri)!);
+
currentUri = uriToPost.get(currentUri)!.parentUri;
+
}
+
branchPosts.push(...parentChain);
+
}
+
+
branchPosts.push(...collectSubtree(branchRoot));
+
+
const minDepth = Math.min(...branchPosts.map((p) => p.depth));
+
branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
+
+
threads.push(
+
createThread(
+
branchPosts,
+
branchRoot.data.uri,
+
isOldestBranch ? undefined : (branchParentUri ?? undefined)
+
)
+
);
+
});
+
}
+
}
+
}
+
+
threads.sort((a, b) => b.newestTime - a.newestTime);
+
+
// console.log(threads);
+
+
return threads;
+
};
+
+
export const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
+
accounts.some((account) => account.did === post.did);
+
export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
+
posts.some((post) => !isOwnPost(post, accounts));
+
+
// todo: add more filtering options
+
export type FilterOptions = {
+
viewOwnPosts: boolean;
+
};
+
+
export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) =>
+
threads.filter((thread) => {
+
if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
+
return true;
+
});
+133 -273
src/routes/+page.svelte
···
import AccountSelector from '$components/AccountSelector.svelte';
import SettingsPopup from '$components/SettingsPopup.svelte';
import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client';
-
import { accounts, addAccount, type Account } from '$lib/accounts';
-
import {
-
type Did,
-
type Handle,
-
parseCanonicalResourceUri,
-
type ResourceUri
-
} from '@atcute/lexicons';
import { onMount } from 'svelte';
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
import { expect, ok } from '$lib/result';
import { AppBskyFeedPost } from '@atcute/bluesky';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
-
import { notificationStream, selectedDid } from '$lib';
import { get } from 'svelte/store';
import Icon from '@iconify/svelte';
-
let loaderState = new LoaderState();
-
let scrollContainer = $state<HTMLDivElement>();
-
let clients = new SvelteMap<Did, AtpClient>();
-
let selectedClient = $derived($selectedDid ? clients.get($selectedDid) : null);
-
let viewClient = $state<AtpClient>(new AtpClient());
-
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
-
let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
let isSettingsOpen = $state(false);
let reverseChronological = $state(true);
let viewOwnPosts = $state(true);
const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
if (!posts.has(did)) {
···
// }
// };
onMount(async () => {
accounts.subscribe((newAccounts) => {
get(notificationStream)?.stop();
···
// });
if ($accounts.length > 0) {
loaderState.status = 'LOADING';
-
$selectedDid = $accounts[0].did;
Promise.all($accounts.map(loginAccount)).then(() => {
loadMore();
});
}
});
-
-
const loginAccount = async (account: Account) => {
-
const client = new AtpClient();
-
const result = await client.login(account.handle, account.password);
-
if (result.ok) clients.set(account.did, client);
-
};
-
-
const handleAccountSelected = async (did: Did) => {
-
$selectedDid = did;
-
const account = $accounts.find((acc) => acc.did === did);
-
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
-
await loginAccount(account);
-
};
-
-
const handleLogout = async (did: Did) => {
-
const newAccounts = $accounts.filter((acc) => acc.did !== did);
-
$accounts = newAccounts;
-
clients.delete(did);
-
posts.delete(did);
-
cursors.delete(did);
-
handleAccountSelected(newAccounts[0]?.did);
-
};
-
-
const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => {
-
const newAccount: Account = { did, handle, password };
-
addAccount(newAccount);
-
$selectedDid = did;
-
loginAccount(newAccount).then(() => fetchTimeline(newAccount));
-
};
-
-
let loading = $state(false);
-
let loadError = $state('');
-
const loadMore = async () => {
-
if (loading || $accounts.length === 0) return;
-
-
loading = true;
-
try {
-
await fetchTimelines($accounts);
-
loaderState.loaded();
-
} catch (error) {
-
loadError = `${error}`;
-
loaderState.error();
-
} finally {
-
loading = false;
-
if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
-
}
-
};
-
-
type ThreadPost = {
-
data: PostWithUri;
-
did: Did;
-
rkey: string;
-
parentUri: ResourceUri | null;
-
depth: number;
-
newestTime: number;
-
};
-
-
type Thread = {
-
rootUri: ResourceUri;
-
posts: ThreadPost[];
-
newestTime: number;
-
branchParentPost?: ThreadPost;
-
};
-
-
const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => {
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
-
const threadMap = new Map<ResourceUri, ThreadPost[]>();
-
-
// group posts by root uri into "thread" chains
-
for (const [, timeline] of timelines) {
-
for (const [uri, data] of timeline) {
-
const parsedUri = expect(parseCanonicalResourceUri(uri));
-
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
-
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
-
-
const post: ThreadPost = {
-
data,
-
did: parsedUri.repo,
-
rkey: parsedUri.rkey,
-
parentUri,
-
depth: 0,
-
newestTime: new Date(data.record.createdAt).getTime()
-
};
-
-
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
-
-
threadMap.get(rootUri)!.push(post);
-
}
-
}
-
-
const threads: Thread[] = [];
-
-
for (const [rootUri, posts] of threadMap) {
-
const uriToPost = new Map(posts.map((p) => [p.data.uri, p]));
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
-
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
-
-
// calculate depths
-
for (const post of posts) {
-
let depth = 0;
-
let currentUri = post.parentUri;
-
-
while (currentUri && uriToPost.has(currentUri)) {
-
depth++;
-
currentUri = uriToPost.get(currentUri)!.parentUri;
-
}
-
-
post.depth = depth;
-
-
if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []);
-
childrenMap.get(post.parentUri)!.push(post);
-
}
-
-
childrenMap
-
.values()
-
.forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
-
-
const createThread = (
-
posts: ThreadPost[],
-
rootUri: ResourceUri,
-
branchParentUri?: ResourceUri
-
): Thread => {
-
return {
-
rootUri,
-
posts,
-
newestTime: Math.max(...posts.map((p) => p.newestTime)),
-
branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
-
};
-
};
-
-
const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
-
const result: ThreadPost[] = [];
-
const addWithChildren = (post: ThreadPost) => {
-
result.push(post);
-
const children = childrenMap.get(post.data.uri) || [];
-
children.forEach(addWithChildren);
-
};
-
addWithChildren(startPost);
-
return result;
-
};
-
-
// find posts with >2 children to split them into separate chains
-
const branchingPoints = Array.from(childrenMap.entries())
-
.filter(([, children]) => children.length > 1)
-
.map(([uri]) => uri);
-
-
if (branchingPoints.length === 0) {
-
const roots = childrenMap.get(null) || [];
-
const allPosts = roots.flatMap((root) => collectSubtree(root));
-
threads.push(createThread(allPosts, rootUri));
-
} else {
-
for (const branchParentUri of branchingPoints) {
-
const branches = childrenMap.get(branchParentUri) || [];
-
-
const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
-
-
sortedBranches.forEach((branchRoot, index) => {
-
const isOldestBranch = index === 0;
-
const branchPosts: ThreadPost[] = [];
-
-
// the oldest branch has the full context
-
// todo: consider letting the user decide this..?
-
if (isOldestBranch && branchParentUri !== null) {
-
const parentChain: ThreadPost[] = [];
-
let currentUri: ResourceUri | null = branchParentUri;
-
while (currentUri && uriToPost.has(currentUri)) {
-
parentChain.unshift(uriToPost.get(currentUri)!);
-
currentUri = uriToPost.get(currentUri)!.parentUri;
-
}
-
branchPosts.push(...parentChain);
-
}
-
-
branchPosts.push(...collectSubtree(branchRoot));
-
-
const minDepth = Math.min(...branchPosts.map((p) => p.depth));
-
branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
-
-
threads.push(
-
createThread(
-
branchPosts,
-
branchRoot.data.uri,
-
isOldestBranch ? undefined : (branchParentUri ?? undefined)
-
)
-
);
-
});
-
}
-
}
-
}
-
-
threads.sort((a, b) => b.newestTime - a.newestTime);
-
-
// console.log(threads);
-
-
return threads;
-
};
-
-
// todo: add more filtering options
-
const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
-
accounts.some((account) => account.did === post.did);
-
const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
-
posts.some((post) => !isOwnPost(post, accounts));
-
const filterThreads = (threads: Thread[], accounts: Account[]) =>
-
threads.filter((thread) => {
-
if (!viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
-
return true;
-
});
-
-
let threads = $derived(filterThreads(buildThreads(posts), $accounts));
-
-
let quoting = $state<PostWithUri | undefined>(undefined);
-
let replying = $state<PostWithUri | undefined>(undefined);
-
-
let expandedThreads = new SvelteSet<ResourceUri>();
</script>
<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
-
<div class="mb-6 flex flex-shrink-0 items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
<div class="mt-1 flex gap-2">
···
</button>
</div>
-
<div class="flex-shrink-0 space-y-4">
<div class="flex min-h-16 items-stretch gap-2">
<AccountSelector
client={viewClient}
accounts={$accounts}
-
bind:selectedDid={$selectedDid}
onAccountSelected={handleAccountSelected}
-
onLoginSucceed={handleLoginSucceed}
onLogout={handleLogout}
/>
···
<div class="flex-1">
<PostComposer
client={selectedClient}
-
{selectedDid}
-
onPostSent={(post) => posts.get($selectedDid!)?.set(post.uri, post)}
bind:quoting
bind:replying
/>
···
</div>
{/if}
</div>
<!-- <hr
class="h-[4px] w-full rounded-full border-0"
···
<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
-
{#snippet renderThreads()}
-
<InfiniteLoader
-
{loaderState}
-
triggerLoad={loadMore}
-
loopDetectionTimeout={0}
-
intersectionOptions={{ root: scrollContainer }}
-
>
-
{@render threadsView()}
-
{#snippet noData()}
-
<div class="flex justify-center py-4">
-
<p class="text-xl opacity-80">
-
all posts seen! <span class="text-2xl">:o</span>
-
</p>
-
</div>
-
{/snippet}
-
{#snippet loading()}
-
<div class="flex justify-center">
-
<div
-
class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
-
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
-
></div>
-
</div>
-
{/snippet}
-
{#snippet error()}
-
<div class="flex justify-center py-4">
-
<p class="text-xl opacity-80">
-
<span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
-
</p>
-
</div>
-
{/snippet}
-
</InfiniteLoader>
-
{/snippet}
-
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
<span
-
class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap break-words overflow-ellipsis"
>
<span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
-
<BskyPost mini {selectedDid} client={selectedClient ?? viewClient} {...post} />
</span>
{/snippet}
···
{#if !mini}
<div class="mb-1.5">
<BskyPost
-
{selectedDid}
client={selectedClient ?? viewClient}
onQuote={(post) => (quoting = post)}
onReply={(post) => (replying = post)}
···
{#if idx === 1}
{@render replyPost(post, !reverseChronological)}
<button
-
class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,_var(--nucleus-fg)_50%,_var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)"
onclick={() => expandedThreads.add(thread.rootUri)}
>
<div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
···
{/each}
</div>
<div
-
class="mx-8 mt-3 mb-4 h-px bg-gradient-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
></div>
{/each}
{/snippet}
···
import AccountSelector from '$components/AccountSelector.svelte';
import SettingsPopup from '$components/SettingsPopup.svelte';
import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client';
+
import { accounts, type Account } from '$lib/accounts';
+
import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons';
import { onMount } from 'svelte';
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
import { expect, ok } from '$lib/result';
import { AppBskyFeedPost } from '@atcute/bluesky';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
+
import { notificationStream } from '$lib/state.svelte';
import { get } from 'svelte/store';
import Icon from '@iconify/svelte';
+
import { sessions } from '$lib/at/oauth';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import type { PageProps } from './+page';
+
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
+
const { data: loadData }: PageProps = $props();
+
+
let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null);
+
$effect(() => {
+
if (selectedDid) {
+
localStorage.setItem('selectedDid', selectedDid);
+
} else {
+
localStorage.removeItem('selectedDid');
+
}
+
});
+
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));
+
if (result.ok) clients.set(account.did, client);
+
};
+
const handleAccountSelected = async (did: AtprotoDid) => {
+
selectedDid = did;
+
const account = $accounts.find((acc) => acc.did === did);
+
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
+
await loginAccount(account);
+
};
+
+
const handleLogout = async (did: AtprotoDid) => {
+
await sessions.remove(did);
+
const newAccounts = $accounts.filter((acc) => acc.did !== did);
+
$accounts = newAccounts;
+
clients.delete(did);
+
posts.delete(did);
+
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 reverseChronological = $state(true);
let viewOwnPosts = $state(true);
+
+
const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts }));
+
+
let quoting = $state<PostWithUri | undefined>(undefined);
+
let replying = $state<PostWithUri | undefined>(undefined);
+
+
const expandedThreads = new SvelteSet<ResourceUri>();
const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
if (!posts.has(did)) {
···
// }
// };
+
const loaderState = new LoaderState();
+
let scrollContainer = $state<HTMLDivElement>();
+
+
let loading = $state(false);
+
let loadError = $state('');
+
const loadMore = async () => {
+
if (loading || $accounts.length === 0) return;
+
+
loading = true;
+
try {
+
await fetchTimelines($accounts);
+
loaderState.loaded();
+
} catch (error) {
+
loadError = `${error}`;
+
loaderState.error();
+
} finally {
+
loading = false;
+
if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
+
}
+
};
+
onMount(async () => {
accounts.subscribe((newAccounts) => {
get(notificationStream)?.stop();
···
// });
if ($accounts.length > 0) {
loaderState.status = 'LOADING';
+
if (loadData.client.ok && loadData.client.value) {
+
const loggedInDid = loadData.client.value.didDoc!.did as AtprotoDid;
+
selectedDid = loggedInDid;
+
clients.set(loggedInDid, loadData.client.value);
+
}
+
if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did;
+
console.log('onMount selectedDid', selectedDid);
Promise.all($accounts.map(loginAccount)).then(() => {
loadMore();
});
+
} else {
+
selectedDid = null;
}
});
</script>
<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
+
<div class="mb-6 flex shrink-0 items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
<div class="mt-1 flex gap-2">
···
</button>
</div>
+
<div class="shrink-0 space-y-4">
<div class="flex min-h-16 items-stretch gap-2">
<AccountSelector
client={viewClient}
accounts={$accounts}
+
bind:selectedDid
onAccountSelected={handleAccountSelected}
onLogout={handleLogout}
/>
···
<div class="flex-1">
<PostComposer
client={selectedClient}
+
onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)}
bind:quoting
bind:replying
/>
···
</div>
{/if}
</div>
+
+
{#if !loadData.client.ok}
+
<div class="error-disclaimer">
+
<p>
+
<Icon class="inline h-12 w-12" icon="heroicons:exclamation-triangle-16-solid" />
+
{loadData.client.error}
+
</p>
+
</div>
+
{/if}
<!-- <hr
class="h-[4px] w-full rounded-full border-0"
···
<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
<span
+
class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis"
>
<span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
+
<BskyPost mini client={selectedClient ?? viewClient} {...post} />
</span>
{/snippet}
···
{#if !mini}
<div class="mb-1.5">
<BskyPost
client={selectedClient ?? viewClient}
onQuote={(post) => (quoting = post)}
onReply={(post) => (replying = post)}
···
{#if idx === 1}
{@render replyPost(post, !reverseChronological)}
<button
+
class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)"
onclick={() => expandedThreads.add(thread.rootUri)}
>
<div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
···
{/each}
</div>
<div
+
class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
></div>
{/each}
{/snippet}
+
+
{#snippet renderThreads()}
+
<InfiniteLoader
+
{loaderState}
+
triggerLoad={loadMore}
+
loopDetectionTimeout={0}
+
intersectionOptions={{ root: scrollContainer }}
+
>
+
{@render threadsView()}
+
{#snippet noData()}
+
<div class="flex justify-center py-4">
+
<p class="text-xl opacity-80">
+
all posts seen! <span class="text-2xl">:o</span>
+
</p>
+
</div>
+
{/snippet}
+
{#snippet loading()}
+
<div class="flex justify-center">
+
<div
+
class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
+
></div>
+
</div>
+
{/snippet}
+
{#snippet error()}
+
<div class="flex justify-center py-4">
+
<p class="text-xl opacity-80">
+
<span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
+
</p>
+
</div>
+
{/snippet}
+
</InfiniteLoader>
+
{/snippet}
+48
src/routes/+page.ts
···
···
+
import { replaceState } from '$app/navigation';
+
import { addAccount, loggingIn } from '$lib/accounts';
+
import { AtpClient } from '$lib/at/client';
+
import { flow } from '$lib/at/oauth';
+
import { err, ok, type Result } from '$lib/result';
+
import type { PageLoad } from './$types';
+
+
export type PageProps = {
+
data: {
+
client: Result<AtpClient | null, string>;
+
};
+
};
+
+
export const load: PageLoad = async (): Promise<PageProps['data']> => {
+
return { client: await handleLogin() };
+
};
+
+
const handleLogin = async (): Promise<Result<AtpClient | null, string>> => {
+
const account = loggingIn.get();
+
if (!account) return ok(null);
+
+
const currentUrl = new URL(window.location.href);
+
// scrub history so auth state cant be replayed
+
try {
+
replaceState('', '/');
+
} catch {
+
// if router was unitialized then we probably dont need to scrub anyway
+
// so its fine
+
}
+
+
loggingIn.set(null);
+
const agent = await flow.finalize(currentUrl);
+
if (!agent.ok || !agent.value) {
+
if (!agent.ok) {
+
return err(agent.error);
+
}
+
return err('no session was logged into?!');
+
}
+
+
const client = new AtpClient();
+
const result = await client.login(account.did, agent.value);
+
if (!result.ok) {
+
return err(result.error);
+
}
+
+
addAccount(account);
+
return ok(client);
+
};
+11
src/routes/oauth-client-metadata.json/+server.ts
···
···
+
import { clientId, oauthMetadata } from '$lib/oauth';
+
import { domain } from '$lib/domain';
+
import { json } from '@sveltejs/kit';
+
+
export const GET = () => {
+
return json({
+
...oauthMetadata,
+
client_id: clientId,
+
client_uri: domain
+
});
+
};
+1 -7
tsconfig.json
···
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
-
"jsx": "react-jsx",
-
"paths": {
-
"$components": ["./src/components"],
-
"$components/*": ["./src/components/*"],
-
"$lib": ["./src/lib"],
-
"$lib/*": ["./src/lib/*"]
-
}
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
···
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
+
"jsx": "react-jsx"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files