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..8f86fb0d3c 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';
@@ -81,6 +82,37 @@ import {
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
+// 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}
+
+
+ );
+}
+
type MessageEditorProps = {
roomId: string;
room: Room;
@@ -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);
+}