refactor: modernize notificationStream, jetstream, and pulsingPostId to Svelte 5 state
123 unmodified linesExpand all124125126127128129130131132133134135136137138139140141142140143144142143144145145146147148149150151149150151152153154152153154155156157158159160161162163164165162166167168169170171172173168169170174175176177172178179180181182183184185180181182186187188189190123 unmodified linesExpand all
# Project Guide & Tech Stack
## Tech Stack
- **Runtime**: Deno 2.x (use `deno install`, `deno task`, etc., NEVER use `npx` or `npm` directly unless necessary)- **Framework**: Svelte 5 (uses Runes like `$state`, `$derived`, `$effect`)- **Virtualization**: `@tanstack/svelte-virtual` (replaces old `@tutorlatin/svelte-tiny-virtual-list`)- **Styling**: Tailwind CSS v4
## Virtualization Guidelines (TanStack Virtual)
When implementing list/timeline virtualization, follow these patterns to prevent bugs and Svelte 5 compilation warnings:
1. **Reactivity & Options Updates**: To avoid Svelte 5 "capturing local state" warnings, initialize the virtualizer with `count: 0` and update it reactively using `$effect` and `$virtualizer.setOptions(...)`:
```typescript let parentRef = $state<HTMLDivElement | null>(null);
const virtualizer = createVirtualizer({ count: 0, getScrollElement: () => parentRef, estimateSize: () => 44, overscan: 5 count: 0, getScrollElement: () => parentRef, estimateSize: () => 44, overscan: 5 });
$effect(() => { $virtualizer.setOptions({ count: items.length, getScrollElement: () => parentRef, estimateSize: () => 44, overscan: 5 }); $virtualizer.setOptions({ count: items.length, getScrollElement: () => parentRef, estimateSize: () => 44, overscan: 5 }); }); ```
2. **Dynamic Height Measurement**: Always provide `getItemKey` using a unique identifier (like post/thread URI) to prevent cache collisions when the list changes. Use a Svelte action `use:measureElement` to measure DOM nodes dynamically:
```typescript const measureElement = (node: HTMLElement) => { $virtualizer.measureElement(node); $virtualizer.measureElement(node); }; ```
And in the markup, set `data-index` and use the action:
```svelte <div data-index={virtualItem.index} use:measureElement style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({virtualItem.start}px);" data-index={virtualItem.index} use:measureElement style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({virtualItem.start}px);" > <!-- content --> <!-- content --> </div> ```
3. **Reactive Scrolling**: To scroll to a target index reactively, use an `$effect`: ```typescript $effect(() => { if (scrollToIndex !== undefined) { $virtualizer.scrollToIndex(scrollToIndex); } if (scrollToIndex !== undefined) { $virtualizer.scrollToIndex(scrollToIndex); } }); ```4 unmodified lines567894 unmodified lines{"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."}}{"id":"int-3ef022dd","kind":"field_change","created_at":"2026-06-29T15:44:24.7334854Z","actor":"dawn","issue_id":"nucleus-huf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Converted global notificationStream, jetstream, and pulsingPostId stores to Svelte 5 state objects."}}123454 unmodified lines{"_type":"issue","id":"nucleus-huf","title":"Modernize state stores to Svelte 5 Runes","description":"Convert notificationStream, jetstream, and pulsingPostId from Svelte 4 writables to Svelte 5 state/derived runes, updating components and routes.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T15:41:52Z","created_by":"dawn","updated_at":"2026-06-29T15:44:25Z","started_at":"2026-06-29T15:42:02Z","closed_at":"2026-06-29T15:44:25Z","close_reason":"Converted global notificationStream, jetstream, and pulsingPostId stores to Svelte 5 state objects.","dependency_count":0,"dependent_count":0,"comment_count":0}{"_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}4 unmodified lines26 unmodified lines2728293031313233343576 unmodified lines1121131141151161161171181191205 unmodified lines126127128129130131130131132133134135143 unmodified linesExpand all279280281282283283284285286287230 unmodified linesExpand all26 unmodified lines clients } from '$lib/state.svelte'; import type { PostWithUri } from '$lib/at/fetch'; import { onMount, type Snippet } from 'svelte'; import { derived } from 'svelte/store';
import Dropdown from './Dropdown.svelte'; import { settings } from '$lib/settings'; import RichText from './RichText.svelte'; import { getRelativeTime } from '$lib/date';76 unmodified lines
const postId = $derived( `timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}` ); const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); const isPulsing = $derived(pulsingPostId.current === postId);
const scrollToAndPulse = (targetUri: ResourceUri) => { const targetId = `timeline-post-${targetUri}-0`; const element = document.getElementById(targetId);5 unmodified lines document.documentElement.style.setProperty( '--nucleus-selected-post', generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) ); pulsingPostId.set(targetId); setTimeout(() => pulsingPostId.set(null), 1200); pulsingPostId.current = targetId; setTimeout(() => (pulsingPostId.current = null), 1200); }, 400); };
let actionsOpen = $state(false);143 unmodified linesExpand all id="timeline-post-{post.value.uri}-{quoteDepth}" oncontextmenu={handleRightClick} class=" group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all {$isPulsing ? 'animate-pulse-highlight' : ''} {isPulsing ? 'animate-pulse-highlight' : ''} {isOnPostComposer ? 'backdrop-brightness-20' : ''} " style=" background: {color}{isOnPostComposer230 unmodified linesExpand all1123427 unmodified lines32333435373836373839404142434445195 unmodified linesExpand all241242243244242245246247248249250251873 unmodified linesExpand allimport { get, writable } from 'svelte/store';import { AtpClient, setRecordCache, type NotificationsStream,27 unmodified linesimport { Router } from './router.svelte';import { accounts, type Account } from './accounts';import { getPreferences, putPreferences, type Preferences } from './at/pocket';
export const notificationStream = writable<NotificationsStream | null>(null);export const jetstream = writable<JetstreamSubscription | null>(null);export const notificationStream = { current: $state<NotificationsStream | null>(null)};export const jetstream = { current: $state<JetstreamSubscription | null>(null)};
export const profiles = new SvelteMap<Did, AppBskyActorProfile.Main>();export const handles = new SvelteMap<Did, Handle>();
195 unmodified linesExpand all } });};
export const pulsingPostId = writable<string | null>(null);export const pulsingPostId = { current: $state<string | null>(null)};
export const viewClient = new AtpClient();export const clients = new SvelteMap<Did, AtpClient>();
873 unmodified linesExpand all23 unmodified lines24252627282829303177 unmodified lines10911011111211411311411511711811912012112212312412512612711611711811912012112212312412512613013113213312712812913013113213313414213513613713813923 unmodified lines163164165166174167168169170171172173174175176177178179204 unmodified linesExpand all23 unmodified lines fetchInitial, loadAccountPreferences, resetFeed } from '$lib/state.svelte'; import { get } from 'svelte/store'; import Icon from '@iconify/svelte'; import { sessions } from '$lib/at/oauth'; import type { AtprotoDid, Did } from '@atcute/lexicons/syntax'; import type { PageProps } from './+page';77 unmodified lines
window.addEventListener('scroll', handleScroll);
accounts.subscribe((newAccounts) => { get(notificationStream)?.stop(); notificationStream.current?.stop(); // jetstream.set(null); if (newAccounts.length === 0) return; notificationStream.set( streamNotifications( newAccounts.map((account) => account.did), 'app.bsky.feed.post:reply.parent.uri', 'app.bsky.feed.post:embed.record.record.uri', 'app.bsky.feed.post:embed.record.uri', 'app.bsky.feed.repost:subject.uri', 'app.bsky.feed.like:subject.uri', 'app.bsky.graph.follow:subject', 'app.bsky.graph.block:subject' ) notificationStream.current = streamNotifications( newAccounts.map((account) => account.did), 'app.bsky.feed.post:reply.parent.uri', 'app.bsky.feed.post:embed.record.record.uri', 'app.bsky.feed.post:embed.record.uri', 'app.bsky.feed.repost:subject.uri', 'app.bsky.feed.like:subject.uri', 'app.bsky.graph.follow:subject', 'app.bsky.graph.block:subject' ); }); notificationStream.subscribe((stream) => { if (!stream) return; stream.listen(handleNotification); });
console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`); const jetstreamSub = new JetstreamSubscription({ url: $settings.endpoints.jetstream, wantedCollections: ['app.bsky.feed.post'], // this is here because if wantedDids is zero jetstream will send all events wantedDids: ['did:web:guestbook.gaze.systems'] }); jetstream.set(jetstreamSub); jetstream.current = jetstreamSub;
(async () => { console.log('polling for jetstream...'); for await (const event of jetstreamSub) handleJetstreamEvent(event);23 unmodified lines const followDids = follows.values().flatMap((followMap) => followMap.keys()); const accountDids = $accounts.values().map((account) => account.did); wantedDids.push(...followDids, ...accountDids); // console.log('updating jetstream options:', wantedDids); $jetstream?.updateOptions({ wantedDids }); jetstream.current?.updateOptions({ wantedDids }); });
$effect(() => { const stream = notificationStream.current; if (stream) { stream.listen(handleNotification); } });</script>
{#snippet appButton( onClick: () => void,204 unmodified linesExpand all