diff --git a/deno.lock b/deno.lock
index 404f2b4..71d1dda 100644
--- a/deno.lock
+++ b/deno.lock
@@ -4,8 +4,10 @@
"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",
@@ -57,6 +59,15 @@
"@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": [
@@ -71,9 +82,36 @@
"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=="
},
@@ -1354,6 +1392,10 @@
"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=="
},
@@ -1434,7 +1476,7 @@
"postcss@8.5.6": {
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dependencies": [
- "nanoid",
+ "nanoid@3.3.11",
"picocolors",
"source-map-js"
]
@@ -1740,8 +1782,10 @@
"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",
diff --git a/flake.lock b/flake.lock
index cadfcf4..54620a3 100644
--- a/flake.lock
+++ b/flake.lock
@@ -17,16 +17,15 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1761656231,
- "narHash": "sha256-EiED5k6gXTWoAIS8yQqi5mAX6ojnzpHwAQTS3ykeYMg=",
+ "lastModified": 1761850514,
+ "narHash": "sha256-qmg1yC6ybzH0/w4Bupx1hpgTS5MTl2qBMoD+DFx3hWM=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "e99366c665bdd53b7b500ccdc5226675cfc51f45",
+ "rev": "1c3d5f4e01f0b18b508be644d9d6a196fb7ed1f5",
"type": "github"
},
"original": {
"owner": "nixos",
- "ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
diff --git a/flake.nix b/flake.nix
index e3a5851..d581313 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,6 +1,6 @@
{
inputs.parts.url = "github:hercules-ci/flake-parts";
- inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
+ inputs.nixpkgs.url = "github:nixos/nixpkgs";
inputs.naked-shell.url = "github:90-008/mk-naked-shell";
outputs = inp:
@@ -17,7 +17,7 @@
devShells.default = config.mk-naked-shell.lib.mkNakedShell {
name = "nucleus-devshell";
packages = with pkgs; [
- nodejs-slim_latest deno
+ nodejs-slim_latest deno biome
];
shellHook = ''
export PATH="$PATH:$PWD/node_modules/.bin"
diff --git a/package.json b/package.json
index 28c1ae2..674f6a5 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,9 @@
"@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",
diff --git a/src/app.css b/src/app.css
index a20192b..ad764c2 100644
--- a/src/app.css
+++ b/src/app.css
@@ -30,6 +30,13 @@
@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);
diff --git a/src/components/AccountSelector.svelte b/src/components/AccountSelector.svelte
index 64b7e42..056a8e3 100644
--- a/src/components/AccountSelector.svelte
+++ b/src/components/AccountSelector.svelte
@@ -1,17 +1,19 @@
{#snippet divider()}
-
+
{/snippet}
{#snippet settingHeader(name: string, desc: string)}
diff --git a/src/lib/accounts.ts b/src/lib/accounts.ts
index 72cec97..6423425 100644
--- a/src/lib/accounts.ts
+++ b/src/lib/accounts.ts
@@ -1,11 +1,11 @@
-import type { Did, Handle } from '@atcute/lexicons';
+import type { Handle } from '@atcute/lexicons';
import { writable } from 'svelte/store';
-import { hashColor } from './theme.svelte';
+import { hashColor } from './theme';
+import type { AtprotoDid } from '@atcute/lexicons/syntax';
export type Account = {
- did: Did;
- handle: Handle;
- password: string;
+ did: AtprotoDid;
+ handle: Handle | null;
};
let _accounts: Account[] = [];
@@ -24,4 +24,18 @@ 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);
diff --git a/src/lib/at/client.ts b/src/lib/at/client.ts
index 5c9dd89..7eddf2b 100644
--- a/src/lib/at/client.ts
+++ b/src/lib/at/client.ts
@@ -4,7 +4,7 @@ import {
ComAtprotoRepoGetRecord,
ComAtprotoRepoListRecords
} from '@atcute/atproto';
-import { Client as AtcuteClient, CredentialManager } from '@atcute/client';
+import { Client as AtcuteClient } from '@atcute/client';
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
import {
isDid,
@@ -37,6 +37,7 @@ import { WebSocket } from '@soffinal/websocket';
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;
@@ -73,16 +74,13 @@ export class AtpClient {
public atcute: AtcuteClient | null = null;
public didDoc: MiniDoc | null = null;
- async login(handle: Handle, password: string): Promise> {
- const didDoc = await this.resolveDidDoc(handle);
+ async login(identifier: ActorIdentifier, agent: OAuthUserAgent): Promise> {
+ const didDoc = await this.resolveDidDoc(identifier);
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 });
-
+ const rpc = new AtcuteClient({ handler: agent });
this.atcute = rpc;
} catch (error) {
return err(`failed to login: ${error}`);
diff --git a/src/lib/at/oauth.ts b/src/lib/at/oauth.ts
new file mode 100644
index 0000000..156a267
--- /dev/null
+++ b/src/lib/at/oauth.ts
@@ -0,0 +1,91 @@
+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> => {
+ 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> => {
+ 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}`);
+ }
+ }
+};
diff --git a/src/lib/domain.ts b/src/lib/domain.ts
new file mode 100644
index 0000000..3bf2293
--- /dev/null
+++ b/src/lib/domain.ts
@@ -0,0 +1,6 @@
+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;
diff --git a/src/lib/index.ts b/src/lib/index.ts
index f7181b5..e69de29 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -1,19 +0,0 @@
-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(null);
-
-export const notificationStream = writable(null);
-// export const jetstream = writable(null);
-
-export type PostActions = {
- like: Backlink | null;
- repost: Backlink | null;
- // reply: Backlink | null;
- // quote: Backlink | null;
-};
-export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts
new file mode 100644
index 0000000..5c73d76
--- /dev/null
+++ b/src/lib/oauth.ts
@@ -0,0 +1,23 @@
+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;
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index b63dd19..6fe89aa 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
-import { defaultTheme, type Theme } from './theme.svelte';
+import { defaultTheme, type Theme } from './theme';
export type ApiEndpoints = Record & {
slingshot: string;
diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts
new file mode 100644
index 0000000..00ef9e7
--- /dev/null
+++ b/src/lib/state.svelte.ts
@@ -0,0 +1,17 @@
+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(null);
+// export const jetstream = writable(null);
+
+export type PostActions = {
+ like: Backlink | null;
+ repost: Backlink | null;
+ // reply: Backlink | null;
+ // quote: Backlink | null;
+};
+export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
diff --git a/src/lib/theme.svelte.ts b/src/lib/theme.ts
similarity index 95%
rename from src/lib/theme.svelte.ts
rename to src/lib/theme.ts
index 56dc80b..0513c9a 100644
--- a/src/lib/theme.svelte.ts
+++ b/src/lib/theme.ts
@@ -31,7 +31,7 @@ export const hashColor = (input: string): string => {
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 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('');
diff --git a/src/lib/thread.ts b/src/lib/thread.ts
new file mode 100644
index 0000000..933327d
--- /dev/null
+++ b/src/lib/thread.ts
@@ -0,0 +1,166 @@
+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>): Thread[] => {
+ const threadMap = new Map();
+
+ // 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();
+
+ // 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;
+ });
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 40d68eb..30e9f58 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -4,38 +4,76 @@
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 { 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, selectedDid } from '$lib';
+ 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');
+ }
+ });
- let loaderState = new LoaderState();
- let scrollContainer = $state();
+ const clients = new SvelteMap();
+ 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);
+ };
- let clients = new SvelteMap();
- let selectedClient = $derived($selectedDid ? clients.get($selectedDid) : null);
+ 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);
+ };
- let viewClient = $state(new AtpClient());
+ const viewClient = new AtpClient();
- let posts = new SvelteMap>();
- let cursors = new SvelteMap();
+ const posts = new SvelteMap>();
+ const cursors = new SvelteMap();
let isSettingsOpen = $state(false);
let reverseChronological = $state(true);
let viewOwnPosts = $state(true);
+ const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts }));
+
+ let quoting = $state(undefined);
+ let replying = $state(undefined);
+
+ const expandedThreads = new SvelteSet();
+
const addPosts = (did: Did, accTimeline: Map) => {
if (!posts.has(did)) {
posts.set(did, new SvelteMap(accTimeline));
@@ -119,6 +157,27 @@
// }
// };
+ const loaderState = new LoaderState();
+ let scrollContainer = $state();
+
+ 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();
@@ -147,229 +206,24 @@
// });
if ($accounts.length > 0) {
loaderState.status = 'LOADING';
- $selectedDid = $accounts[0].did;
+ 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;
}
});
-
- 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>): Thread[] => {
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
- const threadMap = new Map();
-
- // 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();
-
- // 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(undefined);
- let replying = $state(undefined);
-
- let expandedThreads = new SvelteSet();
-
+
nucleus
@@ -387,14 +241,13 @@
-
+
@@ -402,8 +255,7 @@
posts.get($selectedDid!)?.set(post.uri, post)}
+ onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)}
bind:quoting
bind:replying
/>
@@ -417,6 +269,15 @@
{/if}
+ {#if !loadData.client.ok}
+
+
+
+ {loadData.client.error}
+
+
+ {/if}
+