mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	Merge f8dbe6f1cf into 05e83eabef
				
					
				
			This commit is contained in:
		
						commit
						f7cc39b84f
					
				
					 17 changed files with 1552 additions and 346 deletions
				
			
		
							
								
								
									
										96
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										96
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -13,6 +13,7 @@
 | 
			
		|||
        "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
 | 
			
		||||
        "@fontsource/inter": "4.5.14",
 | 
			
		||||
        "@react-spring/web": "10.0.1",
 | 
			
		||||
        "@tanstack/react-query": "5.24.1",
 | 
			
		||||
        "@tanstack/react-query-devtools": "5.24.1",
 | 
			
		||||
        "@tanstack/react-virtual": "3.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -63,13 +64,15 @@
 | 
			
		|||
        "react-modal": "3.16.1",
 | 
			
		||||
        "react-range": "1.8.14",
 | 
			
		||||
        "react-router-dom": "6.20.0",
 | 
			
		||||
        "react-use-gesture": "9.1.3",
 | 
			
		||||
        "sanitize-html": "2.12.1",
 | 
			
		||||
        "slate": "0.112.0",
 | 
			
		||||
        "slate-dom": "0.112.2",
 | 
			
		||||
        "slate-history": "0.110.3",
 | 
			
		||||
        "slate-react": "0.112.1",
 | 
			
		||||
        "tippy.js": "6.3.7",
 | 
			
		||||
        "ua-parser-js": "1.0.35"
 | 
			
		||||
        "ua-parser-js": "1.0.35",
 | 
			
		||||
        "use-long-press": "3.3.0"
 | 
			
		||||
      },
 | 
			
		||||
      "devDependencies": {
 | 
			
		||||
        "@esbuild-plugins/node-globals-polyfill": "0.2.3",
 | 
			
		||||
| 
						 | 
				
			
			@ -3097,6 +3100,78 @@
 | 
			
		|||
        "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@react-spring/animated": {
 | 
			
		||||
      "version": "10.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-BGL3hA66Y8Qm3KmRZUlfG/mFbDPYajgil2/jOP0VXf2+o2WPVmcDps/eEgdDqgf5Pv9eBbyj7LschLMuSjlW3Q==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@react-spring/shared": "~10.0.1",
 | 
			
		||||
        "@react-spring/types": "~10.0.1"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@react-spring/core": {
 | 
			
		||||
      "version": "10.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-KaMMsN1qHuVTsFpg/5ajAVye7OEqhYbCq0g4aKM9bnSZlDBBYpO7Uf+9eixyXN8YEbF+YXaYj9eoWDs+npZ+sA==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@react-spring/animated": "~10.0.1",
 | 
			
		||||
        "@react-spring/shared": "~10.0.1",
 | 
			
		||||
        "@react-spring/types": "~10.0.1"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "opencollective",
 | 
			
		||||
        "url": "https://opencollective.com/react-spring/donate"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@react-spring/rafz": {
 | 
			
		||||
      "version": "10.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-UrzG/d6Is+9i0aCAjsjWRqIlFFiC4lFqFHrH63zK935z2YDU95TOFio4VKGISJ5SG0xq4ULy7c1V3KU+XvL+Yg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@react-spring/shared": {
 | 
			
		||||
      "version": "10.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-KR2tmjDShPruI/GGPfAZOOLvDgkhFseabjvxzZFFggJMPkyICLjO0J6mCIoGtdJSuHywZyc4Mmlgi+C88lS00g==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@react-spring/rafz": "~10.0.1",
 | 
			
		||||
        "@react-spring/types": "~10.0.1"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@react-spring/types": {
 | 
			
		||||
      "version": "10.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-Fk1wYVAKL+ZTYK+4YFDpHf3Slsy59pfFFvnnTfRjQQFGlyIo4VejPtDs3CbDiuBjM135YztRyZjIH2VbycB+ZQ==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@react-spring/web": {
 | 
			
		||||
      "version": "10.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-FgQk02OqFrYyJBTTnBTWAU0WPzkHkKXauc6aeexcvATvLapUxwnfGuLlsLYF8BYjEVfkivPT04ziAue6zyRBtQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@react-spring/animated": "~10.0.1",
 | 
			
		||||
        "@react-spring/core": "~10.0.1",
 | 
			
		||||
        "@react-spring/shared": "~10.0.1",
 | 
			
		||||
        "@react-spring/types": "~10.0.1"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
 | 
			
		||||
        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@react-stately/calendar": {
 | 
			
		||||
      "version": "3.7.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.7.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -9847,6 +9922,16 @@
 | 
			
		|||
        "react-dom": ">=16.8"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-use-gesture": {
 | 
			
		||||
      "version": "9.1.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-use-gesture/-/react-use-gesture-9.1.3.tgz",
 | 
			
		||||
      "integrity": "sha512-CdqA2SmS/fj3kkS2W8ZU8wjTbVBAIwDWaRprX7OKaj7HlGwBasGEFggmk5qNklknqk9zK/h8D355bEJFTpqEMg==",
 | 
			
		||||
      "deprecated": "This package is no longer maintained. Please use @use-gesture/react instead",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": ">= 16.8.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/readable-stream": {
 | 
			
		||||
      "version": "3.6.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -11311,6 +11396,15 @@
 | 
			
		|||
        "punycode": "^2.1.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/use-long-press": {
 | 
			
		||||
      "version": "3.3.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.3.0.tgz",
 | 
			
		||||
      "integrity": "sha512-Yedz46ILxsb1BTS6kUzpV/wyEZPUlJDq+8Oat0LP1eOZQHbS887baJHJbIGENqCo8wTKNxmoTHLdY8lU/e+wvw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": ">=16.8.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/util-deprecate": {
 | 
			
		||||
      "version": "1.0.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,6 +24,7 @@
 | 
			
		|||
    "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
 | 
			
		||||
    "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
 | 
			
		||||
    "@fontsource/inter": "4.5.14",
 | 
			
		||||
    "@react-spring/web": "10.0.1",
 | 
			
		||||
    "@tanstack/react-query": "5.24.1",
 | 
			
		||||
    "@tanstack/react-query-devtools": "5.24.1",
 | 
			
		||||
    "@tanstack/react-virtual": "3.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -74,13 +75,15 @@
 | 
			
		|||
    "react-modal": "3.16.1",
 | 
			
		||||
    "react-range": "1.8.14",
 | 
			
		||||
    "react-router-dom": "6.20.0",
 | 
			
		||||
    "react-use-gesture": "9.1.3",
 | 
			
		||||
    "sanitize-html": "2.12.1",
 | 
			
		||||
    "slate": "0.112.0",
 | 
			
		||||
    "slate-dom": "0.112.2",
 | 
			
		||||
    "slate-history": "0.110.3",
 | 
			
		||||
    "slate-react": "0.112.1",
 | 
			
		||||
    "tippy.js": "6.3.7",
 | 
			
		||||
    "ua-parser-js": "1.0.35"
 | 
			
		||||
    "ua-parser-js": "1.0.35",
 | 
			
		||||
    "use-long-press": "3.3.0"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@esbuild-plugins/node-globals-polyfill": "0.2.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										118
									
								
								src/app/components/message/behavior/DraggableMessage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/app/components/message/behavior/DraggableMessage.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,118 @@
 | 
			
		|||
import React, { useState } from 'react';
 | 
			
		||||
import { useSpring, animated } from '@react-spring/web';
 | 
			
		||||
import { useDrag } from 'react-use-gesture';
 | 
			
		||||
import { Icon, Icons } from 'folds';
 | 
			
		||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
 | 
			
		||||
import { container, iconContainer, messageContent, icon } from './style.css';
 | 
			
		||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 | 
			
		||||
 | 
			
		||||
export function DraggableMessage({
 | 
			
		||||
  children,
 | 
			
		||||
  onReply,
 | 
			
		||||
  onEdit,
 | 
			
		||||
  event,
 | 
			
		||||
  mx,
 | 
			
		||||
}: {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  onReply: () => void;
 | 
			
		||||
  onEdit: () => void;
 | 
			
		||||
  event: MatrixEvent;
 | 
			
		||||
  mx: MatrixClient;
 | 
			
		||||
}) {
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
  const isMobile = screenSize === ScreenSize.Mobile;
 | 
			
		||||
 | 
			
		||||
  const canEdit = mx.getUserId() === event.getSender();
 | 
			
		||||
  const REPLY_THRESHOLD = 50;
 | 
			
		||||
  const EDIT_THRESHOLD = canEdit ? 180 : Infinity;
 | 
			
		||||
 | 
			
		||||
  const [isEditVisible, setEditVisible] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const [{ x, replyOpacity, iconScale }, api] = useSpring(() => ({
 | 
			
		||||
    x: 0,
 | 
			
		||||
    replyOpacity: 0,
 | 
			
		||||
    iconScale: 0.5,
 | 
			
		||||
    config: { tension: 250, friction: 25 },
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  const bind = useDrag(
 | 
			
		||||
    ({ down, movement: [mvx] }) => {
 | 
			
		||||
      if (!down) {
 | 
			
		||||
        const finalDistance = Math.abs(mvx);
 | 
			
		||||
 | 
			
		||||
        if (finalDistance > EDIT_THRESHOLD) {
 | 
			
		||||
          onEdit();
 | 
			
		||||
        } else if (finalDistance > REPLY_THRESHOLD) {
 | 
			
		||||
          onReply();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const xTarget = down ? mvx : 0;
 | 
			
		||||
      const distance = Math.abs(xTarget);
 | 
			
		||||
 | 
			
		||||
      setEditVisible(canEdit && distance >= EDIT_THRESHOLD);
 | 
			
		||||
 | 
			
		||||
      let newReplyOpacity = 0;
 | 
			
		||||
      let newScale = 1.0;
 | 
			
		||||
 | 
			
		||||
      if (canEdit && (distance <= REPLY_THRESHOLD || distance >= EDIT_THRESHOLD)) {
 | 
			
		||||
        newReplyOpacity = 0;
 | 
			
		||||
        if (down && distance > EDIT_THRESHOLD) {
 | 
			
		||||
          newScale = 1.1;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        newReplyOpacity = 1;
 | 
			
		||||
        newScale = 0.5 + (distance / REPLY_THRESHOLD) * 0.5;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (distance < 5) {
 | 
			
		||||
        newReplyOpacity = 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      api.start({
 | 
			
		||||
        x: xTarget,
 | 
			
		||||
        replyOpacity: newReplyOpacity,
 | 
			
		||||
        iconScale: newScale,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      axis: 'x',
 | 
			
		||||
      filterTaps: true,
 | 
			
		||||
      threshold: 10,
 | 
			
		||||
      bounds: { right: 0 },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (isMobile) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className={container}>
 | 
			
		||||
        <div className={iconContainer}>
 | 
			
		||||
          <animated.div
 | 
			
		||||
            className={icon}
 | 
			
		||||
            style={{
 | 
			
		||||
              opacity: replyOpacity,
 | 
			
		||||
              transform: iconScale.to((s) => `scale(${s})`),
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Icon src={Icons.ReplyArrow} size="200" />
 | 
			
		||||
          </animated.div>
 | 
			
		||||
 | 
			
		||||
          <animated.div
 | 
			
		||||
            className={icon}
 | 
			
		||||
            style={{
 | 
			
		||||
              opacity: isEditVisible ? 1 : 0,
 | 
			
		||||
              transform: iconScale.to((s) => `scale(${s})`),
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Icon src={Icons.Pencil} size="200" />
 | 
			
		||||
          </animated.div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <animated.div {...bind()} className={messageContent} style={{ x }}>
 | 
			
		||||
          {children}
 | 
			
		||||
        </animated.div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  return <div>{children}</div>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										29
									
								
								src/app/components/message/behavior/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/app/components/message/behavior/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
 | 
			
		||||
export const container = style({
 | 
			
		||||
  position: 'relative',
 | 
			
		||||
  overflow: 'hidden',
 | 
			
		||||
  width: '100%',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const iconContainer = style({
 | 
			
		||||
  position: 'absolute',
 | 
			
		||||
  top: 0,
 | 
			
		||||
  bottom: 0,
 | 
			
		||||
  right: 0,
 | 
			
		||||
  display: 'flex',
 | 
			
		||||
  alignItems: 'center',
 | 
			
		||||
  justifyContent: 'center',
 | 
			
		||||
  width: '150px',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const messageContent = style({
 | 
			
		||||
  position: 'relative',
 | 
			
		||||
  touchAction: 'pan-y',
 | 
			
		||||
  backgroundColor: 'var(--folds-color-Background-Main)',
 | 
			
		||||
  width: '100%',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const icon = style({
 | 
			
		||||
  position: 'absolute',
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -174,12 +174,11 @@ export const MessageTextBody = recipe({
 | 
			
		|||
    jumboEmoji: {
 | 
			
		||||
      true: {
 | 
			
		||||
        fontSize: '1.504em',
 | 
			
		||||
        lineHeight: '1.4962em',
 | 
			
		||||
        lineHeight: 1.1,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    emote: {
 | 
			
		||||
      true: {
 | 
			
		||||
        color: color.Success.Main,
 | 
			
		||||
        fontStyle: 'italic',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,9 @@ export const Sidebar = style([
 | 
			
		|||
    width: toRem(66),
 | 
			
		||||
    backgroundColor: color.Background.Container,
 | 
			
		||||
    borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
 | 
			
		||||
 | 
			
		||||
    MozUserSelect: 'none',
 | 
			
		||||
    WebkitUserSelect: 'none',
 | 
			
		||||
    userSelect: 'none',
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flexDirection: 'column',
 | 
			
		||||
    color: color.Background.OnContainer,
 | 
			
		||||
| 
						 | 
				
			
			@ -19,6 +21,9 @@ export const Sidebar = style([
 | 
			
		|||
export const SidebarStack = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    MozUserSelect: 'none',
 | 
			
		||||
    WebkitUserSelect: 'none',
 | 
			
		||||
    userSelect: 'none',
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flexDirection: 'column',
 | 
			
		||||
| 
						 | 
				
			
			@ -68,6 +73,9 @@ export const SidebarItem = recipe({
 | 
			
		|||
  base: [
 | 
			
		||||
    DefaultReset,
 | 
			
		||||
    {
 | 
			
		||||
      MozUserSelect: 'none',
 | 
			
		||||
      WebkitUserSelect: 'none',
 | 
			
		||||
      userSelect: 'none',
 | 
			
		||||
      minWidth: toRem(42),
 | 
			
		||||
      display: 'flex',
 | 
			
		||||
      alignItems: 'center',
 | 
			
		||||
| 
						 | 
				
			
			@ -101,6 +109,9 @@ export const SidebarItem = recipe({
 | 
			
		|||
  ],
 | 
			
		||||
  variants: {
 | 
			
		||||
    active: {
 | 
			
		||||
      MozUserSelect: 'none',
 | 
			
		||||
      WebkitUserSelect: 'none',
 | 
			
		||||
      userSelect: 'none',
 | 
			
		||||
      true: {
 | 
			
		||||
        selectors: {
 | 
			
		||||
          '&::before': {
 | 
			
		||||
| 
						 | 
				
			
			@ -148,6 +159,9 @@ export type SidebarItemBadgeVariants = RecipeVariants<typeof SidebarItemBadge>;
 | 
			
		|||
export const SidebarAvatar = recipe({
 | 
			
		||||
  base: [
 | 
			
		||||
    {
 | 
			
		||||
      MozUserSelect: 'none',
 | 
			
		||||
      WebkitUserSelect: 'none',
 | 
			
		||||
      userSelect: 'none',
 | 
			
		||||
      selectors: {
 | 
			
		||||
        'button&': {
 | 
			
		||||
          cursor: 'pointer',
 | 
			
		||||
| 
						 | 
				
			
			@ -158,6 +172,9 @@ export const SidebarAvatar = recipe({
 | 
			
		|||
  variants: {
 | 
			
		||||
    size: {
 | 
			
		||||
      '200': {
 | 
			
		||||
        MozUserSelect: 'none',
 | 
			
		||||
        WebkitUserSelect: 'none',
 | 
			
		||||
        userSelect: 'none',
 | 
			
		||||
        width: toRem(16),
 | 
			
		||||
        height: toRem(16),
 | 
			
		||||
        fontSize: toRem(10),
 | 
			
		||||
| 
						 | 
				
			
			@ -165,10 +182,16 @@ export const SidebarAvatar = recipe({
 | 
			
		|||
        letterSpacing: config.letterSpacing.T200,
 | 
			
		||||
      },
 | 
			
		||||
      '300': {
 | 
			
		||||
        MozUserSelect: 'none',
 | 
			
		||||
        WebkitUserSelect: 'none',
 | 
			
		||||
        userSelect: 'none',
 | 
			
		||||
        width: toRem(34),
 | 
			
		||||
        height: toRem(34),
 | 
			
		||||
      },
 | 
			
		||||
      '400': {
 | 
			
		||||
        MozUserSelect: 'none',
 | 
			
		||||
        WebkitUserSelect: 'none',
 | 
			
		||||
        userSelect: 'none',
 | 
			
		||||
        width: toRem(42),
 | 
			
		||||
        height: toRem(42),
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,9 @@ import { DefaultReset } from 'folds';
 | 
			
		|||
export const VirtualTile = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    WebkitUserSelect: 'none',
 | 
			
		||||
    MozUserSelect: 'none',
 | 
			
		||||
    userSelect: 'none',
 | 
			
		||||
    position: 'absolute',
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    left: 0,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,9 +16,13 @@ import {
 | 
			
		|||
  RectCords,
 | 
			
		||||
  Badge,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Tooltip,
 | 
			
		||||
  TooltipProvider,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { useFocusWithin, useHover } from 'react-aria';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { useParams } from 'react-router-dom';
 | 
			
		||||
import { useLongPress } from 'use-long-press';
 | 
			
		||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
 | 
			
		||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
 | 
			
		||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
 | 
			
		||||
| 
						 | 
				
			
			@ -49,6 +53,10 @@ import {
 | 
			
		|||
  RoomNotificationMode,
 | 
			
		||||
} from '../../hooks/useRoomsNotificationPreferences';
 | 
			
		||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
 | 
			
		||||
import { useCallState } from '../../pages/client/call/CallProvider';
 | 
			
		||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 | 
			
		||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
 | 
			
		||||
import { MobileContextMenu } from '../../molecules/mobile-context-menu/MobileContextMenu';
 | 
			
		||||
 | 
			
		||||
type RoomNavItemMenuProps = {
 | 
			
		||||
  room: Room;
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +73,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
 | 
			
		|||
    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
 | 
			
		||||
    const openRoomSettings = useOpenRoomSettings();
 | 
			
		||||
    const space = useSpaceOptionally();
 | 
			
		||||
    const screenSize = useScreenSizeContext();
 | 
			
		||||
    const isMobile = screenSize === ScreenSize.Mobile;
 | 
			
		||||
 | 
			
		||||
    const handleMarkAsRead = () => {
 | 
			
		||||
      markAsRead(mx, room.roomId, hideActivity);
 | 
			
		||||
| 
						 | 
				
			
			@ -89,7 +99,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
 | 
			
		|||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
 | 
			
		||||
      <Menu ref={ref}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            onClick={handleMarkAsRead}
 | 
			
		||||
| 
						 | 
				
			
			@ -220,9 +230,18 @@ export function RoomNavItem({
 | 
			
		|||
  const typingMember = useRoomTypingMember(room.roomId).filter(
 | 
			
		||||
    (receipt) => receipt.userId !== mx.getUserId()
 | 
			
		||||
  );
 | 
			
		||||
  const { navigateRoom } = useRoomNavigate();
 | 
			
		||||
  const { roomIdOrAlias: viewedRoomId } = useParams();
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
  const isMobile = screenSize === ScreenSize.Mobile;
 | 
			
		||||
  const [isMobileSheetOpen, setMobileSheetOpen] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
 | 
			
		||||
    if (isMobile) {
 | 
			
		||||
      //  return;
 | 
			
		||||
    }
 | 
			
		||||
    setMenuAnchor({
 | 
			
		||||
      x: evt.clientX,
 | 
			
		||||
      y: evt.clientY,
 | 
			
		||||
| 
						 | 
				
			
			@ -235,7 +254,68 @@ export function RoomNavItem({
 | 
			
		|||
    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const optionsVisible = hover || !!menuAnchor;
 | 
			
		||||
  const handleNavItemClick: MouseEventHandler<HTMLElement> = (evt) => {
 | 
			
		||||
    const target = evt.target as HTMLElement;
 | 
			
		||||
    const chatButton = (evt.currentTarget as HTMLElement).querySelector(
 | 
			
		||||
      '[data-testid="chat-button"]'
 | 
			
		||||
    );
 | 
			
		||||
    if (chatButton && chatButton.contains(target)) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (room.isCallRoom()) {
 | 
			
		||||
      if (!isMobile) {
 | 
			
		||||
        if (activeCallRoomId !== room.roomId) {
 | 
			
		||||
          if (mx.getRoom(viewedRoomId)?.isCallRoom()) {
 | 
			
		||||
            navigateRoom(room.roomId);
 | 
			
		||||
          }
 | 
			
		||||
          hangUp(room.roomId);
 | 
			
		||||
          setActiveCallRoomId(room.roomId);
 | 
			
		||||
        } else {
 | 
			
		||||
          navigateRoom(room.roomId);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        evt.stopPropagation();
 | 
			
		||||
        if (isChatOpen) toggleChat();
 | 
			
		||||
        setViewedCallRoomId(room.roomId);
 | 
			
		||||
        navigateRoom(room.roomId);
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      navigateRoom(room.roomId);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleChatButtonClick = (evt: MouseEvent<HTMLButtonElement>) => {
 | 
			
		||||
    evt.stopPropagation();
 | 
			
		||||
    if (!isChatOpen) toggleChat();
 | 
			
		||||
    setViewedCallRoomId(room.roomId);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleCloseMenu = () => {
 | 
			
		||||
    setMenuAnchor(undefined);
 | 
			
		||||
    setMobileSheetOpen(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const optionsVisible = !isMobile && (hover || !!menuAnchor);
 | 
			
		||||
 | 
			
		||||
  const longPressBinder = useLongPress(
 | 
			
		||||
    () => {
 | 
			
		||||
      if (isMobile) {
 | 
			
		||||
        setMobileSheetOpen(true);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      threshold: 400,
 | 
			
		||||
      cancelOnMovement: true,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const menuContent = (
 | 
			
		||||
    <RoomNavItemMenu
 | 
			
		||||
      room={room}
 | 
			
		||||
      requestClose={handleCloseMenu}
 | 
			
		||||
      notificationMode={notificationMode}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <NavItem
 | 
			
		||||
| 
						 | 
				
			
			@ -247,56 +327,56 @@ export function RoomNavItem({
 | 
			
		|||
      onContextMenu={handleContextMenu}
 | 
			
		||||
      {...hoverProps}
 | 
			
		||||
      {...focusWithinProps}
 | 
			
		||||
      {...(isMobile ? longPressBinder() : {})}
 | 
			
		||||
    >
 | 
			
		||||
      <NavLink to={linkPath}>
 | 
			
		||||
        <NavItemContent>
 | 
			
		||||
          <Box as="span" grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Avatar size="200" radii="400">
 | 
			
		||||
              {showAvatar ? (
 | 
			
		||||
                <RoomAvatar
 | 
			
		||||
                  roomId={room.roomId}
 | 
			
		||||
                  src={
 | 
			
		||||
                    direct
 | 
			
		||||
                      ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
 | 
			
		||||
                      : getRoomAvatarUrl(mx, room, 96, useAuthentication)
 | 
			
		||||
                  }
 | 
			
		||||
                  alt={room.name}
 | 
			
		||||
                  renderFallback={() => (
 | 
			
		||||
                    <Text as="span" size="H6">
 | 
			
		||||
                      {nameInitials(room.name)}
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  )}
 | 
			
		||||
                />
 | 
			
		||||
              ) : (
 | 
			
		||||
                <RoomIcon
 | 
			
		||||
                  style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
 | 
			
		||||
                  filled={selected}
 | 
			
		||||
                  size="100"
 | 
			
		||||
                  joinRule={room.getJoinRule()}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
            </Avatar>
 | 
			
		||||
            <Box as="span" grow="Yes">
 | 
			
		||||
              <Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
 | 
			
		||||
                {room.name}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
            {!optionsVisible && !unread && !selected && typingMember.length > 0 && (
 | 
			
		||||
              <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
 | 
			
		||||
                <TypingIndicator size="300" disableAnimation />
 | 
			
		||||
              </Badge>
 | 
			
		||||
            )}
 | 
			
		||||
            {!optionsVisible && unread && (
 | 
			
		||||
              <UnreadBadgeCenter>
 | 
			
		||||
                <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
 | 
			
		||||
              </UnreadBadgeCenter>
 | 
			
		||||
            )}
 | 
			
		||||
            {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
 | 
			
		||||
              <Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} />
 | 
			
		||||
      <NavItemContent onClick={handleNavItemClick}>
 | 
			
		||||
        <Box as="span" grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
          <Avatar size="200" radii="400">
 | 
			
		||||
            {showAvatar ? (
 | 
			
		||||
              <RoomAvatar
 | 
			
		||||
                roomId={room.roomId}
 | 
			
		||||
                src={
 | 
			
		||||
                  direct
 | 
			
		||||
                    ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
 | 
			
		||||
                    : getRoomAvatarUrl(mx, room, 96, useAuthentication)
 | 
			
		||||
                }
 | 
			
		||||
                alt={room.name}
 | 
			
		||||
                renderFallback={() => (
 | 
			
		||||
                  <Text as="span" size="H6">
 | 
			
		||||
                    {nameInitials(room.name)}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                )}
 | 
			
		||||
              />
 | 
			
		||||
            ) : (
 | 
			
		||||
              <RoomIcon
 | 
			
		||||
                style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
 | 
			
		||||
                filled={selected}
 | 
			
		||||
                size="100"
 | 
			
		||||
                joinRule={room.getJoinRule()}
 | 
			
		||||
                call={room.isCallRoom()}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
          </Avatar>
 | 
			
		||||
          <Box as="span" grow="Yes">
 | 
			
		||||
            <Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
 | 
			
		||||
              {room.name}
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </NavItemContent>
 | 
			
		||||
      </NavLink>
 | 
			
		||||
          {!optionsVisible && !unread && !selected && typingMember.length > 0 && (
 | 
			
		||||
            <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
 | 
			
		||||
              <TypingIndicator size="300" disableAnimation />
 | 
			
		||||
            </Badge>
 | 
			
		||||
          )}
 | 
			
		||||
          {!optionsVisible && unread && (
 | 
			
		||||
            <UnreadBadgeCenter>
 | 
			
		||||
              <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
 | 
			
		||||
            </UnreadBadgeCenter>
 | 
			
		||||
          )}
 | 
			
		||||
          {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
 | 
			
		||||
            <Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} />
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </NavItemContent>
 | 
			
		||||
      {optionsVisible && (
 | 
			
		||||
        <NavItemOptions>
 | 
			
		||||
          <PopOut
 | 
			
		||||
| 
						 | 
				
			
			@ -317,14 +397,38 @@ export function RoomNavItem({
 | 
			
		|||
                  escapeDeactivates: stopPropagation,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <RoomNavItemMenu
 | 
			
		||||
                  room={room}
 | 
			
		||||
                  requestClose={() => setMenuAnchor(undefined)}
 | 
			
		||||
                  notificationMode={notificationMode}
 | 
			
		||||
                />
 | 
			
		||||
                {menuContent}
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            {room.isCallRoom() && (
 | 
			
		||||
              <TooltipProvider
 | 
			
		||||
                position="Bottom"
 | 
			
		||||
                offset={4}
 | 
			
		||||
                tooltip={
 | 
			
		||||
                  <Tooltip>
 | 
			
		||||
                    <Text>Open chat</Text>
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {(triggerRef) => (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    ref={triggerRef}
 | 
			
		||||
                    data-testid="chat-button"
 | 
			
		||||
                    onClick={handleChatButtonClick}
 | 
			
		||||
                    aria-pressed={isChatOpen}
 | 
			
		||||
                    variant="Background"
 | 
			
		||||
                    fill="None"
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                  >
 | 
			
		||||
                    <NavLink to={linkPath}>
 | 
			
		||||
                      <Icon size="50" src={Icons.Message} />
 | 
			
		||||
                    </NavLink>
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )}
 | 
			
		||||
              </TooltipProvider>
 | 
			
		||||
            )}
 | 
			
		||||
            <IconButton
 | 
			
		||||
              onClick={handleOpenMenu}
 | 
			
		||||
              aria-pressed={!!menuAnchor}
 | 
			
		||||
| 
						 | 
				
			
			@ -338,6 +442,11 @@ export function RoomNavItem({
 | 
			
		|||
          </PopOut>
 | 
			
		||||
        </NavItemOptions>
 | 
			
		||||
      )}
 | 
			
		||||
      {isMobile && (
 | 
			
		||||
        <MobileContextMenu onClose={handleCloseMenu} isOpen={isMobileSheetOpen}>
 | 
			
		||||
          {menuContent}
 | 
			
		||||
        </MobileContextMenu>
 | 
			
		||||
      )}
 | 
			
		||||
    </NavItem>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										340
									
								
								src/app/features/room/MessageOptionsMenu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										340
									
								
								src/app/features/room/MessageOptionsMenu.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,340 @@
 | 
			
		|||
import { Box, Icon, IconButton, Icons, Line, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
 | 
			
		||||
import React, { forwardRef, MouseEventHandler, useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { MatrixClient, MatrixEvent, Relations, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { EmojiBoard } from '../../components/emoji-board';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
import * as css from './message/styles.css';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  MessageAllReactionItem,
 | 
			
		||||
  MessageCopyLinkItem,
 | 
			
		||||
  MessageDeleteItem,
 | 
			
		||||
  MessagePinItem,
 | 
			
		||||
  MessageQuickReactions,
 | 
			
		||||
  MessageReadReceiptItem,
 | 
			
		||||
  MessageReportItem,
 | 
			
		||||
  MessageSourceCodeItem,
 | 
			
		||||
} from './message/Message';
 | 
			
		||||
 | 
			
		||||
type BaseOptionProps = {
 | 
			
		||||
  mEvent: MatrixEvent;
 | 
			
		||||
  room: Room;
 | 
			
		||||
  mx: MatrixClient;
 | 
			
		||||
  relations: Relations | undefined;
 | 
			
		||||
  canSendReaction: boolean | undefined;
 | 
			
		||||
  canEdit: boolean | undefined;
 | 
			
		||||
  canDelete: boolean | undefined;
 | 
			
		||||
  canPinEvent: boolean | undefined;
 | 
			
		||||
  hideReadReceipts: boolean | undefined;
 | 
			
		||||
  onReactionToggle: (targetEventId: string, key: string, shortcode?: string | undefined) => void;
 | 
			
		||||
  onReplyClick: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onEditId: ((eventId?: string | undefined) => void) | undefined;
 | 
			
		||||
  handleAddReactions: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  closeMenu: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const MessageDropdownMenu = forwardRef<HTMLDivElement, BaseOptionProps>(
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      mEvent,
 | 
			
		||||
      room,
 | 
			
		||||
      mx,
 | 
			
		||||
      relations,
 | 
			
		||||
      canSendReaction,
 | 
			
		||||
      canEdit,
 | 
			
		||||
      canDelete,
 | 
			
		||||
      canPinEvent,
 | 
			
		||||
      hideReadReceipts,
 | 
			
		||||
      onReactionToggle,
 | 
			
		||||
      onReplyClick,
 | 
			
		||||
      onEditId,
 | 
			
		||||
      handleAddReactions,
 | 
			
		||||
      closeMenu,
 | 
			
		||||
    },
 | 
			
		||||
    ref
 | 
			
		||||
  ) => (
 | 
			
		||||
    <Menu ref={ref}>
 | 
			
		||||
      {canSendReaction && (
 | 
			
		||||
        <MessageQuickReactions
 | 
			
		||||
          onReaction={(key, shortcode) => {
 | 
			
		||||
            onReactionToggle(mEvent.getId() ?? '', key, shortcode);
 | 
			
		||||
            closeMenu();
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
 | 
			
		||||
        {canSendReaction && (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            size="300"
 | 
			
		||||
            after={<Icon size="100" src={Icons.SmilePlus} />}
 | 
			
		||||
            radii="300"
 | 
			
		||||
            onClick={handleAddReactions}
 | 
			
		||||
          >
 | 
			
		||||
            <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
 | 
			
		||||
              Add Reaction
 | 
			
		||||
            </Text>
 | 
			
		||||
          </MenuItem>
 | 
			
		||||
        )}
 | 
			
		||||
        {relations && (
 | 
			
		||||
          <MessageAllReactionItem room={room} relations={relations} onClose={closeMenu} />
 | 
			
		||||
        )}
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          size="300"
 | 
			
		||||
          after={<Icon size="100" src={Icons.ReplyArrow} />}
 | 
			
		||||
          radii="300"
 | 
			
		||||
          data-event-id={mEvent.getId()}
 | 
			
		||||
          onClick={(evt) => {
 | 
			
		||||
            onReplyClick(evt);
 | 
			
		||||
            closeMenu();
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
 | 
			
		||||
            Reply
 | 
			
		||||
          </Text>
 | 
			
		||||
        </MenuItem>
 | 
			
		||||
        {canEdit && onEditId && (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            size="300"
 | 
			
		||||
            after={<Icon size="100" src={Icons.Pencil} />}
 | 
			
		||||
            radii="300"
 | 
			
		||||
            data-event-id={mEvent.getId()}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              onEditId(mEvent.getId());
 | 
			
		||||
              closeMenu();
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
 | 
			
		||||
              Edit Message
 | 
			
		||||
            </Text>
 | 
			
		||||
          </MenuItem>
 | 
			
		||||
        )}
 | 
			
		||||
        {!hideReadReceipts && (
 | 
			
		||||
          <MessageReadReceiptItem room={room} eventId={mEvent.getId() ?? ''} onClose={closeMenu} />
 | 
			
		||||
        )}
 | 
			
		||||
        <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
        <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
        {canPinEvent && <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />}
 | 
			
		||||
      </Box>
 | 
			
		||||
      {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
 | 
			
		||||
        <>
 | 
			
		||||
          <Line size="300" />
 | 
			
		||||
          <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
 | 
			
		||||
            {!mEvent.isRedacted() && canDelete && (
 | 
			
		||||
              <MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
            )}
 | 
			
		||||
            {mEvent.getSender() !== mx.getUserId() && (
 | 
			
		||||
              <MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </Menu>
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type ExtendedOptionsProps = BaseOptionProps & {
 | 
			
		||||
  imagePackRooms: Room[] | undefined;
 | 
			
		||||
  onActiveStateChange: React.Dispatch<React.SetStateAction<boolean>>;
 | 
			
		||||
  menuAnchor: RectCords | undefined;
 | 
			
		||||
  emojiBoardAnchor: RectCords | undefined;
 | 
			
		||||
  handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  handleOpenMenu: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  setMenuAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
 | 
			
		||||
  setEmojiBoardAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function MessageOptionsMenu({
 | 
			
		||||
  mEvent,
 | 
			
		||||
  room,
 | 
			
		||||
  mx,
 | 
			
		||||
  relations,
 | 
			
		||||
  imagePackRooms,
 | 
			
		||||
  canSendReaction,
 | 
			
		||||
  canEdit,
 | 
			
		||||
  canDelete,
 | 
			
		||||
  canPinEvent,
 | 
			
		||||
  hideReadReceipts,
 | 
			
		||||
  onReactionToggle,
 | 
			
		||||
  onReplyClick,
 | 
			
		||||
  onEditId,
 | 
			
		||||
  onActiveStateChange,
 | 
			
		||||
  closeMenu,
 | 
			
		||||
  menuAnchor,
 | 
			
		||||
  emojiBoardAnchor,
 | 
			
		||||
  handleOpenEmojiBoard,
 | 
			
		||||
  handleOpenMenu,
 | 
			
		||||
  handleAddReactions,
 | 
			
		||||
  setMenuAnchor,
 | 
			
		||||
  setEmojiBoardAnchor,
 | 
			
		||||
}: ExtendedOptionsProps) {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    onActiveStateChange?.(!!menuAnchor || !!emojiBoardAnchor);
 | 
			
		||||
  }, [emojiBoardAnchor, menuAnchor, onActiveStateChange]);
 | 
			
		||||
 | 
			
		||||
  const eventId = mEvent.getId();
 | 
			
		||||
  if (!eventId) return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={css.MessageOptionsBase}>
 | 
			
		||||
      <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
 | 
			
		||||
        <Box gap="100">
 | 
			
		||||
          {canSendReaction && (
 | 
			
		||||
            <PopOut
 | 
			
		||||
              position="Bottom"
 | 
			
		||||
              align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
 | 
			
		||||
              offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
 | 
			
		||||
              anchor={emojiBoardAnchor}
 | 
			
		||||
              content={
 | 
			
		||||
                <EmojiBoard
 | 
			
		||||
                  imagePackRooms={imagePackRooms ?? []}
 | 
			
		||||
                  returnFocusOnDeactivate={false}
 | 
			
		||||
                  allowTextCustomEmoji
 | 
			
		||||
                  onEmojiSelect={(key) => {
 | 
			
		||||
                    onReactionToggle(eventId, key);
 | 
			
		||||
                    setEmojiBoardAnchor(undefined);
 | 
			
		||||
                  }}
 | 
			
		||||
                  onCustomEmojiSelect={(mxc, shortcode) => {
 | 
			
		||||
                    onReactionToggle(eventId, mxc, shortcode);
 | 
			
		||||
                    setEmojiBoardAnchor(undefined);
 | 
			
		||||
                  }}
 | 
			
		||||
                  requestClose={() => setEmojiBoardAnchor(undefined)}
 | 
			
		||||
                />
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
              <IconButton
 | 
			
		||||
                onClick={handleOpenEmojiBoard}
 | 
			
		||||
                variant="SurfaceVariant"
 | 
			
		||||
                size="300"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                aria-pressed={!!emojiBoardAnchor}
 | 
			
		||||
              >
 | 
			
		||||
                <Icon src={Icons.SmilePlus} size="100" />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </PopOut>
 | 
			
		||||
          )}
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={onReplyClick}
 | 
			
		||||
            data-event-id={eventId}
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            size="300"
 | 
			
		||||
            radii="300"
 | 
			
		||||
          >
 | 
			
		||||
            <Icon src={Icons.ReplyArrow} size="100" />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
          {canEdit && onEditId && (
 | 
			
		||||
            <IconButton
 | 
			
		||||
              onClick={() => onEditId(eventId)}
 | 
			
		||||
              variant="SurfaceVariant"
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
            >
 | 
			
		||||
              <Icon src={Icons.Pencil} size="100" />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          )}
 | 
			
		||||
          <PopOut
 | 
			
		||||
            anchor={menuAnchor}
 | 
			
		||||
            position="Bottom"
 | 
			
		||||
            align={menuAnchor?.width === 0 ? 'Start' : 'End'}
 | 
			
		||||
            offset={menuAnchor?.width === 0 ? 0 : undefined}
 | 
			
		||||
            content={
 | 
			
		||||
              <FocusTrap
 | 
			
		||||
                focusTrapOptions={{
 | 
			
		||||
                  initialFocus: false,
 | 
			
		||||
                  onDeactivate: () => setMenuAnchor(undefined),
 | 
			
		||||
                  clickOutsideDeactivates: true,
 | 
			
		||||
                  isKeyForward: (evt) => evt.key === 'ArrowDown',
 | 
			
		||||
                  isKeyBackward: (evt) => evt.key === 'ArrowUp',
 | 
			
		||||
                  escapeDeactivates: stopPropagation,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <MessageDropdownMenu
 | 
			
		||||
                  mEvent={mEvent}
 | 
			
		||||
                  room={room}
 | 
			
		||||
                  mx={mx}
 | 
			
		||||
                  relations={relations}
 | 
			
		||||
                  canSendReaction={canSendReaction}
 | 
			
		||||
                  canEdit={canEdit}
 | 
			
		||||
                  canDelete={canDelete}
 | 
			
		||||
                  canPinEvent={canPinEvent}
 | 
			
		||||
                  hideReadReceipts={hideReadReceipts}
 | 
			
		||||
                  onReactionToggle={onReactionToggle}
 | 
			
		||||
                  onReplyClick={onReplyClick}
 | 
			
		||||
                  onEditId={onEditId}
 | 
			
		||||
                  handleAddReactions={handleAddReactions}
 | 
			
		||||
                  closeMenu={closeMenu}
 | 
			
		||||
                />
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            <IconButton
 | 
			
		||||
              variant="SurfaceVariant"
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              onClick={handleOpenMenu}
 | 
			
		||||
              aria-pressed={!!menuAnchor}
 | 
			
		||||
            >
 | 
			
		||||
              <Icon src={Icons.VerticalDots} size="100" />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </PopOut>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Menu>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function BottomSheetMenu({
 | 
			
		||||
  isOpen,
 | 
			
		||||
  onClose,
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  isOpen: boolean;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      style={{ position: 'fixed', inset: 0, zIndex: 1000, pointerEvents: isOpen ? 'auto' : 'none' }}
 | 
			
		||||
    >
 | 
			
		||||
      <div
 | 
			
		||||
        className={classNames(css.menuBackdrop, { [css.menuBackdropOpen]: isOpen })}
 | 
			
		||||
        onClick={onClose}
 | 
			
		||||
        aria-hidden="true"
 | 
			
		||||
      />
 | 
			
		||||
      <div
 | 
			
		||||
        className={classNames(css.menuSheet, { [css.menuSheetOpen]: isOpen })}
 | 
			
		||||
        role="dialog"
 | 
			
		||||
        aria-modal="true"
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function MenuItemButton({
 | 
			
		||||
  label,
 | 
			
		||||
  onClick,
 | 
			
		||||
  destructive = false,
 | 
			
		||||
}: {
 | 
			
		||||
  label: string;
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
  destructive?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box
 | 
			
		||||
      as="button"
 | 
			
		||||
      onClick={onClick}
 | 
			
		||||
      alignItems="Center"
 | 
			
		||||
      gap="400"
 | 
			
		||||
      className={classNames(css.menuItem, { [css.menuItemDestructive]: destructive })}
 | 
			
		||||
    >
 | 
			
		||||
      <Icon src={Icons.Alphabet} size="100" />
 | 
			
		||||
      <Text size="B300" style={{ color: 'inherit' }}>
 | 
			
		||||
        {label}
 | 
			
		||||
      </Text>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -435,6 +435,7 @@ export function RoomTimeline({
 | 
			
		|||
  accessibleTagColors,
 | 
			
		||||
}: RoomTimelineProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
 | 
			
		||||
  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
 | 
			
		||||
| 
						 | 
				
			
			@ -592,15 +593,8 @@ export function RoomTimeline({
 | 
			
		|||
    room,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (mEvt: MatrixEvent) => {
 | 
			
		||||
        // if user is at bottom of timeline
 | 
			
		||||
        // keep paginating timeline and conditionally mark as read
 | 
			
		||||
        // otherwise we update timeline without paginating
 | 
			
		||||
        // so timeline can be updated with evt like: edits, reactions etc
 | 
			
		||||
        if (atBottomRef.current) {
 | 
			
		||||
          if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
 | 
			
		||||
            // Check if the document is in focus (user is actively viewing the app),
 | 
			
		||||
            // and either there are no unread messages or the latest message is from the current user.
 | 
			
		||||
            // If either condition is met, trigger the markAsRead function to send a read receipt.
 | 
			
		||||
            requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity));
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -668,13 +662,11 @@ export function RoomTimeline({
 | 
			
		|||
    }, [room, liveTimelineLinked])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Stay at bottom when room editor resize
 | 
			
		||||
  useResizeObserver(
 | 
			
		||||
    useMemo(() => {
 | 
			
		||||
      let mounted = false;
 | 
			
		||||
      return (entries) => {
 | 
			
		||||
        if (!mounted) {
 | 
			
		||||
          // skip initial mounting call
 | 
			
		||||
          mounted = true;
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -742,8 +734,6 @@ export function RoomTimeline({
 | 
			
		|||
        if (inFocus && atBottomRef.current) {
 | 
			
		||||
          if (unreadInfo?.inLiveTimeline) {
 | 
			
		||||
            handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
 | 
			
		||||
              // the unread event is already in view
 | 
			
		||||
              // so, try mark as read;
 | 
			
		||||
              if (!scrolled) {
 | 
			
		||||
                tryAutoMarkAsRead();
 | 
			
		||||
              }
 | 
			
		||||
| 
						 | 
				
			
			@ -757,7 +747,6 @@ export function RoomTimeline({
 | 
			
		|||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Handle up arrow edit
 | 
			
		||||
  useKeyDown(
 | 
			
		||||
    window,
 | 
			
		||||
    useCallback(
 | 
			
		||||
| 
						 | 
				
			
			@ -788,7 +777,6 @@ export function RoomTimeline({
 | 
			
		|||
    }
 | 
			
		||||
  }, [eventId, loadEventTimeline]);
 | 
			
		||||
 | 
			
		||||
  // Scroll to bottom on initial timeline load
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    const scrollEl = scrollRef.current;
 | 
			
		||||
    if (scrollEl) {
 | 
			
		||||
| 
						 | 
				
			
			@ -796,8 +784,6 @@ export function RoomTimeline({
 | 
			
		|||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  // if live timeline is linked and unreadInfo change
 | 
			
		||||
  // Scroll to last read message
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
 | 
			
		||||
    if (readUptoEventId && inLiveTimeline && scrollTo) {
 | 
			
		||||
| 
						 | 
				
			
			@ -815,7 +801,6 @@ export function RoomTimeline({
 | 
			
		|||
    }
 | 
			
		||||
  }, [room, unreadInfo, scrollToItem]);
 | 
			
		||||
 | 
			
		||||
  // scroll to focused message
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    if (focusItem && focusItem.scrollTo) {
 | 
			
		||||
      scrollToItem(focusItem.index, {
 | 
			
		||||
| 
						 | 
				
			
			@ -834,7 +819,6 @@ export function RoomTimeline({
 | 
			
		|||
    }, 2000);
 | 
			
		||||
  }, [alive, focusItem, scrollToItem]);
 | 
			
		||||
 | 
			
		||||
  // scroll to bottom of timeline
 | 
			
		||||
  const scrollToBottomCount = scrollToBottomRef.current.count;
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    if (scrollToBottomCount > 0) {
 | 
			
		||||
| 
						 | 
				
			
			@ -844,14 +828,24 @@ export function RoomTimeline({
 | 
			
		|||
    }
 | 
			
		||||
  }, [scrollToBottomCount]);
 | 
			
		||||
 | 
			
		||||
  // Remove unreadInfo on mark as read
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!unread) {
 | 
			
		||||
      setUnreadInfo(undefined);
 | 
			
		||||
    }
 | 
			
		||||
  }, [unread]);
 | 
			
		||||
 | 
			
		||||
  // scroll out of view msg editor in view.
 | 
			
		||||
  const handleEdit = useCallback(
 | 
			
		||||
    (editEvtId?: string) => {
 | 
			
		||||
      if (editEvtId) {
 | 
			
		||||
        setEditId(editEvtId);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      setEditId(undefined);
 | 
			
		||||
      ReactEditor.focus(editor);
 | 
			
		||||
    },
 | 
			
		||||
    [editor]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (editId) {
 | 
			
		||||
      const editMsgElement =
 | 
			
		||||
| 
						 | 
				
			
			@ -982,18 +976,6 @@ export function RoomTimeline({
 | 
			
		|||
    },
 | 
			
		||||
    [mx, room]
 | 
			
		||||
  );
 | 
			
		||||
  const handleEdit = useCallback(
 | 
			
		||||
    (editEvtId?: string) => {
 | 
			
		||||
      if (editEvtId) {
 | 
			
		||||
        setEditId(editEvtId);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      setEditId(undefined);
 | 
			
		||||
      ReactEditor.focus(editor);
 | 
			
		||||
    },
 | 
			
		||||
    [editor]
 | 
			
		||||
  );
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const renderMatrixEvent = useMatrixEventRenderer<
 | 
			
		||||
    [string, MatrixEvent, number, EventTimelineSet, boolean]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,6 +36,7 @@ import { MatrixEvent, Room } from 'matrix-js-sdk';
 | 
			
		|||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
 | 
			
		||||
import { useLongPress } from 'use-long-press';
 | 
			
		||||
import {
 | 
			
		||||
  AvatarBase,
 | 
			
		||||
  BubbleLayout,
 | 
			
		||||
| 
						 | 
				
			
			@ -65,7 +66,6 @@ import * as css from './styles.css';
 | 
			
		|||
import { EventReaders } from '../../../components/event-readers';
 | 
			
		||||
import { TextViewer } from '../../../components/text-viewer';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { EmojiBoard } from '../../../components/emoji-board';
 | 
			
		||||
import { ReactionViewer } from '../reaction-viewer';
 | 
			
		||||
import { MessageEditor } from './MessageEditor';
 | 
			
		||||
import { UserAvatar } from '../../../components/user-avatar';
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +79,9 @@ import { StateEvent } from '../../../../types/matrix/room';
 | 
			
		|||
import { getTagIconSrc, PowerLevelTag } from '../../../hooks/usePowerLevelTags';
 | 
			
		||||
import { PowerIcon } from '../../../components/power';
 | 
			
		||||
import colorMXID from '../../../../util/colorMXID';
 | 
			
		||||
import { MessageOptionsMenu } from './MessageOptionsMenu';
 | 
			
		||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 | 
			
		||||
import { DraggableMessage } from '../../../components/message/behavior/DraggableMessage';
 | 
			
		||||
 | 
			
		||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -714,16 +717,47 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
    const mx = useMatrixClient();
 | 
			
		||||
    const useAuthentication = useMediaAuthentication();
 | 
			
		||||
    const senderId = mEvent.getSender() ?? '';
 | 
			
		||||
    const [hover, setHover] = useState(false);
 | 
			
		||||
    const { hoverProps } = useHover({ onHoverChange: setHover });
 | 
			
		||||
    const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
 | 
			
		||||
    const { hoverProps, isHovered } = useHover({});
 | 
			
		||||
    const { focusWithinProps } = useFocusWithin({});
 | 
			
		||||
    const [menuAnchor, setMenuAnchor] = useState<RectCords>();
 | 
			
		||||
    const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
 | 
			
		||||
    const [isDesktopOptionsActive, setDesktopOptionsActive] = useState(false);
 | 
			
		||||
    const [isOptionsMenuOpen, setOptionsMenuOpen] = useState(false);
 | 
			
		||||
    const [isEmojiBoardOpen, setEmojiBoardOpen] = useState(false);
 | 
			
		||||
 | 
			
		||||
    const screenSize = useScreenSizeContext();
 | 
			
		||||
    const isMobile = screenSize === ScreenSize.Mobile;
 | 
			
		||||
    const senderDisplayName =
 | 
			
		||||
      getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
 | 
			
		||||
    const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
 | 
			
		||||
 | 
			
		||||
    const closeMenu = () => {
 | 
			
		||||
      setOptionsMenuOpen(false);
 | 
			
		||||
      setMenuAnchor(undefined);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
 | 
			
		||||
      setMenuAnchor(target.getBoundingClientRect());
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
 | 
			
		||||
      setEmojiBoardAnchor(target.getBoundingClientRect());
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleAddReactions: MouseEventHandler<HTMLButtonElement> = () => {
 | 
			
		||||
      const rect = menuAnchor;
 | 
			
		||||
      setEmojiBoardOpen(true);
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        setEmojiBoardAnchor(rect);
 | 
			
		||||
      }, 150);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // TODO: Remove this and clean it up later...
 | 
			
		||||
    const button = document.createElement('button');
 | 
			
		||||
    button.setAttribute('data-event-id', mEvent.getId());
 | 
			
		||||
 | 
			
		||||
    const tagColor = powerLevelTag?.color
 | 
			
		||||
      ? accessibleTagColors?.get(powerLevelTag.color)
 | 
			
		||||
      : undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -733,6 +767,18 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
 | 
			
		||||
    const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
 | 
			
		||||
 | 
			
		||||
    const longPressBinder = useLongPress(
 | 
			
		||||
      () => {
 | 
			
		||||
        if (isMobile) {
 | 
			
		||||
          setOptionsMenuOpen(true);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        threshold: 400,
 | 
			
		||||
        cancelOnMovement: true,
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const headerJSX = !collapse && (
 | 
			
		||||
      <Box
 | 
			
		||||
        gap="300"
 | 
			
		||||
| 
						 | 
				
			
			@ -760,7 +806,7 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
          {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No" gap="100">
 | 
			
		||||
          {messageLayout === MessageLayout.Modern && hover && (
 | 
			
		||||
          {messageLayout === MessageLayout.Modern && isHovered && (
 | 
			
		||||
            <>
 | 
			
		||||
              <Text as="span" size="T200" priority="300">
 | 
			
		||||
                {senderId}
 | 
			
		||||
| 
						 | 
				
			
			@ -833,30 +879,6 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
      });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
 | 
			
		||||
      setMenuAnchor(target.getBoundingClientRect());
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const closeMenu = () => {
 | 
			
		||||
      setMenuAnchor(undefined);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
 | 
			
		||||
      setEmojiBoardAnchor(target.getBoundingClientRect());
 | 
			
		||||
    };
 | 
			
		||||
    const handleAddReactions: MouseEventHandler<HTMLButtonElement> = () => {
 | 
			
		||||
      const rect = menuAnchor;
 | 
			
		||||
      closeMenu();
 | 
			
		||||
      // open it with timeout because closeMenu
 | 
			
		||||
      // FocusTrap will return focus from emojiBoard
 | 
			
		||||
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        setEmojiBoardAnchor(rect);
 | 
			
		||||
      }, 100);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <MessageBase
 | 
			
		||||
        className={classNames(css.MessageBase, className)}
 | 
			
		||||
| 
						 | 
				
			
			@ -864,230 +886,113 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
        space={messageSpacing}
 | 
			
		||||
        collapse={collapse}
 | 
			
		||||
        highlight={highlight}
 | 
			
		||||
        selected={!!menuAnchor || !!emojiBoardAnchor}
 | 
			
		||||
        selected={isDesktopOptionsActive || isOptionsMenuOpen || isEmojiBoardOpen}
 | 
			
		||||
        {...props}
 | 
			
		||||
        {...hoverProps}
 | 
			
		||||
        {...focusWithinProps}
 | 
			
		||||
        {...(!isMobile ? focusWithinProps : {})}
 | 
			
		||||
        {...(isMobile ? longPressBinder() : {})}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
      >
 | 
			
		||||
        {!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
 | 
			
		||||
          <div className={css.MessageOptionsBase}>
 | 
			
		||||
            <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
 | 
			
		||||
              <Box gap="100">
 | 
			
		||||
                {canSendReaction && (
 | 
			
		||||
                  <PopOut
 | 
			
		||||
                    position="Bottom"
 | 
			
		||||
                    align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
 | 
			
		||||
                    offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
 | 
			
		||||
                    anchor={emojiBoardAnchor}
 | 
			
		||||
                    content={
 | 
			
		||||
                      <EmojiBoard
 | 
			
		||||
                        imagePackRooms={imagePackRooms ?? []}
 | 
			
		||||
                        returnFocusOnDeactivate={false}
 | 
			
		||||
                        allowTextCustomEmoji
 | 
			
		||||
                        onEmojiSelect={(key) => {
 | 
			
		||||
                          onReactionToggle(mEvent.getId()!, key);
 | 
			
		||||
                          setEmojiBoardAnchor(undefined);
 | 
			
		||||
                        }}
 | 
			
		||||
                        onCustomEmojiSelect={(mxc, shortcode) => {
 | 
			
		||||
                          onReactionToggle(mEvent.getId()!, mxc, shortcode);
 | 
			
		||||
                          setEmojiBoardAnchor(undefined);
 | 
			
		||||
                        }}
 | 
			
		||||
                        requestClose={() => {
 | 
			
		||||
                          setEmojiBoardAnchor(undefined);
 | 
			
		||||
                        }}
 | 
			
		||||
                      />
 | 
			
		||||
                    }
 | 
			
		||||
                  >
 | 
			
		||||
                    <IconButton
 | 
			
		||||
                      onClick={handleOpenEmojiBoard}
 | 
			
		||||
                      variant="SurfaceVariant"
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      aria-pressed={!!emojiBoardAnchor}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Icon src={Icons.SmilePlus} size="100" />
 | 
			
		||||
                    </IconButton>
 | 
			
		||||
                  </PopOut>
 | 
			
		||||
                )}
 | 
			
		||||
                <IconButton
 | 
			
		||||
                  onClick={onReplyClick}
 | 
			
		||||
                  data-event-id={mEvent.getId()}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                >
 | 
			
		||||
                  <Icon src={Icons.ReplyArrow} size="100" />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
                {canEditEvent(mx, mEvent) && onEditId && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    onClick={() => onEditId(mEvent.getId())}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon src={Icons.Pencil} size="100" />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )}
 | 
			
		||||
                <PopOut
 | 
			
		||||
                  anchor={menuAnchor}
 | 
			
		||||
                  position="Bottom"
 | 
			
		||||
                  align={menuAnchor?.width === 0 ? 'Start' : 'End'}
 | 
			
		||||
                  offset={menuAnchor?.width === 0 ? 0 : undefined}
 | 
			
		||||
                  content={
 | 
			
		||||
                    <FocusTrap
 | 
			
		||||
                      focusTrapOptions={{
 | 
			
		||||
                        initialFocus: false,
 | 
			
		||||
                        onDeactivate: () => setMenuAnchor(undefined),
 | 
			
		||||
                        clickOutsideDeactivates: true,
 | 
			
		||||
                        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
 | 
			
		||||
                        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
 | 
			
		||||
                        escapeDeactivates: stopPropagation,
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Menu>
 | 
			
		||||
                        {canSendReaction && (
 | 
			
		||||
                          <MessageQuickReactions
 | 
			
		||||
                            onReaction={(key, shortcode) => {
 | 
			
		||||
                              onReactionToggle(mEvent.getId()!, key, shortcode);
 | 
			
		||||
                              closeMenu();
 | 
			
		||||
                            }}
 | 
			
		||||
                          />
 | 
			
		||||
                        )}
 | 
			
		||||
                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
 | 
			
		||||
                          {canSendReaction && (
 | 
			
		||||
                            <MenuItem
 | 
			
		||||
                              size="300"
 | 
			
		||||
                              after={<Icon size="100" src={Icons.SmilePlus} />}
 | 
			
		||||
                              radii="300"
 | 
			
		||||
                              onClick={handleAddReactions}
 | 
			
		||||
                            >
 | 
			
		||||
                              <Text
 | 
			
		||||
                                className={css.MessageMenuItemText}
 | 
			
		||||
                                as="span"
 | 
			
		||||
                                size="T300"
 | 
			
		||||
                                truncate
 | 
			
		||||
                              >
 | 
			
		||||
                                Add Reaction
 | 
			
		||||
                              </Text>
 | 
			
		||||
                            </MenuItem>
 | 
			
		||||
                          )}
 | 
			
		||||
                          {relations && (
 | 
			
		||||
                            <MessageAllReactionItem
 | 
			
		||||
                              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: 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>
 | 
			
		||||
        )}
 | 
			
		||||
        {!edit &&
 | 
			
		||||
          (isOptionsMenuOpen ||
 | 
			
		||||
            isEmojiBoardOpen ||
 | 
			
		||||
            isHovered ||
 | 
			
		||||
            !!menuAnchor ||
 | 
			
		||||
            !!emojiBoardAnchor) && (
 | 
			
		||||
            <MessageOptionsMenu
 | 
			
		||||
              mEvent={mEvent}
 | 
			
		||||
              room={room}
 | 
			
		||||
              mx={mx}
 | 
			
		||||
              relations={relations}
 | 
			
		||||
              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}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        {messageLayout === MessageLayout.Compact && (
 | 
			
		||||
          <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
            {msgContentJSX}
 | 
			
		||||
          </CompactLayout>
 | 
			
		||||
          <DraggableMessage
 | 
			
		||||
            event={mEvent}
 | 
			
		||||
            onReply={() => {
 | 
			
		||||
              const mockTargetElement = document.createElement('button');
 | 
			
		||||
              mockTargetElement.setAttribute('data-event-id', mEvent.getId());
 | 
			
		||||
              const mockEvent = {
 | 
			
		||||
                currentTarget: mockTargetElement,
 | 
			
		||||
              };
 | 
			
		||||
 | 
			
		||||
              onReplyClick(mockEvent);
 | 
			
		||||
            }}
 | 
			
		||||
            onEdit={() => {
 | 
			
		||||
              onEditId(mEvent.getId());
 | 
			
		||||
            }}
 | 
			
		||||
            mx={mx}
 | 
			
		||||
          >
 | 
			
		||||
            <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
              {msgContentJSX}
 | 
			
		||||
            </CompactLayout>
 | 
			
		||||
          </DraggableMessage>
 | 
			
		||||
        )}
 | 
			
		||||
        {messageLayout === MessageLayout.Bubble && (
 | 
			
		||||
          <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
            {headerJSX}
 | 
			
		||||
            {msgContentJSX}
 | 
			
		||||
          </BubbleLayout>
 | 
			
		||||
          <DraggableMessage
 | 
			
		||||
            event={mEvent}
 | 
			
		||||
            onReply={() => {
 | 
			
		||||
              const mockTargetElement = document.createElement('button');
 | 
			
		||||
              mockTargetElement.setAttribute('data-event-id', mEvent.getId());
 | 
			
		||||
              const mockEvent = {
 | 
			
		||||
                currentTarget: mockTargetElement,
 | 
			
		||||
              };
 | 
			
		||||
 | 
			
		||||
              onReplyClick(mockEvent);
 | 
			
		||||
            }}
 | 
			
		||||
            onEdit={() => {
 | 
			
		||||
              onEditId(mEvent.getId());
 | 
			
		||||
            }}
 | 
			
		||||
            mx={mx}
 | 
			
		||||
          >
 | 
			
		||||
            <BubbleLayout before={headerJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
              {msgContentJSX}
 | 
			
		||||
            </BubbleLayout>
 | 
			
		||||
          </DraggableMessage>
 | 
			
		||||
        )}
 | 
			
		||||
        {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
 | 
			
		||||
          <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
            {headerJSX}
 | 
			
		||||
            {msgContentJSX}
 | 
			
		||||
          </ModernLayout>
 | 
			
		||||
          <DraggableMessage
 | 
			
		||||
            event={mEvent}
 | 
			
		||||
            onReply={() => {
 | 
			
		||||
              const mockTargetElement = document.createElement('button');
 | 
			
		||||
              mockTargetElement.setAttribute('data-event-id', mEvent.getId());
 | 
			
		||||
              const mockEvent = {
 | 
			
		||||
                currentTarget: mockTargetElement,
 | 
			
		||||
              };
 | 
			
		||||
 | 
			
		||||
              onReplyClick(mockEvent);
 | 
			
		||||
            }}
 | 
			
		||||
            onEdit={() => {
 | 
			
		||||
              onEditId(mEvent.getId());
 | 
			
		||||
            }}
 | 
			
		||||
            mx={mx}
 | 
			
		||||
          >
 | 
			
		||||
            <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
              {headerJSX}
 | 
			
		||||
              {msgContentJSX}
 | 
			
		||||
            </ModernLayout>
 | 
			
		||||
          </DraggableMessage>
 | 
			
		||||
        )}
 | 
			
		||||
      </MessageBase>
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -1155,7 +1060,7 @@ export const Event = as<'div', EventProps>(
 | 
			
		|||
        highlight={highlight}
 | 
			
		||||
        selected={!!menuAnchor}
 | 
			
		||||
        {...props}
 | 
			
		||||
        {...hoverProps}
 | 
			
		||||
        {...hoverProps} // Impacts hover
 | 
			
		||||
        {...focusWithinProps}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
      >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										371
									
								
								src/app/features/room/message/MessageOptionsMenu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										371
									
								
								src/app/features/room/message/MessageOptionsMenu.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,371 @@
 | 
			
		|||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Header,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Line,
 | 
			
		||||
  Menu,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Text,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import React, { forwardRef, lazy, MouseEventHandler, Suspense, useEffect, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { MatrixClient, MatrixEvent, Relations, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  MessageAllReactionItem,
 | 
			
		||||
  MessageCopyLinkItem,
 | 
			
		||||
  MessageDeleteItem,
 | 
			
		||||
  MessagePinItem,
 | 
			
		||||
  MessageQuickReactions,
 | 
			
		||||
  MessageReadReceiptItem,
 | 
			
		||||
  MessageReportItem,
 | 
			
		||||
  MessageSourceCodeItem,
 | 
			
		||||
} from './Message';
 | 
			
		||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 | 
			
		||||
import MobileContextMenu from '../../../molecules/mobile-context-menu/MobileContextMenu';
 | 
			
		||||
 | 
			
		||||
const EmojiBoard = lazy(() =>
 | 
			
		||||
  import('../../../components/emoji-board').then((module) => ({ default: module.EmojiBoard }))
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type BaseOptionProps = {
 | 
			
		||||
  mEvent: MatrixEvent;
 | 
			
		||||
  room: Room;
 | 
			
		||||
  mx: MatrixClient;
 | 
			
		||||
  relations: Relations | undefined;
 | 
			
		||||
  canSendReaction: boolean | undefined;
 | 
			
		||||
  canEdit: boolean | undefined;
 | 
			
		||||
  canDelete: boolean | undefined;
 | 
			
		||||
  canPinEvent: boolean | undefined;
 | 
			
		||||
  hideReadReceipts: boolean | undefined;
 | 
			
		||||
  onReactionToggle: (targetEventId: string, key: string, shortcode?: string | undefined) => void;
 | 
			
		||||
  onReplyClick: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onEditId: ((eventId?: string | undefined) => void) | undefined;
 | 
			
		||||
  handleAddReactions: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  closeMenu: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const MessageDropdownMenu = forwardRef<HTMLDivElement, BaseOptionProps>(
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      mEvent,
 | 
			
		||||
      room,
 | 
			
		||||
      mx,
 | 
			
		||||
      relations,
 | 
			
		||||
      canSendReaction,
 | 
			
		||||
      canEdit,
 | 
			
		||||
      canDelete,
 | 
			
		||||
      canPinEvent,
 | 
			
		||||
      hideReadReceipts,
 | 
			
		||||
      onReactionToggle,
 | 
			
		||||
      onReplyClick,
 | 
			
		||||
      onEditId,
 | 
			
		||||
      handleAddReactions,
 | 
			
		||||
      closeMenu,
 | 
			
		||||
    },
 | 
			
		||||
    ref
 | 
			
		||||
  ) => (
 | 
			
		||||
    <Menu ref={ref}>
 | 
			
		||||
      {canSendReaction && (
 | 
			
		||||
        <MessageQuickReactions
 | 
			
		||||
          onReaction={(key, shortcode) => {
 | 
			
		||||
            onReactionToggle(mEvent.getId() ?? '', key, shortcode);
 | 
			
		||||
            closeMenu();
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
 | 
			
		||||
        {canSendReaction && (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            size="300"
 | 
			
		||||
            after={<Icon size="100" src={Icons.SmilePlus} />}
 | 
			
		||||
            radii="300"
 | 
			
		||||
            onClick={(e) => {
 | 
			
		||||
              handleAddReactions(e);
 | 
			
		||||
              closeMenu();
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
 | 
			
		||||
              Add Reaction
 | 
			
		||||
            </Text>
 | 
			
		||||
          </MenuItem>
 | 
			
		||||
        )}
 | 
			
		||||
        {relations && (
 | 
			
		||||
          <MessageAllReactionItem room={room} relations={relations} onClose={() => {}} />
 | 
			
		||||
        )}
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          size="300"
 | 
			
		||||
          after={<Icon size="100" src={Icons.ReplyArrow} />}
 | 
			
		||||
          radii="300"
 | 
			
		||||
          data-event-id={mEvent.getId()}
 | 
			
		||||
          onClick={(evt) => {
 | 
			
		||||
            onReplyClick(evt);
 | 
			
		||||
            closeMenu();
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
 | 
			
		||||
            Reply
 | 
			
		||||
          </Text>
 | 
			
		||||
        </MenuItem>
 | 
			
		||||
        {canEdit && onEditId && (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            size="300"
 | 
			
		||||
            after={<Icon size="100" src={Icons.Pencil} />}
 | 
			
		||||
            radii="300"
 | 
			
		||||
            data-event-id={mEvent.getId()}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              onEditId(mEvent.getId());
 | 
			
		||||
              closeMenu();
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
 | 
			
		||||
              Edit Message
 | 
			
		||||
            </Text>
 | 
			
		||||
          </MenuItem>
 | 
			
		||||
        )}
 | 
			
		||||
        {!hideReadReceipts && (
 | 
			
		||||
          <MessageReadReceiptItem room={room} eventId={mEvent.getId() ?? ''} onClose={closeMenu} />
 | 
			
		||||
        )}
 | 
			
		||||
        <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
        <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
        {canPinEvent && <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />}
 | 
			
		||||
      </Box>
 | 
			
		||||
      {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
 | 
			
		||||
        <>
 | 
			
		||||
          <Line size="300" />
 | 
			
		||||
          <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
 | 
			
		||||
            {!mEvent.isRedacted() && canDelete && (
 | 
			
		||||
              <MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
            )}
 | 
			
		||||
            {mEvent.getSender() !== mx.getUserId() && (
 | 
			
		||||
              <MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </Menu>
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type ExtendedOptionsProps = BaseOptionProps & {
 | 
			
		||||
  imagePackRooms: Room[] | undefined;
 | 
			
		||||
  menuAnchor: RectCords | undefined;
 | 
			
		||||
  emojiBoardAnchor: RectCords | undefined;
 | 
			
		||||
  handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  handleOpenMenu: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  setMenuAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
 | 
			
		||||
  setEmojiBoardAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
 | 
			
		||||
  isOptionsMenuOpen;
 | 
			
		||||
  setOptionsMenuOpen;
 | 
			
		||||
  isEmojiBoardOpen;
 | 
			
		||||
  setEmojiBoardOpen;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function MessageOptionsMenu({
 | 
			
		||||
  mEvent,
 | 
			
		||||
  room,
 | 
			
		||||
  mx,
 | 
			
		||||
  relations,
 | 
			
		||||
  imagePackRooms,
 | 
			
		||||
  canSendReaction,
 | 
			
		||||
  canEdit,
 | 
			
		||||
  canDelete,
 | 
			
		||||
  canPinEvent,
 | 
			
		||||
  hideReadReceipts,
 | 
			
		||||
  onReactionToggle,
 | 
			
		||||
  onReplyClick,
 | 
			
		||||
  onEditId,
 | 
			
		||||
  closeMenu,
 | 
			
		||||
  menuAnchor,
 | 
			
		||||
  emojiBoardAnchor,
 | 
			
		||||
  handleOpenEmojiBoard,
 | 
			
		||||
  handleOpenMenu,
 | 
			
		||||
  handleAddReactions,
 | 
			
		||||
  setMenuAnchor,
 | 
			
		||||
  setEmojiBoardAnchor,
 | 
			
		||||
  isOptionsMenuOpen,
 | 
			
		||||
  setOptionsMenuOpen,
 | 
			
		||||
  isEmojiBoardOpen,
 | 
			
		||||
  setEmojiBoardOpen,
 | 
			
		||||
}: ExtendedOptionsProps) {
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
  const isMobile = screenSize === ScreenSize.Mobile;
 | 
			
		||||
 | 
			
		||||
  const eventId = mEvent.getId();
 | 
			
		||||
  if (!eventId) return null;
 | 
			
		||||
 | 
			
		||||
  const optionProps: BaseOptionProps = {
 | 
			
		||||
    mEvent,
 | 
			
		||||
    room,
 | 
			
		||||
    mx,
 | 
			
		||||
    relations,
 | 
			
		||||
    canSendReaction,
 | 
			
		||||
    canEdit,
 | 
			
		||||
    canDelete,
 | 
			
		||||
    canPinEvent,
 | 
			
		||||
    hideReadReceipts,
 | 
			
		||||
    onReactionToggle,
 | 
			
		||||
    onReplyClick,
 | 
			
		||||
    onEditId,
 | 
			
		||||
    handleAddReactions,
 | 
			
		||||
    closeMenu,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (isMobile) {
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        {isOptionsMenuOpen && (
 | 
			
		||||
          <MobileContextMenu
 | 
			
		||||
            onClose={() => {
 | 
			
		||||
              closeMenu();
 | 
			
		||||
            }}
 | 
			
		||||
            isOpen={isOptionsMenuOpen}
 | 
			
		||||
          >
 | 
			
		||||
            <MessageDropdownMenu
 | 
			
		||||
              {...optionProps}
 | 
			
		||||
              closeMenu={() => {
 | 
			
		||||
                closeMenu();
 | 
			
		||||
              }}
 | 
			
		||||
              handleAddReactions={handleAddReactions}
 | 
			
		||||
            />
 | 
			
		||||
          </MobileContextMenu>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {isEmojiBoardOpen && (
 | 
			
		||||
          <MobileContextMenu
 | 
			
		||||
            onClose={() => {
 | 
			
		||||
              closeMenu();
 | 
			
		||||
              setEmojiBoardOpen(false);
 | 
			
		||||
            }}
 | 
			
		||||
            isOpen={isEmojiBoardOpen}
 | 
			
		||||
          >
 | 
			
		||||
            <Suspense fallback={<p> </p>}>
 | 
			
		||||
              <EmojiBoard
 | 
			
		||||
                imagePackRooms={imagePackRooms ?? []}
 | 
			
		||||
                returnFocusOnDeactivate
 | 
			
		||||
                allowTextCustomEmoji
 | 
			
		||||
                onEmojiSelect={(key) => {
 | 
			
		||||
                  onReactionToggle(mEvent.getId(), key);
 | 
			
		||||
                  setEmojiBoardAnchor(undefined);
 | 
			
		||||
                  setEmojiBoardOpen(false);
 | 
			
		||||
                  (document.activeElement as HTMLElement)?.blur();
 | 
			
		||||
                }}
 | 
			
		||||
                onCustomEmojiSelect={(mxc, shortcode) => {
 | 
			
		||||
                  onReactionToggle(mEvent.getId(), mxc, shortcode);
 | 
			
		||||
                  setEmojiBoardAnchor(undefined);
 | 
			
		||||
                  setEmojiBoardOpen(false);
 | 
			
		||||
                  (document.activeElement as HTMLElement)?.blur();
 | 
			
		||||
                }}
 | 
			
		||||
                requestClose={() => {
 | 
			
		||||
                  setEmojiBoardAnchor(undefined);
 | 
			
		||||
                  setEmojiBoardOpen(false);
 | 
			
		||||
                }}
 | 
			
		||||
              />
 | 
			
		||||
            </Suspense>
 | 
			
		||||
          </MobileContextMenu>
 | 
			
		||||
        )}
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={css.MessageOptionsBase}>
 | 
			
		||||
      <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
 | 
			
		||||
        <Box gap="100">
 | 
			
		||||
          {canSendReaction && (
 | 
			
		||||
            <PopOut
 | 
			
		||||
              position="Bottom"
 | 
			
		||||
              align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
 | 
			
		||||
              offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
 | 
			
		||||
              anchor={emojiBoardAnchor}
 | 
			
		||||
              content={
 | 
			
		||||
                <Suspense fallback={<p> </p>}>
 | 
			
		||||
                  <EmojiBoard
 | 
			
		||||
                    imagePackRooms={imagePackRooms ?? []}
 | 
			
		||||
                    returnFocusOnDeactivate={false}
 | 
			
		||||
                    allowTextCustomEmoji
 | 
			
		||||
                    onEmojiSelect={(key) => {
 | 
			
		||||
                      onReactionToggle(eventId, key);
 | 
			
		||||
                      setEmojiBoardAnchor(undefined);
 | 
			
		||||
                    }}
 | 
			
		||||
                    onCustomEmojiSelect={(mxc, shortcode) => {
 | 
			
		||||
                      onReactionToggle(eventId, mxc, shortcode);
 | 
			
		||||
                      setEmojiBoardAnchor(undefined);
 | 
			
		||||
                    }}
 | 
			
		||||
                    requestClose={() => setEmojiBoardAnchor(undefined)}
 | 
			
		||||
                  />
 | 
			
		||||
                </Suspense>
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
              <IconButton
 | 
			
		||||
                onClick={handleOpenEmojiBoard}
 | 
			
		||||
                variant="SurfaceVariant"
 | 
			
		||||
                size="300"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                aria-pressed={!!emojiBoardAnchor}
 | 
			
		||||
              >
 | 
			
		||||
                <Icon src={Icons.SmilePlus} size="100" />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </PopOut>
 | 
			
		||||
          )}
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={onReplyClick}
 | 
			
		||||
            data-event-id={eventId}
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            size="300"
 | 
			
		||||
            radii="300"
 | 
			
		||||
          >
 | 
			
		||||
            <Icon src={Icons.ReplyArrow} size="100" />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
          {canEdit && onEditId && (
 | 
			
		||||
            <IconButton
 | 
			
		||||
              onClick={() => onEditId(eventId)}
 | 
			
		||||
              variant="SurfaceVariant"
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
            >
 | 
			
		||||
              <Icon src={Icons.Pencil} size="100" />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          )}
 | 
			
		||||
          <PopOut
 | 
			
		||||
            anchor={menuAnchor}
 | 
			
		||||
            position="Bottom"
 | 
			
		||||
            align={menuAnchor?.width === 0 ? 'Start' : 'End'}
 | 
			
		||||
            offset={menuAnchor?.width === 0 ? 0 : undefined}
 | 
			
		||||
            content={
 | 
			
		||||
              <FocusTrap
 | 
			
		||||
                focusTrapOptions={{
 | 
			
		||||
                  initialFocus: false,
 | 
			
		||||
                  onDeactivate: () => setMenuAnchor(undefined),
 | 
			
		||||
                  clickOutsideDeactivates: true,
 | 
			
		||||
                  isKeyForward: (evt) => evt.key === 'ArrowDown',
 | 
			
		||||
                  isKeyBackward: (evt) => evt.key === 'ArrowUp',
 | 
			
		||||
                  escapeDeactivates: stopPropagation,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <MessageDropdownMenu {...optionProps} />
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            <IconButton
 | 
			
		||||
              variant="SurfaceVariant"
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              onClick={handleOpenMenu}
 | 
			
		||||
              aria-pressed={!!menuAnchor}
 | 
			
		||||
            >
 | 
			
		||||
              <Icon src={Icons.VerticalDots} size="100" />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </PopOut>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Menu>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										32
									
								
								src/app/features/room/message/MobileContextMenu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/app/features/room/message/MobileContextMenu.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
export function BottomSheetMenu({
 | 
			
		||||
  isOpen,
 | 
			
		||||
  onClose,
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  isOpen: boolean;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      style={{ position: 'fixed', inset: 0, zIndex: 1000, pointerEvents: isOpen ? 'auto' : 'none' }}
 | 
			
		||||
    >
 | 
			
		||||
      <div
 | 
			
		||||
        className={classNames(css.menuBackdrop, { [css.menuBackdropOpen]: isOpen })}
 | 
			
		||||
        onClick={onClose}
 | 
			
		||||
        aria-hidden="true"
 | 
			
		||||
      />
 | 
			
		||||
      <div
 | 
			
		||||
        className={classNames(css.menuSheet, { [css.menuSheetOpen]: isOpen })}
 | 
			
		||||
        role="dialog"
 | 
			
		||||
        aria-modal="true"
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +1,16 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { DefaultReset, config, toRem } from 'folds';
 | 
			
		||||
import { DefaultReset, color, config, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const MessageBase = style({
 | 
			
		||||
  position: 'relative',
 | 
			
		||||
  '@media': {
 | 
			
		||||
    'screen and (max-width: 768px)': {
 | 
			
		||||
      userSelect: 'none',
 | 
			
		||||
      WebkitUserSelect: 'none',
 | 
			
		||||
      MozUserSelect: 'none',
 | 
			
		||||
      msUserSelect: 'none',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const MessageOptionsBase = style([
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										87
									
								
								src/app/molecules/mobile-context-menu/MobileContextMenu.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/app/molecules/mobile-context-menu/MobileContextMenu.jsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,87 @@
 | 
			
		|||
import React, { useState, useEffect } from 'react';
 | 
			
		||||
import { useDrag } from 'react-use-gesture';
 | 
			
		||||
import './MobileContextMenu.scss';
 | 
			
		||||
 | 
			
		||||
export function MobileContextMenu({ isOpen, onClose, children }) {
 | 
			
		||||
  const getInnerHeight = () => (typeof window !== 'undefined' ? window.innerHeight : 0);
 | 
			
		||||
  const [y, setY] = useState(getInnerHeight());
 | 
			
		||||
  const [isDragging, setIsDragging] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const timer = setTimeout(() => {
 | 
			
		||||
      setY(isOpen ? 0 : getInnerHeight());
 | 
			
		||||
    }, 10);
 | 
			
		||||
 | 
			
		||||
    return () => clearTimeout(timer);
 | 
			
		||||
  }, [isOpen]);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (isOpen) {
 | 
			
		||||
      document.body.style.overscrollBehavior = 'contain';
 | 
			
		||||
    }
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.body.style.overscrollBehavior = 'auto';
 | 
			
		||||
    };
 | 
			
		||||
  }, [isOpen]);
 | 
			
		||||
 | 
			
		||||
  const bind = useDrag(
 | 
			
		||||
    ({ last, movement: [, my], down }) => {
 | 
			
		||||
      if (down && !isDragging) {
 | 
			
		||||
        setIsDragging(true);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const newY = Math.max(my, 0);
 | 
			
		||||
      setY(newY);
 | 
			
		||||
 | 
			
		||||
      if (last) {
 | 
			
		||||
        setIsDragging(false);
 | 
			
		||||
        if (my > getInnerHeight() / 4) {
 | 
			
		||||
          onClose();
 | 
			
		||||
        } else {
 | 
			
		||||
          setY(0);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      from: () => [0, y],
 | 
			
		||||
      filterTaps: true,
 | 
			
		||||
      bounds: { top: 0 },
 | 
			
		||||
      rubberband: true,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!isOpen && y >= getInnerHeight()) return null;
 | 
			
		||||
  const containerClasses = [
 | 
			
		||||
    'bottom-sheet-container',
 | 
			
		||||
    !isDragging ? 'is-transitioning' : '',
 | 
			
		||||
  ].join(' ');
 | 
			
		||||
 | 
			
		||||
  const backdropOpacity = y > 0 ? 1 - y / getInnerHeight() : 1;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div
 | 
			
		||||
        className="bottom-sheet-backdrop"
 | 
			
		||||
        onClick={onClose}
 | 
			
		||||
        style={{ opacity: Math.max(0, backdropOpacity) }}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <div
 | 
			
		||||
        className={containerClasses}
 | 
			
		||||
        {...bind()}
 | 
			
		||||
        style={{
 | 
			
		||||
          transform: `translate3d(0, ${y}px, 0)`,
 | 
			
		||||
          touchAction: 'none',
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <div className="bottom-sheet-grabber" />
 | 
			
		||||
        <div className="bottom-sheet-content" style={{ overflow: 'visible' }}>
 | 
			
		||||
          {children}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default MobileContextMenu;
 | 
			
		||||
							
								
								
									
										49
									
								
								src/app/molecules/mobile-context-menu/MobileContextMenu.scss
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/app/molecules/mobile-context-menu/MobileContextMenu.scss
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,49 @@
 | 
			
		|||
.bottom-sheet-backdrop {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.5);
 | 
			
		||||
  // The backdrop fade will also be smoother with a transition
 | 
			
		||||
  transition: opacity 300ms ease-out;
 | 
			
		||||
  z-index: 999;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bottom-sheet-container {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  // Set transform from the component's style prop
 | 
			
		||||
  // The 'will-change' property is a performance hint for the browser
 | 
			
		||||
  will-change: transform;
 | 
			
		||||
  z-index: 1000;
 | 
			
		||||
 | 
			
		||||
  // Your existing styles for the sheet itself
 | 
			
		||||
  border-top-left-radius: 16px;
 | 
			
		||||
  border-top-right-radius: 16px;
 | 
			
		||||
  box-shadow: 0 -2px A10px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
 | 
			
		||||
  // This is the magic: apply a transition only when this class is present
 | 
			
		||||
  &.is-transitioning {
 | 
			
		||||
    transition: transform 300ms ease-out;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Your other styles remain the same
 | 
			
		||||
.bottom-sheet-grabber {
 | 
			
		||||
  width: 40px;
 | 
			
		||||
  height: 5px;
 | 
			
		||||
  background-color: #ccc;
 | 
			
		||||
  border-radius: 2.5px;
 | 
			
		||||
  margin: 8px auto;
 | 
			
		||||
  cursor: grab;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bottom-sheet-content {
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -39,6 +39,8 @@ import {
 | 
			
		|||
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
 | 
			
		||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { useLongPress } from 'use-long-press';
 | 
			
		||||
import { createPortal } from 'react-dom';
 | 
			
		||||
import {
 | 
			
		||||
  useOrphanSpaces,
 | 
			
		||||
  useRecursiveChildScopeFactory,
 | 
			
		||||
| 
						 | 
				
			
			@ -91,6 +93,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		|||
import { useSetting } from '../../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../../state/settings';
 | 
			
		||||
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
 | 
			
		||||
import { MobileContextMenu } from '../../../molecules/mobile-context-menu/MobileContextMenu';
 | 
			
		||||
 | 
			
		||||
type SpaceMenuProps = {
 | 
			
		||||
  room: Room;
 | 
			
		||||
| 
						 | 
				
			
			@ -142,7 +145,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
 | 
			
		|||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
 | 
			
		||||
      <Menu ref={ref}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            onClick={handleMarkAsRead}
 | 
			
		||||
| 
						 | 
				
			
			@ -221,7 +224,8 @@ const useDraggableItem = (
 | 
			
		|||
  item: SidebarDraggable,
 | 
			
		||||
  targetRef: RefObject<HTMLElement>,
 | 
			
		||||
  onDragging: (item?: SidebarDraggable) => void,
 | 
			
		||||
  dragHandleRef?: RefObject<HTMLElement>
 | 
			
		||||
  dragHandleRef?: RefObject<HTMLElement>,
 | 
			
		||||
  onActualDragStart?: () => void
 | 
			
		||||
): boolean => {
 | 
			
		||||
  const [dragging, setDragging] = useState(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -238,13 +242,16 @@ const useDraggableItem = (
 | 
			
		|||
          onDragStart: () => {
 | 
			
		||||
            setDragging(true);
 | 
			
		||||
            onDragging?.(item);
 | 
			
		||||
            if (typeof onActualDragStart === 'function') {
 | 
			
		||||
              onActualDragStart();
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          onDrop: () => {
 | 
			
		||||
            setDragging(false);
 | 
			
		||||
            onDragging?.(undefined);
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
  }, [targetRef, dragHandleRef, item, onDragging]);
 | 
			
		||||
  }, [targetRef, dragHandleRef, item, onDragging, onActualDragStart]);
 | 
			
		||||
 | 
			
		||||
  return dragging;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -388,6 +395,11 @@ function SpaceTab({
 | 
			
		|||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const targetRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
  const isMobile = screenSize === ScreenSize.Mobile;
 | 
			
		||||
  const [isMobileSheetOpen, setMobileSheetOpen] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const spaceDraggable: SidebarDraggable = useMemo(
 | 
			
		||||
    () =>
 | 
			
		||||
| 
						 | 
				
			
			@ -400,21 +412,47 @@ function SpaceTab({
 | 
			
		|||
    [folder, space]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useDraggableItem(spaceDraggable, targetRef, onDragging);
 | 
			
		||||
  const handleDragStart = useCallback(() => {
 | 
			
		||||
    if (isMobileSheetOpen) {
 | 
			
		||||
      setMenuAnchor(undefined);
 | 
			
		||||
      setMobileSheetOpen(false);
 | 
			
		||||
    }
 | 
			
		||||
  }, [isMobileSheetOpen]);
 | 
			
		||||
 | 
			
		||||
  const isDragging = useDraggableItem(
 | 
			
		||||
    spaceDraggable,
 | 
			
		||||
    targetRef,
 | 
			
		||||
    onDragging,
 | 
			
		||||
    undefined,
 | 
			
		||||
    handleDragStart
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const dropState = useDropTarget(spaceDraggable, targetRef);
 | 
			
		||||
  const dropType = dropState?.type;
 | 
			
		||||
 | 
			
		||||
  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    const cords = evt.currentTarget.getBoundingClientRect();
 | 
			
		||||
    setMenuAnchor((currentState) => {
 | 
			
		||||
      if (currentState) return undefined;
 | 
			
		||||
      return cords;
 | 
			
		||||
    });
 | 
			
		||||
    if (!isMobile) {
 | 
			
		||||
      setMenuAnchor((currentState) => {
 | 
			
		||||
        if (currentState) return undefined;
 | 
			
		||||
        return cords;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const longPressBinder = useLongPress(
 | 
			
		||||
    () => {
 | 
			
		||||
      if (isMobile && !isDragging) {
 | 
			
		||||
        setMobileSheetOpen(true);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      threshold: 400,
 | 
			
		||||
      cancelOnMovement: true,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <RoomUnreadProvider roomId={space.roomId}>
 | 
			
		||||
      {(unread) => (
 | 
			
		||||
| 
						 | 
				
			
			@ -426,6 +464,7 @@ function SpaceTab({
 | 
			
		|||
          data-drop-above={dropType === 'reorder-above'}
 | 
			
		||||
          data-drop-below={dropType === 'reorder-below'}
 | 
			
		||||
          data-inside-folder={!!folder}
 | 
			
		||||
          {...(isMobile ? longPressBinder() : {})}
 | 
			
		||||
        >
 | 
			
		||||
          <SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
 | 
			
		||||
            {(triggerRef) => (
 | 
			
		||||
| 
						 | 
				
			
			@ -479,6 +518,21 @@ function SpaceTab({
 | 
			
		|||
              }
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {createPortal(
 | 
			
		||||
            <MobileContextMenu
 | 
			
		||||
              onClose={() => {
 | 
			
		||||
                setMobileSheetOpen(false);
 | 
			
		||||
              }}
 | 
			
		||||
              isOpen={isMobileSheetOpen}
 | 
			
		||||
            >
 | 
			
		||||
              <SpaceMenu
 | 
			
		||||
                room={space}
 | 
			
		||||
                requestClose={() => setMobileSheetOpen(false)}
 | 
			
		||||
                onUnpin={onUnpin}
 | 
			
		||||
              />
 | 
			
		||||
            </MobileContextMenu>,
 | 
			
		||||
            document.body
 | 
			
		||||
          )}
 | 
			
		||||
        </SidebarItem>
 | 
			
		||||
      )}
 | 
			
		||||
    </RoomUnreadProvider>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue