diff --git a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt index c5b3096f13..21cc16c7b9 100644 --- a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.enableEdgeToEdge import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import java.io.File import java.io.FileOutputStream import java.util.UUID @@ -153,6 +154,8 @@ class MainActivity : TauriActivity() { companion object { private var instance: MainActivity? = null + private var immersiveSystemBarsBehavior: Int? = null + private var immersiveDepth = 0 // Bars stay transparent (edge-to-edge plugin) so the webview strips supply the color // on every version; these only adapt icon contrast. setStatusBarColor/setNavigationBarColor @@ -177,6 +180,32 @@ class MainActivity : TauriActivity() { } } + @JvmStatic + fun setImmersiveModeNative(enabled: Boolean) { + val activity = instance ?: return + activity.runOnUiThread { + val controller = WindowCompat.getInsetsController(activity.window, activity.window.decorView) + // Overlapping viewers each request immersive mode; the bars only come back + // once the last one has released it. + if (enabled) { + immersiveDepth += 1 + if (immersiveDepth > 1) return@runOnUiThread + if (immersiveSystemBarsBehavior == null) { + immersiveSystemBarsBehavior = controller.systemBarsBehavior + } + controller.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + } else { + immersiveDepth = maxOf(0, immersiveDepth - 1) + if (immersiveDepth > 0) return@runOnUiThread + controller.show(WindowInsetsCompat.Type.systemBars()) + immersiveSystemBarsBehavior?.let { controller.systemBarsBehavior = it } + immersiveSystemBarsBehavior = null + } + } + } + @JvmStatic fun startCallForegroundServiceNative() { val activity = checkNotNull(instance) { "MainActivity is unavailable" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7fcfe176c5..1db10918d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -444,6 +444,8 @@ pub fn run() { #[cfg(target_os = "android")] mobile::set_navigation_bar_color, #[cfg(target_os = "android")] + mobile::set_immersive_mode, + #[cfg(target_os = "android")] mobile::start_call_foreground_service, #[cfg(target_os = "android")] mobile::stop_call_foreground_service, diff --git a/src-tauri/src/mobile.rs b/src-tauri/src/mobile.rs index fa2b42dad3..677f017d66 100644 --- a/src-tauri/src/mobile.rs +++ b/src-tauri/src/mobile.rs @@ -34,6 +34,23 @@ pub fn set_navigation_bar_color(color: u32) -> Result<(), String> { call_bar_color("setNavigationBarColorNative", color) } +#[tauri::command] +pub fn set_immersive_mode(enabled: bool) -> Result<(), String> { + let vm = JAVA_VM.get().ok_or("java vm not initialized")?; + let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; + let result = env.call_static_method( + "moe/sable/client/MainActivity", + "setImmersiveModeNative", + "(Z)V", + &[JValue::Bool(enabled as u8)], + ); + if result.is_err() { + let _ = env.exception_clear(); + } + result.map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] pub fn start_call_foreground_service() -> Result<(), String> { call_static_method("startCallForegroundServiceNative") diff --git a/src/app/components/MobileSwipeDownModal.tsx b/src/app/components/MobileSwipeDownModal.tsx index 5b85948441..0b5126f81b 100644 --- a/src/app/components/MobileSwipeDownModal.tsx +++ b/src/app/components/MobileSwipeDownModal.tsx @@ -28,6 +28,7 @@ interface MobileSwipeDownModalProps { sheetClassName?: string; sheetStyle?: CSSProperties; keyboardAware?: boolean; + zIndex?: number; } type FocusTrapOptions = ComponentProps['focusTrapOptions']; @@ -102,6 +103,7 @@ export function MobileSwipeDownModal({ sheetClassName, sheetStyle, keyboardAware = false, + zIndex, }: MobileSwipeDownModalProps) { const sheetRef = useRef(null); const backdropTouchRef = useRef(false); @@ -359,7 +361,10 @@ export function MobileSwipeDownModal({ portalRef ? css.MessageMobileOptionsWrappedContained : '' }`} data-gestures="ignore" - style={closing ? { opacity: 0, transition: 'opacity 100ms ease-out' } : undefined} + style={{ + ...(closing ? { opacity: 0, transition: 'opacity 100ms ease-out' } : undefined), + zIndex, + }} onClick={handleBackdropClick} // Touch events deliberately propagate: the drag binds them on `document`, and // stopping them here would starve it. `data-gestures="ignore"` is what keeps the diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index d29f6333e0..22e9587a8a 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -75,6 +75,7 @@ type RenderMessageContentProps = { mEvent?: MatrixEvent; mx?: MatrixClient; room?: Room; + onOpenMedia?: (mEvent: MatrixEvent) => boolean; }; const getMediaType = (url: string) => { @@ -112,6 +113,7 @@ function RenderMessageContentInternal({ mEvent, mx, room, + onOpenMedia, }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); @@ -395,6 +397,7 @@ function RenderMessageContentInternal({ renderImageContent={(imageProps) => ( onOpenMedia?.(mEvent) ?? false : undefined} autoPlay={mediaAutoLoad} renderImage={(p) => { if (isGif && !autoplayGifs && p.src) { 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..d2e0e24e2a 100644 --- a/src/app/components/ResponsiveMenu.tsx +++ b/src/app/components/ResponsiveMenu.tsx @@ -1,8 +1,9 @@ import type { ComponentProps, CSSProperties, ReactNode } from 'react'; +import { useEffect, useState } 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'; @@ -28,8 +29,10 @@ type ResponsiveMenuProps = { arrowNavigation?: 'vertical' | 'both'; /** How the menu shows on mobile: a bottom sheet, or a centred dialog for * option pickers, which a sheet makes look like an action menu. */ - mobile?: 'sheet' | 'dialog'; + mobile?: 'sheet' | 'dialog' | 'inline-dialog'; surfaceColor?: string; + /** Raises a mobile sheet above a parent fullscreen overlay. */ + mobileZIndex?: number; }; function MenuDialog({ @@ -57,6 +60,68 @@ function MenuDialog({ ); } +function InlineMenuDialog({ + anchor, + requestClose, + focusTrapOptions, + zIndex, + children, +}: { + anchor: RectCords; + requestClose: () => void; + focusTrapOptions: FocusTrapOptions; + zIndex: number; + children: ReactNode; +}) { + // Android back closes the menu instead of navigating away. + useDismissOnBack(requestClose); + + const [viewport, setViewport] = useState(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + useEffect(() => { + const handleResize = () => + setViewport({ width: window.innerWidth, height: window.innerHeight }); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const maxHeight = Math.round(viewport.height * 0.75); + + return ( + + + event.stopPropagation()} + > + {children} + + + + ); +} + /** * A menu that hangs off its trigger on desktop and rises as a bottom sheet on * mobile, where a popout anchored to a tiny target is hard to hit and easy to @@ -75,9 +140,9 @@ export function ResponsiveMenu({ arrowNavigation = 'vertical', mobile = 'sheet', surfaceColor, + mobileZIndex, }: 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'); @@ -97,6 +162,24 @@ export function ResponsiveMenu({ }; if (isMobile) { + if (mobile === 'inline-dialog') { + return ( + <> + {children} + {anchor && ( + + {menu} + + )} + + ); + } + const sheetStyle: CSSProperties | undefined = surfaceColor ? { backgroundColor: surfaceColor } : undefined; @@ -110,7 +193,11 @@ export function ResponsiveMenu({ )} {anchor && mobile === 'sheet' && ( - + {() => ( ({ + 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.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts index d140df8a55..daab4952ad 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -22,6 +22,18 @@ export const ImageViewerHeader = style([ minHeight: config.space.S400, paddingTop: config.space.S100, paddingBottom: config.space.S100, + '@media': { + '(max-width: 600px)': { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + borderBottomWidth: 0, + background: 'linear-gradient(#000a, transparent)', + color: '#fff', + }, + }, }, ]); @@ -31,6 +43,12 @@ export const ImageViewerContent = style([ backgroundColor: color.Background.Container, color: color.Background.OnContainer, overflow: 'hidden', + '@media': { + '(max-width: 600px)': { + backgroundColor: '#000', + color: '#fff', + }, + }, }, ]); @@ -66,3 +84,22 @@ export const ImageViewerImgPixelated = style({ imageRendering: 'pixelated', willChange: 'auto', }); + +const mobileGalleryControl = { + position: 'absolute' as const, + top: '50%', + zIndex: 1, + transform: 'translateY(-50%)', + backgroundColor: '#0009', + color: '#fff', +}; + +export const ImageViewerPrevious = style({ + ...mobileGalleryControl, + left: config.space.S100, +}); + +export const ImageViewerNext = style({ + ...mobileGalleryControl, + right: config.space.S100, +}); diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx index edae8fa115..3e78517a31 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 } = {}) => @@ -101,7 +102,7 @@ describe('ImageViewer', () => { renderViewer(); - const download = screen.getByText('Download'); + const download = screen.getByRole('button', { name: 'Download' }); fireEvent.pointerDown(download, { pointerId: 1, pointerType: 'touch' }); fireEvent.pointerUp(download, { pointerId: 1, pointerType: 'touch' }); fireEvent.click(download); @@ -110,6 +111,51 @@ describe('ImageViewer', () => { screenMocks.isMobile = false; }); + it('uses compact controls on mobile', () => { + screenMocks.isMobile = true; + try { + renderViewer(); + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Zoom In' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'More options' })); + + expect(screen.getByText('Turn pixelation on')).toBeInTheDocument(); + expect(screen.queryByText('Zoom out')).not.toBeInTheDocument(); + expect(screen.queryByText('Zoom in')).not.toBeInTheDocument(); + expect(screen.queryByText('Save image')).not.toBeInTheDocument(); + } finally { + screenMocks.isMobile = false; + } + }); + + it('closes the mobile overflow menu once an item is picked', () => { + screenMocks.isMobile = true; + try { + renderViewer(); + + fireEvent.click(screen.getByRole('button', { name: 'More options' })); + fireEvent.click(screen.getByText('Turn pixelation on')); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + } finally { + screenMocks.isMobile = false; + } + }); + + it('hides the share control when the platform cannot share', () => { + screenMocks.isMobile = true; + try { + renderViewer(); + + expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument(); + } finally { + screenMocks.isMobile = false; + } + }); + it('shows an error toast when downloading media fails', async () => { const error = new Error('network unavailable'); downloadMedia.mockRejectedValue(error); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 1b47901c11..9b40e8de2c 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,14 +1,30 @@ import type { MouseEventHandler } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; -import { Box, Chip, Header, IconButton, Menu, MenuItem, Text, as, config, toRem } from 'folds'; +import { + Box, + Chip, + Header, + IconButton, + Menu, + MenuItem, + Text, + as, + config, + toRem, + type RectCords, +} from 'folds'; import { ArrowLeft, + CaretLeft, + CaretRight, ArrowsClockwise, Download, + DotsThree, Image, Minus, Plus, + ShareNetwork, menuIcon, phosphorSizeRem, sizedIcon, @@ -23,11 +39,12 @@ import { showToast } from '$state/toast'; import { downloadMedia } from '$utils/matrix'; import * as css from './ImageViewer.css'; import type { IImageInfo } from '$types/matrix/common'; -import { CheckerboardIcon, CopyIcon, DownloadIcon } from '@phosphor-icons/react'; +import { CheckerboardIcon, CopyIcon, ImagesIcon } from '@phosphor-icons/react'; import { copyImageToClipboard } from '$utils/dom'; import { getDownloadFilename, saveFileToDevice, saveMediaToGallery } from '$utils/download'; import { ResponsiveMenu } from '$components/ResponsiveMenu'; import { isAndroidTauri, iosApp } from '$utils/platform'; +import { setImmersiveMode } from '$generated/tauri/commands'; import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize'; import { useMobileTapActivation } from '$hooks/useMobileTapActivation'; @@ -37,14 +54,24 @@ type ImageViewerProps = { src: string; requestClose: () => void; info?: IImageInfo; + onPrevious?: () => void; + onNext?: () => void; }; export const ImageViewer = as<'div', ImageViewerProps>( - ({ className, alt, filename, src, requestClose, info, ...props }, ref) => { + ({ className, alt, filename, src, requestClose, info, onPrevious, onNext, ...props }, ref) => { const zoomInputRef = useRef(null); const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; + useEffect(() => { + if (!isMobile || !isAndroidTauri()) return undefined; + void setImmersiveMode({ enabled: true }); + return () => { + void setImmersiveMode({ enabled: false }); + }; + }, [isMobile]); + // Android back closes the viewer instead of navigating away. useDismissOnBack(requestClose); @@ -70,16 +97,23 @@ export const ImageViewer = as<'div', ImageViewerProps>( handleImageLoad, handleImageDimensions, enableResizeWithWindow, - } = useImageGestures(true, 0.2, 0.1); + } = useImageGestures( + true, + 0.2, + 0.1, + 500, + isMobile ? { onDismiss: requestClose, onPrevious, onNext } : undefined + ); useEffect(() => { setIsImageReady(false); enableResizeWithWindow(); setIsEditingZoom(false); setZoomInput('100'); + resetTransforms(); if (imageRef.current) { imageRef.current = null; } - }, [src, enableResizeWithWindow, imageRef]); + }, [src, enableResizeWithWindow, imageRef, resetTransforms]); // When not actively editing the zoom input, keep it in sync with the current zoom level. useEffect(() => { @@ -99,6 +133,8 @@ export const ImageViewer = as<'div', ImageViewerProps>( // On iOS the primary action saves trusted images straight to Photos (PhotoKit). const iosSaveToPhotos = iosApp() && (galleryMimeType?.startsWith('image/') ?? false); const downloadFilename = getDownloadFilename(filename, alt, 'image'); + // Android's primary action writes to Downloads; the gallery is a separate destination. + const canSaveToGallery = isAndroidTauri() && (galleryMimeType?.startsWith('image/') ?? false); const handleDownload = async () => { if (iosSaveToPhotos) { @@ -116,10 +152,21 @@ export const ImageViewer = as<'div', ImageViewerProps>( await saveFileToDevice(fileContent, downloadFilename); }; - const menu = useMenuAnchor(); - const canSaveToGallery = isAndroidTauri() && (galleryMimeType?.startsWith('image/') ?? false); + const menu = useMenuAnchor(); + const [mobileMenuAnchor, setMobileMenuAnchor] = useState(); + // Mobile anchors the menu itself, so closing has to clear both anchors. + const closeMenu = useCallback(() => { + menu.close(); + setMobileMenuAnchor(undefined); + }, [menu]); const closeActivation = useMobileTapActivation(isMobile, requestClose); + const menuActivation = useMobileTapActivation(isMobile, (evt) => { + if (isMobile) { + const rect = evt.currentTarget.getBoundingClientRect(); + setMobileMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }); + } else menu.openAt(evt.currentTarget); + }); const pixelatedActivation = useMobileTapActivation(isMobile, () => setIsPixelated(!isPixelated) ); @@ -138,18 +185,50 @@ export const ImageViewer = as<'div', ImageViewerProps>( const downloadActivation = useMobileTapActivation(isMobile, () => { void handleDownload(); }); + // Web Share needs the decrypted bytes: `src` is a blob or Tauri asset URL that + // means nothing to the receiving app. + const canShare = isMobile && typeof navigator.share === 'function'; + const shareActivation = useMobileTapActivation(isMobile, () => { + void (async () => { + try { + const blob = await downloadMedia(src); + const file = new File([blob], downloadFilename, { + type: blob.type || galleryMimeType || 'application/octet-stream', + }); + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file], title: filename ?? alt }); + return; + } + await navigator.share({ title: filename ?? alt, text: filename ?? alt }); + } catch { + // Cancelling the share sheet rejects; nothing to report. + } + })(); + }); const copyImageActivation = useMobileTapActivation(isMobile, () => { - menu.close(); + closeMenu(); void downloadMedia(src).then(copyImageToClipboard); }); - const saveImageActivation = useMobileTapActivation(isMobile, () => { - menu.close(); - void handleDownload(); + const pixelatedMenuActivation = useMobileTapActivation(isMobile, () => { + setIsPixelated(!isPixelated); + closeMenu(); + }); + const originalSizeMenuActivation = useMobileTapActivation(isMobile, () => { + setZoom(1); + closeMenu(); }); + const previousActivation = useMobileTapActivation(isMobile, () => onPrevious?.()); + const nextActivation = useMobileTapActivation(isMobile, () => onNext?.()); const galleryActivation = useMobileTapActivation(isMobile, () => { - menu.close(); + closeMenu(); void saveMediaToGallery(src, downloadFilename, galleryMimeType!); }); + const resetZoomMenuActivation = useMobileTapActivation(isMobile, () => { + resetTransforms(); + enableResizeWithWindow(); + setZoom(fitRatio); + closeMenu(); + }); const handleContextMenu: MouseEventHandler = (evt) => { if (evt.altKey || !window.getSelection()?.isCollapsed) return; @@ -161,13 +240,69 @@ export const ImageViewer = as<'div', ImageViewerProps>( return ( <> + + {isMobile && ( + + } + {...pixelatedMenuActivation} + > + + {isPixelated ? 'Turn anti-aliasing on' : 'Turn pixelation on'} + + + )} + {isMobile && fitRatio !== 1 && transforms.zoom !== 1 && ( + + + View original size + + + )} + {isMobile && + (transforms.zoom !== fitRatio || + transforms.pan.x !== 0 || + transforms.pan.y !== 0) && ( + + + Reset zoom + + + )} ( Copy image - - - Save image - - {canSaveToGallery && ( @@ -214,148 +338,203 @@ export const ImageViewer = as<'div', ImageViewerProps>( {...props} ref={ref} > -
- - - {sizedIcon(ArrowLeft, '200')} - - - {alt} - - - - - - - - {sizedIcon(Image, '200')} - - - {sizedIcon(ArrowsClockwise, '200')} - - - {sizedIcon(Minus, '50')} - - - - {isEditingZoom ? ( - - { - setZoomInput(e.target.value); - }} - onBlur={() => { - const next = parseInt(zoomInput, 10); - if (!Number.isNaN(next)) { - setZoom(next / 100); - } - setIsEditingZoom(false); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - const next = parseInt(zoomInput, 10); - if (!Number.isNaN(next)) { - setZoom(next / 100); - } - setIsEditingZoom(false); - } - }} - /> - % - - ) : ( - `${Math.round(transforms.zoom * 100)}%` +
+ {isMobile ? ( + <> + + + {sizedIcon(ArrowLeft, '200')} + + + {alt} + + + + {canShare && ( + + {sizedIcon(ShareNetwork, '200')} + )} - - - 1 ? 'Success' : 'SurfaceVariant'} - outlined={transforms.zoom > 1} - size="300" - radii="Pill" - {...zoomInActivation} - aria-label="Zoom In" - title="Zoom In" - > - {sizedIcon(Plus, '50')} - - - {iosSaveToPhotos ? 'Save to Photos' : 'Download'} - - + + {sizedIcon(Download, '200')} + + + {sizedIcon(DotsThree, '200')} + + + + ) : ( + <> + + + {sizedIcon(ArrowLeft, '200')} + + + {alt} + + + + + + + + {sizedIcon(Image, '200')} + + + {sizedIcon(ArrowsClockwise, '200')} + + + {sizedIcon(Minus, '50')} + + + + {isEditingZoom ? ( + + { + setZoomInput(e.target.value); + }} + onBlur={() => { + const next = parseInt(zoomInput, 10); + if (!Number.isNaN(next)) { + setZoom(next / 100); + } + setIsEditingZoom(false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const next = parseInt(zoomInput, 10); + if (!Number.isNaN(next)) { + setZoom(next / 100); + } + setIsEditingZoom(false); + } + }} + /> + % + + ) : ( + `${Math.round(transforms.zoom * 100)}%` + )} + + + 1 ? 'Success' : 'SurfaceVariant'} + outlined={transforms.zoom > 1} + size="300" + radii="Pill" + {...zoomInActivation} + aria-label="Zoom In" + title="Zoom In" + > + {sizedIcon(Plus, '50')} + + + {iosSaveToPhotos ? 'Save to Photos' : 'Download'} + + + + )}
( onTouchMove={menu.triggerProps.onTouchMove} onTouchCancel={menu.triggerProps.onTouchCancel} > + {isMobile && onPrevious && ( + + {sizedIcon(CaretLeft, '200')} + + )} + {isMobile && onNext && ( + + {sizedIcon(CaretRight, '200')} + + )} ({ + ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' }, + useScreenSizeContext: () => 'Mobile', + useScreenSizeOptionally: () => 'Mobile', + useCompactLayout: () => true, +})); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => false, + invoke: vi.fn<() => Promise>(), +})); + +vi.mock('$utils/matrix', () => ({ + mxcUrlToHttp: (_mx: unknown, url: string) => `https://hs.example/${url}`, + rewriteAuthenticatedMediaUrl: (url: string | null) => url, + downloadEncryptedMedia: vi.fn<() => Promise>(), + decryptFile: vi.fn<() => Promise>(), + downloadMedia: vi.fn<() => Promise>(), +})); + +vi.mock('$hooks/useMatrixClient', () => ({ useMatrixClient: () => ({}) })); +vi.mock('$hooks/useMediaAuthentication', () => ({ useMediaAuthentication: () => false })); +vi.mock('$hooks/useRenderableMediaUrl', () => ({ + useRenderableMediaUrl: (url: string | undefined) => url, +})); +const createObjectURL = (value: string) => Promise.resolve(value); +vi.mock('$hooks/useObjectURL', () => ({ useCreateObjectURL: () => createObjectURL })); + +const items: RoomMediaItem[] = [ + { eventId: '$one', body: 'first.png', url: 'mxc://example.org/one' }, + { eventId: '$two', body: 'second.png', url: 'mxc://example.org/two' }, +]; + +const renderViewer = (selectedEventId: string, selectEvent = vi.fn<(id: string) => void>()) => { + render( + void>()} + selectEvent={selectEvent} + /> + ); + return selectEvent; +}; + +describe('RoomMediaViewer', () => { + it('renders the viewer for the selected item', async () => { + renderViewer('$one'); + + expect(await screen.findByAltText('first.png')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + }); + + it('offers next but not previous on the first item', async () => { + renderViewer('$one'); + + await screen.findByAltText('first.png'); + expect(screen.getByRole('button', { name: 'Next image' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Previous image' })).not.toBeInTheDocument(); + }); + + it('selects the following event when Next is tapped', async () => { + const selectEvent = renderViewer('$one'); + + await screen.findByAltText('first.png'); + fireEvent.click(screen.getByRole('button', { name: 'Next image' })); + + await waitFor(() => expect(selectEvent).toHaveBeenCalledWith('$two')); + }); + + it('closes when the selected event is no longer in the gallery', async () => { + const requestClose = vi.fn<() => void>(); + render( + void>()} + /> + ); + + await waitFor(() => expect(requestClose).toHaveBeenCalled()); + }); +}); diff --git a/src/app/components/image-viewer/RoomMediaViewer.tsx b/src/app/components/image-viewer/RoomMediaViewer.tsx new file mode 100644 index 0000000000..ebb99c6fc2 --- /dev/null +++ b/src/app/components/image-viewer/RoomMediaViewer.tsx @@ -0,0 +1,158 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Spinner } from 'folds'; +import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; +import { useCreateObjectURL } from '$hooks/useObjectURL'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; +import type { IImageInfo } from '$types/matrix/common'; +import { + decryptFile, + downloadEncryptedMedia, + mxcUrlToHttp, + rewriteAuthenticatedMediaUrl, +} from '$utils/matrix'; +import { FALLBACK_MIMETYPE } from '$utils/mimeTypes'; +import { setMediaEncryption } from '$utils/tauriMediaEncryption'; +import { isTauri } from '@tauri-apps/api/core'; +import { ImageViewer } from './ImageViewer'; + +export type RoomMediaItem = { + eventId: string; + body: string; + filename?: string; + url: string; + info?: IImageInfo; + mimeType?: string; + encInfo?: EncryptedAttachmentInfo; +}; + +type RoomMediaViewerProps = { + items: RoomMediaItem[]; + selectedEventId: string; + requestClose: () => void; + selectEvent: (eventId: string) => void; +}; + +type ResolvedMedia = { item: RoomMediaItem; src: string }; + +function ResolvedRoomMedia({ + item, + requestClose, + onPrevious, + onNext, +}: { + item: RoomMediaItem; + requestClose: () => void; + onPrevious?: () => void; + onNext?: () => void; +}) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const createObjectURL = useCreateObjectURL(); + const rawMediaUrl = useMemo( + () => (item.url.startsWith('http') ? item.url : mxcUrlToHttp(mx, item.url, useAuthentication)), + [item.url, mx, useAuthentication] + ); + const resolvedMediaUrl = useRenderableMediaUrl( + item.encInfo ? undefined : (rawMediaUrl ?? undefined) + ); + + // The previously resolved image stays mounted while the next one loads, so that + // stepping through the gallery does not tear the viewer — and with it the Android + // immersive mode — down and back up between every image. + const [resolved, setResolved] = useState(); + const requestRef = useRef(0); + + useEffect(() => { + requestRef.current += 1; + const request = requestRef.current; + const resolve = async () => { + const { encInfo, mimeType } = item; + if (encInfo) { + if (!rawMediaUrl) throw new Error('Invalid media URL'); + if (isTauri()) { + await setMediaEncryption(rawMediaUrl, encInfo, mimeType ?? FALLBACK_MIMETYPE); + return rewriteAuthenticatedMediaUrl(rawMediaUrl)!; + } + return createObjectURL( + downloadEncryptedMedia(rawMediaUrl, (buffer) => + decryptFile(buffer, mimeType ?? FALLBACK_MIMETYPE, encInfo) + ) + ); + } + return resolvedMediaUrl ?? rawMediaUrl ?? item.url; + }; + + resolve() + .then((src) => { + if (requestRef.current !== request) return; + // Bail on an unchanged result: re-setting it would re-run this effect. + setResolved((prev) => (prev?.item === item && prev.src === src ? prev : { item, src })); + }) + .catch(() => undefined); + }, [item, rawMediaUrl, resolvedMediaUrl, createObjectURL]); + + const loading = resolved?.item.eventId !== item.eventId; + + return ( + <> + {resolved && ( + + )} + {loading && ( + + + + )} + + ); +} + +export function RoomMediaViewer({ + items, + selectedEventId, + requestClose, + selectEvent, +}: RoomMediaViewerProps) { + const selectedIndex = items.findIndex((mediaItem) => mediaItem.eventId === selectedEventId); + const item = items[selectedIndex]; + + // The selected event can vanish under us, most often through a redaction. + useEffect(() => { + if (!item) requestClose(); + }, [item, requestClose]); + + if (!item) return null; + + return ( + + 0 ? () => selectEvent(items[selectedIndex - 1]!.eventId) : undefined + } + onNext={ + selectedIndex < items.length - 1 + ? () => selectEvent(items[selectedIndex + 1]!.eventId) + : undefined + } + /> + + ); +} diff --git a/src/app/components/message/content/ImageContent.test.tsx b/src/app/components/message/content/ImageContent.test.tsx index db41be4063..557d896cc9 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', () => ({ @@ -45,7 +46,7 @@ const imageContent = ( preview} - renderViewer={() =>
viewer
} + renderViewer={() => } /> ); @@ -92,6 +93,41 @@ describe('ImageContent', () => { expect(screen.getByAltText('preview').closest('[data-gestures="ignore"]')).not.toBeNull(); }); + it('falls back to its own viewer when the room gallery declines to open', async () => { + const onOpenViewer = vi.fn<() => boolean>(() => false); + render( + preview} + renderViewer={() => } + onOpenViewer={onOpenViewer} + /> + ); + + // A plain click, so this case does not arm the shared synthetic-click blocker. + fireEvent.click(screen.getByRole('button', { name: 'View' })); + + await waitFor(() => expect(screen.getByText('viewer')).toBeInTheDocument()); + expect(onOpenViewer).toHaveBeenCalled(); + }); + + it('leaves the local viewer closed when the room gallery takes over', async () => { + const onOpenViewer = vi.fn<() => boolean>(() => true); + render( + preview} + renderViewer={() => } + onOpenViewer={onOpenViewer} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'View' })); + + await waitFor(() => expect(onOpenViewer).toHaveBeenCalled()); + expect(screen.queryByText('viewer')).not.toBeInTheDocument(); + }); + it('does not mount hover controls for touch pointer entry', () => { render(imageContent); @@ -144,8 +180,15 @@ describe('ImageContent', () => { const initialSrc = srcs[srcs.length - 1]; expect(initialSrc).toBe(SABLE_MEDIA_URL); + // The mobile fullscreen viewer traps focus and blocks outside clicks, so + // close it before retrying. + fireEvent.keyDown(document.body, { key: 'Escape' }); fireEvent.error(img); - fireEvent.click(await screen.findByRole('button', { name: 'Retry' })); + // Press before clicking: the tap that opened the viewer armed the synthetic + // click blocker, and a real press is what disarms it. + const retry = await screen.findByRole('button', { name: 'Retry' }); + fireEvent.pointerDown(retry, { pointerId: 2, pointerType: 'mouse', isPrimary: true }); + fireEvent.click(retry); await waitFor(() => { const retriedSrc = srcs[srcs.length - 1] ?? ''; diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 941c1e7026..3fb36e7888 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -108,6 +108,9 @@ export type ImageContentProps = { spoilerReason?: string; renderViewer: (props: RenderViewerProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode; + /** Opens the room-scoped mobile viewer when this attachment belongs to a timeline. + * Returns false when it declines, and the local viewer opens instead. */ + onOpenViewer?: () => boolean; matrixThumbnailMaxEdge?: number; mediaLayout?: 'default' | 'contained'; containedStripMinPx?: number; @@ -129,6 +132,7 @@ export const ImageContent = as<'div', ImageContentProps>( spoilerReason, renderViewer, renderImage, + onOpenViewer, matrixThumbnailMaxEdge, mediaLayout = 'default', containedStripMinPx, @@ -233,7 +237,7 @@ export const ImageContent = as<'div', ImageContentProps>( if (srcState.status !== AsyncStatus.Idle) return; try { const src = await loadSrc(); - if (src !== undefined) setViewer(true); + if (src !== undefined && !onOpenViewer?.()) setViewer(true); } catch { // The existing error state is handled by the async callback. } @@ -272,6 +276,16 @@ export const ImageContent = as<'div', ImageContentProps>( const fillPreviewSlotStyle = fillsSlot ? ({ width: '100%', height: '100%' } as const) : undefined; + const viewerContent = + srcState.status === AsyncStatus.Success + ? renderViewer({ + src: viewerFullSrc ?? srcState.data, + alt: body ?? '', + filename, + requestClose: () => setViewer(false), + info, + }) + : null; return ( ( }} > {srcState.status === AsyncStatus.Success && ( - setViewer(false)}> - evt.stopPropagation()} - > - {renderViewer({ - src: viewerFullSrc ?? srcState.data, - alt: body ?? '', - filename, - requestClose: () => setViewer(false), - info: info, - })} - + setViewer(false)} + mobile="fullscreen" + background="#000" + > + {isMobile ? ( + viewerContent + ) : ( + evt.stopPropagation()} + > + {viewerContent} + + )} )} {typeof blurHash === 'string' && !load && ( @@ -355,7 +372,7 @@ export const ImageContent = as<'div', ImageContentProps>( onLottieError: handleError, onClick: () => { setIsHovered(false); - setViewer(true); + if (!onOpenViewer?.()) setViewer(true); }, tabIndex: 0, })} diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx index e1328b04d4..1cd72370ec 100644 --- a/src/app/components/modal-overlay/ModalOverlay.tsx +++ b/src/app/components/modal-overlay/ModalOverlay.tsx @@ -27,6 +27,8 @@ type ModalOverlayProps = { contentRef?: MutableRefObject; /** Set false for flows that Escape must not abort, such as device verification. */ escapeDeactivates?: FocusTrapOptions['escapeDeactivates']; + /** Fills the mobile fullscreen wrapper, for content that does not paint its own. */ + background?: string; children: ReactNode; }; @@ -38,6 +40,7 @@ export function ModalOverlay({ size, contentRef, escapeDeactivates = stopPropagation, + background, children, }: ModalOverlayProps) { // Null outside a provider, where desktop is the safe assumption. @@ -57,6 +60,7 @@ export function ModalOverlay({ contentRef?.current ?? document.body, escapeDeactivates, onDeactivate: requestClose, }} @@ -64,7 +68,13 @@ export function ModalOverlay({
{children}
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={ void; }; +const getRoomMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => { + if (mEvent.isRedacted()) return undefined; + + const content = mEvent.getContent() as IImageContent; + const isImage = content.msgtype === MsgType.Image || mEvent.getType() === 'm.sticker'; + const url = content.file?.url ?? content.url; + const eventId = mEvent.getId(); + + if (!isImage || typeof url !== 'string' || !eventId) return undefined; + + return { + eventId, + body: content.body ?? content.filename ?? 'Image', + filename: content.filename, + url, + info: content.info, + mimeType: content.info?.mimetype, + encInfo: content.file, + }; +}; + export function RoomTimeline({ room, eventId, @@ -322,6 +347,7 @@ export function RoomTimeline({ onEditId: propsOnEditId, }: Readonly) { const mx = useMatrixClient(); + const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; const alive = useAlive(); const roomSyncLoading = useSlidingSyncRoomLoading(room.roomId); @@ -936,6 +962,30 @@ export function RoomTimeline({ // the row memo has to compare them itself to notice a power-level change. const rowPermissions = { canRedact, canDeleteOwn, canSendReaction, canPinEvent }; + const [selectedMediaEventId, setSelectedMediaEventId] = useState(); + const [roomMedia, setRoomMedia] = useState([]); + // Returns false on desktop and for anything the gallery cannot show, so the + // attachment falls back to its own viewer instead of doing nothing. The gallery + // is collected on open: events are appended into the linked timelines in place, + // so there is no identity change a memo could key off. + const openRoomMedia = useCallback( + (mEvent: MatrixEvent) => { + const mediaEventId = mEvent.getId(); + if (!isMobile || !mediaEventId || !getRoomMediaItem(mEvent)) return false; + setRoomMedia( + timelineSyncRef.current.timeline.linkedTimelines.flatMap((timeline) => + timeline.getEvents().flatMap((timelineEvent) => { + const item = getRoomMediaItem(timelineEvent); + return item ? [item] : []; + }) + ) + ); + setSelectedMediaEventId(mediaEventId); + return true; + }, + [isMobile] + ); + const renderMatrixEvent = useTimelineEventRenderer({ room, mx, @@ -956,6 +1006,7 @@ export function RoomTimeline({ onDeleteFailedSend: actions.handleDeleteFailedSend, setOpenThread: actions.setOpenThread, handleOpenReply: actions.handleOpenReply, + onOpenMedia: openRoomMedia, }, utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent }, }); @@ -1302,6 +1353,14 @@ export function RoomTimeline({
+ {selectedMediaEventId && ( + setSelectedMediaEventId(undefined)} + /> + )} {showBackPaginationSpinner && ( diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index bb815f2f8e..e16f3f16bf 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -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', @@ -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, diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.css.ts b/src/app/features/room/room-pin-menu/RoomPinMenu.css.ts index 9b0269b5de..0516fb7373 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.css.ts +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.css.ts @@ -5,7 +5,7 @@ export const PinMenu = style({ display: 'flex', maxWidth: toRem(548), width: '100vw', - maxHeight: '90vh', + maxHeight: '90dvh', }); export const PinMenuHeader = style({ diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 26703be0a3..b6a821f12a 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -226,7 +226,7 @@ export const RoomPinMenu = forwardRef( }); }, [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/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 1dd132bbba..6132f6140a 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -74,6 +74,10 @@ export async function saveMediaToPhotos(params: types.SaveMediaToPhotosParams): return invoke('save_media_to_photos', params); } +export async function setImmersiveMode(params: types.SetImmersiveModeParams): Promise { + return invoke('set_immersive_mode', params); +} + export async function setMediaEncryption(params: types.SetMediaEncryptionParams): Promise { return invoke('set_media_encryption', params); } diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 5c41032aff..b3fad783e7 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -120,6 +120,11 @@ export interface SaveMediaToPhotosParams { [key: string]: unknown; } +export interface SetImmersiveModeParams { + enabled: boolean; + [key: string]: unknown; +} + export interface SetMediaEncryptionParams { url: string; key: string; diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 42c8f727f2..8bf1cbc014 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -347,6 +347,7 @@ export interface TimelineEventRendererOptions { onDeleteFailedSend: (mEvent: MatrixEvent) => void; setOpenThread: (threadId: string | undefined) => void; handleOpenReply: MouseEventHandler; + onOpenMedia?: (mEvent: MatrixEvent) => boolean; }; utils: { htmlReactParserOptions: HTMLReactParserOptions; @@ -395,6 +396,7 @@ export function useTimelineEventRenderer({ onDeleteFailedSend, setOpenThread, handleOpenReply, + onOpenMedia, }, utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent }, }: TimelineEventRendererOptions) { @@ -757,6 +759,7 @@ export function useTimelineEventRenderer({ outlineAttachment={messageLayout === MessageLayout.Bubble} mx={mx} room={room} + onOpenMedia={onOpenMedia} /> )} @@ -860,6 +863,7 @@ export function useTimelineEventRenderer({ renderImageContent={(props) => ( onOpenMedia?.(mEvent) ?? false} autoPlay={mediaAutoLoad} renderImage={(p) => { if (!autoplayStickers && p.src) { @@ -912,6 +916,7 @@ export function useTimelineEventRenderer({ mEvent={mEvent} mx={mx} room={room} + onOpenMedia={onOpenMedia} /> ); } @@ -1008,6 +1013,7 @@ export function useTimelineEventRenderer({ renderImageContent={(props) => ( onOpenMedia?.(mEvent) ?? false} autoPlay={mediaAutoLoad} renderImage={(p) => { if (!autoplayStickers && p.src) { @@ -1168,6 +1174,7 @@ export function useTimelineEventRenderer({ mEvent={mEvent} mx={mx} room={room} + onOpenMedia={onOpenMedia} /> )} diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index 9185ca9d5f..07e84dae39 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -6,6 +6,12 @@ interface Vector2 { y: number; } +type FittedSwipeOptions = { + onDismiss?: () => void; + onPrevious?: () => void; + onNext?: () => void; +}; + // calculate pointer position relative to the image center // // use container rect & manually apply transforms as if we get two+ events quickly, @@ -21,7 +27,13 @@ function getCursorOffsetFromImageCenter( }; } -export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 500) => { +export const useImageGestures = ( + active: boolean, + step = 0.2, + min = 0.1, + max = 500, + fittedSwipeOptions?: FittedSwipeOptions +) => { const [transforms, setTransforms] = useState({ zoom: 1, pan: { x: 0, y: 0 }, @@ -52,6 +64,15 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 const activePointers = useRef(new Map()); const initialDist = useRef(0); const lastTapRef = useRef(0); + const fittedSwipeRef = useRef<{ + startX: number; + startY: number; + direction?: 'horizontal' | 'vertical'; + }>(); + // Callers pass a fresh options object every render; keeping it in a ref stops the + // window listeners below from being torn down and rebound on each one. + const fittedSwipeOptionsRef = useRef(fittedSwipeOptions); + fittedSwipeOptionsRef.current = fittedSwipeOptions; const prepareForTransform = useCallback(() => { const img = imageRef.current; @@ -153,15 +174,19 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 lastTapRef.current = now; activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (activePointers.current.size === 1 && Math.abs(transforms.zoom - fitRatio) < 0.01) { + fittedSwipeRef.current = { startX: e.clientX, startY: e.clientY }; + } setCursor('grabbing'); // Initialize pinch zoom if (activePointers.current.size === 2) { + fittedSwipeRef.current = undefined; const points = Array.from(activePointers.current.values()); initialDist.current = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); } }, - [active, disableResizeWithWindow, prepareForTransform] + [active, disableResizeWithWindow, fitRatio, prepareForTransform, transforms.zoom] ); const handlePointerMove = useCallback( @@ -188,6 +213,15 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 // Pan if (activePointers.current.size === 1) { + const fittedSwipe = fittedSwipeRef.current; + if (fittedSwipe) { + const deltaX = e.clientX - fittedSwipe.startX; + const deltaY = e.clientY - fittedSwipe.startY; + if (!fittedSwipe.direction && Math.max(Math.abs(deltaX), Math.abs(deltaY)) > 12) { + fittedSwipe.direction = Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'; + } + return; + } setPan((p) => ({ x: p.x + e.movementX, y: p.y + e.movementY, @@ -197,10 +231,22 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 [setZoom, min, max, setPan] ); - const handlePointerUp = useCallback( - (e: PointerEvent) => { + const releasePointer = useCallback( + (e: PointerEvent, cancelled: boolean) => { + const fittedSwipe = fittedSwipeRef.current; + if (fittedSwipe && !cancelled && activePointers.current.size === 1) { + const deltaX = e.clientX - fittedSwipe.startX; + const deltaY = e.clientY - fittedSwipe.startY; + const options = fittedSwipeOptionsRef.current; + if (fittedSwipe.direction === 'vertical' && deltaY > 96) options?.onDismiss?.(); + if (fittedSwipe.direction === 'horizontal' && Math.abs(deltaX) > 72) { + if (deltaX > 0) options?.onPrevious?.(); + else options?.onNext?.(); + } + } activePointers.current.delete(e.pointerId); if (activePointers.current.size === 0) { + fittedSwipeRef.current = undefined; setCursor(active ? 'grab' : 'initial'); } if (activePointers.current.size < 2) { @@ -210,16 +256,25 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 [active] ); + const handlePointerUp = useCallback( + (e: PointerEvent) => releasePointer(e, false), + [releasePointer] + ); + const handlePointerCancel = useCallback( + (e: PointerEvent) => releasePointer(e, true), + [releasePointer] + ); + useEffect(() => { window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointercancel', handlePointerUp); + window.addEventListener('pointercancel', handlePointerCancel); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointercancel', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerCancel); }; - }, [handlePointerMove, handlePointerUp]); + }, [handlePointerMove, handlePointerUp, handlePointerCancel]); // When the size of the container changes, zoom without a transition. const handleContainerResize = useCallback( diff --git a/src/app/hooks/useMobileTapActivation.test.tsx b/src/app/hooks/useMobileTapActivation.test.tsx new file mode 100644 index 0000000000..db849cd08c --- /dev/null +++ b/src/app/hooks/useMobileTapActivation.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useMobileTapActivation } from './useMobileTapActivation'; + +function TapCloseHarness({ onUnderlyingClick }: { onUnderlyingClick: () => void }) { + const [open, setOpen] = useState(true); + const activation = useMobileTapActivation(true, () => setOpen(false)); + + return ( + <> + {open && ( + + )} + + + ); +} + +describe('useMobileTapActivation', () => { + it('swallows a synthetic click retargeted after activation unmounts its control', () => { + const onUnderlyingClick = vi.fn<() => void>(); + render(); + + const close = screen.getByTestId('close'); + fireEvent.pointerDown(close, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerUp(close, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.click(screen.getByTestId('underlying'), { clientX: 10, clientY: 10 }); + + expect(screen.queryByTestId('close')).not.toBeInTheDocument(); + expect(onUnderlyingClick).not.toHaveBeenCalled(); + }); + + it('does not swallow a genuine second tap at the same spot', () => { + const onUnderlyingClick = vi.fn<() => void>(); + render(); + + const close = screen.getByTestId('close'); + fireEvent.pointerDown(close, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerUp(close, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + + // Android synthesised no click, so the blocker is still armed. A fresh tap on + // the element underneath must still get through. + const underlying = screen.getByTestId('underlying'); + fireEvent.pointerDown(underlying, { + pointerId: 2, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.click(underlying, { clientX: 10, clientY: 10 }); + + expect(onUnderlyingClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/hooks/useMobileTapActivation.ts b/src/app/hooks/useMobileTapActivation.ts index 7e9ec88d71..8359d7adba 100644 --- a/src/app/hooks/useMobileTapActivation.ts +++ b/src/app/hooks/useMobileTapActivation.ts @@ -3,6 +3,40 @@ import { useCallback, useRef } from 'react'; const TAP_MOVEMENT_THRESHOLD = 10; const MAX_TAP_DURATION = 500; +// A synthesised click follows its touchend within a frame or two. The window only +// has to outlast the legacy 300ms tap delay, and a fresh pointerdown disarms it so +// a genuine second tap on the same spot is never swallowed. +const SYNTHETIC_CLICK_TIMEOUT = 350; + +let clearPendingSyntheticClick: (() => void) | undefined; + +function blockSyntheticClick(clientX: number, clientY: number) { + clearPendingSyntheticClick?.(); + + let timeout: number | undefined; + const clear = () => { + document.removeEventListener('click', handleClick, true); + document.removeEventListener('pointerdown', clear, true); + window.clearTimeout(timeout); + if (clearPendingSyntheticClick === clear) clearPendingSyntheticClick = undefined; + }; + const handleClick = (event: MouseEvent) => { + if ( + Math.abs(event.clientX - clientX) > TAP_MOVEMENT_THRESHOLD || + Math.abs(event.clientY - clientY) > TAP_MOVEMENT_THRESHOLD + ) { + return; + } + clear(); + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + clearPendingSyntheticClick = clear; + document.addEventListener('click', handleClick, true); + document.addEventListener('pointerdown', clear, true); + timeout = window.setTimeout(clear, SYNTHETIC_CLICK_TIMEOUT); +} // Android WebView suppresses click synthesis after a drag gesture, so the first // tap on a nav control after swiping the drawer produces no click event. Activate @@ -87,6 +121,7 @@ export function useMobileTapActivation( evt.preventDefault(); activatedRef.current = true; + blockSyntheticClick(evt.clientX, evt.clientY); onActivateRef.current(evt); }; const onPointerCancel: PointerEventHandler = (evt) => { 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();