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/fix-overlapping-panels-mobile-swipe.md b/.changeset/fix-overlapping-panels-mobile-swipe.md new file mode 100644 index 0000000000..2c2ad21691 --- /dev/null +++ b/.changeset/fix-overlapping-panels-mobile-swipe.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Smoother mobile navigation and faster room opening. 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/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..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()?; @@ -327,6 +331,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/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index 69ceb097d0..65ff218c57 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -5,6 +5,7 @@ import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { resolveSection } from '$pages/pathUtils'; import { HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths'; import { lastVisitedRoomAtom } from '$state/room/lastRoom'; +import { isRoomAlias, isRoomId } from '$utils/matrix'; type BackRouteHandlerProps = { children: (onBack: () => void) => ReactNode; @@ -24,11 +25,9 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { .find((match) => match !== null); const currentRoomIdOrAlias = roomMatch?.params.roomIdOrAlias; - if (section.getRoomPath && currentRoomIdOrAlias) { - setLastRoom({ - section: section.key, - roomId: decodeURIComponent(currentRoomIdOrAlias), - }); + const decoded = currentRoomIdOrAlias && decodeURIComponent(currentRoomIdOrAlias); + if (section.getRoomPath && decoded && (isRoomId(decoded) || isRoomAlias(decoded))) { + setLastRoom((prev) => ({ ...prev, [section.key]: decoded })); } navigate(section.listPath); 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; + 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 96f4647d88..963db0b3f8 100644 --- a/src/app/components/page/MobileNavDrawer.tsx +++ b/src/app/components/page/MobileNavDrawer.tsx @@ -4,7 +4,6 @@ import { motion, useMotionValue, useReducedMotion } from 'framer-motion'; import { useDrag } from '@use-gesture/react'; import { useAtomValue, useSetAtom } from 'jotai'; import { matchPath, useLocation, useNavigate } from 'react-router-dom'; -import { useMatrixClient } from '$hooks/useMatrixClient'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { lastVisitedRoomAtom } from '$state/room/lastRoom'; @@ -19,6 +18,9 @@ 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'; const SLIDE_MS = 300; const SLIDE_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'; @@ -41,18 +43,27 @@ const clamp = (value: number, min: number, max: number): number => export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDrawerProps) { const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures'); const reduceMotion = useReducedMotion(); - const matrixClient = useMatrixClient(); const location = useLocation(); const navigate = useNavigate(); const setLastRoom = useSetAtom(lastVisitedRoomAtom); const lastRoom = useAtomValue(lastVisitedRoomAtom); + const openableSection = resolveSection(location.pathname); + const canOpenRoom = Boolean( + openableSection && openableSection.getRoomPath && lastRoom?.[openableSection.key] + ); + const roomMatch = matchPath({ path: HOME_ROOM_PATH, end: false }, location.pathname) ?? matchPath({ path: DIRECT_ROOM_PATH, end: false }, location.pathname) ?? matchPath({ path: SPACE_ROOM_PATH, end: false }, location.pathname); + const matchedRoomId = roomMatch?.params.roomIdOrAlias + ? decodeURIComponent(roomMatch.params.roomIdOrAlias) + : undefined; + // `:roomIdOrAlias` also matches non-room segments like `create`, `search`, `lobby`. + // Only treat it as a room when it's a real Matrix id/alias. + const isRoomRoute = !!matchedRoomId && (isRoomId(matchedRoomId) || isRoomAlias(matchedRoomId)); - // The bare section route is the list; anything deeper is content revealed by dragging. const listView = matchPath({ path: HOME_PATH, end: true }, location.pathname) !== null || matchPath({ path: DIRECT_PATH, end: true }, location.pathname) !== null || @@ -68,18 +79,24 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra const [width, setWidth] = useState(0); const x = useMotionValue(0); const draggingRef = useRef(false); - const openingRef = useRef(false); - const prevContentOpenRef = useRef(contentOpen); - const openRafRef = useRef(0); - const [deferContent, setDeferContent] = useState(false); - const [prevOpen, setPrevOpen] = useState(contentOpen); - if (contentOpen !== prevOpen) { - setPrevOpen(contentOpen); - setDeferContent(contentOpen && !reduceMotion && !draggingRef.current); - } + const initialIntent = contentOpen ? 1 : 0; + const [panelIntent, setPanelIntent] = useState(initialIntent); + const panelIntentRef = useRef(initialIntent); + + const [roomArmed, setRoomArmed] = useState(isRoomRoute); + useEffect(() => { + if (isRoomRoute) { + setRoomArmed(true); + return undefined; + } + if (roomArmed) return undefined; + const ric = window.requestIdleCallback ?? ((cb: () => void) => window.setTimeout(cb, 200)); + const cic = window.cancelIdleCallback ?? window.clearTimeout; + const handle = ric(() => setRoomArmed(true)); + return () => cic(handle as number); + }, [isRoomRoute, roomArmed]); - // Compositor transition for the settle animation; off = finger-1:1 during drag. const applyTransition = useCallback((animated: boolean) => { const el = sliderRef.current; if (el) el.style.transition = animated ? `transform ${SLIDE_MS}ms ${SLIDE_EASE}` : 'none'; @@ -98,15 +115,17 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra [reduceMotion, x, applyTransition] ); - // Release the deferred room after the slide is committed to the compositor. - useLayoutEffect(() => { - if (!deferContent) return undefined; - cancelAnimationFrame(openRafRef.current); - openRafRef.current = requestAnimationFrame(() => - requestAnimationFrame(() => setDeferContent(false)) - ); - return () => cancelAnimationFrame(openRafRef.current); - }, [deferContent]); + const readX = useCallback((): number => { + const el = sliderRef.current; + if (!el) return x.get(); + const t = getComputedStyle(el).transform; + if (!t || t === 'none') return 0; + try { + return new DOMMatrix(t).m41; + } catch { + return x.get(); + } + }, [x]); useLayoutEffect(() => { const el = viewportRef.current; @@ -118,88 +137,33 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return () => observer.disconnect(); }, []); - // Keep the offscreen panel out of the focus order and accessibility tree. useLayoutEffect(() => { - navPanelRef.current?.toggleAttribute('inert', contentOpen); - contentPanelRef.current?.toggleAttribute('inert', !contentOpen); - }, [contentOpen]); + navPanelRef.current?.toggleAttribute('inert', panelIntent === 1); + contentPanelRef.current?.toggleAttribute('inert', panelIntent === 0); + }, [panelIntent]); - // Sync panel position to the route: animate on route change, jump on mount/resize. useLayoutEffect(() => { - const routeChanged = prevContentOpenRef.current !== contentOpen; - prevContentOpenRef.current = contentOpen; if (draggingRef.current) return; - const target = contentOpen ? -width : 0; - if (routeChanged && width > 0 && !reduceMotion) { - settle(target); - } else { - applyTransition(false); - x.jump(target); + const routePanel = contentOpen ? 1 : 0; + if (routePanel !== panelIntentRef.current) { + panelIntentRef.current = routePanel; + setPanelIntent(routePanel); + const target = routePanel === 1 ? -width : 0; + if (width > 0 && !reduceMotion) { + settle(target); + } else { + applyTransition(false); + x.jump(target); + } } }, [contentOpen, width, x, settle, applyTransition, reduceMotion]); - const goToList = useCallback((): boolean => { - const section = resolveSection(location.pathname); - if (!section) return false; - - const id = roomMatch?.params.roomIdOrAlias; - if (section.getRoomPath && id) { - setLastRoom({ section: section.key, roomId: decodeURIComponent(id) }); - } - - navigate(section.listPath); - return true; - }, [roomMatch, location.pathname, navigate, setLastRoom]); - - const goToRoom = useCallback((): boolean => { - const section = resolveSection(location.pathname); - if (!section?.getRoomPath) return false; - // Scope the remembered room to its section so a DM never opens under /home/, etc. - if (!lastRoom || lastRoom.section !== section.key) return false; - - startTransition(() => navigate(section.getRoomPath!(lastRoom.roomId))); - return true; - }, [lastRoom, location.pathname, navigate]); - - const openableSection = resolveSection(location.pathname); - const canOpenRoom = - !!openableSection?.getRoomPath && !!lastRoom && lastRoom.section === openableSection.key; - - useEffect(() => { - if (contentOpen || !canOpenRoom || !lastRoom) return undefined; - const room = matrixClient.getRoom(lastRoom.roomId); - if (!room) return undefined; - const ric = - window.requestIdleCallback ?? - ((cb: IdleRequestCallback) => - window.setTimeout( - () => cb({ didTimeout: false, timeRemaining: () => 30 } as IdleDeadline), - 200 - )); - const cic = window.cancelIdleCallback ?? window.clearTimeout; - const handle = ric(() => { - room - .getLiveTimeline() - .getEvents() - .slice(-20) - .forEach((event) => { - if (event.isEncrypted()) matrixClient.decryptEventIfNeeded(event).catch(() => undefined); - }); - }); - return () => cic(handle); - }, [contentOpen, canOpenRoom, lastRoom, matrixClient]); - - const readX = useCallback((): number => { - const el = sliderRef.current; - if (!el) return x.get(); - const t = getComputedStyle(el).transform; - if (!t || t === 'none') return 0; - try { - return new DOMMatrix(t).m41; - } catch { - return x.get(); - } - }, [x]); + useLayoutEffect(() => { + if (draggingRef.current) return; + const target = panelIntentRef.current === 1 ? -width : 0; + applyTransition(false); + x.jump(target); + }, [width, x, applyTransition]); const bind = useDrag( ({ @@ -216,8 +180,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra if (canceled) { if (draggingRef.current) { draggingRef.current = false; - openingRef.current = false; - settle(contentOpen ? -width : 0); + settle(panelIntentRef.current === 1 ? -width : 0); } return; } @@ -229,8 +192,8 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return; } - if (contentOpen) { - if (mx < -DIRECTION_DEADZONE && !openingRef.current) { + if (panelIntentRef.current === 1) { + if (mx < -DIRECTION_DEADZONE) { if (draggingRef.current) { draggingRef.current = false; settle(-width); @@ -239,7 +202,6 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return; } if (active) { - // Take over any settling animation; offset is seeded from the live position. if (first) { x.stop(); applyTransition(false); @@ -249,20 +211,17 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return; } draggingRef.current = false; - if (openingRef.current) { - openingRef.current = false; - const keepRoom = -ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx < 0); - if (keepRoom) { - settle(-width); - } else { - goToList(); - settle(0); - } - return; - } const opened = width + ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx > 0); - if (opened && goToList()) { + if (opened) { + haptic('light'); + panelIntentRef.current = 0; + setPanelIntent(0); settle(0); + const section = resolveSection(location.pathname); + if (section?.getRoomPath && matchedRoomId && isRoomRoute) { + setLastRoom((prev) => ({ ...prev, [section.key]: matchedRoomId })); + } + if (section) startTransition(() => navigate(section.listPath)); } else { settle(-width); } @@ -270,10 +229,6 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra } if (mx > DIRECTION_DEADZONE) { - if (draggingRef.current) { - draggingRef.current = false; - settle(0); - } cancel(); return; } @@ -285,22 +240,29 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra if (first) { x.stop(); applyTransition(false); + if (!roomArmed) setRoomArmed(true); } draggingRef.current = true; x.set(clamp(ox, -width, 0)); - if (!openingRef.current && -ox > width * 0.12 && goToRoom()) { - openingRef.current = true; - } return; } draggingRef.current = false; - if (openingRef.current) { - openingRef.current = false; - const keep = -ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx < 0); - if (keep) { - settle(-width); + const wantRoom = -ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx < 0); + if (wantRoom) { + const section = resolveSection(location.pathname); + if (section?.getRoomPath) { + const lastRoomId = lastRoom?.[section.key]; + if (lastRoomId) { + const roomPath = section.getRoomPath(lastRoomId); + haptic('light'); + panelIntentRef.current = 1; + setPanelIntent(1); + settle(-width); + startTransition(() => navigate(roomPath)); + } else { + settle(0); + } } else { - goToList(); settle(0); } } else { @@ -358,14 +320,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra {rail && (
{rail}
)} -
+
{nav}
@@ -381,7 +336,15 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra overflow: 'hidden', }} > - {deferContent ? null : children} + {isRoomRoute ? ( + + ) : listView ? ( + roomArmed ? ( + + ) : null + ) : ( + children + )}
diff --git a/src/app/components/page/PersistentRoomHost.tsx b/src/app/components/page/PersistentRoomHost.tsx new file mode 100644 index 0000000000..dd5fc35cf2 --- /dev/null +++ b/src/app/components/page/PersistentRoomHost.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from 'react'; +import { matchPath, useLocation } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { Room } from '$features/room'; +import { IsInactivePanelProvider } from '$hooks/useRoom'; +import { HomeRouteRoomProvider } from '$pages/client/home'; +import { DirectRouteRoomProvider } from '$pages/client/direct'; +import { SpaceRouteRoomProvider } from '$pages/client/space'; +import { lastVisitedRoomAtom } from '$state/room/lastRoom'; +import { resolveSection, type SectionNav } from '$pages/pathUtils'; +import { HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths'; +import { isRoomAlias, isRoomId } from '$utils/matrix'; + +export type DisplayedRoom = { + roomIdOrAlias: string; + eventId?: string; +}; + +export function useDisplayedRoom(section: SectionNav | null): DisplayedRoom | undefined { + const location = useLocation(); + const lastRoom = useAtomValue(lastVisitedRoomAtom); + + if (!section || !section.getRoomPath) return undefined; + + const roomMatch = + matchPath({ path: HOME_ROOM_PATH, end: false }, location.pathname) ?? + matchPath({ path: DIRECT_ROOM_PATH, end: false }, location.pathname) ?? + matchPath({ path: SPACE_ROOM_PATH, end: false }, location.pathname); + + if (roomMatch) { + const encodedId = roomMatch.params.roomIdOrAlias; + const encodedEvent = roomMatch.params.eventId; + if (encodedId) { + const decodedId = decodeURIComponent(encodedId); + // `:roomIdOrAlias` also matches non-room segments like `create`, `search`, `lobby`. + // Only treat it as a room when it's a real Matrix id/alias. + if (isRoomId(decodedId) || isRoomAlias(decodedId)) { + return { + roomIdOrAlias: decodedId, + eventId: encodedEvent ? decodeURIComponent(encodedEvent) : undefined, + }; + } + } + } + + const lastRoomId = lastRoom?.[section.key]; + if (lastRoomId) { + return { roomIdOrAlias: lastRoomId }; + } + + return undefined; +} + +export function PersistentRoomHost({ inactive }: { inactive: boolean }) { + const location = useLocation(); + const section = resolveSection(location.pathname); + const displayed = useDisplayedRoom(section); + + if (!displayed) return null; + + const roomNode = ; + + let hosted: ReactNode = null; + if (section?.key === 'home') { + hosted = ( + + {roomNode} + + ); + } else if (section?.key === 'direct') { + hosted = ( + + {roomNode} + + ); + } else if (section?.key.startsWith('space:')) { + hosted = ( + + {roomNode} + + ); + } + + if (!hosted) return null; + + return {hosted}; +} 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/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 68e6c9882a..67e20547c7 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -72,6 +72,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; import { nicknamesAtom } from '$state/nicknames'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { warmupRoomDecryption } from '$utils/decryptScheduler'; // Call Hooks & Plugins import { useCallMembers, useCallSession } from '$hooks/useCall'; @@ -425,6 +426,7 @@ export function RoomNavItem({ {(triggerRef) => ( warmupRoomDecryption(mx, room.roomId)} aria-label={ariaLabel} ref={triggerRef} style={hideTextStyling(hideText)} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index fa7d171cb6..d46c414b71 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -7,7 +7,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { PowerLevelsContextProvider, usePowerLevels } from '$hooks/usePowerLevels'; -import { useRoom } from '$hooks/useRoom'; +import { useRoom, useDisplayedEventId } from '$hooks/useRoom'; import { useKeyDown } from '$hooks/useKeyDown'; import { markAsRead } from '$utils/notifications'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -30,7 +30,9 @@ import { ThreadBrowser } from './ThreadBrowser'; const debugLog = createDebugLogger('Room'); export function Room() { - const { eventId } = useParams(); + const displayedEventId = useDisplayedEventId(); + const { eventId: paramEventId } = useParams(); + const eventId = displayedEventId ?? paramEventId; const room = useRoom(); const mx = useMatrixClient(); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 29f471230e..4cd5f24442 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -25,6 +25,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useAlive } from '$hooks/useAlive'; import { useMessageEdit } from '$hooks/useMessageEdit'; import { useDocumentFocusChange } from '$hooks/useDocumentFocusChange'; +import { useIsInactivePanel } from '$hooks/useRoom'; import { markAsRead } from '$utils/notifications'; import { getReactCustomHtmlParser, @@ -287,6 +288,7 @@ export function RoomTimeline({ const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); const { navigateRoom } = useRoomNavigate(); + const isInactivePanel = useIsInactivePanel(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -823,6 +825,7 @@ export function RoomTimeline({ }); const tryAutoMarkAsRead = useCallback(() => { + if (isInactivePanel) return; // Don't clear unread while room is behind the list if (!readUptoEventIdRef.current) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads)); return; @@ -832,7 +835,7 @@ export function RoomTimeline({ if (latestTimeline === room.getLiveTimeline()) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads)); } - }, [mx, room, hideReads]); + }, [mx, room, hideReads, isInactivePanel]); useDocumentFocusChange( useCallback( diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index fa9c3dc72a..d8d12a3cbd 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -37,6 +37,7 @@ import { } from '$utils/room'; import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useIsInactivePanel } from '$hooks/useRoom'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { nicknamesAtom } from '$state/nicknames'; @@ -137,6 +138,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const mentionClickHandler = useMentionClickHandler(room.roomId); const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const spoilerClickHandler = useSpoilerClickHandler(); + const isInactivePanel = useIsInactivePanel(); // Settings const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -460,6 +462,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra // Mark thread as read when viewing it useEffect(() => { + if (isInactivePanel) return; // Don't send read receipt while room is behind the list const markThreadAsRead = async () => { const currentThread = room.getThread(threadRootId); if (!currentThread) return; @@ -488,7 +491,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra // Mark as read when opened and when new messages arrive markThreadAsRead(); - }, [mx, room, threadRootId, forceUpdateCounter]); + }, [mx, room, threadRootId, forceUpdateCounter, isInactivePanel]); const replyEvents = getThreadReplyEvents(room, threadRootId); const isThreadLoading = !!thread && !thread.initialEventsFetched && replyEvents.length === 0; diff --git a/src/app/features/room/message/EncryptedContent.tsx b/src/app/features/room/message/EncryptedContent.tsx index 2bb9273973..2ac498318f 100644 --- a/src/app/features/room/message/EncryptedContent.tsx +++ b/src/app/features/room/message/EncryptedContent.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { scheduleDecrypt } from '$utils/decryptScheduler'; import * as Sentry from '@sentry/react'; type EncryptedContentProps = { @@ -19,17 +20,19 @@ export function EncryptedContent({ mEvent, children }: EncryptedContentProps) { useEffect(() => { if (mEvent.getType() !== (EventType.RoomMessageEncrypted as string)) return; - // Sample 5% of events for per-event decryption latency profiling - if (Math.random() < 0.05) { - const start = performance.now(); - Sentry.startSpan({ name: 'decrypt.event', op: 'matrix.crypto' }, () => - mx.decryptEventIfNeeded(mEvent).then(() => { - Sentry.metrics.distribution('sable.decryption.event_ms', performance.now() - start); - }) - ).catch(() => undefined); - } else { - mx.decryptEventIfNeeded(mEvent).catch(() => undefined); - } + scheduleDecrypt(() => { + // Sample 5% of events for per-event decryption latency profiling + if (Math.random() < 0.05) { + const start = performance.now(); + Sentry.startSpan({ name: 'decrypt.event', op: 'matrix.crypto' }, () => + mx.decryptEventIfNeeded(mEvent).then(() => { + Sentry.metrics.distribution('sable.decryption.event_ms', performance.now() - start); + }) + ).catch(() => undefined); + } else { + mx.decryptEventIfNeeded(mEvent).catch(() => undefined); + } + }); }, [mx, mEvent]); useEffect(() => { diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index e64ac4964a..58feb867df 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-19T19:29:48.202533519+00:00 + * Generated at: 2026-07-20T05:55:07.154868857+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index 79bba58a16..5fed3c34d5 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-19T19:29:48.202975583+00:00 + * Generated at: 2026-07-20T05:55:07.155133577+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 8755d0f32e..c6a8ffdb2d 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-19T19:29:48.201956318+00:00 + * Generated at: 2026-07-20T05:55:07.154428637+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/hooks/useRoom.ts b/src/app/hooks/useRoom.ts index 9617414e96..7d2f0259d7 100644 --- a/src/app/hooks/useRoom.ts +++ b/src/app/hooks/useRoom.ts @@ -24,3 +24,16 @@ export const useIsDirectRoom = () => { return direct; }; + +const DisplayedEventIdContext = createContext(undefined); + +export const DisplayedEventIdProvider = DisplayedEventIdContext.Provider; + +export const useDisplayedEventId = () => useContext(DisplayedEventIdContext); + +const IsInactivePanelContext = createContext(false); + +export const IsInactivePanelProvider = IsInactivePanelContext.Provider; + +/** True when the room is mounted behind the list panel. Gates auto-mark-as-read and read receipts. */ +export const useIsInactivePanel = () => useContext(IsInactivePanelContext); diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index e3f274d8ee..45945ca4f4 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -1,17 +1,19 @@ -import { startTransition, useCallback } from 'react'; +import { useCallback } from 'react'; import type { NavigateOptions } from 'react-router-dom'; import { useNavigate } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { getDirectRoomPath, getHomeRoomPath, getSpacePath, getSpaceRoomPath, + resolveSection, } from '$pages/pathUtils'; import { getOrphanParents, guessPerfectParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { mDirectAtom } from '$state/mDirectList'; +import { lastVisitedRoomAtom } from '$state/room/lastRoom'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useSelectedSpace } from './router/useSelectedSpace'; @@ -24,12 +26,12 @@ export const useRoomNavigate = () => { const mDirects = useAtomValue(mDirectAtom); const spaceSelectedId = useSelectedSpace(); const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const setLastRoom = useSetAtom(lastVisitedRoomAtom); const navigateSpace = useCallback( (roomId: string) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); - // Render the (heavy) destination off the urgent path so the tap doesn't freeze the UI. - startTransition(() => navigate(getSpacePath(roomIdOrAlias))); + navigate(getSpacePath(roomIdOrAlias)); }, [mx, navigate] ); @@ -40,6 +42,8 @@ export const useRoomNavigate = () => { const openSpaceTimeline = developerTools && spaceSelectedId === roomId; const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); + let destPath: string; + let roomPart: string; if (orphanParents.length > 0) { let parentSpace: string; if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { @@ -49,24 +53,24 @@ export const useRoomNavigate = () => { } const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - - startTransition(() => - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ) - ); - return; + roomPart = openSpaceTimeline ? roomId : roomIdOrAlias; + destPath = getSpaceRoomPath(pSpaceIdOrAlias, roomPart, eventId); + } else if (mDirects.has(roomId)) { + roomPart = roomIdOrAlias; + destPath = getDirectRoomPath(roomPart, eventId); + } else { + roomPart = roomIdOrAlias; + destPath = getHomeRoomPath(roomPart, eventId); } - if (mDirects.has(roomId)) { - startTransition(() => navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts)); - return; + const section = resolveSection(destPath); + if (section?.getRoomPath) { + setLastRoom((prev) => ({ ...prev, [section.key]: roomPart })); } - startTransition(() => navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts)); + navigate(destPath, opts); }, - [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] + [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools, setLastRoom] ); return { diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5e949aee64..1051b4dcf5 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -4,7 +4,8 @@ import { type as osType } from '@tauri-apps/plugin-os'; import { setTrayBadge } from '$generated/tauri/commands'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { resolveSection } from '$pages/pathUtils'; import type { RoomEventHandlerMap } from '$types/matrix-sdk'; import { getPresenceSyncManager } from '$client/initMatrix'; import { @@ -808,8 +809,11 @@ function SlidingSyncActiveRoomSubscriber() { */ function SentryRoomContextFeature() { const mx = useMatrixClient(); + const location = useLocation(); const mDirect = useAtomValue(mDirectAtom); - const roomId = useAtomValue(lastVisitedRoomAtom)?.roomId; + const lastRoom = useAtomValue(lastVisitedRoomAtom); + const section = resolveSection(location.pathname); + const roomId = section ? lastRoom?.[section.key] : undefined; useEffect(() => { if (!roomId) { 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/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 14c1622203..be791c7151 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -1,20 +1,29 @@ import type { ReactNode } from 'react'; import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; -import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; -import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; +import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; +import { IsDirectRoomProvider, DisplayedEventIdProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useDirectRooms } from './useDirectRooms'; -export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { +export function DirectRouteRoomProvider({ + roomIdOrAlias: roomIdOrAliasProp, + eventId: eventIdProp, + children, +}: { + roomIdOrAlias?: string; + eventId?: string; + children: ReactNode; +}) { const mx = useMatrixClient(); const rooms = useDirectRooms(); const { roomIdOrAlias: encodedRoomIdOrAlias, eventId: encodedEventId } = useParams(); - const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); - const eventId = encodedEventId && decodeURIComponent(encodedEventId); - const { roomId, resolving } = useResolvedSelectedRoom(); + const roomIdOrAlias = + roomIdOrAliasProp ?? (encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias)); + const eventId = eventIdProp ?? (encodedEventId && decodeURIComponent(encodedEventId)); + const { roomId, resolving } = useResolvedRoomIdOrAlias(roomIdOrAlias); const room = mx.getRoom(roomId); if (resolving) return ; @@ -25,7 +34,9 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index baba83a7f0..e36db87c9e 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; -import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; -import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; +import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; +import { IsDirectRoomProvider, DisplayedEventIdProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers'; @@ -10,16 +10,25 @@ import { useHomeRooms } from './useHomeRooms'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { +export function HomeRouteRoomProvider({ + roomIdOrAlias: roomIdOrAliasProp, + eventId: eventIdProp, + children, +}: { + roomIdOrAlias?: string; + eventId?: string; + children: ReactNode; +}) { const mx = useMatrixClient(); const [isShowingAllRoomsInHome] = useSetting(settingsAtom, 'isShowingAllRoomsInHome'); const rooms = useHomeRooms(); const { roomIdOrAlias: encodedRoomIdOrAlias, eventId: encodedEventId } = useParams(); - const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); - const eventId = encodedEventId && decodeURIComponent(encodedEventId); + const roomIdOrAlias = + roomIdOrAliasProp ?? (encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias)); + const eventId = eventIdProp ?? (encodedEventId && decodeURIComponent(encodedEventId)); const viaServers = useSearchParamsViaServers(); - const { roomId, resolving } = useResolvedSelectedRoom(); + const { roomId, resolving } = useResolvedRoomIdOrAlias(roomIdOrAlias); const room = mx.getRoom(roomId); if (resolving) return ; @@ -36,7 +45,9 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index d85ba24920..bd5cada128 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from 'react'; import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; import { useAtom, useAtomValue } from 'jotai'; -import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; -import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; +import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; +import { IsDirectRoomProvider, DisplayedEventIdProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSpace } from '$hooks/useSpace'; @@ -15,7 +15,15 @@ import { mDirectAtom } from '$state/mDirectList'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; -export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { +export function SpaceRouteRoomProvider({ + roomIdOrAlias: roomIdOrAliasProp, + eventId: eventIdProp, + children, +}: { + roomIdOrAlias?: string; + eventId?: string; + children: ReactNode; +}) { const mx = useMatrixClient(); const space = useSpace(); const [developerTools] = useSetting(settingsAtom, 'developerTools'); @@ -24,10 +32,11 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const allRooms = useAtomValue(allRoomsAtom); const { roomIdOrAlias: encodedRoomIdOrAlias, eventId: encodedEventId } = useParams(); - const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); - const eventId = encodedEventId && decodeURIComponent(encodedEventId); + const roomIdOrAlias = + roomIdOrAliasProp ?? (encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias)); + const eventId = eventIdProp ?? (encodedEventId && decodeURIComponent(encodedEventId)); const viaServers = useSearchParamsViaServers(); - const { roomId, resolving } = useResolvedSelectedRoom(); + const { roomId, resolving } = useResolvedRoomIdOrAlias(roomIdOrAlias); const room = mx.getRoom(roomId); if (resolving) return ; @@ -47,7 +56,9 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { // allow to view space timeline return ( - {children} + + {children} + ); } @@ -73,7 +84,9 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/app/state/room/lastRoom.ts b/src/app/state/room/lastRoom.ts index 6f5470422e..8690b8008d 100644 --- a/src/app/state/room/lastRoom.ts +++ b/src/app/state/room/lastRoom.ts @@ -1,12 +1,15 @@ -import { atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from '$state/utils/atomWithLocalStorage'; -export type LastVisitedRoom = { - /** Section key the room was open under (see `resolveSection`). */ - section: string; - roomId: string; -}; +const LAST_VISITED_ROOM_KEY = 'sable.lastVisitedRoom'; -// This is only used for mobile swipe gestures -// It is not particularly accurate and shouldn't be used for much else -// unless you plan major refractors -export const lastVisitedRoomAtom = atom(undefined); +const lastVisitedRoomBaseAtom = atomWithLocalStorage>( + LAST_VISITED_ROOM_KEY, + (key) => getLocalStorageItem>(key, {}), + (key, value) => setLocalStorageItem(key, value) +); + +export const lastVisitedRoomAtom = lastVisitedRoomBaseAtom; diff --git a/src/app/state/utils/atomWithLocalStorage.ts b/src/app/state/utils/atomWithLocalStorage.ts index 08f3b2fd42..84f1aeea42 100644 --- a/src/app/state/utils/atomWithLocalStorage.ts +++ b/src/app/state/utils/atomWithLocalStorage.ts @@ -39,11 +39,13 @@ export const atomWithLocalStorage = ( }; }; - const localStorageAtom = atom( + const localStorageAtom = atom T)], undefined>( (get) => get(baseAtom), (get, set, newValue) => { - set(baseAtom, newValue); - setItem(key, newValue); + const resolved = + typeof newValue === 'function' ? (newValue as (prev: T) => T)(get(baseAtom)) : newValue; + set(baseAtom, resolved); + setItem(key, resolved); } ); 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/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/decryptScheduler.ts b/src/app/utils/decryptScheduler.ts new file mode 100644 index 0000000000..a970751383 --- /dev/null +++ b/src/app/utils/decryptScheduler.ts @@ -0,0 +1,46 @@ +import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; + +const FRAME_BUDGET_MS = 10; + +const queue: Array<() => void> = []; +const channel = typeof MessageChannel !== 'undefined' ? new MessageChannel() : undefined; +let scheduled = false; + +const drain = () => { + scheduled = false; + const deadline = performance.now() + FRAME_BUDGET_MS; + while (queue.length > 0 && performance.now() < deadline) { + queue.shift()?.(); + } + if (queue.length > 0) schedule(); +}; + +if (channel) { + channel.port1.addEventListener('message', drain); + channel.port1.start(); +} + +function schedule(): void { + if (scheduled) return; + scheduled = true; + if (channel) channel.port2.postMessage(null); + else setTimeout(drain, 0); +} + +export function scheduleDecrypt(task: () => void): void { + queue.push(task); + schedule(); +} + +export function warmupRoomDecryption(mx: MatrixClient, roomId: string, count = 12): void { + const room = mx.getRoom(roomId); + if (!room) return; + const events: MatrixEvent[] = room.getLiveTimeline().getEvents().slice(-count); + events.forEach((event) => { + if (event.isEncrypted()) { + scheduleDecrypt(() => { + mx.decryptEventIfNeeded(event).catch(() => undefined); + }); + } + }); +} 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();