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: add notification commands for listing, updating seen status, and fetching unread count

+327 -76
+16
src-tauri/src/commands.rs
··· 2 2 use super::auth::{self, LoginSuggestion}; 3 3 use super::error::AppError; 4 4 use super::feed::{self, CreateRecordResult, EmbedInput, FeedViewPrefItem, ReplyRefInput, UserPreferences}; 5 + use super::notifications; 5 6 use super::state::{AccountSummary, AppBootstrap, AppState}; 6 7 use serde_json::Value; 7 8 use tauri::{AppHandle, State}; ··· 118 119 pub async fn update_feed_view_pref(pref: FeedViewPrefItem, state: State<'_, AppState>) -> Result<(), AppError> { 119 120 feed::update_feed_view_pref(pref, &state).await 120 121 } 122 + 123 + #[tauri::command] 124 + pub async fn list_notifications(cursor: Option<String>, state: State<'_, AppState>) -> Result<Value, AppError> { 125 + notifications::list_notifications(cursor, &state).await 126 + } 127 + 128 + #[tauri::command] 129 + pub async fn update_seen(state: State<'_, AppState>) -> Result<(), AppError> { 130 + notifications::update_seen(&state).await 131 + } 132 + 133 + #[tauri::command] 134 + pub async fn get_unread_count(state: State<'_, AppState>) -> Result<i64, AppError> { 135 + notifications::get_unread_count(&state).await 136 + }
+5 -1
src-tauri/src/lib.rs
··· 3 3 mod db; 4 4 mod error; 5 5 mod feed; 6 + mod notifications; 6 7 mod state; 7 8 mod tray; 8 9 ··· 77 78 cmd::repost, 78 79 cmd::unrepost, 79 80 cmd::update_saved_feeds, 80 - cmd::update_feed_view_pref 81 + cmd::update_feed_view_pref, 82 + cmd::list_notifications, 83 + cmd::update_seen, 84 + cmd::get_unread_count 81 85 ]) 82 86 .run(tauri::generate_context!()) 83 87 .expect("error while running tauri application");
+102
src-tauri/src/notifications.rs
··· 1 + use super::auth::LazuriteOAuthSession; 2 + use super::error::{AppError, Result}; 3 + use super::state::AppState; 4 + use jacquard::api::app_bsky::notification::get_unread_count::GetUnreadCount; 5 + use jacquard::api::app_bsky::notification::list_notifications::ListNotifications; 6 + use jacquard::api::app_bsky::notification::update_seen::UpdateSeen; 7 + use jacquard::types::datetime::Datetime; 8 + use jacquard::xrpc::XrpcClient; 9 + use std::sync::Arc; 10 + use tauri_plugin_log::log; 11 + 12 + async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 13 + let did = state 14 + .active_session 15 + .read() 16 + .map_err(|error| { 17 + log::error!("active_session poisoned: {error}"); 18 + AppError::StatePoisoned("active_session") 19 + })? 20 + .as_ref() 21 + .ok_or_else(|| { 22 + log::error!("no active account"); 23 + AppError::Validation("no active account".into()) 24 + })? 25 + .did 26 + .clone(); 27 + 28 + state 29 + .sessions 30 + .read() 31 + .map_err(|error| { 32 + log::error!("sessions poisoned: {error}"); 33 + AppError::StatePoisoned("sessions") 34 + })? 35 + .get(&did) 36 + .cloned() 37 + .ok_or_else(|| { 38 + log::error!("session not found for active account"); 39 + AppError::Validation("session not found for active account".into()) 40 + }) 41 + } 42 + 43 + pub async fn list_notifications(cursor: Option<String>, state: &AppState) -> Result<serde_json::Value> { 44 + let session = get_session(state).await?; 45 + let mut req = ListNotifications::new().limit(50i64); 46 + if let Some(c) = &cursor { 47 + req = req.cursor(Some(c.as_str().into())); 48 + } 49 + 50 + let output = session 51 + .send(req.build()) 52 + .await 53 + .map_err(|error| { 54 + log::error!("listNotifications error: {error}"); 55 + AppError::validation("listNotifications error") 56 + })? 57 + .into_output() 58 + .map_err(|error| { 59 + log::error!("listNotifications output error: {error}"); 60 + AppError::validation("listNotifications output error") 61 + })?; 62 + 63 + serde_json::to_value(&output).map_err(AppError::from) 64 + } 65 + 66 + pub async fn update_seen(state: &AppState) -> Result<()> { 67 + let session = get_session(state).await?; 68 + 69 + session 70 + .send(UpdateSeen::new().seen_at(Datetime::now()).build()) 71 + .await 72 + .map_err(|error| { 73 + log::error!("updateSeen error: {error}"); 74 + AppError::validation("updateSeen error") 75 + })? 76 + .into_output() 77 + .map_err(|error| { 78 + log::error!("updateSeen output error: {error}"); 79 + AppError::validation("updateSeen output error") 80 + })?; 81 + 82 + Ok(()) 83 + } 84 + 85 + pub async fn get_unread_count(state: &AppState) -> Result<i64> { 86 + let session = get_session(state).await?; 87 + 88 + let output = session 89 + .send(GetUnreadCount::new().build()) 90 + .await 91 + .map_err(|error| { 92 + log::error!("getUnreadCount error: {error}"); 93 + AppError::validation("getUnreadCount error") 94 + })? 95 + .into_output() 96 + .map_err(|error| { 97 + log::error!("getUnreadCount output error: {error}"); 98 + AppError::validation("getUnreadCount output error") 99 + })?; 100 + 101 + Ok(output.count) 102 + }
+24 -18
src-tauri/src/tray.rs
··· 7 7 use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; 8 8 9 9 const COMPOSER_WINDOW_LABEL: &str = "composer"; 10 - const COMPOSER_WINDOW_ROUTE: &str = "index.html#/composer"; 10 + const APP_INDEX_PATH: &str = "index.html"; 11 + const COMPOSER_HASH_ROUTE: &str = "#/composer"; 12 + const COMPOSER_INIT_SCRIPT: &str = r#" 13 + if (window.location.hash !== '#/composer') { 14 + window.location.replace(`${window.location.pathname}${window.location.search}#/composer`); 15 + } 16 + "#; 11 17 const MAIN_WINDOW_LABEL: &str = "main"; 12 18 const MENU_NEW_POST: &str = "new_post"; 13 19 const MENU_TOGGLE_WINDOW: &str = "toggle_window"; ··· 29 35 MENU_NEW_POST => { 30 36 let _ = open_composer_window(app); 31 37 } 32 - MENU_TOGGLE_WINDOW => { 33 - toggle_window_visibility(app); 34 - } 35 - MENU_QUIT => { 36 - app.exit(0); 37 - } 38 + MENU_TOGGLE_WINDOW => toggle_window_visibility(app), 39 + MENU_QUIT => app.exit(0), 38 40 _ => {} 39 41 }) 40 42 .on_tray_icon_event(|tray, event| { ··· 77 79 78 80 fn open_composer_window(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> { 79 81 if let Some(window) = app.get_webview_window(COMPOSER_WINDOW_LABEL) { 82 + route_window_to_composer(&window); 80 83 show_window(&window); 81 84 return Ok(()); 82 85 } 83 86 84 - let window = WebviewWindowBuilder::new( 85 - app, 86 - COMPOSER_WINDOW_LABEL, 87 - WebviewUrl::App(COMPOSER_WINDOW_ROUTE.into()), 88 - ) 89 - .title("New Post") 90 - .inner_size(720.0, 640.0) 91 - .min_inner_size(560.0, 420.0) 92 - .resizable(true) 93 - .center() 94 - .build()?; 87 + let window = WebviewWindowBuilder::new(app, COMPOSER_WINDOW_LABEL, WebviewUrl::App(APP_INDEX_PATH.into())) 88 + .initialization_script(COMPOSER_INIT_SCRIPT) 89 + .title("New Post") 90 + .inner_size(720.0, 640.0) 91 + .min_inner_size(560.0, 420.0) 92 + .resizable(true) 93 + .center() 94 + .build()?; 95 95 96 96 show_window(&window); 97 97 98 98 Ok(()) 99 + } 100 + 101 + fn route_window_to_composer(window: &WebviewWindow) { 102 + let _ = window.eval(format!( 103 + "if (window.location.hash !== '{COMPOSER_HASH_ROUTE}') {{ window.location.hash = '/composer'; }}" 104 + )); 99 105 } 100 106 101 107 fn show_window(window: &WebviewWindow) {
+97 -36
src/App.tsx
··· 5 5 switchAccount as switchAccountRequest, 6 6 } from "$/lib/api/app"; 7 7 import { listen } from "@tauri-apps/api/event"; 8 + import { getCurrentWindow } from "@tauri-apps/api/window"; 8 9 import { createEffect, createMemo, onCleanup, onMount, Show, startTransition } from "solid-js"; 9 10 import { createStore } from "solid-js/store"; 10 11 import "@fontsource-variable/google-sans"; ··· 22 23 import type { AccountSummary, ActiveSession } from "./lib/types"; 23 24 import { AppRouter } from "./router"; 24 25 26 + const COMPOSER_WINDOW_LABEL = "composer"; 25 27 const RAIL_COLLAPSED_STORAGE_KEY = "lazurite:rail-collapsed"; 26 28 27 29 type AppState = { ··· 61 63 function App() { 62 64 const [app, setApp] = createStore<AppState>(createInitialAppState()); 63 65 66 + const standaloneComposerWindow = isComposerWindow(); 64 67 const activeAccount = createMemo(() => 65 68 app.accounts.find((account) => account.did === app.activeSession?.did) ?? null 66 69 ); ··· 240 243 } 241 244 242 245 return ( 243 - <AppRouter 244 - bootstrapping={app.bootstrapping} 245 - hasSession={hasSession()} 246 - session={app.activeSession} 247 - onLocationChange={() => setApp("showSwitcher", false)} 248 - renderAuth={() => ( 249 - <AuthWorkspace 250 - accounts={app.accounts} 251 - activeAccount={activeAccount()} 252 - activeSession={app.activeSession} 253 - activeDid={app.activeSession?.did ?? null} 254 - bootstrapping={app.bootstrapping} 255 - loggingIn={app.loggingIn} 256 - loginValue={app.loginValue} 257 - logoutDid={app.logoutDid} 258 - metaLabel={metaLabel()} 259 - reauthNeeded={app.reauthNeeded} 260 - shakeCount={app.shakeCount} 261 - switchingDid={app.switchingDid} 262 - onInput={(value) => setApp("loginValue", value)} 263 - onLogout={(did) => void logout(did)} 264 - onReauth={() => void reauthorizePrimaryAccount()} 265 - onSubmit={() => void submitLogin()} 266 - onSwitch={(did) => void switchAccount(did)} /> 267 - )} 268 - renderShell={AppShell} 269 - renderComposer={(session) => ( 270 - <ComposerWindow activeHandle={session.handle} onError={(message) => setApp("errorMessage", message)} /> 271 - )} 272 - renderTimeline={(session, context) => ( 273 - <FeedWorkspace 274 - activeSession={session} 275 - onError={(message) => setApp("errorMessage", message)} 276 - onThreadRouteChange={context.onThreadRouteChange} 277 - threadUri={context.threadUri} /> 278 - )} /> 246 + <Show 247 + when={!standaloneComposerWindow} 248 + fallback={ 249 + <> 250 + <Show when={!app.bootstrapping} fallback={<ComposerBootState />}> 251 + <Show 252 + when={app.activeSession} 253 + keyed 254 + fallback={ 255 + <div class="grid min-h-screen place-items-center bg-surface-container-lowest p-6"> 256 + <div class="w-full max-w-md"> 257 + <LoginPanel 258 + value={app.loginValue} 259 + pending={app.loggingIn} 260 + shakeCount={app.shakeCount} 261 + onInput={(value) => setApp("loginValue", value)} 262 + onSubmit={() => void submitLogin()} /> 263 + </div> 264 + </div> 265 + }> 266 + {(session) => ( 267 + <ComposerWindow 268 + activeAvatar={activeAccount()?.avatar} 269 + activeHandle={session.handle} 270 + onError={(message) => setApp("errorMessage", message)} /> 271 + )} 272 + </Show> 273 + </Show> 274 + 275 + <ErrorToast 276 + message={app.errorMessage} 277 + onDismiss={() => setApp("errorMessage", null)} /> 278 + </> 279 + }> 280 + <AppRouter 281 + bootstrapping={app.bootstrapping} 282 + hasSession={hasSession()} 283 + session={app.activeSession} 284 + onLocationChange={() => setApp("showSwitcher", false)} 285 + renderAuth={() => ( 286 + <AuthWorkspace 287 + accounts={app.accounts} 288 + activeAccount={activeAccount()} 289 + activeSession={app.activeSession} 290 + activeDid={app.activeSession?.did ?? null} 291 + bootstrapping={app.bootstrapping} 292 + loggingIn={app.loggingIn} 293 + loginValue={app.loginValue} 294 + logoutDid={app.logoutDid} 295 + metaLabel={metaLabel()} 296 + reauthNeeded={app.reauthNeeded} 297 + shakeCount={app.shakeCount} 298 + switchingDid={app.switchingDid} 299 + onInput={(value) => setApp("loginValue", value)} 300 + onLogout={(did) => void logout(did)} 301 + onReauth={() => void reauthorizePrimaryAccount()} 302 + onSubmit={() => void submitLogin()} 303 + onSwitch={(did) => void switchAccount(did)} /> 304 + )} 305 + renderShell={AppShell} 306 + renderComposer={(session) => ( 307 + <ComposerWindow 308 + activeAvatar={activeAccount()?.avatar} 309 + activeHandle={session.handle} 310 + onError={(message) => setApp("errorMessage", message)} /> 311 + )} 312 + renderTimeline={(session, context) => ( 313 + <FeedWorkspace 314 + activeAvatar={activeAccount()?.avatar} 315 + activeSession={session} 316 + onError={(message) => setApp("errorMessage", message)} 317 + onThreadRouteChange={context.onThreadRouteChange} 318 + threadUri={context.threadUri} /> 319 + )} /> 320 + </Show> 321 + ); 322 + } 323 + 324 + function isComposerWindow() { 325 + try { 326 + return getCurrentWindow().label === COMPOSER_WINDOW_LABEL; 327 + } catch { 328 + return false; 329 + } 330 + } 331 + 332 + function ComposerBootState() { 333 + return ( 334 + <div class="grid min-h-screen place-items-center bg-surface-container-lowest p-6"> 335 + <div class="grid gap-3 text-center"> 336 + <p class="overline-copy text-sm text-on-surface-variant">Loading</p> 337 + <p class="m-0 text-base text-on-surface">Restoring the composer.</p> 338 + </div> 339 + </div> 279 340 ); 280 341 } 281 342
+3 -2
src/components/AppRail.tsx
··· 60 60 ) { 61 61 return ( 62 62 <aside 63 - 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" 63 + 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)_auto] max-[1180px]:items-center max-[1180px]:gap-x-4 max-[1180px]:gap-y-3 max-[1180px]:p-4" 64 64 classList={{ "items-center px-4": props.collapsed && !props.narrow, "gap-5": props.collapsed && !props.narrow }} 65 65 aria-label="Primary navigation"> 66 66 <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> ··· 70 70 activeSession={props.activeSession} 71 71 accounts={props.accounts} 72 72 busyDid={props.switchingDid} 73 - compact={props.collapsed && !props.narrow} 73 + compact={props.collapsed || props.narrow} 74 74 logoutDid={props.logoutDid} 75 + narrow={props.narrow} 75 76 open={props.openSwitcher} 76 77 onClose={props.onCloseSwitcher} 77 78 onToggle={props.onToggleSwitcher}
+9 -3
src/components/account/AccountSwitcher.tsx
··· 11 11 busyDid: string | null; 12 12 compact?: boolean; 13 13 logoutDid: string | null; 14 + narrow?: boolean; 14 15 open: boolean; 15 16 onClose: () => void; 16 17 onToggle: () => void; ··· 68 69 69 70 return ( 70 71 <div 71 - 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" 72 - classList={{ "w-auto": !!props.compact }} 72 + class="relative mt-auto w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:mt-0 max-[1180px]:max-w-none" 73 + classList={{ 74 + "w-auto": !!props.compact, 75 + "max-[1180px]:col-start-3 max-[1180px]:row-start-1 max-[1180px]:justify-self-end": !!props.narrow, 76 + "max-[1180px]:col-span-full max-[1180px]:justify-self-stretch": !props.narrow, 77 + }} 73 78 ref={(element) => { 74 79 container = element; 75 80 }}> ··· 110 115 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 116 classList={{ 112 117 "inset-x-0 bottom-[calc(100%+0.75rem)]": !props.compact, 113 - "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": !!props.compact, 118 + "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": !!props.compact && !props.narrow, 119 + "right-0 w-[19rem]": !!props.compact && !!props.narrow, 114 120 }} 115 121 role="menu"> 116 122 <p class="overline-copy text-[0.68rem] text-on-surface-variant">Accounts</p>
+2 -1
src/components/feeds/ComposerWindow.tsx
··· 5 5 import { createSignal } from "solid-js"; 6 6 import { ComposerSurface } from "./FeedComposer"; 7 7 8 - type ComposerWindowProps = { activeHandle: string; onError: (message: string) => void }; 8 + type ComposerWindowProps = { activeAvatar?: string | null; activeHandle: string; onError: (message: string) => void }; 9 9 10 10 async function closeWindow() { 11 11 await getCurrentWindow().close(); ··· 36 36 return ( 37 37 <div class="min-h-screen bg-[radial-gradient(circle_at_top,rgba(125,175,255,0.12),transparent_32%),#000]"> 38 38 <ComposerSurface 39 + activeAvatar={props.activeAvatar} 39 40 activeHandle={props.activeHandle} 40 41 layout="window" 41 42 pending={pending()}
+33 -8
src/components/feeds/FeedComposer.tsx
··· 6 6 7 7 type ComposerSuggestion = { label: string; type: "handle" | "hashtag" }; 8 8 9 - export function ComposerLauncher(props: { activeHandle: string; onCompose: () => void }) { 9 + export function ComposerLauncher(props: { activeAvatar?: string | null; activeHandle: string; onCompose: () => void }) { 10 10 return ( 11 11 <button 12 12 class="mb-4 flex w-full min-w-0 items-center gap-3 rounded-3xl border-0 bg-white/3 px-4 py-4 text-left text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/5 max-[760px]:gap-2 max-[760px]:px-3.5 max-[520px]:py-3.5" 13 13 type="button" 14 14 onClick={() => props.onCompose()}> 15 - <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.9),rgba(0,115,222,0.72))] text-sm font-semibold text-on-primary-fixed"> 16 - {props.activeHandle.slice(0, 1).toUpperCase()} 17 - </div> 15 + <ComposerIdentityAvatar 16 + activeAvatar={props.activeAvatar} 17 + activeHandle={props.activeHandle} 18 + sizeClass="h-10 w-10" /> 18 19 <div class="min-w-0 flex-1"> 19 20 <p class="m-0 wrap-break-word text-[0.9rem] text-on-surface-variant">What's happening?</p> 20 21 </div> ··· 28 29 } 29 30 30 31 type FeedComposerProps = { 32 + activeAvatar?: string | null; 31 33 activeHandle: string | null; 32 34 open: boolean; 33 35 pending: boolean; ··· 60 62 onClick={() => props.onClose()} /> 61 63 62 64 <ComposerSurface 65 + activeAvatar={props.activeAvatar} 63 66 activeHandle={props.activeHandle} 64 67 layout="dialog" 65 68 pending={props.pending} ··· 92 95 exit={{ opacity: 0, y: 30 }} 93 96 transition={{ duration: 0.24, easing: [0.22, 1, 0.36, 1] }}> 94 97 <ComposerHeader 98 + activeAvatar={props.activeAvatar} 95 99 activeHandle={props.activeHandle} 96 100 pending={props.pending} 97 101 quoteTarget={props.quoteTarget} ··· 99 103 onClose={props.onClose} 100 104 onSubmit={props.onSubmit} /> 101 105 <ComposerBody 106 + activeAvatar={props.activeAvatar} 102 107 activeHandle={props.activeHandle} 103 108 quoteTarget={props.quoteTarget} 104 109 replyTarget={props.replyTarget} ··· 138 143 139 144 function ComposerHeader( 140 145 props: { 146 + activeAvatar?: string | null; 141 147 activeHandle: string | null; 142 148 pending: boolean; 143 149 quoteTarget: PostView | null; ··· 191 197 192 198 function ComposerBody( 193 199 props: { 200 + activeAvatar?: string | null; 194 201 activeHandle: string | null; 195 202 quoteTarget: PostView | null; 196 203 replyTarget: PostView | null; ··· 205 212 return ( 206 213 <div class="min-h-0 overflow-y-auto overscroll-contain p-6"> 207 214 <div class="flex gap-4 max-[640px]:flex-col"> 208 - <ComposerAvatar activeHandle={props.activeHandle} /> 215 + <ComposerAvatar activeAvatar={props.activeAvatar} activeHandle={props.activeHandle} /> 209 216 <div class="min-w-0 flex-1"> 210 217 <ComposerContexts 211 218 quoteTarget={props.quoteTarget} ··· 221 228 ); 222 229 } 223 230 224 - function ComposerAvatar(props: { activeHandle: string | null }) { 231 + function ComposerAvatar(props: { activeAvatar?: string | null; activeHandle: string | null }) { 232 + return ( 233 + <div class="mt-1"> 234 + <ComposerIdentityAvatar 235 + activeAvatar={props.activeAvatar} 236 + activeHandle={props.activeHandle} 237 + sizeClass="h-11 w-11" /> 238 + </div> 239 + ); 240 + } 241 + 242 + function ComposerIdentityAvatar( 243 + props: { activeAvatar?: string | null; activeHandle: string | null; sizeClass: "h-10 w-10" | "h-11 w-11" }, 244 + ) { 245 + const fallback = () => (props.activeHandle ?? "L").slice(0, 1).toUpperCase(); 246 + 225 247 return ( 226 - <div class="mt-1 flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.95),rgba(0,115,222,0.75))] text-sm font-semibold text-on-primary-fixed"> 227 - {(props.activeHandle ?? "L").slice(0, 1).toUpperCase()} 248 + <div 249 + class={`flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.95),rgba(0,115,222,0.75))] text-sm font-semibold text-on-primary-fixed ${props.sizeClass}`}> 250 + <Show when={props.activeAvatar} fallback={fallback()}> 251 + {(avatar) => <img class="h-full w-full object-cover" src={avatar()} alt="" />} 252 + </Show> 228 253 </div> 229 254 ); 230 255 }
+18 -7
src/components/feeds/FeedPane.tsx
··· 6 6 import { FeedTabBar } from "./FeedTabs"; 7 7 import type { FeedState } from "./types"; 8 8 9 - function FeedHeaderActions(props: { onCompose: () => void; onToggleDrawer: () => void }) { 9 + function FeedHeaderActions(props: { onCompose: () => void; onRefresh: () => void }) { 10 10 return ( 11 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 ··· 19 19 <button 20 20 class="inline-flex h-11 w-11 items-center justify-center rounded-full border-0 bg-white/5 text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 21 21 type="button" 22 - onClick={() => props.onToggleDrawer()}> 23 - <Icon aria-hidden="true" kind="menu" /> 22 + aria-label="Refresh active feed" 23 + title="Refresh active feed" 24 + onClick={() => props.onRefresh()}> 25 + <Icon aria-hidden="true" kind="refresh" /> 24 26 </button> 25 27 </div> 26 28 ); ··· 30 32 props: { 31 33 activeFeedId: string; 32 34 activeFeedState: FeedState | undefined; 35 + activeAvatar?: string | null; 33 36 activeHandle: string; 34 37 focusedIndex: number; 35 38 generators: Record<string, FeedGeneratorView>; ··· 56 59 ref={(element) => props.scrollerRef(element)} 57 60 class="feed-scroll-region min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain px-6 pb-8 pt-4 max-[760px]:px-4 max-[520px]:px-3" 58 61 onScroll={(event) => props.setScrollTop(event.currentTarget.scrollTop)}> 59 - <ComposerLauncher activeHandle={props.activeHandle} onCompose={props.onCompose} /> 62 + <ComposerLauncher 63 + activeAvatar={props.activeAvatar} 64 + activeHandle={props.activeHandle} 65 + onCompose={props.onCompose} /> 60 66 <FeedContent 61 67 activeFeedId={props.activeFeedId} 62 68 activeFeedState={props.activeFeedState} ··· 83 89 activeFeed: SavedFeedItem; 84 90 generators: Record<string, FeedGeneratorView>; 85 91 onCompose: () => void; 86 - onToggleDrawer: () => void; 92 + onRefresh: () => void; 87 93 }, 88 94 ) { 89 95 return ( ··· 94 100 {getFeedName(props.activeFeed, props.generators[props.activeFeed.value]?.displayName)} 95 101 </p> 96 102 </div> 97 - <FeedHeaderActions onCompose={props.onCompose} onToggleDrawer={props.onToggleDrawer} /> 103 + <FeedHeaderActions onCompose={props.onCompose} onRefresh={props.onRefresh} /> 98 104 </div> 99 105 ); 100 106 } ··· 105 111 generators: Record<string, FeedGeneratorView>; 106 112 onCompose: () => void; 107 113 onFeedSelect: (feedId: string) => void; 114 + onRefresh: () => void; 108 115 onToggleDrawer: () => void; 109 116 pinnedFeeds: SavedFeedItem[]; 110 117 }, ··· 115 122 activeFeed={props.activeFeed} 116 123 generators={props.generators} 117 124 onCompose={props.onCompose} 118 - onToggleDrawer={props.onToggleDrawer} /> 125 + onRefresh={props.onRefresh} /> 119 126 <FeedTabBar 120 127 activeFeedId={props.activeFeed.id} 121 128 generators={props.generators} ··· 131 138 activeFeed: SavedFeedItem; 132 139 activeFeedId: string; 133 140 activeFeedState: FeedState | undefined; 141 + activeAvatar?: string | null; 134 142 activeHandle: string; 135 143 focusedIndex: number; 136 144 generators: Record<string, FeedGeneratorView>; ··· 142 150 onLike: (post: PostView) => Promise<void>; 143 151 onOpenThread: (uri: string) => Promise<void>; 144 152 onQuote: (post: PostView) => void; 153 + onRefresh: () => void; 145 154 onReply: (post: PostView, root: PostView) => void; 146 155 onRepost: (post: PostView) => Promise<void>; 147 156 onToggleDrawer: () => void; ··· 162 171 generators={props.generators} 163 172 onCompose={props.onCompose} 164 173 onFeedSelect={props.onFeedSelect} 174 + onRefresh={props.onRefresh} 165 175 onToggleDrawer={props.onToggleDrawer} 166 176 pinnedFeeds={props.pinnedFeeds} /> 167 177 <FeedScroller 168 178 activeFeedId={props.activeFeedId} 169 179 activeFeedState={props.activeFeedState} 180 + activeAvatar={props.activeAvatar} 170 181 activeHandle={props.activeHandle} 171 182 focusedIndex={props.focusedIndex} 172 183 generators={props.generators}
+3
src/components/feeds/FeedWorkspace.tsx
··· 15 15 activeFeed={controller.activeFeed()} 16 16 activeFeedId={controller.activeFeed().id} 17 17 activeFeedState={controller.activeFeedState()} 18 + activeAvatar={props.activeAvatar} 18 19 activeHandle={props.activeSession.handle} 19 20 focusedIndex={controller.workspace.focusedIndex} 20 21 generators={controller.workspace.generators} ··· 26 27 onLike={controller.toggleLike} 27 28 onOpenThread={controller.openThread} 28 29 onQuote={controller.openQuoteComposer} 30 + onRefresh={() => void controller.refreshActiveFeed()} 29 31 onReply={controller.openReplyComposer} 30 32 onRepost={controller.toggleRepost} 31 33 onToggleDrawer={controller.toggleFeedsDrawer} ··· 70 72 thread={controller.workspace.thread.data} /> 71 73 72 74 <FeedComposer 75 + activeAvatar={props.activeAvatar} 73 76 activeHandle={props.activeSession.handle} 74 77 open={controller.workspace.composer.open} 75 78 pending={controller.workspace.composer.pending}
+2
src/components/feeds/useFeedWorkspaceController.ts
··· 44 44 } from "./workspace-state"; 45 45 46 46 export type FeedWorkspaceProps = { 47 + activeAvatar?: string | null; 47 48 activeSession: ActiveSession; 48 49 onError: (message: string) => void; 49 50 onThreadRouteChange: (uri: string | null) => void; ··· 707 708 postRefs, 708 709 registerScroller, 709 710 registerSentinel, 711 + refreshActiveFeed, 710 712 rememberScrollTop, 711 713 reorderPinnedFeeds, 712 714 resetComposer,
+13
src/index.tsx
··· 1 1 /* @refresh reload */ 2 + import { getCurrentWindow } from "@tauri-apps/api/window"; 2 3 import { render } from "solid-js/web"; 3 4 import App from "./App"; 4 5 6 + applyInitialRoute(); 7 + 5 8 render(() => <App />, document.getElementById("root") as HTMLElement); 9 + 10 + function applyInitialRoute() { 11 + try { 12 + if (getCurrentWindow().label === "composer") { 13 + globalThis.history.replaceState(null, "", "#/composer"); 14 + } 15 + } catch { 16 + // Non-Tauri environments do not expose a window label. 17 + } 18 + }