From 5cc5bd7f7efc9f194e100b5184ce28d8d1487743 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:10:31 +0200 Subject: [PATCH 1/7] feat(mobile): haptics, iOS keyboard inset, composer scroll, hardware back button --- .../android/app/src/main/AndroidManifest.xml | 3 ++ .../java/moe/sable/client/MainActivity.kt | 28 ++++++++++ src-tauri/src/ios.rs | 32 +++++++++++ src-tauri/src/lib.rs | 2 + src/app/components/SwipeableChatWrapper.tsx | 2 + .../components/SwipeableMessageWrapper.tsx | 7 ++- src/app/components/editor/Editor.tsx | 8 +++ .../components/image-viewer/ImageViewer.tsx | 7 +++ src/app/components/page/MobileNavDrawer.tsx | 3 ++ src/app/utils/androidBack.ts | 53 +++++++++++++++++++ src/app/utils/haptics.ts | 21 ++++++++ src/app/utils/tauriNative.ts | 42 +++++++++++++++ src/index.tsx | 4 ++ 13 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/app/utils/androidBack.ts create mode 100644 src/app/utils/haptics.ts create mode 100644 src/app/utils/tauriNative.ts 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/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 088b4ce3f4..e5b37fb552 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -327,6 +327,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/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 076cae68e8..881d19a6de 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -450,6 +450,14 @@ export const CustomEditor = forwardRef( 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; + window.setTimeout(() => { + rootRef.current?.scrollIntoView({ block: 'nearest' }); + }, 300); + }} 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/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..a8d9788bdd --- /dev/null +++ b/src/app/utils/tauriNative.ts @@ -0,0 +1,42 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; + +const KEYBOARD_HEIGHT = '--keyboard-height'; + +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); + }; +} + +export function installTauriNativeBehaviors(): void { + if (!isTauri()) return; + + const os = osType(); + if (os === 'ios') installIosKeyboardInset(); +} 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(); From 2e64126e45b0c7d719317e1363edc538575ba3df Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:15:13 +0200 Subject: [PATCH 2/7] chore: changeset for mobile native UX --- .changeset/mobile-native-ux.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mobile-native-ux.md 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. From 44bf728aed4d3bfe8d2a8c320a491c874e566d1b Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:11:29 +0200 Subject: [PATCH 3/7] feat(desktop): macOS/Linux custom titlebar, sync-status pill, context-menu suppression --- src-tauri/src/lib.rs | 8 +- src/app/components/app-shell/AppShell.tsx | 14 ++-- ...indowsTitleBar.tsx => DesktopTitleBar.tsx} | 81 ++++++++++--------- src/app/components/tauri/MacTitleBar.tsx | 22 +++++ src/app/pages/client/SyncStatus.tsx | 73 ++++++----------- src/app/styles/edgeToEdgeInsets.test.ts | 2 +- src/app/styles/overrides/TauriDesktop.css | 10 +++ src/app/utils/tauriNative.ts | 16 ++++ src/app/utils/tauriTitlebar.ts | 10 +++ 9 files changed, 142 insertions(+), 94 deletions(-) rename src/app/components/tauri/{WindowsTitleBar.tsx => DesktopTitleBar.tsx} (82%) create mode 100644 src/app/components/tauri/MacTitleBar.tsx create mode 100644 src/app/utils/tauriTitlebar.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e5b37fb552..0e0338c4c8 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()?; 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 && }
(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/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index b616e0a1ff..3f3b48c779 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,10 +1,12 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { SyncState } from '$types/matrix-sdk'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Box, config, Line, Text } from 'folds'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import { useSyncState } from '$hooks/useSyncState'; -import { ContainerColor } from '$styles/ContainerColor.css'; +import { type TitlebarStatusView, titlebarStatusAtom } from '$state/titlebarStatus'; +import { SyncConnectionStatusBanner } from '$components/SyncConnectionStatus'; +import { hasCustomDesktopTitlebar } from '$utils/tauriTitlebar'; const DISCONNECTED_GRACE_MS = 2000; @@ -29,6 +31,7 @@ type SyncStatusProps = { mx: MatrixClient; }; export function SyncStatus({ mx }: SyncStatusProps) { + const setTitlebarStatus = useSetAtom(titlebarStatusAtom); const hasConnectedRef = useRef(false); const [stateData, setStateData] = useState({ 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); + }, [setTitlebarStatus, view]); + useEffect(() => () => setTitlebarStatus(null), [setTitlebarStatus]); - 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 522d313292..2093929380 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/tauriNative.ts b/src/app/utils/tauriNative.ts index a8d9788bdd..94e962256b 100644 --- a/src/app/utils/tauriNative.ts +++ b/src/app/utils/tauriNative.ts @@ -2,6 +2,8 @@ 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; @@ -34,9 +36,23 @@ function installIosKeyboardInset(): () => void { }; } +// 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'; +} From b456e87bfb7cdeaa450683927942f102d5120a68 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:15:35 +0200 Subject: [PATCH 4/7] chore: changeset for desktop window chrome --- .changeset/desktop-window-chrome.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/desktop-window-chrome.md 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. From fddce393379eb3407071178667f9ae3218d6fd68 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:12:40 +0200 Subject: [PATCH 5/7] feat(desktop): menu accelerators and global show/hide shortcut --- src-tauri/Cargo.lock | 70 +++++++++++++----- src-tauri/Cargo.toml | 1 + src-tauri/src/desktop/menu.rs | 72 +++++++++++++++++++ src-tauri/src/desktop/mod.rs | 1 + src-tauri/src/lib.rs | 10 ++- src/app/components/tauri/DesktopShortcuts.tsx | 57 +++++++++++++++ src/app/pages/Router.tsx | 2 + 7 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 src-tauri/src/desktop/menu.rs create mode 100644 src/app/components/tauri/DesktopShortcuts.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0fa65b93e3..f58b92a573 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -148,7 +148,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -159,7 +159,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -957,7 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1481,7 +1481,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1783,7 +1783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -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" @@ -2705,7 +2723,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3530,7 +3548,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3654,7 +3672,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4040,7 +4058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4684,7 +4702,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4721,7 +4739,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -5124,7 +5142,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5182,7 +5200,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5237,6 +5255,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", @@ -5756,7 +5775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6385,6 +6404,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" @@ -6767,7 +6801,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7393,7 +7427,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7467,7 +7501,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8124,7 +8158,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f618f3fa77..a4ddc8ccc4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,6 +74,7 @@ tauri-plugin-updater = "2" 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/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/lib.rs b/src-tauri/src/lib.rs index 0e0338c4c8..1f047193af 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -219,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 +314,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() 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/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 */}
Date: Mon, 20 Jul 2026 10:15:45 +0200 Subject: [PATCH 6/7] chore: changeset for desktop shortcuts --- .changeset/desktop-shortcuts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/desktop-shortcuts.md 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. From f82e9e1b378155fa320ab37f69850716920336ec Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 20 Jul 2026 16:18:40 -0500 Subject: [PATCH 7/7] fix: consolidate titlebar effect, improve keyboard scroll --- src/app/components/editor/Editor.tsx | 6 +++--- src/app/generated/tauri/commands.ts | 6 +++++- src/app/generated/tauri/events.ts | 30 ++++++++++++++++++++++++++++ src/app/generated/tauri/index.ts | 3 ++- src/app/generated/tauri/types.ts | 7 ++++++- src/app/pages/client/SyncStatus.tsx | 2 +- 6 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 src/app/generated/tauri/events.ts diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 881d19a6de..7a6579102b 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -454,9 +454,9 @@ export const CustomEditor = forwardRef( // not left hidden behind it. onFocus={() => { if (!mobileOrTablet()) return; - window.setTimeout(() => { - rootRef.current?.scrollIntoView({ block: 'nearest' }); - }, 300); + 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/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 58feb867df..2a50c56c05 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-20T05:55:07.154868857+00:00 + * Generated at: 2026-07-20T21:11:07.668379700+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -22,6 +22,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..a3bb88431a --- /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:11:07.669009900+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 5fed3c34d5..7a51107aab 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-20T05:55:07.155133577+00:00 + * Generated at: 2026-07-20T21:11:07.669558100+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 c6a8ffdb2d..19704be9a2 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-20T05:55:07.154428637+00:00 + * Generated at: 2026-07-20T21:11:07.667578500+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -42,6 +42,11 @@ export interface AbortLoopbackFetchParams { [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/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 3f3b48c779..446ed40ac1 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -97,8 +97,8 @@ export function SyncStatus({ mx }: SyncStatusProps) { // Publish to the atom that feeds the custom desktop titlebar's status pill. useEffect(() => { setTitlebarStatus(view); + return () => setTitlebarStatus(null); }, [setTitlebarStatus, view]); - useEffect(() => () => setTitlebarStatus(null), [setTitlebarStatus]); // Where a custom titlebar renders the pill, skip the inline banner. if (hasCustomDesktopTitlebar()) return null;