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} +