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.

fix: validate feed payloads

+644 -202
+8 -1
docs/tasks/05-explorer.md
··· 21 21 - [ ] **Frontend**: breadcrumb navigation bar with `Motion` width animation on segment changes 22 22 - [ ] **Frontend**: `Presence` crossfade transitions between explorer view levels 23 23 - [ ] **Frontend**: keyboard shortcuts — `Backspace` up a level, `Cmd+[/]` back/forward 24 - - [ ] **Optional**: Jetstream live-tail view with `Motion` slide-in for new records 24 + - [ ] **Frontend**: Jetstream live-tail view with `Motion` slide-in for new records 25 + 26 + ### Parking Lot 27 + 28 + These require update to the spec & more research before implementation. 29 + 30 + - [ ] **Frontend**: Firehose Viewer 31 + - [ ] **Frontend**: [Spacedust](https://spacedust.microcosm.blue/) Viewer
+1
docs/tasks/06-search.md
··· 25 25 - [ ] **Frontend**: search results with staggered `Motion` fade-in, highlighted keyword matches 26 26 - [ ] **Frontend**: sync status indicator with animated progress bar, `Presence` fade-out on complete 27 27 - [ ] **Frontend**: model download progress bar (percentage + ETA) on first launch 28 + - Splash/Preflight route should explain what the point of this is 28 29 - [ ] **Frontend**: empty state illustration when no posts synced yet 29 30 - [ ] **Frontend**: `Tab` cycles search mode, `Escape` clears
+6 -1
src-tauri/src/commands.rs
··· 1 1 #![allow(clippy::needless_pass_by_value)] 2 2 use super::auth::{self, LoginSuggestion}; 3 3 use super::error::AppError; 4 - use super::feed::{self, CreateRecordResult, EmbedInput, ReplyRefInput, UserPreferences}; 4 + use super::feed::{self, CreateRecordResult, EmbedInput, FeedViewPrefItem, ReplyRefInput, UserPreferences}; 5 5 use super::state::{AccountSummary, AppBootstrap, AppState}; 6 6 use serde_json::Value; 7 7 use tauri::{AppHandle, State}; ··· 113 113 pub async fn update_saved_feeds(feeds: Vec<feed::SavedFeedItem>, state: State<'_, AppState>) -> Result<(), AppError> { 114 114 feed::update_saved_feeds(feed::UpdateSavedFeedsInput { feeds }, &state).await 115 115 } 116 + 117 + #[tauri::command] 118 + pub async fn update_feed_view_pref(pref: FeedViewPrefItem, state: State<'_, AppState>) -> Result<(), AppError> { 119 + feed::update_feed_view_pref(pref, &state).await 120 + }
+207 -50
src-tauri/src/feed.rs
··· 90 90 pub feed_view_prefs: Vec<FeedViewPrefItem>, 91 91 } 92 92 93 + type StoredPreferences = Vec<PreferencesItem<'static>>; 94 + 93 95 fn extract_saved_feeds(pref: &SavedFeedsPrefV2<'_>) -> Vec<SavedFeedItem> { 94 96 pref.items 95 97 .iter() ··· 118 120 } 119 121 } 120 122 123 + fn user_preferences_from_items(items: &[PreferencesItem<'_>]) -> UserPreferences { 124 + let mut saved_feeds = Vec::new(); 125 + let mut feed_view_prefs = Vec::new(); 126 + 127 + for item in items { 128 + 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 + _ => {} 136 + } 137 + } 138 + 139 + UserPreferences { saved_feeds, feed_view_prefs } 140 + } 141 + 142 + async fn fetch_preference_items(state: &AppState) -> Result<StoredPreferences> { 143 + let session = get_session(state).await?; 144 + fetch_preference_items_with_session(&session).await 145 + } 146 + 147 + async fn fetch_preference_items_with_session(session: &Arc<LazuriteOAuthSession>) -> Result<StoredPreferences> { 148 + let output = session 149 + .send(GetPreferences) 150 + .await 151 + .map_err(|_| AppError::validation("getPreferences"))? 152 + .into_output() 153 + .map_err(|_| AppError::validation("getPreferences output"))?; 154 + 155 + Ok(output.preferences.into_iter().map(IntoStatic::into_static).collect()) 156 + } 157 + 158 + async fn store_preference_items(session: &Arc<LazuriteOAuthSession>, items: StoredPreferences) -> Result<()> { 159 + session 160 + .send(PutPreferences::new().preferences(items).build()) 161 + .await 162 + .map_err(|_| AppError::validation("putPreferences"))? 163 + .into_output() 164 + .map_err(|_| AppError::validation("putPreferences output"))?; 165 + 166 + Ok(()) 167 + } 168 + 169 + fn build_saved_feeds_preference_item(feeds: Vec<SavedFeedItem>) -> PreferencesItem<'static> { 170 + let items = feeds 171 + .into_iter() 172 + .map(|feed| { 173 + SavedFeed::new() 174 + .id(feed.id) 175 + .r#type(match feed.r#type.as_str() { 176 + "timeline" => SavedFeedType::Timeline, 177 + "feed" => SavedFeedType::Feed, 178 + "list" => SavedFeedType::List, 179 + _ => SavedFeedType::Other(feed.r#type.into()), 180 + }) 181 + .value(feed.value) 182 + .pinned(feed.pinned) 183 + .build() 184 + }) 185 + .collect::<Vec<_>>(); 186 + 187 + PreferencesItem::SavedFeedsPrefV2(Box::new(SavedFeedsPrefV2Builder::new().items(items).build())) 188 + } 189 + 190 + fn build_feed_view_pref_item(pref: FeedViewPrefItem) -> PreferencesItem<'static> { 191 + PreferencesItem::FeedViewPref(Box::new(FeedViewPref { 192 + feed: pref.feed.into(), 193 + hide_quote_posts: Some(pref.hide_quote_posts), 194 + hide_replies: Some(pref.hide_replies), 195 + hide_replies_by_like_count: pref.hide_replies_by_like_count, 196 + hide_replies_by_unfollowed: Some(pref.hide_replies_by_unfollowed), 197 + hide_reposts: Some(pref.hide_reposts), 198 + extra_data: Default::default(), 199 + })) 200 + } 201 + 202 + fn merge_saved_feeds_preferences(preferences: StoredPreferences, feeds: Vec<SavedFeedItem>) -> StoredPreferences { 203 + let mut merged = preferences 204 + .into_iter() 205 + .filter(|item| { 206 + !matches!( 207 + item, 208 + PreferencesItem::SavedFeedsPref(_) | PreferencesItem::SavedFeedsPrefV2(_) 209 + ) 210 + }) 211 + .collect::<Vec<_>>(); 212 + merged.push(build_saved_feeds_preference_item(feeds)); 213 + merged 214 + } 215 + 216 + fn merge_feed_view_preferences(preferences: StoredPreferences, pref: FeedViewPrefItem) -> StoredPreferences { 217 + let feed = pref.feed.clone(); 218 + let mut merged = preferences 219 + .into_iter() 220 + .filter(|item| match item { 221 + PreferencesItem::FeedViewPref(existing) => existing.feed.as_ref() != feed.as_str(), 222 + _ => true, 223 + }) 224 + .collect::<Vec<_>>(); 225 + merged.push(build_feed_view_pref_item(pref)); 226 + merged 227 + } 228 + 121 229 #[derive(Debug, Deserialize)] 122 230 #[serde(rename_all = "camelCase")] 123 231 pub struct StrongRefInput { ··· 146 254 } 147 255 148 256 pub async fn get_preferences(state: &AppState) -> Result<UserPreferences> { 149 - let session = get_session(state).await?; 150 - let output = session 151 - .send(GetPreferences) 152 - .await 153 - .map_err(|_| AppError::validation("getPreferences"))? 154 - .into_output() 155 - .map_err(|_| AppError::validation("getPreferences output"))?; 156 - 157 - let mut saved_feeds = Vec::new(); 158 - let mut feed_view_prefs = Vec::new(); 159 - 160 - for item in &output.preferences { 161 - match item { 162 - PreferencesItem::SavedFeedsPrefV2(pref) => { 163 - saved_feeds = extract_saved_feeds(pref); 164 - } 165 - PreferencesItem::FeedViewPref(pref) => { 166 - feed_view_prefs.push(extract_feed_view_pref(pref)); 167 - } 168 - _ => {} 169 - } 170 - } 171 - 172 - Ok(UserPreferences { saved_feeds, feed_view_prefs }) 257 + let preferences = fetch_preference_items(state).await?; 258 + Ok(user_preferences_from_items(&preferences)) 173 259 } 174 260 175 261 pub async fn get_feed_generators(uris: Vec<String>, state: &AppState) -> Result<serde_json::Value> { ··· 476 562 477 563 pub async fn update_saved_feeds(input: UpdateSavedFeedsInput, state: &AppState) -> Result<()> { 478 564 let session = get_session(state).await?; 565 + let preferences = fetch_preference_items_with_session(&session).await?; 566 + let merged = merge_saved_feeds_preferences(preferences, input.feeds); 567 + store_preference_items(&session, merged).await 568 + } 479 569 480 - let items: Vec<SavedFeed<'_>> = input 481 - .feeds 482 - .into_iter() 483 - .map(|f| { 484 - SavedFeed::new() 485 - .id(f.id) 486 - .r#type(match f.r#type.as_str() { 487 - "timeline" => SavedFeedType::Timeline, 488 - "feed" => SavedFeedType::Feed, 489 - "list" => SavedFeedType::List, 490 - _ => SavedFeedType::Other(f.r#type.into()), 491 - }) 492 - .value(f.value) 493 - .pinned(f.pinned) 494 - .build() 495 - }) 496 - .collect(); 570 + pub async fn update_feed_view_pref(pref: FeedViewPrefItem, state: &AppState) -> Result<()> { 571 + let session = get_session(state).await?; 572 + let preferences = fetch_preference_items_with_session(&session).await?; 573 + let merged = merge_feed_view_preferences(preferences, pref); 574 + store_preference_items(&session, merged).await 575 + } 576 + 577 + #[cfg(test)] 578 + mod tests { 579 + use super::{ 580 + merge_feed_view_preferences, merge_saved_feeds_preferences, user_preferences_from_items, FeedViewPrefItem, 581 + SavedFeedItem, 582 + }; 583 + use jacquard::api::app_bsky::actor::{AdultContentPref, FeedViewPref, PreferencesItem}; 584 + 585 + fn adult_content_pref_item() -> PreferencesItem<'static> { 586 + PreferencesItem::AdultContentPref(Box::new(AdultContentPref::new().enabled(true).build())) 587 + } 588 + 589 + fn feed_view_pref_item(feed: &str, hide_reposts: bool) -> PreferencesItem<'static> { 590 + PreferencesItem::FeedViewPref(Box::new(FeedViewPref { 591 + feed: feed.to_owned().into(), 592 + hide_quote_posts: Some(false), 593 + hide_replies: Some(false), 594 + hide_replies_by_like_count: None, 595 + hide_replies_by_unfollowed: Some(true), 596 + hide_reposts: Some(hide_reposts), 597 + extra_data: Default::default(), 598 + })) 599 + } 600 + 601 + #[test] 602 + fn merging_saved_feeds_preserves_other_preferences() { 603 + let preferences = vec![adult_content_pref_item(), feed_view_pref_item("following", true)]; 604 + let merged = merge_saved_feeds_preferences( 605 + preferences, 606 + vec![SavedFeedItem { 607 + id: "following".into(), 608 + r#type: "timeline".into(), 609 + value: "following".into(), 610 + pinned: true, 611 + }], 612 + ); 613 + 614 + assert!(merged 615 + .iter() 616 + .any(|item| matches!(item, PreferencesItem::AdultContentPref(_)))); 617 + assert!(merged 618 + .iter() 619 + .any(|item| matches!(item, PreferencesItem::FeedViewPref(_)))); 620 + 621 + let user_preferences = user_preferences_from_items(&merged); 622 + assert_eq!(user_preferences.saved_feeds.len(), 1); 623 + assert_eq!(user_preferences.feed_view_prefs.len(), 1); 624 + assert!(user_preferences.feed_view_prefs[0].hide_reposts); 625 + } 626 + 627 + #[test] 628 + fn merging_feed_view_pref_replaces_only_matching_feed() { 629 + let preferences = vec![ 630 + adult_content_pref_item(), 631 + feed_view_pref_item("following", true), 632 + feed_view_pref_item("at://feed/custom", false), 633 + ]; 634 + let merged = merge_feed_view_preferences( 635 + preferences, 636 + FeedViewPrefItem { 637 + feed: "following".into(), 638 + hide_replies: true, 639 + hide_replies_by_unfollowed: false, 640 + hide_replies_by_like_count: Some(4), 641 + hide_reposts: false, 642 + hide_quote_posts: true, 643 + }, 644 + ); 497 645 498 - let saved_feeds_pref = Box::new(SavedFeedsPrefV2Builder::new().items(items).build()); 499 - let pref_item = PreferencesItem::SavedFeedsPrefV2(saved_feeds_pref); 646 + let user_preferences = user_preferences_from_items(&merged); 647 + assert_eq!(user_preferences.feed_view_prefs.len(), 2); 500 648 501 - session 502 - .send(PutPreferences::new().preferences(vec![pref_item]).build()) 503 - .await 504 - .map_err(|_| AppError::validation("putPreferences"))? 505 - .into_output() 506 - .map_err(|_| AppError::validation("putPreferences output"))?; 649 + let following = user_preferences 650 + .feed_view_prefs 651 + .iter() 652 + .find(|pref| pref.feed == "following") 653 + .expect("following pref should exist"); 654 + assert!(!following.hide_reposts); 655 + assert!(following.hide_quote_posts); 656 + assert_eq!(following.hide_replies_by_like_count, Some(4)); 507 657 508 - Ok(()) 658 + let custom = user_preferences 659 + .feed_view_prefs 660 + .iter() 661 + .find(|pref| pref.feed == "at://feed/custom") 662 + .expect("custom pref should exist"); 663 + assert!(!custom.hide_quote_posts); 664 + assert!(!custom.hide_replies); 665 + } 509 666 }
+2 -1
src-tauri/src/lib.rs
··· 74 74 cmd::unlike_post, 75 75 cmd::repost, 76 76 cmd::unrepost, 77 - cmd::update_saved_feeds 77 + cmd::update_saved_feeds, 78 + cmd::update_feed_view_pref 78 79 ]) 79 80 .run(tauri::generate_context!()) 80 81 .expect("error while running tauri application");
+7 -1
src/App.css
··· 1 - @import "tailwindcss"; 1 + /* NOTE: This prevents vite from processing src-tauri */ 2 + @import "tailwindcss" source(none); 3 + @source "./"; 2 4 @plugin "@egoist/tailwindcss-icons"; 3 5 @plugin "@tailwindcss/forms"; 4 6 ··· 98 100 transform: translateX(100%); 99 101 } 100 102 } 103 + 104 + button { 105 + @apply cursor-pointer disabled:cursor-auto; 106 + }
+4 -2
src/App.tsx
··· 62 62 const primaryAccount = createMemo(() => activeAccount() ?? app.accounts[0] ?? null); 63 63 const hasSession = createMemo(() => !!app.activeSession); 64 64 const railCompact = createMemo(() => app.railCollapsed && !app.narrowViewport); 65 + const railCondensed = createMemo(() => railCompact() || app.narrowViewport); 65 66 const railColumns = createMemo(() => (railCompact() ? "5.75rem minmax(0,1fr)" : "16rem minmax(0,1fr)")); 66 67 const metaLabel = createMemo(() => { 67 68 if (app.bootstrapping) { ··· 209 210 activeAccount={activeAccount()} 210 211 activeSession={app.activeSession} 211 212 accounts={app.accounts} 212 - collapsed={railCompact()} 213 + collapsed={railCondensed()} 213 214 hasSession={hasSession()} 214 215 logoutDid={app.logoutDid} 216 + narrow={app.narrowViewport} 215 217 openSwitcher={app.showSwitcher} 216 218 switchingDid={app.switchingDid} 217 219 onLogout={(did) => void logout(did)} ··· 220 222 onToggleSwitcher={() => setApp("showSwitcher", (open) => !open)} /> 221 223 222 224 <section 223 - class="m-5 grid min-h-0 overflow-hidden gap-6 rounded-2xl bg-surface p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-6 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-5 max-[1180px]:overflow-visible max-[760px]:gap-5 max-[760px]:p-4" 225 + class="m-5 grid min-h-0 overflow-hidden gap-6 rounded-2xl bg-surface p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-6 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-4.75rem)] max-[1180px]:rounded-none max-[1180px]:p-5 max-[1180px]:overflow-visible max-[900px]:gap-5 max-[900px]:p-4 max-[640px]:gap-4 max-[640px]:p-3" 224 226 aria-busy={app.bootstrapping}> 225 227 {props.children} 226 228 </section>
+6 -5
src/components/AppRail.tsx
··· 8 8 function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 9 9 return ( 10 10 <div 11 - class="flex shrink-0 items-center justify-between gap-3 max-[1180px]:min-w-0 max-[1180px]:items-center" 11 + class="flex shrink-0 items-center justify-between gap-3 max-[1180px]:min-w-0 max-[1180px]:justify-self-start" 12 12 classList={{ "w-full flex-col gap-3": props.collapsed }}> 13 13 <Wordmark compact={props.collapsed} iconClass="text-primary" /> 14 14 <button ··· 27 27 28 28 function RailNavigation(props: { collapsed: boolean; hasSession: boolean }) { 29 29 return ( 30 - <div class="grid gap-1 max-[1180px]:min-w-0 max-[1180px]:flex-1 max-[1180px]:overflow-x-auto max-[1180px]:overscroll-contain max-[1180px]:[scrollbar-width:none] max-[1180px]:[&::-webkit-scrollbar]:hidden"> 30 + <div class="grid gap-1 max-[1180px]:col-start-2 max-[1180px]:row-start-1 max-[1180px]:flex max-[1180px]:min-w-0 max-[1180px]:items-center max-[1180px]:gap-2 max-[1180px]:overflow-x-auto max-[1180px]:overscroll-contain max-[1180px]:[scrollbar-width:none] max-[1180px]:[&::-webkit-scrollbar]:hidden max-[760px]:col-start-auto max-[760px]:row-start-auto"> 31 31 <Show 32 32 when={props.hasSession} 33 33 fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> ··· 48 48 collapsed: boolean; 49 49 hasSession: boolean; 50 50 logoutDid: string | null; 51 + narrow: boolean; 51 52 openSwitcher: boolean; 52 53 switchingDid: string | null; 53 54 onLogout: (did: string) => void; ··· 58 59 ) { 59 60 return ( 60 61 <aside 61 - class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:min-h-0 max-[1180px]:flex-row max-[1180px]:flex-wrap max-[1180px]:items-center max-[1180px]:gap-3 max-[1180px]:p-4 max-[760px]:items-stretch" 62 - classList={{ "items-center px-4": props.collapsed, "gap-5": props.collapsed }} 62 + class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:grid max-[1180px]:min-h-0 max-[1180px]:grid-cols-[auto_minmax(0,1fr)] max-[1180px]:items-center max-[1180px]:gap-x-4 max-[1180px]:gap-y-3 max-[1180px]:p-4 max-[760px]:grid-cols-1 max-[760px]:items-stretch" 63 + classList={{ "items-center px-4": props.collapsed && !props.narrow, "gap-5": props.collapsed && !props.narrow }} 63 64 aria-label="Primary navigation"> 64 65 <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> 65 66 <RailNavigation collapsed={props.collapsed} hasSession={props.hasSession} /> ··· 68 69 activeSession={props.activeSession} 69 70 accounts={props.accounts} 70 71 busyDid={props.switchingDid} 71 - compact={props.collapsed} 72 + compact={props.collapsed && !props.narrow} 72 73 logoutDid={props.logoutDid} 73 74 open={props.openSwitcher} 74 75 onToggle={props.onToggleSwitcher}
+3 -3
src/components/ReauthBanner.tsx
··· 4 4 export function ReauthBanner(props: { onReauth: () => void }) { 5 5 return ( 6 6 <Motion.div 7 - class="flex items-center justify-between gap-4 rounded-xl bg-primary/12 px-[1.1rem] py-4 max-[920px]:flex-col max-[920px]:items-stretch" 7 + class="flex items-center justify-between gap-4 rounded-lg bg-primary/12 p-4 max-[920px]:flex-col max-[920px]:items-stretch" 8 8 role="status" 9 9 initial={{ opacity: 0, y: 12 }} 10 - animate={{ opacity: 1, y: 0, scale: [1, 1.015, 1] }} 10 + animate={{ opacity: 1, y: 0, scale: [1, 1.008, 1] }} 11 11 exit={{ opacity: 0, y: 8 }} 12 - transition={{ duration: 1.8, repeat: Number.POSITIVE_INFINITY, easing: "ease-in-out" }}> 12 + transition={{ duration: 0.9, easing: "ease-in-out" }}> 13 13 <div class="grid gap-[0.2rem]"> 14 14 <p class="m-0 text-base font-semibold">Your session expired.</p> 15 15 <p class="m-0 text-xs text-on-surface-variant">Sign in again to reconnect your account.</p>
+2 -6
src/components/account/AccountSwitcher.tsx
··· 43 43 44 44 return ( 45 45 <div 46 - class="relative mt-auto w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:mt-0 max-[1180px]:max-w-[24rem] max-[1180px]:justify-self-end max-[760px]:max-w-none" 47 - classList={{ 48 - "w-auto": !!props.compact, 49 - "max-[980px]:order-3 max-[980px]:w-full max-[980px]:max-w-none max-[980px]:justify-self-stretch": !props 50 - .compact, 51 - }} 46 + class="relative mt-auto w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:col-span-full max-[1180px]:mt-0 max-[1180px]:max-w-none max-[1180px]:justify-self-stretch" 47 + classList={{ "w-auto": !!props.compact }} 52 48 ref={(element) => { 53 49 container = element; 54 50 }}>
+4 -4
src/components/feeds/FeedPane.tsx
··· 8 8 9 9 function FeedHeaderActions(props: { onCompose: () => void; onToggleDrawer: () => void }) { 10 10 return ( 11 - <div class="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[640px]:w-full max-[640px]:justify-between"> 11 + <div class="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[960px]:w-full max-[960px]:justify-between"> 12 12 <button 13 - class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 max-[520px]:flex-1 max-[520px]:justify-center" 13 + class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 max-[960px]:flex-1 max-[960px]:justify-center max-[520px]:px-3" 14 14 type="button" 15 15 onClick={() => props.onCompose()}> 16 16 <Icon aria-hidden="true" kind="quill" /> ··· 87 87 }, 88 88 ) { 89 89 return ( 90 - <div class="flex min-w-0 items-start justify-between gap-4 max-[900px]:gap-3 max-[640px]:flex-col max-[640px]:items-stretch"> 90 + <div class="flex min-w-0 items-start justify-between gap-4 max-[960px]:flex-col max-[960px]:items-stretch max-[900px]:gap-3"> 91 91 <div class="min-w-0"> 92 92 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Timeline</p> 93 93 <p class="mt-1 wrap-break-word text-xs uppercase tracking-[0.12em] text-on-surface-variant"> ··· 110 110 }, 111 111 ) { 112 112 return ( 113 - <header class="sticky top-0 z-20 overflow-hidden rounded-t-4xl bg-[rgba(14,14,14,0.94)] px-6 pb-3 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)] max-[760px]:px-4 max-[760px]:pt-4 max-[520px]:px-3"> 113 + <header class="sticky top-0 z-20 overflow-hidden rounded-t-4xl bg-[rgba(14,14,14,0.94)] px-6 pb-3 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)] max-[960px]:px-5 max-[960px]:pb-4 max-[960px]:pt-4 max-[760px]:px-4 max-[520px]:px-3"> 114 114 <FeedPaneTitle 115 115 activeFeed={props.activeFeed} 116 116 generators={props.generators}
+3 -3
src/components/feeds/FeedTabs.tsx
··· 14 14 }, 15 15 ) { 16 16 return ( 17 - <div class="mt-4 flex items-start gap-3 max-[720px]:mt-3 max-[720px]:gap-2"> 17 + <div class="mt-4 flex items-start gap-3 max-[960px]:mt-3 max-[960px]:gap-2"> 18 18 <div class="flex min-w-0 flex-1 gap-1.5 overflow-x-auto overscroll-contain pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> 19 19 <For each={props.pinnedFeeds}> 20 20 {(feed) => ( ··· 27 27 </For> 28 28 </div> 29 29 <button 30 - class="inline-flex h-11 shrink-0 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 max-[1040px]:px-3 max-[1040px]:text-xs max-[920px]:hidden" 30 + class="inline-flex h-11 shrink-0 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 max-[1040px]:px-3 max-[1040px]:text-xs max-[900px]:w-11 max-[900px]:justify-center max-[900px]:px-0" 31 31 type="button" 32 32 onClick={() => props.onToggleDrawer()}> 33 33 <Icon aria-hidden="true" iconClass="i-ri-stack-line" /> 34 - <span>Saved feeds</span> 34 + <span class="max-[900px]:hidden">Saved feeds</span> 35 35 </button> 36 36 </div> 37 37 );
+75 -79
src/components/feeds/FeedWorkspace.tsx
··· 5 5 getFeedCommand, 6 6 getFeedName, 7 7 getReplyRootPost, 8 + parseFeedGeneratorsResponse, 9 + parseFeedResponse, 10 + parseThreadResponse, 8 11 patchFeedItems, 9 12 patchThreadNode, 10 13 toStrongRef, ··· 14 17 CreateRecordResult, 15 18 EmbedInput, 16 19 FeedGeneratorView, 17 - FeedResponse, 18 20 FeedViewPrefItem, 19 21 PostView, 20 22 ReplyRefInput, 21 23 SavedFeedItem, 22 - ThreadResponse, 23 24 UserPreferences, 24 25 } from "$/lib/types"; 25 26 import { shouldIgnoreKey } from "$/lib/utils/events"; ··· 33 34 import { SavedFeedsDrawer } from "./FeedDrawer"; 34 35 import { FeedPane } from "./FeedPane"; 35 36 import { ThreadPanel } from "./ThreadPanel"; 36 - import type { FeedState, FeedWorkspaceState } from "./types"; 37 + import type { FeedWorkspaceState } from "./types"; 38 + import { 39 + buildLocalPrefs, 40 + createDefaultFeedPref, 41 + createDefaultFeedState, 42 + createDefaultThreadState, 43 + createInitialWorkspaceState, 44 + DEFAULT_TIMELINE, 45 + getNextFocusedIndex, 46 + getNextFocusedScrollTop, 47 + updateFeedScrollState, 48 + upsertFeedViewPrefs, 49 + } from "./workspace-state"; 37 50 38 51 type FeedWorkspaceProps = { 39 52 activeSession: ActiveSession; ··· 44 57 45 58 const DEFAULT_LIMIT = 30; 46 59 47 - const DEFAULT_TIMELINE: SavedFeedItem = { id: "following", type: "timeline", value: "following", pinned: true }; 48 - 49 - function createDefaultFeedState(): FeedState { 50 - return { cursor: null, error: null, items: [], loading: false, loadingMore: false, scrollTop: 0 }; 51 - } 52 - 53 - function createDefaultFeedPref(feed: SavedFeedItem): FeedViewPrefItem { 54 - return { 55 - feed: feed.value, 56 - hideQuotePosts: false, 57 - hideReplies: false, 58 - hideRepliesByLikeCount: null, 59 - hideRepliesByUnfollowed: false, 60 - hideReposts: false, 61 - }; 62 - } 63 - 64 - function createInitialWorkspaceState(): FeedWorkspaceState { 65 - return { 66 - activeFeedId: null, 67 - composer: { open: false, pending: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }, 68 - feedStates: {}, 69 - focusedIndex: 0, 70 - generators: {}, 71 - likePendingByUri: {}, 72 - likePulseUri: null, 73 - localPrefs: {}, 74 - preferences: null, 75 - repostPendingByUri: {}, 76 - repostPulseUri: null, 77 - showFeedsDrawer: false, 78 - thread: createDefaultThreadState(), 79 - }; 80 - } 81 - 82 - function createDefaultThreadState() { 83 - return { data: null, error: null, loading: false, uri: null } satisfies FeedWorkspaceState["thread"]; 84 - } 85 - 86 60 export function FeedWorkspace(props: FeedWorkspaceProps) { 87 61 const [workspace, setWorkspace] = createStore<FeedWorkspaceState>(createInitialWorkspaceState()); 88 62 ··· 150 124 void ensureFeedLoaded(feed); 151 125 const nextScrollTop = workspace.feedStates[feed.id]?.scrollTop ?? 0; 152 126 queueMicrotask(() => { 153 - if (scroller) { 127 + if (scroller && scroller.scrollTop !== nextScrollTop) { 154 128 scroller.scrollTop = nextScrollTop; 155 129 } 156 130 }); ··· 195 169 196 170 lastFocusedUri = item.post.uri; 197 171 queueMicrotask(() => { 172 + if (!scroller) { 173 + return; 174 + } 175 + 198 176 const element = postRefs.get(item.post.uri); 199 177 if (!element?.isConnected) { 200 178 return; 201 179 } 202 180 203 - if (document.activeElement !== element) { 204 - element.focus(); 205 - } 181 + const scrollerRect = scroller.getBoundingClientRect(); 182 + const elementRect = element.getBoundingClientRect(); 183 + const itemTop = elementRect.top - scrollerRect.top + scroller.scrollTop; 184 + const nextScrollTop = getNextFocusedScrollTop( 185 + scroller.scrollTop, 186 + scroller.clientHeight, 187 + itemTop, 188 + element.offsetHeight, 189 + ); 206 190 207 - element.scrollIntoView({ block: "nearest" }); 191 + if (nextScrollTop !== null && scroller.scrollTop !== nextScrollTop) { 192 + scroller.scrollTop = nextScrollTop; 193 + } 208 194 }); 209 195 }); 210 196 ··· 263 249 event.preventDefault(); 264 250 setWorkspace("focusedIndex", (current) => { 265 251 if (event.key === "j") { 266 - return Math.min(current + 1, items.length - 1); 252 + return getNextFocusedIndex(current, "next", items.length); 267 253 } 268 254 269 - return Math.max(current - 1, 0); 255 + return getNextFocusedIndex(current, "previous", items.length); 270 256 }); 271 257 return; 272 258 } ··· 331 317 } 332 318 333 319 setWorkspace("preferences", nextPreferences); 334 - setWorkspace( 335 - "localPrefs", 336 - reconcile(Object.fromEntries(nextPreferences.feedViewPrefs.map((pref) => [pref.feed, pref]))), 337 - ); 320 + setWorkspace("localPrefs", reconcile(buildLocalPrefs(nextPreferences))); 338 321 339 322 const uris = [ 340 323 ...new Set(nextPreferences.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value)), 341 324 ]; 342 325 if (uris.length > 0) { 343 - const hydrated = await invoke<{ feeds: FeedGeneratorView[] }>("get_feed_generators", { uris }); 326 + const hydrated = parseFeedGeneratorsResponse(await invoke("get_feed_generators", { uris })); 344 327 setWorkspace( 345 328 "generators", 346 329 reconcile(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))), ··· 375 358 376 359 try { 377 360 const command = getFeedCommand(feed); 378 - const payload = await invoke<FeedResponse>(command.name, command.args(state.cursor, DEFAULT_LIMIT)); 361 + const payload = parseFeedResponse(await invoke(command.name, command.args(state.cursor, DEFAULT_LIMIT))); 379 362 const items = append ? [...state.items, ...payload.feed] : payload.feed; 380 363 setWorkspace("feedStates", feed.id, { 381 364 cursor: payload.cursor ?? null, ··· 397 380 setWorkspace("thread", { data: null, error: null, loading: true, uri }); 398 381 399 382 try { 400 - const payload = await invoke<ThreadResponse>("get_post_thread", { uri }); 383 + const payload = parseThreadResponse(await invoke("get_post_thread", { uri })); 401 384 if (props.threadUri === uri) { 402 385 setWorkspace("thread", { data: payload.thread, error: null, loading: false, uri }); 403 386 } ··· 634 617 635 618 return ( 636 619 <> 637 - <div class="grid h-full min-h-0 min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 620 + <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"> 638 621 <FeedPane 639 622 activeFeed={activeFeed()} 640 623 activeFeedId={activeFeed().id} ··· 663 646 sentinelRef={(element) => { 664 647 sentinel = element; 665 648 }} 666 - setScrollTop={(top) => 667 - setWorkspace("feedStates", activeFeed().id, { 668 - ...(workspace.feedStates[activeFeed().id] ?? createDefaultFeedState()), 669 - scrollTop: top, 670 - })} 649 + setScrollTop={(top) => { 650 + const feedId = activeFeed().id; 651 + const nextState = updateFeedScrollState(workspace.feedStates[feedId], top); 652 + if (!nextState) { 653 + return; 654 + } 655 + 656 + setWorkspace("feedStates", feedId, nextState); 657 + }} 671 658 visibleItems={visibleItems()} /> 672 659 673 660 <WorkspaceSidebar ··· 721 708 </> 722 709 ); 723 710 724 - function setFeedPref<K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) { 711 + async function setFeedPref<K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) { 725 712 const feed = activeFeed(); 726 - setWorkspace("localPrefs", feed.value, { ...activePref(), [key]: value }); 713 + const previousPref = activePref(); 714 + const nextPref = { ...previousPref, [key]: value }; 715 + 716 + setWorkspace("localPrefs", feed.value, nextPref); 717 + 718 + try { 719 + await invoke("update_feed_view_pref", { pref: nextPref }); 720 + setWorkspace( 721 + "preferences", 722 + (current) => 723 + current ? { ...current, feedViewPrefs: upsertFeedViewPrefs(current.feedViewPrefs, nextPref) } : current, 724 + ); 725 + } catch (error) { 726 + setWorkspace("localPrefs", feed.value, previousPref); 727 + props.onError(`Failed to update display filters: ${String(error)}`); 728 + } 727 729 } 728 730 } 729 731 730 732 function WorkspaceSidebar( 731 733 props: { 732 - activePref: UserPreferences["feedViewPrefs"][number]; 734 + activePref: FeedViewPrefItem; 733 735 drawerFeeds: SavedFeedItem[]; 734 736 generators: Record<string, FeedGeneratorView>; 735 737 onFeedSelect: (feedId: string) => void; 736 - onPrefChange: <K extends keyof UserPreferences["feedViewPrefs"][number]>( 737 - key: K, 738 - value: UserPreferences["feedViewPrefs"][number][K], 739 - ) => void; 738 + onPrefChange: <K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) => void; 740 739 }, 741 740 ) { 742 741 return ( 743 - <aside class="grid min-h-0 min-w-0 gap-4 overflow-hidden xl:overflow-y-auto xl:overscroll-contain"> 742 + <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"> 744 743 <SavedFeedsCard drawerFeeds={props.drawerFeeds} generators={props.generators} onFeedSelect={props.onFeedSelect} /> 745 744 <DisplayFiltersCard activePref={props.activePref} onPrefChange={props.onPrefChange} /> 746 745 <ShortcutsCard /> ··· 792 791 793 792 function DisplayFiltersCard( 794 793 props: { 795 - activePref: UserPreferences["feedViewPrefs"][number]; 796 - onPrefChange: <K extends keyof UserPreferences["feedViewPrefs"][number]>( 797 - key: K, 798 - value: UserPreferences["feedViewPrefs"][number][K], 799 - ) => void; 794 + activePref: FeedViewPrefItem; 795 + onPrefChange: <K extends keyof FeedViewPrefItem>(key: K, value: FeedViewPrefItem[K]) => void; 800 796 }, 801 797 ) { 802 798 return ( ··· 805 801 <ToggleRow 806 802 checked={props.activePref.hideReposts} 807 803 label="Hide reposts" 808 - onChange={(checked) => props.onPrefChange("hideReposts", checked)} /> 804 + onChange={(checked) => void props.onPrefChange("hideReposts", checked)} /> 809 805 <ToggleRow 810 806 checked={props.activePref.hideReplies} 811 807 label="Hide replies" 812 - onChange={(checked) => props.onPrefChange("hideReplies", checked)} /> 808 + onChange={(checked) => void props.onPrefChange("hideReplies", checked)} /> 813 809 <ToggleRow 814 810 checked={props.activePref.hideQuotePosts} 815 811 label="Hide quotes" 816 - onChange={(checked) => props.onPrefChange("hideQuotePosts", checked)} /> 812 + onChange={(checked) => void props.onPrefChange("hideQuotePosts", checked)} /> 817 813 <ReplyLikeThreshold 818 814 value={props.activePref.hideRepliesByLikeCount} 819 - onChange={(value) => props.onPrefChange("hideRepliesByLikeCount", value)} /> 815 + onChange={(value) => void props.onPrefChange("hideRepliesByLikeCount", value)} /> 820 816 </div> 821 817 </SidebarCard> 822 818 );
+24 -23
src/components/feeds/PostCard.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { 3 - formatCount, 4 3 formatRelativeTime, 5 4 getAvatarLabel, 6 5 getDisplayName, ··· 9 8 getQuotedAuthor, 10 9 getQuotedText, 11 10 } from "$/lib/feeds"; 12 - import type { FeedViewPost, ImagesEmbedView, PostView } from "$/lib/types"; 11 + import type { FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic } from "$/lib/types"; 12 + import { formatCount } from "$/lib/utils/text"; 13 13 import { createMemo, For, Match, Show, Switch } from "solid-js"; 14 14 import { Motion } from "solid-motionone"; 15 15 ··· 43 43 44 44 return `${getDisplayName(reason.by)} reposted`; 45 45 }); 46 + 47 + const likeCount = createMemo(() => formatCount(props.post.likeCount)); 48 + const replyCount = createMemo(() => formatCount(props.post.replyCount)); 49 + const repostCount = createMemo(() => formatCount(props.post.repostCount)); 46 50 47 51 return ( 48 52 <Motion.article ··· 99 103 busy={!!props.likePending} 100 104 icon="i-ri-heart-3-line" 101 105 iconActive="i-ri-heart-3-fill" 102 - label={formatCount(props.post.likeCount)} 106 + label={likeCount()} 103 107 pulse={!!props.pulseLike} 104 108 onClick={props.onLike} /> 105 - <ActionButton icon="i-ri-chat-1-line" label={formatCount(props.post.replyCount)} onClick={props.onReply} /> 109 + <ActionButton icon="i-ri-chat-1-line" label={replyCount()} onClick={props.onReply} /> 106 110 <ActionButton 107 111 active={isReposted()} 108 112 busy={!!props.repostPending} 109 113 icon="i-ri-repeat-2-line" 110 114 iconActive="i-ri-repeat-2-fill" 111 - label={formatCount(props.post.repostCount)} 115 + label={repostCount()} 112 116 pulse={!!props.pulseRepost} 113 117 onClick={props.onRepost} /> 114 118 <ActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={props.onQuote} /> ··· 167 171 } 168 172 169 173 function PostEmbeds(props: { post: PostView }) { 170 - const embed = createMemo(() => props.post.embed); 171 - 172 174 return ( 173 - <Show when={embed()}> 175 + <Show when={props.post.embed}> 174 176 {(current) => ( 175 177 <div class="mt-4"> 176 178 <Switch> ··· 204 206 } 205 207 206 208 function ImageEmbed(props: { embed: ImagesEmbedView }) { 209 + const images = createMemo(() => props.embed.images.slice(0, 4)); 207 210 return ( 208 211 <div class="grid min-w-0 gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}> 209 - <For each={props.embed.images.slice(0, 4)}> 212 + <For each={images()}> 210 213 {(image) => ( 211 214 <div class="overflow-hidden rounded-[1.2rem] bg-black/30 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 212 215 <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} /> ··· 246 249 ); 247 250 } 248 251 249 - function QuoteEmbed(props: { author: PostView["author"] | null; text?: unknown; title: string }) { 252 + function QuoteEmbed(props: { author: ProfileViewBasic | null; text?: unknown; title: string }) { 250 253 const preview = createMemo(() => (typeof props.text === "string" ? props.text : "")); 254 + const title = () => props.title; 251 255 252 256 return ( 253 257 <div class="rounded-[1.25rem] bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 254 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 258 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{title()}</p> 255 259 <Show when={props.author}> 256 260 {(author) => ( 257 261 <p class="mt-2 wrap-break-word text-sm font-semibold text-on-surface"> ··· 274 278 275 279 return ( 276 280 <For each={parts()}> 277 - {(part) => { 278 - if (/^https?:\/\//i.test(part)) { 279 - return ( 281 + {(part) => ( 282 + <Switch fallback={<span class="wrap-anywhere">{part}</span>}> 283 + <Match when={/^https?:\/\//i.test(part)}> 280 284 <a class="break-all text-primary no-underline hover:underline" href={part} rel="noreferrer" target="_blank"> 281 285 {part} 282 286 </a> 283 - ); 284 - } 285 - 286 - if (/^[@#]/.test(part)) { 287 - return <span class="break-all text-primary">{part}</span>; 288 - } 289 - 290 - return <span class="wrap-anywhere">{part}</span>; 291 - }} 287 + </Match> 288 + <Match when={/^[@#]/.test(part)}> 289 + <span class="break-all text-primary">{part}</span> 290 + </Match> 291 + </Switch> 292 + )} 292 293 </For> 293 294 ); 294 295 }
+13 -9
src/components/feeds/types.ts
··· 16 16 scrollTop: number; 17 17 }; 18 18 19 + export type ComposerState = { 20 + open: boolean; 21 + pending: boolean; 22 + quoteTarget: PostView | null; 23 + replyRoot: PostView | null; 24 + replyTarget: PostView | null; 25 + text: string; 26 + }; 27 + 28 + export type ThreadState = { data: ThreadNode | null; error: string | null; loading: boolean; uri: string | null }; 29 + 19 30 export type FeedWorkspaceState = { 20 31 activeFeedId: string | null; 21 - composer: { 22 - open: boolean; 23 - pending: boolean; 24 - quoteTarget: PostView | null; 25 - replyRoot: PostView | null; 26 - replyTarget: PostView | null; 27 - text: string; 28 - }; 32 + composer: ComposerState; 29 33 feedStates: Record<string, FeedState>; 30 34 focusedIndex: number; 31 35 generators: Record<string, FeedGeneratorView>; ··· 36 40 repostPendingByUri: Record<string, boolean>; 37 41 repostPulseUri: string | null; 38 42 showFeedsDrawer: boolean; 39 - thread: { data: ThreadNode | null; error: string | null; loading: boolean; uri: string | null }; 43 + thread: ThreadState; 40 44 };
+77
src/components/feeds/workspace-state.test.ts
··· 1 + import type { FeedViewPrefItem, UserPreferences } from "$/lib/types"; 2 + import { describe, expect, it } from "vitest"; 3 + import { 4 + buildLocalPrefs, 5 + createDefaultFeedPref, 6 + createDefaultFeedState, 7 + DEFAULT_TIMELINE, 8 + getNextFocusedIndex, 9 + getNextFocusedScrollTop, 10 + updateFeedScrollState, 11 + upsertFeedViewPrefs, 12 + } from "./workspace-state"; 13 + 14 + const createFeedViewPref = (overrides: Partial<FeedViewPrefItem> = {}) => ({ 15 + feed: "following", 16 + hideQuotePosts: false, 17 + hideReplies: false, 18 + hideRepliesByLikeCount: null, 19 + hideRepliesByUnfollowed: true, 20 + hideReposts: false, 21 + ...overrides, 22 + }); 23 + 24 + describe("workspaceState", () => { 25 + it("builds default timeline preferences with unfollowed replies hidden", () => { 26 + expect(createDefaultFeedPref(DEFAULT_TIMELINE)).toEqual({ 27 + feed: "following", 28 + hideQuotePosts: false, 29 + hideReplies: false, 30 + hideRepliesByLikeCount: null, 31 + hideRepliesByUnfollowed: true, 32 + hideReposts: false, 33 + }); 34 + }); 35 + 36 + it("indexes feed preferences by feed id", () => { 37 + const preferences = { 38 + savedFeeds: [], 39 + feedViewPrefs: [createFeedViewPref(), createFeedViewPref({ feed: "at://feed/custom", hideReposts: true })], 40 + } satisfies UserPreferences; 41 + 42 + expect(buildLocalPrefs(preferences)).toEqual({ 43 + following: createFeedViewPref(), 44 + "at://feed/custom": createFeedViewPref({ feed: "at://feed/custom", hideReposts: true }), 45 + }); 46 + }); 47 + 48 + it("upserts a saved feed preference without dropping unrelated ones", () => { 49 + const current = [createFeedViewPref(), createFeedViewPref({ feed: "at://feed/custom", hideReplies: true })]; 50 + const nextPref = createFeedViewPref({ hideReposts: true, hideRepliesByLikeCount: 5 }); 51 + 52 + expect(upsertFeedViewPrefs(current, nextPref)).toEqual([ 53 + createFeedViewPref({ feed: "at://feed/custom", hideReplies: true }), 54 + createFeedViewPref({ hideReposts: true, hideRepliesByLikeCount: 5 }), 55 + ]); 56 + }); 57 + 58 + it("clamps keyboard focus movement within the rendered feed", () => { 59 + expect(getNextFocusedIndex(0, "next", 3)).toBe(1); 60 + expect(getNextFocusedIndex(2, "next", 3)).toBe(2); 61 + expect(getNextFocusedIndex(0, "previous", 3)).toBe(0); 62 + expect(getNextFocusedIndex(2, "previous", 3)).toBe(1); 63 + expect(getNextFocusedIndex(0, "next", 0)).toBe(0); 64 + }); 65 + 66 + it("avoids scroll-state writes when the scroll position is unchanged", () => { 67 + const state = createDefaultFeedState(); 68 + expect(updateFeedScrollState(state, 0)).toBeNull(); 69 + expect(updateFeedScrollState(state, 48)).toEqual({ ...state, scrollTop: 48 }); 70 + }); 71 + 72 + it("computes focused-post scrolling without using browser focus", () => { 73 + expect(getNextFocusedScrollTop(120, 300, 100, 60)).toBe(84); 74 + expect(getNextFocusedScrollTop(120, 300, 380, 80)).toBe(176); 75 + expect(getNextFocusedScrollTop(120, 300, 180, 60)).toBeNull(); 76 + }); 77 + });
+88
src/components/feeds/workspace-state.ts
··· 1 + import type { FeedViewPrefItem, FeedViewPrefs, SavedFeedItem, UserPreferences } from "$/lib/types"; 2 + import type { FeedState, FeedWorkspaceState, ThreadState } from "./types"; 3 + 4 + export const DEFAULT_TIMELINE: SavedFeedItem = { id: "following", type: "timeline", value: "following", pinned: true }; 5 + 6 + export function createDefaultFeedState(): FeedState { 7 + return { cursor: null, error: null, items: [], loading: false, loadingMore: false, scrollTop: 0 }; 8 + } 9 + 10 + export const createDefaultThreadState = (): ThreadState => ({ data: null, error: null, loading: false, uri: null }); 11 + 12 + export const createDefaultFeedPref = (feed: SavedFeedItem): FeedViewPrefItem => ({ 13 + feed: feed.value, 14 + hideQuotePosts: false, 15 + hideReplies: false, 16 + hideRepliesByLikeCount: null, 17 + hideRepliesByUnfollowed: true, 18 + hideReposts: false, 19 + }); 20 + 21 + export function createInitialWorkspaceState(): FeedWorkspaceState { 22 + return { 23 + activeFeedId: null, 24 + composer: { open: false, pending: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }, 25 + feedStates: {}, 26 + focusedIndex: 0, 27 + generators: {}, 28 + likePendingByUri: {}, 29 + likePulseUri: null, 30 + localPrefs: {}, 31 + preferences: null, 32 + repostPendingByUri: {}, 33 + repostPulseUri: null, 34 + showFeedsDrawer: false, 35 + thread: createDefaultThreadState(), 36 + }; 37 + } 38 + 39 + export function buildLocalPrefs(preferences: UserPreferences): Record<string, FeedViewPrefItem> { 40 + return Object.fromEntries(preferences.feedViewPrefs.map((pref) => [pref.feed, pref])); 41 + } 42 + 43 + export function upsertFeedViewPrefs(feedViewPrefs: FeedViewPrefs, nextPref: FeedViewPrefItem): FeedViewPrefs { 44 + return [...feedViewPrefs.filter((pref) => pref.feed !== nextPref.feed), nextPref]; 45 + } 46 + 47 + export function getNextFocusedIndex(currentIndex: number, direction: "next" | "previous", totalItems: number): number { 48 + if (totalItems <= 0) { 49 + return 0; 50 + } 51 + 52 + if (direction === "next") { 53 + return Math.min(currentIndex + 1, totalItems - 1); 54 + } 55 + 56 + return Math.max(currentIndex - 1, 0); 57 + } 58 + 59 + export function updateFeedScrollState(state: FeedState | undefined, scrollTop: number): FeedState | null { 60 + const currentState = state ?? createDefaultFeedState(); 61 + if (currentState.scrollTop === scrollTop) { 62 + return null; 63 + } 64 + 65 + return { ...currentState, scrollTop }; 66 + } 67 + 68 + export function getNextFocusedScrollTop( 69 + currentScrollTop: number, 70 + viewportHeight: number, 71 + itemTop: number, 72 + itemHeight: number, 73 + padding = 16, 74 + ): number | null { 75 + const viewportTop = currentScrollTop; 76 + const viewportBottom = currentScrollTop + viewportHeight; 77 + const itemBottom = itemTop + itemHeight; 78 + 79 + if (itemTop < viewportTop + padding) { 80 + return Math.max(0, itemTop - padding); 81 + } 82 + 83 + if (itemBottom > viewportBottom - padding) { 84 + return Math.max(0, itemBottom - viewportHeight + padding); 85 + } 86 + 87 + return null; 88 + }
+17 -1
src/lib/feeds.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { applyFeedPreferences, buildThreadRoute, decodeThreadRouteUri, getFeedCommand } from "./feeds"; 2 + import { 3 + applyFeedPreferences, 4 + buildThreadRoute, 5 + decodeThreadRouteUri, 6 + getFeedCommand, 7 + parseFeedResponse, 8 + parseThreadResponse, 9 + } from "./feeds"; 3 10 import type { FeedViewPost, FeedViewPrefItem, SavedFeedItem } from "./types"; 4 11 5 12 function createFeedItem(overrides: Partial<FeedViewPost> = {}): FeedViewPost { ··· 91 98 expect(decodeThreadRouteUri("at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123")).toBe(uri); 92 99 expect(decodeThreadRouteUri(uri)).toBe(uri); 93 100 expect(decodeThreadRouteUri("https%3A%2F%2Fexample.com")).toBeNull(); 101 + }); 102 + 103 + it("rejects malformed feed payloads", () => { 104 + expect(() => parseFeedResponse({ cursor: null, feed: {} })).toThrow("feed response payload is invalid"); 105 + expect(() => parseFeedResponse({ cursor: 42, feed: [] })).toThrow("feed response cursor is invalid"); 106 + }); 107 + 108 + it("rejects malformed thread payloads", () => { 109 + expect(() => parseThreadResponse({ thread: { nope: true } })).toThrow("thread response payload is invalid"); 94 110 }); 95 111 });
+79 -12
src/lib/feeds.ts
··· 1 1 import type { 2 2 BlockedPost, 3 3 EmbedView, 4 + FeedGeneratorsResponse, 4 5 FeedReplyNode, 6 + FeedResponse, 5 7 FeedViewPost, 6 8 FeedViewPrefItem, 7 9 Maybe, ··· 12 14 SavedFeedItem, 13 15 StrongRefInput, 14 16 ThreadNode, 17 + ThreadResponse, 15 18 ThreadViewPost, 16 19 } from "./types"; 17 20 ··· 31 34 return (asRecord(value) ?? {}) as PostRecord; 32 35 } 33 36 37 + function asArray(value: unknown) { 38 + return Array.isArray(value) ? value : null; 39 + } 40 + 41 + function isProfileViewBasic(value: unknown): boolean { 42 + const record = asRecord(value); 43 + return !!record && typeof record.did === "string" && typeof record.handle === "string"; 44 + } 45 + 46 + function isPostView(value: unknown): value is PostView { 47 + const record = asRecord(value); 48 + const author = asRecord(record?.author); 49 + const postRecord = asRecord(record?.record); 50 + 51 + return !!record 52 + && !!author 53 + && !!postRecord 54 + && typeof record.cid === "string" 55 + && typeof record.indexedAt === "string" 56 + && typeof record.uri === "string" 57 + && isProfileViewBasic(author); 58 + } 59 + 60 + function isFeedViewPost(value: unknown): value is FeedViewPost { 61 + const record = asRecord(value); 62 + return !!record && isPostView(record.post); 63 + } 64 + 65 + function isThreadNode(value: unknown): value is ThreadNode { 66 + const record = asRecord(value); 67 + if (!record || typeof record.$type !== "string") { 68 + return false; 69 + } 70 + 71 + if (record.$type === "app.bsky.feed.defs#threadViewPost") { 72 + return isPostView(record.post); 73 + } 74 + 75 + return record.$type === "app.bsky.feed.defs#blockedPost" || record.$type === "app.bsky.feed.defs#notFoundPost"; 76 + } 77 + 78 + export function parseFeedResponse(value: unknown): FeedResponse { 79 + const record = asRecord(value); 80 + const feed = asArray(record?.feed); 81 + 82 + if (!record || !feed || !feed.every((item) => isFeedViewPost(item))) { 83 + throw new Error("feed response payload is invalid"); 84 + } 85 + 86 + if (record.cursor !== undefined && record.cursor !== null && typeof record.cursor !== "string") { 87 + throw new Error("feed response cursor is invalid"); 88 + } 89 + 90 + return { cursor: (record.cursor as string | null | undefined) ?? null, feed }; 91 + } 92 + 93 + export function parseThreadResponse(value: unknown): ThreadResponse { 94 + const record = asRecord(value); 95 + if (!record || !isThreadNode(record.thread)) { 96 + throw new Error("thread response payload is invalid"); 97 + } 98 + 99 + return { thread: record.thread }; 100 + } 101 + 102 + export function parseFeedGeneratorsResponse(value: unknown): FeedGeneratorsResponse { 103 + const record = asRecord(value); 104 + const feeds = asArray(record?.feeds); 105 + 106 + if (!record || !feeds) { 107 + throw new Error("feed generators payload is invalid"); 108 + } 109 + 110 + return { feeds: feeds as FeedGeneratorsResponse["feeds"] }; 111 + } 112 + 34 113 export function getPostText(post: PostView) { 35 114 const text = post.record.text; 36 115 return typeof text === "string" ? text.trim() : ""; ··· 72 151 } 73 152 74 153 return formatter.format(deltaSeconds, "second"); 75 - } 76 - 77 - export function formatCount(value: Maybe<number>) { 78 - if (!value) { 79 - return "0"; 80 - } 81 - 82 - if (value >= 1000) { 83 - return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}K`; 84 - } 85 - 86 - return value.toString(); 87 154 } 88 155 89 156 export function getFeedName(item: { type: string; value: string }, hydratedName?: string | null) {
+3 -1
src/lib/types.ts
··· 21 21 hideQuotePosts: boolean; 22 22 }; 23 23 24 - export type UserPreferences = { savedFeeds: SavedFeedItem[]; feedViewPrefs: FeedViewPrefItem[] }; 24 + export type FeedViewPrefs = Array<FeedViewPrefItem>; 25 + 26 + export type UserPreferences = { savedFeeds: SavedFeedItem[]; feedViewPrefs: FeedViewPrefs }; 25 27 26 28 export type AuthorViewerState = { following?: string | null }; 27 29
+14
src/lib/utils/text.ts
··· 1 + import type { Maybe } from "$/lib/types"; 2 + 1 3 export function escapeForRegex(value: string) { 2 4 return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); 3 5 } 6 + 7 + export function formatCount(value: Maybe<number>) { 8 + if (!value) { 9 + return "0"; 10 + } 11 + 12 + if (value >= 1000) { 13 + return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}K`; 14 + } 15 + 16 + return value.toString(); 17 + }
+1
vite.config.ts
··· 11 11 server: { deps: { inline: ["@solidjs/router"] } }, 12 12 ui: false, 13 13 watch: false, 14 + testTimeout: 2500, 14 15 }; 15 16 16 17 const host = process.env.TAURI_DEV_HOST;