From 6e7e1e4c2a3695bdfe075d7904f8a395f3ae0715 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 22 Jul 2026 09:12:15 +0200 Subject: [PATCH] feat: improve input attachement modal --- .changeset/bottom-sheet-files.md | 5 + .../attachment-sheet/AttachmentSheet.css.ts | 207 +++++++++++++++ .../attachment-sheet/AttachmentSheet.test.tsx | 127 +++++++++ .../attachment-sheet/AttachmentSheet.tsx | 240 ++++++++++++++++++ src/app/features/room/RoomInput.tsx | 200 +++++++++------ src/app/features/room/RoomView.tsx | 5 +- src/app/features/room/ThreadDrawer.tsx | 4 +- 7 files changed, 707 insertions(+), 81 deletions(-) create mode 100644 .changeset/bottom-sheet-files.md create mode 100644 src/app/components/attachment-sheet/AttachmentSheet.css.ts create mode 100644 src/app/components/attachment-sheet/AttachmentSheet.test.tsx create mode 100644 src/app/components/attachment-sheet/AttachmentSheet.tsx diff --git a/.changeset/bottom-sheet-files.md b/.changeset/bottom-sheet-files.md new file mode 100644 index 000000000..66da5a913 --- /dev/null +++ b/.changeset/bottom-sheet-files.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Attach files and media from a bottom sheet on the message input. diff --git a/src/app/components/attachment-sheet/AttachmentSheet.css.ts b/src/app/components/attachment-sheet/AttachmentSheet.css.ts new file mode 100644 index 000000000..c06e5f7f6 --- /dev/null +++ b/src/app/components/attachment-sheet/AttachmentSheet.css.ts @@ -0,0 +1,207 @@ +import { style } from '@vanilla-extract/css'; +import { color, toRem } from 'folds'; + +export const Backdrop = style({ + position: 'absolute', + inset: 0, + zIndex: 1000, + background: 'rgba(0, 0, 0, 0.42)', + touchAction: 'none', +}); + +export const Sheet = style({ + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + zIndex: 1001, + display: 'flex', + flexDirection: 'column', + width: '100%', + minWidth: 0, + boxSizing: 'border-box', + background: color.Surface.Container, + borderTopLeftRadius: toRem(20), + borderTopRightRadius: toRem(20), + paddingBottom: `calc(${toRem(12)} + env(safe-area-inset-bottom, 0px))`, + maxHeight: '100%', + overflowX: 'hidden', + overflowY: 'auto', + boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.15)', +}); + +export const SheetHeader = style({ + position: 'relative', + flexShrink: 0, + padding: `${toRem(22)} ${toRem(16)} ${toRem(8)}`, +}); + +export const DragHandle = style({ + position: 'absolute', + top: toRem(8), + left: '50%', + width: toRem(40), + height: toRem(4), + borderRadius: toRem(4), + background: color.Surface.OnContainer, + opacity: 0.3, + transform: 'translateX(-50%)', +}); + +export const Heading = style({ + margin: 0, + color: color.Surface.OnContainer, + fontSize: toRem(16), + fontWeight: 700, + lineHeight: toRem(22), +}); + +export const ActionsRow = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + padding: `${toRem(4)} ${toRem(12)} 0`, + gap: toRem(8), + flexShrink: 0, + minWidth: 0, +}); + +export const ActionButton = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: toRem(7), + minWidth: 0, + minHeight: toRem(76), + padding: `${toRem(8)} ${toRem(4)}`, + border: 'none', + background: 'transparent', + cursor: 'pointer', + borderRadius: toRem(12), + transition: 'background-color 0.15s ease', + color: color.Surface.OnContainer, + + ':hover': { + background: color.Surface.ContainerHover, + }, + + ':focus-visible': { + outline: `${toRem(2)} solid ${color.Primary.Main}`, + outlineOffset: toRem(-2), + }, + + ':active': { + background: color.Surface.ContainerActive, + }, + + '@media': { + '(prefers-reduced-motion: reduce)': { + transition: 'none', + }, + }, +}); + +export const ActionIcon = style({ + display: 'grid', + placeItems: 'center', + width: toRem(44), + height: toRem(44), + flexShrink: 0, + borderRadius: '50%', + color: color.SurfaceVariant.OnContainer, + background: color.SurfaceVariant.Container, +}); + +export const ActionLabel = style({ + fontSize: toRem(12), + lineHeight: toRem(16), + textAlign: 'center', + color: color.Surface.OnContainer, + overflowWrap: 'anywhere', +}); + +export const GallerySection = style({ + padding: `${toRem(4)} ${toRem(16)} ${toRem(8)}`, + flexShrink: 0, + minWidth: 0, +}); + +export const GalleryButton = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: toRem(14), + width: '100%', + minWidth: 0, + minHeight: toRem(72), + boxSizing: 'border-box', + padding: toRem(10), + border: `${toRem(1)} solid ${color.Surface.ContainerLine}`, + borderRadius: toRem(16), + background: color.Surface.ContainerHover, + color: color.Surface.OnContainer, + cursor: 'pointer', + textAlign: 'left', + transition: 'background-color 0.15s ease, border-color 0.15s ease', + + ':hover': { + background: color.Surface.ContainerActive, + }, + + ':focus-visible': { + outline: `${toRem(2)} solid ${color.Primary.Main}`, + outlineOffset: toRem(2), + }, + + ':active': { + background: color.Surface.ContainerActive, + }, + + '@media': { + '(prefers-reduced-motion: reduce)': { + transition: 'none', + }, + }, +}); + +export const GalleryIcon = style({ + display: 'grid', + placeItems: 'center', + width: toRem(52), + height: toRem(52), + flexShrink: 0, + borderRadius: toRem(13), + color: color.Primary.OnContainer, + background: color.Primary.Container, +}); + +export const GalleryCopy = style({ + display: 'flex', + flexDirection: 'column', + minWidth: 0, + flexGrow: 1, +}); + +export const GalleryTitle = style({ + color: color.Surface.OnContainer, + fontSize: toRem(14), + fontWeight: 700, + lineHeight: toRem(20), +}); + +export const GalleryGrid = style({ + color: color.Surface.OnContainer, + opacity: 0.55, + flexShrink: 0, + marginRight: toRem(4), +}); + +export const GalleryLabel = style({ + fontSize: toRem(12), + lineHeight: toRem(17), + color: color.Surface.OnContainer, + opacity: 0.68, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); diff --git a/src/app/components/attachment-sheet/AttachmentSheet.test.tsx b/src/app/components/attachment-sheet/AttachmentSheet.test.tsx new file mode 100644 index 000000000..270bce6eb --- /dev/null +++ b/src/app/components/attachment-sheet/AttachmentSheet.test.tsx @@ -0,0 +1,127 @@ +import { type PointerEventHandler, useRef, useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AttachmentSheet } from './AttachmentSheet'; + +vi.mock('$state/hooks/settings', () => ({ + useSetting: () => [false, vi.fn<() => void>()], +})); + +vi.mock('$utils/user-agent', () => ({ + mobileOrTablet: () => false, +})); + +const callbacks = () => ({ + onClose: vi.fn<() => void>(), + onPickPhotos: vi.fn<() => void>(), + onPickFile: vi.fn<() => void>(), + onPickPoll: vi.fn<() => void>(), + onPickLocation: vi.fn<() => void>(), +}); + +type Handlers = ReturnType; + +function AttachmentSheetHarness({ + handlers, + onPointerDown, +}: { + handlers: Handlers; + onPointerDown?: PointerEventHandler; +}) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + return ( +
+ +
+ { + handlers.onClose(); + setOpen(false); + }} + /> +
+ ); +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('AttachmentSheet', () => { + it('portals into a host whose ref was attached while the sheet was closed', () => { + const handlers = callbacks(); + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open attachments' })); + + const chatPane = screen.getByTestId('chat-pane'); + const dialog = screen.getByRole('dialog', { name: 'Share' }); + const backdrop = dialog.previousElementSibling; + + expect(chatPane).toContainElement(dialog); + expect(dialog.parentElement).toBe(chatPane); + expect(backdrop).not.toBeNull(); + expect(backdrop?.parentElement).toBe(chatPane); + expect(dialog.parentElement).not.toBe(document.body); + }); + + it('does not render the dialog without a portal target', () => { + const { container } = render( + + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); + }); + + it('delegates Photos selection without directly closing', () => { + const handlers = callbacks(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open attachments' })); + fireEvent.click(screen.getByRole('button', { name: 'Open photo gallery' })); + + expect(handlers.onPickPhotos).toHaveBeenCalledOnce(); + expect(handlers.onClose).not.toHaveBeenCalled(); + }); + + it('shields room pointer gestures from icon descendants', () => { + const handlers = callbacks(); + const onPointerDown = vi.fn>(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open attachments' })); + const iconPath = screen + .getByRole('button', { name: 'Open photo gallery' }) + .querySelector('path'); + + expect(iconPath).not.toBeNull(); + fireEvent.pointerDown(iconPath!); + expect(onPointerDown).not.toHaveBeenCalled(); + }); + + it('closes once on Escape and returns focus to the opener', async () => { + const handlers = callbacks(); + render(); + const opener = screen.getByRole('button', { name: 'Open attachments' }); + + opener.focus(); + fireEvent.click(opener); + await waitFor(() => expect(screen.getByRole('dialog', { name: 'Share' })).toHaveFocus()); + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }); + + expect(handlers.onClose).toHaveBeenCalledOnce(); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(opener).toHaveFocus(); + }); + }); +}); diff --git a/src/app/components/attachment-sheet/AttachmentSheet.tsx b/src/app/components/attachment-sheet/AttachmentSheet.tsx new file mode 100644 index 000000000..f6e77f9fc --- /dev/null +++ b/src/app/components/attachment-sheet/AttachmentSheet.tsx @@ -0,0 +1,240 @@ +import { type RefObject, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from 'framer-motion'; +import { useDrag } from '@use-gesture/react'; +import FocusTrap from 'focus-trap-react'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useAndroidBackHandler } from '$utils/androidBack'; +import { stopPropagation } from '$utils/keyboard'; +import { mobileOrTablet } from '$utils/user-agent'; +import type { Icon } from '@phosphor-icons/react'; +import { + Image as ImageIcon, + PlusCircle, + ListBullets, + MapPinPlusIcon, + GridFour, +} from '$components/icons/phosphor'; +import * as css from './AttachmentSheet.css'; + +interface AttachmentAction { + icon: Icon; + label: string; + onClick: () => void; +} + +export interface AttachmentSheetProps { + open: boolean; + onClose: () => void; + onPickPhotos: () => void; + onPickFile: () => void; + onPickPoll: () => void; + onPickLocation: () => void; + containerRef: RefObject; +} + +const SWIPE_THRESHOLD = 100; +const VELOCITY_THRESHOLD = 0.5; + +export function AttachmentSheet({ + open, + onClose, + onPickPhotos, + onPickFile, + onPickPoll, + onPickLocation, + containerRef, +}: AttachmentSheetProps) { + const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures'); + const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion'); + const containerEl = containerRef.current; + const sheetRef = useRef(null); + const skipReturnFocusRef = useRef(false); + const y = useMotionValue(0); + const prefersReducedMotion = useReducedMotion() ?? false; + const shouldReduceMotion = reducedMotion || prefersReducedMotion; + + useEffect(() => { + if (open) { + skipReturnFocusRef.current = false; + y.set(0); + } + }, [open, y]); + + useAndroidBackHandler(() => { + onClose(); + return true; + }, open); + + const gesturesEnabled = mobileGestures && mobileOrTablet(); + + const bind = useDrag( + ({ first, active, offset: [, oy], velocity: [, vy], direction: [, dy], event }) => { + if (event && 'target' in event && event.target instanceof Element) { + if (event.target.closest('[data-gestures="ignore"]')) { + return; + } + } + + if (!gesturesEnabled) return; + + event.stopPropagation(); + + const val = Math.max(0, oy); + + if (active) { + if (first) y.stop(); + y.set(val); + } else { + const swipedDown = val > SWIPE_THRESHOLD || (vy > VELOCITY_THRESHOLD && dy > 0); + + if (swipedDown) { + onClose(); + } else if (shouldReduceMotion) { + y.set(0); + } else { + animate(y, 0, { type: 'spring', stiffness: 400, damping: 40 }); + } + } + }, + { + axis: 'y', + bounds: { top: 0, bottom: 300 }, + rubberband: true, + filterTaps: true, + pointer: { capture: true }, + from: () => [0, y.get()], + } + ); + + const actions: AttachmentAction[] = [ + { icon: PlusCircle, label: 'Add File', onClick: onPickFile }, + { icon: ListBullets, label: 'Create Poll', onClick: onPickPoll }, + { icon: MapPinPlusIcon, label: 'Add Location', onClick: onPickLocation }, + ]; + + const handleAction = (action: () => void) => { + skipReturnFocusRef.current = true; + action(); + }; + + const sheetContent = ( + <> +
+ + +
+ +
+ +
+ {actions.map((action) => ( + + ))} +
+ + ); + + const sheetElement = ( + + {open && ( + <> + event.stopPropagation()} + data-gestures="ignore" + aria-hidden="true" + /> + + sheetRef.current ?? containerEl, + fallbackFocus: () => sheetRef.current ?? containerEl, + returnFocusOnDeactivate: true, + setReturnFocus: (previousActiveElement: HTMLElement) => + skipReturnFocusRef.current ? false : previousActiveElement, + allowOutsideClick: true, + clickOutsideDeactivates: false, + escapeDeactivates: (event: KeyboardEvent) => { + if (!stopPropagation(event)) return false; + onClose(); + return false; + }, + }} + > + event.stopPropagation()} + > + + {sheetContent} + + + + + )} + + ); + + // Never render inline: without the active pane as a portal target, the sheet + // could briefly anchor to the room layout and cover the sidebar. + if (!containerEl) return null; + + return createPortal(sheetElement, containerEl); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 8c1d553c4..df0a2b583 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -170,6 +170,7 @@ import { import { ImageUsage } from '$plugins/custom-emoji'; import { SerializableMap } from '$types/wrapper/SerializableMap'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; +import { AttachmentSheet } from '$components/attachment-sheet/AttachmentSheet'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { @@ -474,6 +475,7 @@ export const RoomInput = forwardRef( roomIdToEditingScheduledDelayIdAtomFamily(roomId) ); const [AddMenuAnchor, setAddMenuAnchor] = useState(); + const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); const [showPollPicker, setShowPollPicker] = useState(false); const [showLocationPicker, setShowLocationPicker] = useState(false); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); @@ -1711,87 +1713,127 @@ export const RoomInput = forwardRef( } before={ <> - setAddMenuAnchor(undefined), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, + {mobileOrTablet() ? ( + <> + setShowAttachmentSheet(true)} + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + radii="300" + style={{ backgroundColor: 'transparent' }} + title="Add" + aria-label="Add new Item" + > + {composerIcon(PlusCircle)} + + setShowAttachmentSheet(false)} + onPickPhotos={() => { + pickFile('image/*'); + setShowAttachmentSheet(false); + }} + onPickFile={() => { + pickFile('*'); + setShowAttachmentSheet(false); + }} + onPickPoll={() => { + setShowAttachmentSheet(false); + setShowPollPicker(true); }} + onPickLocation={() => { + setShowAttachmentSheet(false); + setShowLocationPicker(true); + }} + containerRef={fileDropContainerRef} + /> + + ) : ( + <> + setAddMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + { + setAddMenuAnchor(undefined); + setShowPollPicker(true); + }} + before={menuIcon(ListBullets)} + > + Create Poll + + { + setAddMenuAnchor(undefined); + setShowLocationPicker(true); + }} + before={menuIcon(MapPinPlusIcon)} + > + Add Location + + { + pickFile('image/*'); + setAddMenuAnchor(undefined); + }} + before={menuIcon(ImageIcon)} + > + Photos + + { + pickFile('*'); + setAddMenuAnchor(undefined); + }} + before={menuIcon(PlusCircle)} + > + Add File + + + + + } + /> + + editorOldAddFile + ? pickFile('*') + : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect()) + } + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + radii="300" + style={{ backgroundColor: 'transparent' }} + title={editorOldAddFile ? 'Upload File' : 'Add'} + aria-label={editorOldAddFile ? 'Upload and attach a File' : 'Add new Item'} > - - - { - setAddMenuAnchor(undefined); - setShowPollPicker(true); - }} - before={menuIcon(ListBullets)} - > - Create Poll - - { - setAddMenuAnchor(undefined); - setShowLocationPicker(true); - }} - before={menuIcon(MapPinPlusIcon)} - > - Add Location - - { - pickFile('image/*'); - setAddMenuAnchor(undefined); - }} - before={menuIcon(ImageIcon)} - > - Photos - - { - pickFile('*'); - setAddMenuAnchor(undefined); - }} - before={menuIcon(PlusCircle)} - > - Add File - - - - - } - /> - - editorOldAddFile - ? pickFile('*') - : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect()) - } - onPointerDown={suppressEditorRefocus} - variant="SurfaceVariant" - size="300" - radii="300" - style={{ backgroundColor: 'transparent' }} - title={editorOldAddFile ? 'Upload File' : 'Add'} - aria-label={editorOldAddFile ? 'Upload and attach a File' : 'Add new Item'} - > - {composerIcon(PlusCircle)} - + {composerIcon(PlusCircle)} + + + )} {pmpPickerEnable && ( 0 || isJoinedInThisRoom); return ( - + {showCallView && ( diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index c7726fabc..30b566860 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -819,11 +819,13 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra }, [threadRootHeight]); return (