mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 22:40: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-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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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: {
|
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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
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]
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
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 { 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([
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue