This commit is contained in:
Jaggar 2025-06-27 18:14:15 -07:00 committed by GitHub
commit f7cc39b84f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1552 additions and 346 deletions

96
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@react-spring/web": "10.0.1",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@ -63,13 +64,15 @@
"react-modal": "3.16.1", "react-modal": "3.16.1",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.20.0",
"react-use-gesture": "9.1.3",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.112.0",
"slate-dom": "0.112.2", "slate-dom": "0.112.2",
"slate-history": "0.110.3", "slate-history": "0.110.3",
"slate-react": "0.112.1", "slate-react": "0.112.1",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35",
"use-long-press": "3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@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" "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": { "node_modules/@react-stately/calendar": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.7.0.tgz", "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.7.0.tgz",
@ -9847,6 +9922,16 @@
"react-dom": ">=16.8" "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": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -11311,6 +11396,15 @@
"punycode": "^2.1.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View file

@ -24,6 +24,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@react-spring/web": "10.0.1",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@ -74,13 +75,15 @@
"react-modal": "3.16.1", "react-modal": "3.16.1",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.20.0",
"react-use-gesture": "9.1.3",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.112.0",
"slate-dom": "0.112.2", "slate-dom": "0.112.2",
"slate-history": "0.110.3", "slate-history": "0.110.3",
"slate-react": "0.112.1", "slate-react": "0.112.1",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35",
"use-long-press": "3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",

View file

@ -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 (
<div className={container}>
<div className={iconContainer}>
<animated.div
className={icon}
style={{
opacity: replyOpacity,
transform: iconScale.to((s) => `scale(${s})`),
}}
>
<Icon src={Icons.ReplyArrow} size="200" />
</animated.div>
<animated.div
className={icon}
style={{
opacity: isEditVisible ? 1 : 0,
transform: iconScale.to((s) => `scale(${s})`),
}}
>
<Icon src={Icons.Pencil} size="200" />
</animated.div>
</div>
<animated.div {...bind()} className={messageContent} style={{ x }}>
{children}
</animated.div>
</div>
);
}
return <div>{children}</div>;
}

View file

@ -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',
});

View file

@ -174,12 +174,11 @@ export const MessageTextBody = recipe({
jumboEmoji: { jumboEmoji: {
true: { true: {
fontSize: '1.504em', fontSize: '1.504em',
lineHeight: '1.4962em', lineHeight: 1.1,
}, },
}, },
emote: { emote: {
true: { true: {
color: color.Success.Main,
fontStyle: 'italic', fontStyle: 'italic',
}, },
}, },

View file

@ -9,7 +9,9 @@ export const Sidebar = style([
width: toRem(66), width: toRem(66),
backgroundColor: color.Background.Container, backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
MozUserSelect: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
color: color.Background.OnContainer, color: color.Background.OnContainer,
@ -19,6 +21,9 @@ export const Sidebar = style([
export const SidebarStack = style([ export const SidebarStack = style([
DefaultReset, DefaultReset,
{ {
MozUserSelect: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -68,6 +73,9 @@ export const SidebarItem = recipe({
base: [ base: [
DefaultReset, DefaultReset,
{ {
MozUserSelect: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
minWidth: toRem(42), minWidth: toRem(42),
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -101,6 +109,9 @@ export const SidebarItem = recipe({
], ],
variants: { variants: {
active: { active: {
MozUserSelect: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
true: { true: {
selectors: { selectors: {
'&::before': { '&::before': {
@ -148,6 +159,9 @@ export type SidebarItemBadgeVariants = RecipeVariants<typeof SidebarItemBadge>;
export const SidebarAvatar = recipe({ export const SidebarAvatar = recipe({
base: [ base: [
{ {
MozUserSelect: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
selectors: { selectors: {
'button&': { 'button&': {
cursor: 'pointer', cursor: 'pointer',
@ -158,6 +172,9 @@ export const SidebarAvatar = recipe({
variants: { variants: {
size: { size: {
'200': { '200': {
MozUserSelect: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
width: toRem(16), width: toRem(16),
height: toRem(16), height: toRem(16),
fontSize: toRem(10), fontSize: toRem(10),
@ -165,10 +182,16 @@ export const SidebarAvatar = recipe({
letterSpacing: config.letterSpacing.T200, letterSpacing: config.letterSpacing.T200,
}, },
'300': { '300': {
MozUserSelect: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
width: toRem(34), width: toRem(34),
height: toRem(34), height: toRem(34),
}, },
'400': { '400': {
MozUserSelect: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
width: toRem(42), width: toRem(42),
height: toRem(42), height: toRem(42),
}, },

View file

@ -4,6 +4,9 @@ import { DefaultReset } from 'folds';
export const VirtualTile = style([ export const VirtualTile = style([
DefaultReset, DefaultReset,
{ {
WebkitUserSelect: 'none',
MozUserSelect: 'none',
userSelect: 'none',
position: 'absolute', position: 'absolute',
width: '100%', width: '100%',
left: 0, left: 0,

View file

@ -16,9 +16,13 @@ import {
RectCords, RectCords,
Badge, Badge,
Spinner, Spinner,
Tooltip,
TooltipProvider,
} from 'folds'; } from 'folds';
import { useFocusWithin, useHover } from 'react-aria'; import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react'; 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 { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@ -49,6 +53,10 @@ import {
RoomNotificationMode, RoomNotificationMode,
} from '../../hooks/useRoomsNotificationPreferences'; } from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; 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 = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@ -65,6 +73,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const openRoomSettings = useOpenRoomSettings(); const openRoomSettings = useOpenRoomSettings();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
@ -89,7 +99,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
}; };
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <Menu ref={ref}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem <MenuItem
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
@ -220,9 +230,18 @@ export function RoomNavItem({
const typingMember = useRoomTypingMember(room.roomId).filter( const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId() (receipt) => 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<HTMLElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
if (isMobile) {
// return;
}
setMenuAnchor({ setMenuAnchor({
x: evt.clientX, x: evt.clientX,
y: evt.clientY, y: evt.clientY,
@ -235,7 +254,68 @@ export function RoomNavItem({
setMenuAnchor(evt.currentTarget.getBoundingClientRect()); setMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const optionsVisible = hover || !!menuAnchor; const handleNavItemClick: MouseEventHandler<HTMLElement> = (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<HTMLButtonElement>) => {
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 = (
<RoomNavItemMenu
room={room}
requestClose={handleCloseMenu}
notificationMode={notificationMode}
/>
);
return ( return (
<NavItem <NavItem
@ -247,9 +327,9 @@ export function RoomNavItem({
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
{...hoverProps} {...hoverProps}
{...focusWithinProps} {...focusWithinProps}
{...(isMobile ? longPressBinder() : {})}
> >
<NavLink to={linkPath}> <NavItemContent onClick={handleNavItemClick}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">
{showAvatar ? ( {showAvatar ? (
@ -273,6 +353,7 @@ export function RoomNavItem({
filled={selected} filled={selected}
size="100" size="100"
joinRule={room.getJoinRule()} joinRule={room.getJoinRule()}
call={room.isCallRoom()}
/> />
)} )}
</Avatar> </Avatar>
@ -296,7 +377,6 @@ export function RoomNavItem({
)} )}
</Box> </Box>
</NavItemContent> </NavItemContent>
</NavLink>
{optionsVisible && ( {optionsVisible && (
<NavItemOptions> <NavItemOptions>
<PopOut <PopOut
@ -317,14 +397,38 @@ export function RoomNavItem({
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<RoomNavItemMenu {menuContent}
room={room}
requestClose={() => setMenuAnchor(undefined)}
notificationMode={notificationMode}
/>
</FocusTrap> </FocusTrap>
} }
> >
{room.isCallRoom() && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Open chat</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
data-testid="chat-button"
onClick={handleChatButtonClick}
aria-pressed={isChatOpen}
variant="Background"
fill="None"
size="300"
radii="300"
>
<NavLink to={linkPath}>
<Icon size="50" src={Icons.Message} />
</NavLink>
</IconButton>
)}
</TooltipProvider>
)}
<IconButton <IconButton
onClick={handleOpenMenu} onClick={handleOpenMenu}
aria-pressed={!!menuAnchor} aria-pressed={!!menuAnchor}
@ -338,6 +442,11 @@ export function RoomNavItem({
</PopOut> </PopOut>
</NavItemOptions> </NavItemOptions>
)} )}
{isMobile && (
<MobileContextMenu onClose={handleCloseMenu} isOpen={isMobileSheetOpen}>
{menuContent}
</MobileContextMenu>
)}
</NavItem> </NavItem>
); );
} }

View file

@ -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<HTMLButtonElement>;
onEditId: ((eventId?: string | undefined) => void) | undefined;
handleAddReactions: MouseEventHandler<HTMLButtonElement>;
closeMenu: () => void;
};
export const MessageDropdownMenu = forwardRef<HTMLDivElement, BaseOptionProps>(
(
{
mEvent,
room,
mx,
relations,
canSendReaction,
canEdit,
canDelete,
canPinEvent,
hideReadReceipts,
onReactionToggle,
onReplyClick,
onEditId,
handleAddReactions,
closeMenu,
},
ref
) => (
<Menu ref={ref}>
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId() ?? '', key, shortcode);
closeMenu();
}}
/>
)}
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.SmilePlus} />}
radii="300"
onClick={handleAddReactions}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Add Reaction
</Text>
</MenuItem>
)}
{relations && (
<MessageAllReactionItem room={room} relations={relations} onClose={closeMenu} />
)}
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Reply
</Text>
</MenuItem>
{canEdit && onEditId && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={() => {
onEditId(mEvent.getId());
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Edit Message
</Text>
</MenuItem>
)}
{!hideReadReceipts && (
<MessageReadReceiptItem room={room} eventId={mEvent.getId() ?? ''} onClose={closeMenu} />
)}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />}
</Box>
{((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box>
</>
)}
</Menu>
)
);
type ExtendedOptionsProps = BaseOptionProps & {
imagePackRooms: Room[] | undefined;
onActiveStateChange: React.Dispatch<React.SetStateAction<boolean>>;
menuAnchor: RectCords | undefined;
emojiBoardAnchor: RectCords | undefined;
handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement>;
handleOpenMenu: MouseEventHandler<HTMLButtonElement>;
setMenuAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
setEmojiBoardAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
};
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 (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
{canSendReaction && (
<PopOut
position="Bottom"
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
anchor={emojiBoardAnchor}
content={
<EmojiBoard
imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate={false}
allowTextCustomEmoji
onEmojiSelect={(key) => {
onReactionToggle(eventId, key);
setEmojiBoardAnchor(undefined);
}}
onCustomEmojiSelect={(mxc, shortcode) => {
onReactionToggle(eventId, mxc, shortcode);
setEmojiBoardAnchor(undefined);
}}
requestClose={() => setEmojiBoardAnchor(undefined)}
/>
}
>
<IconButton
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
radii="300"
aria-pressed={!!emojiBoardAnchor}
>
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
</PopOut>
)}
<IconButton
onClick={onReplyClick}
data-event-id={eventId}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{canEdit && onEditId && (
<IconButton
onClick={() => onEditId(eventId)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
)}
<PopOut
anchor={menuAnchor}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
offset={menuAnchor?.width === 0 ? 0 : undefined}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt) => evt.key === 'ArrowDown',
isKeyBackward: (evt) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<MessageDropdownMenu
mEvent={mEvent}
room={room}
mx={mx}
relations={relations}
canSendReaction={canSendReaction}
canEdit={canEdit}
canDelete={canDelete}
canPinEvent={canPinEvent}
hideReadReceipts={hideReadReceipts}
onReactionToggle={onReactionToggle}
onReplyClick={onReplyClick}
onEditId={onEditId}
handleAddReactions={handleAddReactions}
closeMenu={closeMenu}
/>
</FocusTrap>
}
>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
</PopOut>
</Box>
</Menu>
</div>
);
}
export function BottomSheetMenu({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: 1000, pointerEvents: isOpen ? 'auto' : 'none' }}
>
<div
className={classNames(css.menuBackdrop, { [css.menuBackdropOpen]: isOpen })}
onClick={onClose}
aria-hidden="true"
/>
<div
className={classNames(css.menuSheet, { [css.menuSheetOpen]: isOpen })}
role="dialog"
aria-modal="true"
>
{children}
</div>
</div>
);
}
export function MenuItemButton({
label,
onClick,
destructive = false,
}: {
label: string;
onClick: () => void;
destructive?: boolean;
}) {
return (
<Box
as="button"
onClick={onClick}
alignItems="Center"
gap="400"
className={classNames(css.menuItem, { [css.menuItemDestructive]: destructive })}
>
<Icon src={Icons.Alphabet} size="100" />
<Text size="B300" style={{ color: 'inherit' }}>
{label}
</Text>
</Box>
);
}

View file

@ -435,6 +435,7 @@ export function RoomTimeline({
accessibleTagColors, accessibleTagColors,
}: RoomTimelineProps) { }: RoomTimelineProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { t } = useTranslation();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
@ -592,15 +593,8 @@ export function RoomTimeline({
room, room,
useCallback( useCallback(
(mEvt: MatrixEvent) => { (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 (atBottomRef.current) {
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { 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)); requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity));
} }
@ -668,13 +662,11 @@ export function RoomTimeline({
}, [room, liveTimelineLinked]) }, [room, liveTimelineLinked])
); );
// Stay at bottom when room editor resize
useResizeObserver( useResizeObserver(
useMemo(() => { useMemo(() => {
let mounted = false; let mounted = false;
return (entries) => { return (entries) => {
if (!mounted) { if (!mounted) {
// skip initial mounting call
mounted = true; mounted = true;
return; return;
} }
@ -742,8 +734,6 @@ export function RoomTimeline({
if (inFocus && atBottomRef.current) { if (inFocus && atBottomRef.current) {
if (unreadInfo?.inLiveTimeline) { if (unreadInfo?.inLiveTimeline) {
handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => { handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
// the unread event is already in view
// so, try mark as read;
if (!scrolled) { if (!scrolled) {
tryAutoMarkAsRead(); tryAutoMarkAsRead();
} }
@ -757,7 +747,6 @@ export function RoomTimeline({
) )
); );
// Handle up arrow edit
useKeyDown( useKeyDown(
window, window,
useCallback( useCallback(
@ -788,7 +777,6 @@ export function RoomTimeline({
} }
}, [eventId, loadEventTimeline]); }, [eventId, loadEventTimeline]);
// Scroll to bottom on initial timeline load
useLayoutEffect(() => { useLayoutEffect(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (scrollEl) { if (scrollEl) {
@ -796,8 +784,6 @@ export function RoomTimeline({
} }
}, []); }, []);
// if live timeline is linked and unreadInfo change
// Scroll to last read message
useLayoutEffect(() => { useLayoutEffect(() => {
const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {}; const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
if (readUptoEventId && inLiveTimeline && scrollTo) { if (readUptoEventId && inLiveTimeline && scrollTo) {
@ -815,7 +801,6 @@ export function RoomTimeline({
} }
}, [room, unreadInfo, scrollToItem]); }, [room, unreadInfo, scrollToItem]);
// scroll to focused message
useLayoutEffect(() => { useLayoutEffect(() => {
if (focusItem && focusItem.scrollTo) { if (focusItem && focusItem.scrollTo) {
scrollToItem(focusItem.index, { scrollToItem(focusItem.index, {
@ -834,7 +819,6 @@ export function RoomTimeline({
}, 2000); }, 2000);
}, [alive, focusItem, scrollToItem]); }, [alive, focusItem, scrollToItem]);
// scroll to bottom of timeline
const scrollToBottomCount = scrollToBottomRef.current.count; const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => { useLayoutEffect(() => {
if (scrollToBottomCount > 0) { if (scrollToBottomCount > 0) {
@ -844,14 +828,24 @@ export function RoomTimeline({
} }
}, [scrollToBottomCount]); }, [scrollToBottomCount]);
// Remove unreadInfo on mark as read
useEffect(() => { useEffect(() => {
if (!unread) { if (!unread) {
setUnreadInfo(undefined); setUnreadInfo(undefined);
} }
}, [unread]); }, [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(() => { useEffect(() => {
if (editId) { if (editId) {
const editMsgElement = const editMsgElement =
@ -982,18 +976,6 @@ export function RoomTimeline({
}, },
[mx, room] [mx, room]
); );
const handleEdit = useCallback(
(editEvtId?: string) => {
if (editEvtId) {
setEditId(editEvtId);
return;
}
setEditId(undefined);
ReactEditor.focus(editor);
},
[editor]
);
const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer< const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, number, EventTimelineSet, boolean] [string, MatrixEvent, number, EventTimelineSet, boolean]

View file

@ -36,6 +36,7 @@ import { MatrixEvent, Room } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations'; import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames'; import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { useLongPress } from 'use-long-press';
import { import {
AvatarBase, AvatarBase,
BubbleLayout, BubbleLayout,
@ -65,7 +66,6 @@ import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers'; import { EventReaders } from '../../../components/event-readers';
import { TextViewer } from '../../../components/text-viewer'; import { TextViewer } from '../../../components/text-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
import { ReactionViewer } from '../reaction-viewer'; import { ReactionViewer } from '../reaction-viewer';
import { MessageEditor } from './MessageEditor'; import { MessageEditor } from './MessageEditor';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
@ -79,6 +79,9 @@ import { StateEvent } from '../../../../types/matrix/room';
import { getTagIconSrc, PowerLevelTag } from '../../../hooks/usePowerLevelTags'; import { getTagIconSrc, PowerLevelTag } from '../../../hooks/usePowerLevelTags';
import { PowerIcon } from '../../../components/power'; import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID'; 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; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@ -714,16 +717,47 @@ export const Message = as<'div', MessageProps>(
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const [hover, setHover] = useState(false); const { hoverProps, isHovered } = useHover({});
const { hoverProps } = useHover({ onHoverChange: setHover }); const { focusWithinProps } = useFocusWithin({});
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
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 = const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId); const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const closeMenu = () => {
setOptionsMenuOpen(false);
setMenuAnchor(undefined);
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setMenuAnchor(target.getBoundingClientRect());
};
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setEmojiBoardAnchor(target.getBoundingClientRect());
};
const handleAddReactions: MouseEventHandler<HTMLButtonElement> = () => {
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 const tagColor = powerLevelTag?.color
? accessibleTagColors?.get(powerLevelTag.color) ? accessibleTagColors?.get(powerLevelTag.color)
: undefined; : undefined;
@ -733,6 +767,18 @@ export const Message = as<'div', MessageProps>(
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
const longPressBinder = useLongPress(
() => {
if (isMobile) {
setOptionsMenuOpen(true);
}
},
{
threshold: 400,
cancelOnMovement: true,
}
);
const headerJSX = !collapse && ( const headerJSX = !collapse && (
<Box <Box
gap="300" gap="300"
@ -760,7 +806,7 @@ export const Message = as<'div', MessageProps>(
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />} {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box> </Box>
<Box shrink="No" gap="100"> <Box shrink="No" gap="100">
{messageLayout === MessageLayout.Modern && hover && ( {messageLayout === MessageLayout.Modern && isHovered && (
<> <>
<Text as="span" size="T200" priority="300"> <Text as="span" size="T200" priority="300">
{senderId} {senderId}
@ -833,30 +879,6 @@ export const Message = as<'div', MessageProps>(
}); });
}; };
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setMenuAnchor(target.getBoundingClientRect());
};
const closeMenu = () => {
setMenuAnchor(undefined);
};
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setEmojiBoardAnchor(target.getBoundingClientRect());
};
const handleAddReactions: MouseEventHandler<HTMLButtonElement> = () => {
const rect = menuAnchor;
closeMenu();
// open it with timeout because closeMenu
// FocusTrap will return focus from emojiBoard
setTimeout(() => {
setEmojiBoardAnchor(rect);
}, 100);
};
return ( return (
<MessageBase <MessageBase
className={classNames(css.MessageBase, className)} className={classNames(css.MessageBase, className)}
@ -864,230 +886,113 @@ export const Message = as<'div', MessageProps>(
space={messageSpacing} space={messageSpacing}
collapse={collapse} collapse={collapse}
highlight={highlight} highlight={highlight}
selected={!!menuAnchor || !!emojiBoardAnchor} selected={isDesktopOptionsActive || isOptionsMenuOpen || isEmojiBoardOpen}
{...props} {...props}
{...hoverProps} {...hoverProps}
{...focusWithinProps} {...(!isMobile ? focusWithinProps : {})}
{...(isMobile ? longPressBinder() : {})}
ref={ref} ref={ref}
> >
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && ( {!edit &&
<div className={css.MessageOptionsBase}> (isOptionsMenuOpen ||
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant"> isEmojiBoardOpen ||
<Box gap="100"> isHovered ||
{canSendReaction && ( !!menuAnchor ||
<PopOut !!emojiBoardAnchor) && (
position="Bottom" <MessageOptionsMenu
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'} mEvent={mEvent}
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
anchor={emojiBoardAnchor}
content={
<EmojiBoard
imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate={false}
allowTextCustomEmoji
onEmojiSelect={(key) => {
onReactionToggle(mEvent.getId()!, key);
setEmojiBoardAnchor(undefined);
}}
onCustomEmojiSelect={(mxc, shortcode) => {
onReactionToggle(mEvent.getId()!, mxc, shortcode);
setEmojiBoardAnchor(undefined);
}}
requestClose={() => {
setEmojiBoardAnchor(undefined);
}}
/>
}
>
<IconButton
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
radii="300"
aria-pressed={!!emojiBoardAnchor}
>
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
</PopOut>
)}
<IconButton
onClick={onReplyClick}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{canEditEvent(mx, mEvent) && onEditId && (
<IconButton
onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
)}
<PopOut
anchor={menuAnchor}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
offset={menuAnchor?.width === 0 ? 0 : undefined}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId()!, key, shortcode);
closeMenu();
}}
/>
)}
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.SmilePlus} />}
radii="300"
onClick={handleAddReactions}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Add Reaction
</Text>
</MenuItem>
)}
{relations && (
<MessageAllReactionItem
room={room} room={room}
mx={mx}
relations={relations} relations={relations}
onClose={closeMenu} imagePackRooms={imagePackRooms}
canSendReaction={canSendReaction}
canEdit={canEditEvent(mx, mEvent)}
canDelete={canDelete}
canPinEvent={canPinEvent}
hideReadReceipts={hideReadReceipts}
onReactionToggle={onReactionToggle}
onReplyClick={onReplyClick}
onEditId={onEditId}
handleAddReactions={handleAddReactions}
closeMenu={closeMenu}
emojiBoardAnchor={emojiBoardAnchor}
menuAnchor={menuAnchor}
handleOpenEmojiBoard={handleOpenEmojiBoard}
setEmojiBoardAnchor={setEmojiBoardAnchor}
setMenuAnchor={setMenuAnchor}
handleOpenMenu={handleOpenMenu}
setOptionsMenuOpen={setOptionsMenuOpen}
isOptionsMenuOpen={isOptionsMenuOpen}
setEmojiBoardOpen={setEmojiBoardOpen}
isEmojiBoardOpen={isEmojiBoardOpen}
/> />
)} )}
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Reply
</Text>
</MenuItem>
{canEditEvent(mx, mEvent) && onEditId && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={() => {
onEditId(mEvent.getId());
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Edit Message
</Text>
</MenuItem>
)}
{!hideReadReceipts && (
<MessageReadReceiptItem
room={room}
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
)}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box>
{((!mEvent.isRedacted() && canDelete) ||
mEvent.getSender() !== mx.getUserId()) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
</Box>
</>
)}
</Menu>
</FocusTrap>
}
>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
</PopOut>
</Box>
</Menu>
</div>
)}
{messageLayout === MessageLayout.Compact && ( {messageLayout === MessageLayout.Compact && (
<DraggableMessage
event={mEvent}
onReply={() => {
const mockTargetElement = document.createElement('button');
mockTargetElement.setAttribute('data-event-id', mEvent.getId());
const mockEvent = {
currentTarget: mockTargetElement,
};
onReplyClick(mockEvent);
}}
onEdit={() => {
onEditId(mEvent.getId());
}}
mx={mx}
>
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}> <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX} {msgContentJSX}
</CompactLayout> </CompactLayout>
</DraggableMessage>
)} )}
{messageLayout === MessageLayout.Bubble && ( {messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}> <DraggableMessage
{headerJSX} event={mEvent}
onReply={() => {
const mockTargetElement = document.createElement('button');
mockTargetElement.setAttribute('data-event-id', mEvent.getId());
const mockEvent = {
currentTarget: mockTargetElement,
};
onReplyClick(mockEvent);
}}
onEdit={() => {
onEditId(mEvent.getId());
}}
mx={mx}
>
<BubbleLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX} {msgContentJSX}
</BubbleLayout> </BubbleLayout>
</DraggableMessage>
)} )}
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
<DraggableMessage
event={mEvent}
onReply={() => {
const mockTargetElement = document.createElement('button');
mockTargetElement.setAttribute('data-event-id', mEvent.getId());
const mockEvent = {
currentTarget: mockTargetElement,
};
onReplyClick(mockEvent);
}}
onEdit={() => {
onEditId(mEvent.getId());
}}
mx={mx}
>
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}> <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX} {headerJSX}
{msgContentJSX} {msgContentJSX}
</ModernLayout> </ModernLayout>
</DraggableMessage>
)} )}
</MessageBase> </MessageBase>
); );
@ -1155,7 +1060,7 @@ export const Event = as<'div', EventProps>(
highlight={highlight} highlight={highlight}
selected={!!menuAnchor} selected={!!menuAnchor}
{...props} {...props}
{...hoverProps} {...hoverProps} // Impacts hover
{...focusWithinProps} {...focusWithinProps}
ref={ref} ref={ref}
> >

View file

@ -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<HTMLButtonElement>;
onEditId: ((eventId?: string | undefined) => void) | undefined;
handleAddReactions: MouseEventHandler<HTMLButtonElement>;
closeMenu: () => void;
};
export const MessageDropdownMenu = forwardRef<HTMLDivElement, BaseOptionProps>(
(
{
mEvent,
room,
mx,
relations,
canSendReaction,
canEdit,
canDelete,
canPinEvent,
hideReadReceipts,
onReactionToggle,
onReplyClick,
onEditId,
handleAddReactions,
closeMenu,
},
ref
) => (
<Menu ref={ref}>
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId() ?? '', key, shortcode);
closeMenu();
}}
/>
)}
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.SmilePlus} />}
radii="300"
onClick={(e) => {
handleAddReactions(e);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Add Reaction
</Text>
</MenuItem>
)}
{relations && (
<MessageAllReactionItem room={room} relations={relations} onClose={() => {}} />
)}
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Reply
</Text>
</MenuItem>
{canEdit && onEditId && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={() => {
onEditId(mEvent.getId());
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Edit Message
</Text>
</MenuItem>
)}
{!hideReadReceipts && (
<MessageReadReceiptItem room={room} eventId={mEvent.getId() ?? ''} onClose={closeMenu} />
)}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />}
</Box>
{((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box>
</>
)}
</Menu>
)
);
type ExtendedOptionsProps = BaseOptionProps & {
imagePackRooms: Room[] | undefined;
menuAnchor: RectCords | undefined;
emojiBoardAnchor: RectCords | undefined;
handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement>;
handleOpenMenu: MouseEventHandler<HTMLButtonElement>;
setMenuAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
setEmojiBoardAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
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 && (
<MobileContextMenu
onClose={() => {
closeMenu();
}}
isOpen={isOptionsMenuOpen}
>
<MessageDropdownMenu
{...optionProps}
closeMenu={() => {
closeMenu();
}}
handleAddReactions={handleAddReactions}
/>
</MobileContextMenu>
)}
{isEmojiBoardOpen && (
<MobileContextMenu
onClose={() => {
closeMenu();
setEmojiBoardOpen(false);
}}
isOpen={isEmojiBoardOpen}
>
<Suspense fallback={<p> </p>}>
<EmojiBoard
imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate
allowTextCustomEmoji
onEmojiSelect={(key) => {
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);
}}
/>
</Suspense>
</MobileContextMenu>
)}
</>
);
}
return (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
{canSendReaction && (
<PopOut
position="Bottom"
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
anchor={emojiBoardAnchor}
content={
<Suspense fallback={<p> </p>}>
<EmojiBoard
imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate={false}
allowTextCustomEmoji
onEmojiSelect={(key) => {
onReactionToggle(eventId, key);
setEmojiBoardAnchor(undefined);
}}
onCustomEmojiSelect={(mxc, shortcode) => {
onReactionToggle(eventId, mxc, shortcode);
setEmojiBoardAnchor(undefined);
}}
requestClose={() => setEmojiBoardAnchor(undefined)}
/>
</Suspense>
}
>
<IconButton
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
radii="300"
aria-pressed={!!emojiBoardAnchor}
>
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
</PopOut>
)}
<IconButton
onClick={onReplyClick}
data-event-id={eventId}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{canEdit && onEditId && (
<IconButton
onClick={() => onEditId(eventId)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
)}
<PopOut
anchor={menuAnchor}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
offset={menuAnchor?.width === 0 ? 0 : undefined}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt) => evt.key === 'ArrowDown',
isKeyBackward: (evt) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<MessageDropdownMenu {...optionProps} />
</FocusTrap>
}
>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
</PopOut>
</Box>
</Menu>
</div>
);
}

View file

@ -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 (
<div
style={{ position: 'fixed', inset: 0, zIndex: 1000, pointerEvents: isOpen ? 'auto' : 'none' }}
>
<div
className={classNames(css.menuBackdrop, { [css.menuBackdropOpen]: isOpen })}
onClick={onClose}
aria-hidden="true"
/>
<div
className={classNames(css.menuSheet, { [css.menuSheetOpen]: isOpen })}
role="dialog"
aria-modal="true"
>
{children}
</div>
</div>
);
}

View file

@ -1,8 +1,16 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { DefaultReset, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const MessageBase = style({ export const MessageBase = style({
position: 'relative', position: 'relative',
'@media': {
'screen and (max-width: 768px)': {
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
},
},
}); });
export const MessageOptionsBase = style([ export const MessageOptionsBase = style([

View file

@ -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 (
<>
<div
className="bottom-sheet-backdrop"
onClick={onClose}
style={{ opacity: Math.max(0, backdropOpacity) }}
/>
<div
className={containerClasses}
{...bind()}
style={{
transform: `translate3d(0, ${y}px, 0)`,
touchAction: 'none',
}}
>
<div className="bottom-sheet-grabber" />
<div className="bottom-sheet-content" style={{ overflow: 'visible' }}>
{children}
</div>
</div>
</>
);
}
export default MobileContextMenu;

View file

@ -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;
}

View file

@ -39,6 +39,8 @@ import {
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useLongPress } from 'use-long-press';
import { createPortal } from 'react-dom';
import { import {
useOrphanSpaces, useOrphanSpaces,
useRecursiveChildScopeFactory, useRecursiveChildScopeFactory,
@ -91,6 +93,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { MobileContextMenu } from '../../../molecules/mobile-context-menu/MobileContextMenu';
type SpaceMenuProps = { type SpaceMenuProps = {
room: Room; room: Room;
@ -142,7 +145,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
}; };
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <Menu ref={ref}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem <MenuItem
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
@ -221,7 +224,8 @@ const useDraggableItem = (
item: SidebarDraggable, item: SidebarDraggable,
targetRef: RefObject<HTMLElement>, targetRef: RefObject<HTMLElement>,
onDragging: (item?: SidebarDraggable) => void, onDragging: (item?: SidebarDraggable) => void,
dragHandleRef?: RefObject<HTMLElement> dragHandleRef?: RefObject<HTMLElement>,
onActualDragStart?: () => void
): boolean => { ): boolean => {
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
@ -238,13 +242,16 @@ const useDraggableItem = (
onDragStart: () => { onDragStart: () => {
setDragging(true); setDragging(true);
onDragging?.(item); onDragging?.(item);
if (typeof onActualDragStart === 'function') {
onActualDragStart();
}
}, },
onDrop: () => { onDrop: () => {
setDragging(false); setDragging(false);
onDragging?.(undefined); onDragging?.(undefined);
}, },
}); });
}, [targetRef, dragHandleRef, item, onDragging]); }, [targetRef, dragHandleRef, item, onDragging, onActualDragStart]);
return dragging; return dragging;
}; };
@ -388,6 +395,11 @@ function SpaceTab({
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const targetRef = useRef<HTMLDivElement>(null); const targetRef = useRef<HTMLDivElement>(null);
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const [isMobileSheetOpen, setMobileSheetOpen] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const spaceDraggable: SidebarDraggable = useMemo( const spaceDraggable: SidebarDraggable = useMemo(
() => () =>
@ -400,21 +412,47 @@ function SpaceTab({
[folder, space] [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 dropState = useDropTarget(spaceDraggable, targetRef);
const dropType = dropState?.type; const dropType = dropState?.type;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect(); const cords = evt.currentTarget.getBoundingClientRect();
if (!isMobile) {
setMenuAnchor((currentState) => { setMenuAnchor((currentState) => {
if (currentState) return undefined; if (currentState) return undefined;
return cords; return cords;
}); });
}
}; };
const longPressBinder = useLongPress(
() => {
if (isMobile && !isDragging) {
setMobileSheetOpen(true);
}
},
{
threshold: 400,
cancelOnMovement: true,
}
);
return ( return (
<RoomUnreadProvider roomId={space.roomId}> <RoomUnreadProvider roomId={space.roomId}>
{(unread) => ( {(unread) => (
@ -426,6 +464,7 @@ function SpaceTab({
data-drop-above={dropType === 'reorder-above'} data-drop-above={dropType === 'reorder-above'}
data-drop-below={dropType === 'reorder-below'} data-drop-below={dropType === 'reorder-below'}
data-inside-folder={!!folder} data-inside-folder={!!folder}
{...(isMobile ? longPressBinder() : {})}
> >
<SidebarItemTooltip tooltip={disabled ? undefined : space.name}> <SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
{(triggerRef) => ( {(triggerRef) => (
@ -479,6 +518,21 @@ function SpaceTab({
} }
/> />
)} )}
{createPortal(
<MobileContextMenu
onClose={() => {
setMobileSheetOpen(false);
}}
isOpen={isMobileSheetOpen}
>
<SpaceMenu
room={space}
requestClose={() => setMobileSheetOpen(false)}
onUnpin={onUnpin}
/>
</MobileContextMenu>,
document.body
)}
</SidebarItem> </SidebarItem>
)} )}
</RoomUnreadProvider> </RoomUnreadProvider>