diff --git a/src/app/components/ResponsiveMenu.css.ts b/src/app/components/ResponsiveMenu.css.ts
index 31d8d34f3d..19f4479bab 100644
--- a/src/app/components/ResponsiveMenu.css.ts
+++ b/src/app/components/ResponsiveMenu.css.ts
@@ -3,7 +3,7 @@ import { config, toRem } from 'folds';
export const DialogContent = style({
width: `min(90vw, ${toRem(400)})`,
- maxHeight: '85vh',
+ maxHeight: '85dvh',
display: 'flex',
flexDirection: 'column',
});
diff --git a/src/app/components/ResponsiveMenu.tsx b/src/app/components/ResponsiveMenu.tsx
index 449c3719ca..6950360628 100644
--- a/src/app/components/ResponsiveMenu.tsx
+++ b/src/app/components/ResponsiveMenu.tsx
@@ -2,7 +2,7 @@ import type { ComponentProps, CSSProperties, ReactNode } from 'react';
import type { RectCords } from 'folds';
import { Box, Overlay, OverlayBackdrop, OverlayCenter, PopOut } from 'folds';
import FocusTrap from 'focus-trap-react';
-import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize';
+import { useCompactLayout } from '$hooks/useScreenSize';
import { stopPropagation } from '$utils/keyboard';
import { useDismissOnBack } from '$utils/androidBack';
import { MobileSheetFocusTrap, MobileSwipeDownModal } from './MobileSwipeDownModal';
@@ -76,8 +76,7 @@ export function ResponsiveMenu({
mobile = 'sheet',
surfaceColor,
}: ResponsiveMenuProps) {
- // Null outside a provider, where desktop is the safe assumption.
- const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile;
+ const isMobile = useCompactLayout();
const isKeyForward = (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || (arrowNavigation === 'both' && evt.key === 'ArrowRight');
diff --git a/src/app/components/SwipeableMessageWrapper.test.tsx b/src/app/components/SwipeableMessageWrapper.test.tsx
new file mode 100644
index 0000000000..c2e0ac301f
--- /dev/null
+++ b/src/app/components/SwipeableMessageWrapper.test.tsx
@@ -0,0 +1,110 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SwipeableMessageWrapper } from './SwipeableMessageWrapper';
+
+vi.mock('$utils/platform', () => ({
+ isMobileOrTablet: () => true,
+}));
+
+vi.mock('$utils/haptics', () => ({
+ haptic: vi.fn<(kind?: 'light' | 'medium' | 'heavy' | 'selection') => void>(),
+}));
+
+const touchList = (target: HTMLElement, clientX: number, clientY: number) => {
+ const point = { identifier: 0, target, clientX, clientY, pageX: clientX, pageY: clientY };
+ return { touches: [point], targetTouches: [point], changedTouches: [point] };
+};
+
+// Rendered without MobileNavDrawerContext, which is the tablet and iPadOS-fullscreen
+// case: no nav drawer coordinates the touch, so the message tracks it itself.
+function renderWrapper(onReply: () => void) {
+ render(
+
+
+
+ );
+ return screen.getByTestId('content').closest('[data-message-swipe]') as HTMLElement;
+}
+
+describe('SwipeableMessageWrapper without a nav drawer', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('replies after a leftward swipe past the threshold', () => {
+ const onReply = vi.fn<() => void>();
+ const container = renderWrapper(onReply);
+
+ fireEvent.touchStart(container, touchList(container, 200, 100));
+ fireEvent.touchMove(container, touchList(container, 100, 100));
+ fireEvent.touchEnd(container, {
+ ...touchList(container, 100, 100),
+ touches: [],
+ targetTouches: [],
+ });
+
+ expect(onReply).toHaveBeenCalledOnce();
+ act(() => vi.advanceTimersByTime(220));
+ });
+
+ it('leaves a vertical scroll alone', () => {
+ const onReply = vi.fn<() => void>();
+ const container = renderWrapper(onReply);
+
+ fireEvent.touchStart(container, touchList(container, 200, 100));
+ fireEvent.touchMove(container, touchList(container, 205, 260));
+ fireEvent.touchEnd(container, {
+ ...touchList(container, 205, 260),
+ touches: [],
+ targetTouches: [],
+ });
+
+ expect(onReply).not.toHaveBeenCalled();
+ });
+
+ it('does not reply on a rightward swipe', () => {
+ const onReply = vi.fn<() => void>();
+ const container = renderWrapper(onReply);
+
+ fireEvent.touchStart(container, touchList(container, 100, 100));
+ fireEvent.touchMove(container, touchList(container, 220, 100));
+ fireEvent.touchEnd(container, {
+ ...touchList(container, 220, 100),
+ touches: [],
+ targetTouches: [],
+ });
+
+ expect(onReply).not.toHaveBeenCalled();
+ });
+
+ it('does not reply on a cancelled gesture', () => {
+ const onReply = vi.fn<() => void>();
+ const container = renderWrapper(onReply);
+
+ fireEvent.touchStart(container, touchList(container, 200, 100));
+ fireEvent.touchMove(container, touchList(container, 100, 100));
+ fireEvent.touchCancel(container, { touches: [], targetTouches: [] });
+
+ expect(onReply).not.toHaveBeenCalled();
+ });
+
+ it('does not reply when a second finger joins mid-gesture', () => {
+ const onReply = vi.fn<() => void>();
+ const container = renderWrapper(onReply);
+
+ fireEvent.touchStart(container, touchList(container, 200, 100));
+ fireEvent.touchMove(container, touchList(container, 100, 100));
+
+ const first = { identifier: 0, target: container, clientX: 100, clientY: 100 };
+ const second = { identifier: 1, target: container, clientX: 140, clientY: 140 };
+ fireEvent.touchStart(container, { touches: [first, second], targetTouches: [first, second] });
+ fireEvent.touchEnd(container, { touches: [], targetTouches: [] });
+
+ expect(onReply).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx
index f8661674c9..97ef8e9c82 100644
--- a/src/app/components/SwipeableMessageWrapper.tsx
+++ b/src/app/components/SwipeableMessageWrapper.tsx
@@ -6,6 +6,10 @@ import { haptic } from '$utils/haptics';
import { isMobileOrTablet } from '$utils/platform';
import { RightSwipeAction, settingsAtom } from '$state/settings';
import { useMobileNavDrawer } from '$components/page/MobileNavDrawerContext';
+import {
+ classifyMobileGesture,
+ type MobileGestureMode,
+} from '$components/page/mobileSwipeCoordinator';
import {
getTranslateX,
NATIVE_EASE_OUT,
@@ -149,6 +153,68 @@ function ActiveSwipeWrapper({
});
}, [drawer, finish, move]);
+ // Above the mobile breakpoint there is no nav drawer to coordinate the touch,
+ // so track it on the message itself. Tablets and touch desktops reach this;
+ // iPadOS in fullscreen is the case that has no drawer but is still a touch device.
+ useLayoutEffect(() => {
+ const element = containerRef.current;
+ if (drawer || !element) return undefined;
+
+ let gesture: { startX: number; startY: number; mode: MobileGestureMode } | undefined;
+ const release = (commit: boolean) => {
+ const active = gesture?.mode === 'message';
+ gesture = undefined;
+ if (active) finish(commit);
+ };
+
+ const onTouchStart = (event: TouchEvent) => {
+ const touch = event.touches[0];
+ if (!touch || event.touches.length !== 1) {
+ release(false);
+ return;
+ }
+ gesture = { startX: touch.clientX, startY: touch.clientY, mode: 'pending' };
+ };
+
+ const onTouchMove = (event: TouchEvent) => {
+ const touch = event.touches[0];
+ if (!gesture || !touch) return;
+ if (gesture.mode === 'blocked' || gesture.mode === 'vertical') return;
+
+ const distanceX = touch.clientX - gesture.startX;
+ if (gesture.mode === 'pending') {
+ // width 0 and canOpenRoom false leave `drawer` unreachable, so this only
+ // ever resolves to vertical, message, or blocked.
+ gesture.mode = classifyMobileGesture({
+ distanceX,
+ distanceY: touch.clientY - gesture.startY,
+ startPosition: 0,
+ width: 0,
+ canOpenRoom: false,
+ hasMessage: true,
+ hasChat: false,
+ });
+ }
+ if (gesture.mode === 'message') move(distanceX);
+ };
+
+ const onTouchEnd = () => release(true);
+ const onTouchCancel = () => release(false);
+
+ element.addEventListener('touchstart', onTouchStart, { passive: true });
+ element.addEventListener('touchmove', onTouchMove, { passive: true });
+ element.addEventListener('touchend', onTouchEnd, { passive: true });
+ element.addEventListener('touchcancel', onTouchCancel, { passive: true });
+
+ return () => {
+ element.removeEventListener('touchstart', onTouchStart);
+ element.removeEventListener('touchmove', onTouchMove);
+ element.removeEventListener('touchend', onTouchEnd);
+ element.removeEventListener('touchcancel', onTouchCancel);
+ release(false);
+ };
+ }, [drawer, finish, move]);
+
const IconComponent = actionMode === 'edit' ? PencilSimple : ArrowBendUpLeftIcon;
const iconColor =
actionMode === 'edit'
diff --git a/src/app/components/editor/Editor.test.tsx b/src/app/components/editor/Editor.test.tsx
index cb76a0b5bd..f60206f29c 100644
--- a/src/app/components/editor/Editor.test.tsx
+++ b/src/app/components/editor/Editor.test.tsx
@@ -375,7 +375,7 @@ describe('CustomEditor', () => {
expect(editorRoot).not.toBeNull();
expect(editorRoot?.contains(measurer)).toBe(true);
expect(measurer?.parentElement).not.toBe(document.body);
- expect(scroll?.style.maxHeight).toBe('50vh');
+ expect(scroll?.style.maxHeight).toBe('50dvh');
expect(screen.getByText('Attach')).toBeVisible();
expect(screen.getByText('Send')).toBeVisible();
expect(screen.getByTestId('recorder').parentElement).toHaveClass(css.EditorOptions);
diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx
index 413aba42de..44bbaef06e 100644
--- a/src/app/components/editor/Editor.tsx
+++ b/src/app/components/editor/Editor.tsx
@@ -146,7 +146,7 @@ export const CustomEditor = forwardRef(
after,
responsiveAfter,
forceMultilineLayout = false,
- maxHeight = '50vh',
+ maxHeight = '50dvh',
editor,
placeholder,
onKeyDown,
diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx
index 7a1e89b564..7edfbcd2af 100644
--- a/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx
+++ b/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx
@@ -22,7 +22,7 @@ export const AutocompleteMenuContainer = style([
export const AutocompleteMenu = style([
DefaultReset,
{
- maxHeight: '30vh',
+ maxHeight: '30dvh',
height: '100%',
display: 'flex',
flexDirection: 'column',
diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx
index edae8fa115..1ec85f1edc 100644
--- a/src/app/components/image-viewer/ImageViewer.test.tsx
+++ b/src/app/components/image-viewer/ImageViewer.test.tsx
@@ -63,6 +63,7 @@ vi.mock('$hooks/useScreenSize', () => ({
ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' },
useScreenSizeContext: () => (screenMocks.isMobile ? 'Mobile' : 'Desktop'),
useScreenSizeOptionally: () => (screenMocks.isMobile ? 'Mobile' : 'Desktop'),
+ useCompactLayout: () => screenMocks.isMobile,
}));
const renderViewer = (props: { alt?: string; src?: string; info?: IImageInfo } = {}) =>
diff --git a/src/app/components/message/content/ImageContent.test.tsx b/src/app/components/message/content/ImageContent.test.tsx
index db41be4063..3382082d76 100644
--- a/src/app/components/message/content/ImageContent.test.tsx
+++ b/src/app/components/message/content/ImageContent.test.tsx
@@ -7,6 +7,7 @@ const screenMocks = vi.hoisted(() => ({ isMobile: true, tauri: false }));
vi.mock('$hooks/useScreenSize', () => ({
ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' },
useScreenSizeOptionally: () => (screenMocks.isMobile ? 'Mobile' : 'Desktop'),
+ useCompactLayout: () => screenMocks.isMobile,
}));
vi.mock('@tauri-apps/api/core', () => ({
diff --git a/src/app/components/notification-banner/NotificationBanner.css.ts b/src/app/components/notification-banner/NotificationBanner.css.ts
index 492438fff0..7d67c85dd3 100644
--- a/src/app/components/notification-banner/NotificationBanner.css.ts
+++ b/src/app/components/notification-banner/NotificationBanner.css.ts
@@ -116,12 +116,12 @@ export const BannerSubtitle = style({
// Desktop: 25vh, mobile (≤768px): 35vh.
export const BannerBody = style({
position: 'relative',
- maxHeight: '25vh',
+ maxHeight: '25dvh',
overflow: 'hidden',
'@media': {
'(max-width: 768px)': {
- maxHeight: '35vh',
+ maxHeight: '35dvh',
},
},
diff --git a/src/app/components/setting-menu-selector/SettingMenuSelector.tsx b/src/app/components/setting-menu-selector/SettingMenuSelector.tsx
index 8bb801ce20..d57993b65d 100644
--- a/src/app/components/setting-menu-selector/SettingMenuSelector.tsx
+++ b/src/app/components/setting-menu-selector/SettingMenuSelector.tsx
@@ -184,7 +184,7 @@ export function SettingMenuSelector({
menu={