diff --git a/package-lock.json b/package-lock.json
index 7dd2bf0a..d05ca387 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
+ "@react-spring/web": "10.0.1",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
@@ -63,13 +64,15 @@
"react-modal": "3.16.1",
"react-range": "1.8.14",
"react-router-dom": "6.20.0",
+ "react-use-gesture": "9.1.3",
"sanitize-html": "2.12.1",
"slate": "0.112.0",
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
"tippy.js": "6.3.7",
- "ua-parser-js": "1.0.35"
+ "ua-parser-js": "1.0.35",
+ "use-long-press": "3.3.0"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
@@ -3097,6 +3100,78 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
+ "node_modules/@react-spring/animated": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.1.tgz",
+ "integrity": "sha512-BGL3hA66Y8Qm3KmRZUlfG/mFbDPYajgil2/jOP0VXf2+o2WPVmcDps/eEgdDqgf5Pv9eBbyj7LschLMuSjlW3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/shared": "~10.0.1",
+ "@react-spring/types": "~10.0.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@react-spring/core": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.1.tgz",
+ "integrity": "sha512-KaMMsN1qHuVTsFpg/5ajAVye7OEqhYbCq0g4aKM9bnSZlDBBYpO7Uf+9eixyXN8YEbF+YXaYj9eoWDs+npZ+sA==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~10.0.1",
+ "@react-spring/shared": "~10.0.1",
+ "@react-spring/types": "~10.0.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-spring/donate"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@react-spring/rafz": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.1.tgz",
+ "integrity": "sha512-UrzG/d6Is+9i0aCAjsjWRqIlFFiC4lFqFHrH63zK935z2YDU95TOFio4VKGISJ5SG0xq4ULy7c1V3KU+XvL+Yg==",
+ "license": "MIT"
+ },
+ "node_modules/@react-spring/shared": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.1.tgz",
+ "integrity": "sha512-KR2tmjDShPruI/GGPfAZOOLvDgkhFseabjvxzZFFggJMPkyICLjO0J6mCIoGtdJSuHywZyc4Mmlgi+C88lS00g==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/rafz": "~10.0.1",
+ "@react-spring/types": "~10.0.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@react-spring/types": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.1.tgz",
+ "integrity": "sha512-Fk1wYVAKL+ZTYK+4YFDpHf3Slsy59pfFFvnnTfRjQQFGlyIo4VejPtDs3CbDiuBjM135YztRyZjIH2VbycB+ZQ==",
+ "license": "MIT"
+ },
+ "node_modules/@react-spring/web": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.1.tgz",
+ "integrity": "sha512-FgQk02OqFrYyJBTTnBTWAU0WPzkHkKXauc6aeexcvATvLapUxwnfGuLlsLYF8BYjEVfkivPT04ziAue6zyRBtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~10.0.1",
+ "@react-spring/core": "~10.0.1",
+ "@react-spring/shared": "~10.0.1",
+ "@react-spring/types": "~10.0.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@react-stately/calendar": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.7.0.tgz",
@@ -9847,6 +9922,16 @@
"react-dom": ">=16.8"
}
},
+ "node_modules/react-use-gesture": {
+ "version": "9.1.3",
+ "resolved": "https://registry.npmjs.org/react-use-gesture/-/react-use-gesture-9.1.3.tgz",
+ "integrity": "sha512-CdqA2SmS/fj3kkS2W8ZU8wjTbVBAIwDWaRprX7OKaj7HlGwBasGEFggmk5qNklknqk9zK/h8D355bEJFTpqEMg==",
+ "deprecated": "This package is no longer maintained. Please use @use-gesture/react instead",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -11311,6 +11396,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-long-press": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.3.0.tgz",
+ "integrity": "sha512-Yedz46ILxsb1BTS6kUzpV/wyEZPUlJDq+8Oat0LP1eOZQHbS887baJHJbIGENqCo8wTKNxmoTHLdY8lU/e+wvw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/package.json b/package.json
index 3c1cef8c..0083b8de 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
+ "@react-spring/web": "10.0.1",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
@@ -74,13 +75,15 @@
"react-modal": "3.16.1",
"react-range": "1.8.14",
"react-router-dom": "6.20.0",
+ "react-use-gesture": "9.1.3",
"sanitize-html": "2.12.1",
"slate": "0.112.0",
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
"tippy.js": "6.3.7",
- "ua-parser-js": "1.0.35"
+ "ua-parser-js": "1.0.35",
+ "use-long-press": "3.3.0"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
diff --git a/src/app/components/message/behavior/DraggableMessage.tsx b/src/app/components/message/behavior/DraggableMessage.tsx
new file mode 100644
index 00000000..2a1c1588
--- /dev/null
+++ b/src/app/components/message/behavior/DraggableMessage.tsx
@@ -0,0 +1,118 @@
+import React, { useState } from 'react';
+import { useSpring, animated } from '@react-spring/web';
+import { useDrag } from 'react-use-gesture';
+import { Icon, Icons } from 'folds';
+import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
+import { container, iconContainer, messageContent, icon } from './style.css';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+
+export function DraggableMessage({
+ children,
+ onReply,
+ onEdit,
+ event,
+ mx,
+}: {
+ children: React.ReactNode;
+ onReply: () => void;
+ onEdit: () => void;
+ event: MatrixEvent;
+ mx: MatrixClient;
+}) {
+ const screenSize = useScreenSizeContext();
+ const isMobile = screenSize === ScreenSize.Mobile;
+
+ const canEdit = mx.getUserId() === event.getSender();
+ const REPLY_THRESHOLD = 50;
+ const EDIT_THRESHOLD = canEdit ? 180 : Infinity;
+
+ const [isEditVisible, setEditVisible] = useState(false);
+
+ const [{ x, replyOpacity, iconScale }, api] = useSpring(() => ({
+ x: 0,
+ replyOpacity: 0,
+ iconScale: 0.5,
+ config: { tension: 250, friction: 25 },
+ }));
+
+ const bind = useDrag(
+ ({ down, movement: [mvx] }) => {
+ if (!down) {
+ const finalDistance = Math.abs(mvx);
+
+ if (finalDistance > EDIT_THRESHOLD) {
+ onEdit();
+ } else if (finalDistance > REPLY_THRESHOLD) {
+ onReply();
+ }
+ }
+
+ const xTarget = down ? mvx : 0;
+ const distance = Math.abs(xTarget);
+
+ setEditVisible(canEdit && distance >= EDIT_THRESHOLD);
+
+ let newReplyOpacity = 0;
+ let newScale = 1.0;
+
+ if (canEdit && (distance <= REPLY_THRESHOLD || distance >= EDIT_THRESHOLD)) {
+ newReplyOpacity = 0;
+ if (down && distance > EDIT_THRESHOLD) {
+ newScale = 1.1;
+ }
+ } else {
+ newReplyOpacity = 1;
+ newScale = 0.5 + (distance / REPLY_THRESHOLD) * 0.5;
+ }
+
+ if (distance < 5) {
+ newReplyOpacity = 0;
+ }
+
+ api.start({
+ x: xTarget,
+ replyOpacity: newReplyOpacity,
+ iconScale: newScale,
+ });
+ },
+ {
+ axis: 'x',
+ filterTaps: true,
+ threshold: 10,
+ bounds: { right: 0 },
+ }
+ );
+
+ if (isMobile) {
+ return (
+
+
+
`scale(${s})`),
+ }}
+ >
+
+
+
+
`scale(${s})`),
+ }}
+ >
+
+
+
+
+
+ {children}
+
+
+ );
+ }
+ return {children}
;
+}
diff --git a/src/app/components/message/behavior/style.css.ts b/src/app/components/message/behavior/style.css.ts
new file mode 100644
index 00000000..39df357c
--- /dev/null
+++ b/src/app/components/message/behavior/style.css.ts
@@ -0,0 +1,29 @@
+import { style } from '@vanilla-extract/css';
+
+export const container = style({
+ position: 'relative',
+ overflow: 'hidden',
+ width: '100%',
+});
+
+export const iconContainer = style({
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ right: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '150px',
+});
+
+export const messageContent = style({
+ position: 'relative',
+ touchAction: 'pan-y',
+ backgroundColor: 'var(--folds-color-Background-Main)',
+ width: '100%',
+});
+
+export const icon = style({
+ position: 'absolute',
+});
diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts
index a9b3f35f..290e8466 100644
--- a/src/app/components/message/layout/layout.css.ts
+++ b/src/app/components/message/layout/layout.css.ts
@@ -174,12 +174,11 @@ export const MessageTextBody = recipe({
jumboEmoji: {
true: {
fontSize: '1.504em',
- lineHeight: '1.4962em',
+ lineHeight: 1.1,
},
},
emote: {
true: {
- color: color.Success.Main,
fontStyle: 'italic',
},
},
diff --git a/src/app/components/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts
index c3686223..59856a98 100644
--- a/src/app/components/sidebar/Sidebar.css.ts
+++ b/src/app/components/sidebar/Sidebar.css.ts
@@ -9,7 +9,9 @@ export const Sidebar = style([
width: toRem(66),
backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
-
+ MozUserSelect: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
display: 'flex',
flexDirection: 'column',
color: color.Background.OnContainer,
@@ -19,6 +21,9 @@ export const Sidebar = style([
export const SidebarStack = style([
DefaultReset,
{
+ MozUserSelect: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
width: '100%',
display: 'flex',
flexDirection: 'column',
@@ -68,6 +73,9 @@ export const SidebarItem = recipe({
base: [
DefaultReset,
{
+ MozUserSelect: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
minWidth: toRem(42),
display: 'flex',
alignItems: 'center',
@@ -101,6 +109,9 @@ export const SidebarItem = recipe({
],
variants: {
active: {
+ MozUserSelect: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
true: {
selectors: {
'&::before': {
@@ -148,6 +159,9 @@ export type SidebarItemBadgeVariants = RecipeVariants;
export const SidebarAvatar = recipe({
base: [
{
+ MozUserSelect: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
selectors: {
'button&': {
cursor: 'pointer',
@@ -158,6 +172,9 @@ export const SidebarAvatar = recipe({
variants: {
size: {
'200': {
+ MozUserSelect: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
width: toRem(16),
height: toRem(16),
fontSize: toRem(10),
@@ -165,10 +182,16 @@ export const SidebarAvatar = recipe({
letterSpacing: config.letterSpacing.T200,
},
'300': {
+ MozUserSelect: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
width: toRem(34),
height: toRem(34),
},
'400': {
+ MozUserSelect: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
width: toRem(42),
height: toRem(42),
},
diff --git a/src/app/components/virtualizer/style.css.ts b/src/app/components/virtualizer/style.css.ts
index 962550cc..c93636a8 100644
--- a/src/app/components/virtualizer/style.css.ts
+++ b/src/app/components/virtualizer/style.css.ts
@@ -4,6 +4,9 @@ import { DefaultReset } from 'folds';
export const VirtualTile = style([
DefaultReset,
{
+ WebkitUserSelect: 'none',
+ MozUserSelect: 'none',
+ userSelect: 'none',
position: 'absolute',
width: '100%',
left: 0,
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index bdb81418..886301ee 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -16,9 +16,13 @@ import {
RectCords,
Badge,
Spinner,
+ Tooltip,
+ TooltipProvider,
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
+import { useParams } from 'react-router-dom';
+import { useLongPress } from 'use-long-press';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -49,6 +53,10 @@ import {
RoomNotificationMode,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
+import { useCallState } from '../../pages/client/call/CallProvider';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { MobileContextMenu } from '../../molecules/mobile-context-menu/MobileContextMenu';
type RoomNavItemMenuProps = {
room: Room;
@@ -65,6 +73,8 @@ const RoomNavItemMenu = forwardRef(
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const openRoomSettings = useOpenRoomSettings();
const space = useSpaceOptionally();
+ const screenSize = useScreenSizeContext();
+ const isMobile = screenSize === ScreenSize.Mobile;
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
@@ -89,7 +99,7 @@ const RoomNavItemMenu = forwardRef(
};
return (
-