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={ {optionsContent} diff --git a/src/app/components/toast/Toast.tsx b/src/app/components/toast/Toast.tsx index ffd64db2dc..790362e077 100644 --- a/src/app/components/toast/Toast.tsx +++ b/src/app/components/toast/Toast.tsx @@ -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', diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 678f9cdadc..5b4051f860 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -477,7 +477,7 @@ export function MutualRoomsChip({ style={{ display: 'flex', maxWidth: toRem(200), - maxHeight: '80vh', + maxHeight: '80dvh', backgroundColor: innerColor, }} > diff --git a/src/app/features/call-status/LiveChip.tsx b/src/app/features/call-status/LiveChip.tsx index 944d791532..c1feaedaf5 100644 --- a/src/app/features/call-status/LiveChip.tsx +++ b/src/app/features/call-status/LiveChip.tsx @@ -35,7 +35,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) { menu={ ( }); }, [pinListKey, totalSize]); const currentLatchedSize = latchedSize.pinListKey === pinListKey ? latchedSize.size : totalSize; - const mobileMaxHeight = 'calc(85vh - 4rem)'; + const mobileMaxHeight = 'calc(85dvh - 4rem)'; const renderMatrixEvent = useRoomMessagePreviewRenderer(room); diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx index d382c10fca..9b2a4c873f 100644 --- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx @@ -1183,7 +1183,7 @@ export function ThemeCatalogSettings({ mode, onBrowseOpenChange }: ThemeCatalogS ) { escapeDeactivates: stopPropagation, }} > - +
Formatting
diff --git a/src/app/features/widgets/IntegrationManager.css.ts b/src/app/features/widgets/IntegrationManager.css.ts index 4d1d12bced..d298ea91a9 100644 --- a/src/app/features/widgets/IntegrationManager.css.ts +++ b/src/app/features/widgets/IntegrationManager.css.ts @@ -3,7 +3,7 @@ import { color, config, toRem } from 'folds'; export const IntegrationManagerOverlay = style({ width: '80vw', - height: '80vh', + height: '80dvh', maxWidth: toRem(960), maxHeight: toRem(720), backgroundColor: color.Background.Container, diff --git a/src/app/hooks/useScreenSize.ts b/src/app/hooks/useScreenSize.ts index c234df1769..64d2883e57 100644 --- a/src/app/hooks/useScreenSize.ts +++ b/src/app/hooks/useScreenSize.ts @@ -40,3 +40,9 @@ export const useScreenSizeContext = (): ScreenSize => { } return screenSize; }; + +/** Tablet as well as Mobile, for touch presentation rather than available width. */ +export const useCompactLayout = (): boolean => { + const screenSize = useContext(ScreenSizeContext); + return screenSize !== null && screenSize !== ScreenSize.Desktop; +}; diff --git a/src/app/styles/Modal.css.ts b/src/app/styles/Modal.css.ts index 83e84c2df6..201b69ee47 100644 --- a/src/app/styles/Modal.css.ts +++ b/src/app/styles/Modal.css.ts @@ -2,5 +2,5 @@ import { style } from '@vanilla-extract/css'; export const ModalWide = style({ minWidth: '85vw', - minHeight: '90vh', + minHeight: '90dvh', }); diff --git a/src/app/utils/tauriNative.ts b/src/app/utils/tauriNative.ts index e551f5f237..0fdc0ad42c 100644 --- a/src/app/utils/tauriNative.ts +++ b/src/app/utils/tauriNative.ts @@ -3,7 +3,6 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import { type as osType } from '@tauri-apps/plugin-os'; import { isKeyHotkey } from 'is-hotkey'; -const KEYBOARD_HEIGHT = '--keyboard-height'; const DESKTOP_OS = new Set(['linux', 'macos', 'windows']); const EDITABLE_SELECTOR = 'input, textarea, [contenteditable="true"], [contenteditable=""]'; const BLOCKED_CEF_DESKTOP_SHORTCUTS = [ @@ -23,37 +22,6 @@ const BLOCKED_CEF_DESKTOP_SHORTCUTS = [ 'ctrl+shift+p', ] as const; -function installIosKeyboardInset(): () => void { - let frame = 0; - - const update = () => { - frame = 0; - const viewport = window.visualViewport; - const height = viewport ? window.innerHeight - viewport.height - viewport.offsetTop : 0; - document.documentElement.style.setProperty( - KEYBOARD_HEIGHT, - `${Math.max(0, Math.round(height))}px` - ); - }; - - const schedule = () => { - if (frame) cancelAnimationFrame(frame); - frame = requestAnimationFrame(update); - }; - - update(); - window.visualViewport?.addEventListener('resize', schedule); - window.visualViewport?.addEventListener('scroll', schedule); - window.addEventListener('orientationchange', schedule); - - return () => { - if (frame) cancelAnimationFrame(frame); - window.visualViewport?.removeEventListener('resize', schedule); - window.visualViewport?.removeEventListener('scroll', schedule); - window.removeEventListener('orientationchange', schedule); - }; -} - // Suppress the webview's own context menu except on editable fields, where the // native paste/spellcheck menu is expected. function installDesktopContextMenuSuppression(): () => void { @@ -88,7 +56,6 @@ export function installTauriNativeBehaviors(): void { if (!isTauri()) return; const os = osType(); - if (os === 'ios') installIosKeyboardInset(); if (DESKTOP_OS.has(os)) installDesktopContextMenuSuppression(); if (os === 'linux') { installDesktopCefMiddleClickOpener();