diff --git a/.changeset/desktop-shortcuts.md b/.changeset/desktop-shortcuts.md new file mode 100644 index 0000000000..2c1dfa4025 --- /dev/null +++ b/.changeset/desktop-shortcuts.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Desktop: add keyboard accelerators for settings, close and quit (macOS via the native menu, Windows/Linux via Ctrl shortcuts) and a global show/hide-window hotkey. diff --git a/.changeset/desktop-window-chrome.md b/.changeset/desktop-window-chrome.md new file mode 100644 index 0000000000..78017e0c8c --- /dev/null +++ b/.changeset/desktop-window-chrome.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Desktop: unify the macOS titlebar (native traffic lights float over the content), add a custom titlebar with window controls on Linux, surface sync/connection status as a pill in the custom titlebar, and suppress the webview's default context menu everywhere except editable fields. diff --git a/.changeset/mobile-native-ux.md b/.changeset/mobile-native-ux.md new file mode 100644 index 0000000000..5b536e9dee --- /dev/null +++ b/.changeset/mobile-native-ux.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Mobile: bridge the Android hardware back button to the app (closes overlays and pops routes, backgrounds instead of exiting), add haptic feedback on swipe-to-reply, chat swipe and drawer commits, set the iOS keyboard inset so the composer stays above the keyboard, and scroll the composer into view on focus. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 98a9129c16..cbf12d9301 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2324,6 +2324,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types 0.7.0", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "globset" version = "0.4.18" @@ -5244,6 +5262,7 @@ dependencies = [ "tauri-plugin-devtools", "tauri-plugin-dialog", "tauri-plugin-edge-to-edge", + "tauri-plugin-global-shortcut", "tauri-plugin-http", "tauri-plugin-log", "tauri-plugin-notifications", @@ -6393,6 +6412,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-http" version = "2.5.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f26f453bd2..f932110037 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -79,6 +79,7 @@ tauri-plugin-updater = { version = "2", optional = true } tauri-plugin-process = "2" tauri-plugin-dialog = "2" tauri-plugin-window-state = "2.4.1" +tauri-plugin-global-shortcut = "2" [target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] gtk = "0.18" diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index a8d3a0936b..69ae2e3695 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + diff --git a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt index 7130a13a32..1210a65549 100644 --- a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt @@ -2,12 +2,18 @@ package moe.sable.client import android.graphics.Color import android.os.Bundle +import android.webkit.WebView +import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.core.view.WindowCompat class MainActivity : TauriActivity() { private external fun nativeInitStatusBar() + // Route the hardware back button through the web app (see onWebViewCreate). + override val handleBackNavigation: Boolean = false + private var webView: WebView? = null + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -15,6 +21,28 @@ class MainActivity : TauriActivity() { runCatching { nativeInitStatusBar() } } + override fun onWebViewCreate(webView: WebView) { + this.webView = webView + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val wv = this@MainActivity.webView + if (wv == null) { + moveTaskToBack(true) + return + } + // If the web app didn't consume the back press, background the app. + wv.evaluateJavascript( + "(typeof window.__sableAndroidBack === 'function' && window.__sableAndroidBack() === true)" + ) { result -> + if (result != "true") moveTaskToBack(true) + } + } + } + ) + } + override fun onDestroy() { if (instance === this) instance = null super.onDestroy() diff --git a/src-tauri/src/desktop/menu.rs b/src-tauri/src/desktop/menu.rs new file mode 100644 index 0000000000..c52c69ae6b --- /dev/null +++ b/src-tauri/src/desktop/menu.rs @@ -0,0 +1,72 @@ +#[cfg(target_os = "macos")] +use tauri::Emitter; +use tauri::{AppHandle, Manager}; + +#[cfg(target_os = "macos")] +pub const SETTINGS_MENU_ID: &str = "settings"; + +pub const TOGGLE_WINDOW_ACCELERATOR: &str = "CmdOrCtrl+Shift+S"; + +// Extend the standard menu (Edit submenu for webview copy/paste, Quit, Close) +// with a Settings item. +#[cfg(target_os = "macos")] +pub fn build_app_menu( + handle: &AppHandle, +) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, Submenu}; + + let menu = Menu::default(handle)?; + let settings = MenuItem::with_id( + handle, + SETTINGS_MENU_ID, + "Settings…", + true, + Some("CmdOrCtrl+,"), + )?; + let preferences = Submenu::with_items(handle, "Preferences", true, &[&settings])?; + menu.append(&preferences)?; + Ok(menu) +} + +#[cfg(target_os = "macos")] +pub fn handle_menu_event(app: &AppHandle, event: &tauri::menu::MenuEvent) { + if event.id().as_ref() == SETTINGS_MENU_ID { + let _ = app.emit("open-settings", ()); + } +} + +pub fn toggle_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window(crate::MAIN_WINDOW_LABEL) { + let visible = window.is_visible().unwrap_or(false); + let focused = window.is_focused().unwrap_or(false); + if visible && focused { + let _ = window.hide(); + } else { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + } else { + let _ = crate::show_or_create_main_window(app); + } +} + +pub fn global_shortcut_plugin() -> tauri::plugin::TauriPlugin { + use tauri_plugin_global_shortcut::{Builder, ShortcutState}; + + Builder::new() + .with_handler(|app, _shortcut, event| { + if event.state() == ShortcutState::Pressed { + toggle_main_window(app); + } + }) + .build() +} + +pub fn register_global_shortcuts(app: &AppHandle) { + use tauri_plugin_global_shortcut::GlobalShortcutExt; + + if let Err(error) = app.global_shortcut().register(TOGGLE_WINDOW_ACCELERATOR) { + log::warn!("Failed to register global show/hide shortcut: {error}"); + } +} diff --git a/src-tauri/src/desktop/mod.rs b/src-tauri/src/desktop/mod.rs index 7211f51de7..c9393f0273 100644 --- a/src-tauri/src/desktop/mod.rs +++ b/src-tauri/src/desktop/mod.rs @@ -1,4 +1,5 @@ pub mod download; +pub mod menu; pub mod runtime_state; pub mod settings; pub mod tray; diff --git a/src-tauri/src/ios.rs b/src-tauri/src/ios.rs index 26ab66cafe..6bb47393a1 100644 --- a/src-tauri/src/ios.rs +++ b/src-tauri/src/ios.rs @@ -5,6 +5,7 @@ use std::ffi::CString; +use objc2::rc::{Allocated, Retained}; use objc2::runtime::{AnyClass, AnyObject, ClassBuilder, Sel}; use objc2::{msg_send, sel}; use tauri::webview::WebviewWindow; @@ -23,6 +24,37 @@ pub fn enable_swipe_back_navigation(window: &WebviewWindow }); } +// UIFeedbackGenerator must run on the main thread, where Tauri schedules mobile +// commands. +#[tauri::command] +pub fn haptic_feedback(style: String) { + unsafe { + if style == "selection" { + let Some(cls) = AnyClass::get(c"UISelectionFeedbackGenerator") else { + return; + }; + let allocated: Allocated = msg_send![cls, alloc]; + let generator: Retained = msg_send![allocated, init]; + let _: () = msg_send![&*generator, prepare]; + let _: () = msg_send![&*generator, selectionChanged]; + } else { + // UIImpactFeedbackStyle: light = 0, medium = 1, heavy = 2. + let intensity: isize = match style.as_str() { + "medium" => 1, + "heavy" => 2, + _ => 0, + }; + let Some(cls) = AnyClass::get(c"UIImpactFeedbackGenerator") else { + return; + }; + let allocated: Allocated = msg_send![cls, alloc]; + let generator: Retained = msg_send![allocated, initWithStyle: intensity]; + let _: () = msg_send![&*generator, prepare]; + let _: () = msg_send![&*generator, impactOccurred]; + } + } +} + pub fn hide_form_accessory_bar(window: &WebviewWindow) { let _ = window.with_webview(|webview| unsafe { let webview: *mut AnyObject = webview.inner().cast(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9aa3058a28..db2a7a72b2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -129,10 +129,14 @@ pub fn show_or_create_main_window(app: &AppHandle) -> taur .inner_size(1280.0, 720.0) .visible(false); + // Float the native traffic lights over the content for a unified look. #[cfg(target_os = "macos")] - let builder = builder.hidden_title(true); + let builder = builder + .hidden_title(true) + .title_bar_style(tauri::TitleBarStyle::Transparent); - #[cfg(target_os = "windows")] + // Windows and Linux draw their own titlebar (DesktopTitleBar). + #[cfg(any(target_os = "windows", target_os = "linux"))] let builder = builder.decorations(false); let _webview_window = builder.build()?; @@ -215,7 +219,12 @@ pub fn run() { // copy/paste/select-all to work in the webview. It lives in the system menu // bar, so it is macOS-only to avoid an in-window menu bar elsewhere. #[cfg(target_os = "macos")] - let builder = builder.menu(|handle| tauri::menu::Menu::default(handle)); + let builder = builder + .menu(|handle| desktop::menu::build_app_menu(handle)) + .on_menu_event(|app, event| desktop::menu::handle_menu_event(app, &event)); + + #[cfg(desktop)] + let builder = builder.plugin(desktop::menu::global_shortcut_plugin()); #[cfg(desktop)] let builder = builder.plugin( @@ -309,6 +318,9 @@ pub fn run() { #[cfg(desktop)] desktop::tray::sync_desktop_settings_inner(app.handle())?; + #[cfg(desktop)] + desktop::menu::register_global_shortcuts(app.handle()); + #[cfg(debug_assertions)] { let (log_plugin, _level, logger) = tauri_plugin_log::Builder::default() @@ -334,6 +346,8 @@ pub fn run() { mobile::set_status_bar_color, #[cfg(target_os = "android")] mobile::set_navigation_bar_color, + #[cfg(target_os = "ios")] + ios::haptic_feedback, #[cfg(desktop)] desktop::download::save_download, #[cfg(desktop)] diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx index 1ed0ecf301..8541977053 100644 --- a/src/app/components/SwipeableChatWrapper.tsx +++ b/src/app/components/SwipeableChatWrapper.tsx @@ -3,6 +3,7 @@ import { animate, motion, useMotionValue } from 'framer-motion'; import { useDrag } from '@use-gesture/react'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom, RightSwipeAction } from '$state/settings'; +import { haptic } from '$utils/haptics'; import { mobileOrTablet } from '$utils/user-agent'; interface SwipeableChatWrapperProps { @@ -48,6 +49,7 @@ export function SwipeableChatWrapper({ const velocityThreshold = 0.5; if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) { + haptic('light'); if (rightSwipeAction === RightSwipeAction.Members) { onOpenMembers?.(); } else { diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx index 43ab004344..72e5bdf5b1 100644 --- a/src/app/components/SwipeableMessageWrapper.tsx +++ b/src/app/components/SwipeableMessageWrapper.tsx @@ -5,6 +5,7 @@ import { useMemo, useState } from 'react'; import { useAtomValue } from 'jotai'; import { config } from 'folds'; import { ArrowBendUpLeftIcon, getPhosphorIconSize } from '$components/icons/phosphor'; +import { haptic } from '$utils/haptics'; import { mobileOrTablet } from '$utils/user-agent'; import { RightSwipeAction, settingsAtom } from '$state/settings'; @@ -19,7 +20,11 @@ function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onRepl if (active) { const val = mx < 0 ? mx : 0; x.set(Math.max(-80, val)); - if (mx < -50 !== isReady) setIsReady(mx < -50); + const nextReady = mx < -50; + if (nextReady !== isReady) { + setIsReady(nextReady); + if (nextReady) haptic('selection'); + } } else { if (mx < -50) onReply(); x.set(0); diff --git a/src/app/components/app-shell/AppShell.tsx b/src/app/components/app-shell/AppShell.tsx index 837cb0fdf3..82fdf15212 100644 --- a/src/app/components/app-shell/AppShell.tsx +++ b/src/app/components/app-shell/AppShell.tsx @@ -6,7 +6,8 @@ import { isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; import { TauriFrontendReady } from '$components/tauri/TauriFrontendReady'; -import { WindowsTitleBar } from '$components/tauri/WindowsTitleBar'; +import { DesktopTitleBar } from '$components/tauri/DesktopTitleBar'; +import { MacTitleBar } from '$components/tauri/MacTitleBar'; import { Toast } from '$components/toast/Toast'; import type { ScreenSize } from '$hooks/useScreenSize'; import { ScreenSizeProvider } from '$hooks/useScreenSize'; @@ -27,11 +28,11 @@ type AppShellProps = { export function AppShell({ children, queryClient, screenSize }: AppShellProps) { const tauriOs = isTauri() ? osType() : undefined; - const useCustomWindowsTitleBar = tauriOs === 'windows'; + const useDesktopTitleBar = tauriOs === 'windows' || tauriOs === 'linux'; + const useMacTitleBar = tauriOs === 'macos'; + const hasCustomTitleBar = useDesktopTitleBar || useMacTitleBar; const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); - const contentHeight = useCustomWindowsTitleBar - ? 'calc(100% - var(--tauri-titlebar-height))' - : '100%'; + const contentHeight = hasCustomTitleBar ? 'calc(100% - var(--tauri-titlebar-height))' : '100%'; const [portalContainer, setPortalContainer] = useState(null); return ( @@ -52,7 +53,8 @@ export function AppShell({ children, queryClient, screenSize }: AppShellProps) { height: '100%', }} > - {useCustomWindowsTitleBar && } + {useDesktopTitleBar && } + {useMacTitleBar && }
( if (next && next !== editableRef.current && next.isContentEditable) return; ReactEditor.focus(editor); }} + // Once the virtual keyboard has settled, make sure the composer is + // not left hidden behind it. + onFocus={() => { + if (!mobileOrTablet()) return; + const scrollIn = () => rootRef.current?.scrollIntoView({ block: 'nearest' }); + window.visualViewport?.addEventListener('resize', scrollIn, { once: true }); + window.setTimeout(scrollIn, 500); + }} style={{ boxShadow: 'none' }} /> diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index ab3fde5624..8f141ce174 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -27,6 +27,7 @@ import { sizedIcon, } from '$components/icons/phosphor'; import { useImageGestures } from '$hooks/useImageGestures'; +import { useAndroidBackHandler } from '$utils/androidBack'; import { useSetting } from '$state/hooks/settings'; import { isPixelatedRendering, settingsAtom } from '$state/settings'; import { downloadMedia } from '$utils/matrix'; @@ -51,6 +52,12 @@ export const ImageViewer = as<'div', ImageViewerProps>( const zoomInputRef = useRef(null); const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); + // Android back closes the viewer instead of navigating away. + useAndroidBackHandler(() => { + requestClose(); + return true; + }); + const [isImageReady, setIsImageReady] = useState(false); const [isEditingZoom, setIsEditingZoom] = useState(false); const [zoomInput, setZoomInput] = useState('100'); diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx index 39ba12a453..963db0b3f8 100644 --- a/src/app/components/page/MobileNavDrawer.tsx +++ b/src/app/components/page/MobileNavDrawer.tsx @@ -18,6 +18,7 @@ import { SPACE_ROOM_PATH, } from '$pages/paths'; import { resolveSection } from '$pages/pathUtils'; +import { haptic } from '$utils/haptics'; import { isRoomAlias, isRoomId } from '$utils/matrix'; import { PersistentRoomHost } from './PersistentRoomHost'; @@ -212,6 +213,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra draggingRef.current = false; const opened = width + ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx > 0); if (opened) { + haptic('light'); panelIntentRef.current = 0; setPanelIntent(0); settle(0); @@ -252,6 +254,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra const lastRoomId = lastRoom?.[section.key]; if (lastRoomId) { const roomPath = section.getRoomPath(lastRoomId); + haptic('light'); panelIntentRef.current = 1; setPanelIntent(1); settle(-width); diff --git a/src/app/components/tauri/DesktopShortcuts.tsx b/src/app/components/tauri/DesktopShortcuts.tsx new file mode 100644 index 0000000000..537af9d5a0 --- /dev/null +++ b/src/app/components/tauri/DesktopShortcuts.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { listen } from '@tauri-apps/api/event'; +import { exit } from '@tauri-apps/plugin-process'; +import { useOpenSettings } from '$features/settings'; +import { createLogger } from '$utils/debug'; + +const log = createLogger('DesktopShortcuts'); + +// macOS drives these through the native menu (emitting `open-settings`); +// Windows/Linux have no menubar, so they are wired to Ctrl-key shortcuts here. +export function DesktopShortcuts() { + const openSettings = useOpenSettings(); + + useEffect(() => { + if (!isTauri()) return undefined; + const os = osType(); + if (os !== 'windows' && os !== 'linux' && os !== 'macos') return undefined; + + const cleanups: Array<() => void> = []; + + let unlisten: (() => void) | undefined; + listen('open-settings', () => openSettings()) + .then((remove) => { + unlisten = remove; + }) + .catch((error) => log.warn('Failed to listen for open-settings:', error)); + cleanups.push(() => unlisten?.()); + + if (os !== 'macos') { + const onKeyDown = (event: KeyboardEvent) => { + if (!event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return; + const key = event.key.toLowerCase(); + if (key === ',') { + event.preventDefault(); + openSettings(); + } else if (key === 'w') { + event.preventDefault(); + getCurrentWindow() + .close() + .catch((error) => log.warn('Failed to close window:', error)); + } else if (key === 'q') { + event.preventDefault(); + exit(0).catch((error) => log.warn('Failed to quit:', error)); + } + }; + window.addEventListener('keydown', onKeyDown); + cleanups.push(() => window.removeEventListener('keydown', onKeyDown)); + } + + return () => cleanups.forEach((cleanup) => cleanup()); + }, [openSettings]); + + return null; +} diff --git a/src/app/components/tauri/WindowsTitleBar.tsx b/src/app/components/tauri/DesktopTitleBar.tsx similarity index 82% rename from src/app/components/tauri/WindowsTitleBar.tsx rename to src/app/components/tauri/DesktopTitleBar.tsx index 2e65038e5c..5f7c212e9b 100644 --- a/src/app/components/tauri/WindowsTitleBar.tsx +++ b/src/app/components/tauri/DesktopTitleBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { getVersion } from '@tauri-apps/api/app'; import { isTauri } from '@tauri-apps/api/core'; import { getCurrentWindow, type Window } from '@tauri-apps/api/window'; @@ -15,7 +15,7 @@ import { stopWindowTracking, } from '$generated/tauri/commands'; -const log = createLogger('WindowsTitleBar'); +const log = createLogger('DesktopTitleBar'); const SNAP_OVERLAY_DELAY_MS = 620; const SNAP_POPUP_WINDOW_CLASS = 'Xaml_WindowedPopupClass'; const SNAP_POPUP_EXE = 'explorer.exe'; @@ -90,24 +90,29 @@ function CloseIcon() { ); } -export function WindowsTitleBar() { +// Custom titlebar with window controls for Windows and Linux (native chrome is +// disabled there). The Snap Layouts overlay is Windows-only. +export function DesktopTitleBar() { const [maximized, setMaximized] = useState(false); const [windowTitle, setWindowTitle] = useState('Sable'); const appWindowRef = useRef(null); const snapTimerRef = useRef(undefined); - const isWindowsDesktopTauri = isTauri() && osType() === 'windows'; + const os = isTauri() ? osType() : undefined; + const isWindows = os === 'windows'; + const isDesktopTitleBar = isWindows || os === 'linux'; const titlebarStatus = useAtomValue(titlebarStatusAtom); - const hideSnapOverlay = async () => { + const hideSnapOverlay = useCallback(async () => { + if (!isWindows) return; try { await hideSnapOverlayCommand(); } catch (error) { log.warn('Failed to hide snap overlay:', error); } - }; + }, [isWindows]); useEffect(() => { - if (!isWindowsDesktopTauri) return undefined; + if (!isDesktopTitleBar) return undefined; const appWindow = getCurrentWindow(); appWindowRef.current = appWindow; @@ -149,70 +154,74 @@ export function WindowsTitleBar() { log.warn('Failed to subscribe to window resize:', error); }); - listen('window-tracking', (event) => { - const eventType = event.payload?.event_type; - if (eventType === 'TargetLost' || eventType === 'Timeout') { - hideSnapOverlay(); - } - }) - .then((removeListener) => { - unlistenTracking = removeListener; + if (isWindows) { + listen('window-tracking', (event) => { + const eventType = event.payload?.event_type; + if (eventType === 'TargetLost' || eventType === 'Timeout') { + hideSnapOverlay(); + } }) - .catch((error) => { - log.warn('Failed to subscribe to window-tracking event:', error); - }); + .then((removeListener) => { + unlistenTracking = removeListener; + }) + .catch((error) => { + log.warn('Failed to subscribe to window-tracking event:', error); + }); + } return () => { mounted = false; if (snapTimerRef.current !== undefined) { window.clearTimeout(snapTimerRef.current); } - stopWindowTracking().catch(() => {}); - hideSnapOverlay(); + if (isWindows) { + stopWindowTracking().catch(() => {}); + hideSnapOverlay(); + } unlistenResize?.(); unlistenTracking?.(); }; - }, [isWindowsDesktopTauri]); + }, [isDesktopTitleBar, isWindows, hideSnapOverlay]); - if (!isWindowsDesktopTauri) return null; + if (!isDesktopTitleBar) return null; const minimize = () => { - if (!isWindowsDesktopTauri) return; - - stopWindowTracking().catch(() => {}); - hideSnapOverlay(); + if (isWindows) { + stopWindowTracking().catch(() => {}); + hideSnapOverlay(); + } appWindowRef.current?.minimize().catch((error) => { log.warn('Failed to minimize window:', error); }); }; const toggleMaximize = () => { - if (!isWindowsDesktopTauri) return; - if (snapTimerRef.current !== undefined) { window.clearTimeout(snapTimerRef.current); snapTimerRef.current = undefined; } - stopWindowTracking().catch(() => {}); - hideSnapOverlay(); + if (isWindows) { + stopWindowTracking().catch(() => {}); + hideSnapOverlay(); + } appWindowRef.current?.toggleMaximize().catch((error) => { log.warn('Failed to toggle maximize:', error); }); }; const close = () => { - if (!isWindowsDesktopTauri) return; - - stopWindowTracking().catch(() => {}); - hideSnapOverlay(); + if (isWindows) { + stopWindowTracking().catch(() => {}); + hideSnapOverlay(); + } appWindowRef.current?.close().catch((error) => { log.warn('Failed to close window:', error); }); }; const showSnapOverlay = () => { - if (!isWindowsDesktopTauri) return; + if (!isWindows) return; if (snapTimerRef.current !== undefined) { window.clearTimeout(snapTimerRef.current); @@ -230,7 +239,7 @@ export function WindowsTitleBar() { }; const cancelSnapOverlay = () => { - if (!isWindowsDesktopTauri) return; + if (!isWindows) return; if (snapTimerRef.current !== undefined) { window.clearTimeout(snapTimerRef.current); diff --git a/src/app/components/tauri/MacTitleBar.tsx b/src/app/components/tauri/MacTitleBar.tsx new file mode 100644 index 0000000000..bb5799e1d3 --- /dev/null +++ b/src/app/components/tauri/MacTitleBar.tsx @@ -0,0 +1,22 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; +import { useAtomValue } from 'jotai'; +import { titlebarStatusAtom } from '$state/titlebarStatus'; +import { SyncConnectionStatusTitlebar } from '$components/SyncConnectionStatus'; + +// Draggable strip that reserves space for the native traffic lights (floating +// over the content via the transparent titlebar) and hosts the sync pill. +export function MacTitleBar() { + const isMac = isTauri() && osType() === 'macos'; + const titlebarStatus = useAtomValue(titlebarStatusAtom); + + if (!isMac) return null; + + return ( + + ); +} diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 27a99e2066..870cfdb38f 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-20T19:29:46.118402061+00:00 + * Generated at: 2026-07-20T21:19:31.189767200+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -26,6 +26,10 @@ export async function getDesktopRuntimeState(): Promise { + return invoke('haptic_feedback', params); +} + export async function hideSnapOverlay(): Promise { return invoke('hide_snap_overlay'); } diff --git a/src/app/generated/tauri/events.ts b/src/app/generated/tauri/events.ts new file mode 100644 index 0000000000..ca22ec1d70 --- /dev/null +++ b/src/app/generated/tauri/events.ts @@ -0,0 +1,30 @@ +/** + * Auto-generated TypeScript bindings for Tauri commands + * Generated by tauri-typegen v0.5.0 + * Generated at: 2026-07-20T21:19:31.190594500+00:00 + * Generator: none + * + * Do not edit manually - regenerate using: cargo tauri-typegen generate + */ + +/** + * Event Listeners + * Type-safe event listener helpers for Tauri events + */ +import { listen, type UnlistenFn, type Event } from '@tauri-apps/api/event'; +import * as types from './types'; + +/** + * Listen for 'open-settings' events + * @param handler - Callback function to handle the event + * @returns Promise that resolves to an unlisten function + */ +export async function onOpenSettings( + handler: (payload: void) => void +): Promise { + return listen('open-settings', (event) => { + handler(event.payload); + }); +} + + diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index 5b327d1d8e..1552c71e1f 100644 --- a/src/app/generated/tauri/index.ts +++ b/src/app/generated/tauri/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-20T19:29:46.118855830+00:00 + * Generated at: 2026-07-20T21:19:31.191156200+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -9,3 +9,4 @@ export * from './types'; export * from './commands'; +export * from './events'; diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 36d349b021..8589d12658 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-20T19:29:46.117694182+00:00 + * Generated at: 2026-07-20T21:19:31.188681500+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -60,6 +60,11 @@ export interface AbortNativeUploadParams { [key: string]: unknown; } +export interface HapticFeedbackParams { + style: string; + [key: string]: unknown; +} + export interface LoopbackFetchParams { request: LoopbackFetchRequest; [key: string]: unknown; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index d1ffe04883..35fffbdace 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -27,6 +27,7 @@ import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { NotificationJumper } from '$hooks/useNotificationJumper'; import { SearchModalRenderer } from '$features/navigate'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; +import { DesktopShortcuts } from '$components/tauri/DesktopShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; import { SplashScreen } from '$components/splash-screen'; import { @@ -257,6 +258,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + {/* Screen reader live region — populated by announce() in utils/announce.ts */}
({ current: null, @@ -80,53 +83,25 @@ export function SyncStatus({ mx }: SyncStatusProps) { }, []) ); - if (stateData.showConnecting) { - return ( - - - Connecting... - - - - ); - } + const view = useMemo(() => { + if (stateData.showConnecting) return { text: 'Connecting...', variant: 'Success' }; + if (showDisconnected && stateData.current === SyncState.Reconnecting) { + return { text: 'Connection Lost! Reconnecting...', variant: 'Warning' }; + } + if (showDisconnected && stateData.current === SyncState.Error) { + return { text: 'Connection Lost!', variant: 'Critical' }; + } + return null; + }, [stateData, showDisconnected]); - if (showDisconnected && stateData.current === SyncState.Reconnecting) { - return ( - - - Connection Lost! Reconnecting... - - - - ); - } + // Publish to the atom that feeds the custom desktop titlebar's status pill. + useEffect(() => { + setTitlebarStatus(view); + return () => setTitlebarStatus(null); + }, [setTitlebarStatus, view]); - if (showDisconnected && stateData.current === SyncState.Error) { - return ( - - - Connection Lost! - - - - ); - } + // Where a custom titlebar renders the pill, skip the inline banner. + if (hasCustomDesktopTitlebar()) return null; - return null; + return ; } diff --git a/src/app/styles/edgeToEdgeInsets.test.ts b/src/app/styles/edgeToEdgeInsets.test.ts index a5ef0e2df6..2afcca8452 100644 --- a/src/app/styles/edgeToEdgeInsets.test.ts +++ b/src/app/styles/edgeToEdgeInsets.test.ts @@ -47,7 +47,7 @@ describe('android edge-to-edge inset contract', () => { const systemBarShell = readWorkspaceFile('src/app/components/app-shell/SystemBarShell.tsx'); const mobileCapability = readWorkspaceFile('src-tauri/capabilities/mobile.json'); - expect(appShell).toContain('const contentHeight = useCustomWindowsTitleBar'); + expect(appShell).toContain('const contentHeight = hasCustomTitleBar'); expect(appShell).toContain("height: '100%'"); expect(appShell).toContain('height: contentHeight'); expect(appShell).toContain(''); diff --git a/src/app/styles/overrides/TauriDesktop.css b/src/app/styles/overrides/TauriDesktop.css index f09f856e0b..9d4a9a4090 100644 --- a/src/app/styles/overrides/TauriDesktop.css +++ b/src/app/styles/overrides/TauriDesktop.css @@ -20,6 +20,16 @@ -webkit-app-region: drag; } +/* macOS keeps native traffic lights (transparent titlebar); this is just a + draggable strip that reserves space for them and hosts the status pill. */ +.tauri-titlebar--mac { + display: block; + grid-template-columns: none; + padding-left: 72px; + app-region: drag; + -webkit-app-region: drag; +} + .tauri-titlebar__drag { display: flex; align-items: center; diff --git a/src/app/utils/androidBack.ts b/src/app/utils/androidBack.ts new file mode 100644 index 0000000000..7f6b96d771 --- /dev/null +++ b/src/app/utils/androidBack.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react'; +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; + +// Returns true if it consumed the back event (e.g. closed an overlay). +export type AndroidBackHandler = () => boolean; + +const handlers: AndroidBackHandler[] = []; + +export function pushAndroidBackHandler(handler: AndroidBackHandler): () => void { + handlers.push(handler); + return () => { + const index = handlers.lastIndexOf(handler); + if (index !== -1) handlers.splice(index, 1); + }; +} + +function routerBack(): boolean { + const idx = (window.history.state as { idx?: number } | null)?.idx ?? 0; + if (idx > 0) { + window.history.back(); + return true; + } + return false; +} + +function runAndroidBack(): boolean { + for (let i = handlers.length - 1; i >= 0; i -= 1) { + if (handlers[i]?.()) return true; + } + return routerBack(); +} + +declare global { + interface Window { + __sableAndroidBack?: () => boolean; + } +} + +export function installAndroidBackBridge(): void { + if (!isTauri() || osType() !== 'android') return; + window.__sableAndroidBack = runAndroidBack; +} + +export function useAndroidBackHandler(handler: AndroidBackHandler, enabled = true): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + if (!enabled) return undefined; + return pushAndroidBackHandler(() => handlerRef.current()); + }, [enabled]); +} diff --git a/src/app/utils/haptics.ts b/src/app/utils/haptics.ts new file mode 100644 index 0000000000..460b3172e3 --- /dev/null +++ b/src/app/utils/haptics.ts @@ -0,0 +1,21 @@ +import { invoke, isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; + +export type HapticKind = 'light' | 'medium' | 'heavy' | 'selection'; + +const ANDROID_MS: Record = { + light: 8, + medium: 12, + heavy: 18, + selection: 5, +}; + +export function haptic(kind: HapticKind = 'light'): void { + if (!isTauri()) return; + const os = osType(); + if (os === 'android') { + navigator.vibrate?.(ANDROID_MS[kind]); + } else if (os === 'ios') { + invoke('haptic_feedback', { style: kind }).catch(() => {}); + } +} diff --git a/src/app/utils/tauriNative.ts b/src/app/utils/tauriNative.ts new file mode 100644 index 0000000000..94e962256b --- /dev/null +++ b/src/app/utils/tauriNative.ts @@ -0,0 +1,58 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; + +const KEYBOARD_HEIGHT = '--keyboard-height'; +const DESKTOP_OS = new Set(['linux', 'macos', 'windows']); +const EDITABLE_SELECTOR = 'input, textarea, [contenteditable="true"], [contenteditable=""]'; + +function installIosKeyboardInset(): () => void { + let frame = 0; + + const update = () => { + frame = 0; + const viewport = window.visualViewport; + const height = viewport ? window.innerHeight - viewport.height - viewport.offsetTop : 0; + document.documentElement.style.setProperty( + KEYBOARD_HEIGHT, + `${Math.max(0, Math.round(height))}px` + ); + }; + + const schedule = () => { + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(update); + }; + + update(); + window.visualViewport?.addEventListener('resize', schedule); + window.visualViewport?.addEventListener('scroll', schedule); + window.addEventListener('orientationchange', schedule); + + return () => { + if (frame) cancelAnimationFrame(frame); + window.visualViewport?.removeEventListener('resize', schedule); + window.visualViewport?.removeEventListener('scroll', schedule); + window.removeEventListener('orientationchange', schedule); + }; +} + +// Suppress the webview's own context menu except on editable fields, where the +// native paste/spellcheck menu is expected. +function installDesktopContextMenuSuppression(): () => void { + const onContextMenu = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (target?.closest(EDITABLE_SELECTOR)) return; + event.preventDefault(); + }; + + document.addEventListener('contextmenu', onContextMenu); + return () => document.removeEventListener('contextmenu', onContextMenu); +} + +export function installTauriNativeBehaviors(): void { + if (!isTauri()) return; + + const os = osType(); + if (os === 'ios') installIosKeyboardInset(); + if (DESKTOP_OS.has(os)) installDesktopContextMenuSuppression(); +} diff --git a/src/app/utils/tauriTitlebar.ts b/src/app/utils/tauriTitlebar.ts new file mode 100644 index 0000000000..7167df47aa --- /dev/null +++ b/src/app/utils/tauriTitlebar.ts @@ -0,0 +1,10 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; + +// Windows and Linux draw their own titlebar with a slot for the sync pill; macOS +// keeps native decorations and uses the inline banner like web/mobile. +export function hasCustomDesktopTitlebar(): boolean { + if (!isTauri()) return false; + const os = osType(); + return os === 'windows' || os === 'linux'; +} diff --git a/src/index.tsx b/src/index.tsx index 036d41cbbe..cc5c5ec8df 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -24,6 +24,8 @@ import { initTauriMediaSession } from './app/utils/tauriMediaAuth'; import { registerAppServiceWorker } from './serviceWorkerBootstrap'; import { hasServiceWorker } from './app/utils/platform'; import { installIosPwaViewportHeight } from './app/utils/iosPwaViewport'; +import { installTauriNativeBehaviors } from './app/utils/tauriNative'; +import { installAndroidBackBridge } from './app/utils/androidBack'; enableMapSet(); installConsolePasteScamWarning(); @@ -32,6 +34,8 @@ const log = createLogger('index'); document.body.classList.add(configClass, varsClass); installIosPwaViewportHeight(); +installTauriNativeBehaviors(); +installAndroidBackBridge(); registerAppServiceWorker();