mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 14:30:29 +03:00
Merge f8dbe6f1cf into 05e83eabef
This commit is contained in:
commit
f7cc39b84f
17 changed files with 1552 additions and 346 deletions
96
package-lock.json
generated
96
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
118
src/app/components/message/behavior/DraggableMessage.tsx
Normal file
118
src/app/components/message/behavior/DraggableMessage.tsx
Normal 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>;
|
||||
}
|
||||
29
src/app/components/message/behavior/style.css.ts
Normal file
29
src/app/components/message/behavior/style.css.ts
Normal 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',
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<typeof SidebarItemBadge>;
|
|||
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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement, RoomNavItemMenuProps>(
|
|||
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<HTMLDivElement, RoomNavItemMenuProps>(
|
|||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Menu ref={ref}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
|
|
@ -220,9 +230,18 @@ export function RoomNavItem({
|
|||
const typingMember = useRoomTypingMember(room.roomId).filter(
|
||||
(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) => {
|
||||
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<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 (
|
||||
<NavItem
|
||||
|
|
@ -247,9 +327,9 @@ export function RoomNavItem({
|
|||
onContextMenu={handleContextMenu}
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
{...(isMobile ? longPressBinder() : {})}
|
||||
>
|
||||
<NavLink to={linkPath}>
|
||||
<NavItemContent>
|
||||
<NavItemContent onClick={handleNavItemClick}>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
{showAvatar ? (
|
||||
|
|
@ -273,6 +353,7 @@ export function RoomNavItem({
|
|||
filled={selected}
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
call={room.isCallRoom()}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
|
|
@ -296,7 +377,6 @@ export function RoomNavItem({
|
|||
)}
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
{optionsVisible && (
|
||||
<NavItemOptions>
|
||||
<PopOut
|
||||
|
|
@ -317,14 +397,38 @@ export function RoomNavItem({
|
|||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomNavItemMenu
|
||||
room={room}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
notificationMode={notificationMode}
|
||||
/>
|
||||
{menuContent}
|
||||
</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
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
|
|
@ -338,6 +442,11 @@ export function RoomNavItem({
|
|||
</PopOut>
|
||||
</NavItemOptions>
|
||||
)}
|
||||
{isMobile && (
|
||||
<MobileContextMenu onClose={handleCloseMenu} isOpen={isMobileSheetOpen}>
|
||||
{menuContent}
|
||||
</MobileContextMenu>
|
||||
)}
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
340
src/app/features/room/MessageOptionsMenu.tsx
Normal file
340
src/app/features/room/MessageOptionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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 =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? 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
|
||||
? 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 && (
|
||||
<Box
|
||||
gap="300"
|
||||
|
|
@ -760,7 +806,7 @@ export const Message = as<'div', MessageProps>(
|
|||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
</Box>
|
||||
<Box shrink="No" gap="100">
|
||||
{messageLayout === MessageLayout.Modern && hover && (
|
||||
{messageLayout === MessageLayout.Modern && isHovered && (
|
||||
<>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{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 (
|
||||
<MessageBase
|
||||
className={classNames(css.MessageBase, className)}
|
||||
|
|
@ -864,230 +886,113 @@ export const Message = as<'div', MessageProps>(
|
|||
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) && (
|
||||
<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(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
|
||||
{!edit &&
|
||||
(isOptionsMenuOpen ||
|
||||
isEmojiBoardOpen ||
|
||||
isHovered ||
|
||||
!!menuAnchor ||
|
||||
!!emojiBoardAnchor) && (
|
||||
<MessageOptionsMenu
|
||||
mEvent={mEvent}
|
||||
room={room}
|
||||
mx={mx}
|
||||
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 && (
|
||||
<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}>
|
||||
{msgContentJSX}
|
||||
</CompactLayout>
|
||||
</DraggableMessage>
|
||||
)}
|
||||
{messageLayout === MessageLayout.Bubble && (
|
||||
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
||||
{headerJSX}
|
||||
<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}
|
||||
>
|
||||
<BubbleLayout before={headerJSX} onContextMenu={handleContextMenu}>
|
||||
{msgContentJSX}
|
||||
</BubbleLayout>
|
||||
</DraggableMessage>
|
||||
)}
|
||||
{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}>
|
||||
{headerJSX}
|
||||
{msgContentJSX}
|
||||
</ModernLayout>
|
||||
</DraggableMessage>
|
||||
)}
|
||||
</MessageBase>
|
||||
);
|
||||
|
|
@ -1155,7 +1060,7 @@ export const Event = as<'div', EventProps>(
|
|||
highlight={highlight}
|
||||
selected={!!menuAnchor}
|
||||
{...props}
|
||||
{...hoverProps}
|
||||
{...hoverProps} // Impacts hover
|
||||
{...focusWithinProps}
|
||||
ref={ref}
|
||||
>
|
||||
|
|
|
|||
371
src/app/features/room/message/MessageOptionsMenu.tsx
Normal file
371
src/app/features/room/message/MessageOptionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/features/room/message/MobileContextMenu.tsx
Normal file
32
src/app/features/room/message/MobileContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
87
src/app/molecules/mobile-context-menu/MobileContextMenu.jsx
Normal file
87
src/app/molecules/mobile-context-menu/MobileContextMenu.jsx
Normal 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;
|
||||
49
src/app/molecules/mobile-context-menu/MobileContextMenu.scss
Normal file
49
src/app/molecules/mobile-context-menu/MobileContextMenu.scss
Normal 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;
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement, SpaceMenuProps>(
|
|||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Menu ref={ref}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
|
|
@ -221,7 +224,8 @@ const useDraggableItem = (
|
|||
item: SidebarDraggable,
|
||||
targetRef: RefObject<HTMLElement>,
|
||||
onDragging: (item?: SidebarDraggable) => void,
|
||||
dragHandleRef?: RefObject<HTMLElement>
|
||||
dragHandleRef?: RefObject<HTMLElement>,
|
||||
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<HTMLDivElement>(null);
|
||||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
const [isMobileSheetOpen, setMobileSheetOpen] = useState(false);
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
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<RectCords>();
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
if (!isMobile) {
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const longPressBinder = useLongPress(
|
||||
() => {
|
||||
if (isMobile && !isDragging) {
|
||||
setMobileSheetOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 400,
|
||||
cancelOnMovement: true,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<RoomUnreadProvider roomId={space.roomId}>
|
||||
{(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() : {})}
|
||||
>
|
||||
<SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
|
||||
{(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>
|
||||
)}
|
||||
</RoomUnreadProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue