Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/app/components/ResponsiveMenu.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down
5 changes: 2 additions & 3 deletions src/app/components/ResponsiveMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
110 changes: 110 additions & 0 deletions src/app/components/SwipeableMessageWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SwipeableMessageWrapper onReply={onReply}>
<div data-testid="content" />
</SwipeableMessageWrapper>
);
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();
});
});
66 changes: 66 additions & 0 deletions src/app/components/SwipeableMessageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/editor/Editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
after,
responsiveAfter,
forceMultilineLayout = false,
maxHeight = '50vh',
maxHeight = '50dvh',
editor,
placeholder,
onKeyDown,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const AutocompleteMenuContainer = style([
export const AutocompleteMenu = style([
DefaultReset,
{
maxHeight: '30vh',
maxHeight: '30dvh',
height: '100%',
display: 'flex',
flexDirection: 'column',
Expand Down
1 change: 1 addition & 0 deletions src/app/components/image-viewer/ImageViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) =>
Expand Down
1 change: 1 addition & 0 deletions src/app/components/message/content/ImageContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export function SettingMenuSelector<T extends string | number>({
menu={
<Menu
style={
scrollable ? { maxHeight: '75vh', maxWidth: toRem(300), display: 'flex' } : undefined
scrollable ? { maxHeight: '75dvh', maxWidth: toRem(300), display: 'flex' } : undefined
}
>
{optionsContent}
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function Toast({ container }: ToastProps) {
position: 'fixed',
left: 0,
right: 0,
bottom: `calc(env(safe-area-inset-bottom, 0px) + ${toRem(24)})`,
bottom: `calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + ${toRem(24)})`,
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/user-profile/UserChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ export function MutualRoomsChip({
style={{
display: 'flex',
maxWidth: toRem(200),
maxHeight: '80vh',
maxHeight: '80dvh',
backgroundColor: innerColor,
}}
>
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/call-status/LiveChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
menu={
<Menu
style={{
maxHeight: '75vh',
maxHeight: '75dvh',
maxWidth: toRem(300),
display: 'flex',
}}
Expand Down
4 changes: 2 additions & 2 deletions src/app/features/call/IncomingCallModal.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { MOBILE_BREAKPOINT } from '$hooks/useScreenSize';

export const Content = style({
padding: config.space.S600,
paddingBottom: `max(${config.space.S600}, env(safe-area-inset-bottom))`,
paddingBottom: `max(${config.space.S600}, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))`,
gap: config.space.S500,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT}px)`]: {
padding: config.space.S400,
paddingBottom: `max(${config.space.S500}, env(safe-area-inset-bottom))`,
paddingBottom: `max(${config.space.S500}, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))`,
gap: config.space.S400,
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/common-settings/permissions/Powers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee
>
<Menu
style={{
maxHeight: '75vh',
maxHeight: '75dvh',
maxWidth: toRem(300),
display: 'flex',
}}
Expand Down
6 changes: 3 additions & 3 deletions src/app/features/room/message/styles.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export const MessageMobileOptionsContainer = style({
right: 0,
zIndex: 1005,
width: '100%',
maxHeight: '85vh',
maxHeight: '85dvh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
Expand All @@ -145,9 +145,9 @@ export const MessageMobileSheetFill = style({

// Ratio is against the keyboard-free height so the picker keeps one size while
// typing. The ceiling only binds where a keyboard would otherwise cover the sheet.
const pickerHeight = `min(max(calc(var(--mobile-sheet-viewport, 100vh) * 0.5), ${toRem(
const pickerHeight = `min(max(calc(var(--mobile-sheet-viewport, 100dvh) * 0.5), ${toRem(
280
)}), var(--mobile-sheet-visible, 100vh))`;
)}), var(--mobile-sheet-visible, 100dvh))`;

export const MessageMobileOptionsContainerPicker = style({
height: pickerHeight,
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/room/room-pin-menu/RoomPinMenu.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const PinMenu = style({
display: 'flex',
maxWidth: toRem(548),
width: '100vw',
maxHeight: '90vh',
maxHeight: '90dvh',
});

export const PinMenuHeader = style({
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/room/room-pin-menu/RoomPinMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
});
}, [pinListKey, totalSize]);
const currentLatchedSize = latchedSize.pinListKey === pinListKey ? latchedSize.size : totalSize;
const mobileMaxHeight = 'calc(85vh - 4rem)';
const mobileMaxHeight = 'calc(85dvh - 4rem)';

const renderMatrixEvent = useRoomMessagePreviewRenderer(room);

Expand Down
Loading
Loading