BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: scroll state management for feeds & session restoration

+357 -48
+2
src-tauri/src/lib.rs
··· 24 24 25 25 app.manage(app_state); 26 26 27 + AppState::spawn_token_refresh_task(app.handle().clone()); 28 + 27 29 let app_handle = app.handle().clone(); 28 30 app.deep_link().on_open_url(move |event| { 29 31 for url in event.urls() {
+143 -5
src-tauri/src/state.rs
··· 1 1 use super::auth::{ 2 2 account_summaries, active_session_from_accounts, build_oauth_client, emit_account_switch, fetch_account_summary, 3 - remove_cached_session, restore_session_from_data, 3 + remove_cached_session, restore_session_from_data, ACCOUNT_SWITCHED_EVENT, 4 4 }; 5 5 use super::auth::{login_with_loopback, LazuriteOAuthClient, LazuriteOAuthSession}; 6 6 use super::auth::{PersistentAuthStore, StoredAccount}; ··· 12 12 use serde::Serialize; 13 13 use std::collections::HashMap; 14 14 use std::sync::{Arc, RwLock}; 15 - use tauri::AppHandle; 15 + use std::time::Duration; 16 + use tauri::{AppHandle, Emitter, Manager}; 17 + use tauri_plugin_log::log; 16 18 17 19 #[derive(Clone, Debug, Serialize)] 18 20 #[serde(rename_all = "camelCase")] ··· 48 50 49 51 impl AppState { 50 52 pub async fn bootstrap(db_pool: DbPool) -> Result<Self, AppError> { 53 + log::info!("bootstrapping application state"); 51 54 let auth_store = PersistentAuthStore::new(db_pool.clone()); 52 55 auth_store.prune_orphaned_sessions()?; 53 56 let oauth_client = build_oauth_client(auth_store.clone()); 54 57 let accounts = auth_store.load_accounts()?; 58 + log::info!("loaded {} stored account(s)", accounts.len()); 59 + 60 + let active = active_session_from_accounts(&accounts); 61 + if let Some(ref session) = active { 62 + log::info!("active account from database: {}", session.handle); 63 + } else { 64 + log::debug!("no active account found in database"); 65 + } 66 + 55 67 let app_state = Self { 56 68 auth_store, 57 69 oauth_client, 58 - active_session: RwLock::new(active_session_from_accounts(&accounts)), 70 + active_session: RwLock::new(active), 59 71 account_list: RwLock::new(account_summaries(&accounts)), 60 72 sessions: RwLock::new(HashMap::new()), 61 73 }; ··· 63 75 app_state.restore_sessions().await?; 64 76 app_state.refresh_account_cache()?; 65 77 78 + let final_active = app_state.current_active_session()?; 79 + if let Some(ref session) = final_active { 80 + log::info!("bootstrap complete, active session: {}", session.handle); 81 + } else { 82 + log::warn!("bootstrap complete, no active session (reauth may be required)"); 83 + } 84 + 66 85 Ok(app_state) 67 86 } 68 87 ··· 90 109 } 91 110 92 111 pub async fn login(&self, app: &AppHandle, identifier: String) -> Result<AccountSummary, AppError> { 112 + log::info!("starting login flow for {}", identifier.trim()); 93 113 let session = Arc::new(login_with_loopback(&self.oauth_client, identifier.trim()).await?); 94 114 let (did, session_id) = session.session_info().await; 95 115 let did = did.to_string(); ··· 116 136 self.refresh_account_cache()?; 117 137 emit_account_switch(app, self.current_active_session()?)?; 118 138 139 + log::info!("login complete for {}", account_summary.handle); 119 140 Ok(account_summary) 120 141 } 121 142 122 143 pub async fn logout(&self, app: &AppHandle, did: &str) -> Result<(), AppError> { 144 + log::info!("logging out account {did}"); 123 145 let account = self 124 146 .auth_store 125 147 .get_account(did)? ··· 137 159 } 138 160 139 161 pub async fn switch_account(&self, app: &AppHandle, did: &str) -> Result<(), AppError> { 162 + log::info!("switching to account {did}"); 140 163 let account = self 141 164 .auth_store 142 165 .get_account(did)? ··· 180 203 .get(&account.did) 181 204 .cloned() 182 205 { 206 + log::debug!("using cached session for {}", account.handle); 183 207 return Ok(existing); 184 208 } 185 209 ··· 189 213 190 214 let did = Did::new(&account.did)?; 191 215 let session = if refresh { 216 + log::info!("restoring session with token refresh for {}", account.handle); 192 217 Arc::new(self.oauth_client.restore(&did, session_id).await?) 193 218 } else { 219 + log::debug!("restoring session from persisted data for {}", account.handle); 194 220 let session_data = self.auth_store.get_session(&did, session_id).await?.ok_or_else(|| { 195 221 AppError::Validation(format!("missing persisted oauth session for account {}", account.did)) 196 222 })?; ··· 205 231 .map_err(|_| AppError::StatePoisoned("sessions"))? 206 232 .insert(account.did.clone(), session.clone()); 207 233 234 + log::info!("session restored successfully for {}", account.handle); 208 235 Ok(session) 209 236 } 210 237 211 238 async fn restore_sessions(&self) -> Result<(), AppError> { 212 239 let accounts = self.auth_store.load_accounts()?; 240 + log::info!("restoring sessions for {} account(s)", accounts.len()); 213 241 214 - for account in accounts { 242 + for account in &accounts { 215 243 if account.session_id.is_none() { 244 + log::debug!("skipping {} (no session_id)", account.handle); 216 245 continue; 217 246 } 218 247 219 - let restored = self.ensure_session(&account, account.active).await; 248 + log::debug!( 249 + "restoring session for {} (active={}, refresh={})", 250 + account.handle, 251 + account.active, 252 + account.active 253 + ); 254 + 255 + let restored = self.ensure_session(account, account.active).await; 220 256 if restored.is_ok() { 221 257 continue; 222 258 } 223 259 260 + if let Err(error) = restored { 261 + log::warn!("failed to restore session for {}: {error}", account.handle); 262 + } 263 + 224 264 remove_cached_session(&self.sessions, &account.did)?; 225 265 if account.active { 266 + log::warn!("clearing active flag for {} after restore failure", account.handle); 226 267 self.auth_store.clear_active_account()?; 227 268 } 228 269 } 229 270 230 271 Ok(()) 272 + } 273 + 274 + pub async fn refresh_active_token(&self, app: &AppHandle) -> Result<(), AppError> { 275 + let active = self 276 + .active_session 277 + .read() 278 + .map_err(|_| AppError::StatePoisoned("active_session"))? 279 + .clone(); 280 + 281 + let Some(active) = active else { 282 + return self.try_recover_session(app).await; 283 + }; 284 + 285 + let account = match self.auth_store.get_account(&active.did)? { 286 + Some(account) => account, 287 + None => return Ok(()), 288 + }; 289 + 290 + let session_id = match account.session_id.as_deref() { 291 + Some(id) => id, 292 + None => return Ok(()), 293 + }; 294 + 295 + remove_cached_session(&self.sessions, &active.did)?; 296 + 297 + let did = Did::new(&active.did)?; 298 + match self.oauth_client.restore(&did, session_id).await { 299 + Ok(session) => { 300 + self.sessions 301 + .write() 302 + .map_err(|_| AppError::StatePoisoned("sessions"))? 303 + .insert(active.did.clone(), Arc::new(session)); 304 + log::info!("token refresh succeeded for {}", active.handle); 305 + Ok(()) 306 + } 307 + Err(error) => { 308 + log::warn!("token refresh failed for {}: {error}", active.handle); 309 + self.auth_store.clear_active_account()?; 310 + self.refresh_account_cache()?; 311 + app.emit(ACCOUNT_SWITCHED_EVENT, None::<ActiveSession>)?; 312 + Err(AppError::validation(format!("refresh failed: {error}"))) 313 + } 314 + } 315 + } 316 + 317 + /// Attempt to recover a session when no account is currently active. 318 + /// This handles the case where bootstrap failed to restore a session 319 + /// (e.g. due to a transient network error) and the active flag was cleared. 320 + async fn try_recover_session(&self, app: &AppHandle) -> Result<(), AppError> { 321 + let accounts = self.auth_store.load_accounts()?; 322 + let candidate = accounts.iter().find(|a| a.session_id.is_some()); 323 + 324 + let Some(account) = candidate else { 325 + return Ok(()); 326 + }; 327 + 328 + let session_id = account.session_id.as_deref().unwrap(); 329 + let did = Did::new(&account.did)?; 330 + 331 + match self.oauth_client.restore(&did, session_id).await { 332 + Ok(session) => { 333 + self.sessions 334 + .write() 335 + .map_err(|_| AppError::StatePoisoned("sessions"))? 336 + .insert(account.did.clone(), Arc::new(session)); 337 + 338 + self.auth_store.set_active_account(&account.did)?; 339 + self.refresh_account_cache()?; 340 + 341 + log::info!("session recovery succeeded for {}", account.handle); 342 + emit_account_switch(app, self.current_active_session()?)?; 343 + Ok(()) 344 + } 345 + Err(error) => { 346 + log::warn!("session recovery failed for {}: {error}", account.handle); 347 + Err(AppError::validation(format!("recovery failed: {error}"))) 348 + } 349 + } 350 + } 351 + 352 + pub fn spawn_token_refresh_task(app: AppHandle) { 353 + const INITIAL_DELAY: Duration = Duration::from_secs(30); 354 + const REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); 355 + 356 + tauri::async_runtime::spawn(async move { 357 + // Short initial delay to quickly retry if bootstrap restore failed 358 + tokio::time::sleep(INITIAL_DELAY).await; 359 + 360 + loop { 361 + let state = app.state::<AppState>(); 362 + if let Err(error) = state.refresh_active_token(&app).await { 363 + log::warn!("background token refresh error: {error}"); 364 + } 365 + 366 + tokio::time::sleep(REFRESH_INTERVAL).await; 367 + } 368 + }); 231 369 } 232 370 }
+7 -4
src/App.tsx
··· 290 290 }, 291 291 ) { 292 292 const hasAccounts = () => props.accounts.length > 0; 293 + const displayAccount = () => props.activeAccount ?? (props.reauthNeeded ? props.accounts[0] ?? null : null); 294 + const displaySession = () => { 295 + const account = displayAccount(); 296 + return account ? { did: account.did, handle: account.handle } : null; 297 + }; 293 298 294 299 return ( 295 300 <Show ··· 309 314 <> 310 315 <HeaderPanel metaLabel={props.metaLabel} /> 311 316 <SessionSpotlight 312 - activeSession={props.activeAccount 313 - ? { did: props.activeAccount.did, handle: props.activeAccount.handle } 314 - : null} 315 - activeAccount={props.activeAccount} 317 + activeSession={displaySession()} 318 + activeAccount={displayAccount()} 316 319 bootstrapping={props.bootstrapping} 317 320 reauthNeeded={props.reauthNeeded} 318 321 onReauth={props.onReauth} />
+1 -1
src/components/Session.tsx
··· 16 16 export function SessionProfile(props: { session: ActiveSession; activeAccount: AccountSummary | null }) { 17 17 return ( 18 18 <div class="grid items-center gap-4 [align-content:start] grid-cols-[auto_minmax(0,1fr)]"> 19 - <AvatarBadge label={props.session.handle} tone="primary" /> 19 + <AvatarBadge label={props.session.handle} src={props.activeAccount?.avatar} tone="primary" /> 20 20 <div class="grid"> 21 21 <h2 class="m-0 text-[clamp(1.3rem,2vw,1.7rem)] tracking-[-0.02em]">{props.session.handle}</h2> 22 22 <p class="m-0 text-xs text-on-surface-variant">{props.session.did}</p>
+6 -5
src/components/account/AccountSwitcher.tsx
··· 1 1 import type { AccountSummary, ActiveSession } from "$/lib/types"; 2 - import { onCleanup, onMount, Show } from "solid-js"; 2 + import { createMemo, onCleanup, onMount, Show } from "solid-js"; 3 3 import { Motion, Presence } from "solid-motionone"; 4 4 import { ArrowIcon } from "../shared/Icon"; 5 5 import { SwitcherIdentity } from "./AccountSwitcherIdentity"; ··· 20 20 21 21 export function AccountSwitcher(props: AccountSwitcherProps) { 22 22 const isOpen = () => props.open; 23 + const staleAccount = createMemo(() => (!props.activeSession && props.accounts.length > 0 ? props.accounts[0] : null)); 23 24 let container: HTMLDivElement | undefined; 24 25 25 26 onMount(() => { ··· 64 65 keyed 65 66 fallback={ 66 67 <SwitcherIdentity 67 - avatar={null} 68 + avatar={staleAccount()?.avatar ?? null} 68 69 compact={props.compact} 69 - label="?" 70 - name="Sign in" 71 - meta="No account connected" 70 + label={staleAccount()?.handle ?? "?"} 71 + name={staleAccount()?.handle ?? "Sign in"} 72 + meta={staleAccount() ? "Session expired" : "No account connected"} 72 73 tone="muted" /> 73 74 }> 74 75 {(session) => (
+133
src/components/feeds/FeedWorkspace.test.tsx
··· 1 + import { fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { FeedWorkspace } from "./FeedWorkspace"; 4 + 5 + const invokeMock = vi.hoisted(() => vi.fn()); 6 + const listenMock = vi.hoisted(() => vi.fn()); 7 + 8 + vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 9 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 10 + 11 + type ObserverInstance = { 12 + callback: IntersectionObserverCallback; 13 + disconnect: ReturnType<typeof vi.fn>; 14 + observe: ReturnType<typeof vi.fn>; 15 + unobserve: ReturnType<typeof vi.fn>; 16 + }; 17 + 18 + const observers: ObserverInstance[] = []; 19 + 20 + const ACTIVE_SESSION = { did: "did:plc:alice", handle: "alice.test" } as const; 21 + 22 + function createFeedItem(id: string, text = `Post ${id}`) { 23 + return { 24 + post: { 25 + author: { did: `did:plc:${id}`, handle: `${id}.test`, displayName: `Author ${id}` }, 26 + cid: `cid-${id}`, 27 + indexedAt: "2026-03-28T12:00:00.000Z", 28 + likeCount: 0, 29 + record: { createdAt: "2026-03-28T12:00:00.000Z", text }, 30 + replyCount: 0, 31 + repostCount: 0, 32 + uri: `at://did:plc:${id}/app.bsky.feed.post/${id}`, 33 + viewer: {}, 34 + }, 35 + }; 36 + } 37 + 38 + function createDeferred<T>() { 39 + let resolve!: (value: T) => void; 40 + const promise = new Promise<T>((innerResolve) => { 41 + resolve = innerResolve; 42 + }); 43 + return { promise, resolve }; 44 + } 45 + 46 + async function flushMicrotasks() { 47 + await Promise.resolve(); 48 + await Promise.resolve(); 49 + } 50 + 51 + function triggerIntersection(index = 0) { 52 + const observer = observers[index]; 53 + if (!observer) { 54 + throw new Error(`missing intersection observer at index ${index}`); 55 + } 56 + 57 + observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver); 58 + } 59 + 60 + describe("FeedWorkspace", () => { 61 + beforeEach(() => { 62 + observers.length = 0; 63 + invokeMock.mockReset(); 64 + listenMock.mockReset(); 65 + listenMock.mockResolvedValue(() => {}); 66 + 67 + Object.defineProperty(globalThis, "IntersectionObserver", { 68 + configurable: true, 69 + value: class MockIntersectionObserver { 70 + callback: IntersectionObserverCallback; 71 + disconnect = vi.fn(); 72 + observe = vi.fn(); 73 + unobserve = vi.fn(); 74 + 75 + constructor(callback: IntersectionObserverCallback) { 76 + this.callback = callback; 77 + observers.push(this); 78 + } 79 + }, 80 + }); 81 + }); 82 + 83 + afterEach(() => { 84 + vi.restoreAllMocks(); 85 + }); 86 + 87 + it("keeps the latest scroll position when more feed items arrive", async () => { 88 + const nextPage = createDeferred<{ cursor: string | null; feed: Array<ReturnType<typeof createFeedItem>> }>(); 89 + 90 + invokeMock.mockImplementation((command: string, args: { cursor?: string | null }) => { 91 + if (command === "get_preferences") { 92 + return Promise.resolve({ 93 + savedFeeds: [{ id: "following", pinned: true, type: "timeline", value: "following" }], 94 + feedViewPrefs: [], 95 + }); 96 + } 97 + 98 + if (command === "get_timeline" && !args?.cursor) { 99 + return Promise.resolve({ cursor: "cursor-2", feed: [createFeedItem("1", "Page 1")] }); 100 + } 101 + 102 + if (command === "get_timeline" && args.cursor === "cursor-2") { 103 + return nextPage.promise; 104 + } 105 + 106 + throw new Error(`unexpected invoke: ${command}`); 107 + }); 108 + 109 + const { container } = render(() => ( 110 + <FeedWorkspace activeSession={ACTIVE_SESSION} onError={vi.fn()} onThreadRouteChange={vi.fn()} threadUri={null} /> 111 + )); 112 + 113 + await screen.findByText("Page 1"); 114 + 115 + const scroller = container.querySelector(".feed-scroll-region") as HTMLDivElement | null; 116 + expect(scroller).not.toBeNull(); 117 + 118 + scroller!.scrollTop = 120; 119 + fireEvent.scroll(scroller!); 120 + 121 + triggerIntersection(); 122 + 123 + scroller!.scrollTop = 260; 124 + fireEvent.scroll(scroller!); 125 + 126 + nextPage.resolve({ cursor: null, feed: [createFeedItem("2", "Page 2")] }); 127 + 128 + await screen.findByText("Page 2"); 129 + await flushMicrotasks(); 130 + 131 + expect(scroller!.scrollTop).toBe(260); 132 + }); 133 + });
+24 -17
src/components/feeds/FeedWorkspace.tsx
··· 27 27 import { escapeForRegex } from "$/lib/utils/text"; 28 28 import { invoke } from "@tauri-apps/api/core"; 29 29 import { listen } from "@tauri-apps/api/event"; 30 - import { createEffect, createMemo, For, onCleanup, onMount, type ParentProps, Show } from "solid-js"; 30 + import { createEffect, createMemo, For, onCleanup, onMount, type ParentProps, Show, untrack } from "solid-js"; 31 31 import { createStore, reconcile } from "solid-js/store"; 32 32 import { FeedChipAvatar } from "./FeedChipAvatar"; 33 33 import { FeedComposer } from "./FeedComposer"; ··· 42 42 createDefaultThreadState, 43 43 createInitialWorkspaceState, 44 44 DEFAULT_TIMELINE, 45 + getFeedScrollTop, 45 46 getNextFocusedIndex, 46 47 getNextFocusedScrollTop, 47 - updateFeedScrollState, 48 + updateFeedScrollTop, 48 49 upsertFeedViewPrefs, 49 50 } from "./workspace-state"; 50 51 ··· 121 122 setWorkspace("activeFeedId", feed.id); 122 123 } 123 124 124 - void ensureFeedLoaded(feed); 125 - const nextScrollTop = workspace.feedStates[feed.id]?.scrollTop ?? 0; 126 - queueMicrotask(() => { 127 - if (scroller && scroller.scrollTop !== nextScrollTop) { 128 - scroller.scrollTop = nextScrollTop; 129 - } 125 + untrack(() => { 126 + void ensureFeedLoaded(feed); 127 + const nextScrollTop = getFeedScrollTop(workspace.feedScrollTops, feed.id); 128 + queueMicrotask(() => { 129 + if (scroller && scroller.scrollTop !== nextScrollTop) { 130 + scroller.scrollTop = nextScrollTop; 131 + } 132 + }); 130 133 }); 131 134 }); 132 135 ··· 366 369 items, 367 370 loading: false, 368 371 loadingMore: false, 369 - scrollTop: append ? state.scrollTop : 0, 370 372 }); 371 373 } catch (error) { 372 374 setWorkspace("feedStates", feed.id, { ...state, error: String(error), loading: false, loadingMore: false }); ··· 395 397 function switchFeed(feedId: string) { 396 398 const current = activeFeed(); 397 399 if (current && scroller) { 398 - setWorkspace("feedStates", current.id, { 399 - ...(workspace.feedStates[current.id] ?? createDefaultFeedState()), 400 - scrollTop: scroller.scrollTop, 401 - }); 400 + const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, current.id, scroller.scrollTop); 401 + if (nextScrollTops) { 402 + setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 403 + } 402 404 } 403 405 404 406 setWorkspace("activeFeedId", feedId); ··· 469 471 await invoke<CreateRecordResult>("create_post", { embed, replyTo, text }); 470 472 resetComposer(); 471 473 props.onThreadRouteChange(null); 472 - await loadFeed(activeFeed(), false); 474 + const feed = activeFeed(); 475 + await loadFeed(feed, false); 476 + const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, feed.id, 0); 477 + if (nextScrollTops) { 478 + setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 479 + } 473 480 if (scroller) { 474 481 scroller.scrollTop = 0; 475 482 } ··· 648 655 }} 649 656 setScrollTop={(top) => { 650 657 const feedId = activeFeed().id; 651 - const nextState = updateFeedScrollState(workspace.feedStates[feedId], top); 652 - if (!nextState) { 658 + const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, feedId, top); 659 + if (!nextScrollTops) { 653 660 return; 654 661 } 655 662 656 - setWorkspace("feedStates", feedId, nextState); 663 + setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 657 664 }} 658 665 visibleItems={visibleItems()} /> 659 666
+1 -1
src/components/feeds/types.ts
··· 13 13 items: FeedViewPost[]; 14 14 loading: boolean; 15 15 loadingMore: boolean; 16 - scrollTop: number; 17 16 }; 18 17 19 18 export type ComposerState = { ··· 31 30 activeFeedId: string | null; 32 31 composer: ComposerState; 33 32 feedStates: Record<string, FeedState>; 33 + feedScrollTops: Record<string, number>; 34 34 focusedIndex: number; 35 35 generators: Record<string, FeedGeneratorView>; 36 36 likePendingByUri: Record<string, boolean>;
+17 -5
src/components/feeds/workspace-state.test.ts
··· 5 5 createDefaultFeedPref, 6 6 createDefaultFeedState, 7 7 DEFAULT_TIMELINE, 8 + getFeedScrollTop, 8 9 getNextFocusedIndex, 9 10 getNextFocusedScrollTop, 10 - updateFeedScrollState, 11 + updateFeedScrollTop, 11 12 upsertFeedViewPrefs, 12 13 } from "./workspace-state"; 13 14 ··· 63 64 expect(getNextFocusedIndex(0, "next", 0)).toBe(0); 64 65 }); 65 66 66 - it("avoids scroll-state writes when the scroll position is unchanged", () => { 67 - const state = createDefaultFeedState(); 68 - expect(updateFeedScrollState(state, 0)).toBeNull(); 69 - expect(updateFeedScrollState(state, 48)).toEqual({ ...state, scrollTop: 48 }); 67 + it("tracks scroll state outside the fetched feed payload", () => { 68 + expect(createDefaultFeedState()).toEqual({ 69 + cursor: null, 70 + error: null, 71 + items: [], 72 + loading: false, 73 + loadingMore: false, 74 + }); 75 + 76 + const scrollTops = { following: 24 }; 77 + expect(getFeedScrollTop(scrollTops, "following")).toBe(24); 78 + expect(getFeedScrollTop(scrollTops, "custom")).toBe(0); 79 + expect(updateFeedScrollTop(scrollTops, "following", 24)).toBeNull(); 80 + expect(updateFeedScrollTop(scrollTops, "following", 48)).toEqual({ following: 48 }); 81 + expect(updateFeedScrollTop(scrollTops, "custom", 64)).toEqual({ following: 24, custom: 64 }); 70 82 }); 71 83 72 84 it("computes focused-post scrolling without using browser focus", () => {
+13 -5
src/components/feeds/workspace-state.ts
··· 4 4 export const DEFAULT_TIMELINE: SavedFeedItem = { id: "following", type: "timeline", value: "following", pinned: true }; 5 5 6 6 export function createDefaultFeedState(): FeedState { 7 - return { cursor: null, error: null, items: [], loading: false, loadingMore: false, scrollTop: 0 }; 7 + return { cursor: null, error: null, items: [], loading: false, loadingMore: false }; 8 8 } 9 9 10 10 export const createDefaultThreadState = (): ThreadState => ({ data: null, error: null, loading: false, uri: null }); ··· 23 23 activeFeedId: null, 24 24 composer: { open: false, pending: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }, 25 25 feedStates: {}, 26 + feedScrollTops: {}, 26 27 focusedIndex: 0, 27 28 generators: {}, 28 29 likePendingByUri: {}, ··· 56 57 return Math.max(currentIndex - 1, 0); 57 58 } 58 59 59 - export function updateFeedScrollState(state: FeedState | undefined, scrollTop: number): FeedState | null { 60 - const currentState = state ?? createDefaultFeedState(); 61 - if (currentState.scrollTop === scrollTop) { 60 + export function getFeedScrollTop(feedScrollTops: Record<string, number>, feedId: string): number { 61 + return feedScrollTops[feedId] ?? 0; 62 + } 63 + 64 + export function updateFeedScrollTop( 65 + feedScrollTops: Record<string, number>, 66 + feedId: string, 67 + scrollTop: number, 68 + ): Record<string, number> | null { 69 + if (getFeedScrollTop(feedScrollTops, feedId) === scrollTop) { 62 70 return null; 63 71 } 64 72 65 - return { ...currentState, scrollTop }; 73 + return { ...feedScrollTops, [feedId]: scrollTop }; 66 74 } 67 75 68 76 export function getNextFocusedScrollTop(
+10 -5
src/components/panels/Header.tsx
··· 1 + import { LazuriteLogo } from "../Wordmark"; 2 + 1 3 type HeaderPanelProps = { metaLabel: string }; 2 4 3 5 export function HeaderPanel(props: HeaderPanelProps) { 4 6 return ( 5 7 <header class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start"> 6 - <div class="max-w-3xl"> 7 - <p class="overline-copy text-xs text-primary">Welcome</p> 8 - <h1 class="m-0 max-w-[11ch] text-balance text-[clamp(2.3rem,5vw,4.2rem)] leading-[0.94] tracking-[-0.03em] max-[760px]:text-[clamp(1.95rem,10vw,3.2rem)]"> 9 - Join the conversation. 10 - </h1> 8 + <div class="flex items-center gap-5"> 9 + <span class="grid shrink-0 place-items-center rounded-xl bg-white/4 p-3 text-primary shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 10 + <LazuriteLogo class="h-12 w-12" /> 11 + </span> 12 + <div class="grid gap-0.5"> 13 + <h1 class="m-0 text-[clamp(1.6rem,3vw,2.4rem)] font-semibold leading-[1.08] tracking-[-0.03em]">Lazurite</h1> 14 + <p class="m-0 text-xs text-on-surface-variant">Powered by Bluesky</p> 15 + </div> 11 16 </div> 12 17 <p class="overline-copy text-xs tracking-[0.18em] text-on-surface-variant xl:pt-2">{props.metaLabel}</p> 13 18 </header>