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

Configure Feed

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

feat: global shortcut and tray access

+510 -96
+8 -8
docs/tasks/03-feeds.md
··· 38 38 39 39 ### Frontend — Post Composer 40 40 41 - - [ ] Composer with `Presence` slide-up/down, `n` keyboard shortcut to open 42 - - [ ] Mention/hashtag autocomplete 43 - - [ ] Reply threading with parent/root refs 44 - - [ ] Quote post embed 45 - - [ ] Tray button and global keyboard shortcut to open composer from anywhere 41 + - [x] Composer with `Presence` slide-up/down, `n` keyboard shortcut to open 42 + - [x] Mention/hashtag autocomplete 43 + - [x] Reply threading with parent/root refs 44 + - [x] Quote post embed 45 + - [x] Tray button and global keyboard shortcut to open composer from anywhere 46 46 47 47 ### Frontend — Feed Preferences 48 48 49 - - [ ] Per-feed display toggles (hide reposts/replies/quotes) via `feedViewPref` 50 - - [ ] Feeds drawer for accessing saved (unpinned) feeds 51 - - [ ] Feed generator management (pin/unpin, reorder) via `savedFeedsPrefV2` 49 + - [x] Per-feed display toggles (hide reposts/replies/quotes) via `feedViewPref` 50 + - [x] Feeds drawer for accessing saved (unpinned) feeds 51 + - [x] Feed generator management (pin/unpin, reorder) via `savedFeedsPrefV2`
+67
src-tauri/Cargo.lock
··· 2358 2358 ] 2359 2359 2360 2360 [[package]] 2361 + name = "gethostname" 2362 + version = "1.1.0" 2363 + source = "registry+https://github.com/rust-lang/crates.io-index" 2364 + checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" 2365 + dependencies = [ 2366 + "rustix", 2367 + "windows-link 0.2.1", 2368 + ] 2369 + 2370 + [[package]] 2361 2371 name = "getrandom" 2362 2372 version = "0.1.16" 2363 2373 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2502 2512 version = "0.3.3" 2503 2513 source = "registry+https://github.com/rust-lang/crates.io-index" 2504 2514 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 2515 + 2516 + [[package]] 2517 + name = "global-hotkey" 2518 + version = "0.7.0" 2519 + source = "registry+https://github.com/rust-lang/crates.io-index" 2520 + checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" 2521 + dependencies = [ 2522 + "crossbeam-channel", 2523 + "keyboard-types", 2524 + "objc2", 2525 + "objc2-app-kit", 2526 + "once_cell", 2527 + "serde", 2528 + "thiserror 2.0.18", 2529 + "windows-sys 0.59.0", 2530 + "x11rb", 2531 + "xkeysym", 2532 + ] 2505 2533 2506 2534 [[package]] 2507 2535 name = "gloo-storage" ··· 3738 3766 "tauri", 3739 3767 "tauri-build", 3740 3768 "tauri-plugin-deep-link", 3769 + "tauri-plugin-global-shortcut", 3741 3770 "tauri-plugin-log", 3742 3771 "tauri-plugin-notification", 3743 3772 "tauri-plugin-opener", ··· 7241 7270 ] 7242 7271 7243 7272 [[package]] 7273 + name = "tauri-plugin-global-shortcut" 7274 + version = "2.3.1" 7275 + source = "registry+https://github.com/rust-lang/crates.io-index" 7276 + checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" 7277 + dependencies = [ 7278 + "global-hotkey", 7279 + "log", 7280 + "serde", 7281 + "serde_json", 7282 + "tauri", 7283 + "tauri-plugin", 7284 + "thiserror 2.0.18", 7285 + ] 7286 + 7287 + [[package]] 7244 7288 name = "tauri-plugin-log" 7245 7289 version = "2.8.0" 7246 7290 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 9265 9309 "once_cell", 9266 9310 "pkg-config", 9267 9311 ] 9312 + 9313 + [[package]] 9314 + name = "x11rb" 9315 + version = "0.13.2" 9316 + source = "registry+https://github.com/rust-lang/crates.io-index" 9317 + checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" 9318 + dependencies = [ 9319 + "gethostname", 9320 + "rustix", 9321 + "x11rb-protocol", 9322 + ] 9323 + 9324 + [[package]] 9325 + name = "x11rb-protocol" 9326 + version = "0.13.2" 9327 + source = "registry+https://github.com/rust-lang/crates.io-index" 9328 + checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" 9329 + 9330 + [[package]] 9331 + name = "xkeysym" 9332 + version = "0.2.1" 9333 + source = "registry+https://github.com/rust-lang/crates.io-index" 9334 + checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" 9268 9335 9269 9336 [[package]] 9270 9337 name = "xml5ever"
+2 -1
src-tauri/Cargo.toml
··· 18 18 tauri-build = { version = "2", features = [] } 19 19 20 20 [dependencies] 21 - tauri = { version = "2", features = [] } 21 + tauri = { version = "2", features = ["tray-icon"] } 22 22 tauri-plugin-opener = "2" 23 + tauri-plugin-global-shortcut = "2" 23 24 serde = { version = "1", features = ["derive"] } 24 25 serde_json = "1" 25 26 reqwest = { version = "0.12.28", features = ["json"] }
+5
src-tauri/src/commands.rs
··· 108 108 pub async fn unrepost(repost_uri: String, state: State<'_, AppState>) -> Result<(), AppError> { 109 109 feed::unrepost(repost_uri, &state).await 110 110 } 111 + 112 + #[tauri::command] 113 + pub async fn update_saved_feeds(feeds: Vec<feed::SavedFeedItem>, state: State<'_, AppState>) -> Result<(), AppError> { 114 + feed::update_saved_feeds(feed::UpdateSavedFeedsInput { feeds }, &state).await 115 + }
+44 -1
src-tauri/src/feed.rs
··· 2 2 use super::error::{AppError, Result}; 3 3 use super::state::AppState; 4 4 use jacquard::api::app_bsky::actor::get_preferences::GetPreferences; 5 - use jacquard::api::app_bsky::actor::{FeedViewPref, PreferencesItem, SavedFeedType, SavedFeedsPrefV2}; 5 + use jacquard::api::app_bsky::actor::put_preferences::PutPreferences; 6 + use jacquard::api::app_bsky::actor::{ 7 + FeedViewPref, PreferencesItem, SavedFeed, SavedFeedType, SavedFeedsPrefV2, SavedFeedsPrefV2Builder, 8 + }; 6 9 use jacquard::api::app_bsky::embed::record::Record; 7 10 use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 8 11 use jacquard::api::app_bsky::feed::get_feed::GetFeed; ··· 464 467 ))), 465 468 } 466 469 } 470 + 471 + #[derive(Debug, Deserialize)] 472 + #[serde(rename_all = "camelCase")] 473 + pub struct UpdateSavedFeedsInput { 474 + pub feeds: Vec<SavedFeedItem>, 475 + } 476 + 477 + pub async fn update_saved_feeds(input: UpdateSavedFeedsInput, state: &AppState) -> Result<()> { 478 + let session = get_session(state).await?; 479 + 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(); 497 + 498 + let saved_feeds_pref = Box::new(SavedFeedsPrefV2Builder::new().items(items).build()); 499 + let pref_item = PreferencesItem::SavedFeedsPrefV2(saved_feeds_pref); 500 + 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"))?; 507 + 508 + Ok(()) 509 + }
+10 -1
src-tauri/src/lib.rs
··· 4 4 mod error; 5 5 mod feed; 6 6 mod state; 7 + mod tray; 7 8 8 9 use auth::emit_at_uri_navigation; 9 10 use commands as cmd; ··· 36 37 } 37 38 } 38 39 40 + #[cfg(not(any(target_os = "android", target_os = "ios")))] 41 + { 42 + tray::setup_tray(app.handle())?; 43 + tray::setup_global_shortcut(app.handle())?; 44 + } 45 + 39 46 Ok(()) 40 47 }) 41 48 .plugin(tauri_plugin_notification::init()) ··· 46 53 ) 47 54 .plugin(tauri_plugin_deep_link::init()) 48 55 .plugin(tauri_plugin_opener::init()) 56 + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) 49 57 .invoke_handler(tauri::generate_handler![ 50 58 cmd::get_app_bootstrap, 51 59 cmd::list_accounts, ··· 65 73 cmd::like_post, 66 74 cmd::unlike_post, 67 75 cmd::repost, 68 - cmd::unrepost 76 + cmd::unrepost, 77 + cmd::update_saved_feeds 69 78 ]) 70 79 .run(tauri::generate_context!()) 71 80 .expect("error while running tauri application");
+82
src-tauri/src/tray.rs
··· 1 + use tauri::{ 2 + menu::{Menu, MenuItem}, 3 + tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, 4 + AppHandle, Emitter, Manager, WebviewWindow, 5 + }; 6 + use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; 7 + 8 + const COMPOSER_OPEN_EVENT: &str = "composer:open"; 9 + const MENU_NEW_POST: &str = "new_post"; 10 + const MENU_TOGGLE_WINDOW: &str = "toggle_window"; 11 + const MENU_QUIT: &str = "quit"; 12 + 13 + pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> { 14 + let new_post_i = MenuItem::with_id(app, MENU_NEW_POST, "New Post…", true, None::<&str>)?; 15 + let toggle_window_i = MenuItem::with_id(app, MENU_TOGGLE_WINDOW, "Show / Hide", true, None::<&str>)?; 16 + let quit_i = MenuItem::with_id(app, MENU_QUIT, "Quit", true, None::<&str>)?; 17 + 18 + let menu = Menu::with_items(app, &[&new_post_i, &toggle_window_i, &quit_i])?; 19 + 20 + let tray = TrayIconBuilder::new() 21 + .icon(app.default_window_icon().unwrap().clone()) 22 + .menu(&menu) 23 + .show_menu_on_left_click(false) 24 + .on_menu_event(|app, event| match event.id().as_ref() { 25 + MENU_NEW_POST => { 26 + show_window_and_emit(app, COMPOSER_OPEN_EVENT); 27 + } 28 + MENU_TOGGLE_WINDOW => { 29 + toggle_window_visibility(app); 30 + } 31 + MENU_QUIT => { 32 + app.exit(0); 33 + } 34 + _ => {} 35 + }) 36 + .on_tray_icon_event(|tray, event| { 37 + if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event { 38 + toggle_window_visibility(tray.app_handle()); 39 + } 40 + }) 41 + .build(app)?; 42 + 43 + app.manage(tray); 44 + 45 + Ok(()) 46 + } 47 + 48 + pub fn setup_global_shortcut(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> { 49 + let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyN); 50 + 51 + app.global_shortcut().on_shortcut(shortcut, |app, _shortcut, _event| { 52 + show_window_and_emit(app, COMPOSER_OPEN_EVENT); 53 + })?; 54 + 55 + Ok(()) 56 + } 57 + 58 + fn toggle_window_visibility(app: &AppHandle) { 59 + if let Some(window) = app.get_webview_window("main") { 60 + if is_window_visible(&window) { 61 + let _ = window.hide(); 62 + } else { 63 + let _ = window.unminimize(); 64 + let _ = window.show(); 65 + let _ = window.set_focus(); 66 + } 67 + } 68 + } 69 + 70 + fn is_window_visible(window: &WebviewWindow) -> bool { 71 + window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(false) 72 + } 73 + 74 + fn show_window_and_emit(app: &AppHandle, event: &str) { 75 + if let Some(window) = app.get_webview_window("main") { 76 + let _ = window.unminimize(); 77 + let _ = window.show(); 78 + let _ = window.set_focus(); 79 + } 80 + 81 + let _ = app.emit(event, ()); 82 + }
+4 -2
src/App.tsx
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 2 import { listen } from "@tauri-apps/api/event"; 3 - import { createEffect, createMemo, type JSX, onCleanup, onMount, Show, startTransition } from "solid-js"; 3 + import { createEffect, createMemo, onCleanup, onMount, Show, startTransition } from "solid-js"; 4 4 import { createStore } from "solid-js/store"; 5 5 import "@fontsource-variable/google-sans"; 6 6 import "./App.css"; 7 + import type { ParentProps } from "solid-js"; 7 8 import { AccountLedger } from "./components/account/AccountLedger"; 8 9 import { AppRail } from "./components/AppRail"; 9 10 import { FeedWorkspace } from "./components/feeds/FeedWorkspace"; ··· 15 16 import { AppRouter } from "./router"; 16 17 17 18 const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; 19 + 18 20 const RAIL_COLLAPSED_STORAGE_KEY = "lazurite:rail-collapsed"; 19 21 20 22 type AppState = { ··· 197 199 globalThis.localStorage.setItem(RAIL_COLLAPSED_STORAGE_KEY, app.railCollapsed ? "true" : "false"); 198 200 }); 199 201 200 - function AppShell(props: { children: JSX.Element }) { 202 + function AppShell(props: ParentProps) { 201 203 return ( 202 204 <> 203 205 <main
+215
src/components/feeds/FeedDrawer.tsx
··· 1 + import { getFeedName } from "$/lib/feeds"; 2 + import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 3 + import { For, Show } from "solid-js"; 4 + import { Motion, Presence } from "solid-motionone"; 5 + import { Icon } from "../shared/Icon"; 6 + import { FeedChipAvatar } from "./FeedChipAvatar"; 7 + 8 + export function SavedFeedsDrawer( 9 + props: { 10 + drawerFeeds: SavedFeedItem[]; 11 + generators: Record<string, FeedGeneratorView>; 12 + open: boolean; 13 + pinnedFeeds: SavedFeedItem[]; 14 + onClose: () => void; 15 + onPinFeed: (feedId: string) => void; 16 + onReorderPinned: (feedId: string, direction: "up" | "down") => void; 17 + onSelectFeed: (feedId: string) => void; 18 + onUnpinFeed: (feedId: string) => void; 19 + }, 20 + ) { 21 + return ( 22 + <Presence> 23 + <Show when={props.open}> 24 + <Motion.aside 25 + class="fixed inset-y-0 right-0 z-30 w-full max-w-104 overflow-y-auto overscroll-contain border-l border-white/5 bg-[rgba(12,12,12,0.95)] px-5 pb-6 pt-5 backdrop-blur-[22px] shadow-[-28px_0_50px_rgba(0,0,0,0.35)]" 26 + initial={{ opacity: 0, x: 20 }} 27 + animate={{ opacity: 1, x: 0 }} 28 + exit={{ opacity: 0, x: 24 }} 29 + transition={{ duration: 0.2 }}> 30 + <DrawerHeader onClose={props.onClose} /> 31 + <DrawerContent {...props} /> 32 + </Motion.aside> 33 + </Show> 34 + </Presence> 35 + ); 36 + } 37 + 38 + function DrawerContent( 39 + props: { 40 + drawerFeeds: SavedFeedItem[]; 41 + generators: Record<string, FeedGeneratorView>; 42 + pinnedFeeds: SavedFeedItem[]; 43 + onPinFeed: (feedId: string) => void; 44 + onReorderPinned: (feedId: string, direction: "up" | "down") => void; 45 + onSelectFeed: (feedId: string) => void; 46 + onUnpinFeed: (feedId: string) => void; 47 + }, 48 + ) { 49 + return ( 50 + <> 51 + <PinnedFeedsSection {...props} /> 52 + <UnpinnedFeedsSection {...props} /> 53 + <Show when={props.pinnedFeeds.length === 0 && props.drawerFeeds.length === 0}> 54 + <p class="mt-8 text-center text-sm text-on-surface-variant">No saved feeds yet.</p> 55 + </Show> 56 + </> 57 + ); 58 + } 59 + 60 + function PinnedFeedsSection( 61 + props: { 62 + generators: Record<string, FeedGeneratorView>; 63 + pinnedFeeds: SavedFeedItem[]; 64 + onReorderPinned: (feedId: string, direction: "up" | "down") => void; 65 + onSelectFeed: (feedId: string) => void; 66 + onUnpinFeed: (feedId: string) => void; 67 + }, 68 + ) { 69 + return ( 70 + <Show when={props.pinnedFeeds.length > 0}> 71 + <div class="mt-6"> 72 + <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Pinned Feeds</p> 73 + <div class="mt-3 grid gap-2"> 74 + <For each={props.pinnedFeeds}> 75 + {(feed, index) => ( 76 + <DrawerPinnedFeedRow 77 + feed={feed} 78 + generator={props.generators[feed.value]} 79 + index={index()} 80 + isFirst={index() === 0} 81 + isLast={index() === props.pinnedFeeds.length - 1} 82 + onSelect={() => props.onSelectFeed(feed.id)} 83 + onUnpin={() => props.onUnpinFeed(feed.id)} 84 + onMoveUp={() => props.onReorderPinned(feed.id, "up")} 85 + onMoveDown={() => props.onReorderPinned(feed.id, "down")} /> 86 + )} 87 + </For> 88 + </div> 89 + </div> 90 + </Show> 91 + ); 92 + } 93 + 94 + function UnpinnedFeedsSection( 95 + props: { 96 + drawerFeeds: SavedFeedItem[]; 97 + generators: Record<string, FeedGeneratorView>; 98 + onPinFeed: (feedId: string) => void; 99 + onSelectFeed: (feedId: string) => void; 100 + }, 101 + ) { 102 + return ( 103 + <Show when={props.drawerFeeds.length > 0}> 104 + <div class="mt-6"> 105 + <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Saved Feeds</p> 106 + <div class="mt-3 grid gap-2"> 107 + <For each={props.drawerFeeds}> 108 + {(feed) => ( 109 + <DrawerUnpinnedFeedRow 110 + feed={feed} 111 + generator={props.generators[feed.value]} 112 + onSelect={() => props.onSelectFeed(feed.id)} 113 + onPin={() => props.onPinFeed(feed.id)} /> 114 + )} 115 + </For> 116 + </div> 117 + </div> 118 + </Show> 119 + ); 120 + } 121 + 122 + function DrawerHeader(props: { onClose: () => void }) { 123 + return ( 124 + <div class="flex items-center justify-between"> 125 + <div> 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> 128 + </div> 129 + <button 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" 131 + type="button" 132 + onClick={() => props.onClose()}> 133 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 134 + </button> 135 + </div> 136 + ); 137 + } 138 + 139 + function DrawerPinnedFeedRow( 140 + props: { 141 + feed: SavedFeedItem; 142 + generator?: FeedGeneratorView; 143 + index: number; 144 + isFirst: boolean; 145 + isLast: boolean; 146 + onSelect: () => void; 147 + onUnpin: () => void; 148 + onMoveUp: () => void; 149 + onMoveDown: () => void; 150 + }, 151 + ) { 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"> 154 + <button class="flex min-w-0 flex-1 items-center gap-3 text-left" type="button" onClick={() => props.onSelect()}> 155 + <FeedChipAvatar feed={props.feed} generator={props.generator} /> 156 + <div class="min-w-0 flex-1"> 157 + <p class="m-0 truncate text-[0.88rem] font-semibold text-on-surface"> 158 + {getFeedName(props.feed, props.generator?.displayName)} 159 + </p> 160 + <p class="m-0 break-all text-[0.74rem] text-on-surface-variant">{props.feed.value}</p> 161 + </div> 162 + </button> 163 + <div class="flex items-center gap-1"> 164 + <button 165 + class="inline-flex h-8 w-8 items-center justify-center rounded-lg border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface disabled:opacity-30" 166 + type="button" 167 + disabled={props.isFirst} 168 + title="Move up" 169 + onClick={() => props.onMoveUp()}> 170 + <Icon aria-hidden="true" iconClass="i-ri-arrow-up-line" /> 171 + </button> 172 + <button 173 + class="inline-flex h-8 w-8 items-center justify-center rounded-lg border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface disabled:opacity-30" 174 + type="button" 175 + disabled={props.isLast} 176 + title="Move down" 177 + onClick={() => props.onMoveDown()}> 178 + <Icon aria-hidden="true" iconClass="i-ri-arrow-down-line" /> 179 + </button> 180 + <button 181 + class="inline-flex h-8 w-8 items-center justify-center rounded-lg border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-primary" 182 + type="button" 183 + title="Unpin from tabs" 184 + onClick={() => props.onUnpin()}> 185 + <Icon aria-hidden="true" iconClass="i-ri-unpin-line" /> 186 + </button> 187 + </div> 188 + </div> 189 + ); 190 + } 191 + 192 + function DrawerUnpinnedFeedRow( 193 + props: { feed: SavedFeedItem; generator?: FeedGeneratorView; onSelect: () => void; onPin: () => void }, 194 + ) { 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"> 197 + <button class="flex min-w-0 flex-1 items-center gap-3 text-left" type="button" onClick={() => props.onSelect()}> 198 + <FeedChipAvatar feed={props.feed} generator={props.generator} /> 199 + <div class="min-w-0 flex-1"> 200 + <p class="m-0 truncate text-[0.88rem] font-semibold text-on-surface"> 201 + {getFeedName(props.feed, props.generator?.displayName)} 202 + </p> 203 + <p class="m-0 break-all text-[0.74rem] text-on-surface-variant">{props.feed.value}</p> 204 + </div> 205 + </button> 206 + <button 207 + class="inline-flex h-8 w-8 items-center justify-center rounded-lg border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-primary" 208 + type="button" 209 + title="Pin to tabs" 210 + onClick={() => props.onPin()}> 211 + <Icon aria-hidden="true" iconClass="i-ri-pushpin-line" /> 212 + </button> 213 + </div> 214 + ); 215 + }
+68 -76
src/components/feeds/FeedWorkspace.tsx
··· 1 - import { Icon } from "$/components/shared/Icon"; 2 1 import { 3 2 applyFeedPreferences, 4 3 extractHandles, ··· 26 25 import { shouldIgnoreKey } from "$/lib/utils/events"; 27 26 import { escapeForRegex } from "$/lib/utils/text"; 28 27 import { invoke } from "@tauri-apps/api/core"; 29 - import { createEffect, createMemo, For, type JSX, onCleanup, onMount, Show } from "solid-js"; 28 + import { listen } from "@tauri-apps/api/event"; 29 + import { createEffect, createMemo, For, onCleanup, onMount, type ParentProps, Show } from "solid-js"; 30 30 import { createStore, reconcile } from "solid-js/store"; 31 - import { Motion, Presence } from "solid-motionone"; 32 31 import { FeedChipAvatar } from "./FeedChipAvatar"; 33 32 import { FeedComposer } from "./FeedComposer"; 33 + import { SavedFeedsDrawer } from "./FeedDrawer"; 34 34 import { FeedPane } from "./FeedPane"; 35 35 import { ThreadPanel } from "./ThreadPanel"; 36 36 import type { FeedState, FeedWorkspaceState } from "./types"; ··· 306 306 307 307 onMount(() => { 308 308 globalThis.addEventListener("keydown", handleGlobalKeydown); 309 - onCleanup(() => globalThis.removeEventListener("keydown", handleGlobalKeydown)); 309 + 310 + let unlistenComposer: (() => void) | undefined; 311 + void listen("composer:open", () => { 312 + openComposer(); 313 + }).then((dispose) => { 314 + unlistenComposer = dispose; 315 + }); 316 + 317 + onCleanup(() => { 318 + globalThis.removeEventListener("keydown", handleGlobalKeydown); 319 + unlistenComposer?.(); 320 + }); 310 321 }); 311 322 312 323 async function bootstrapFeeds() { ··· 575 586 globalThis.setTimeout(() => setWorkspace("repostPulseUri", (current) => (current === uri ? null : current)), 320); 576 587 } 577 588 589 + async function saveFeedPreferences(updatedFeeds: SavedFeedItem[]) { 590 + try { 591 + await invoke("update_saved_feeds", { feeds: updatedFeeds }); 592 + setWorkspace("preferences", (current) => current ? { ...current, savedFeeds: updatedFeeds } : current); 593 + } catch (error) { 594 + props.onError(`Failed to update feeds: ${String(error)}`); 595 + } 596 + } 597 + 598 + function pinFeed(feedId: string) { 599 + const currentFeeds = workspace.preferences?.savedFeeds ?? []; 600 + const updatedFeeds = currentFeeds.map((feed) => feed.id === feedId ? { ...feed, pinned: true } : feed); 601 + void saveFeedPreferences(updatedFeeds); 602 + } 603 + 604 + function unpinFeed(feedId: string) { 605 + const currentFeeds = workspace.preferences?.savedFeeds ?? []; 606 + const updatedFeeds = currentFeeds.map((feed) => feed.id === feedId ? { ...feed, pinned: false } : feed); 607 + void saveFeedPreferences(updatedFeeds); 608 + } 609 + 610 + function reorderPinnedFeeds(feedId: string, direction: "up" | "down") { 611 + const pinned = pinnedFeeds(); 612 + const index = pinned.findIndex((f) => f.id === feedId); 613 + if (index === -1) return; 614 + 615 + const newIndex = direction === "up" ? index - 1 : index + 1; 616 + if (newIndex < 0 || newIndex >= pinned.length) return; 617 + 618 + const currentFeeds = [...(workspace.preferences?.savedFeeds ?? [])]; 619 + const feedIds = currentFeeds.map((f) => f.id); 620 + const pinnedIds = pinned.map((f) => f.id); 621 + 622 + const itemId = pinnedIds[index]; 623 + const swapId = pinnedIds[newIndex]; 624 + const itemIdx = feedIds.indexOf(itemId); 625 + const swapIdx = feedIds.indexOf(swapId); 626 + 627 + if (itemIdx === -1 || swapIdx === -1) return; 628 + 629 + const reordered = [...currentFeeds]; 630 + [reordered[itemIdx], reordered[swapIdx]] = [reordered[swapIdx], reordered[itemIdx]]; 631 + 632 + void saveFeedPreferences(reordered); 633 + } 634 + 578 635 return ( 579 636 <> 580 637 <div class="grid h-full min-h-0 min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> ··· 622 679 </div> 623 680 624 681 <SavedFeedsDrawer 625 - feeds={drawerFeeds()} 682 + drawerFeeds={drawerFeeds()} 626 683 generators={workspace.generators} 627 684 open={workspace.showFeedsDrawer} 685 + pinnedFeeds={pinnedFeeds()} 628 686 onClose={() => setWorkspace("showFeedsDrawer", false)} 629 - onSelectFeed={switchFeed} /> 687 + onPinFeed={pinFeed} 688 + onReorderPinned={reorderPinnedFeeds} 689 + onSelectFeed={switchFeed} 690 + onUnpinFeed={unpinFeed} /> 630 691 631 692 <ThreadPanel 632 693 activeUri={props.threadUri} ··· 794 855 ); 795 856 } 796 857 797 - function SavedFeedsDrawer( 798 - props: { 799 - feeds: SavedFeedItem[]; 800 - generators: Record<string, FeedGeneratorView>; 801 - open: boolean; 802 - onClose: () => void; 803 - onSelectFeed: (feedId: string) => void; 804 - }, 805 - ) { 806 - return ( 807 - <Presence> 808 - <Show when={props.open}> 809 - <Motion.aside 810 - class="fixed inset-y-0 right-0 z-30 w-full max-w-104 overflow-y-auto overscroll-contain border-l border-white/5 bg-[rgba(12,12,12,0.95)] px-5 pb-6 pt-5 backdrop-blur-[22px] shadow-[-28px_0_50px_rgba(0,0,0,0.35)]" 811 - initial={{ opacity: 0, x: 20 }} 812 - animate={{ opacity: 1, x: 0 }} 813 - exit={{ opacity: 0, x: 24 }} 814 - transition={{ duration: 0.2 }}> 815 - <DrawerHeader onClose={props.onClose} /> 816 - <div class="mt-5 grid gap-3"> 817 - <For each={props.feeds}> 818 - {(feed) => ( 819 - <DrawerFeedButton 820 - feed={feed} 821 - generator={props.generators[feed.value]} 822 - onSelectFeed={props.onSelectFeed} /> 823 - )} 824 - </For> 825 - </div> 826 - </Motion.aside> 827 - </Show> 828 - </Presence> 829 - ); 830 - } 831 - 832 - function DrawerHeader(props: { onClose: () => void }) { 833 - return ( 834 - <div class="flex items-center justify-between"> 835 - <div> 836 - <p class="m-0 text-[1rem] font-semibold text-on-surface">Saved Feeds</p> 837 - <p class="m-0 text-[0.74rem] uppercase tracking-[0.12em] text-on-surface-variant">Unpinned drawer</p> 838 - </div> 839 - <button 840 - 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" 841 - type="button" 842 - onClick={() => props.onClose()}> 843 - <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 844 - </button> 845 - </div> 846 - ); 847 - } 848 - 849 - function DrawerFeedButton( 850 - props: { feed: SavedFeedItem; generator?: FeedGeneratorView; onSelectFeed: (feedId: string) => void }, 851 - ) { 852 - return ( 853 - <button 854 - class="flex w-full items-center gap-3 rounded-[1.25rem] border-0 bg-white/4 px-4 py-4 text-left text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 855 - type="button" 856 - onClick={() => props.onSelectFeed(props.feed.id)}> 857 - <FeedChipAvatar feed={props.feed} generator={props.generator} /> 858 - <div class="min-w-0 flex-1"> 859 - <p class="m-0 truncate text-[0.88rem] font-semibold">{getFeedName(props.feed, props.generator?.displayName)}</p> 860 - <p class="m-0 break-all text-[0.74rem] text-on-surface-variant">{props.feed.value}</p> 861 - </div> 862 - </button> 863 - ); 864 - } 865 - 866 - function SidebarCard(props: { children: JSX.Element; subtitle: string; title: string }) { 858 + function SidebarCard(props: ParentProps & { subtitle: string; title: string }) { 867 859 return ( 868 860 <section class="rounded-[1.6rem] bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 869 861 <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p>
+2 -2
src/router.test.tsx
··· 1 1 import { render, screen } from "@solidjs/testing-library"; 2 - import type { Component, JSX } from "solid-js"; 2 + import type { Component, ParentProps } from "solid-js"; 3 3 import { describe, expect, it, vi } from "vitest"; 4 4 import { buildThreadRoute } from "./lib/feeds"; 5 5 import type { ActiveSession } from "./lib/types"; ··· 7 7 8 8 const session: ActiveSession = { did: "did:plc:alice", handle: "alice.test" }; 9 9 10 - const Shell: Component<{ children: JSX.Element }> = (props) => <div>{props.children}</div>; 10 + const Shell: Component<ParentProps> = (props) => <div>{props.children}</div>; 11 11 12 12 function renderRouter(hash: string) { 13 13 globalThis.location.hash = hash;
+3 -5
src/router.tsx
··· 7 7 useNavigate, 8 8 useParams, 9 9 } from "@solidjs/router"; 10 - import { type Component, createEffect, type JSX, Show } from "solid-js"; 10 + import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js"; 11 11 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 12 12 import type { ActiveSession } from "./lib/types"; 13 13 ··· 16 16 hasSession: boolean; 17 17 onLocationChange?: () => void; 18 18 renderAuth: () => JSX.Element; 19 - renderShell: Component<{ children: JSX.Element }>; 19 + renderShell: Component<ParentProps>; 20 20 renderTimeline: ( 21 21 session: ActiveSession, 22 22 context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null }, ··· 151 151 ); 152 152 } 153 153 154 - function PublicOnlyRoute( 155 - props: { bootstrapping: boolean; when: boolean; redirectHref: string; children: JSX.Element }, 156 - ) { 154 + function PublicOnlyRoute(props: ParentProps & { bootstrapping: boolean; when: boolean; redirectHref: string }) { 157 155 return ( 158 156 <Show when={props.when || props.bootstrapping} fallback={<Navigate href={props.redirectHref} />}> 159 157 {props.children}