diff --git a/package-lock.json b/package-lock.json index 7dd2bf0a..d05ca387 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@fontsource/inter": "4.5.14", + "@react-spring/web": "10.0.1", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -63,13 +64,15 @@ "react-modal": "3.16.1", "react-range": "1.8.14", "react-router-dom": "6.20.0", + "react-use-gesture": "9.1.3", "sanitize-html": "2.12.1", "slate": "0.112.0", "slate-dom": "0.112.2", "slate-history": "0.110.3", "slate-react": "0.112.1", "tippy.js": "6.3.7", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "use-long-press": "3.3.0" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", @@ -3097,6 +3100,78 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-spring/animated": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.1.tgz", + "integrity": "sha512-BGL3hA66Y8Qm3KmRZUlfG/mFbDPYajgil2/jOP0VXf2+o2WPVmcDps/eEgdDqgf5Pv9eBbyj7LschLMuSjlW3Q==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.0.1", + "@react-spring/types": "~10.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.1.tgz", + "integrity": "sha512-KaMMsN1qHuVTsFpg/5ajAVye7OEqhYbCq0g4aKM9bnSZlDBBYpO7Uf+9eixyXN8YEbF+YXaYj9eoWDs+npZ+sA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.1", + "@react-spring/shared": "~10.0.1", + "@react-spring/types": "~10.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.1.tgz", + "integrity": "sha512-UrzG/d6Is+9i0aCAjsjWRqIlFFiC4lFqFHrH63zK935z2YDU95TOFio4VKGISJ5SG0xq4ULy7c1V3KU+XvL+Yg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.1.tgz", + "integrity": "sha512-KR2tmjDShPruI/GGPfAZOOLvDgkhFseabjvxzZFFggJMPkyICLjO0J6mCIoGtdJSuHywZyc4Mmlgi+C88lS00g==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.1", + "@react-spring/types": "~10.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.1.tgz", + "integrity": "sha512-Fk1wYVAKL+ZTYK+4YFDpHf3Slsy59pfFFvnnTfRjQQFGlyIo4VejPtDs3CbDiuBjM135YztRyZjIH2VbycB+ZQ==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.1.tgz", + "integrity": "sha512-FgQk02OqFrYyJBTTnBTWAU0WPzkHkKXauc6aeexcvATvLapUxwnfGuLlsLYF8BYjEVfkivPT04ziAue6zyRBtQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.1", + "@react-spring/core": "~10.0.1", + "@react-spring/shared": "~10.0.1", + "@react-spring/types": "~10.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@react-stately/calendar": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.7.0.tgz", @@ -9847,6 +9922,16 @@ "react-dom": ">=16.8" } }, + "node_modules/react-use-gesture": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/react-use-gesture/-/react-use-gesture-9.1.3.tgz", + "integrity": "sha512-CdqA2SmS/fj3kkS2W8ZU8wjTbVBAIwDWaRprX7OKaj7HlGwBasGEFggmk5qNklknqk9zK/h8D355bEJFTpqEMg==", + "deprecated": "This package is no longer maintained. Please use @use-gesture/react instead", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -11311,6 +11396,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-long-press": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.3.0.tgz", + "integrity": "sha512-Yedz46ILxsb1BTS6kUzpV/wyEZPUlJDq+8Oat0LP1eOZQHbS887baJHJbIGENqCo8wTKNxmoTHLdY8lU/e+wvw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 3c1cef8c..0083b8de 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@fontsource/inter": "4.5.14", + "@react-spring/web": "10.0.1", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -74,13 +75,15 @@ "react-modal": "3.16.1", "react-range": "1.8.14", "react-router-dom": "6.20.0", + "react-use-gesture": "9.1.3", "sanitize-html": "2.12.1", "slate": "0.112.0", "slate-dom": "0.112.2", "slate-history": "0.110.3", "slate-react": "0.112.1", "tippy.js": "6.3.7", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "use-long-press": "3.3.0" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", diff --git a/src/app/components/message/behavior/DraggableMessage.tsx b/src/app/components/message/behavior/DraggableMessage.tsx new file mode 100644 index 00000000..2a1c1588 --- /dev/null +++ b/src/app/components/message/behavior/DraggableMessage.tsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; +import { useSpring, animated } from '@react-spring/web'; +import { useDrag } from 'react-use-gesture'; +import { Icon, Icons } from 'folds'; +import { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; +import { container, iconContainer, messageContent, icon } from './style.css'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; + +export function DraggableMessage({ + children, + onReply, + onEdit, + event, + mx, +}: { + children: React.ReactNode; + onReply: () => void; + onEdit: () => void; + event: MatrixEvent; + mx: MatrixClient; +}) { + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + + const canEdit = mx.getUserId() === event.getSender(); + const REPLY_THRESHOLD = 50; + const EDIT_THRESHOLD = canEdit ? 180 : Infinity; + + const [isEditVisible, setEditVisible] = useState(false); + + const [{ x, replyOpacity, iconScale }, api] = useSpring(() => ({ + x: 0, + replyOpacity: 0, + iconScale: 0.5, + config: { tension: 250, friction: 25 }, + })); + + const bind = useDrag( + ({ down, movement: [mvx] }) => { + if (!down) { + const finalDistance = Math.abs(mvx); + + if (finalDistance > EDIT_THRESHOLD) { + onEdit(); + } else if (finalDistance > REPLY_THRESHOLD) { + onReply(); + } + } + + const xTarget = down ? mvx : 0; + const distance = Math.abs(xTarget); + + setEditVisible(canEdit && distance >= EDIT_THRESHOLD); + + let newReplyOpacity = 0; + let newScale = 1.0; + + if (canEdit && (distance <= REPLY_THRESHOLD || distance >= EDIT_THRESHOLD)) { + newReplyOpacity = 0; + if (down && distance > EDIT_THRESHOLD) { + newScale = 1.1; + } + } else { + newReplyOpacity = 1; + newScale = 0.5 + (distance / REPLY_THRESHOLD) * 0.5; + } + + if (distance < 5) { + newReplyOpacity = 0; + } + + api.start({ + x: xTarget, + replyOpacity: newReplyOpacity, + iconScale: newScale, + }); + }, + { + axis: 'x', + filterTaps: true, + threshold: 10, + bounds: { right: 0 }, + } + ); + + if (isMobile) { + return ( +
+
+ `scale(${s})`), + }} + > + + + + `scale(${s})`), + }} + > + + +
+ + + {children} + +
+ ); + } + return
{children}
; +} diff --git a/src/app/components/message/behavior/style.css.ts b/src/app/components/message/behavior/style.css.ts new file mode 100644 index 00000000..39df357c --- /dev/null +++ b/src/app/components/message/behavior/style.css.ts @@ -0,0 +1,29 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + position: 'relative', + overflow: 'hidden', + width: '100%', +}); + +export const iconContainer = style({ + position: 'absolute', + top: 0, + bottom: 0, + right: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '150px', +}); + +export const messageContent = style({ + position: 'relative', + touchAction: 'pan-y', + backgroundColor: 'var(--folds-color-Background-Main)', + width: '100%', +}); + +export const icon = style({ + position: 'absolute', +}); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index a9b3f35f..290e8466 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -174,12 +174,11 @@ export const MessageTextBody = recipe({ jumboEmoji: { true: { fontSize: '1.504em', - lineHeight: '1.4962em', + lineHeight: 1.1, }, }, emote: { true: { - color: color.Success.Main, fontStyle: 'italic', }, }, diff --git a/src/app/components/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts index c3686223..59856a98 100644 --- a/src/app/components/sidebar/Sidebar.css.ts +++ b/src/app/components/sidebar/Sidebar.css.ts @@ -9,7 +9,9 @@ export const Sidebar = style([ width: toRem(66), backgroundColor: color.Background.Container, borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, - + MozUserSelect: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', display: 'flex', flexDirection: 'column', color: color.Background.OnContainer, @@ -19,6 +21,9 @@ export const Sidebar = style([ export const SidebarStack = style([ DefaultReset, { + MozUserSelect: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', width: '100%', display: 'flex', flexDirection: 'column', @@ -68,6 +73,9 @@ export const SidebarItem = recipe({ base: [ DefaultReset, { + MozUserSelect: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', minWidth: toRem(42), display: 'flex', alignItems: 'center', @@ -101,6 +109,9 @@ export const SidebarItem = recipe({ ], variants: { active: { + MozUserSelect: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', true: { selectors: { '&::before': { @@ -148,6 +159,9 @@ export type SidebarItemBadgeVariants = RecipeVariants; export const SidebarAvatar = recipe({ base: [ { + MozUserSelect: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', selectors: { 'button&': { cursor: 'pointer', @@ -158,6 +172,9 @@ export const SidebarAvatar = recipe({ variants: { size: { '200': { + MozUserSelect: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', width: toRem(16), height: toRem(16), fontSize: toRem(10), @@ -165,10 +182,16 @@ export const SidebarAvatar = recipe({ letterSpacing: config.letterSpacing.T200, }, '300': { + MozUserSelect: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', width: toRem(34), height: toRem(34), }, '400': { + MozUserSelect: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', width: toRem(42), height: toRem(42), }, diff --git a/src/app/components/virtualizer/style.css.ts b/src/app/components/virtualizer/style.css.ts index 962550cc..c93636a8 100644 --- a/src/app/components/virtualizer/style.css.ts +++ b/src/app/components/virtualizer/style.css.ts @@ -4,6 +4,9 @@ import { DefaultReset } from 'folds'; export const VirtualTile = style([ DefaultReset, { + WebkitUserSelect: 'none', + MozUserSelect: 'none', + userSelect: 'none', position: 'absolute', width: '100%', left: 0, diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index bdb81418..886301ee 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -16,9 +16,13 @@ import { RectCords, Badge, Spinner, + Tooltip, + TooltipProvider, } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; +import { useParams } from 'react-router-dom'; +import { useLongPress } from 'use-long-press'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; @@ -49,6 +53,10 @@ import { RoomNotificationMode, } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { MobileContextMenu } from '../../molecules/mobile-context-menu/MobileContextMenu'; type RoomNavItemMenuProps = { room: Room; @@ -65,6 +73,8 @@ const RoomNavItemMenu = forwardRef( const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const openRoomSettings = useOpenRoomSettings(); const space = useSpaceOptionally(); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; const handleMarkAsRead = () => { markAsRead(mx, room.roomId, hideActivity); @@ -89,7 +99,7 @@ const RoomNavItemMenu = forwardRef( }; return ( - + receipt.userId !== mx.getUserId() ); + const { navigateRoom } = useRoomNavigate(); + const { roomIdOrAlias: viewedRoomId } = useParams(); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const [isMobileSheetOpen, setMobileSheetOpen] = useState(false); const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); + + if (isMobile) { + // return; + } setMenuAnchor({ x: evt.clientX, y: evt.clientY, @@ -235,7 +254,68 @@ export function RoomNavItem({ setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; - const optionsVisible = hover || !!menuAnchor; + const handleNavItemClick: MouseEventHandler = (evt) => { + const target = evt.target as HTMLElement; + const chatButton = (evt.currentTarget as HTMLElement).querySelector( + '[data-testid="chat-button"]' + ); + if (chatButton && chatButton.contains(target)) { + return; + } + if (room.isCallRoom()) { + if (!isMobile) { + if (activeCallRoomId !== room.roomId) { + if (mx.getRoom(viewedRoomId)?.isCallRoom()) { + navigateRoom(room.roomId); + } + hangUp(room.roomId); + setActiveCallRoomId(room.roomId); + } else { + navigateRoom(room.roomId); + } + } else { + evt.stopPropagation(); + if (isChatOpen) toggleChat(); + setViewedCallRoomId(room.roomId); + navigateRoom(room.roomId); + } + } else { + navigateRoom(room.roomId); + } + }; + + const handleChatButtonClick = (evt: MouseEvent) => { + evt.stopPropagation(); + if (!isChatOpen) toggleChat(); + setViewedCallRoomId(room.roomId); + }; + + const handleCloseMenu = () => { + setMenuAnchor(undefined); + setMobileSheetOpen(false); + }; + + const optionsVisible = !isMobile && (hover || !!menuAnchor); + + const longPressBinder = useLongPress( + () => { + if (isMobile) { + setMobileSheetOpen(true); + } + }, + { + threshold: 400, + cancelOnMovement: true, + } + ); + + const menuContent = ( + + ); return ( - - - - - {showAvatar ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - - )} - - - - {room.name} - - - {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( - - - - )} - {!optionsVisible && unread && ( - - 0} count={unread.total} /> - - )} - {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( - + + + + {showAvatar ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + )} + + + + {room.name} + - - + {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( + + + + )} + {!optionsVisible && unread && ( + + 0} count={unread.total} /> + + )} + {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( + + )} + + {optionsVisible && ( - setMenuAnchor(undefined)} - notificationMode={notificationMode} - /> + {menuContent} } > + {room.isCallRoom() && ( + + Open chat + + } + > + {(triggerRef) => ( + + + + + + )} + + )} )} + {isMobile && ( + + {menuContent} + + )} ); } diff --git a/src/app/features/room/MessageOptionsMenu.tsx b/src/app/features/room/MessageOptionsMenu.tsx new file mode 100644 index 00000000..c4e145c6 --- /dev/null +++ b/src/app/features/room/MessageOptionsMenu.tsx @@ -0,0 +1,340 @@ +import { Box, Icon, IconButton, Icons, Line, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; +import React, { forwardRef, MouseEventHandler, useEffect } from 'react'; + +import FocusTrap from 'focus-trap-react'; +import classNames from 'classnames'; +import { MatrixClient, MatrixEvent, Relations, Room } from 'matrix-js-sdk'; +import { EmojiBoard } from '../../components/emoji-board'; +import { stopPropagation } from '../../utils/keyboard'; +import * as css from './message/styles.css'; + +import { + MessageAllReactionItem, + MessageCopyLinkItem, + MessageDeleteItem, + MessagePinItem, + MessageQuickReactions, + MessageReadReceiptItem, + MessageReportItem, + MessageSourceCodeItem, +} from './message/Message'; + +type BaseOptionProps = { + mEvent: MatrixEvent; + room: Room; + mx: MatrixClient; + relations: Relations | undefined; + canSendReaction: boolean | undefined; + canEdit: boolean | undefined; + canDelete: boolean | undefined; + canPinEvent: boolean | undefined; + hideReadReceipts: boolean | undefined; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string | undefined) => void; + onReplyClick: MouseEventHandler; + onEditId: ((eventId?: string | undefined) => void) | undefined; + handleAddReactions: MouseEventHandler; + closeMenu: () => void; +}; + +export const MessageDropdownMenu = forwardRef( + ( + { + mEvent, + room, + mx, + relations, + canSendReaction, + canEdit, + canDelete, + canPinEvent, + hideReadReceipts, + onReactionToggle, + onReplyClick, + onEditId, + handleAddReactions, + closeMenu, + }, + ref + ) => ( + + {canSendReaction && ( + { + onReactionToggle(mEvent.getId() ?? '', key, shortcode); + closeMenu(); + }} + /> + )} + + {canSendReaction && ( + } + radii="300" + onClick={handleAddReactions} + > + + Add Reaction + + + )} + {relations && ( + + )} + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt) => { + onReplyClick(evt); + closeMenu(); + }} + > + + Reply + + + {canEdit && onEditId && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={() => { + onEditId(mEvent.getId()); + closeMenu(); + }} + > + + Edit Message + + + )} + {!hideReadReceipts && ( + + )} + + + {canPinEvent && } + + {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( + <> + + + {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} + + + )} + + ) +); + +type ExtendedOptionsProps = BaseOptionProps & { + imagePackRooms: Room[] | undefined; + onActiveStateChange: React.Dispatch>; + menuAnchor: RectCords | undefined; + emojiBoardAnchor: RectCords | undefined; + handleOpenEmojiBoard: MouseEventHandler; + handleOpenMenu: MouseEventHandler; + setMenuAnchor: React.Dispatch>; + setEmojiBoardAnchor: React.Dispatch>; +}; + +export function MessageOptionsMenu({ + mEvent, + room, + mx, + relations, + imagePackRooms, + canSendReaction, + canEdit, + canDelete, + canPinEvent, + hideReadReceipts, + onReactionToggle, + onReplyClick, + onEditId, + onActiveStateChange, + closeMenu, + menuAnchor, + emojiBoardAnchor, + handleOpenEmojiBoard, + handleOpenMenu, + handleAddReactions, + setMenuAnchor, + setEmojiBoardAnchor, +}: ExtendedOptionsProps) { + useEffect(() => { + onActiveStateChange?.(!!menuAnchor || !!emojiBoardAnchor); + }, [emojiBoardAnchor, menuAnchor, onActiveStateChange]); + + const eventId = mEvent.getId(); + if (!eventId) return null; + + return ( +
+ + + {canSendReaction && ( + { + onReactionToggle(eventId, key); + setEmojiBoardAnchor(undefined); + }} + onCustomEmojiSelect={(mxc, shortcode) => { + onReactionToggle(eventId, mxc, shortcode); + setEmojiBoardAnchor(undefined); + }} + requestClose={() => setEmojiBoardAnchor(undefined)} + /> + } + > + + + + + )} + + + + {canEdit && onEditId && ( + onEditId(eventId)} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + )} + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt) => evt.key === 'ArrowDown', + isKeyBackward: (evt) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + } + > + + + + + + +
+ ); +} + +export function BottomSheetMenu({ + isOpen, + onClose, + children, +}: { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +}) { + return ( +
+ + ); +} + +export function MenuItemButton({ + label, + onClick, + destructive = false, +}: { + label: string; + onClick: () => void; + destructive?: boolean; +}) { + return ( + + + + {label} + + + ); +} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 05caf4b0..7b58ee6b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -435,6 +435,7 @@ export function RoomTimeline({ accessibleTagColors, }: RoomTimelineProps) { const mx = useMatrixClient(); + const { t } = useTranslation(); const useAuthentication = useMediaAuthentication(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -592,15 +593,8 @@ export function RoomTimeline({ room, useCallback( (mEvt: MatrixEvent) => { - // if user is at bottom of timeline - // keep paginating timeline and conditionally mark as read - // otherwise we update timeline without paginating - // so timeline can be updated with evt like: edits, reactions etc if (atBottomRef.current) { if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { - // Check if the document is in focus (user is actively viewing the app), - // and either there are no unread messages or the latest message is from the current user. - // If either condition is met, trigger the markAsRead function to send a read receipt. requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity)); } @@ -668,13 +662,11 @@ export function RoomTimeline({ }, [room, liveTimelineLinked]) ); - // Stay at bottom when room editor resize useResizeObserver( useMemo(() => { let mounted = false; return (entries) => { if (!mounted) { - // skip initial mounting call mounted = true; return; } @@ -742,8 +734,6 @@ export function RoomTimeline({ if (inFocus && atBottomRef.current) { if (unreadInfo?.inLiveTimeline) { handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => { - // the unread event is already in view - // so, try mark as read; if (!scrolled) { tryAutoMarkAsRead(); } @@ -757,7 +747,6 @@ export function RoomTimeline({ ) ); - // Handle up arrow edit useKeyDown( window, useCallback( @@ -788,7 +777,6 @@ export function RoomTimeline({ } }, [eventId, loadEventTimeline]); - // Scroll to bottom on initial timeline load useLayoutEffect(() => { const scrollEl = scrollRef.current; if (scrollEl) { @@ -796,8 +784,6 @@ export function RoomTimeline({ } }, []); - // if live timeline is linked and unreadInfo change - // Scroll to last read message useLayoutEffect(() => { const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {}; if (readUptoEventId && inLiveTimeline && scrollTo) { @@ -815,7 +801,6 @@ export function RoomTimeline({ } }, [room, unreadInfo, scrollToItem]); - // scroll to focused message useLayoutEffect(() => { if (focusItem && focusItem.scrollTo) { scrollToItem(focusItem.index, { @@ -834,7 +819,6 @@ export function RoomTimeline({ }, 2000); }, [alive, focusItem, scrollToItem]); - // scroll to bottom of timeline const scrollToBottomCount = scrollToBottomRef.current.count; useLayoutEffect(() => { if (scrollToBottomCount > 0) { @@ -844,14 +828,24 @@ export function RoomTimeline({ } }, [scrollToBottomCount]); - // Remove unreadInfo on mark as read useEffect(() => { if (!unread) { setUnreadInfo(undefined); } }, [unread]); - // scroll out of view msg editor in view. + const handleEdit = useCallback( + (editEvtId?: string) => { + if (editEvtId) { + setEditId(editEvtId); + return; + } + setEditId(undefined); + ReactEditor.focus(editor); + }, + [editor] + ); + useEffect(() => { if (editId) { const editMsgElement = @@ -982,18 +976,6 @@ export function RoomTimeline({ }, [mx, room] ); - const handleEdit = useCallback( - (editEvtId?: string) => { - if (editEvtId) { - setEditId(editEvtId); - return; - } - setEditId(undefined); - ReactEditor.focus(editor); - }, - [editor] - ); - const { t } = useTranslation(); const renderMatrixEvent = useMatrixEventRenderer< [string, MatrixEvent, number, EventTimelineSet, boolean] diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index ae971ab8..eea14245 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -36,6 +36,7 @@ import { MatrixEvent, Room } from 'matrix-js-sdk'; import { Relations } from 'matrix-js-sdk/lib/models/relations'; import classNames from 'classnames'; import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; +import { useLongPress } from 'use-long-press'; import { AvatarBase, BubbleLayout, @@ -65,7 +66,6 @@ import * as css from './styles.css'; import { EventReaders } from '../../../components/event-readers'; import { TextViewer } from '../../../components/text-viewer'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { EmojiBoard } from '../../../components/emoji-board'; import { ReactionViewer } from '../reaction-viewer'; import { MessageEditor } from './MessageEditor'; import { UserAvatar } from '../../../components/user-avatar'; @@ -79,6 +79,9 @@ import { StateEvent } from '../../../../types/matrix/room'; import { getTagIconSrc, PowerLevelTag } from '../../../hooks/usePowerLevelTags'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; +import { MessageOptionsMenu } from './MessageOptionsMenu'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { DraggableMessage } from '../../../components/message/behavior/DraggableMessage'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -714,16 +717,47 @@ export const Message = as<'div', MessageProps>( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const senderId = mEvent.getSender() ?? ''; - const [hover, setHover] = useState(false); - const { hoverProps } = useHover({ onHoverChange: setHover }); - const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); + const { hoverProps, isHovered } = useHover({}); + const { focusWithinProps } = useFocusWithin({}); const [menuAnchor, setMenuAnchor] = useState(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); + const [isDesktopOptionsActive, setDesktopOptionsActive] = useState(false); + const [isOptionsMenuOpen, setOptionsMenuOpen] = useState(false); + const [isEmojiBoardOpen, setEmojiBoardOpen] = useState(false); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); + const closeMenu = () => { + setOptionsMenuOpen(false); + setMenuAnchor(undefined); + }; + + const handleOpenMenu: MouseEventHandler = (evt) => { + const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget; + setMenuAnchor(target.getBoundingClientRect()); + }; + + const handleOpenEmojiBoard: MouseEventHandler = (evt) => { + const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget; + setEmojiBoardAnchor(target.getBoundingClientRect()); + }; + + const handleAddReactions: MouseEventHandler = () => { + const rect = menuAnchor; + setEmojiBoardOpen(true); + setTimeout(() => { + setEmojiBoardAnchor(rect); + }, 150); + }; + + // TODO: Remove this and clean it up later... + const button = document.createElement('button'); + button.setAttribute('data-event-id', mEvent.getId()); + const tagColor = powerLevelTag?.color ? accessibleTagColors?.get(powerLevelTag.color) : undefined; @@ -733,6 +767,18 @@ export const Message = as<'div', MessageProps>( const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; + const longPressBinder = useLongPress( + () => { + if (isMobile) { + setOptionsMenuOpen(true); + } + }, + { + threshold: 400, + cancelOnMovement: true, + } + ); + const headerJSX = !collapse && ( ( {tagIconSrc && } - {messageLayout === MessageLayout.Modern && hover && ( + {messageLayout === MessageLayout.Modern && isHovered && ( <> {senderId} @@ -833,30 +879,6 @@ export const Message = as<'div', MessageProps>( }); }; - const handleOpenMenu: MouseEventHandler = (evt) => { - const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget; - setMenuAnchor(target.getBoundingClientRect()); - }; - - const closeMenu = () => { - setMenuAnchor(undefined); - }; - - const handleOpenEmojiBoard: MouseEventHandler = (evt) => { - const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget; - setEmojiBoardAnchor(target.getBoundingClientRect()); - }; - const handleAddReactions: MouseEventHandler = () => { - const rect = menuAnchor; - closeMenu(); - // open it with timeout because closeMenu - // FocusTrap will return focus from emojiBoard - - setTimeout(() => { - setEmojiBoardAnchor(rect); - }, 100); - }; - return ( ( space={messageSpacing} collapse={collapse} highlight={highlight} - selected={!!menuAnchor || !!emojiBoardAnchor} + selected={isDesktopOptionsActive || isOptionsMenuOpen || isEmojiBoardOpen} {...props} {...hoverProps} - {...focusWithinProps} + {...(!isMobile ? focusWithinProps : {})} + {...(isMobile ? longPressBinder() : {})} ref={ref} > - {!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && ( -
- - - {canSendReaction && ( - { - onReactionToggle(mEvent.getId()!, key); - setEmojiBoardAnchor(undefined); - }} - onCustomEmojiSelect={(mxc, shortcode) => { - onReactionToggle(mEvent.getId()!, mxc, shortcode); - setEmojiBoardAnchor(undefined); - }} - requestClose={() => { - setEmojiBoardAnchor(undefined); - }} - /> - } - > - - - - - )} - - - - {canEditEvent(mx, mEvent) && onEditId && ( - onEditId(mEvent.getId())} - variant="SurfaceVariant" - size="300" - radii="300" - > - - - )} - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - {canSendReaction && ( - { - onReactionToggle(mEvent.getId()!, key, shortcode); - closeMenu(); - }} - /> - )} - - {canSendReaction && ( - } - radii="300" - onClick={handleAddReactions} - > - - Add Reaction - - - )} - {relations && ( - - )} - } - radii="300" - data-event-id={mEvent.getId()} - onClick={(evt: any) => { - onReplyClick(evt); - closeMenu(); - }} - > - - Reply - - - {canEditEvent(mx, mEvent) && onEditId && ( - } - radii="300" - data-event-id={mEvent.getId()} - onClick={() => { - onEditId(mEvent.getId()); - closeMenu(); - }} - > - - Edit Message - - - )} - {!hideReadReceipts && ( - - )} - - - {canPinEvent && ( - - )} - - {((!mEvent.isRedacted() && canDelete) || - mEvent.getSender() !== mx.getUserId()) && ( - <> - - - {!mEvent.isRedacted() && canDelete && ( - - )} - {mEvent.getSender() !== mx.getUserId() && ( - - )} - - - )} - - - } - > - - - - - - -
- )} + {!edit && + (isOptionsMenuOpen || + isEmojiBoardOpen || + isHovered || + !!menuAnchor || + !!emojiBoardAnchor) && ( + + )} {messageLayout === MessageLayout.Compact && ( - - {msgContentJSX} - + { + const mockTargetElement = document.createElement('button'); + mockTargetElement.setAttribute('data-event-id', mEvent.getId()); + const mockEvent = { + currentTarget: mockTargetElement, + }; + + onReplyClick(mockEvent); + }} + onEdit={() => { + onEditId(mEvent.getId()); + }} + mx={mx} + > + + {msgContentJSX} + + )} {messageLayout === MessageLayout.Bubble && ( - - {headerJSX} - {msgContentJSX} - + { + const mockTargetElement = document.createElement('button'); + mockTargetElement.setAttribute('data-event-id', mEvent.getId()); + const mockEvent = { + currentTarget: mockTargetElement, + }; + + onReplyClick(mockEvent); + }} + onEdit={() => { + onEditId(mEvent.getId()); + }} + mx={mx} + > + + {msgContentJSX} + + )} {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( - - {headerJSX} - {msgContentJSX} - + { + const mockTargetElement = document.createElement('button'); + mockTargetElement.setAttribute('data-event-id', mEvent.getId()); + const mockEvent = { + currentTarget: mockTargetElement, + }; + + onReplyClick(mockEvent); + }} + onEdit={() => { + onEditId(mEvent.getId()); + }} + mx={mx} + > + + {headerJSX} + {msgContentJSX} + + )}
); @@ -1155,7 +1060,7 @@ export const Event = as<'div', EventProps>( highlight={highlight} selected={!!menuAnchor} {...props} - {...hoverProps} + {...hoverProps} // Impacts hover {...focusWithinProps} ref={ref} > diff --git a/src/app/features/room/message/MessageOptionsMenu.tsx b/src/app/features/room/message/MessageOptionsMenu.tsx new file mode 100644 index 00000000..c5f85ada --- /dev/null +++ b/src/app/features/room/message/MessageOptionsMenu.tsx @@ -0,0 +1,371 @@ +import { + Box, + Header, + Icon, + IconButton, + Icons, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Text, +} from 'folds'; +import React, { forwardRef, lazy, MouseEventHandler, Suspense, useEffect, useState } from 'react'; + +import FocusTrap from 'focus-trap-react'; +import { MatrixClient, MatrixEvent, Relations, Room } from 'matrix-js-sdk'; +import { stopPropagation } from '../../../utils/keyboard'; +import * as css from './styles.css'; + +import { + MessageAllReactionItem, + MessageCopyLinkItem, + MessageDeleteItem, + MessagePinItem, + MessageQuickReactions, + MessageReadReceiptItem, + MessageReportItem, + MessageSourceCodeItem, +} from './Message'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import MobileContextMenu from '../../../molecules/mobile-context-menu/MobileContextMenu'; + +const EmojiBoard = lazy(() => + import('../../../components/emoji-board').then((module) => ({ default: module.EmojiBoard })) +); + +type BaseOptionProps = { + mEvent: MatrixEvent; + room: Room; + mx: MatrixClient; + relations: Relations | undefined; + canSendReaction: boolean | undefined; + canEdit: boolean | undefined; + canDelete: boolean | undefined; + canPinEvent: boolean | undefined; + hideReadReceipts: boolean | undefined; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string | undefined) => void; + onReplyClick: MouseEventHandler; + onEditId: ((eventId?: string | undefined) => void) | undefined; + handleAddReactions: MouseEventHandler; + closeMenu: () => void; +}; + +export const MessageDropdownMenu = forwardRef( + ( + { + mEvent, + room, + mx, + relations, + canSendReaction, + canEdit, + canDelete, + canPinEvent, + hideReadReceipts, + onReactionToggle, + onReplyClick, + onEditId, + handleAddReactions, + closeMenu, + }, + ref + ) => ( + + {canSendReaction && ( + { + onReactionToggle(mEvent.getId() ?? '', key, shortcode); + closeMenu(); + }} + /> + )} + + {canSendReaction && ( + } + radii="300" + onClick={(e) => { + handleAddReactions(e); + closeMenu(); + }} + > + + Add Reaction + + + )} + {relations && ( + {}} /> + )} + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt) => { + onReplyClick(evt); + closeMenu(); + }} + > + + Reply + + + {canEdit && onEditId && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={() => { + onEditId(mEvent.getId()); + closeMenu(); + }} + > + + Edit Message + + + )} + {!hideReadReceipts && ( + + )} + + + {canPinEvent && } + + {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( + <> + + + {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} + + + )} + + ) +); + +type ExtendedOptionsProps = BaseOptionProps & { + imagePackRooms: Room[] | undefined; + menuAnchor: RectCords | undefined; + emojiBoardAnchor: RectCords | undefined; + handleOpenEmojiBoard: MouseEventHandler; + handleOpenMenu: MouseEventHandler; + setMenuAnchor: React.Dispatch>; + setEmojiBoardAnchor: React.Dispatch>; + isOptionsMenuOpen; + setOptionsMenuOpen; + isEmojiBoardOpen; + setEmojiBoardOpen; +}; + +export function MessageOptionsMenu({ + mEvent, + room, + mx, + relations, + imagePackRooms, + canSendReaction, + canEdit, + canDelete, + canPinEvent, + hideReadReceipts, + onReactionToggle, + onReplyClick, + onEditId, + closeMenu, + menuAnchor, + emojiBoardAnchor, + handleOpenEmojiBoard, + handleOpenMenu, + handleAddReactions, + setMenuAnchor, + setEmojiBoardAnchor, + isOptionsMenuOpen, + setOptionsMenuOpen, + isEmojiBoardOpen, + setEmojiBoardOpen, +}: ExtendedOptionsProps) { + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + + const eventId = mEvent.getId(); + if (!eventId) return null; + + const optionProps: BaseOptionProps = { + mEvent, + room, + mx, + relations, + canSendReaction, + canEdit, + canDelete, + canPinEvent, + hideReadReceipts, + onReactionToggle, + onReplyClick, + onEditId, + handleAddReactions, + closeMenu, + }; + + if (isMobile) { + return ( + <> + {isOptionsMenuOpen && ( + { + closeMenu(); + }} + isOpen={isOptionsMenuOpen} + > + { + closeMenu(); + }} + handleAddReactions={handleAddReactions} + /> + + )} + + {isEmojiBoardOpen && ( + { + closeMenu(); + setEmojiBoardOpen(false); + }} + isOpen={isEmojiBoardOpen} + > +

}> + { + onReactionToggle(mEvent.getId(), key); + setEmojiBoardAnchor(undefined); + setEmojiBoardOpen(false); + (document.activeElement as HTMLElement)?.blur(); + }} + onCustomEmojiSelect={(mxc, shortcode) => { + onReactionToggle(mEvent.getId(), mxc, shortcode); + setEmojiBoardAnchor(undefined); + setEmojiBoardOpen(false); + (document.activeElement as HTMLElement)?.blur(); + }} + requestClose={() => { + setEmojiBoardAnchor(undefined); + setEmojiBoardOpen(false); + }} + /> +
+
+ )} + + ); + } + + return ( +
+ + + {canSendReaction && ( +

}> + { + onReactionToggle(eventId, key); + setEmojiBoardAnchor(undefined); + }} + onCustomEmojiSelect={(mxc, shortcode) => { + onReactionToggle(eventId, mxc, shortcode); + setEmojiBoardAnchor(undefined); + }} + requestClose={() => setEmojiBoardAnchor(undefined)} + /> + + } + > + + + +
+ )} + + + + {canEdit && onEditId && ( + onEditId(eventId)} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + )} + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt) => evt.key === 'ArrowDown', + isKeyBackward: (evt) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + } + > + + + + +
+
+
+ ); +} diff --git a/src/app/features/room/message/MobileContextMenu.tsx b/src/app/features/room/message/MobileContextMenu.tsx new file mode 100644 index 00000000..3711b72c --- /dev/null +++ b/src/app/features/room/message/MobileContextMenu.tsx @@ -0,0 +1,32 @@ +import classNames from 'classnames'; +import React from 'react'; +import * as css from './styles.css'; + +export function BottomSheetMenu({ + isOpen, + onClose, + children, +}: { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +}) { + return ( +
+ + ); +} diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index b87cb505..e0545bd6 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -1,8 +1,16 @@ import { style } from '@vanilla-extract/css'; -import { DefaultReset, config, toRem } from 'folds'; +import { DefaultReset, color, config, toRem } from 'folds'; export const MessageBase = style({ position: 'relative', + '@media': { + 'screen and (max-width: 768px)': { + userSelect: 'none', + WebkitUserSelect: 'none', + MozUserSelect: 'none', + msUserSelect: 'none', + }, + }, }); export const MessageOptionsBase = style([ diff --git a/src/app/molecules/mobile-context-menu/MobileContextMenu.jsx b/src/app/molecules/mobile-context-menu/MobileContextMenu.jsx new file mode 100644 index 00000000..b9beda87 --- /dev/null +++ b/src/app/molecules/mobile-context-menu/MobileContextMenu.jsx @@ -0,0 +1,87 @@ +import React, { useState, useEffect } from 'react'; +import { useDrag } from 'react-use-gesture'; +import './MobileContextMenu.scss'; + +export function MobileContextMenu({ isOpen, onClose, children }) { + const getInnerHeight = () => (typeof window !== 'undefined' ? window.innerHeight : 0); + const [y, setY] = useState(getInnerHeight()); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setY(isOpen ? 0 : getInnerHeight()); + }, 10); + + return () => clearTimeout(timer); + }, [isOpen]); + + + useEffect(() => { + if (isOpen) { + document.body.style.overscrollBehavior = 'contain'; + } + return () => { + document.body.style.overscrollBehavior = 'auto'; + }; + }, [isOpen]); + + const bind = useDrag( + ({ last, movement: [, my], down }) => { + if (down && !isDragging) { + setIsDragging(true); + } + + const newY = Math.max(my, 0); + setY(newY); + + if (last) { + setIsDragging(false); + if (my > getInnerHeight() / 4) { + onClose(); + } else { + setY(0); + } + } + }, + { + from: () => [0, y], + filterTaps: true, + bounds: { top: 0 }, + rubberband: true, + } + ); + + if (!isOpen && y >= getInnerHeight()) return null; + const containerClasses = [ + 'bottom-sheet-container', + !isDragging ? 'is-transitioning' : '', + ].join(' '); + + const backdropOpacity = y > 0 ? 1 - y / getInnerHeight() : 1; + + return ( + <> +
+ +
+
+
+ {children} +
+
+ + ); +} + +export default MobileContextMenu; diff --git a/src/app/molecules/mobile-context-menu/MobileContextMenu.scss b/src/app/molecules/mobile-context-menu/MobileContextMenu.scss new file mode 100644 index 00000000..326385da --- /dev/null +++ b/src/app/molecules/mobile-context-menu/MobileContextMenu.scss @@ -0,0 +1,49 @@ +.bottom-sheet-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + // The backdrop fade will also be smoother with a transition + transition: opacity 300ms ease-out; + z-index: 999; +} + +.bottom-sheet-container { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + // Set transform from the component's style prop + // The 'will-change' property is a performance hint for the browser + will-change: transform; + z-index: 1000; + + // Your existing styles for the sheet itself + border-top-left-radius: 16px; + border-top-right-radius: 16px; + box-shadow: 0 -2px A10px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + + // This is the magic: apply a transition only when this class is present + &.is-transitioning { + transition: transform 300ms ease-out; + } +} + +// Your other styles remain the same +.bottom-sheet-grabber { + width: 40px; + height: 5px; + background-color: #ccc; + border-radius: 2.5px; + margin: 8px auto; + cursor: grab; +} + +.bottom-sheet-content { + padding: 16px; + overflow-y: auto; +} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 011741ee..98f4d600 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -39,6 +39,8 @@ import { import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import FocusTrap from 'focus-trap-react'; +import { useLongPress } from 'use-long-press'; +import { createPortal } from 'react-dom'; import { useOrphanSpaces, useRecursiveChildScopeFactory, @@ -91,6 +93,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; +import { MobileContextMenu } from '../../../molecules/mobile-context-menu/MobileContextMenu'; type SpaceMenuProps = { room: Room; @@ -142,7 +145,7 @@ const SpaceMenu = forwardRef( }; return ( - + , onDragging: (item?: SidebarDraggable) => void, - dragHandleRef?: RefObject + dragHandleRef?: RefObject, + onActualDragStart?: () => void ): boolean => { const [dragging, setDragging] = useState(false); @@ -238,13 +242,16 @@ const useDraggableItem = ( onDragStart: () => { setDragging(true); onDragging?.(item); + if (typeof onActualDragStart === 'function') { + onActualDragStart(); + } }, onDrop: () => { setDragging(false); onDragging?.(undefined); }, }); - }, [targetRef, dragHandleRef, item, onDragging]); + }, [targetRef, dragHandleRef, item, onDragging, onActualDragStart]); return dragging; }; @@ -388,6 +395,11 @@ function SpaceTab({ const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const targetRef = useRef(null); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const [isMobileSheetOpen, setMobileSheetOpen] = useState(false); + + const [menuAnchor, setMenuAnchor] = useState(); const spaceDraggable: SidebarDraggable = useMemo( () => @@ -400,21 +412,47 @@ function SpaceTab({ [folder, space] ); - useDraggableItem(spaceDraggable, targetRef, onDragging); + const handleDragStart = useCallback(() => { + if (isMobileSheetOpen) { + setMenuAnchor(undefined); + setMobileSheetOpen(false); + } + }, [isMobileSheetOpen]); + + const isDragging = useDraggableItem( + spaceDraggable, + targetRef, + onDragging, + undefined, + handleDragStart + ); + const dropState = useDropTarget(spaceDraggable, targetRef); const dropType = dropState?.type; - const [menuAnchor, setMenuAnchor] = useState(); - const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); + if (!isMobile) { + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + } }; + const longPressBinder = useLongPress( + () => { + if (isMobile && !isDragging) { + setMobileSheetOpen(true); + } + }, + { + threshold: 400, + cancelOnMovement: true, + } + ); + return ( {(unread) => ( @@ -426,6 +464,7 @@ function SpaceTab({ data-drop-above={dropType === 'reorder-above'} data-drop-below={dropType === 'reorder-below'} data-inside-folder={!!folder} + {...(isMobile ? longPressBinder() : {})} > {(triggerRef) => ( @@ -479,6 +518,21 @@ function SpaceTab({ } /> )} + {createPortal( + { + setMobileSheetOpen(false); + }} + isOpen={isMobileSheetOpen} + > + setMobileSheetOpen(false)} + onUnpin={onUnpin} + /> + , + document.body + )} )}