diff --git a/deno.lock b/deno.lock index 0af664f..5de8e38 100644 --- a/deno.lock +++ b/deno.lock @@ -6,8 +6,10 @@ "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.0_eslint@9.37.0", "npm:@eslint/js@^9.36.0": "9.37.0", + "npm:@iconify/svelte@^5.0.2": "5.0.2_svelte@5.40.1__acorn@8.15.0", "npm:@soffinal/websocket@~0.2.1": "0.2.1_typescript@5.9.3", "npm:@sveltejs/adapter-auto@^6.1.0": "6.1.1_@sveltejs+kit@2.47.0__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.40.1____acorn@8.15.0___vite@7.1.10____@types+node@24.8.0____picomatch@4.0.3___@types+node@24.8.0__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.8.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0", "npm:@sveltejs/kit@^2.43.2": "2.47.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.8.0", @@ -69,6 +71,9 @@ "esm-env" ] }, + "@atcute/tid@1.0.3": { + "integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==" + }, "@badrap/valita@0.4.6": { "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" }, @@ -285,6 +290,16 @@ "@humanwhocodes/retry@0.4.3": { "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==" }, + "@iconify/svelte@5.0.2_svelte@5.40.1__acorn@8.15.0": { + "integrity": "sha512-1iWUT+1veS/QOAzKDG0NPgBtJYGoJqEPwF97voTm8jw6PQ6yU0hL73lEwFoTGMrZmatLvh9cjRBmeSHHaltmrg==", + "dependencies": [ + "@iconify/types", + "svelte" + ] + }, + "@iconify/types@2.0.0": { + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==" + }, "@isaacs/fs-minipass@4.0.1": { "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dependencies": [ @@ -1752,8 +1767,10 @@ "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:@iconify/svelte@^5.0.2", "npm:@soffinal/websocket@~0.2.1", "npm:@sveltejs/adapter-auto@^6.1.0", "npm:@sveltejs/kit@^2.43.2", diff --git a/package.json b/package.json index b080f08..d0b007e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,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", "hash-wasm": "^4.12.0", @@ -28,6 +29,7 @@ "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.36.0", + "@iconify/svelte": "^5.0.2", "@sveltejs/adapter-auto": "^6.1.0", "@sveltejs/kit": "^2.43.2", "@sveltejs/vite-plugin-svelte": "^6.2.0", diff --git a/src/components/BskyPost.svelte b/src/components/BskyPost.svelte index bb5fb08..34992a9 100644 --- a/src/components/BskyPost.svelte +++ b/src/components/BskyPost.svelte @@ -1,10 +1,12 @@ @@ -88,7 +187,7 @@ class="rounded-full px-2.5 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' - : color} 13%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};" + : color} 10%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};" > {getEmbedText(record.embed.$type)} @@ -96,12 +195,12 @@ {/snippet} {#if mini} -
+
{#await post} loading... {:then post} {#if post.ok} - {@const record = post.value} + {@const record = post.value.record} @{handle}: {@render embedBadge(record)} {record.text} {:else} @@ -122,10 +221,12 @@
{:then post} {#if post.ok} - {@const record = post.value} + {@const record = post.value.record}
{profileValue.displayName}(@{handle}) + >(@{handle}) {:else} {handle} {/if} {/await} - - · {getRelativeTime(new Date(record.createdAt))}
-

+

{record.text} + {#if isOnPostComposer} + {@render embedBadge(record)} + {/if}

- {#if record.embed} + {#if !isOnPostComposer && record.embed} {@const embed = record.embed}
{#snippet embedPost(uri: ResourceUri)} {@const parsedUri = expect(parseCanonicalResourceUri(uri))} {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} - + {:else} you think you're funny with that recursive quote but i'm onto you {/if} @@ -222,6 +311,10 @@
{/if} + {#if !isOnPostComposer} + {@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)} + {@render postControls(post.value, backlinks)} + {/if}
{:else}
@@ -230,3 +323,58 @@ {/if} {/await} {/if} + +{#snippet postControls(post: PostWithUri, backlinks?: PostActions)} +
+ {#snippet label( + name: string, + icon: string, + onClick: (link: Backlink | null | undefined) => void, + backlink?: Backlink | null, + hasSolid?: boolean + )} + + {/snippet} + {@render label('reply', 'heroicons:chat-bubble-left', () => { + onReply?.(post); + })} + {@render label( + 'repost', + 'heroicons:arrow-path-rounded-square-20-solid', + async (link) => { + if (link === undefined) return; + postActions.set(`${$selectedDid!}:${aturi}`, { + ...backlinks!, + repost: await toggleLink(link, 'app.bsky.feed.repost') + }); + }, + backlinks?.repost + )} + {@render label('quote', 'heroicons:paper-clip-20-solid', () => { + onQuote?.(post); + })} + {@render label( + 'like', + 'heroicons:star', + async (link) => { + if (link === undefined) return; + postActions.set(`${$selectedDid!}:${aturi}`, { + ...backlinks!, + like: await toggleLink(link, 'app.bsky.feed.like') + }); + }, + backlinks?.like, + true + )} +
+{/snippet} diff --git a/src/components/PfpPlaceholder.svelte b/src/components/PfpPlaceholder.svelte index 106c994..4d8b988 100644 --- a/src/components/PfpPlaceholder.svelte +++ b/src/components/PfpPlaceholder.svelte @@ -8,7 +8,7 @@ import type { AtpClient } from '$lib/at/client'; - import { ok, err, type Result } from '$lib/result'; + import { ok, err, type Result, expect } from '$lib/result'; import type { AppBskyFeedPost } from '@atcute/bluesky'; - import type { ResourceUri } from '@atcute/lexicons'; 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; - onPostSent: (uri: ResourceUri, post: AppBskyFeedPost.Main) => void; + selectedDid: Writable; + onPostSent: (post: PostWithUri) => void; + quoting?: PostWithUri; + replying?: PostWithUri; } - const { client, onPostSent }: Props = $props(); + let { + client, + selectedDid, + onPostSent, + quoting = $bindable(undefined), + replying = $bindable(undefined) + }: Props = $props(); let color = $derived( client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)' ); - const post = async ( - text: string - ): Promise> => { + const post = async (text: string): Promise> => { + const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ + $type: 'com.atproto.repo.strongRef', + cid: p.cid!, + uri: p.uri + }); const record: AppBskyFeedPost.Main = { $type: 'app.bsky.feed.post', text, + reply: replying + ? { + root: replying.record.reply?.root ?? strongRef(replying), + parent: strongRef(replying) + } + : undefined, + embed: quoting + ? { + $type: 'app.bsky.embed.record', + record: strongRef(quoting) + } + : undefined, createdAt: new Date().toISOString() }; @@ -43,6 +71,7 @@ return ok({ uri: res.data.uri, + cid: res.data.cid, record }); }; @@ -57,11 +86,15 @@ post(postText).then((res) => { if (res.ok) { - onPostSent(res.value.uri, res.value.record); + onPostSent(res.value); postText = ''; info = 'posted!'; - setTimeout(() => (info = ''), 1000 * 3); + isFocused = false; + quoting = undefined; + replying = undefined; + setTimeout(() => (info = ''), 1000 * 0.8); } else { + // todo: add a way to clear error info = res.error; } }); @@ -69,6 +102,7 @@ $effect(() => { if (isFocused && textareaEl) textareaEl.focus(); + if (quoting || replying) isFocused = true; }); @@ -104,11 +138,26 @@ {:else}
{#if isFocused} + {#if replying} + {@const parsedUri = expect(parseCanonicalResourceUri(replying.uri))} + + {/if} + {#if quoting} + {@const parsedUri = expect(parseCanonicalResourceUri(quoting.uri))} + + {/if}
(isFocused = true)} - onkeydown={(event) => { - if (event.key === 'Enter') doPost(); - }} type="text" placeholder="what's on your mind?" class="single-line-input flex-1 bg-(--nucleus-bg)/40" diff --git a/src/lib/accounts.ts b/src/lib/accounts.ts index 063e664..e96c428 100644 --- a/src/lib/accounts.ts +++ b/src/lib/accounts.ts @@ -25,59 +25,58 @@ export const addAccount = (account: Account): void => { export const generateColorForDid = (did: string) => hashColor(did); -function hashColor(input: string | number): string { - let hash = typeof input === 'string' ? stringToHash(input) : input; +function hashColor(input: string): string { + let hash: number; + const id = input.split(':').pop() || input; + + hash = 0; + for (let i = 0; i < Math.min(10, id.length); i++) { + hash = (hash << 4) + id.charCodeAt(i); + } + hash = hash >>> 0; + + // magic mixing hash ^= hash >>> 16; - hash = Math.imul(hash, 0x85ebca6b); - hash ^= hash >>> 13; - hash = Math.imul(hash, 0xb00b1355); - hash ^= hash >>> 16; + hash = Math.imul(hash, 0x21f0aaad); + hash ^= hash >>> 15; hash = hash >>> 0; const hue = hash % 360; - const saturation = 0.7 + ((hash >>> 8) % 30) * 0.01; - const value = 0.6 + ((hash >>> 16) % 40) * 0.01; + const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100% + const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 50-75% - const rgb = hsvToRgb(hue, saturation, value); + const rgb = hslToRgb(hue, saturation, lightness); const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join(''); return `#${hex}`; } -function stringToHash(str: string): number { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = (Math.imul(hash << 5, 1) - hash + str.charCodeAt(i)) | 0; - } - return hash >>> 0; -} - -function hsvToRgb(h: number, s: number, v: number): [number, number, number] { - const c = v * s; - const hPrime = h * 0.016666667; +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + const c = (1 - Math.abs(2 * l - 1)) * s; + const hPrime = h / 60; const x = c * (1 - Math.abs((hPrime % 2) - 1)); - const m = v - c; + const m = l - c / 2; let r: number, g: number, b: number; - if (h < 60) { + if (hPrime < 1) { r = c; g = x; b = 0; - } else if (h < 120) { + } else if (hPrime < 2) { r = x; g = c; b = 0; - } else if (h < 180) { + } else if (hPrime < 3) { r = 0; g = c; b = x; - } else if (h < 240) { + } else if (hPrime < 4) { r = 0; g = x; b = c; - } else if (h < 300) { + } else if (hPrime < 5) { r = x; g = 0; b = c; @@ -87,5 +86,5 @@ function hsvToRgb(h: number, s: number, v: number): [number, number, number] { b = x; } - return [((r + m) * 255) | 0, ((g + m) * 255) | 0, ((b + m) * 255) | 0]; + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; } diff --git a/src/lib/at/client.ts b/src/lib/at/client.ts index c10b716..5c9dd89 100644 --- a/src/lib/at/client.ts +++ b/src/lib/at/client.ts @@ -13,6 +13,7 @@ import { type ActorIdentifier, type AtprotoDid, type CanonicalResourceUri, + type Cid, type Did, type Nsid, type RecordKey, @@ -66,6 +67,8 @@ type NotificationsStreamEncoder = WebSocket.Encoder; export type NotificationsStream = WebSocket; export type NotificationsStreamEvent = WebSocket.Event; +export type RecordOutput = { uri: ResourceUri; cid: Cid | undefined; record: Output }; + export class AtpClient { public atcute: AtcuteClient | null = null; public didDoc: MiniDoc | null = null; @@ -94,7 +97,7 @@ export class AtpClient { TKey extends RecordKeySchema, Schema extends RecordSchema, Output extends InferInput - >(schema: Schema, uri: ResourceUri): Promise> { + >(schema: Schema, uri: ResourceUri): Promise, string>> { const parsedUri = expect(parseResourceUri(uri)); if (parsedUri.collection !== schema.object.shape.$type.expected) return err( @@ -109,12 +112,16 @@ export class AtpClient { TKey extends RecordKeySchema, Schema extends RecordSchema, Output extends InferInput - >(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise> { + >( + schema: Schema, + repo: ActorIdentifier, + rkey: RecordKey + ): Promise, string>> { const collection = schema.object.shape.$type.expected; const cacheKey = `${repo}:${collection}:${rkey}`; const cached = recordCache.get(cacheKey); - if (cached) return ok(cached.value as Output); + if (cached) return ok({ uri: cached.uri, cid: cached.cid, record: cached.value as Output }); const cachedSignal = recordCache.getSignal(cacheKey); const result = await Promise.race([ @@ -122,7 +129,7 @@ export class AtpClient { repo, collection, rkey - }).then((result): Result => { + }).then((result): Result, string> => { if (!result.ok) return result; const parsed = safeParse(schema, result.value.value); @@ -130,20 +137,27 @@ export class AtpClient { recordCache.set(cacheKey, result.value); - return ok(parsed.value as Output); + return ok({ + uri: result.value.uri, + cid: result.value.cid, + record: parsed.value as Output + }); }), - cachedSignal.then((d): Result => ok(d.value as Output)) + cachedSignal.then( + (d): Result, string> => + ok({ uri: d.uri, cid: d.cid, record: d.value as Output }) + ) ]); if (!result.ok) return result; - return ok(result.value as Output); + return ok(result.value); } async getProfile(repo?: ActorIdentifier): Promise> { repo = repo ?? this.didDoc?.did; if (!repo) return err('not authenticated'); - return await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'); + return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); } async listRecords( @@ -232,6 +246,7 @@ export class AtpClient { if (!did.ok) { return err(`failed to resolve handle: ${did.error}`); } + return await fetchMicrocosm(constellationUrl, BacklinksQuery, { subject: `at://${did.value}/${collection}/${rkey}`, source, @@ -277,17 +292,26 @@ const fetchMicrocosm = async < init?: RequestInit ): Promise> => { if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); + api.pathname = `/xrpc/${schema.nsid}`; + api.search = params ? `?${new URLSearchParams(params)}` : ''; try { - api.pathname = `/xrpc/${schema.nsid}`; - api.search = params ? `?${new URLSearchParams(params)}` : ''; - // console.info(`fetching:`, api.href); - const response = await fetch(api, init); + const body = await fetchJson(api, init); + if (!body.ok) return err(body.error); + const parsed = safeParse(schema.output.schema, body.value); + if (!parsed.ok) return err(parsed.message); + return ok(parsed.value as Output); + } catch (error) { + return err(`FetchError: ${error}`); + } +}; + +const fetchJson = async (url: URL, init?: RequestInit): Promise> => { + try { + const response = await fetch(url, init); const body = await response.json(); if (response.status === 400) return err(`${body.error}: ${body.message}`); if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`); - const parsed = safeParse(schema.output.schema, body); - if (!parsed.ok) return err(parsed.message); - return ok(parsed.value as Output); + return ok(body); } catch (error) { return err(`FetchError: ${error}`); } diff --git a/src/lib/at/fetch.ts b/src/lib/at/fetch.ts index d70f1f2..88b7a2e 100644 --- a/src/lib/at/fetch.ts +++ b/src/lib/at/fetch.ts @@ -1,10 +1,10 @@ -import type { ActorIdentifier, CanonicalResourceUri } from '@atcute/lexicons'; -import type { AtpClient } from './client'; -import { err, map, ok, type Result } from '$lib/result'; +import type { ActorIdentifier, CanonicalResourceUri, Cid, ResourceUri } from '@atcute/lexicons'; +import { recordCache, type AtpClient } from './client'; +import { err, ok, type Result } from '$lib/result'; import type { Backlinks } from './constellation'; import { AppBskyFeedPost } from '@atcute/bluesky'; -export type PostWithUri = { uri: CanonicalResourceUri; record: AppBskyFeedPost.Main }; +export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; export type PostWithBacklinks = PostWithUri & { replies: Result; }; @@ -22,17 +22,19 @@ export const fetchPostsWithBacklinks = async ( const records = recordsList.value.records; const allBacklinks = await Promise.all( - records.map((r) => - client - .getBacklinksUri(r.uri as CanonicalResourceUri, 'app.bsky.feed.post:reply.parent.uri') - .then( - (res): PostWithBacklinks => ({ - uri: r.uri as CanonicalResourceUri, - record: r.value as AppBskyFeedPost.Main, - replies: res - }) - ) - ) + records.map(async (r) => { + recordCache.set(r.uri, r); + const res = await client.getBacklinksUri( + r.uri as CanonicalResourceUri, + 'app.bsky.feed.post:reply.parent.uri' + ); + return { + uri: r.uri, + cid: r.cid, + record: r.value as AppBskyFeedPost.Main, + replies: res + }; + }) ); return ok({ posts: allBacklinks, cursor }); @@ -41,22 +43,14 @@ export const fetchPostsWithBacklinks = async ( export const hydratePosts = async ( client: AtpClient, data: PostsWithReplyBacklinks -): Promise> => { +): Promise> => { const allPosts = await Promise.all( data.map(async (post) => { - const result: Result[] = [ok({ uri: post.uri, record: post.record })]; + const result: Result[] = [ok(post)]; if (post.replies.ok) { const replies = await Promise.all( post.replies.value.records.map((r) => - client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey).then((res) => - map( - res, - (d): PostWithUri => ({ - uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}` as CanonicalResourceUri, - record: d - }) - ) - ) + client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey) ) ); result.push(...replies); @@ -68,22 +62,22 @@ export const hydratePosts = async ( allPosts .flat() .flatMap((res) => (res.ok ? [res.value] : [])) - .map((post) => [post.uri, post.record]) + .map((post) => [post.uri, post]) ); // hydrate posts const missingPosts = await Promise.all( - Array.from(posts).map(async ([uri, record]) => { - let result: PostWithUri[] = [{ uri, record }]; - let parent = record.reply?.parent; + Array.from(posts).map(async ([, post]) => { + let result: PostWithUri[] = [post]; + let parent = post.record.reply?.parent; while (parent) { if (posts.has(parent.uri as CanonicalResourceUri)) { return result; } const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri); if (p.ok) { - result = [{ uri: parent.uri as CanonicalResourceUri, record: p.value }, ...result]; - parent = p.value.reply?.parent; + result = [p.value, ...result]; + parent = p.value.record.reply?.parent; continue; } parent = undefined; @@ -92,7 +86,7 @@ export const hydratePosts = async ( }) ); for (const post of missingPosts.flat()) { - posts.set(post.uri, post.record); + posts.set(post.uri, post); } return posts; diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 0083040..c05f1be 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -61,8 +61,11 @@ export class PersistedLRU { set(key: K, value: V): void { this.memory.set(key, value); this.storage.set(this.prefixed(key), value); - for (const signal of this.signals.get(key) ?? []) { + const signals = this.signals.get(key); + let signal = signals?.pop(); + while (signal) { signal(value); + signal = signals?.pop(); } this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly) } diff --git a/src/lib/index.ts b/src/lib/index.ts index 238f69b..f7181b5 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,6 +1,19 @@ 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/routes/+page.svelte b/src/routes/+page.svelte index 9cd6f86..b454e62 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -12,29 +12,31 @@ type ResourceUri } from '@atcute/lexicons'; import { onMount } from 'svelte'; - import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch'; + import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch'; import { expect, ok } from '$lib/result'; import { AppBskyFeedPost } from '@atcute/bluesky'; - import { SvelteMap } from 'svelte/reactivity'; + import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; - import { notificationStream } from '$lib'; + import { notificationStream, selectedDid } from '$lib'; import { get } from 'svelte/store'; + import Icon from '@iconify/svelte'; let loaderState = new LoaderState(); let scrollContainer = $state(); - let selectedDid = $state(null); let clients = new SvelteMap(); - let selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); + let selectedClient = $derived($selectedDid ? clients.get($selectedDid) : null); let viewClient = $state(new AtpClient()); - let posts = new SvelteMap>(); + let posts = new SvelteMap>(); let cursors = new SvelteMap(); let isSettingsOpen = $state(false); + let reverseChronological = $state(true); + let viewOwnPosts = $state(true); - const addPosts = (did: Did, accTimeline: Map) => { + const addPosts = (did: Did, accTimeline: Map) => { if (!posts.has(did)) { posts.set(did, new SvelteMap(accTimeline)); return; @@ -50,7 +52,7 @@ const cursor = cursors.get(account.did); if (cursor && cursor.end) return; - const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 12); + const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6); if (!accPosts.ok) throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`; @@ -80,8 +82,9 @@ const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); const hydrated = await hydratePosts(viewClient, [ { - record: subjectPost.value, + record: subjectPost.value.record, uri: event.data.link.subject, + cid: subjectPost.value.cid, replies: ok({ cursor: null, total: 1, @@ -144,7 +147,7 @@ // }); if ($accounts.length > 0) { loaderState.status = 'LOADING'; - selectedDid = $accounts[0].did; + $selectedDid = $accounts[0].did; Promise.all($accounts.map(loginAccount)).then(() => { loadMore(); }); @@ -158,7 +161,7 @@ }; const handleAccountSelected = async (did: Did) => { - selectedDid = 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); @@ -176,7 +179,7 @@ const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => { const newAccount: Account = { did, handle, password }; addAccount(newAccount); - selectedDid = did; + $selectedDid = did; loginAccount(newAccount).then(() => fetchTimeline(newAccount)); }; @@ -198,14 +201,10 @@ } }; - let reverseChronological = $state(true); - let viewOwnPosts = $state(true); - type ThreadPost = { - uri: ResourceUri; + data: PostWithUri; did: Did; rkey: string; - record: AppBskyFeedPost.Main; parentUri: ResourceUri | null; depth: number; newestTime: number; @@ -218,25 +217,24 @@ branchParentPost?: ThreadPost; }; - const buildThreads = (timelines: Map>): Thread[] => { + const buildThreads = (timelines: Map>): Thread[] => { // eslint-disable-next-line svelte/prefer-svelte-reactivity const threadMap = new Map(); // Single pass: create posts and group by thread for (const [, timeline] of timelines) { - for (const [uri, record] of timeline) { + for (const [uri, data] of timeline) { const parsedUri = expect(parseCanonicalResourceUri(uri)); - const rootUri = (record.reply?.root.uri as ResourceUri) || uri; - const parentUri = (record.reply?.parent.uri as ResourceUri) || null; + const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; + const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; const post: ThreadPost = { - uri, + data, did: parsedUri.repo, rkey: parsedUri.rkey, - record, parentUri, depth: 0, - newestTime: new Date(record.createdAt).getTime() + newestTime: new Date(data.record.createdAt).getTime() }; if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); @@ -248,7 +246,7 @@ const threads: Thread[] = []; for (const [rootUri, posts] of threadMap) { - const uriToPost = new Map(posts.map((p) => [p.uri, p])); + const uriToPost = new Map(posts.map((p) => [p.data.uri, p])); // eslint-disable-next-line svelte/prefer-svelte-reactivity const childrenMap = new Map(); @@ -292,7 +290,7 @@ const result: ThreadPost[] = []; const addWithChildren = (post: ThreadPost) => { result.push(post); - const children = childrenMap.get(post.uri) || []; + const children = childrenMap.get(post.data.uri) || []; children.forEach(addWithChildren); }; addWithChildren(startPost); @@ -342,7 +340,7 @@ threads.push( createThread( branchPosts, - branchRoot.uri, + branchRoot.data.uri, isOldestBranch ? undefined : (branchParentUri ?? undefined) ) ); @@ -371,6 +369,11 @@ }); let threads = $derived(filterThreads(buildThreads(posts), $accounts)); + + let quoting = $state(undefined); + let replying = $state(undefined); + + let expandedThreads = new SvelteSet();
@@ -384,23 +387,11 @@
@@ -409,7 +400,7 @@ posts.get(selectedDid!)?.set(uri, record)} + {selectedDid} + onPostSent={(post) => posts.get($selectedDid!)?.set(post.uri, post)} + bind:quoting + bind:replying />
{:else} @@ -431,10 +425,10 @@ {/if}
-
+ /> -->
{/snippet} +{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} + + {reverse ? '↱' : '↳'} + + +{/snippet} + {#snippet threadsView()} - {#each threads as thread ([thread.rootUri, thread.branchParentPost, ...thread.posts.map((post) => post.uri)])} + {#each threads as thread (thread.rootUri)}
{#if thread.branchParentPost} - {@const post = thread.branchParentPost} -
- {reverseChronological ? '↱' : '↳'} - -
+ {@render replyPost(thread.branchParentPost)} {/if} - {#each thread.posts as post (post.uri)} -
- -
+ {#each thread.posts as post, idx (post.data.uri)} + {@const mini = + !expandedThreads.has(thread.rootUri) && + thread.posts.length > 4 && + idx > 0 && + idx < thread.posts.length - 2} + {#if !mini} +
+ (quoting = post)} + onReply={(post) => (replying = post)} + {...post} + /> +
+ {:else if mini} + {#if idx === 1} + {@render replyPost(post, !reverseChronological)} + + {:else if idx === thread.posts.length - 3} + {@render replyPost(post)} + {/if} + {/if} {/each}
{/each}