diff --git a/AGENTS.md b/AGENTS.md --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. **Use these forms instead:** + ```bash # Force overwrite without prompting cp -f source dest # NOT: cp source dest @@ -42,12 +43,14 @@ ``` **Other commands that may prompt:** + - `scp` - use `-o BatchMode=yes` for non-interactive - `ssh` - use `-o BatchMode=yes` to fail instead of prompting - `apt-get` - use `-y` flag - `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var + ## Beads Issue Tracker This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. @@ -89,6 +92,7 @@ 7. **Hand off** - Provide context for next session **CRITICAL RULES:** + - Work is NOT complete until `git push` succeeds - NEVER stop before pushing - that leaves work stranded locally - NEVER say "ready to push when you are" - YOU must push @@ -96,6 +100,7 @@ + ## Beads Issue Tracker Use Beads (`bd`) for durable task tracking in repositories that include it. Use the `beads` skill at `.agents/skills/beads/SKILL.md` (project install) or `~/.agents/skills/beads/SKILL.md` (global install) for Beads workflow guidance, then use the `bd` CLI for issue operations. diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ This file provides instructions and context for AI coding agents working on this project. + ## Beads Issue Tracker This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. @@ -44,12 +45,12 @@ 7. **Hand off** - Provide context for next session **CRITICAL RULES:** + - Work is NOT complete until `git push` succeeds - NEVER stop before pushing - that leaves work stranded locally - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds - ## Build & Test diff --git a/deno.lock b/deno.lock --- a/deno.lock +++ b/deno.lock @@ -22,7 +22,7 @@ "npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3", "npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18", "npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3", - "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18": "3.0.18_svelte@5.46.1__acorn@8.15.0", + "npm:@tanstack/svelte-virtual@^3.13.30": "3.13.30_svelte@5.46.1__acorn@8.15.0", "npm:@types/node@^25.0.3": "25.0.3", "npm:@wora/cache-persist@^2.2.1": "2.2.1", "npm:async-cache-dedupe@^3.4.0": "3.4.0", @@ -737,11 +737,15 @@ "vite" ] }, - "@tutorlatin/svelte-tiny-virtual-list@3.0.18_svelte@5.46.1__acorn@8.15.0": { - "integrity": "sha512-In7ASkVkLhg0ClWEVA50J/hWrGovwkw7dfHYlUyQJz4rPvh1TGpfo0lN1mXWERj2bkWY8AYITSbBZnVz34tcfA==", + "@tanstack/svelte-virtual@3.13.30_svelte@5.46.1__acorn@8.15.0": { + "integrity": "sha512-Yj6FOrUhYZ48kaSrc/CqOWM57wAJivoyW+L3V9zgL7vtDOvFU02aOnvmcmd/9Qypl/V29+ySwEzKA7f1Rpv0Xw==", "dependencies": [ + "@tanstack/virtual-core", "svelte" ] + }, + "@tanstack/virtual-core@3.17.2": { + "integrity": "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==" }, "@types/cookie@0.6.0": { "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" @@ -1863,7 +1867,7 @@ "npm:@sveltejs/vite-plugin-svelte@^6.2.1", "npm:@tailwindcss/forms@~0.5.11", "npm:@tailwindcss/vite@^4.1.18", - "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18", + "npm:@tanstack/svelte-virtual@^3.13.30", "npm:@types/node@^25.0.3", "npm:@wora/cache-persist@^2.2.1", "npm:async-cache-dedupe@^3.4.0", diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@atcute/tid": "^1.1.1", "@floating-ui/dom": "^1.7.4", "@soffinal/websocket": "^0.2.1", - "@tutorlatin/svelte-tiny-virtual-list": "^3.0.18", + "@tanstack/svelte-virtual": "^3.13.30", "@wora/cache-persist": "^2.2.1", "async-cache-dedupe": "^3.4.0", "hash-wasm": "^4.12.0", diff --git a/.beads/README.md b/.beads/README.md --- a/.beads/README.md +++ b/.beads/README.md @@ -33,6 +33,7 @@ ### Working with Issues Issues in Beads are: + - **Git-native**: Stored in Dolt database with version control and branching - **AI-friendly**: CLI-first design works perfectly with AI coding agents - **Branch-aware**: Issues can follow your branch workflow @@ -41,16 +42,19 @@ ## Why Beads? ✨ **AI-Native Design** + - Built specifically for AI-assisted development workflows - CLI-first interface works seamlessly with AI coding agents - No context switching to web UIs 🚀 **Developer Focused** + - Issues live in your repo, right next to your code - Works offline, syncs when you push - Fast, lightweight, and stays out of your way 🔧 **Git Integration** + - Automatic sync with git commits - Branch-aware issue tracking - Dolt-native three-way merge resolution @@ -78,4 +82,4 @@ --- -*Beads: Issue tracking that moves at the speed of thought* ⚡ +_Beads: Issue tracking that moves at the speed of thought_ ⚡ diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -0,0 +1,8 @@ +{"id":"int-aaf30fe3","kind":"field_change","created_at":"2026-06-29T14:29:06.229359693Z","actor":"dawn","issue_id":"nucleus-n16","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"installed @tanstack/svelte-virtual"}} +{"id":"int-ec7a0de4","kind":"field_change","created_at":"2026-06-29T14:29:49.557989683Z","actor":"dawn","issue_id":"nucleus-xnz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"refactored SettingsFeedsTab.svelte to use TanStack Virtual"}} +{"id":"int-4f6810f8","kind":"field_change","created_at":"2026-06-29T14:30:13.834214558Z","actor":"dawn","issue_id":"nucleus-3w3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"refactored SettingsModerationTab.svelte to use TanStack Virtual"}} +{"id":"int-5a6cee4a","kind":"field_change","created_at":"2026-06-29T14:30:37.276554381Z","actor":"dawn","issue_id":"nucleus-fcu","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"refactored FollowingView.svelte to use TanStack Virtual"}} +{"id":"int-2699475f","kind":"field_change","created_at":"2026-06-29T14:32:54.755171197Z","actor":"dawn","issue_id":"nucleus-ib8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"refactored FeedTimelineView.svelte to use TanStack Virtual with dynamic height"}} +{"id":"int-453baacc","kind":"field_change","created_at":"2026-06-29T14:33:25.416212006Z","actor":"dawn","issue_id":"nucleus-ehl","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"refactored GenericTimelineView.svelte to use TanStack Virtual with dynamic height"}} +{"id":"int-ef0bb2b7","kind":"field_change","created_at":"2026-06-29T14:35:47.990869363Z","actor":"dawn","issue_id":"nucleus-9ot","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Removed old package and verified typescript / prettier formatting"}} +{"id":"int-a4d53813","kind":"field_change","created_at":"2026-06-29T14:35:55.325922806Z","actor":"dawn","issue_id":"nucleus-bkx","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Epic complete: all list virtualizations rewritten to use TanStack Virtual."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,8 @@ +{"_type":"issue","id":"nucleus-9ot","title":"Clean up @tutorlatin/svelte-tiny-virtual-list and verify build/lints","description":"Uninstall unused dependency, run linter and build to make sure everything works perfectly","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:27:01Z","created_by":"dawn","updated_at":"2026-06-29T14:35:48Z","started_at":"2026-06-29T14:33:32Z","closed_at":"2026-06-29T14:35:48Z","close_reason":"Removed old package and verified typescript / prettier formatting","dependencies":[{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-3w3","type":"blocks","created_at":"2026-06-29T17:28:10Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-ehl","type":"blocks","created_at":"2026-06-29T17:28:23Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-fcu","type":"blocks","created_at":"2026-06-29T17:28:15Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-ib8","type":"blocks","created_at":"2026-06-29T17:28:19Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:28:02Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-xnz","type":"blocks","created_at":"2026-06-29T17:28:06Z","created_by":"dawn","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"nucleus-ehl","title":"Refactor GenericTimelineView to use TanStack Virtual","description":"Replace VirtualList in GenericTimelineView.svelte with TanStack Virtual using dynamic height measurement (measureElement)","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:53Z","created_by":"dawn","updated_at":"2026-06-29T14:33:25Z","started_at":"2026-06-29T14:33:01Z","closed_at":"2026-06-29T14:33:25Z","close_reason":"refactored GenericTimelineView.svelte to use TanStack Virtual with dynamic height","dependencies":[{"issue_id":"nucleus-ehl","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:27:57Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"nucleus-ib8","title":"Refactor FeedTimelineView to use TanStack Virtual","description":"Replace VirtualList in FeedTimelineView.svelte with TanStack Virtual using dynamic height measurement (measureElement)","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:45Z","created_by":"dawn","updated_at":"2026-06-29T14:32:55Z","started_at":"2026-06-29T14:30:44Z","closed_at":"2026-06-29T14:32:55Z","close_reason":"refactored FeedTimelineView.svelte to use TanStack Virtual with dynamic height","dependencies":[{"issue_id":"nucleus-ib8","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:27:53Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"nucleus-fcu","title":"Refactor FollowingView to use TanStack Virtual","description":"Replace VirtualList in FollowingView.svelte with TanStack Virtual","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:39Z","created_by":"dawn","updated_at":"2026-06-29T14:30:37Z","started_at":"2026-06-29T14:30:20Z","closed_at":"2026-06-29T14:30:37Z","close_reason":"refactored FollowingView.svelte to use TanStack Virtual","dependencies":[{"issue_id":"nucleus-fcu","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:27:49Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"nucleus-3w3","title":"Refactor SettingsModerationTab to use TanStack Virtual","description":"Replace VirtualList in SettingsModerationTab.svelte with TanStack Virtual","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:33Z","created_by":"dawn","updated_at":"2026-06-29T14:30:14Z","started_at":"2026-06-29T14:29:56Z","closed_at":"2026-06-29T14:30:14Z","close_reason":"refactored SettingsModerationTab.svelte to use TanStack Virtual","dependencies":[{"issue_id":"nucleus-3w3","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:27:44Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"nucleus-xnz","title":"Refactor SettingsFeedsTab to use TanStack Virtual","description":"Replace VirtualList in SettingsFeedsTab.svelte with TanStack Virtual","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:24Z","created_by":"dawn","updated_at":"2026-06-29T14:29:50Z","started_at":"2026-06-29T14:29:19Z","closed_at":"2026-06-29T14:29:50Z","close_reason":"refactored SettingsFeedsTab.svelte to use TanStack Virtual","dependencies":[{"issue_id":"nucleus-xnz","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:27:40Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"nucleus-n16","title":"Install @tanstack/svelte-virtual","description":"Add @tanstack/svelte-virtual package using deno install and update package.json","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:18Z","created_by":"dawn","updated_at":"2026-06-29T14:29:06Z","started_at":"2026-06-29T14:28:39Z","closed_at":"2026-06-29T14:29:06Z","close_reason":"installed @tanstack/svelte-virtual","dependency_count":0,"dependent_count":7,"comment_count":0} +{"_type":"issue","id":"nucleus-bkx","title":"Epic: Rewrite nucleus to use TanStack Virtual","description":"Replace @tutorlatin/svelte-tiny-virtual-list with @tanstack/svelte-virtual, getting rid of custom height measurement hacks and improving scroll/list performance.","status":"closed","priority":2,"issue_type":"feature","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:11Z","created_by":"dawn","updated_at":"2026-06-29T14:35:55Z","closed_at":"2026-06-29T14:35:55Z","close_reason":"Epic complete: all list virtualizations rewritten to use TanStack Virtual.","dependencies":[{"issue_id":"nucleus-bkx","depends_on_id":"nucleus-3w3","type":"blocks","created_at":"2026-06-29T17:27:16Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-bkx","depends_on_id":"nucleus-9ot","type":"blocks","created_at":"2026-06-29T17:27:33Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-bkx","depends_on_id":"nucleus-ehl","type":"blocks","created_at":"2026-06-29T17:27:29Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-bkx","depends_on_id":"nucleus-fcu","type":"blocks","created_at":"2026-06-29T17:27:20Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-bkx","depends_on_id":"nucleus-ib8","type":"blocks","created_at":"2026-06-29T17:27:24Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-bkx","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:27:07Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-bkx","depends_on_id":"nucleus-xnz","type":"blocks","created_at":"2026-06-29T17:27:12Z","created_by":"dawn","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} diff --git a/.beads/metadata.json b/.beads/metadata.json --- a/.beads/metadata.json +++ b/.beads/metadata.json @@ -1,7 +1,7 @@ { - "database": "dolt", - "backend": "dolt", - "dolt_mode": "embedded", - "dolt_database": "nucleus", - "project_id": "bf0b94c2-7ce1-4a31-87a1-b40a40ae1e2f" -} \ No newline at end of file + "database": "dolt", + "backend": "dolt", + "dolt_mode": "embedded", + "dolt_database": "nucleus", + "project_id": "bf0b94c2-7ce1-4a31-87a1-b40a40ae1e2f" +} diff --git a/.claude/settings.json b/.claude/settings.json --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,26 +1,26 @@ { - "hooks": { - "PreCompact": [ - { - "hooks": [ - { - "command": "bd prime", - "type": "command" - } - ], - "matcher": "" - } - ], - "SessionStart": [ - { - "hooks": [ - { - "command": "bd prime", - "type": "command" - } - ], - "matcher": "" - } - ] - } -} \ No newline at end of file + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ] + } +} diff --git a/src/app.css b/src/app.css --- a/src/app.css +++ b/src/app.css @@ -108,7 +108,6 @@ } @keyframes pulse-highlight { - 0%, 100% { box-shadow: 0 0 0 0 var(--nucleus-selected-post); @@ -165,4 +164,4 @@ .settings-desc { @apply mb-2 text-sm text-(--nucleus-fg)/80; -} \ No newline at end of file +} diff --git a/src/components/FeedTimelineView.svelte b/src/components/FeedTimelineView.svelte --- a/src/components/FeedTimelineView.svelte +++ b/src/components/FeedTimelineView.svelte @@ -7,11 +7,10 @@ import { accounts } from '$lib/accounts'; import type { Did, RecordKey } from '@atcute/lexicons/syntax'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; - import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; + import { createVirtualizer } from '@tanstack/svelte-virtual'; import { allPosts, viewClient, - accountPreferences, feedTimelines, feedCursors, fetchFeed, @@ -44,17 +43,15 @@ let feedServiceDid = $state(null); let newPostsAvailable = $state(false); - let virtualList = $state(null); let scrollToIndex = $state(undefined); const viewKey = $derived(`${userDid ?? 'anon'}-${selectedFeed}`); $effect(() => { - viewKey; // dependency + void viewKey; // dependency feedServiceDid = null; newPostsAvailable = false; displayCount = 15; - measuredHeights = []; loaderState.reset(); scrollToIndex = undefined; @@ -82,7 +79,6 @@ let loadError = $state(''); let fetchingInteractions = $state(false); - let scheduledFetchInteractions = $state(false); export const clearFeed = () => { if (!userDid) return; @@ -90,7 +86,6 @@ setTimeout(() => (scrollToIndex = undefined), 100); newPostsAvailable = false; displayCount = 15; - measuredHeights = []; resetFeed(userDid, selectedFeed); loaderState.reset(); loadMore(); @@ -106,20 +101,34 @@ .filter((p): p is NonNullable => p !== undefined); }); - let measuredHeights: number[] = $state([]); - const itemHeights = $derived.by(() => { - const heights = measuredHeights.slice(0, feedPosts.length); - while (heights.length < feedPosts.length) { - heights.push(estimatePostHeight(feedPosts[heights.length])); - } - return heights; + let parentRef = $state(null); + const virtualizer = createVirtualizer({ + count: 0, + getScrollElement: () => parentRef, + estimateSize: (index) => estimatePostHeight(feedPosts[index]), + overscan: 5, + getItemKey: (index) => feedPosts[index]?.uri ?? index }); - const averageHeight = $derived.by(() => { - if (measuredHeights.length === 0) return 150; - const sum = measuredHeights.reduce((a, b) => a + b, 0); - return sum / measuredHeights.length; + $effect(() => { + $virtualizer.setOptions({ + count: feedPosts.length, + getScrollElement: () => parentRef, + estimateSize: (index) => estimatePostHeight(feedPosts[index]), + overscan: 5, + getItemKey: (index) => feedPosts[index]?.uri ?? index + }); }); + + $effect(() => { + if (scrollToIndex !== undefined) { + $virtualizer.scrollToIndex(scrollToIndex); + } + }); + + const measureElement = (node: HTMLElement) => { + $virtualizer.measureElement(node); + }; const loadMore = async () => { if (loading || !client || !userDid || !feedServiceDid) return; @@ -136,12 +145,9 @@ const result = await fetchFeed(client, selectedFeed, feedServiceDid); if (client.user && userDid) { if (!fetchingInteractions) { - scheduledFetchInteractions = false; fetchingInteractions = true; await fetchInteractionsToFeedTimelineEnd(client, userDid, selectedFeed); fetchingInteractions = false; - } else { - scheduledFetchInteractions = true; } } console.log('feed loaded', result?.end); @@ -180,69 +186,58 @@ {#if userDid || $accounts.length > 0} {#key viewKey} - 0 ? scrollToIndex : undefined} +
- {#snippet item({ index, style }: { index: number; style: string })} - {@const { post, postDid, postRkey } = renderItem(index)} -
{ - // we need to return this so the bind works - return measuredHeights[index] ?? estimatePostHeight(post); - }, - (h) => { - // update the height - if (measuredHeights[index] !== h) measuredHeights[index] = h; - } - } - > +
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} + {@const { post, postDid, postRkey } = renderItem(virtualItem.index)}
- {#if post && postDid && postRkey} - { - postComposerState.focus = 'focused'; - postComposerState.quoting = p; - }} - onReply={(p) => { - postComposerState.focus = 'focused'; - postComposerState.replying = p; - }} - /> - {/if} +
+ {#if post && postDid && postRkey} + { + postComposerState.focus = 'focused'; + postComposerState.quoting = p; + }} + onReply={(p) => { + postComposerState.focus = 'focused'; + postComposerState.replying = p; + }} + /> + {/if} +
-
- {/snippet} + {/each} +
- {#snippet footer()} -
- -
- {#snippet noData()} - - {/snippet} - {#snippet loading()} - - {/snippet} - {#snippet error()} - - {/snippet} -
-
- {/snippet} - +
+ +
+ {#snippet noData()} + + {/snippet} + {#snippet loading()} + + {/snippet} + {#snippet error()} + + {/snippet} +
+
+
{/key} {:else} diff --git a/src/components/FollowingView.svelte b/src/components/FollowingView.svelte --- a/src/components/FollowingView.svelte +++ b/src/components/FollowingView.svelte @@ -9,7 +9,7 @@ } from '$lib/state.svelte'; import type { Did } from '@atcute/lexicons'; import { type AtpClient } from '$lib/at/client.svelte'; - import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; + import { createVirtualizer } from '@tanstack/svelte-virtual'; import { calculateFollowedUserStats, calculateInteractionScores, @@ -121,6 +121,23 @@ return () => observer.disconnect(); } }); + + let parentRef = $state(null); + const virtualizer = createVirtualizer({ + count: 0, + getScrollElement: () => parentRef, + estimateSize: () => 76, + overscan: 5 + }); + + $effect(() => { + $virtualizer.setOptions({ + count: sortedFollowing.length, + getScrollElement: () => parentRef, + estimateSize: () => 76, + overscan: 5 + }); + });
@@ -157,19 +174,24 @@ >
{:else if listHeight > 0} - - {#snippet item({ index, style }: { index: number; style: string })} - {@const user = sortedFollowing[index]} - - {/snippet} - +
+
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} + {@const user = sortedFollowing[virtualItem.index]} + + {/each} +
+
{/if} diff --git a/src/components/GenericTimelineView.svelte b/src/components/GenericTimelineView.svelte --- a/src/components/GenericTimelineView.svelte +++ b/src/components/GenericTimelineView.svelte @@ -5,7 +5,7 @@ import { type ResourceUri } from '@atcute/lexicons'; import { SvelteSet } from 'svelte/reactivity'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; - import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; + import { createVirtualizer } from '@tanstack/svelte-virtual'; import Icon from '@iconify/svelte'; import { type ThreadPost, type Thread } from '$lib/thread'; import NotLoggedIn from './NotLoggedIn.svelte'; @@ -13,7 +13,7 @@ import EndOfList from './EndOfList.svelte'; import LoadError from './LoadError.svelte'; import LoadNewPosts from './LoadNewPosts.svelte'; - import { onMount } from 'svelte'; + import { initialDone } from '$lib/state.svelte'; import { estimatePostHeight } from '$lib/post-height'; @@ -48,7 +48,6 @@ let reverseChronological = $state(true); const expandedThreads = new SvelteSet(); - let isAtTop = $state(true); let boundaryTime = $state(null); const visibleThreads = $derived.by(() => { @@ -57,9 +56,8 @@ }); $effect(() => { - timelineId; + void timelineId; displayCount = 15; - measuredHeights = []; }); // const renderedThreads = $derived(visibleThreads.slice(0, displayCount)); @@ -70,13 +68,9 @@ const showNewPosts = () => { boundaryTime = threads[0]?.newestTime ?? null; - window.scrollTo({ top: 0, behavior: 'instant' }); - isAtTop = true; - }; - - const onScroll = (event: { event: Event; offset: number }) => { - const { offset } = event; - isAtTop = offset < 300; + if (parentRef) { + parentRef.scrollTo({ top: 0, behavior: 'instant' }); + } }; const loaderState = new LoaderState(); @@ -112,20 +106,28 @@ return height; }; - let measuredHeights: number[] = $state([]); - const itemHeights = $derived.by(() => { - const heights = measuredHeights.slice(0, visibleThreads.length); - while (heights.length < visibleThreads.length) { - heights.push(estimateThreadHeight(visibleThreads[heights.length])); - } - return heights; + let parentRef = $state(null); + const virtualizer = createVirtualizer({ + count: 0, + getScrollElement: () => parentRef, + estimateSize: (index) => estimateThreadHeight(visibleThreads[index]), + overscan: 5, + getItemKey: (index) => visibleThreads[index]?.rootUri ?? index }); - const averageHeight = $derived.by(() => { - if (measuredHeights.length === 0) return 300; - const sum = measuredHeights.reduce((a, b) => a + b, 0); - return sum / measuredHeights.length; + $effect(() => { + $virtualizer.setOptions({ + count: visibleThreads.length, + getScrollElement: () => parentRef, + estimateSize: (index) => estimateThreadHeight(visibleThreads[index]), + overscan: 5, + getItemKey: (index) => visibleThreads[index]?.rootUri ?? index + }); }); + + const measureElement = (node: HTMLElement) => { + $virtualizer.measureElement(node); + }; const loadMore = async () => { if (loading || !shouldLoad) return; @@ -186,117 +188,107 @@ /> {#if isLoggedIn} {#key timelineId} - - {#snippet item({ index, style }: { index: number; style: string })} - {@const thread = renderItem(index)} -
{ - // we need to return this so the bind works - return measuredHeights[index] ?? estimateThreadHeight(thread); - }, - (h) => { - // update the height - if (measuredHeights[index] !== h) measuredHeights[index] = h; - } - } - > - {#if thread} -
- {#if thread.branchParentPost} - {@render replyPost(thread.branchParentPost)} - {/if} - {#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} -
- { - postComposerState.focus = 'focused'; - postComposerState.quoting = post; - }} - onReply={(post) => { - postComposerState.focus = 'focused'; - postComposerState.replying = post; - }} - {...post} - blockRelationship={post.blockRelationship} - /> -
- {:else if mini} - {#if idx === 1} - {@render replyPost(post, !reverseChronological)} - - {:else if idx === thread.posts.length - 3} - {@render replyPost(post)} - {/if} - {/if} - {/each} -
- {#if index < visibleThreads.length - 1} +
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} + {@const thread = renderItem(virtualItem.index)} +
+ {#if thread}
- {/if} - {/if} -
- {/snippet} - - {#snippet footer()} -
- -
- {#snippet noData()} - - {/snippet} - {#snippet loading()} - - {#if !shouldLoad} -

- warming up... ◔.◔ -

+ class="flex w-full shrink-0 {reverseChronological + ? 'flex-col' + : 'flex-col-reverse'}" + > + {#if thread.branchParentPost} + {@render replyPost(thread.branchParentPost)} + {/if} + {#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} +
+ { + postComposerState.focus = 'focused'; + postComposerState.quoting = post; + }} + onReply={(post) => { + postComposerState.focus = 'focused'; + postComposerState.replying = post; + }} + {...post} + blockRelationship={post.blockRelationship} + /> +
+ {:else if mini} + {#if idx === 1} + {@render replyPost(post, !reverseChronological)} + + {:else if idx === thread.posts.length - 3} + {@render replyPost(post)} + {/if} + {/if} + {/each} +
+ {#if virtualItem.index < visibleThreads.length - 1} +
{/if} - {/snippet} - {#snippet error()} - - {/snippet} - -
- {/snippet} - + {/if} +
+ {/each} + + +
+ +
+ {#snippet noData()} + + {/snippet} + {#snippet loading()} + + {#if !shouldLoad} +

+ warming up... ◔.◔ +

+ {/if} + {/snippet} + {#snippet error()} + + {/snippet} +
+
+ {/key} {:else} diff --git a/src/components/SettingsFeedsTab.svelte b/src/components/SettingsFeedsTab.svelte --- a/src/components/SettingsFeedsTab.svelte +++ b/src/components/SettingsFeedsTab.svelte @@ -1,6 +1,6 @@
@@ -47,20 +64,22 @@
{#if sortedFeeds.length > 0}
- - {#snippet item({ index, style }: { index: number; style: string })} - onRemoveFeed(sortedFeeds[index].feed.uri)} - onTogglePin={() => onTogglePin(sortedFeeds[index].feed.uri)} - /> - {/snippet} - +
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} + onRemoveFeed(sortedFeeds[virtualItem.index].feed.uri)} + onTogglePin={() => onTogglePin(sortedFeeds[virtualItem.index].feed.uri)} + /> + {/each} +
+
{:else}

no saved feeds

diff --git a/src/components/SettingsModerationTab.svelte b/src/components/SettingsModerationTab.svelte --- a/src/components/SettingsModerationTab.svelte +++ b/src/components/SettingsModerationTab.svelte @@ -1,6 +1,6 @@
@@ -78,19 +112,23 @@
{#if mutes.length > 0}
- - {#snippet item({ index, style }: { index: number; style: string })} - onRemoveMute(mutes[index])} - /> - {/snippet} - +
+ {#each $mutesVirtualizer.getVirtualItems() as virtualItem (virtualItem.key)} + onRemoveMute(mutes[virtualItem.index])} + /> + {/each} +
+
{:else}

no muted accounts

@@ -116,19 +154,23 @@ {#if blocks.length > 0}
- - {#snippet item({ index, style }: { index: number; style: string })} - handleRemoveBlock(blocks[index])} - /> - {/snippet} - +
+ {#each $blocksVirtualizer.getVirtualItems() as virtualItem (virtualItem.key)} + handleRemoveBlock(blocks[virtualItem.index])} + /> + {/each} +
+
{:else}

no blocked accounts

diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -19,6 +19,6 @@ export const redirectUri = `${domain}/`; export const clientId = dev ? `http://localhost` + - `?redirect_uri=${encodeURIComponent(redirectUri)}` + - `&scope=${encodeURIComponent(oauthMetadata.scope)}` + `?redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent(oauthMetadata.scope)}` : oauthMetadata.client_id; diff --git a/src/lib/post-height.ts b/src/lib/post-height.ts --- a/src/lib/post-height.ts +++ b/src/lib/post-height.ts @@ -11,67 +11,66 @@ // idk this is dumb idk i hate virtual lists and shit bleh export const estimatePostHeight = (post: PostWithUri | undefined | null, depth = 0): number => { - if (!post) return 150; // default fallback if post is missing - if (depth > 1) return 0; // prevent infinite recursion + if (!post) return 150; // default fallback if post is missing + if (depth > 1) return 0; // prevent infinite recursion - const record = post.record; - let height = BASE_HEIGHT; + const record = post.record; + let height = BASE_HEIGHT; - // 1. text height - if (record.text) { - const lines = Math.ceil(record.text.length / CHARS_PER_LINE) || 1; - // add a bit more for newlines if present - const newlines = (record.text.match(/\n/g) || []).length; - height += (Math.max(lines, newlines + 1) * LINE_HEIGHT); - } + // 1. text height + if (record.text) { + const lines = Math.ceil(record.text.length / CHARS_PER_LINE) || 1; + // add a bit more for newlines if present + const newlines = (record.text.match(/\n/g) || []).length; + height += Math.max(lines, newlines + 1) * LINE_HEIGHT; + } - // 2. embeds - if (record.embed) { - const embed = record.embed; + // 2. embeds + if (record.embed) { + const embed = record.embed; - if (embed.$type === 'app.bsky.embed.images') { - // images - if (embed.images.length === 1) { - const aspect = embed.images[0].aspectRatio; - if (aspect) { - // w / h = a => h = w / a - // assuming max width of ~500px in feed - const calculatedHeight = 500 / (aspect.width / aspect.height); - height += Math.min(calculatedHeight, 500); // clamp max height - } else { - height += IMAGE_HEIGHT; - } - } else { - height += IMAGE_GRID_HEIGHT; - } - } else if (embed.$type === 'app.bsky.embed.video') { - // video - const aspect = embed.aspectRatio; - if (aspect) { - const calculatedHeight = 500 / (aspect.width / aspect.height); - height += Math.min(calculatedHeight, 500); - } else { - height += VIDEO_HEIGHT; - } - } else if (embed.$type === 'app.bsky.embed.record') { - // quote post - height += QUOTE_FALLBACK_HEIGHT; + if (embed.$type === 'app.bsky.embed.images') { + // images + if (embed.images.length === 1) { + const aspect = embed.images[0].aspectRatio; + if (aspect) { + // w / h = a => h = w / a + // assuming max width of ~500px in feed + const calculatedHeight = 500 / (aspect.width / aspect.height); + height += Math.min(calculatedHeight, 500); // clamp max height + } else { + height += IMAGE_HEIGHT; + } + } else { + height += IMAGE_GRID_HEIGHT; + } + } else if (embed.$type === 'app.bsky.embed.video') { + // video + const aspect = embed.aspectRatio; + if (aspect) { + const calculatedHeight = 500 / (aspect.width / aspect.height); + height += Math.min(calculatedHeight, 500); + } else { + height += VIDEO_HEIGHT; + } + } else if (embed.$type === 'app.bsky.embed.record') { + // quote post + height += QUOTE_FALLBACK_HEIGHT; + } else if (embed.$type === 'app.bsky.embed.recordWithMedia') { + // recordWithMedia + // media part + const media = embed.media; + if (media.$type === 'app.bsky.embed.images') { + height += media.images.length === 1 ? IMAGE_HEIGHT : IMAGE_GRID_HEIGHT; + } else if (media.$type === 'app.bsky.embed.video') { + height += VIDEO_HEIGHT; + } + // quote part + height += QUOTE_FALLBACK_HEIGHT; + } else if (embed.$type === 'app.bsky.embed.external') { + height += LINK_CARD_HEIGHT; + } + } - } else if (embed.$type === 'app.bsky.embed.recordWithMedia') { - // recordWithMedia - // media part - const media = embed.media; - if (media.$type === 'app.bsky.embed.images') { - height += (media.images.length === 1 ? IMAGE_HEIGHT : IMAGE_GRID_HEIGHT); - } else if (media.$type === 'app.bsky.embed.video') { - height += VIDEO_HEIGHT; - } - // quote part - height += QUOTE_FALLBACK_HEIGHT; - } else if (embed.$type === 'app.bsky.embed.external') { - height += LINK_CARD_HEIGHT; - } - } - - return Math.round(height); + return Math.round(height); }; diff --git a/src/lib/router.svelte.ts b/src/lib/router.svelte.ts --- a/src/lib/router.svelte.ts +++ b/src/lib/router.svelte.ts @@ -17,11 +17,11 @@ type ExtractParams = // eslint-disable-next-line @typescript-eslint/no-unused-vars Path extends `${infer Start}/:${infer Param}/${infer Rest}` - ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string } - : // eslint-disable-next-line @typescript-eslint/no-unused-vars - Path extends `${infer Start}/:${infer Param}` - ? { [K in Param]: string } - : Record; + ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string } + : // eslint-disable-next-line @typescript-eslint/no-unused-vars + Path extends `${infer Start}/:${infer Param}` + ? { [K in Param]: string } + : Record; export type Route = { [T in K]: { diff --git a/src/lib/settings.ts b/src/lib/settings.ts --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -12,8 +12,8 @@ }; export type SavedFeed = { - feed: FeedGenerator, - pinned: boolean, + feed: FeedGenerator; + pinned: boolean; }; export type Settings = { @@ -101,4 +101,3 @@ current.endpoints.cdn !== other.endpoints.cdn ); }; - diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -32,11 +32,7 @@ } from '$lib'; import { Router } from './router.svelte'; import { accounts, type Account } from './accounts'; -import { - getPreferences, - putPreferences, - type Preferences -} from './at/pocket'; +import { getPreferences, putPreferences, type Preferences } from './at/pocket'; export const notificationStream = writable(null); export const jetstream = writable(null); @@ -354,10 +350,7 @@ }; export const follows = new SvelteMap>(); -export const addFollows = ( - did: Did, - followList: Iterable -) => { +export const addFollows = (did: Did, followList: Iterable) => { let map = follows.get(did)!; if (!map) { map = new SvelteMap(); @@ -423,7 +416,11 @@ await Promise.all([repostSource].map((s) => fetchLinksUntil(subject, client, s, timestamp))); }; -export const fetchFollowingTimeline = async (client: AtpClient, targetDid?: Did, limit: number = 10) => { +export const fetchFollowingTimeline = async ( + client: AtpClient, + targetDid?: Did, + limit: number = 10 +) => { // 1. Identify candidates (active follows + self) const userDid = targetDid ?? client.user?.did; if (!userDid) return; @@ -759,7 +756,10 @@ // feed state: Did -> feedUri -> post URIs export const feedTimelines = new SvelteMap>(); -export const feedCursors = new SvelteMap>(); +export const feedCursors = new SvelteMap< + Did, + SvelteMap +>(); export const followingFeed = new SvelteMap>(); // merged timeline: UserDid -> Set export const followingBuffer = new SvelteMap>(); // buffer: UserDid -> Set @@ -823,7 +823,11 @@ return newCursor; }; -export const checkForNewPosts = async (client: AtpClient, feedUri: string, feedServiceDid: string) => { +export const checkForNewPosts = async ( + client: AtpClient, + feedUri: string, + feedServiceDid: string +) => { const userDid = client.user?.did; if (!userDid) return false; @@ -846,7 +850,6 @@ feedTimelines.get(did)?.delete(feedUri); feedCursors.get(did)?.delete(feedUri); }; - const traversePostChain = (post: PostWithUri) => { const result = [post.uri]; @@ -919,10 +922,7 @@ ); }; -export const fetchInteractionsToFollowingTimelineEnd = async ( - client: AtpClient, - userDid: Did -) => { +export const fetchInteractionsToFollowingTimelineEnd = async (client: AtpClient, userDid: Did) => { const userCursors = followingCursors.get(userDid); if (!userCursors) return; @@ -987,7 +987,9 @@ fetchBlocks(account), fetchForInteractions(client, account.did), fetchFollows(account).then((follows) => - Promise.allSettled(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? []) + Promise.allSettled( + follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? [] + ) ) ]); initialDone.add(account.did); diff --git a/src/lib/thread.ts b/src/lib/thread.ts --- a/src/lib/thread.ts +++ b/src/lib/thread.ts @@ -165,7 +165,9 @@ childrenMap.get(post.parentUri)!.push(post); } - childrenMap.values().forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); + childrenMap + .values() + .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); const createThread = ( posts: ThreadPost[], @@ -262,4 +264,3 @@ const match = uri.match(/^at:\/\/(did:plc:[a-z0-9]+)/); return match ? (match[1] as Did) : null; }; - diff --git a/src/lib/at/feeds.ts b/src/lib/at/feeds.ts --- a/src/lib/at/feeds.ts +++ b/src/lib/at/feeds.ts @@ -5,85 +5,93 @@ import type { Blob as AtprotoBlob } from '@atcute/lexicons'; export type FeedGenerator = { - uri: string; - displayName: string; - description?: string; - avatar?: string; - did: string; + uri: string; + displayName: string; + description?: string; + avatar?: string; + did: string; }; export function parseFeedUri(uri: string): { repo: ActorIdentifier; rkey: RecordKey } | null { - if (uri.startsWith('at://')) { - const match = uri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.generator\/([^/]+)$/); - if (!match) return null; - return { repo: match[1] as ActorIdentifier, rkey: match[2] as RecordKey }; - } else if (uri.startsWith('https://') || uri.startsWith('http://')) { - const match = uri.match(/^https?:\/\/(?:[^/]+)\/profile\/([^/]+)\/feed\/([^/]+)$/); - if (!match) return null; - return { repo: match[1] as ActorIdentifier, rkey: match[2] as RecordKey }; - } - return null; + if (uri.startsWith('at://')) { + const match = uri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.generator\/([^/]+)$/); + if (!match) return null; + return { repo: match[1] as ActorIdentifier, rkey: match[2] as RecordKey }; + } else if (uri.startsWith('https://') || uri.startsWith('http://')) { + const match = uri.match(/^https?:\/\/(?:[^/]+)\/profile\/([^/]+)\/feed\/([^/]+)$/); + if (!match) return null; + return { repo: match[1] as ActorIdentifier, rkey: match[2] as RecordKey }; + } + return null; } -export async function fetchFeedGenerator(client: AtpClient, uri: string): Promise { - const parsed = parseFeedUri(uri); - if (!parsed) return null; +export async function fetchFeedGenerator( + client: AtpClient, + uri: string +): Promise { + const parsed = parseFeedUri(uri); + if (!parsed) return null; - try { - const response = await client.getRecord(AppBskyFeedGenerator.mainSchema, parsed.repo, parsed.rkey); - if (!response.ok) return null; + try { + const response = await client.getRecord( + AppBskyFeedGenerator.mainSchema, + parsed.repo, + parsed.rkey + ); + if (!response.ok) return null; - const value = response.value.record; - const did = response.value.uri.split('/')[2] as Did; - const avatar = value.avatar ? img('avatar_thumbnail', did, (value.avatar as AtprotoBlob).ref.$link, 'webp') : undefined; + const value = response.value.record; + const did = response.value.uri.split('/')[2] as Did; + const avatar = value.avatar + ? img('avatar_thumbnail', did, (value.avatar as AtprotoBlob).ref.$link, 'webp') + : undefined; - return { - uri: response.value.uri, - displayName: value.displayName, - description: value.description, - did: value.did, - avatar, - }; - } catch { - return null; - } + return { + uri: response.value.uri, + displayName: value.displayName, + description: value.description, + did: value.did, + avatar + }; + } catch { + return null; + } } export type FeedSkeletonItem = { - post: string; - reason?: { $type: string; repost?: string }; + post: string; + reason?: { $type: string; repost?: string }; }; export type FeedSkeleton = { - feed: FeedSkeletonItem[]; - cursor?: string; + feed: FeedSkeletonItem[]; + cursor?: string; }; export async function fetchFeedSkeleton( - client: AtpClient, - feedUri: string, - feedServiceDid: string, - cursor?: string, - limit: number = 25 + client: AtpClient, + feedUri: string, + feedServiceDid: string, + cursor?: string, + limit: number = 25 ): Promise { - const auth = client.user; - if (!auth) return null; + const auth = client.user; + if (!auth) return null; - const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''; - const url = `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}&limit=${limit}${cursorParam}`; + const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''; + const url = `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}&limit=${limit}${cursorParam}`; - try { - const response = await auth.atcute.handler(url, { - method: 'GET', - headers: { - 'atproto-proxy': `${feedServiceDid}#bsky_fg` - } - }); + try { + const response = await auth.atcute.handler(url, { + method: 'GET', + headers: { + 'atproto-proxy': `${feedServiceDid}#bsky_fg` + } + }); - if (!response.ok) return null; - return (await response.json()) as FeedSkeleton; - } catch { - return null; - } + if (!response.ok) return null; + return (await response.json()) as FeedSkeleton; + } catch { + return null; + } } - diff --git a/src/lib/at/pocket.ts b/src/lib/at/pocket.ts --- a/src/lib/at/pocket.ts +++ b/src/lib/at/pocket.ts @@ -6,69 +6,65 @@ const POCKET_PROXY = `${httpToDidWeb(pocketUrl.toString())}#pocket_prefs`; export type Preferences = { - mutes?: Did[]; - updatedAt: string; + mutes?: Did[]; + updatedAt: string; }; type PrefsResponse = { - preferences: Preferences; + preferences: Preferences; }; -export async function getPreferences(client: AtpClient): Promise> { - const auth = client.user; - if (!auth) return err('not authenticated'); +export async function getPreferences( + client: AtpClient +): Promise> { + const auth = client.user; + if (!auth) return err('not authenticated'); - try { - const response = await auth.atcute.handler( - '/xrpc/net.at-app.pet.ptr.nucleus.getPreferences', - { - method: 'GET', - headers: { - 'atproto-proxy': POCKET_PROXY - } - } - ); + try { + const response = await auth.atcute.handler('/xrpc/net.at-app.pet.ptr.nucleus.getPreferences', { + method: 'GET', + headers: { + 'atproto-proxy': POCKET_PROXY + } + }); - if (!response.ok) { - if (response.status === 400) return ok(null); - const error = await response.text(); - return err(`failed to get preferences: ${error}`); - } + if (!response.ok) { + if (response.status === 400) return ok(null); + const error = await response.text(); + return err(`failed to get preferences: ${error}`); + } - const data = (await response.json()) as PrefsResponse; - return ok(data.preferences); - } catch (error) { - return err(`failed to get preferences: ${error}`); - } + const data = (await response.json()) as PrefsResponse; + return ok(data.preferences); + } catch (error) { + return err(`failed to get preferences: ${error}`); + } } export async function putPreferences( - client: AtpClient, - prefs: Preferences + client: AtpClient, + prefs: Preferences ): Promise> { - const auth = client.user; - if (!auth) return err('not authenticated'); + const auth = client.user; + if (!auth) return err('not authenticated'); - try { - const response = await auth.atcute.handler( - '/xrpc/net.at-app.pet.ptr.nucleus.putPreferences', - { - method: 'POST', - headers: { - 'atproto-proxy': POCKET_PROXY, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ preferences: prefs }) - } - ); + try { + const response = await auth.atcute.handler('/xrpc/net.at-app.pet.ptr.nucleus.putPreferences', { + method: 'POST', + headers: { + 'atproto-proxy': POCKET_PROXY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ preferences: prefs }) + }); - if (!response.ok) { - const error = await response.text(); - return err(`failed to put preferences: ${error}`); - } + if (!response.ok) { + const error = await response.text(); + return err(`failed to put preferences: ${error}`); + } - return ok(null); - } catch (error) { - return err(`failed to put preferences: ${error}`); - } + return ok(null); + } catch (error) { + return err(`failed to put preferences: ${error}`); + } } diff --git a/.agents/skills/beads/agents/openai.yaml b/.agents/skills/beads/agents/openai.yaml --- a/.agents/skills/beads/agents/openai.yaml +++ b/.agents/skills/beads/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Beads" - short_description: "Project task tracking with bd" - default_prompt: "Use $beads to inspect ready work and manage durable project tasks." + display_name: 'Beads' + short_description: 'Project task tracking with bd' + default_prompt: 'Use $beads to inspect ready work and manage durable project tasks.'