From 47dbbbddb2aa27b2847cfdb15e4bbbe9a3666265 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 23 Jul 2026 17:18:34 +0200 Subject: [PATCH 1/2] fix(android): dismiss overlays on system back instead of navigating away Add useDismissOnBack(requestClose, enabled) hook to androidBack utils and wire it into full-screen and interaction-blocking overlays. On Android the system back action (edge-swipe and 3-button nav) was passing through these overlays and navigating the underlying route, leaving the overlay open. Wired overlays: - Modal500: Room Settings, Space Settings, Settings shallow route - MobileSwipeDownModal: message context menu, header menu, nav sidebar menu - ImageViewer (refactored from inline handler) - GlobalModalManager: Delete, Report, Source, Reactions, EditHistory, ReadReceipts (gated, excludes Forward and MobileOptions) - MessageForward: forward picker - IncomingCallModal (gated by open state) - DeviceVerification - Emoji boards in RoomInput, Reactions, MessageEditor (gated, the latter via a MobileEmojiOverlay wrapper since its state comes from a UseStateProvider render-prop) - FileContent text and PDF viewers (gated) --- .../fix-android-back-dismiss-overlays.md | 5 ++ src/app/components/DeviceVerification.tsx | 4 ++ src/app/components/IncomingCallModal.tsx | 8 ++- src/app/components/MobileSwipeDownModal.tsx | 4 ++ src/app/components/Modal500.tsx | 4 ++ .../components/image-viewer/ImageViewer.tsx | 7 +-- .../message/content/FileContent.tsx | 7 +++ .../message/modals/GlobalModalManager.tsx | 7 +++ .../message/modals/MessageForward.tsx | 4 ++ src/app/features/room/RoomInput.tsx | 3 ++ .../features/room/message/MessageEditor.tsx | 54 +++++++++++++------ src/app/features/room/message/Reactions.tsx | 3 ++ src/app/utils/androidBack.ts | 19 +++++++ 13 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 .changeset/fix-android-back-dismiss-overlays.md diff --git a/.changeset/fix-android-back-dismiss-overlays.md b/.changeset/fix-android-back-dismiss-overlays.md new file mode 100644 index 0000000000..0c93994ba6 --- /dev/null +++ b/.changeset/fix-android-back-dismiss-overlays.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Dismiss overlays on Android system back instead of navigating away. diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx index 4979c54e7a..fe0f1e53fb 100644 --- a/src/app/components/DeviceVerification.tsx +++ b/src/app/components/DeviceVerification.tsx @@ -25,6 +25,7 @@ import { useVerifierShowSas, } from '$hooks/useVerificationRequest'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { useDismissOnBack } from '$utils/androidBack'; import { ContainerColor } from '$styles/ContainerColor.css'; const DialogHeaderStyles: CSSProperties = { @@ -239,6 +240,9 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) onExit(); }, [request, onExit]); + // Android back cancels/dismisses the verification overlay instead of navigating away. + useDismissOnBack(handleCancel); + const handleAccept = useCallback(() => request.accept(), [request]); const handleStart = useCallback(async () => { await request.startVerification(VerificationMethod.Sas); diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index ff072788da..643f9077b5 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -34,6 +34,7 @@ import { type IncomingCall, } from '$state/callEmbed'; import { createDebugLogger } from '$utils/debugLogger'; +import { useDismissOnBack } from '$utils/androidBack'; import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; import { getIncomingCallBlockers } from '$features/call/getIncomingCallBlockers'; import { RoomAvatar } from './room-avatar'; @@ -334,10 +335,13 @@ export function IncomingCallModal() { const mx = useMatrixClient(); const room = incomingCall ? mx.getRoom(incomingCall.roomId) : null; - if (!incomingCall || !room) return null; - const close = () => setIncomingCall(null); + // Android back dismisses the incoming call modal instead of navigating away. + useDismissOnBack(close, !!incomingCall && !!room); + + if (!incomingCall || !room) return null; + return ( }> diff --git a/src/app/components/MobileSwipeDownModal.tsx b/src/app/components/MobileSwipeDownModal.tsx index 629160c17a..636dc9f805 100644 --- a/src/app/components/MobileSwipeDownModal.tsx +++ b/src/app/components/MobileSwipeDownModal.tsx @@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { Box } from 'folds'; import * as css from '$features/room/message/styles.css'; +import { useDismissOnBack } from '$utils/androidBack'; interface MobileSwipeDownModalProps { children: ( @@ -26,6 +27,9 @@ export function MobileSwipeDownModal({ children, requestClose }: MobileSwipeDown setMounted(true); }, []); + // Android back closes the overlay instead of navigating away. + useDismissOnBack(requestClose); + const handleTouchStart = (e: React.TouchEvent) => { touchStartY.current = e.touches[0]?.clientY ?? null; startTime.current = Date.now(); diff --git a/src/app/components/Modal500.tsx b/src/app/components/Modal500.tsx index 6dbd04c715..62fa97c5a7 100644 --- a/src/app/components/Modal500.tsx +++ b/src/app/components/Modal500.tsx @@ -4,6 +4,7 @@ import FocusTrap from 'focus-trap-react'; import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { stopPropagation } from '$utils/keyboard'; +import { useDismissOnBack } from '$utils/androidBack'; type Modal500Props = { requestClose: () => void; @@ -13,6 +14,9 @@ export function Modal500({ requestClose, children }: Modal500Props) { const modalRef = useRef(null); const screenSize = useScreenSizeContext(); + // Android back closes the overlay instead of navigating away. + useDismissOnBack(requestClose); + if (screenSize === ScreenSize.Mobile) { return ( diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 8f141ce174..f1ca2fdf83 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -27,7 +27,7 @@ import { sizedIcon, } from '$components/icons/phosphor'; import { useImageGestures } from '$hooks/useImageGestures'; -import { useAndroidBackHandler } from '$utils/androidBack'; +import { useDismissOnBack } from '$utils/androidBack'; import { useSetting } from '$state/hooks/settings'; import { isPixelatedRendering, settingsAtom } from '$state/settings'; import { downloadMedia } from '$utils/matrix'; @@ -53,10 +53,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); // Android back closes the viewer instead of navigating away. - useAndroidBackHandler(() => { - requestClose(); - return true; - }); + useDismissOnBack(requestClose); const [isImageReady, setIsImageReady] = useState(false); const [isEditingZoom, setIsEditingZoom] = useState(false); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index c87a0fe723..ae751be894 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -30,6 +30,7 @@ import { stopPropagation } from '$utils/keyboard'; import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useRevokeObjectURL } from '$hooks/useObjectURL'; +import { useDismissOnBack } from '$utils/androidBack'; import { ModalWide } from '$styles/Modal.css'; import { getDownloadFilename, saveFileToDevice } from '$utils/download'; @@ -80,6 +81,9 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea const useAuthentication = useMediaAuthentication(); const [textViewer, setTextViewer] = useState(false); + // Android back closes the text viewer instead of navigating away. + useDismissOnBack(() => setTextViewer(false), textViewer); + const [textState, loadText] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); @@ -171,6 +175,9 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read const useAuthentication = useMediaAuthentication(); const [pdfViewer, setPdfViewer] = useState(false); + // Android back closes the PDF viewer instead of navigating away. + useDismissOnBack(() => setPdfViewer(false), pdfViewer); + const [pdfState, loadPdf] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); diff --git a/src/app/components/message/modals/GlobalModalManager.tsx b/src/app/components/message/modals/GlobalModalManager.tsx index 509d0c8fd0..6b8594dd06 100644 --- a/src/app/components/message/modals/GlobalModalManager.tsx +++ b/src/app/components/message/modals/GlobalModalManager.tsx @@ -2,6 +2,7 @@ import { useAtom } from 'jotai'; import { Overlay, OverlayBackdrop, OverlayCenter, Box, Modal } from 'folds'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '$utils/keyboard'; +import { useDismissOnBack } from '$utils/androidBack'; import { modalAtom, ModalType } from '$state/modal'; import { MessageReportInternal } from './MessageReport'; import { MessageDeleteInternal } from './MessageDelete'; @@ -19,6 +20,12 @@ export function GlobalModalManager() { setModal(null); }; + // Forward and MobileOptions render their own back handlers via their children. + useDismissOnBack( + close, + !!modal && modal.type !== ModalType.Forward && modal.type !== ModalType.MobileOptions + ); + if (!modal) return null; if (modal.type === ModalType.Forward) { diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx index aef10a95cb..70ef9265ff 100644 --- a/src/app/components/message/modals/MessageForward.tsx +++ b/src/app/components/message/modals/MessageForward.tsx @@ -18,6 +18,7 @@ import { isRoomPrivate } from '$utils/roomVisibility'; import { canForwardEvent } from '$utils/room'; import * as prefix from '$unstable/prefixes'; import { SearchWrapper } from '$features/navigate'; +import { useDismissOnBack } from '$utils/androidBack'; const debugLog = createDebugLogger('MessageForward'); // Message forwarding component @@ -107,6 +108,9 @@ export function MessageForwardInternal({ if (!forwardable) onClose(); }, [forwardable, onClose]); + // Android back closes the forward picker instead of navigating away. + useDismissOnBack(onClose); + // possible targets to forward the message to const forwardTargets = useMemo( () => diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 8c1d553c4a..b90534ad8e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -35,6 +35,7 @@ import { } from 'folds'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useDismissOnBack } from '$utils/androidBack'; import type { AutocompleteQuery } from '$components/editor'; import { AutocompletePrefix, @@ -484,6 +485,8 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); + // Android back closes the mobile emoji board instead of navigating away. + useDismissOnBack(() => setEmojiBoardTab(undefined), emojiBoardTab !== undefined); const [enableMediaGalleries] = useSetting(settingsAtom, 'enableMediaGalleries'); const [sendIndividualAttachmentAsCaption] = useSetting( settingsAtom, diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 20cf1dce31..a1fad9a0ae 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -1,4 +1,4 @@ -import type { KeyboardEventHandler, MouseEventHandler } from 'react'; +import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAtomValue } from 'jotai'; import type { RectCords } from 'folds'; @@ -60,6 +60,7 @@ import { UseStateProvider } from '$components/UseStateProvider'; import { EmojiBoard } from '$components/emoji-board'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useDismissOnBack } from '$utils/androidBack'; import { nicknamesAtom } from '$state/nicknames'; import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '$utils/room'; import { mobileOrTablet } from '$utils/user-agent'; @@ -68,6 +69,37 @@ import { floatingEditor } from '$styles/overrides/Composer.css'; import { RenderMessageContent } from '$components/RenderMessageContent'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser'; + +// Wraps the mobile emoji-board overlay so the Android back action closes it +// instead of navigating away. Hooks can't run inside the UseStateProvider +// render-prop below, so this component holds the back handler. +function MobileEmojiOverlay({ + open, + onClose, + children, +}: { + open: boolean; + onClose: () => void; + children: ReactNode; +}) { + useDismissOnBack(onClose, open); + return ( + }> +
+ {children} +
+
+ ); +} import { testMatrixTo } from '$plugins/matrix-to'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; import type { HTMLReactParserOptions } from 'html-react-parser'; @@ -628,20 +660,12 @@ export const MessageEditor = as<'div', MessageEditorProps>( return ( <> {trigger} - }> -
- {emojiBoard} -
-
+ setAnchor(undefined)} + > + {emojiBoard} + ); } diff --git a/src/app/features/room/message/Reactions.tsx b/src/app/features/room/message/Reactions.tsx index 0578bb0b0b..f4e3b55f15 100644 --- a/src/app/features/room/message/Reactions.tsx +++ b/src/app/features/room/message/Reactions.tsx @@ -27,6 +27,7 @@ import { sizedIcon, Smiley } from '$components/icons/phosphor'; import { useRelations } from '$hooks/useRelations'; import { stopPropagation } from '$utils/keyboard'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useDismissOnBack } from '$utils/androidBack'; import { ReactionViewer } from '$features/room/reaction-viewer'; import * as css from './styles.css'; @@ -58,6 +59,8 @@ export const Reactions = as<'div', ReactionsProps>( const useAuthentication = useMediaAuthentication(); const [viewer, setViewer] = useState(false); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); + // Android back closes the mobile emoji board instead of navigating away. + useDismissOnBack(() => setEmojiBoardAnchor(undefined), emojiBoardAnchor !== undefined); const myUserId = mx.getUserId(); const reactions = useRelations( relations, diff --git a/src/app/utils/androidBack.ts b/src/app/utils/androidBack.ts index 7f6b96d771..2d5bd01097 100644 --- a/src/app/utils/androidBack.ts +++ b/src/app/utils/androidBack.ts @@ -51,3 +51,22 @@ export function useAndroidBackHandler(handler: AndroidBackHandler, enabled = tru return pushAndroidBackHandler(() => handlerRef.current()); }, [enabled]); } + +/** + * Registers an Android back handler that dismisses the overlay it's bound to. + * Returns true so the back event is consumed instead of falling through to + * the underlying route. Mirrors the Escape-key / tap-outside dismiss path + * by calling `requestClose`. + * + * For always-mounted components that toggle open via state, pass `enabled` + * (e.g. the open state) so the handler only consumes back while the overlay + * is actually showing. + */ +export function useDismissOnBack(requestClose: () => void, enabled = true): void { + const requestCloseRef = useRef(requestClose); + requestCloseRef.current = requestClose; + useAndroidBackHandler(() => { + requestCloseRef.current(); + return true; + }, enabled); +} From 92931b2100a8232293aa15471118d10210238e7b Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 23 Jul 2026 17:26:03 -0500 Subject: [PATCH 2/2] adjust import order --- .../features/room/message/MessageEditor.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index a1fad9a0ae..8f86fb0d3c 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -69,6 +69,18 @@ import { floatingEditor } from '$styles/overrides/Composer.css'; import { RenderMessageContent } from '$components/RenderMessageContent'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser'; +import { testMatrixTo } from '$plugins/matrix-to'; +import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; +import type { HTMLReactParserOptions } from 'html-react-parser'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import type { GetContentCallback } from '$types/matrix/room'; +import { sanitizeText } from '$utils/sanitize'; +import type { BundleContent } from '$components/message'; +import { + readdAngleBracketsForHiddenPreviews, + stripMarkdownEscapesForHiddenPreviews, +} from './hiddenLinkPreviews'; // Wraps the mobile emoji-board overlay so the Android back action closes it // instead of navigating away. Hooks can't run inside the UseStateProvider @@ -100,18 +112,6 @@ function MobileEmojiOverlay({
); } -import { testMatrixTo } from '$plugins/matrix-to'; -import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; -import type { HTMLReactParserOptions } from 'html-react-parser'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import type { Opts as LinkifyOpts } from 'linkifyjs'; -import type { GetContentCallback } from '$types/matrix/room'; -import { sanitizeText } from '$utils/sanitize'; -import type { BundleContent } from '$components/message'; -import { - readdAngleBracketsForHiddenPreviews, - stripMarkdownEscapesForHiddenPreviews, -} from './hiddenLinkPreviews'; type MessageEditorProps = { roomId: string;