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.

refactor: remove motion from feed navigation

+1474 -1048
+140 -44
src-tauri/src/feed.rs
··· 33 33 use jacquard::IntoStatic; 34 34 use serde::{Deserialize, Serialize}; 35 35 use std::sync::Arc; 36 + use tauri_plugin_log::log; 36 37 37 38 async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 38 39 let did = state 39 40 .active_session 40 41 .read() 41 - .map_err(|_| AppError::StatePoisoned("active_session"))? 42 + .map_err(|error| { 43 + log::error!("active_session poisoned: {error}"); 44 + AppError::StatePoisoned("active_session") 45 + })? 42 46 .as_ref() 43 - .ok_or_else(|| AppError::Validation("no active account".into()))? 47 + .ok_or_else(|| { 48 + log::error!("no active account"); 49 + AppError::Validation("no active account".into()) 50 + })? 44 51 .did 45 52 .clone(); 46 53 47 54 state 48 55 .sessions 49 56 .read() 50 - .map_err(|_| AppError::StatePoisoned("sessions"))? 57 + .map_err(|error| { 58 + log::error!("sessions poisoned: {error}"); 59 + AppError::StatePoisoned("sessions") 60 + })? 51 61 .get(&did) 52 62 .cloned() 53 - .ok_or_else(|| AppError::Validation("session not found for active account".into())) 63 + .ok_or_else(|| { 64 + log::error!("session not found for active account"); 65 + AppError::Validation("session not found for active account".into()) 66 + }) 54 67 } 55 68 56 69 fn active_did(state: &AppState) -> Result<String> { 57 70 state 58 71 .active_session 59 72 .read() 60 - .map_err(|_| AppError::StatePoisoned("active_session"))? 73 + .map_err(|error| { 74 + log::error!("active_session poisoned: {error}"); 75 + AppError::StatePoisoned("active_session") 76 + })? 61 77 .as_ref() 62 - .ok_or_else(|| AppError::Validation("no active account".into())) 78 + .ok_or_else(|| { 79 + log::error!("no active account"); 80 + AppError::Validation("no active account".into()) 81 + }) 63 82 .map(|s| s.did.clone()) 64 83 } 65 84 ··· 126 145 127 146 for item in items { 128 147 match item { 129 - PreferencesItem::SavedFeedsPrefV2(pref) => { 130 - saved_feeds = extract_saved_feeds(pref); 131 - } 132 - PreferencesItem::FeedViewPref(pref) => { 133 - feed_view_prefs.push(extract_feed_view_pref(pref)); 134 - } 135 - _ => {} 148 + PreferencesItem::SavedFeedsPrefV2(pref) => saved_feeds = extract_saved_feeds(pref), 149 + PreferencesItem::FeedViewPref(pref) => feed_view_prefs.push(extract_feed_view_pref(pref)), 150 + _ => (), 136 151 } 137 152 } 138 153 ··· 148 163 let output = session 149 164 .send(GetPreferences) 150 165 .await 151 - .map_err(|_| AppError::validation("getPreferences"))? 166 + .map_err(|error| { 167 + log::error!("fetch Preferences error: {error}"); 168 + AppError::validation("fetch Preferences error") 169 + })? 152 170 .into_output() 153 - .map_err(|_| AppError::validation("getPreferences output"))?; 171 + .map_err(|error| { 172 + log::error!("fetch Preferences output error: {error}"); 173 + AppError::validation("fetch Preferences output error") 174 + })?; 154 175 155 176 Ok(output.preferences.into_iter().map(IntoStatic::into_static).collect()) 156 177 } ··· 159 180 session 160 181 .send(PutPreferences::new().preferences(items).build()) 161 182 .await 162 - .map_err(|_| AppError::validation("putPreferences"))? 183 + .map_err(|error| { 184 + log::error!("putPreferences error: {error}"); 185 + AppError::validation("putPreferences error") 186 + })? 163 187 .into_output() 164 - .map_err(|_| AppError::validation("putPreferences output"))?; 188 + .map_err(|error| { 189 + log::error!("putPreferences output error: {error}"); 190 + AppError::validation("putPreferences output error") 191 + })?; 165 192 166 193 Ok(()) 167 194 } ··· 265 292 266 293 let session = get_session(state).await?; 267 294 let parsed: std::result::Result<Vec<AtUri<'_>>, _> = uris.iter().map(|u| AtUri::new(u)).collect(); 268 - let feeds = parsed.map_err(|_| AppError::validation("invalid feed URI"))?; 295 + let feeds = parsed.map_err(|error| { 296 + log::warn!("invalid feed URI in get_feed_generators input: {:?}", error); 297 + AppError::validation("invalid feed URI") 298 + })?; 269 299 270 300 let output = session 271 301 .send(GetFeedGenerators::new().feeds(feeds).build()) 272 302 .await 273 - .map_err(|_| AppError::validation("getFeedGenerators"))? 303 + .map_err(|error| { 304 + log::error!("getFeedGenerators error: {error}"); 305 + AppError::validation("getFeedGenerators error") 306 + })? 274 307 .into_output() 275 - .map_err(|_| AppError::validation("getFeedGenerators output"))?; 308 + .map_err(|error| { 309 + log::error!("getFeedGenerators output error: {error}"); 310 + AppError::validation("getFeedGenerators output error") 311 + })?; 276 312 277 313 serde_json::to_value(&output).map_err(AppError::from) 278 314 } ··· 287 323 let output = session 288 324 .send(req.build()) 289 325 .await 290 - .map_err(|_| AppError::validation("getTimeline"))? 326 + .map_err(|error| { 327 + log::error!("getTimeline error: {error}"); 328 + AppError::validation("getTimeline") 329 + })? 291 330 .into_output() 292 - .map_err(|_| AppError::validation("getTimeline output"))?; 331 + .map_err(|error| { 332 + log::error!("getTimeline output error: {error}"); 333 + AppError::validation("getTimeline output") 334 + })?; 293 335 294 336 serde_json::to_value(&output).map_err(AppError::from) 295 337 } ··· 305 347 let output = session 306 348 .send(req.build()) 307 349 .await 308 - .map_err(|_| AppError::validation("getFeed"))? 350 + .map_err(|error| { 351 + log::error!("getFeed error: {error}"); 352 + AppError::validation("getFeed") 353 + })? 309 354 .into_output() 310 - .map_err(|_| AppError::validation("getFeed output"))?; 355 + .map_err(|error| { 356 + log::error!("getFeed output error: {error}"); 357 + AppError::validation("getFeed output") 358 + })?; 311 359 312 360 serde_json::to_value(&output).map_err(AppError::from) 313 361 } ··· 325 373 let output = session 326 374 .send(req.build()) 327 375 .await 328 - .map_err(|_| AppError::validation("getListFeed"))? 376 + .map_err(|error| { 377 + log::error!("getListFeed error: {error}"); 378 + AppError::validation("getListFeed") 379 + })? 329 380 .into_output() 330 - .map_err(|_| AppError::validation("getListFeed output"))?; 381 + .map_err(|error| { 382 + log::error!("getListFeed output error: {error}"); 383 + AppError::validation("getListFeed output") 384 + })?; 331 385 332 386 serde_json::to_value(&output).map_err(AppError::from) 333 387 } ··· 339 393 let output = session 340 394 .send(GetPostThread::new().uri(post_uri).build()) 341 395 .await 342 - .map_err(|_| AppError::validation("getPostThread"))? 396 + .map_err(|error| { 397 + log::error!("getPostThread error: {error}"); 398 + AppError::validation("getPostThread") 399 + })? 343 400 .into_output() 344 - .map_err(|_| AppError::validation("getPostThread output"))?; 401 + .map_err(|error| { 402 + log::error!("getPostThread output error: {error}"); 403 + AppError::validation("getPostThread output") 404 + })?; 345 405 346 406 serde_json::to_value(&output).map_err(AppError::from) 347 407 } ··· 357 417 let output = session 358 418 .send(req.build()) 359 419 .await 360 - .map_err(|_| AppError::validation("getAuthorFeed"))? 420 + .map_err(|error| { 421 + log::error!("getAuthorFeed error: {error}"); 422 + AppError::validation("getAuthorFeed") 423 + })? 361 424 .into_output() 362 - .map_err(|_| AppError::validation("getAuthorFeed output"))?; 425 + .map_err(|error| { 426 + log::error!("getAuthorFeed output error: {error}"); 427 + AppError::validation("getAuthorFeed output") 428 + })?; 363 429 364 430 serde_json::to_value(&output).map_err(AppError::from) 365 431 } ··· 375 441 let did = active_did(state)?; 376 442 377 443 let resolver = JacquardResolver::default(); 378 - let rich = richtext::parse(&text) 379 - .build_async(&resolver) 380 - .await 381 - .map_err(|_| AppError::validation("richtext parse"))?; 444 + let rich = richtext::parse(&text).build_async(&resolver).await.map_err(|error| { 445 + log::error!("richtext parse error: {error}"); 446 + AppError::validation("failed to parse post text") 447 + })?; 382 448 383 449 let mut post = Post::new().text(rich.text).created_at(Datetime::now()); 384 450 ··· 413 479 .build(), 414 480 ) 415 481 .await 416 - .map_err(|_| AppError::validation("createRecord (post)"))? 482 + .map_err(|error| { 483 + log::error!("createRecord (post) error: {error}"); 484 + AppError::validation("failed to create record (post)") 485 + })? 417 486 .into_output() 418 - .map_err(|_| AppError::validation("createRecord (post) output"))?; 487 + .map_err(|error| { 488 + log::error!("createRecord (post) output error: {error}"); 489 + AppError::validation("failed to create record (post) output") 490 + })?; 419 491 420 492 Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) 421 493 } ··· 446 518 .build(), 447 519 ) 448 520 .await 449 - .map_err(|_| AppError::validation("createRecord (like)"))? 521 + .map_err(|error| { 522 + log::error!("createRecord (like) error: {error}"); 523 + AppError::validation("failed to create record (like)") 524 + })? 450 525 .into_output() 451 - .map_err(|_| AppError::validation("createRecord (like) output"))?; 526 + .map_err(|error| { 527 + log::error!("createRecord (like) output error: {error}"); 528 + AppError::validation("failed to create record (like) output") 529 + })?; 452 530 453 531 Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) 454 532 } ··· 470 548 session 471 549 .send(DeleteRecord::new().repo(repo).collection(collection).rkey(rkey).build()) 472 550 .await 473 - .map_err(|_| AppError::validation("deleteRecord (unlike)"))? 551 + .map_err(|error| { 552 + log::error!("deleteRecord (unlike) error: {error}"); 553 + AppError::validation("failed to delete record (unlike)") 554 + })? 474 555 .into_output() 475 - .map_err(|_| AppError::validation("deleteRecord (unlike) output"))?; 556 + .map_err(|error| { 557 + log::error!("deleteRecord (unlike) output error: {error}"); 558 + AppError::validation("failed to delete record (unlike) output") 559 + })?; 476 560 477 561 Ok(()) 478 562 } ··· 503 587 .build(), 504 588 ) 505 589 .await 506 - .map_err(|_| AppError::validation("createRecord (repost)"))? 590 + .map_err(|error| { 591 + log::error!("createRecord (repost) error: {error}"); 592 + AppError::validation("failed to create record (repost)") 593 + })? 507 594 .into_output() 508 - .map_err(|_| AppError::validation("createRecord (repost) output"))?; 595 + .map_err(|error| { 596 + log::error!("createRecord (repost) output error: {error}"); 597 + AppError::validation("failed to create record (repost) output") 598 + })?; 509 599 510 600 Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) 511 601 } ··· 528 618 session 529 619 .send(DeleteRecord::new().repo(repo).collection(collection).rkey(rkey).build()) 530 620 .await 531 - .map_err(|_| AppError::validation("deleteRecord (unrepost)"))? 621 + .map_err(|error| { 622 + log::error!("deleteRecord (unrepost) error: {error}"); 623 + AppError::validation("failed to delete record (unrepost)") 624 + })? 532 625 .into_output() 533 - .map_err(|_| AppError::validation("deleteRecord (unrepost) output"))?; 626 + .map_err(|error| { 627 + log::error!("deleteRecord (unrepost) output error: {error}"); 628 + AppError::validation("failed to delete record (unrepost) output") 629 + })?; 534 630 535 631 Ok(()) 536 632 }
+13 -8
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, ACCOUNT_SWITCHED_EVENT, 3 + login_with_loopback, remove_cached_session, restore_session_from_data, 4 4 }; 5 - use super::auth::{login_with_loopback, LazuriteOAuthClient, LazuriteOAuthSession}; 6 - use super::auth::{PersistentAuthStore, StoredAccount}; 5 + use super::auth::{LazuriteOAuthClient, LazuriteOAuthSession, PersistentAuthStore, StoredAccount}; 7 6 use super::db::DbPool; 8 7 use super::error::AppError; 9 8 use jacquard::oauth::authstore::ClientAuthStore; ··· 299 298 Ok(session) => { 300 299 self.sessions 301 300 .write() 302 - .map_err(|_| AppError::StatePoisoned("sessions"))? 301 + .map_err(|error| { 302 + log::error!("failed to acquire sessions write lock: {error}"); 303 + AppError::StatePoisoned("sessions") 304 + })? 303 305 .insert(active.did.clone(), Arc::new(session)); 304 306 log::info!("token refresh succeeded for {}", active.handle); 305 307 Ok(()) ··· 308 310 log::warn!("token refresh failed for {}: {error}", active.handle); 309 311 self.auth_store.clear_active_account()?; 310 312 self.refresh_account_cache()?; 311 - app.emit(ACCOUNT_SWITCHED_EVENT, None::<ActiveSession>)?; 313 + app.emit(super::auth::ACCOUNT_SWITCHED_EVENT, None::<ActiveSession>)?; 312 314 Err(AppError::validation(format!("refresh failed: {error}"))) 313 315 } 314 316 } ··· 349 351 } 350 352 } 351 353 354 + /// Starts a background task to refresh the active session's token at regular intervals. 355 + /// 356 + /// Adds a short initial delay to quickly retry if bootstrap restore failed 352 357 pub fn spawn_token_refresh_task(app: AppHandle) { 353 358 const INITIAL_DELAY: Duration = Duration::from_secs(30); 354 359 const REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); 355 360 356 361 tauri::async_runtime::spawn(async move { 357 - // Short initial delay to quickly retry if bootstrap restore failed 358 362 tokio::time::sleep(INITIAL_DELAY).await; 359 363 360 364 loop { 361 365 let state = app.state::<AppState>(); 362 - if let Err(error) = state.refresh_active_token(&app).await { 363 - log::warn!("background token refresh error: {error}"); 366 + match state.refresh_active_token(&app).await { 367 + Ok(_) => log::debug!("background token refresh successful"), 368 + Err(error) => log::warn!("background token refresh error: {error}"), 364 369 } 365 370 366 371 tokio::time::sleep(REFRESH_INTERVAL).await;
+15 -11
src/App.tsx
··· 1 - import { invoke } from "@tauri-apps/api/core"; 1 + import { 2 + getAppBootstrap, 3 + login as loginRequest, 4 + logout as logoutRequest, 5 + switchAccount as switchAccountRequest, 6 + } from "$/lib/api/app"; 2 7 import { listen } from "@tauri-apps/api/event"; 3 8 import { createEffect, createMemo, onCleanup, onMount, Show, startTransition } from "solid-js"; 4 9 import { createStore } from "solid-js/store"; ··· 12 17 import { HeaderPanel } from "./components/panels/Header"; 13 18 import { SessionSpotlight } from "./components/Session"; 14 19 import { ErrorToast } from "./components/shared/ErrorToast"; 15 - import type { AccountSummary, ActiveSession, AppBootstrap } from "./lib/types"; 20 + import type { AccountSummary, ActiveSession } from "./lib/types"; 16 21 import { AppRouter } from "./router"; 17 22 18 23 const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; ··· 80 85 setApp("bootstrapping", true); 81 86 82 87 try { 83 - const payload = await invoke<AppBootstrap>("get_app_bootstrap"); 88 + const payload = await getAppBootstrap(); 84 89 startTransition(() => { 85 90 setApp("activeSession", payload.activeSession); 86 91 setApp("accounts", payload.accountList); ··· 120 125 121 126 setApp("loggingIn", true); 122 127 try { 123 - await invoke("login", { handle: trimmed }); 128 + await loginRequest(trimmed); 124 129 setApp("loginValue", ""); 125 130 closeSwitcher(); 126 131 await loadBootstrap(); ··· 135 140 async function switchAccount(did: string) { 136 141 setApp("switchingDid", did); 137 142 try { 138 - await invoke("switch_account", { did }); 143 + await switchAccountRequest(did); 139 144 closeSwitcher(); 140 145 await loadBootstrap(); 141 146 } catch (error) { ··· 149 154 async function logout(did: string) { 150 155 setApp("logoutDid", did); 151 156 try { 152 - await invoke("logout", { did }); 157 + await logoutRequest(did); 153 158 closeSwitcher(); 154 159 await loadBootstrap(); 155 160 } catch (error) { ··· 215 220 logoutDid={app.logoutDid} 216 221 narrow={app.narrowViewport} 217 222 openSwitcher={app.showSwitcher} 223 + onCloseSwitcher={closeSwitcher} 218 224 switchingDid={app.switchingDid} 219 225 onLogout={(did) => void logout(did)} 220 226 onSwitch={(did) => void switchAccount(did)} ··· 243 249 <AuthWorkspace 244 250 accounts={app.accounts} 245 251 activeAccount={activeAccount()} 252 + activeSession={app.activeSession} 246 253 activeDid={app.activeSession?.did ?? null} 247 254 bootstrapping={app.bootstrapping} 248 255 loggingIn={app.loggingIn} ··· 273 280 props: { 274 281 accounts: AccountSummary[]; 275 282 activeAccount: AccountSummary | null; 283 + activeSession: ActiveSession | null; 276 284 activeDid: string | null; 277 285 bootstrapping: boolean; 278 286 loggingIn: boolean; ··· 291 299 ) { 292 300 const hasAccounts = () => props.accounts.length > 0; 293 301 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 - }; 298 302 299 303 return ( 300 304 <Show ··· 314 318 <> 315 319 <HeaderPanel metaLabel={props.metaLabel} /> 316 320 <SessionSpotlight 317 - activeSession={displaySession()} 321 + activeSession={props.activeSession} 318 322 activeAccount={displayAccount()} 319 323 bootstrapping={props.bootstrapping} 320 324 reauthNeeded={props.reauthNeeded}
+2
src/components/AppRail.tsx
··· 50 50 logoutDid: string | null; 51 51 narrow: boolean; 52 52 openSwitcher: boolean; 53 + onCloseSwitcher: () => void; 53 54 switchingDid: string | null; 54 55 onLogout: (did: string) => void; 55 56 onSwitch: (did: string) => void; ··· 72 73 compact={props.collapsed && !props.narrow} 73 74 logoutDid={props.logoutDid} 74 75 open={props.openSwitcher} 76 + onClose={props.onCloseSwitcher} 75 77 onToggle={props.onToggleSwitcher} 76 78 onSwitch={props.onSwitch} 77 79 onLogout={props.onLogout} />
+2 -2
src/components/LoginPanel.tsx
··· 247 247 return ( 248 248 <Show when={props.open && props.suggestions.length > 0}> 249 249 <div 250 - class="absolute inset-x-0 top-[calc(100%+0.7rem)] z-10 rounded-[1.35rem] bg-(--surface-container-highest) p-2.5 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px]" 250 + class="absolute inset-x-0 top-[calc(100%+0.7rem)] z-10 rounded-3xl bg-(--surface-container-highest) p-2.5 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px]" 251 251 id="login-suggestions" 252 252 role="listbox"> 253 253 <p class="px-2 pb-2 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant">Suggested handles</p> ··· 272 272 ) { 273 273 return ( 274 274 <button 275 - class="grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-[1.05rem] border-0 bg-transparent px-3 py-2.5 text-left transition duration-150 ease-out hover:bg-white/6" 275 + class="grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-1xl border-0 bg-transparent px-3 py-2.5 text-left transition duration-150 ease-out hover:bg-white/6" 276 276 classList={{ "bg-white/7 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]": props.active }} 277 277 id={props.id} 278 278 type="button"
+15
src/components/Session.test.tsx
··· 38 38 expect(screen.getByText("Ready")).toBeInTheDocument(); 39 39 }); 40 40 41 + it("shows expired account state when reauth is needed", () => { 42 + render(() => ( 43 + <SessionSpotlight 44 + activeSession={null} 45 + activeAccount={{ active: false, did: "did:plc:alice", handle: "alice.test", pdsUrl: "https://pds.example.com" }} 46 + bootstrapping={false} 47 + reauthNeeded 48 + onReauth={vi.fn()} /> 49 + )); 50 + 51 + expect(screen.getByText("Expired")).toBeInTheDocument(); 52 + expect(screen.getByText("alice.test")).toBeInTheDocument(); 53 + expect(screen.getByText("Stored account")).toBeInTheDocument(); 54 + }); 55 + 41 56 it("shows Reconnecting status when bootstrapping", () => { 42 57 render(() => ( 43 58 <SessionSpotlight
+35 -3
src/components/Session.tsx
··· 4 4 import { AvatarBadge } from "./AvatarBadge"; 5 5 import { ProfileSkeleton } from "./ProfileSkeleton"; 6 6 import { ReauthBanner } from "./ReauthBanner"; 7 + 7 8 export function SessionEmptyState() { 8 9 return ( 9 10 <div class="grid"> 10 11 <h2 class="m-0 text-[clamp(1.4rem,2vw,1.85rem)] leading-[1.08] tracking-[-0.03em]">No account connected yet.</h2> 11 12 <p class="m-0 text-xs leading-[1.55] text-on-surface-variant">Connect your Bluesky account to start exploring.</p> 13 + </div> 14 + ); 15 + } 16 + 17 + export function SessionExpiredState(props: { account: AccountSummary }) { 18 + return ( 19 + <div class="grid items-center gap-4 [align-content:start] grid-cols-[auto_minmax(0,1fr)]"> 20 + <AvatarBadge label={props.account.handle || props.account.did} src={props.account.avatar} tone="muted" /> 21 + <div class="grid"> 22 + <h2 class="m-0 text-[clamp(1.3rem,2vw,1.7rem)] tracking-[-0.02em]"> 23 + {props.account.handle || props.account.did} 24 + </h2> 25 + <p class="m-0 text-xs text-on-surface-variant">Stored account</p> 26 + </div> 27 + <p class="m-0 text-xs text-on-surface-variant">{props.account.pdsUrl || "PDS unavailable"}</p> 12 28 </div> 13 29 ); 14 30 } ··· 48 64 return "Connected"; 49 65 } 50 66 67 + if (props.reauthNeeded && props.activeAccount) { 68 + return "Expired"; 69 + } 70 + 51 71 return "Ready"; 52 72 }); 53 73 return ( ··· 60 80 <SessionBody 61 81 activeSession={props.activeSession} 62 82 activeAccount={props.activeAccount} 63 - bootstrapping={props.bootstrapping} /> 83 + bootstrapping={props.bootstrapping} 84 + reauthNeeded={props.reauthNeeded} /> 64 85 65 86 <Presence> 66 87 <Show when={props.reauthNeeded}> ··· 72 93 } 73 94 74 95 export function SessionBody( 75 - props: { activeSession: ActiveSession | null; activeAccount: AccountSummary | null; bootstrapping: boolean }, 96 + props: { 97 + activeSession: ActiveSession | null; 98 + activeAccount: AccountSummary | null; 99 + bootstrapping: boolean; 100 + reauthNeeded: boolean; 101 + }, 76 102 ) { 77 103 return ( 78 104 <Show when={!props.bootstrapping} fallback={<ProfileSkeleton />}> 79 - <Show when={props.activeSession} fallback={<SessionEmptyState />}> 105 + <Show 106 + when={props.activeSession} 107 + fallback={ 108 + <Show when={props.reauthNeeded && props.activeAccount} fallback={<SessionEmptyState />}> 109 + {(account) => <SessionExpiredState account={account()} />} 110 + </Show> 111 + }> 80 112 {(session) => <SessionProfile session={session()} activeAccount={props.activeAccount} />} 81 113 </Show> 82 114 </Show>
+59
src/components/account/AccountSwitcher.test.tsx
··· 1 + import { fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { AccountSwitcher } from "./AccountSwitcher"; 4 + 5 + const ACCOUNT = { 6 + active: false, 7 + avatar: "https://example.com/avatar.png", 8 + did: "did:plc:alice", 9 + handle: "alice.test", 10 + pdsUrl: "https://pds.example.com", 11 + } as const; 12 + 13 + describe("AccountSwitcher", () => { 14 + it("renders the stored account when no active session exists", () => { 15 + render(() => ( 16 + <AccountSwitcher 17 + activeAccount={null} 18 + activeSession={null} 19 + accounts={[ACCOUNT]} 20 + busyDid={null} 21 + logoutDid={null} 22 + open={false} 23 + onClose={vi.fn()} 24 + onLogout={vi.fn()} 25 + onSwitch={vi.fn()} 26 + onToggle={vi.fn()} /> 27 + )); 28 + 29 + expect(screen.getByText("alice.test")).toBeInTheDocument(); 30 + expect(screen.getByText("Session expired")).toBeInTheDocument(); 31 + }); 32 + 33 + it("closes the menu on outside pointerdown instead of toggling", () => { 34 + const onClose = vi.fn(); 35 + const onToggle = vi.fn(); 36 + 37 + render(() => ( 38 + <> 39 + <AccountSwitcher 40 + activeAccount={ACCOUNT} 41 + activeSession={{ did: ACCOUNT.did, handle: ACCOUNT.handle }} 42 + accounts={[ACCOUNT]} 43 + busyDid={null} 44 + logoutDid={null} 45 + open 46 + onClose={onClose} 47 + onLogout={vi.fn()} 48 + onSwitch={vi.fn()} 49 + onToggle={onToggle} /> 50 + <div data-testid="outside">outside</div> 51 + </> 52 + )); 53 + 54 + fireEvent.pointerDown(screen.getByTestId("outside")); 55 + 56 + expect(onClose).toHaveBeenCalledTimes(1); 57 + expect(onToggle).not.toHaveBeenCalled(); 58 + }); 59 + });
+52 -49
src/components/account/AccountSwitcher.tsx
··· 1 1 import type { AccountSummary, ActiveSession } from "$/lib/types"; 2 2 import { createMemo, onCleanup, onMount, Show } from "solid-js"; 3 - import { Motion, Presence } from "solid-motionone"; 4 3 import { ArrowIcon } from "../shared/Icon"; 5 4 import { SwitcherIdentity } from "./AccountSwitcherIdentity"; 6 5 import { AccountSwitcherMenuList } from "./AccountSwitcherMenuList"; ··· 13 12 compact?: boolean; 14 13 logoutDid: string | null; 15 14 open: boolean; 15 + onClose: () => void; 16 16 onToggle: () => void; 17 17 onSwitch: (did: string) => void; 18 18 onLogout: (did: string) => void; ··· 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 + const previewAccount = createMemo(() => props.activeAccount ?? props.accounts[0] ?? null); 24 + const identity = createMemo(() => { 25 + if (props.activeSession) { 26 + return { 27 + avatar: props.activeAccount?.avatar ?? null, 28 + label: props.activeSession.handle, 29 + meta: "Current account", 30 + name: props.activeSession.handle, 31 + tone: "primary" as const, 32 + }; 33 + } 34 + 35 + const account = previewAccount(); 36 + if (account) { 37 + return { 38 + avatar: account.avatar ?? null, 39 + label: account.handle || account.did, 40 + meta: "Session expired", 41 + name: account.handle || account.did, 42 + tone: "muted" as const, 43 + }; 44 + } 45 + 46 + return { avatar: null, label: "?", meta: "No account connected", name: "Sign in", tone: "muted" as const }; 47 + }); 24 48 let container: HTMLDivElement | undefined; 25 49 26 50 onMount(() => { ··· 34 58 return; 35 59 } 36 60 37 - props.onToggle(); 61 + props.onClose(); 38 62 }, 39 63 }; 40 64 ··· 58 82 type="button" 59 83 aria-haspopup="menu" 60 84 aria-expanded={props.open} 61 - aria-label={props.activeSession ? `Current account ${props.activeSession.handle}` : "Sign in"} 85 + aria-label={props.activeSession ? `Current account ${props.activeSession.handle}` : identity().name} 62 86 onClick={() => props.onToggle()}> 63 - <Show 64 - when={props.activeSession} 65 - keyed 66 - fallback={ 67 - <SwitcherIdentity 68 - avatar={staleAccount()?.avatar ?? null} 69 - compact={props.compact} 70 - label={staleAccount()?.handle ?? "?"} 71 - name={staleAccount()?.handle ?? "Sign in"} 72 - meta={staleAccount() ? "Session expired" : "No account connected"} 73 - tone="muted" /> 74 - }> 75 - {(session) => ( 76 - <SwitcherIdentity 77 - avatar={props.activeAccount?.avatar} 78 - compact={props.compact} 79 - label={session.handle} 80 - name={session.handle} 81 - meta="Current account" 82 - tone="primary" /> 83 - )} 84 - </Show> 87 + <SwitcherIdentity 88 + avatar={identity().avatar} 89 + compact={props.compact} 90 + label={identity().label} 91 + meta={identity().meta} 92 + name={identity().name} 93 + tone={identity().tone} /> 85 94 <span 86 95 class="absolute flex items-center text-on-surface-variant" 87 96 classList={{ ··· 96 105 </span> 97 106 </button> 98 107 99 - <Presence> 100 - <Show when={props.open}> 101 - <Motion.div 102 - class="absolute rounded-2xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 103 - classList={{ 104 - "inset-x-0 bottom-[calc(100%+0.75rem)]": !props.compact, 105 - "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": !!props.compact, 106 - }} 107 - role="menu" 108 - initial={{ opacity: 0, y: 10, scale: 0.98 }} 109 - animate={{ opacity: 1, y: 0, scale: 1 }} 110 - exit={{ opacity: 0, y: 8, scale: 0.98 }} 111 - transition={{ duration: 0.2 }}> 112 - <p class="overline-copy text-[0.68rem] text-on-surface-variant">Accounts</p> 113 - <AccountSwitcherMenuList 114 - accounts={props.accounts} 115 - busyDid={props.busyDid} 116 - logoutDid={props.logoutDid} 117 - onSwitch={props.onSwitch} 118 - onLogout={props.onLogout} /> 119 - </Motion.div> 120 - </Show> 121 - </Presence> 108 + <Show when={props.open}> 109 + <div 110 + class="absolute z-20 rounded-2xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 111 + classList={{ 112 + "inset-x-0 bottom-[calc(100%+0.75rem)]": !props.compact, 113 + "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": !!props.compact, 114 + }} 115 + role="menu"> 116 + <p class="overline-copy text-[0.68rem] text-on-surface-variant">Accounts</p> 117 + <AccountSwitcherMenuList 118 + accounts={props.accounts} 119 + busyDid={props.busyDid} 120 + logoutDid={props.logoutDid} 121 + onSwitch={props.onSwitch} 122 + onLogout={props.onLogout} /> 123 + </div> 124 + </Show> 122 125 </div> 123 126 ); 124 127 }
+2 -9
src/components/account/AccountSwitcherIdentity.tsx
··· 1 1 import { Show } from "solid-js"; 2 - import { Motion } from "solid-motionone"; 3 2 import { AvatarBadge } from "../AvatarBadge"; 4 3 5 4 export function SwitcherIdentity( ··· 13 12 }, 14 13 ) { 15 14 return ( 16 - <Motion.div 17 - class="flex min-w-0 items-center gap-3" 18 - classList={{ "justify-center": !!props.compact }} 19 - initial={{ opacity: 0, y: 8, scale: 0.96 }} 20 - animate={{ opacity: 1, y: 0, scale: 1 }} 21 - exit={{ opacity: 0, y: -6, scale: 0.94 }} 22 - transition={{ duration: 0.24 }}> 15 + <div class="flex min-w-0 items-center gap-3" classList={{ "justify-center": !!props.compact }}> 23 16 <AvatarBadge label={props.label} src={props.avatar} tone={props.tone} /> 24 17 <Show when={!props.compact}> 25 18 <div class="grid min-w-0"> ··· 27 20 <span class="text-xs text-on-surface-variant">{props.meta}</span> 28 21 </div> 29 22 </Show> 30 - </Motion.div> 23 + </div> 31 24 ); 32 25 }
+2 -2
src/components/feeds/FeedComposer.tsx
··· 236 236 return ( 237 237 <Show when={props.post}> 238 238 {(post) => ( 239 - <div class="mt-4 rounded-[1.25rem] bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 239 + <div class="mt-4 rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 240 240 <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Quote preview</p> 241 241 <p class="mt-2 text-sm font-semibold text-on-surface"> 242 242 {getDisplayName(post().author)} ··· 257 257 const suggestions = () => props.suggestions.slice(0, 12); 258 258 return ( 259 259 <Show when={props.suggestions.length > 0}> 260 - <div class="mt-4 rounded-[1.25rem] bg-black/35 p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 260 + <div class="mt-4 rounded-2xl bg-black/35 p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 261 261 <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Suggestions</p> 262 262 <div class="mt-3 max-h-44 overflow-y-auto overscroll-contain pr-1"> 263 263 <div class="grid gap-2">
+84
src/components/feeds/FeedContent.test.tsx
··· 1 + import { render } from "@solidjs/testing-library"; 2 + import { createSignal } from "solid-js"; 3 + import { describe, expect, it, vi } from "vitest"; 4 + import { FeedContent } from "./FeedContent"; 5 + 6 + function createFeedItem(id: string) { 7 + return { 8 + post: { 9 + author: { did: `did:plc:${id}`, handle: `${id}.test`, displayName: `Author ${id}` }, 10 + cid: `cid-${id}`, 11 + indexedAt: "2026-03-28T12:00:00.000Z", 12 + likeCount: 0, 13 + record: { createdAt: "2026-03-28T12:00:00.000Z", text: `Post ${id}` }, 14 + replyCount: 0, 15 + repostCount: 0, 16 + uri: `at://did:plc:${id}/app.bsky.feed.post/${id}`, 17 + viewer: {}, 18 + }, 19 + }; 20 + } 21 + 22 + const baseProps = { 23 + activeFeedState: { 24 + cursor: null, 25 + error: null, 26 + items: [createFeedItem("1"), createFeedItem("2")], 27 + loading: false, 28 + loadingMore: false, 29 + }, 30 + likePendingByUri: {}, 31 + likePulseUri: null, 32 + onFocusIndex: vi.fn(), 33 + onLike: vi.fn(async () => {}), 34 + onOpenThread: vi.fn(async () => {}), 35 + onQuote: vi.fn(), 36 + onReply: vi.fn(), 37 + onRepost: vi.fn(async () => {}), 38 + postRefs: new Map<string, HTMLElement>(), 39 + repostPendingByUri: {}, 40 + repostPulseUri: null, 41 + sentinelRef: vi.fn(), 42 + visibleItems: [createFeedItem("1"), createFeedItem("2")], 43 + }; 44 + 45 + describe("FeedContent", () => { 46 + it("keeps the active feed container stable for focus-only updates", () => { 47 + let setFocusedIndex!: (value: number) => void; 48 + 49 + const { container } = render(() => { 50 + const [focusedIndex, updateFocusedIndex] = createSignal(0); 51 + setFocusedIndex = updateFocusedIndex; 52 + 53 + return <FeedContent {...baseProps} activeFeedId="following" focusedIndex={focusedIndex()} />; 54 + }); 55 + 56 + const before = container.querySelector("[data-feed-id='following']"); 57 + expect(before).not.toBeNull(); 58 + 59 + setFocusedIndex(1); 60 + 61 + const after = container.querySelector("[data-feed-id='following']"); 62 + expect(after).toBe(before); 63 + }); 64 + 65 + it("updates the rendered feed id without tearing down the list container", () => { 66 + let setActiveFeedId!: (value: string) => void; 67 + 68 + const { container } = render(() => { 69 + const [activeFeedId, updateActiveFeedId] = createSignal("following"); 70 + setActiveFeedId = updateActiveFeedId; 71 + 72 + return <FeedContent {...baseProps} activeFeedId={activeFeedId()} focusedIndex={0} />; 73 + }); 74 + 75 + const before = container.querySelector("[data-feed-id='following']"); 76 + expect(before).not.toBeNull(); 77 + 78 + setActiveFeedId("custom"); 79 + 80 + const after = container.querySelector("[data-feed-id='custom']"); 81 + expect(after).not.toBeNull(); 82 + expect(after).toBe(before); 83 + }); 84 + });
+22 -34
src/components/feeds/FeedContent.tsx
··· 1 1 import { getReplyRootPost } from "$/lib/feeds"; 2 2 import type { FeedViewPost, PostView } from "$/lib/types"; 3 3 import { For, Show } from "solid-js"; 4 - import { Motion, Presence } from "solid-motionone"; 5 4 import { EmptyFeedState, FeedSkeleton, LoadingMoreIndicator } from "./FeedEmpty"; 6 5 import { PostCard } from "./PostCard"; 7 6 import type { FeedState } from "./types"; ··· 49 48 }, 50 49 ) { 51 50 return ( 52 - <Presence exitBeforeEnter> 53 - <For each={[props.activeFeedId]}> 54 - {() => ( 55 - <Motion.div 56 - class="grid min-w-0 gap-3" 57 - initial={{ opacity: 0 }} 58 - animate={{ opacity: 1 }} 59 - exit={{ opacity: 0 }} 60 - transition={{ duration: 0.2 }}> 61 - <FeedStatus activeFeedState={props.activeFeedState} visibleItems={props.visibleItems} /> 62 - <For each={props.visibleItems}> 63 - {(item, index) => ( 64 - <PostCard 65 - focused={props.focusedIndex === index()} 66 - item={item} 67 - likePending={!!props.likePendingByUri[item.post.uri]} 68 - onFocus={() => props.onFocusIndex(index())} 69 - onLike={() => void props.onLike(item.post)} 70 - onOpenThread={() => void props.onOpenThread(item.post.uri)} 71 - onQuote={() => props.onQuote(item.post)} 72 - onReply={() => props.onReply(item.post, getReplyRootPost(item))} 73 - onRepost={() => void props.onRepost(item.post)} 74 - post={item.post} 75 - pulseLike={props.likePulseUri === item.post.uri} 76 - pulseRepost={props.repostPulseUri === item.post.uri} 77 - registerRef={(element) => props.postRefs.set(item.post.uri, element)} 78 - repostPending={!!props.repostPendingByUri[item.post.uri]} /> 79 - )} 80 - </For> 81 - <div ref={(element) => props.sentinelRef(element)} /> 82 - <LoadingMoreIndicator loading={!!props.activeFeedState?.loadingMore} /> 83 - </Motion.div> 51 + <div class="grid min-w-0 gap-3" data-feed-id={props.activeFeedId}> 52 + <FeedStatus activeFeedState={props.activeFeedState} visibleItems={props.visibleItems} /> 53 + <For each={props.visibleItems}> 54 + {(item, index) => ( 55 + <PostCard 56 + focused={props.focusedIndex === index()} 57 + item={item} 58 + likePending={!!props.likePendingByUri[item.post.uri]} 59 + onFocus={() => props.onFocusIndex(index())} 60 + onLike={() => void props.onLike(item.post)} 61 + onOpenThread={() => void props.onOpenThread(item.post.uri)} 62 + onQuote={() => props.onQuote(item.post)} 63 + onReply={() => props.onReply(item.post, getReplyRootPost(item))} 64 + onRepost={() => void props.onRepost(item.post)} 65 + post={item.post} 66 + pulseLike={props.likePulseUri === item.post.uri} 67 + pulseRepost={props.repostPulseUri === item.post.uri} 68 + registerRef={(element) => props.postRefs.set(item.post.uri, element)} 69 + repostPending={!!props.repostPendingByUri[item.post.uri]} /> 84 70 )} 85 71 </For> 86 - </Presence> 72 + <div ref={(element) => props.sentinelRef(element)} /> 73 + <LoadingMoreIndicator loading={!!props.activeFeedState?.loadingMore} /> 74 + </div> 87 75 ); 88 76 }
+7 -7
src/components/feeds/FeedDrawer.tsx
··· 69 69 return ( 70 70 <Show when={props.pinnedFeeds.length > 0}> 71 71 <div class="mt-6"> 72 - <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Pinned Feeds</p> 72 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Pinned Feeds</p> 73 73 <div class="mt-3 grid gap-2"> 74 74 <For each={props.pinnedFeeds}> 75 75 {(feed, index) => ( ··· 102 102 return ( 103 103 <Show when={props.drawerFeeds.length > 0}> 104 104 <div class="mt-6"> 105 - <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Saved Feeds</p> 105 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Saved Feeds</p> 106 106 <div class="mt-3 grid gap-2"> 107 107 <For each={props.drawerFeeds}> 108 108 {(feed) => ( ··· 124 124 <div class="flex items-center justify-between"> 125 125 <div> 126 126 <p class="m-0 text-[1rem] font-semibold text-on-surface">Saved Feeds</p> 127 - <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Unpinned drawer</p> 127 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">All Saved Feeds</p> 128 128 </div> 129 129 <button 130 130 class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" ··· 150 150 }, 151 151 ) { 152 152 return ( 153 - <div class="flex items-center gap-2 rounded-[1.25rem] bg-white/4 px-3 py-3 transition duration-150 ease-out hover:bg-white/6"> 153 + <div class="flex items-center gap-2 rounded-2xl bg-white/4 px-3 py-3 transition duration-150 ease-out hover:bg-white/6"> 154 154 <button class="flex min-w-0 flex-1 items-center gap-3 text-left" type="button" onClick={() => props.onSelect()}> 155 155 <FeedChipAvatar feed={props.feed} generator={props.generator} /> 156 156 <div class="min-w-0 flex-1"> 157 157 <p class="m-0 truncate text-[0.88rem] font-semibold text-on-surface"> 158 158 {getFeedName(props.feed, props.generator?.displayName)} 159 159 </p> 160 - <p class="m-0 break-all text-[0.74rem] text-on-surface-variant">{props.feed.value}</p> 160 + <p class="m-0 break-all text-xs text-on-surface-variant">{props.feed.value}</p> 161 161 </div> 162 162 </button> 163 163 <div class="flex items-center gap-1"> ··· 193 193 props: { feed: SavedFeedItem; generator?: FeedGeneratorView; onSelect: () => void; onPin: () => void }, 194 194 ) { 195 195 return ( 196 - <div class="flex items-center gap-2 rounded-[1.25rem] bg-white/4 px-3 py-3 transition duration-150 ease-out hover:bg-white/6"> 196 + <div class="flex items-center gap-2 rounded-2xl bg-white/4 px-3 py-3 transition duration-150 ease-out hover:bg-white/6"> 197 197 <button class="flex min-w-0 flex-1 items-center gap-3 text-left" type="button" onClick={() => props.onSelect()}> 198 198 <FeedChipAvatar feed={props.feed} generator={props.generator} /> 199 199 <div class="min-w-0 flex-1"> 200 200 <p class="m-0 truncate text-[0.88rem] font-semibold text-on-surface"> 201 201 {getFeedName(props.feed, props.generator?.displayName)} 202 202 </p> 203 - <p class="m-0 break-all text-[0.74rem] text-on-surface-variant">{props.feed.value}</p> 203 + <p class="m-0 break-all text-xs text-on-surface-variant">{props.feed.value}</p> 204 204 </div> 205 205 </button> 206 206 <button
+1 -1
src/components/feeds/FeedEmpty.tsx
··· 14 14 15 15 export function EmptyFeedState() { 16 16 return ( 17 - <div class="rounded-[1.6rem] bg-white/3 p-8 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 17 + <div class="rounded-3xl bg-white/3 p-8 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 18 18 <p class="m-0 text-[1rem] font-semibold text-on-surface">Nothing to show yet</p> 19 19 <p class="mt-2 text-sm leading-[1.6] text-on-surface-variant"> 20 20 This feed is empty with the current filters. Try another tab or loosen the display settings.
+62 -863
src/components/feeds/FeedWorkspace.tsx
··· 1 - import { 2 - applyFeedPreferences, 3 - extractHandles, 4 - extractHashtags, 5 - getFeedCommand, 6 - getFeedName, 7 - getReplyRootPost, 8 - parseFeedGeneratorsResponse, 9 - parseFeedResponse, 10 - parseThreadResponse, 11 - patchFeedItems, 12 - patchThreadNode, 13 - toStrongRef, 14 - } from "$/lib/feeds"; 15 - import type { 16 - ActiveSession, 17 - CreateRecordResult, 18 - EmbedInput, 19 - FeedGeneratorView, 20 - FeedViewPrefItem, 21 - PostView, 22 - ReplyRefInput, 23 - SavedFeedItem, 24 - UserPreferences, 25 - } from "$/lib/types"; 26 - import { shouldIgnoreKey } from "$/lib/utils/events"; 27 - import { escapeForRegex } from "$/lib/utils/text"; 28 - import { invoke } from "@tauri-apps/api/core"; 29 - import { listen } from "@tauri-apps/api/event"; 30 - import { createEffect, createMemo, For, onCleanup, onMount, type ParentProps, Show, untrack } from "solid-js"; 31 - import { createStore, reconcile } from "solid-js/store"; 32 - import { FeedChipAvatar } from "./FeedChipAvatar"; 33 1 import { FeedComposer } from "./FeedComposer"; 34 2 import { SavedFeedsDrawer } from "./FeedDrawer"; 35 3 import { FeedPane } from "./FeedPane"; 4 + import { FeedWorkspaceSidebar } from "./FeedWorkspaceSidebar"; 36 5 import { ThreadPanel } from "./ThreadPanel"; 37 - import type { FeedWorkspaceState } from "./types"; 38 - import { 39 - buildLocalPrefs, 40 - createDefaultFeedPref, 41 - createDefaultFeedState, 42 - createDefaultThreadState, 43 - createInitialWorkspaceState, 44 - DEFAULT_TIMELINE, 45 - getFeedScrollTop, 46 - getNextFocusedIndex, 47 - getNextFocusedScrollTop, 48 - updateFeedScrollTop, 49 - upsertFeedViewPrefs, 50 - } from "./workspace-state"; 51 - 52 - type FeedWorkspaceProps = { 53 - activeSession: ActiveSession; 54 - onError: (message: string) => void; 55 - onThreadRouteChange: (uri: string | null) => void; 56 - threadUri: string | null; 57 - }; 58 - 59 - const DEFAULT_LIMIT = 30; 6 + import { type FeedWorkspaceProps, useFeedWorkspaceController } from "./useFeedWorkspaceController"; 60 7 61 8 export function FeedWorkspace(props: FeedWorkspaceProps) { 62 - const [workspace, setWorkspace] = createStore<FeedWorkspaceState>(createInitialWorkspaceState()); 63 - 64 - let scroller: HTMLDivElement | undefined; 65 - let sentinel: HTMLDivElement | undefined; 66 - const postRefs = new Map<string, HTMLElement>(); 67 - let lastFocusedUri: string | null = null; 68 - 69 - const savedFeeds = createMemo(() => { 70 - const stored = workspace.preferences?.savedFeeds ?? []; 71 - return stored.length > 0 ? stored : [DEFAULT_TIMELINE]; 72 - }); 73 - const pinnedFeeds = createMemo(() => { 74 - const pinned = savedFeeds().filter((feed) => feed.pinned); 75 - return pinned.length > 0 ? pinned : [DEFAULT_TIMELINE]; 76 - }); 77 - const drawerFeeds = createMemo(() => savedFeeds().filter((feed) => !feed.pinned)); 78 - const activeFeed = createMemo(() => { 79 - const feedId = workspace.activeFeedId; 80 - return savedFeeds().find((feed) => feed.id === feedId) ?? pinnedFeeds()[0] ?? DEFAULT_TIMELINE; 81 - }); 82 - const activePref = createMemo(() => { 83 - const feed = activeFeed(); 84 - return workspace.localPrefs[feed.value] ?? createDefaultFeedPref(feed); 85 - }); 86 - const activeFeedState = createMemo(() => workspace.feedStates[activeFeed().id]); 87 - const visibleItems = createMemo(() => applyFeedPreferences(activeFeedState()?.items ?? [], activePref())); 88 - const composerToken = createMemo(() => { 89 - const match = /(^|\s)([@#][^\s@#]*)$/u.exec(workspace.composer.text); 90 - return match?.[2] ?? null; 91 - }); 92 - const composerSuggestions = createMemo(() => { 93 - const token = composerToken(); 94 - if (!token) { 95 - return []; 96 - } 97 - 98 - const posts = visibleItems().map((item) => item.post); 99 - if (token.startsWith("@")) { 100 - return extractHandles(posts, props.activeSession.handle).filter((handle) => 101 - handle.toLowerCase().startsWith(token.toLowerCase()) 102 - ).map((label) => ({ label, type: "handle" as const })); 103 - } 104 - 105 - return extractHashtags(posts).filter((tag) => tag.toLowerCase().startsWith(token.toLowerCase())).map((label) => ({ 106 - label, 107 - type: "hashtag" as const, 108 - })); 109 - }); 110 - 111 - createEffect(() => { 112 - void bootstrapFeeds(); 113 - }); 114 - 115 - createEffect(() => { 116 - const feed = activeFeed(); 117 - if (!feed) { 118 - return; 119 - } 120 - 121 - if (workspace.activeFeedId !== feed.id) { 122 - setWorkspace("activeFeedId", feed.id); 123 - } 124 - 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 - }); 133 - }); 134 - }); 135 - 136 - createEffect(() => { 137 - const uri = props.threadUri; 138 - if (!uri) { 139 - if (workspace.thread.uri || workspace.thread.data || workspace.thread.error || workspace.thread.loading) { 140 - setWorkspace("thread", reconcile(createDefaultThreadState())); 141 - } 142 - return; 143 - } 144 - 145 - if (workspace.thread.uri === uri && (workspace.thread.data || workspace.thread.error || workspace.thread.loading)) { 146 - return; 147 - } 148 - 149 - void loadThread(uri); 150 - }); 151 - 152 - createEffect(() => { 153 - const items = visibleItems(); 154 - if (items.length === 0) { 155 - setWorkspace("focusedIndex", 0); 156 - return; 157 - } 158 - 159 - setWorkspace("focusedIndex", (current) => Math.min(current, items.length - 1)); 160 - }); 161 - 162 - createEffect(() => { 163 - const item = visibleItems()[workspace.focusedIndex]; 164 - if (!item) { 165 - lastFocusedUri = null; 166 - return; 167 - } 168 - 169 - if (lastFocusedUri === item.post.uri) { 170 - return; 171 - } 172 - 173 - lastFocusedUri = item.post.uri; 174 - queueMicrotask(() => { 175 - if (!scroller) { 176 - return; 177 - } 178 - 179 - const element = postRefs.get(item.post.uri); 180 - if (!element?.isConnected) { 181 - return; 182 - } 183 - 184 - const scrollerRect = scroller.getBoundingClientRect(); 185 - const elementRect = element.getBoundingClientRect(); 186 - const itemTop = elementRect.top - scrollerRect.top + scroller.scrollTop; 187 - const nextScrollTop = getNextFocusedScrollTop( 188 - scroller.scrollTop, 189 - scroller.clientHeight, 190 - itemTop, 191 - element.offsetHeight, 192 - ); 193 - 194 - if (nextScrollTop !== null && scroller.scrollTop !== nextScrollTop) { 195 - scroller.scrollTop = nextScrollTop; 196 - } 197 - }); 198 - }); 199 - 200 - createEffect(() => { 201 - const root = scroller; 202 - const currentSentinel = sentinel; 203 - const feed = activeFeed(); 204 - if (!root || !currentSentinel || !feed) { 205 - return; 206 - } 207 - 208 - const observer = new IntersectionObserver((entries) => { 209 - const entry = entries[0]; 210 - if (!entry?.isIntersecting) { 211 - return; 212 - } 213 - 214 - const state = workspace.feedStates[feed.id]; 215 - if (state?.cursor && !state.loading && !state.loadingMore) { 216 - void loadFeed(feed, true); 217 - } 218 - }, { root, threshold: 0.15 }); 219 - 220 - observer.observe(currentSentinel); 221 - onCleanup(() => observer.disconnect()); 222 - }); 223 - 224 - function handleGlobalKeydown(event: KeyboardEvent) { 225 - if (workspace.composer.open || shouldIgnoreKey(event)) { 226 - return; 227 - } 228 - 229 - const tabs = pinnedFeeds(); 230 - if (/^[1-9]$/.test(event.key)) { 231 - const index = Number(event.key) - 1; 232 - const target = tabs[index]; 233 - if (target) { 234 - event.preventDefault(); 235 - switchFeed(target.id); 236 - } 237 - return; 238 - } 239 - 240 - if (event.key === "n") { 241 - event.preventDefault(); 242 - openComposer(); 243 - return; 244 - } 245 - 246 - const items = visibleItems(); 247 - if (items.length === 0) { 248 - return; 249 - } 250 - 251 - if (event.key === "j" || event.key === "k") { 252 - event.preventDefault(); 253 - setWorkspace("focusedIndex", (current) => { 254 - if (event.key === "j") { 255 - return getNextFocusedIndex(current, "next", items.length); 256 - } 257 - 258 - return getNextFocusedIndex(current, "previous", items.length); 259 - }); 260 - return; 261 - } 262 - 263 - const item = items[workspace.focusedIndex]; 264 - if (!item) { 265 - return; 266 - } 267 - 268 - switch (event.key) { 269 - case "l": { 270 - event.preventDefault(); 271 - void toggleLike(item.post); 272 - break; 273 - } 274 - case "r": { 275 - event.preventDefault(); 276 - openReplyComposer(item.post, getReplyRootPost(item)); 277 - break; 278 - } 279 - case "t": { 280 - event.preventDefault(); 281 - void toggleRepost(item.post); 282 - break; 283 - } 284 - case "o": 285 - case "Enter": { 286 - event.preventDefault(); 287 - void openThread(item.post.uri); 288 - break; 289 - } 290 - default: { 291 - break; 292 - } 293 - } 294 - } 295 - 296 - onMount(() => { 297 - globalThis.addEventListener("keydown", handleGlobalKeydown); 298 - 299 - let unlistenComposer: (() => void) | undefined; 300 - void listen("composer:open", () => { 301 - openComposer(); 302 - }).then((dispose) => { 303 - unlistenComposer = dispose; 304 - }); 305 - 306 - onCleanup(() => { 307 - globalThis.removeEventListener("keydown", handleGlobalKeydown); 308 - unlistenComposer?.(); 309 - }); 310 - }); 311 - 312 - async function bootstrapFeeds() { 313 - const currentDid = props.activeSession.did; 314 - setWorkspace(reconcile(createInitialWorkspaceState())); 315 - 316 - try { 317 - const nextPreferences = await invoke<UserPreferences>("get_preferences"); 318 - if (currentDid !== props.activeSession.did) { 319 - return; 320 - } 321 - 322 - setWorkspace("preferences", nextPreferences); 323 - setWorkspace("localPrefs", reconcile(buildLocalPrefs(nextPreferences))); 324 - 325 - const uris = [ 326 - ...new Set(nextPreferences.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value)), 327 - ]; 328 - if (uris.length > 0) { 329 - const hydrated = parseFeedGeneratorsResponse(await invoke("get_feed_generators", { uris })); 330 - setWorkspace( 331 - "generators", 332 - reconcile(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))), 333 - ); 334 - } 335 - 336 - const nextActive = nextPreferences.savedFeeds.find((feed) => feed.pinned) ?? nextPreferences.savedFeeds[0] 337 - ?? DEFAULT_TIMELINE; 338 - setWorkspace("activeFeedId", nextActive.id); 339 - } catch (error) { 340 - props.onError(`Failed to load feeds: ${String(error)}`); 341 - } 342 - } 343 - 344 - async function ensureFeedLoaded(feed: SavedFeedItem) { 345 - const state = workspace.feedStates[feed.id]; 346 - if (state?.loading || state?.loadingMore || state?.items.length) { 347 - return; 348 - } 349 - 350 - await loadFeed(feed, false); 351 - } 352 - 353 - async function loadFeed(feed: SavedFeedItem, append: boolean) { 354 - const state = workspace.feedStates[feed.id] ?? createDefaultFeedState(); 355 - 356 - if (append) { 357 - setWorkspace("feedStates", feed.id, { ...state, error: null, loadingMore: true }); 358 - } else { 359 - setWorkspace("feedStates", feed.id, { ...state, error: null, loading: true }); 360 - } 361 - 362 - try { 363 - const command = getFeedCommand(feed); 364 - const payload = parseFeedResponse(await invoke(command.name, command.args(state.cursor, DEFAULT_LIMIT))); 365 - const items = append ? [...state.items, ...payload.feed] : payload.feed; 366 - setWorkspace("feedStates", feed.id, { 367 - cursor: payload.cursor ?? null, 368 - error: null, 369 - items, 370 - loading: false, 371 - loadingMore: false, 372 - }); 373 - } catch (error) { 374 - setWorkspace("feedStates", feed.id, { ...state, error: String(error), loading: false, loadingMore: false }); 375 - props.onError( 376 - `Failed to load ${getFeedName(feed, workspace.generators[feed.value]?.displayName)}: ${String(error)}`, 377 - ); 378 - } 379 - } 380 - 381 - async function loadThread(uri: string) { 382 - setWorkspace("thread", { data: null, error: null, loading: true, uri }); 383 - 384 - try { 385 - const payload = parseThreadResponse(await invoke("get_post_thread", { uri })); 386 - if (props.threadUri === uri) { 387 - setWorkspace("thread", { data: payload.thread, error: null, loading: false, uri }); 388 - } 389 - } catch (error) { 390 - if (props.threadUri === uri) { 391 - setWorkspace("thread", { data: null, error: String(error), loading: false, uri }); 392 - } 393 - props.onError(`Failed to open thread: ${String(error)}`); 394 - } 395 - } 396 - 397 - function switchFeed(feedId: string) { 398 - const current = activeFeed(); 399 - if (current && scroller) { 400 - const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, current.id, scroller.scrollTop); 401 - if (nextScrollTops) { 402 - setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 403 - } 404 - } 405 - 406 - setWorkspace("activeFeedId", feedId); 407 - setWorkspace("focusedIndex", 0); 408 - setWorkspace("showFeedsDrawer", false); 409 - } 410 - 411 - async function openThread(uri: string) { 412 - if (props.threadUri === uri) { 413 - await loadThread(uri); 414 - return; 415 - } 416 - 417 - props.onThreadRouteChange(uri); 418 - } 419 - 420 - function openComposer() { 421 - setWorkspace("composer", "open", true); 422 - } 423 - 424 - function resetComposer() { 425 - setWorkspace( 426 - "composer", 427 - (current) => ({ ...current, open: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }), 428 - ); 429 - } 430 - 431 - function openReplyComposer(post: PostView, root: PostView) { 432 - setWorkspace( 433 - "composer", 434 - (current) => ({ ...current, open: true, quoteTarget: null, replyRoot: root, replyTarget: post }), 435 - ); 436 - } 437 - 438 - function openQuoteComposer(post: PostView) { 439 - setWorkspace( 440 - "composer", 441 - (current) => ({ ...current, open: true, quoteTarget: post, replyRoot: null, replyTarget: null }), 442 - ); 443 - } 444 - 445 - function applySuggestion(value: string) { 446 - const token = composerToken(); 447 - if (!token) { 448 - return; 449 - } 450 - 451 - setWorkspace( 452 - "composer", 453 - "text", 454 - (current) => current.replace(new RegExp(`${escapeForRegex(token)}$`, "u"), `${value} `), 455 - ); 456 - } 457 - 458 - async function submitPost() { 459 - const text = workspace.composer.text; 460 - const reply = workspace.composer.replyTarget; 461 - const root = workspace.composer.replyRoot; 462 - const quote = workspace.composer.quoteTarget; 463 - 464 - const replyTo: ReplyRefInput | null = reply && root 465 - ? { parent: toStrongRef(reply), root: toStrongRef(root) } 466 - : null; 467 - const embed: EmbedInput | null = quote ? { type: "record", record: toStrongRef(quote) } : null; 468 - 469 - setWorkspace("composer", "pending", true); 470 - try { 471 - await invoke<CreateRecordResult>("create_post", { embed, replyTo, text }); 472 - resetComposer(); 473 - props.onThreadRouteChange(null); 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 - } 480 - if (scroller) { 481 - scroller.scrollTop = 0; 482 - } 483 - } catch (error) { 484 - props.onError(`Failed to create post: ${String(error)}`); 485 - } finally { 486 - setWorkspace("composer", "pending", false); 487 - } 488 - } 489 - 490 - async function toggleLike(post: PostView) { 491 - setWorkspace("likePendingByUri", post.uri, true); 492 - try { 493 - if (post.viewer?.like) { 494 - await invoke("unlike_post", { likeUri: post.viewer.like }); 495 - patchPost( 496 - post.uri, 497 - (current) => ({ 498 - ...current, 499 - likeCount: Math.max(0, (current.likeCount ?? 0) - 1), 500 - viewer: { ...current.viewer, like: null }, 501 - }), 502 - ); 503 - } else { 504 - const result = await invoke<CreateRecordResult>("like_post", { cid: post.cid, uri: post.uri }); 505 - patchPost( 506 - post.uri, 507 - (current) => ({ 508 - ...current, 509 - likeCount: (current.likeCount ?? 0) + 1, 510 - viewer: { ...current.viewer, like: result.uri }, 511 - }), 512 - ); 513 - triggerLikePulse(post.uri); 514 - } 515 - } catch (error) { 516 - props.onError(`Failed to update like: ${String(error)}`); 517 - } finally { 518 - setWorkspace("likePendingByUri", post.uri, false); 519 - } 520 - } 521 - 522 - async function toggleRepost(post: PostView) { 523 - setWorkspace("repostPendingByUri", post.uri, true); 524 - try { 525 - if (post.viewer?.repost) { 526 - await invoke("unrepost", { repostUri: post.viewer.repost }); 527 - patchPost( 528 - post.uri, 529 - (current) => ({ 530 - ...current, 531 - repostCount: Math.max(0, (current.repostCount ?? 0) - 1), 532 - viewer: { ...current.viewer, repost: null }, 533 - }), 534 - ); 535 - } else { 536 - const result = await invoke<CreateRecordResult>("repost", { cid: post.cid, uri: post.uri }); 537 - patchPost( 538 - post.uri, 539 - (current) => ({ 540 - ...current, 541 - repostCount: (current.repostCount ?? 0) + 1, 542 - viewer: { ...current.viewer, repost: result.uri }, 543 - }), 544 - ); 545 - triggerRepostPulse(post.uri); 546 - } 547 - } catch (error) { 548 - props.onError(`Failed to update repost: ${String(error)}`); 549 - } finally { 550 - setWorkspace("repostPendingByUri", post.uri, false); 551 - } 552 - } 553 - 554 - function patchPost(uri: string, updater: (post: PostView) => PostView) { 555 - for (const [feedId, state] of Object.entries(workspace.feedStates)) { 556 - if (!state) { 557 - continue; 558 - } 559 - 560 - setWorkspace("feedStates", feedId, "items", patchFeedItems(state.items, uri, updater)); 561 - } 562 - 563 - const currentThread = workspace.thread.data; 564 - if (currentThread) { 565 - setWorkspace("thread", "data", patchThreadNode(currentThread, uri, updater)); 566 - } 567 - } 568 - 569 - function triggerLikePulse(uri: string) { 570 - setWorkspace("likePulseUri", uri); 571 - globalThis.setTimeout(() => setWorkspace("likePulseUri", (current) => (current === uri ? null : current)), 320); 572 - } 573 - 574 - function triggerRepostPulse(uri: string) { 575 - setWorkspace("repostPulseUri", uri); 576 - globalThis.setTimeout(() => setWorkspace("repostPulseUri", (current) => (current === uri ? null : current)), 320); 577 - } 578 - 579 - async function saveFeedPreferences(updatedFeeds: SavedFeedItem[]) { 580 - try { 581 - await invoke("update_saved_feeds", { feeds: updatedFeeds }); 582 - setWorkspace("preferences", (current) => current ? { ...current, savedFeeds: updatedFeeds } : current); 583 - } catch (error) { 584 - props.onError(`Failed to update feeds: ${String(error)}`); 585 - } 586 - } 587 - 588 - function pinFeed(feedId: string) { 589 - const currentFeeds = workspace.preferences?.savedFeeds ?? []; 590 - const updatedFeeds = currentFeeds.map((feed) => feed.id === feedId ? { ...feed, pinned: true } : feed); 591 - void saveFeedPreferences(updatedFeeds); 592 - } 593 - 594 - function unpinFeed(feedId: string) { 595 - const currentFeeds = workspace.preferences?.savedFeeds ?? []; 596 - const updatedFeeds = currentFeeds.map((feed) => feed.id === feedId ? { ...feed, pinned: false } : feed); 597 - void saveFeedPreferences(updatedFeeds); 598 - } 599 - 600 - function reorderPinnedFeeds(feedId: string, direction: "up" | "down") { 601 - const pinned = pinnedFeeds(); 602 - const index = pinned.findIndex((f) => f.id === feedId); 603 - if (index === -1) return; 604 - 605 - const newIndex = direction === "up" ? index - 1 : index + 1; 606 - if (newIndex < 0 || newIndex >= pinned.length) return; 607 - 608 - const currentFeeds = [...(workspace.preferences?.savedFeeds ?? [])]; 609 - const feedIds = currentFeeds.map((f) => f.id); 610 - const pinnedIds = pinned.map((f) => f.id); 611 - 612 - const itemId = pinnedIds[index]; 613 - const swapId = pinnedIds[newIndex]; 614 - const itemIdx = feedIds.indexOf(itemId); 615 - const swapIdx = feedIds.indexOf(swapId); 616 - 617 - if (itemIdx === -1 || swapIdx === -1) return; 618 - 619 - const reordered = [...currentFeeds]; 620 - [reordered[itemIdx], reordered[swapIdx]] = [reordered[swapIdx], reordered[itemIdx]]; 621 - 622 - void saveFeedPreferences(reordered); 623 - } 9 + const controller = useFeedWorkspaceController(props); 624 10 625 11 return ( 626 12 <> 627 13 <div class="grid h-full min-h-0 min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem] max-[1180px]:gap-5 max-[900px]:gap-4"> 628 14 <FeedPane 629 - activeFeed={activeFeed()} 630 - activeFeedId={activeFeed().id} 631 - activeFeedState={activeFeedState()} 15 + activeFeed={controller.activeFeed()} 16 + activeFeedId={controller.activeFeed().id} 17 + activeFeedState={controller.activeFeedState()} 632 18 activeHandle={props.activeSession.handle} 633 - focusedIndex={workspace.focusedIndex} 634 - generators={workspace.generators} 635 - likePendingByUri={workspace.likePendingByUri} 636 - likePulseUri={workspace.likePulseUri} 637 - onCompose={openComposer} 638 - onFeedSelect={switchFeed} 639 - onFocusIndex={(index) => setWorkspace("focusedIndex", index)} 640 - onLike={toggleLike} 641 - onOpenThread={openThread} 642 - onQuote={openQuoteComposer} 643 - onReply={openReplyComposer} 644 - onRepost={toggleRepost} 645 - onToggleDrawer={() => setWorkspace("showFeedsDrawer", (value) => !value)} 646 - pinnedFeeds={pinnedFeeds().slice(0, 9)} 647 - postRefs={postRefs} 648 - repostPendingByUri={workspace.repostPendingByUri} 649 - repostPulseUri={workspace.repostPulseUri} 650 - scrollerRef={(element) => { 651 - scroller = element; 652 - }} 653 - sentinelRef={(element) => { 654 - sentinel = element; 655 - }} 656 - setScrollTop={(top) => { 657 - const feedId = activeFeed().id; 658 - const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, feedId, top); 659 - if (!nextScrollTops) { 660 - return; 661 - } 662 - 663 - setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 664 - }} 665 - visibleItems={visibleItems()} /> 19 + focusedIndex={controller.workspace.focusedIndex} 20 + generators={controller.workspace.generators} 21 + likePendingByUri={controller.workspace.likePendingByUri} 22 + likePulseUri={controller.workspace.likePulseUri} 23 + onCompose={controller.openComposer} 24 + onFeedSelect={controller.switchFeed} 25 + onFocusIndex={controller.setFocusedIndex} 26 + onLike={controller.toggleLike} 27 + onOpenThread={controller.openThread} 28 + onQuote={controller.openQuoteComposer} 29 + onReply={controller.openReplyComposer} 30 + onRepost={controller.toggleRepost} 31 + onToggleDrawer={controller.toggleFeedsDrawer} 32 + pinnedFeeds={controller.pinnedFeeds().slice(0, 9)} 33 + postRefs={controller.postRefs} 34 + repostPendingByUri={controller.workspace.repostPendingByUri} 35 + repostPulseUri={controller.workspace.repostPulseUri} 36 + scrollerRef={controller.registerScroller} 37 + sentinelRef={controller.registerSentinel} 38 + setScrollTop={controller.rememberScrollTop} 39 + visibleItems={controller.visibleItems()} /> 666 40 667 - <WorkspaceSidebar 668 - activePref={activePref()} 669 - drawerFeeds={drawerFeeds()} 670 - generators={workspace.generators} 671 - onFeedSelect={switchFeed} 672 - onPrefChange={setFeedPref} /> 41 + <FeedWorkspaceSidebar 42 + activePref={controller.activePref()} 43 + drawerFeeds={controller.drawerFeeds()} 44 + generators={controller.workspace.generators} 45 + onFeedSelect={controller.switchFeed} 46 + onPrefChange={controller.setFeedPref} /> 673 47 </div> 674 48 675 49 <SavedFeedsDrawer 676 - drawerFeeds={drawerFeeds()} 677 - generators={workspace.generators} 678 - open={workspace.showFeedsDrawer} 679 - pinnedFeeds={pinnedFeeds()} 680 - onClose={() => setWorkspace("showFeedsDrawer", false)} 681 - onPinFeed={pinFeed} 682 - onReorderPinned={reorderPinnedFeeds} 683 - onSelectFeed={switchFeed} 684 - onUnpinFeed={unpinFeed} /> 50 + drawerFeeds={controller.drawerFeeds()} 51 + generators={controller.workspace.generators} 52 + open={controller.workspace.showFeedsDrawer} 53 + pinnedFeeds={controller.pinnedFeeds()} 54 + onClose={controller.closeFeedsDrawer} 55 + onPinFeed={controller.pinFeed} 56 + onReorderPinned={controller.reorderPinnedFeeds} 57 + onSelectFeed={controller.switchFeed} 58 + onUnpinFeed={controller.unpinFeed} /> 685 59 686 60 <ThreadPanel 687 61 activeUri={props.threadUri} 688 - error={workspace.thread.error} 689 - loading={workspace.thread.loading} 62 + error={controller.workspace.thread.error} 63 + loading={controller.workspace.thread.loading} 690 64 onClose={() => props.onThreadRouteChange(null)} 691 - onLike={(post) => void toggleLike(post)} 692 - onOpenThread={(uri) => void openThread(uri)} 693 - onQuote={(post) => openQuoteComposer(post)} 694 - onReply={(post, root) => openReplyComposer(post, root)} 695 - onRepost={(post) => void toggleRepost(post)} 696 - thread={workspace.thread.data} /> 65 + onLike={(post) => void controller.toggleLike(post)} 66 + onOpenThread={(uri) => void controller.openThread(uri)} 67 + onQuote={(post) => controller.openQuoteComposer(post)} 68 + onReply={(post, root) => controller.openReplyComposer(post, root)} 69 + onRepost={(post) => void controller.toggleRepost(post)} 70 + thread={controller.workspace.thread.data} /> 697 71 698 72 <FeedComposer 699 73 activeHandle={props.activeSession.handle} 700 - open={workspace.composer.open} 701 - pending={workspace.composer.pending} 702 - quoteTarget={workspace.composer.quoteTarget} 703 - replyTarget={workspace.composer.replyTarget} 704 - suggestions={composerSuggestions()} 705 - text={workspace.composer.text} 706 - onApplySuggestion={applySuggestion} 707 - onClearQuote={() => setWorkspace("composer", "quoteTarget", null)} 708 - onClearReply={() => { 709 - setWorkspace("composer", "replyTarget", null); 710 - setWorkspace("composer", "replyRoot", null); 711 - }} 712 - onClose={() => resetComposer()} 713 - onSubmit={() => void submitPost()} 714 - onTextChange={(text) => setWorkspace("composer", "text", text)} /> 74 + open={controller.workspace.composer.open} 75 + pending={controller.workspace.composer.pending} 76 + quoteTarget={controller.workspace.composer.quoteTarget} 77 + replyTarget={controller.workspace.composer.replyTarget} 78 + suggestions={controller.composerSuggestions()} 79 + text={controller.workspace.composer.text} 80 + onApplySuggestion={controller.applySuggestion} 81 + onClearQuote={controller.clearQuoteComposer} 82 + onClearReply={controller.clearReplyComposer} 83 + onClose={controller.resetComposer} 84 + onSubmit={() => void controller.submitPost()} 85 + onTextChange={controller.setComposerText} /> 715 86 </> 716 - ); 717 - 718 - async function setFeedPref<K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) { 719 - const feed = activeFeed(); 720 - const previousPref = activePref(); 721 - const nextPref = { ...previousPref, [key]: value }; 722 - 723 - setWorkspace("localPrefs", feed.value, nextPref); 724 - 725 - try { 726 - await invoke("update_feed_view_pref", { pref: nextPref }); 727 - setWorkspace( 728 - "preferences", 729 - (current) => 730 - current ? { ...current, feedViewPrefs: upsertFeedViewPrefs(current.feedViewPrefs, nextPref) } : current, 731 - ); 732 - } catch (error) { 733 - setWorkspace("localPrefs", feed.value, previousPref); 734 - props.onError(`Failed to update display filters: ${String(error)}`); 735 - } 736 - } 737 - } 738 - 739 - function WorkspaceSidebar( 740 - props: { 741 - activePref: FeedViewPrefItem; 742 - drawerFeeds: SavedFeedItem[]; 743 - generators: Record<string, FeedGeneratorView>; 744 - onFeedSelect: (feedId: string) => void; 745 - onPrefChange: <K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) => void; 746 - }, 747 - ) { 748 - return ( 749 - <aside class="grid min-h-0 min-w-0 gap-4 overflow-hidden md:grid-cols-2 xl:grid-cols-1 xl:overflow-y-auto xl:overscroll-contain"> 750 - <SavedFeedsCard drawerFeeds={props.drawerFeeds} generators={props.generators} onFeedSelect={props.onFeedSelect} /> 751 - <DisplayFiltersCard activePref={props.activePref} onPrefChange={props.onPrefChange} /> 752 - <ShortcutsCard /> 753 - </aside> 754 - ); 755 - } 756 - 757 - function SavedFeedsCard( 758 - props: { 759 - drawerFeeds: SavedFeedItem[]; 760 - generators: Record<string, FeedGeneratorView>; 761 - onFeedSelect: (feedId: string) => void; 762 - }, 763 - ) { 764 - return ( 765 - <SidebarCard title="Saved Feeds" subtitle="Drawer access"> 766 - <div class="grid gap-2"> 767 - <For each={props.drawerFeeds.slice(0, 4)}> 768 - {(feed) => ( 769 - <SidebarFeedButton feed={feed} generator={props.generators[feed.value]} onSelect={props.onFeedSelect} /> 770 - )} 771 - </For> 772 - <Show when={props.drawerFeeds.length === 0}> 773 - <p class="m-0 text-[0.8rem] leading-[1.6] text-on-surface-variant"> 774 - All saved feeds are already pinned as tabs. 775 - </p> 776 - </Show> 777 - </div> 778 - </SidebarCard> 779 - ); 780 - } 781 - 782 - function SidebarFeedButton( 783 - props: { feed: SavedFeedItem; generator?: FeedGeneratorView; onSelect: (feedId: string) => void }, 784 - ) { 785 - return ( 786 - <button 787 - class="flex w-full items-center gap-3 rounded-[1.1rem] border-0 bg-white/4 px-3 py-3 text-left text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/[0.07]" 788 - type="button" 789 - onClick={() => props.onSelect(props.feed.id)}> 790 - <FeedChipAvatar feed={props.feed} generator={props.generator} /> 791 - <div class="min-w-0 flex-1"> 792 - <p class="m-0 truncate text-sm font-medium">{getFeedName(props.feed, props.generator?.displayName)}</p> 793 - <p class="m-0 text-xs uppercase tracking-[0.08em] text-on-surface-variant">{props.feed.type}</p> 794 - </div> 795 - </button> 796 - ); 797 - } 798 - 799 - function DisplayFiltersCard( 800 - props: { 801 - activePref: FeedViewPrefItem; 802 - onPrefChange: <K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) => void; 803 - }, 804 - ) { 805 - return ( 806 - <SidebarCard title="Display Filters" subtitle="Per-feed"> 807 - <div class="grid gap-3"> 808 - <ToggleRow 809 - checked={props.activePref.hideReposts} 810 - label="Hide reposts" 811 - onChange={(checked) => void props.onPrefChange("hideReposts", checked)} /> 812 - <ToggleRow 813 - checked={props.activePref.hideReplies} 814 - label="Hide replies" 815 - onChange={(checked) => void props.onPrefChange("hideReplies", checked)} /> 816 - <ToggleRow 817 - checked={props.activePref.hideQuotePosts} 818 - label="Hide quotes" 819 - onChange={(checked) => void props.onPrefChange("hideQuotePosts", checked)} /> 820 - <ReplyLikeThreshold 821 - value={props.activePref.hideRepliesByLikeCount} 822 - onChange={(value) => void props.onPrefChange("hideRepliesByLikeCount", value)} /> 823 - </div> 824 - </SidebarCard> 825 - ); 826 - } 827 - 828 - function ReplyLikeThreshold(props: { value: number | null; onChange: (value: number | null) => void }) { 829 - return ( 830 - <label class="grid gap-2 text-[0.8rem] text-on-surface-variant"> 831 - <span>Minimum likes for replies</span> 832 - <input 833 - class="rounded-full border-0 bg-white/6 px-4 py-2 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] focus:outline focus:outline-primary/50" 834 - min="0" 835 - type="number" 836 - value={props.value ?? ""} 837 - onInput={(event) => { 838 - const value = event.currentTarget.value.trim(); 839 - props.onChange(value ? Number(value) : null); 840 - }} /> 841 - </label> 842 - ); 843 - } 844 - 845 - function ShortcutsCard() { 846 - return ( 847 - <SidebarCard title="Shortcuts" subtitle="Feed controls"> 848 - <div class="grid gap-2 text-[0.8rem] text-on-surface-variant"> 849 - <ShortcutLine keys="1-9" label="Switch pinned feeds" /> 850 - <ShortcutLine keys="j / k" label="Move focus" /> 851 - <ShortcutLine keys="l" label="Like focused post" /> 852 - <ShortcutLine keys="r" label="Reply to focused post" /> 853 - <ShortcutLine keys="t" label="Repost focused post" /> 854 - <ShortcutLine keys="o" label="Open thread" /> 855 - <ShortcutLine keys="n" label="Open composer" /> 856 - </div> 857 - </SidebarCard> 858 - ); 859 - } 860 - 861 - function SidebarCard(props: ParentProps & { subtitle: string; title: string }) { 862 - return ( 863 - <section class="rounded-[1.6rem] bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 864 - <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p> 865 - <p class="mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.subtitle}</p> 866 - <div class="mt-4">{props.children}</div> 867 - </section> 868 - ); 869 - } 870 - 871 - function ToggleRow(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) { 872 - return ( 873 - <label class="flex items-center justify-between gap-3 rounded-2xl bg-white/4 px-3 py-3 text-sm text-on-surface"> 874 - <span>{props.label}</span> 875 - <input checked={props.checked} type="checkbox" onInput={(event) => props.onChange(event.currentTarget.checked)} /> 876 - </label> 877 - ); 878 - } 879 - 880 - function ShortcutLine(props: { keys: string; label: string }) { 881 - return ( 882 - <div class="flex items-center justify-between gap-3 rounded-2xl bg-white/4 px-3 py-2.5"> 883 - <span>{props.label}</span> 884 - <span class="rounded-full bg-black/30 px-2 py-1 text-[0.68rem] uppercase tracking-[0.08em] text-primary"> 885 - {props.keys} 886 - </span> 887 - </div> 888 87 ); 889 88 }
+156
src/components/feeds/FeedWorkspaceSidebar.tsx
··· 1 + import { getFeedName } from "$/lib/feeds"; 2 + import type { FeedGeneratorView, FeedViewPrefItem, SavedFeedItem } from "$/lib/types"; 3 + import { For, type ParentProps, Show } from "solid-js"; 4 + import { FeedChipAvatar } from "./FeedChipAvatar"; 5 + 6 + type FeedWorkspaceSidebarProps = { 7 + activePref: FeedViewPrefItem; 8 + drawerFeeds: SavedFeedItem[]; 9 + generators: Record<string, FeedGeneratorView>; 10 + onFeedSelect: (feedId: string) => void; 11 + onPrefChange: <K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) => void; 12 + }; 13 + 14 + export function FeedWorkspaceSidebar(props: FeedWorkspaceSidebarProps) { 15 + return ( 16 + <aside class="grid min-h-0 min-w-0 gap-4 overflow-hidden md:grid-cols-2 xl:grid-cols-1 xl:overflow-y-auto xl:overscroll-contain"> 17 + <SavedFeedsCard drawerFeeds={props.drawerFeeds} generators={props.generators} onFeedSelect={props.onFeedSelect} /> 18 + <DisplayFiltersCard activePref={props.activePref} onPrefChange={props.onPrefChange} /> 19 + <ShortcutsCard /> 20 + </aside> 21 + ); 22 + } 23 + 24 + function SavedFeedsCard( 25 + props: { 26 + drawerFeeds: SavedFeedItem[]; 27 + generators: Record<string, FeedGeneratorView>; 28 + onFeedSelect: (feedId: string) => void; 29 + }, 30 + ) { 31 + return ( 32 + <SidebarCard title="Saved Feeds" subtitle="Drawer access"> 33 + <div class="grid gap-2"> 34 + <For each={props.drawerFeeds.slice(0, 4)}> 35 + {(feed) => ( 36 + <SidebarFeedButton feed={feed} generator={props.generators[feed.value]} onSelect={props.onFeedSelect} /> 37 + )} 38 + </For> 39 + <Show when={props.drawerFeeds.length === 0}> 40 + <p class="m-0 text-[0.8rem] leading-[1.6] text-on-surface-variant"> 41 + All saved feeds are already pinned as tabs. 42 + </p> 43 + </Show> 44 + </div> 45 + </SidebarCard> 46 + ); 47 + } 48 + 49 + function SidebarFeedButton( 50 + props: { feed: SavedFeedItem; generator?: FeedGeneratorView; onSelect: (feedId: string) => void }, 51 + ) { 52 + return ( 53 + <button 54 + class="flex w-full items-center gap-3 rounded-1xl border-0 bg-white/4 px-3 py-3 text-left text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/[0.07]" 55 + type="button" 56 + onClick={() => props.onSelect(props.feed.id)}> 57 + <FeedChipAvatar feed={props.feed} generator={props.generator} /> 58 + <div class="min-w-0 flex-1"> 59 + <p class="m-0 truncate text-sm font-medium">{getFeedName(props.feed, props.generator?.displayName)}</p> 60 + <p class="m-0 text-xs uppercase tracking-[0.08em] text-on-surface-variant">{props.feed.type}</p> 61 + </div> 62 + </button> 63 + ); 64 + } 65 + 66 + function DisplayFiltersCard( 67 + props: { 68 + activePref: FeedViewPrefItem; 69 + onPrefChange: <K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) => void; 70 + }, 71 + ) { 72 + return ( 73 + <SidebarCard title="Display Filters" subtitle="Per-feed"> 74 + <div class="grid gap-3"> 75 + <ToggleRow 76 + checked={props.activePref.hideReposts} 77 + label="Hide reposts" 78 + onChange={(checked) => void props.onPrefChange("hideReposts", checked)} /> 79 + <ToggleRow 80 + checked={props.activePref.hideReplies} 81 + label="Hide replies" 82 + onChange={(checked) => void props.onPrefChange("hideReplies", checked)} /> 83 + <ToggleRow 84 + checked={props.activePref.hideQuotePosts} 85 + label="Hide quotes" 86 + onChange={(checked) => void props.onPrefChange("hideQuotePosts", checked)} /> 87 + <ReplyLikeThreshold 88 + value={props.activePref.hideRepliesByLikeCount} 89 + onChange={(value) => void props.onPrefChange("hideRepliesByLikeCount", value)} /> 90 + </div> 91 + </SidebarCard> 92 + ); 93 + } 94 + 95 + function ReplyLikeThreshold(props: { value: number | null; onChange: (value: number | null) => void }) { 96 + return ( 97 + <label class="grid gap-2 text-[0.8rem] text-on-surface-variant"> 98 + <span>Minimum likes for replies</span> 99 + <input 100 + class="rounded-full border-0 bg-white/6 px-4 py-2 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] focus:outline focus:outline-primary/50" 101 + min="0" 102 + type="number" 103 + value={props.value ?? ""} 104 + onInput={(event) => { 105 + const value = event.currentTarget.value.trim(); 106 + props.onChange(value ? Number(value) : null); 107 + }} /> 108 + </label> 109 + ); 110 + } 111 + 112 + function ShortcutsCard() { 113 + return ( 114 + <SidebarCard title="Shortcuts" subtitle="Feed controls"> 115 + <div class="grid gap-2 text-[0.8rem] text-on-surface-variant"> 116 + <ShortcutLine keys="1-9" label="Switch pinned feeds" /> 117 + <ShortcutLine keys="j / k" label="Move focus" /> 118 + <ShortcutLine keys="l" label="Like focused post" /> 119 + <ShortcutLine keys="r" label="Reply to focused post" /> 120 + <ShortcutLine keys="t" label="Repost focused post" /> 121 + <ShortcutLine keys="o" label="Open thread" /> 122 + <ShortcutLine keys="n" label="Open composer" /> 123 + </div> 124 + </SidebarCard> 125 + ); 126 + } 127 + 128 + function SidebarCard(props: ParentProps & { subtitle: string; title: string }) { 129 + return ( 130 + <section class="rounded-3xl bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 131 + <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p> 132 + <p class="mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.subtitle}</p> 133 + <div class="mt-4">{props.children}</div> 134 + </section> 135 + ); 136 + } 137 + 138 + function ToggleRow(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) { 139 + return ( 140 + <label class="flex items-center justify-between gap-3 rounded-2xl bg-white/4 px-3 py-3 text-sm text-on-surface"> 141 + <span>{props.label}</span> 142 + <input checked={props.checked} type="checkbox" onInput={(event) => props.onChange(event.currentTarget.checked)} /> 143 + </label> 144 + ); 145 + } 146 + 147 + function ShortcutLine(props: { keys: string; label: string }) { 148 + return ( 149 + <div class="flex items-center justify-between gap-3 rounded-2xl bg-white/4 px-3 py-2.5"> 150 + <span>{props.label}</span> 151 + <span class="rounded-full bg-black/30 px-2 py-1 text-[0.68rem] uppercase tracking-[0.08em] text-primary"> 152 + {props.keys} 153 + </span> 154 + </div> 155 + ); 156 + }
+6 -9
src/components/feeds/PostCard.tsx
··· 49 49 const repostCount = createMemo(() => formatCount(props.post.repostCount)); 50 50 51 51 return ( 52 - <Motion.article 52 + <article 53 53 ref={(element) => props.registerRef?.(element)} 54 - class="group min-w-0 overflow-hidden rounded-[1.6rem] bg-white/2.5 px-5 py-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4 max-[760px]:px-4 max-[760px]:py-4 max-[520px]:rounded-[1.35rem] max-[520px]:px-3.5" 54 + class="group min-w-0 overflow-hidden rounded-3xl bg-white/2.5 px-5 py-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4 max-[760px]:px-4 max-[760px]:py-4 max-[520px]:rounded-3xl max-[520px]:px-3.5" 55 55 classList={{ 56 56 "bg-[linear-gradient(135deg,rgba(125,175,255,0.11),rgba(0,115,222,0.06))] shadow-[inset_0_0_0_1px_rgba(125,175,255,0.22),0_0_0_1px_rgba(125,175,255,0.08)]": 57 57 !!props.focused, 58 58 }} 59 59 role="article" 60 60 tabIndex={0} 61 - initial={{ opacity: 0, y: 18 }} 62 - animate={{ opacity: 1, y: 0 }} 63 - transition={{ duration: 0.22 }} 64 61 onClick={() => props.onFocus?.()} 65 62 onFocus={() => props.onFocus?.()} 66 63 onKeyDown={(event) => { ··· 120 117 </footer> 121 118 </div> 122 119 </div> 123 - </Motion.article> 120 + </article> 124 121 ); 125 122 } 126 123 ··· 223 220 function ExternalEmbed(props: { description?: string; thumb?: string; title?: string; uri?: string }) { 224 221 return ( 225 222 <a 226 - class="grid min-w-0 gap-3 overflow-hidden rounded-[1.25rem] bg-black/30 p-3 text-inherit no-underline shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] transition duration-150 ease-out hover:bg-black/40" 223 + class="grid min-w-0 gap-3 overflow-hidden rounded-2xl bg-black/30 p-3 text-inherit no-underline shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] transition duration-150 ease-out hover:bg-black/40" 227 224 href={props.uri} 228 225 rel="noreferrer" 229 226 target="_blank"> ··· 239 236 </Show> 240 237 <Show when={props.uri}> 241 238 {(uri) => ( 242 - <p class="m-0 break-all text-[0.74rem] uppercase tracking-[0.08em] text-primary"> 239 + <p class="m-0 break-all text-xs uppercase tracking-[0.08em] text-primary"> 243 240 {uri().replace(/^https?:\/\//, "")} 244 241 </p> 245 242 )} ··· 254 251 const title = () => props.title; 255 252 256 253 return ( 257 - <div class="rounded-[1.25rem] bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 254 + <div class="rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 258 255 <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{title()}</p> 259 256 <Show when={props.author}> 260 257 {(author) => (
+5 -5
src/components/feeds/ThreadPanel.tsx
··· 69 69 <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-3xl bg-[rgba(14,14,14,0.9)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 70 70 <div> 71 71 <p class="m-0 text-base font-semibold text-on-surface">Thread</p> 72 - <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Nested replies</p> 72 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Nested replies</p> 73 73 </div> 74 74 <button 75 75 class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" ··· 119 119 <div class="grid gap-4"> 120 120 <Show when={threadNode().parent}> 121 121 {(parent) => ( 122 - <div class="rounded-[1.35rem] bg-white/[0.03] p-3"> 122 + <div class="rounded-3xl bg-white/3 p-3"> 123 123 <ThreadNodeView 124 124 activeUri={props.activeUri} 125 125 node={parent()} ··· 143 143 onRepost={() => props.onRepost(threadNode().post)} /> 144 144 145 145 <Show when={threadNode().replies?.length}> 146 - <div class="grid gap-4 rounded-[1.35rem] bg-white/[0.03] p-3"> 146 + <div class="grid gap-4 rounded-3xl bg-white/3 p-3"> 147 147 <For each={threadNode().replies}> 148 148 {(reply) => ( 149 149 <ThreadNodeView ··· 168 168 169 169 function StateCard(props: { label: string; meta: string }) { 170 170 return ( 171 - <div class="rounded-[1.3rem] bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 171 + <div class="rounded-3xl bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 172 172 <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 173 - <p class="mt-1 text-[0.74rem] text-on-surface-variant">{props.meta}</p> 173 + <p class="mt-1 text-xs text-on-surface-variant">{props.meta}</p> 174 174 </div> 175 175 ); 176 176 }
+720
src/components/feeds/useFeedWorkspaceController.ts
··· 1 + import { 2 + createPost, 3 + getFeedGenerators, 4 + getFeedPage, 5 + getPostThread, 6 + getPreferences, 7 + likePost, 8 + repost, 9 + unlikePost, 10 + unrepost, 11 + updateFeedViewPref, 12 + updateSavedFeeds, 13 + } from "$/lib/api/feeds"; 14 + import { 15 + applyFeedPreferences, 16 + extractHandles, 17 + extractHashtags, 18 + getFeedName, 19 + getReplyRootPost, 20 + patchFeedItems, 21 + patchThreadNode, 22 + toStrongRef, 23 + } from "$/lib/feeds"; 24 + import type { ActiveSession, EmbedInput, FeedViewPrefItem, PostView, ReplyRefInput, SavedFeedItem } from "$/lib/types"; 25 + import { shouldIgnoreKey } from "$/lib/utils/events"; 26 + import { escapeForRegex } from "$/lib/utils/text"; 27 + import { listen } from "@tauri-apps/api/event"; 28 + import { createEffect, createMemo, onCleanup, onMount, untrack } from "solid-js"; 29 + import { createStore, reconcile } from "solid-js/store"; 30 + import type { FeedWorkspaceState } from "./types"; 31 + import { 32 + buildLocalPrefs, 33 + createDefaultFeedPref, 34 + createDefaultFeedState, 35 + createDefaultThreadState, 36 + createInitialWorkspaceState, 37 + DEFAULT_TIMELINE, 38 + getFeedScrollTop, 39 + getNextFocusedIndex, 40 + getNextFocusedScrollTop, 41 + updateFeedScrollTop, 42 + upsertFeedViewPrefs, 43 + } from "./workspace-state"; 44 + 45 + export type FeedWorkspaceProps = { 46 + activeSession: ActiveSession; 47 + onError: (message: string) => void; 48 + onThreadRouteChange: (uri: string | null) => void; 49 + threadUri: string | null; 50 + }; 51 + 52 + const DEFAULT_LIMIT = 30; 53 + 54 + export function useFeedWorkspaceController(props: FeedWorkspaceProps) { 55 + const [workspace, setWorkspace] = createStore<FeedWorkspaceState>(createInitialWorkspaceState()); 56 + 57 + let scroller: HTMLDivElement | undefined; 58 + let sentinel: HTMLDivElement | undefined; 59 + let lastFocusedUri: string | null = null; 60 + const postRefs = new Map<string, HTMLElement>(); 61 + 62 + const savedFeeds = createMemo(() => { 63 + const stored = workspace.preferences?.savedFeeds ?? []; 64 + return stored.length > 0 ? stored : [DEFAULT_TIMELINE]; 65 + }); 66 + const pinnedFeeds = createMemo(() => { 67 + const pinned = savedFeeds().filter((feed) => feed.pinned); 68 + return pinned.length > 0 ? pinned : [DEFAULT_TIMELINE]; 69 + }); 70 + const drawerFeeds = createMemo(() => savedFeeds().filter((feed) => !feed.pinned)); 71 + const activeFeed = createMemo(() => { 72 + const feedId = workspace.activeFeedId; 73 + return savedFeeds().find((feed) => feed.id === feedId) ?? pinnedFeeds()[0] ?? DEFAULT_TIMELINE; 74 + }); 75 + const activePref = createMemo(() => { 76 + const feed = activeFeed(); 77 + return workspace.localPrefs[feed.value] ?? createDefaultFeedPref(feed); 78 + }); 79 + const activeFeedState = createMemo(() => workspace.feedStates[activeFeed().id]); 80 + const visibleItems = createMemo(() => applyFeedPreferences(activeFeedState()?.items ?? [], activePref())); 81 + const composerToken = createMemo(() => { 82 + const match = /(^|\s)([@#][^\s@#]*)$/u.exec(workspace.composer.text); 83 + return match?.[2] ?? null; 84 + }); 85 + const composerSuggestions = createMemo(() => { 86 + const token = composerToken(); 87 + if (!token) { 88 + return []; 89 + } 90 + 91 + const posts = visibleItems().map((item) => item.post); 92 + if (token.startsWith("@")) { 93 + return extractHandles(posts, props.activeSession.handle).filter((handle) => 94 + handle.toLowerCase().startsWith(token.toLowerCase()) 95 + ).map((label) => ({ label, type: "handle" as const })); 96 + } 97 + 98 + return extractHashtags(posts).filter((tag) => tag.toLowerCase().startsWith(token.toLowerCase())).map((label) => ({ 99 + label, 100 + type: "hashtag" as const, 101 + })); 102 + }); 103 + 104 + createEffect(() => { 105 + void bootstrapFeeds(); 106 + }); 107 + 108 + createEffect(() => { 109 + const feed = activeFeed(); 110 + if (!feed) { 111 + return; 112 + } 113 + 114 + if (workspace.activeFeedId !== feed.id) { 115 + setWorkspace("activeFeedId", feed.id); 116 + } 117 + 118 + untrack(() => { 119 + void ensureFeedLoaded(feed); 120 + const nextScrollTop = getFeedScrollTop(workspace.feedScrollTops, feed.id); 121 + queueMicrotask(() => { 122 + if (scroller && scroller.scrollTop !== nextScrollTop) { 123 + scroller.scrollTop = nextScrollTop; 124 + } 125 + }); 126 + }); 127 + }); 128 + 129 + createEffect(() => { 130 + const uri = props.threadUri; 131 + if (!uri) { 132 + if (workspace.thread.uri || workspace.thread.data || workspace.thread.error || workspace.thread.loading) { 133 + setWorkspace("thread", reconcile(createDefaultThreadState())); 134 + } 135 + return; 136 + } 137 + 138 + if (workspace.thread.uri === uri && (workspace.thread.data || workspace.thread.error || workspace.thread.loading)) { 139 + return; 140 + } 141 + 142 + void loadThread(uri); 143 + }); 144 + 145 + createEffect(() => { 146 + const items = visibleItems(); 147 + if (items.length === 0) { 148 + setWorkspace("focusedIndex", 0); 149 + return; 150 + } 151 + 152 + setWorkspace("focusedIndex", (current) => Math.min(current, items.length - 1)); 153 + }); 154 + 155 + createEffect(() => { 156 + const item = visibleItems()[workspace.focusedIndex]; 157 + if (!item) { 158 + lastFocusedUri = null; 159 + return; 160 + } 161 + 162 + if (lastFocusedUri === item.post.uri) { 163 + return; 164 + } 165 + 166 + lastFocusedUri = item.post.uri; 167 + queueMicrotask(() => { 168 + if (!scroller) { 169 + return; 170 + } 171 + 172 + const element = postRefs.get(item.post.uri); 173 + if (!element?.isConnected) { 174 + return; 175 + } 176 + 177 + const scrollerRect = scroller.getBoundingClientRect(); 178 + const elementRect = element.getBoundingClientRect(); 179 + const itemTop = elementRect.top - scrollerRect.top + scroller.scrollTop; 180 + const nextScrollTop = getNextFocusedScrollTop( 181 + scroller.scrollTop, 182 + scroller.clientHeight, 183 + itemTop, 184 + element.offsetHeight, 185 + ); 186 + 187 + if (nextScrollTop !== null && scroller.scrollTop !== nextScrollTop) { 188 + scroller.scrollTop = nextScrollTop; 189 + } 190 + }); 191 + }); 192 + 193 + createEffect(() => { 194 + const root = scroller; 195 + const currentSentinel = sentinel; 196 + const feed = activeFeed(); 197 + if (!root || !currentSentinel || !feed) { 198 + return; 199 + } 200 + 201 + const observer = new IntersectionObserver((entries) => { 202 + const entry = entries[0]; 203 + if (!entry?.isIntersecting) { 204 + return; 205 + } 206 + 207 + const state = workspace.feedStates[feed.id]; 208 + if (state?.cursor && !state.loading && !state.loadingMore) { 209 + void loadFeed(feed, true); 210 + } 211 + }, { root, threshold: 0.15 }); 212 + 213 + observer.observe(currentSentinel); 214 + onCleanup(() => observer.disconnect()); 215 + }); 216 + 217 + onMount(() => { 218 + globalThis.addEventListener("keydown", handleGlobalKeydown); 219 + 220 + let unlistenComposer: (() => void) | undefined; 221 + void listen("composer:open", () => { 222 + openComposer(); 223 + }).then((dispose) => { 224 + unlistenComposer = dispose; 225 + }); 226 + 227 + onCleanup(() => { 228 + globalThis.removeEventListener("keydown", handleGlobalKeydown); 229 + unlistenComposer?.(); 230 + }); 231 + }); 232 + 233 + function registerScroller(element: HTMLDivElement) { 234 + scroller = element; 235 + } 236 + 237 + function registerSentinel(element: HTMLDivElement) { 238 + sentinel = element; 239 + } 240 + 241 + function setFocusedIndex(index: number) { 242 + setWorkspace("focusedIndex", index); 243 + } 244 + 245 + function rememberScrollTop(top: number) { 246 + const feedId = activeFeed().id; 247 + const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, feedId, top); 248 + if (!nextScrollTops) { 249 + return; 250 + } 251 + 252 + setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 253 + } 254 + 255 + function handleGlobalKeydown(event: KeyboardEvent) { 256 + if (workspace.composer.open || shouldIgnoreKey(event)) { 257 + return; 258 + } 259 + 260 + const tabs = pinnedFeeds(); 261 + if (/^[1-9]$/.test(event.key)) { 262 + const index = Number(event.key) - 1; 263 + const target = tabs[index]; 264 + if (target) { 265 + event.preventDefault(); 266 + switchFeed(target.id); 267 + } 268 + return; 269 + } 270 + 271 + if (event.key === "n") { 272 + event.preventDefault(); 273 + openComposer(); 274 + return; 275 + } 276 + 277 + const items = visibleItems(); 278 + if (items.length === 0) { 279 + return; 280 + } 281 + 282 + if (event.key === "j" || event.key === "k") { 283 + event.preventDefault(); 284 + setWorkspace("focusedIndex", (current) => { 285 + if (event.key === "j") { 286 + return getNextFocusedIndex(current, "next", items.length); 287 + } 288 + 289 + return getNextFocusedIndex(current, "previous", items.length); 290 + }); 291 + return; 292 + } 293 + 294 + const item = items[workspace.focusedIndex]; 295 + if (!item) { 296 + return; 297 + } 298 + 299 + switch (event.key) { 300 + case "l": { 301 + event.preventDefault(); 302 + void toggleLike(item.post); 303 + break; 304 + } 305 + case "r": { 306 + event.preventDefault(); 307 + openReplyComposer(item.post, getReplyRootPost(item)); 308 + break; 309 + } 310 + case "t": { 311 + event.preventDefault(); 312 + void toggleRepost(item.post); 313 + break; 314 + } 315 + case "o": 316 + case "Enter": { 317 + event.preventDefault(); 318 + void openThread(item.post.uri); 319 + break; 320 + } 321 + default: { 322 + break; 323 + } 324 + } 325 + } 326 + 327 + async function bootstrapFeeds() { 328 + const currentDid = props.activeSession.did; 329 + setWorkspace(reconcile(createInitialWorkspaceState())); 330 + 331 + try { 332 + const nextPreferences = await getPreferences(); 333 + if (currentDid !== props.activeSession.did) { 334 + return; 335 + } 336 + 337 + setWorkspace("preferences", nextPreferences); 338 + setWorkspace("localPrefs", reconcile(buildLocalPrefs(nextPreferences))); 339 + 340 + const uris = [ 341 + ...new Set(nextPreferences.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value)), 342 + ]; 343 + if (uris.length > 0) { 344 + const hydrated = await getFeedGenerators(uris); 345 + setWorkspace( 346 + "generators", 347 + reconcile(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))), 348 + ); 349 + } 350 + 351 + const nextActive = nextPreferences.savedFeeds.find((feed) => feed.pinned) ?? nextPreferences.savedFeeds[0] 352 + ?? DEFAULT_TIMELINE; 353 + setWorkspace("activeFeedId", nextActive.id); 354 + } catch (error) { 355 + props.onError(`Failed to load feeds: ${String(error)}`); 356 + } 357 + } 358 + 359 + async function ensureFeedLoaded(feed: SavedFeedItem) { 360 + const state = workspace.feedStates[feed.id]; 361 + if (state?.loading || state?.loadingMore || state?.items.length) { 362 + return; 363 + } 364 + 365 + await loadFeed(feed, false); 366 + } 367 + 368 + async function loadFeed(feed: SavedFeedItem, append: boolean) { 369 + const state = workspace.feedStates[feed.id] ?? createDefaultFeedState(); 370 + 371 + if (append) { 372 + setWorkspace("feedStates", feed.id, { ...state, error: null, loadingMore: true }); 373 + } else { 374 + setWorkspace("feedStates", feed.id, { ...state, error: null, loading: true }); 375 + } 376 + 377 + try { 378 + const payload = await getFeedPage(feed, state.cursor, DEFAULT_LIMIT); 379 + const items = append ? [...state.items, ...payload.feed] : payload.feed; 380 + setWorkspace("feedStates", feed.id, { 381 + cursor: payload.cursor ?? null, 382 + error: null, 383 + items, 384 + loading: false, 385 + loadingMore: false, 386 + }); 387 + } catch (error) { 388 + setWorkspace("feedStates", feed.id, { ...state, error: String(error), loading: false, loadingMore: false }); 389 + props.onError( 390 + `Failed to load ${getFeedName(feed, workspace.generators[feed.value]?.displayName)}: ${String(error)}`, 391 + ); 392 + } 393 + } 394 + 395 + async function loadThread(uri: string) { 396 + setWorkspace("thread", { data: null, error: null, loading: true, uri }); 397 + 398 + try { 399 + const payload = await getPostThread(uri); 400 + if (props.threadUri === uri) { 401 + setWorkspace("thread", { data: payload.thread, error: null, loading: false, uri }); 402 + } 403 + } catch (error) { 404 + if (props.threadUri === uri) { 405 + setWorkspace("thread", { data: null, error: String(error), loading: false, uri }); 406 + } 407 + props.onError(`Failed to open thread: ${String(error)}`); 408 + } 409 + } 410 + 411 + function switchFeed(feedId: string) { 412 + const current = activeFeed(); 413 + if (current && scroller) { 414 + const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, current.id, scroller.scrollTop); 415 + if (nextScrollTops) { 416 + setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 417 + } 418 + } 419 + 420 + setWorkspace("activeFeedId", feedId); 421 + setWorkspace("focusedIndex", 0); 422 + setWorkspace("showFeedsDrawer", false); 423 + } 424 + 425 + async function openThread(uri: string) { 426 + if (props.threadUri === uri) { 427 + await loadThread(uri); 428 + return; 429 + } 430 + 431 + props.onThreadRouteChange(uri); 432 + } 433 + 434 + function openComposer() { 435 + setWorkspace("composer", "open", true); 436 + } 437 + 438 + function setComposerText(text: string) { 439 + setWorkspace("composer", "text", text); 440 + } 441 + 442 + function resetComposer() { 443 + setWorkspace( 444 + "composer", 445 + (current) => ({ ...current, open: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }), 446 + ); 447 + } 448 + 449 + function openReplyComposer(post: PostView, root: PostView) { 450 + setWorkspace( 451 + "composer", 452 + (current) => ({ ...current, open: true, quoteTarget: null, replyRoot: root, replyTarget: post }), 453 + ); 454 + } 455 + 456 + function openQuoteComposer(post: PostView) { 457 + setWorkspace( 458 + "composer", 459 + (current) => ({ ...current, open: true, quoteTarget: post, replyRoot: null, replyTarget: null }), 460 + ); 461 + } 462 + 463 + function clearQuoteComposer() { 464 + setWorkspace("composer", "quoteTarget", null); 465 + } 466 + 467 + function clearReplyComposer() { 468 + setWorkspace("composer", "replyTarget", null); 469 + setWorkspace("composer", "replyRoot", null); 470 + } 471 + 472 + function applySuggestion(value: string) { 473 + const token = composerToken(); 474 + if (!token) { 475 + return; 476 + } 477 + 478 + setWorkspace( 479 + "composer", 480 + "text", 481 + (current) => current.replace(new RegExp(`${escapeForRegex(token)}$`, "u"), `${value} `), 482 + ); 483 + } 484 + 485 + async function submitPost() { 486 + const text = workspace.composer.text; 487 + const reply = workspace.composer.replyTarget; 488 + const root = workspace.composer.replyRoot; 489 + const quote = workspace.composer.quoteTarget; 490 + 491 + const replyTo: ReplyRefInput | null = reply && root 492 + ? { parent: toStrongRef(reply), root: toStrongRef(root) } 493 + : null; 494 + const embed: EmbedInput | null = quote ? { type: "record", record: toStrongRef(quote) } : null; 495 + 496 + setWorkspace("composer", "pending", true); 497 + try { 498 + await createPost(text, replyTo, embed); 499 + resetComposer(); 500 + props.onThreadRouteChange(null); 501 + const feed = activeFeed(); 502 + await loadFeed(feed, false); 503 + const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, feed.id, 0); 504 + if (nextScrollTops) { 505 + setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 506 + } 507 + if (scroller) { 508 + scroller.scrollTop = 0; 509 + } 510 + } catch (error) { 511 + props.onError(`Failed to create post: ${String(error)}`); 512 + } finally { 513 + setWorkspace("composer", "pending", false); 514 + } 515 + } 516 + 517 + async function toggleLike(post: PostView) { 518 + setWorkspace("likePendingByUri", post.uri, true); 519 + try { 520 + if (post.viewer?.like) { 521 + await unlikePost(post.viewer.like); 522 + patchPost( 523 + post.uri, 524 + (current) => ({ 525 + ...current, 526 + likeCount: Math.max(0, (current.likeCount ?? 0) - 1), 527 + viewer: { ...current.viewer, like: null }, 528 + }), 529 + ); 530 + } else { 531 + const result = await likePost(post.uri, post.cid); 532 + patchPost( 533 + post.uri, 534 + (current) => ({ 535 + ...current, 536 + likeCount: (current.likeCount ?? 0) + 1, 537 + viewer: { ...current.viewer, like: result.uri }, 538 + }), 539 + ); 540 + triggerLikePulse(post.uri); 541 + } 542 + } catch (error) { 543 + props.onError(`Failed to update like: ${String(error)}`); 544 + } finally { 545 + setWorkspace("likePendingByUri", post.uri, false); 546 + } 547 + } 548 + 549 + async function toggleRepost(post: PostView) { 550 + setWorkspace("repostPendingByUri", post.uri, true); 551 + try { 552 + if (post.viewer?.repost) { 553 + await unrepost(post.viewer.repost); 554 + patchPost( 555 + post.uri, 556 + (current) => ({ 557 + ...current, 558 + repostCount: Math.max(0, (current.repostCount ?? 0) - 1), 559 + viewer: { ...current.viewer, repost: null }, 560 + }), 561 + ); 562 + } else { 563 + const result = await repost(post.uri, post.cid); 564 + patchPost( 565 + post.uri, 566 + (current) => ({ 567 + ...current, 568 + repostCount: (current.repostCount ?? 0) + 1, 569 + viewer: { ...current.viewer, repost: result.uri }, 570 + }), 571 + ); 572 + triggerRepostPulse(post.uri); 573 + } 574 + } catch (error) { 575 + props.onError(`Failed to update repost: ${String(error)}`); 576 + } finally { 577 + setWorkspace("repostPendingByUri", post.uri, false); 578 + } 579 + } 580 + 581 + function patchPost(uri: string, updater: (post: PostView) => PostView) { 582 + for (const [feedId, state] of Object.entries(workspace.feedStates)) { 583 + if (!state) { 584 + continue; 585 + } 586 + 587 + setWorkspace("feedStates", feedId, "items", patchFeedItems(state.items, uri, updater)); 588 + } 589 + 590 + const currentThread = workspace.thread.data; 591 + if (currentThread) { 592 + setWorkspace("thread", "data", patchThreadNode(currentThread, uri, updater)); 593 + } 594 + } 595 + 596 + function triggerLikePulse(uri: string) { 597 + setWorkspace("likePulseUri", uri); 598 + globalThis.setTimeout(() => setWorkspace("likePulseUri", (current) => (current === uri ? null : current)), 320); 599 + } 600 + 601 + function triggerRepostPulse(uri: string) { 602 + setWorkspace("repostPulseUri", uri); 603 + globalThis.setTimeout(() => setWorkspace("repostPulseUri", (current) => (current === uri ? null : current)), 320); 604 + } 605 + 606 + async function saveFeedPreferences(updatedFeeds: SavedFeedItem[]) { 607 + try { 608 + await updateSavedFeeds(updatedFeeds); 609 + setWorkspace("preferences", (current) => current ? { ...current, savedFeeds: updatedFeeds } : current); 610 + } catch (error) { 611 + props.onError(`Failed to update feeds: ${String(error)}`); 612 + } 613 + } 614 + 615 + function pinFeed(feedId: string) { 616 + const currentFeeds = workspace.preferences?.savedFeeds ?? []; 617 + const updatedFeeds = currentFeeds.map((feed) => feed.id === feedId ? { ...feed, pinned: true } : feed); 618 + void saveFeedPreferences(updatedFeeds); 619 + } 620 + 621 + function unpinFeed(feedId: string) { 622 + const currentFeeds = workspace.preferences?.savedFeeds ?? []; 623 + const updatedFeeds = currentFeeds.map((feed) => feed.id === feedId ? { ...feed, pinned: false } : feed); 624 + void saveFeedPreferences(updatedFeeds); 625 + } 626 + 627 + function reorderPinnedFeeds(feedId: string, direction: "up" | "down") { 628 + const pinned = pinnedFeeds(); 629 + const index = pinned.findIndex((feed) => feed.id === feedId); 630 + if (index === -1) { 631 + return; 632 + } 633 + 634 + const newIndex = direction === "up" ? index - 1 : index + 1; 635 + if (newIndex < 0 || newIndex >= pinned.length) { 636 + return; 637 + } 638 + 639 + const currentFeeds = [...(workspace.preferences?.savedFeeds ?? [])]; 640 + const feedIds = currentFeeds.map((feed) => feed.id); 641 + const pinnedIds = pinned.map((feed) => feed.id); 642 + 643 + const itemId = pinnedIds[index]; 644 + const swapId = pinnedIds[newIndex]; 645 + const itemIndex = feedIds.indexOf(itemId); 646 + const swapIndex = feedIds.indexOf(swapId); 647 + 648 + if (itemIndex === -1 || swapIndex === -1) { 649 + return; 650 + } 651 + 652 + const reordered = [...currentFeeds]; 653 + [reordered[itemIndex], reordered[swapIndex]] = [reordered[swapIndex], reordered[itemIndex]]; 654 + 655 + void saveFeedPreferences(reordered); 656 + } 657 + 658 + async function setFeedPref<K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) { 659 + const feed = activeFeed(); 660 + const previousPref = activePref(); 661 + const nextPref = { ...previousPref, [key]: value }; 662 + 663 + setWorkspace("localPrefs", feed.value, nextPref); 664 + 665 + try { 666 + await updateFeedViewPref(nextPref); 667 + setWorkspace( 668 + "preferences", 669 + (current) => 670 + current ? { ...current, feedViewPrefs: upsertFeedViewPrefs(current.feedViewPrefs, nextPref) } : current, 671 + ); 672 + } catch (error) { 673 + setWorkspace("localPrefs", feed.value, previousPref); 674 + props.onError(`Failed to update display filters: ${String(error)}`); 675 + } 676 + } 677 + 678 + function toggleFeedsDrawer() { 679 + setWorkspace("showFeedsDrawer", (open) => !open); 680 + } 681 + 682 + function closeFeedsDrawer() { 683 + setWorkspace("showFeedsDrawer", false); 684 + } 685 + 686 + return { 687 + activeFeed, 688 + activeFeedState, 689 + activePref, 690 + applySuggestion, 691 + clearQuoteComposer, 692 + clearReplyComposer, 693 + closeFeedsDrawer, 694 + composerSuggestions, 695 + drawerFeeds, 696 + openComposer, 697 + openThread, 698 + openQuoteComposer, 699 + openReplyComposer, 700 + pinFeed, 701 + pinnedFeeds, 702 + postRefs, 703 + registerScroller, 704 + registerSentinel, 705 + rememberScrollTop, 706 + reorderPinnedFeeds, 707 + resetComposer, 708 + setFeedPref, 709 + setFocusedIndex, 710 + setComposerText, 711 + submitPost, 712 + switchFeed, 713 + toggleFeedsDrawer, 714 + toggleLike, 715 + toggleRepost, 716 + unpinFeed, 717 + visibleItems, 718 + workspace, 719 + }; 720 + }
+1 -1
src/components/shared/Icon.tsx
··· 35 35 <i class={local.iconClass} /> 36 36 </Match> 37 37 <Match when={local.kind === "quill"}> 38 - <i class="i-ri-quill-3-line" /> 38 + <i class="i-ri-quill-pen-line" /> 39 39 </Match> 40 40 <Match when={local.kind === "menu"}> 41 41 <i class="i-ri-menu-line" />
+18
src/lib/api/app.ts
··· 1 + import type { AppBootstrap } from "$/lib/types"; 2 + import { invoke } from "@tauri-apps/api/core"; 3 + 4 + export function getAppBootstrap() { 5 + return invoke<AppBootstrap>("get_app_bootstrap"); 6 + } 7 + 8 + export function login(handle: string) { 9 + return invoke("login", { handle }); 10 + } 11 + 12 + export function logout(did: string) { 13 + return invoke("logout", { did }); 14 + } 15 + 16 + export function switchAccount(did: string) { 17 + return invoke("switch_account", { did }); 18 + }
+55
src/lib/api/feeds.ts
··· 1 + import { getFeedCommand, parseFeedGeneratorsResponse, parseFeedResponse, parseThreadResponse } from "$/lib/feeds"; 2 + import type { 3 + CreateRecordResult, 4 + EmbedInput, 5 + FeedViewPrefItem, 6 + ReplyRefInput, 7 + SavedFeedItem, 8 + UserPreferences, 9 + } from "$/lib/types"; 10 + import { invoke } from "@tauri-apps/api/core"; 11 + 12 + export function getPreferences() { 13 + return invoke<UserPreferences>("get_preferences"); 14 + } 15 + 16 + export async function getFeedGenerators(uris: string[]) { 17 + return parseFeedGeneratorsResponse(await invoke("get_feed_generators", { uris })); 18 + } 19 + 20 + export async function getFeedPage(feed: SavedFeedItem, cursor: string | null, limit: number) { 21 + const command = getFeedCommand(feed); 22 + return parseFeedResponse(await invoke(command.name, command.args(cursor, limit))); 23 + } 24 + 25 + export async function getPostThread(uri: string) { 26 + return parseThreadResponse(await invoke("get_post_thread", { uri })); 27 + } 28 + 29 + export function createPost(text: string, replyTo: ReplyRefInput | null, embed: EmbedInput | null) { 30 + return invoke<CreateRecordResult>("create_post", { embed, replyTo, text }); 31 + } 32 + 33 + export function likePost(uri: string, cid: string) { 34 + return invoke<CreateRecordResult>("like_post", { cid, uri }); 35 + } 36 + 37 + export function unlikePost(likeUri: string) { 38 + return invoke("unlike_post", { likeUri }); 39 + } 40 + 41 + export function repost(uri: string, cid: string) { 42 + return invoke<CreateRecordResult>("repost", { cid, uri }); 43 + } 44 + 45 + export function unrepost(repostUri: string) { 46 + return invoke("unrepost", { repostUri }); 47 + } 48 + 49 + export function updateSavedFeeds(feeds: SavedFeedItem[]) { 50 + return invoke("update_saved_feeds", { feeds }); 51 + } 52 + 53 + export function updateFeedViewPref(pref: FeedViewPrefItem) { 54 + return invoke("update_feed_view_pref", { pref }); 55 + }