Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-android-back-dismiss-overlays.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Dismiss overlays on Android system back instead of navigating away.
4 changes: 4 additions & 0 deletions src/app/components/DeviceVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions src/app/components/IncomingCallModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/MobileSwipeDownModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand All @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/Modal500.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,6 +14,9 @@ export function Modal500({ requestClose, children }: Modal500Props) {
const modalRef = useRef<HTMLDivElement | null>(null);
const screenSize = useScreenSizeContext();

// Android back closes the overlay instead of navigating away.
useDismissOnBack(requestClose);

if (screenSize === ScreenSize.Mobile) {
return (
<Overlay open>
Expand Down
7 changes: 2 additions & 5 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/app/components/message/content/FileContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/app/components/message/modals/GlobalModalManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/message/modals/MessageForward.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
() =>
Expand Down
3 changes: 3 additions & 0 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -484,6 +485,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [sendError, setSendError] = useState<string | undefined>();
const isEncrypted = room.hasEncryptionStateEvent();
const [emojiBoardTab, setEmojiBoardTab] = useState<EmojiBoardTab | undefined>(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,
Expand Down
54 changes: 39 additions & 15 deletions src/app/features/room/message/MessageEditor.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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 (
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<div
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
display: 'flex',
justifyContent: 'center',
}}
>
{children}
</div>
</Overlay>
);
}

type MessageEditorProps = {
roomId: string;
room: Room;
Expand Down Expand Up @@ -628,20 +660,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
return (
<>
{trigger}
<Overlay open={anchor !== undefined} backdrop={<OverlayBackdrop />}>
<div
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
display: 'flex',
justifyContent: 'center',
}}
>
{emojiBoard}
</div>
</Overlay>
<MobileEmojiOverlay
open={anchor !== undefined}
onClose={() => setAnchor(undefined)}
>
{emojiBoard}
</MobileEmojiOverlay>
</>
);
}
Expand Down
3 changes: 3 additions & 0 deletions src/app/features/room/message/Reactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -58,6 +59,8 @@ export const Reactions = as<'div', ReactionsProps>(
const useAuthentication = useMediaAuthentication();
const [viewer, setViewer] = useState<boolean | string>(false);
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
// Android back closes the mobile emoji board instead of navigating away.
useDismissOnBack(() => setEmojiBoardAnchor(undefined), emojiBoardAnchor !== undefined);
const myUserId = mx.getUserId();
const reactions = useRelations(
relations,
Expand Down
19 changes: 19 additions & 0 deletions src/app/utils/androidBack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading