From 67b891541c9f4a9600931026746a19b43ba5d75f Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 00:16:57 +0200 Subject: [PATCH 1/5] perf(mobile): overlapping-panels swipe, immediate room open, progressive decryption --- .../fix-overlapping-panels-mobile-swipe.md | 5 + src/app/components/BackRouteHandler.tsx | 9 +- src/app/components/page/MobileNavDrawer.tsx | 232 ++++++++---------- .../components/page/PersistentRoomHost.tsx | 87 +++++++ src/app/features/room-nav/RoomNavItem.tsx | 2 + src/app/features/room/Room.tsx | 6 +- src/app/features/room/RoomTimeline.tsx | 5 +- src/app/features/room/ThreadDrawer.tsx | 5 +- .../room/message/EncryptedContent.tsx | 25 +- src/app/generated/tauri/commands.ts | 2 +- src/app/generated/tauri/index.ts | 2 +- src/app/generated/tauri/types.ts | 2 +- src/app/hooks/useRoom.ts | 13 + src/app/hooks/useRoomNavigate.ts | 38 +-- src/app/pages/client/ClientNonUIFeatures.tsx | 8 +- src/app/pages/client/direct/RoomProvider.tsx | 25 +- src/app/pages/client/home/RoomProvider.tsx | 25 +- src/app/pages/client/space/RoomProvider.tsx | 29 ++- src/app/state/room/lastRoom.ts | 23 +- src/app/state/utils/atomWithLocalStorage.ts | 8 +- src/app/utils/decryptScheduler.ts | 46 ++++ 21 files changed, 384 insertions(+), 213 deletions(-) create mode 100644 .changeset/fix-overlapping-panels-mobile-swipe.md create mode 100644 src/app/components/page/PersistentRoomHost.tsx create mode 100644 src/app/utils/decryptScheduler.ts 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/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/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx index 96f4647d88..39ba12a453 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,8 @@ import { SPACE_ROOM_PATH, } from '$pages/paths'; import { resolveSection } from '$pages/pathUtils'; +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 +42,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 +78,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 +114,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 +136,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 +179,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 +191,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 +201,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 +210,16 @@ 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) { + 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 +227,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 +238,28 @@ 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); + panelIntentRef.current = 1; + setPanelIntent(1); + settle(-width); + startTransition(() => navigate(roomPath)); + } else { + settle(0); + } } else { - goToList(); settle(0); } } else { @@ -358,14 +317,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra {rail && (
{rail}
)} -
+
{nav}
@@ -381,7 +333,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/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/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/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); + }); + } + }); +} From 83ee6a94f1ddf9c55f3a41051df3fb28f08c5db3 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:10:31 +0200 Subject: [PATCH 2/5] 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 445967434542cd7ab54bc2a9628eebff5eb1e3b3 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:15:13 +0200 Subject: [PATCH 3/5] 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 e24f649ad32bd71689d2bb8ac51d6c9c512e412f Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:11:29 +0200 Subject: [PATCH 4/5] 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 6a56539601b8f3cace2620014f94cc31253a31e3 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 10:15:35 +0200 Subject: [PATCH 5/5] 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.