--- /dev/null
+module.exports = {
+ env: {
+ browser: true,
+ es2021: true,
+ },
+ extends: [
+ "eslint:recommended",
+ "plugin:react/recommended",
+ "plugin:react-hooks/recommended",
+ "plugin:@typescript-eslint/eslint-recommended",
+ "plugin:@typescript-eslint/recommended",
+ 'airbnb',
+ 'prettier',
+ ],
+ parser: "@typescript-eslint/parser",
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+ "globals": {
+ JSX: "readonly"
+ },
+ plugins: [
+ 'react',
+ '@typescript-eslint'
+ ],
+ rules: {
+ 'linebreak-style': 0,
+ 'no-underscore-dangle': 0,
+ "no-shadow": "off",
+
+ "import/prefer-default-export": "off",
+ "import/extensions": "off",
+ "import/no-unresolved": "off",
+ "import/no-extraneous-dependencies": [
+ "error",
+ {
+ devDependencies: true,
+ },
+ ],
+
+ 'react/no-unstable-nested-components': [
+ 'error',
+ { allowAsProps: true },
+ ],
+ "react/jsx-filename-extension": [
+ "error",
+ {
+ extensions: [".tsx", ".jsx"],
+ },
+ ],
+
+ "react/require-default-props": "off",
+ "react/jsx-props-no-spreading": "off",
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "error",
+
+ "@typescript-eslint/no-unused-vars": "error",
+ "@typescript-eslint/no-shadow": "error"
+ },
+};
+++ /dev/null
-module.exports = {
- env: {
- browser: true,
- es2021: true,
- },
- extends: [
- "eslint:recommended",
- "plugin:react/recommended",
- "plugin:react-hooks/recommended",
- "plugin:@typescript-eslint/eslint-recommended",
- "plugin:@typescript-eslint/recommended",
- 'airbnb',
- 'prettier',
- ],
- parser: "@typescript-eslint/parser",
- parserOptions: {
- ecmaFeatures: {
- jsx: true,
- },
- ecmaVersion: 'latest',
- sourceType: 'module',
- },
- "globals": {
- JSX: "readonly"
- },
- plugins: [
- 'react',
- '@typescript-eslint'
- ],
- rules: {
- 'linebreak-style': 0,
- 'no-underscore-dangle': 0,
- "no-shadow": "off",
-
- "import/prefer-default-export": "off",
- "import/extensions": "off",
- "import/no-unresolved": "off",
- "import/no-extraneous-dependencies": [
- "error",
- {
- devDependencies: true,
- },
- ],
-
- 'react/no-unstable-nested-components': [
- 'error',
- { allowAsProps: true },
- ],
- "react/jsx-filename-extension": [
- "error",
- {
- extensions: [".tsx", ".jsx"],
- },
- ],
-
- "react/require-default-props": "off",
- "react/jsx-props-no-spreading": "off",
- "react-hooks/rules-of-hooks": "error",
- "react-hooks/exhaustive-deps": "error",
-
- "@typescript-eslint/no-unused-vars": "error",
- "@typescript-eslint/no-shadow": "error"
- },
-};
],
"allowCustomHomeservers": true,
+ "featuredCommunities": {
+ "openAsDefault": false,
+ "spaces": [
+ "#cinny-space:matrix.org",
+ "#community:matrix.org",
+ "#space:envs.net",
+ "#science-space:matrix.org",
+ "#libregaming-games:tchncs.de",
+ "#mathematics-on:matrix.org"
+ ],
+ "rooms": [
+ "#cinny:matrix.org",
+ "#foundation-office:matrix.org",
+ "#thisweekinmatrix:matrix.org",
+ "#matrix-dev:matrix.org",
+ "#matrix:matrix.org"
+ ],
+ "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
+ },
+
"hashRouter": {
"enabled": false,
"basename": "/"
status = 200
[[redirects]]
- from = "/olm.wasm"
+ from = "*/olm.wasm"
to = "/olm.wasm"
status = 200
+ force = true
[[redirects]]
from = "/pdf.worker.min.js"
[[redirects]]
from = "/*"
to = "/index.html"
- status = 200
\ No newline at end of file
+ status = 200
+ force = true
\ No newline at end of file
"version": "3.2.0",
"license": "AGPL-3.0-only",
"dependencies": {
+ "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
+ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@khanacademy/simple-markdown": "0.8.6",
"@matrix-org/olm": "3.2.14",
- "@tanstack/react-virtual": "3.0.0-beta.54",
+ "@tanstack/react-query": "5.24.1",
+ "@tanstack/react-query-devtools": "5.24.1",
+ "@tanstack/react-virtual": "3.2.0",
"@tippyjs/react": "4.2.6",
"@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0",
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
- "folds": "1.5.1",
+ "folds": "2.0.0",
"formik": "2.2.9",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"node": ">=6.0.0"
}
},
+ "node_modules/@atlaskit/pragmatic-drag-and-drop": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.1.6.tgz",
+ "integrity": "sha512-+jGspaRMyHWB6g9w+N1KImS5I+xt0ML89pwUyCueEhf2KGsl6zyH9ZxjTVKfrbY89FyZvuuXT9oFRHTUKGBi/w==",
+ "dependencies": {
+ "@babel/runtime": "^7.0.0",
+ "bind-event-listener": "^3.0.0",
+ "raf-schd": "^4.0.3"
+ }
+ },
+ "node_modules/@atlaskit/pragmatic-drag-and-drop-auto-scroll": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-auto-scroll/-/pragmatic-drag-and-drop-auto-scroll-1.3.0.tgz",
+ "integrity": "sha512-8wjKAI5qSrLojt8ZJ2WhoS5P75oBu5g0yMpAnTDgfqFyQnkt5Uc1txCRWpG26SS1mv19nm8ak9XHF2DOugVfpw==",
+ "dependencies": {
+ "@atlaskit/pragmatic-drag-and-drop": "^1.1.0",
+ "@babel/runtime": "^7.0.0"
+ }
+ },
+ "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz",
+ "integrity": "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==",
+ "dependencies": {
+ "@atlaskit/pragmatic-drag-and-drop": "^1.1.0",
+ "@babel/runtime": "^7.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
"@swc/counter": "^0.1.3"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.24.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.24.1.tgz",
+ "integrity": "sha512-DZ6Nx9p7BhjkG50ayJ+MKPgff+lMeol7QYXkvuU5jr2ryW/4ok5eanaS9W5eooA4xN0A/GPHdLGOZGzArgf5Cg==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-devtools": {
+ "version": "5.24.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.24.0.tgz",
+ "integrity": "sha512-pThim455t69zrZaQKa7IRkEIK8UBTS+gHVAdNfhO72Xh4rWpMc63ovRje5/n6iw63+d6QiJzVadsJVdPoodSeQ==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.24.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.24.1.tgz",
+ "integrity": "sha512-4+09JEdO4d6+Gc8Y/g2M/MuxDK5IY0QV8+2wL2304wPKJgJ54cBbULd3nciJ5uvh/as8rrxx6s0mtIwpRuGd1g==",
+ "dependencies": {
+ "@tanstack/query-core": "5.24.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ }
+ },
+ "node_modules/@tanstack/react-query-devtools": {
+ "version": "5.24.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.24.1.tgz",
+ "integrity": "sha512-qa4SEugN+EF8JJXcpsM9Lu05HfUv5cvHvLuB0uw/81eJZyNHFdtHFBi5RLCgpBrOyVMDfH8UQ3VBMqXzFKV68A==",
+ "dependencies": {
+ "@tanstack/query-devtools": "5.24.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@tanstack/react-query": "^5.24.1",
+ "react": "^18.0.0"
+ }
+ },
"node_modules/@tanstack/react-virtual": {
- "version": "3.0.0-beta.54",
- "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.54.tgz",
- "integrity": "sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.2.0.tgz",
+ "integrity": "sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==",
"dependencies": {
- "@tanstack/virtual-core": "3.0.0-beta.54"
+ "@tanstack/virtual-core": "3.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
- "version": "3.0.0-beta.54",
- "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.54.tgz",
- "integrity": "sha512-jtkwqdP2rY2iCCDVAFuaNBH3fiEi29aTn2RhtIoky8DTTiCdc48plpHHreLwmv1PICJ4AJUUESaq3xa8fZH8+g==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.2.0.tgz",
+ "integrity": "sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
"node": ">=8"
}
},
+ "node_modules/bind-event-listener": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz",
+ "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q=="
+ },
"node_modules/blurhash": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.4.tgz",
}
},
"node_modules/folds": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/folds/-/folds-1.5.1.tgz",
- "integrity": "sha512-2QxyA+FRKjPKXDTMDoD7NmOUiReWrKYO0Msg44QqlzTkTrRVEzJgyPIfC/Ia4/u0ByQpk6dbq8UQxomKmneJ/g==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/folds/-/folds-2.0.0.tgz",
+ "integrity": "sha512-lKv31vij4GEpEzGKWk5c3ar78fMZ9Di5n1XFR14Z2wnnpqhiiM5JTIzr127Gk5dOfy4mJkjnv/ZfMZvM2k+OQg==",
"peerDependencies": {
"@vanilla-extract/css": "^1.9.2",
"@vanilla-extract/recipes": "^0.3.0",
}
]
},
+ "node_modules/raf-schd": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
+ "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
+ },
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"author": "Ajay Bura",
"license": "AGPL-3.0-only",
"dependencies": {
+ "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
+ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@khanacademy/simple-markdown": "0.8.6",
"@matrix-org/olm": "3.2.14",
- "@tanstack/react-virtual": "3.0.0-beta.54",
+ "@tanstack/react-query": "5.24.1",
+ "@tanstack/react-query-devtools": "5.24.1",
+ "@tanstack/react-virtual": "3.2.0",
"@tippyjs/react": "4.2.6",
"@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0",
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
- "folds": "1.5.1",
+ "folds": "2.0.0",
"formik": "2.2.9",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
--- /dev/null
+import { ReactNode, useCallback, useEffect } from 'react';
+import { Capabilities } from 'matrix-js-sdk';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { MediaConfig } from '../hooks/useMediaConfig';
+import { promiseFulfilledResult } from '../utils/common';
+
+type CapabilitiesAndMediaConfigLoaderProps = {
+ children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode;
+};
+export function CapabilitiesAndMediaConfigLoader({
+ children,
+}: CapabilitiesAndMediaConfigLoaderProps) {
+ const mx = useMatrixClient();
+
+ const [state, load] = useAsyncCallback<
+ [Capabilities | undefined, MediaConfig | undefined],
+ unknown,
+ []
+ >(
+ useCallback(async () => {
+ const result = await Promise.allSettled([mx.getCapabilities(true), mx.getMediaConfig()]);
+ const capabilities = promiseFulfilledResult(result[0]);
+ const mediaConfig = promiseFulfilledResult(result[1]);
+ return [capabilities, mediaConfig];
+ }, [mx])
+ );
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const [capabilities, mediaConfig] =
+ state.status === AsyncStatus.Success ? state.data : [undefined, undefined];
+ return children(capabilities, mediaConfig);
+}
--- /dev/null
+import { ReactNode, useCallback, useEffect } from 'react';
+import { Capabilities } from 'matrix-js-sdk';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+
+type CapabilitiesLoaderProps = {
+ children: (capabilities: Capabilities | undefined) => ReactNode;
+};
+export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
+ const mx = useMatrixClient();
+
+ const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx]));
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ return children(state.status === AsyncStatus.Success ? state.data : undefined);
+}
--- /dev/null
+import { ReactNode, useCallback, useEffect } from 'react';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { MediaConfig } from '../hooks/useMediaConfig';
+
+type MediaConfigLoaderProps = {
+ children: (mediaConfig: MediaConfig | undefined) => ReactNode;
+};
+export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
+ const mx = useMatrixClient();
+
+ const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ return children(state.status === AsyncStatus.Success ? state.data : undefined);
+}
/* eslint-disable no-param-reassign */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
-import React, { FormEventHandler, useEffect, useRef, useState } from 'react';
+import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import {
Box,
Input,
Menu,
PopOut,
+ RectCords,
Scroll,
Spinner,
Text,
const isError =
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
const [pageNo, setPageNo] = useState(1);
- const [openJump, setOpenJump] = useState(false);
+ const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
useEffect(() => {
loadPdfJS();
if (!jumpInput) return;
const jumpTo = parseInt(jumpInput.value, 10);
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
- setOpenJump(false);
+ setJumpAnchor(undefined);
};
const handlePrevPage = () => {
setPageNo((n) => Math.min(n + 1, docState.data.numPages));
};
+ const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setJumpAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
return (
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400">
</Chip>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<PopOut
- open={openJump}
+ anchor={jumpAnchor}
align="Center"
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
- onDeactivate: () => setOpenJump(false),
+ onDeactivate: () => setJumpAnchor(undefined),
clickOutsideDeactivates: true,
}}
>
</FocusTrap>
}
>
- {(anchorRef) => (
- <Chip
- onClick={() => setOpenJump(!openJump)}
- ref={anchorRef}
- variant="SurfaceVariant"
- radii="300"
- aria-pressed={openJump}
- >
- <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
- </Chip>
- )}
+ <Chip
+ onClick={handleOpenJump}
+ variant="SurfaceVariant"
+ radii="300"
+ aria-pressed={jumpAnchor !== undefined}
+ >
+ <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
+ </Chip>
</PopOut>
</Box>
<Chip
--- /dev/null
+import React from 'react';
+import { MsgType } from 'matrix-js-sdk';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import {
+ AudioContent,
+ DownloadFile,
+ FileContent,
+ ImageContent,
+ MAudio,
+ MBadEncrypted,
+ MEmote,
+ MFile,
+ MImage,
+ MLocation,
+ MNotice,
+ MText,
+ MVideo,
+ ReadPdfFile,
+ ReadTextFile,
+ RenderBody,
+ ThumbnailContent,
+ UnsupportedContent,
+ VideoContent,
+} from './message';
+import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
+import { Image, MediaControl, Video } from './media';
+import { ImageViewer } from './image-viewer';
+import { PdfViewer } from './Pdf-viewer';
+import { TextViewer } from './text-viewer';
+
+type RenderMessageContentProps = {
+ displayName: string;
+ msgType: string;
+ ts: number;
+ edited?: boolean;
+ getContent: <T>() => T;
+ mediaAutoLoad?: boolean;
+ urlPreview?: boolean;
+ highlightRegex?: RegExp;
+ htmlReactParserOptions: HTMLReactParserOptions;
+ outlineAttachment?: boolean;
+};
+export function RenderMessageContent({
+ displayName,
+ msgType,
+ ts,
+ edited,
+ getContent,
+ mediaAutoLoad,
+ urlPreview,
+ highlightRegex,
+ htmlReactParserOptions,
+ outlineAttachment,
+}: RenderMessageContentProps) {
+ const renderFile = () => (
+ <MFile
+ content={getContent()}
+ renderFileContent={({ body, mimeType, info, encInfo, url }) => (
+ <FileContent
+ body={body}
+ mimeType={mimeType}
+ renderAsPdfFile={() => (
+ <ReadPdfFile
+ body={body}
+ mimeType={mimeType}
+ url={url}
+ encInfo={encInfo}
+ renderViewer={(p) => <PdfViewer {...p} />}
+ />
+ )}
+ renderAsTextFile={() => (
+ <ReadTextFile
+ body={body}
+ mimeType={mimeType}
+ url={url}
+ encInfo={encInfo}
+ renderViewer={(p) => <TextViewer {...p} />}
+ />
+ )}
+ >
+ <DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
+ </FileContent>
+ )}
+ outlined={outlineAttachment}
+ />
+ );
+
+ if (msgType === MsgType.Text) {
+ return (
+ <MText
+ edited={edited}
+ content={getContent()}
+ renderBody={(props) => (
+ <RenderBody
+ {...props}
+ highlightRegex={highlightRegex}
+ htmlReactParserOptions={htmlReactParserOptions}
+ />
+ )}
+ renderUrlsPreview={
+ urlPreview
+ ? (urls) => (
+ <UrlPreviewHolder>
+ {urls.map((url) => (
+ <UrlPreviewCard key={url} url={url} ts={ts} />
+ ))}
+ </UrlPreviewHolder>
+ )
+ : undefined
+ }
+ />
+ );
+ }
+
+ if (msgType === MsgType.Emote) {
+ return (
+ <MEmote
+ displayName={displayName}
+ edited={edited}
+ content={getContent()}
+ renderBody={(props) => (
+ <RenderBody
+ {...props}
+ highlightRegex={highlightRegex}
+ htmlReactParserOptions={htmlReactParserOptions}
+ />
+ )}
+ renderUrlsPreview={
+ urlPreview
+ ? (urls) => (
+ <UrlPreviewHolder>
+ {urls.map((url) => (
+ <UrlPreviewCard key={url} url={url} ts={ts} />
+ ))}
+ </UrlPreviewHolder>
+ )
+ : undefined
+ }
+ />
+ );
+ }
+
+ if (msgType === MsgType.Notice) {
+ return (
+ <MNotice
+ edited={edited}
+ content={getContent()}
+ renderBody={(props) => (
+ <RenderBody
+ {...props}
+ highlightRegex={highlightRegex}
+ htmlReactParserOptions={htmlReactParserOptions}
+ />
+ )}
+ renderUrlsPreview={
+ urlPreview
+ ? (urls) => (
+ <UrlPreviewHolder>
+ {urls.map((url) => (
+ <UrlPreviewCard key={url} url={url} ts={ts} />
+ ))}
+ </UrlPreviewHolder>
+ )
+ : undefined
+ }
+ />
+ );
+ }
+
+ if (msgType === MsgType.Image) {
+ return (
+ <MImage
+ content={getContent()}
+ renderImageContent={(props) => (
+ <ImageContent
+ {...props}
+ autoPlay={mediaAutoLoad}
+ renderImage={(p) => <Image {...p} loading="lazy" />}
+ renderViewer={(p) => <ImageViewer {...p} />}
+ />
+ )}
+ outlined={outlineAttachment}
+ />
+ );
+ }
+
+ if (msgType === MsgType.Video) {
+ return (
+ <MVideo
+ content={getContent()}
+ renderAsFile={renderFile}
+ renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
+ <VideoContent
+ body={body}
+ info={info}
+ mimeType={mimeType}
+ url={url}
+ encInfo={encInfo}
+ renderThumbnail={
+ mediaAutoLoad
+ ? () => (
+ <ThumbnailContent
+ info={info}
+ renderImage={(src) => (
+ <Image alt={body} title={body} src={src} loading="lazy" />
+ )}
+ />
+ )
+ : undefined
+ }
+ renderVideo={(p) => <Video {...p} />}
+ />
+ )}
+ outlined={outlineAttachment}
+ />
+ );
+ }
+
+ if (msgType === MsgType.Audio) {
+ return (
+ <MAudio
+ content={getContent()}
+ renderAsFile={renderFile}
+ renderAudioContent={(props) => (
+ <AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
+ )}
+ outlined={outlineAttachment}
+ />
+ );
+ }
+
+ if (msgType === MsgType.File) {
+ return renderFile();
+ }
+
+ if (msgType === MsgType.Location) {
+ return <MLocation content={getContent()} />;
+ }
+
+ if (msgType === 'm.bad.encrypted') {
+ return <MBadEncrypted />;
+ }
+
+ return <UnsupportedContent />;
+}
--- /dev/null
+import { ReactNode, useCallback, useState } from 'react';
+import { MatrixClient, Room } from 'matrix-js-sdk';
+import { useQuery } from '@tanstack/react-query';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
+import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
+
+export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
+
+type RoomSummaryLoaderProps = {
+ roomIdOrAlias: string;
+ children: (roomSummary?: IRoomSummary) => ReactNode;
+};
+
+export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
+ const mx = useMatrixClient();
+
+ const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]);
+
+ const { data } = useQuery({
+ queryKey: [roomIdOrAlias, `summary`],
+ queryFn: fetchSummary,
+ });
+
+ return children(data);
+}
+
+export function LocalRoomSummaryLoader({
+ room,
+ children,
+}: {
+ room: Room;
+ children: (roomSummary: LocalRoomSummary) => ReactNode;
+}) {
+ const summary = useLocalRoomSummary(room);
+
+ return children(summary);
+}
+
+export function HierarchyRoomSummaryLoader({
+ roomId,
+ children,
+}: {
+ roomId: string;
+ children: (state: AsyncState<IHierarchyRoom, Error>) => ReactNode;
+}) {
+ const mx = useMatrixClient();
+
+ const fetchSummary = useCallback(() => mx.getRoomHierarchy(roomId, 1, 1), [mx, roomId]);
+ const [errorMemo, setError] = useState<Error>();
+
+ const { data, error } = useQuery({
+ queryKey: [roomId, `hierarchy`],
+ queryFn: fetchSummary,
+ retryOnMount: false,
+ refetchOnWindowFocus: false,
+ retry: (failureCount, err) => {
+ setError(err);
+ if (failureCount > 3) return false;
+ return true;
+ },
+ });
+
+ let state: AsyncState<IHierarchyRoom, Error> = {
+ status: AsyncStatus.Loading,
+ };
+ if (error) {
+ state = {
+ status: AsyncStatus.Error,
+ error,
+ };
+ }
+ if (errorMemo) {
+ state = {
+ status: AsyncStatus.Error,
+ error: errorMemo,
+ };
+ }
+
+ const summary = data?.rooms[0] ?? undefined;
+ if (summary) {
+ state = {
+ status: AsyncStatus.Success,
+ data: summary,
+ };
+ }
+
+ return children(state);
+}
--- /dev/null
+import { ReactElement } from 'react';
+import { Unread } from '../../types/matrix/room';
+import { useRoomUnread, useRoomsUnread } from '../state/hooks/unread';
+import { roomToUnreadAtom } from '../state/room/roomToUnread';
+
+type RoomUnreadProviderProps = {
+ roomId: string;
+ children: (unread?: Unread) => ReactElement;
+};
+export function RoomUnreadProvider({ roomId, children }: RoomUnreadProviderProps) {
+ const unread = useRoomUnread(roomId, roomToUnreadAtom);
+
+ return children(unread);
+}
+
+type RoomsUnreadProviderProps = {
+ rooms: string[];
+ children: (unread?: Unread) => ReactElement;
+};
+export function RoomsUnreadProvider({ rooms, children }: RoomsUnreadProviderProps) {
+ const unread = useRoomsUnread(rooms, roomToUnreadAtom);
+
+ return children(unread);
+}
--- /dev/null
+import { ReactNode } from 'react';
+import { RoomToParents } from '../../types/matrix/room';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { allRoomsAtom } from '../state/room-list/roomList';
+import { useChildDirectScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
+
+type SpaceChildDirectsProviderProps = {
+ spaceId: string;
+ mDirects: Set<string>;
+ roomToParents: RoomToParents;
+ children: (rooms: string[]) => ReactNode;
+};
+export function SpaceChildDirectsProvider({
+ spaceId,
+ roomToParents,
+ mDirects,
+ children,
+}: SpaceChildDirectsProviderProps) {
+ const mx = useMatrixClient();
+
+ const childDirects = useSpaceChildren(
+ allRoomsAtom,
+ spaceId,
+ useChildDirectScopeFactory(mx, mDirects, roomToParents)
+ );
+
+ return children(childDirects);
+}
--- /dev/null
+import { ReactNode } from 'react';
+import { RoomToParents } from '../../types/matrix/room';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { allRoomsAtom } from '../state/room-list/roomList';
+import { useChildRoomScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
+
+type SpaceChildRoomsProviderProps = {
+ spaceId: string;
+ mDirects: Set<string>;
+ roomToParents: RoomToParents;
+ children: (rooms: string[]) => ReactNode;
+};
+export function SpaceChildRoomsProvider({
+ spaceId,
+ roomToParents,
+ mDirects,
+ children,
+}: SpaceChildRoomsProviderProps) {
+ const mx = useMatrixClient();
+
+ const childRooms = useSpaceChildren(
+ allRoomsAtom,
+ spaceId,
+ useChildRoomScopeFactory(mx, mDirects, roomToParents)
+ );
+
+ return children(childRooms);
+}
-import { ReactNode, useCallback, useEffect } from 'react';
+import { ReactNode, useCallback, useEffect, useState } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { SpecVersions, specVersions } from '../cs-api';
-import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
type SpecVersionsLoaderProps = {
+ baseUrl: string;
fallback?: () => ReactNode;
- error?: (err: unknown) => ReactNode;
+ error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
children: (versions: SpecVersions) => ReactNode;
};
-export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLoaderProps) {
- const autoDiscoveryInfo = useAutoDiscoveryInfo();
- const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
-
+export function SpecVersionsLoader({
+ baseUrl,
+ fallback,
+ error,
+ children,
+}: SpecVersionsLoaderProps) {
const [state, load] = useAsyncCallback(
useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
);
+ const [ignoreError, setIgnoreError] = useState(false);
+
+ const ignoreCallback = useCallback(() => setIgnoreError(true), []);
useEffect(() => {
load();
return fallback?.();
}
- if (state.status === AsyncStatus.Error) {
- return error?.(state.error);
+ if (!ignoreError && state.status === AsyncStatus.Error) {
+ return error?.(state.error, load, ignoreCallback);
}
- return children(state.data);
+ return children(
+ state.status === AsyncStatus.Success
+ ? state.data
+ : {
+ versions: [],
+ }
+ );
}
Line,
Menu,
PopOut,
+ RectCords,
Scroll,
Text,
Tooltip,
TooltipProvider,
toRem,
} from 'folds';
-import React, { ReactNode, useState } from 'react';
+import React, { MouseEventHandler, ReactNode, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import {
headingLevel,
export function HeadingBlockButton() {
const editor = useSlate();
const level = headingLevel(editor);
- const [open, setOpen] = useState(false);
+ const [anchor, setAnchor] = useState<RectCords>();
const isActive = isBlockActive(editor, BlockType.Heading);
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const handleMenuSelect = (selectedLevel: HeadingLevel) => {
- setOpen(false);
+ setAnchor(undefined);
toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
ReactEditor.focus(editor);
};
+ const handleMenuOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ if (isActive) {
+ toggleBlock(editor, BlockType.Heading);
+ return;
+ }
+ setAnchor(evt.currentTarget.getBoundingClientRect());
+ };
return (
<PopOut
- open={open}
+ anchor={anchor}
offset={5}
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
- onDeactivate: () => setOpen(false),
+ onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
</FocusTrap>
}
>
- {(ref) => (
- <IconButton
- style={{ width: 'unset' }}
- ref={ref}
- variant="SurfaceVariant"
- onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
- aria-pressed={isActive}
- size="400"
- radii="300"
- >
- <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
- <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
- </IconButton>
- )}
+ <IconButton
+ style={{ width: 'unset' }}
+ variant="SurfaceVariant"
+ onClick={handleMenuOpen}
+ aria-pressed={isActive}
+ size="400"
+ radii="300"
+ >
+ <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
+ <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
+ </IconButton>
</PopOut>
);
}
-import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
+import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
import { Editor } from 'slate';
-import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
-import { MatrixClient } from 'matrix-js-sdk';
+import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
+import { JoinRule, MatrixClient } from 'matrix-js-sdk';
+import { useAtomValue } from 'jotai';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
-import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
-import { roomIdByActivity } from '../../../../util/sort';
-import initMatrix from '../../../../client/initMatrix';
+import { getDirectRoomAvatarUrl } from '../../../utils/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { useKeyDown } from '../../../hooks/useKeyDown';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { factoryRoomIdByActivity } from '../../../utils/sort';
+import { RoomAvatar, RoomIcon } from '../../room-avatar';
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
requestClose,
}: RoomMentionAutocompleteProps) {
const mx = useMatrixClient();
- const dms: Set<string> = initMatrix.roomList?.directs ?? new Set();
+ const mDirects = useAtomValue(mDirectAtom);
- const allRoomId: string[] = useMemo(() => {
- const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {};
- return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
- }, []);
+ const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
const [result, search, resetSearch] = useAsyncSearch(
- allRoomId,
+ allRooms,
useCallback(
(rId) => {
const r = mx.getRoom(rId);
SEARCH_OPTIONS
);
- const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20);
+ const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20);
useEffect(() => {
if (query.text) search(query.text);
autoCompleteRoomIds.map((rId) => {
const room = mx.getRoom(rId);
if (!room) return null;
- const dm = dms.has(room.roomId);
- const avatarUrl = getRoomAvatarUrl(mx, room);
- const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom());
+ const dm = mDirects.has(room.roomId);
const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
}
before={
<Avatar size="200">
- {iconSrc && <Icon src={iconSrc} size="100" />}
- {avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />}
- {!avatarUrl && !iconSrc && (
- <AvatarFallback
- style={{
- backgroundColor: color.Secondary.Container,
- color: color.Secondary.OnContainer,
- }}
- >
- <Text size="H6">{room.name[0]}</Text>
- </AvatarFallback>
+ {dm ? (
+ <RoomAvatar
+ roomId={room.roomId}
+ src={getDirectRoomAvatarUrl(mx, room)}
+ alt={room.name}
+ renderFallback={() => (
+ <RoomIcon
+ size="50"
+ joinRule={room.getJoinRule() ?? JoinRule.Restricted}
+ filled
+ />
+ )}
+ />
+ ) : (
+ <RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
)}
</Avatar>
}
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate';
-import { Avatar, AvatarFallback, AvatarImage, MenuItem, Text, color } from 'folds';
+import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
+import { UserAvatar } from '../../user-avatar';
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownMentionItem({
- query,
userId,
name,
handleAutocomplete,
}: {
- query: AutocompleteQuery<string>;
userId: string;
name: string;
handleAutocomplete: MentionAutoCompleteHandler;
onClick={() => handleAutocomplete(userId, name)}
before={
<Avatar size="200">
- <AvatarFallback
- style={{
- backgroundColor: color.Secondary.Container,
- color: color.Secondary.OnContainer,
- }}
- >
- <Text size="H6">{query.text[0]}</Text>
- </AvatarFallback>
+ <UserAvatar
+ userId={userId}
+ renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+ />
</Avatar>
}
>
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
{query.text === 'room' && (
<UnknownMentionItem
- query={query}
userId={roomAliasOrId}
name="@room"
handleAutocomplete={handleAutocomplete}
)}
{autoCompleteMembers.length === 0 ? (
<UnknownMentionItem
- query={query}
userId={userIdFromQueryText(mx, query.text)}
name={userIdFromQueryText(mx, query.text)}
handleAutocomplete={handleAutocomplete}
}
before={
<Avatar size="200">
- {avatarUrl ? (
- <AvatarImage src={avatarUrl} alt={getName(roomMember)} />
- ) : (
- <AvatarFallback
- style={{
- backgroundColor: color.Secondary.Container,
- color: color.Secondary.OnContainer,
- }}
- >
- <Text size="H6">{getName(roomMember)[0]}</Text>
- </AvatarFallback>
- )}
+ <UserAvatar
+ userId={roomMember.userId}
+ src={avatarUrl ?? undefined}
+ alt={getName(roomMember)}
+ renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+ />
</Avatar>
}
>
import classNames from 'classnames';
import {
Avatar,
- AvatarFallback,
- AvatarImage,
Box,
Header,
Icon,
import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
-import colorMXID from '../../../util/colorMXID';
import { openProfileViewer } from '../../../client/action/navigation';
+import { UserAvatar } from '../user-avatar';
export type EventReadersProps = {
room: Room;
}}
before={
<Avatar size="200">
- {avatarUrl ? (
- <AvatarImage src={avatarUrl} />
- ) : (
- <AvatarFallback
- style={{
- background: colorMXID(readerId),
- color: 'white',
- }}
- >
- <Text size="H6">{name[0]}</Text>
- </AvatarFallback>
- )}
+ <UserAvatar
+ userId={readerId}
+ src={avatarUrl ?? undefined}
+ alt={name}
+ renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+ />
</Avatar>
}
>
--- /dev/null
+import React, { useCallback, useEffect } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Dialog,
+ Overlay,
+ OverlayCenter,
+ OverlayBackdrop,
+ Header,
+ config,
+ Box,
+ Text,
+ IconButton,
+ Icon,
+ Icons,
+ color,
+ Button,
+ Spinner,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+
+type LeaveRoomPromptProps = {
+ roomId: string;
+ onDone: () => void;
+ onCancel: () => void;
+};
+export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
+ const mx = useMatrixClient();
+
+ const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
+ useCallback(async () => {
+ mx.leave(roomId);
+ }, [mx, roomId])
+ );
+
+ const handleLeave = () => {
+ leaveRoom();
+ };
+
+ useEffect(() => {
+ if (leaveState.status === AsyncStatus.Success) {
+ onDone();
+ }
+ }, [leaveState, onDone]);
+
+ return (
+ <Overlay open backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: onCancel,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Leave Room</Text>
+ </Box>
+ <IconButton size="300" onClick={onCancel} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+ <Box direction="Column" gap="200">
+ <Text priority="400">Are you sure you want to leave this room?</Text>
+ {leaveState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T300">
+ Failed to leave room! {leaveState.error.message}
+ </Text>
+ )}
+ </Box>
+ <Button
+ type="submit"
+ variant="Critical"
+ onClick={handleLeave}
+ before={
+ leaveState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" variant="Critical" size="200" />
+ ) : undefined
+ }
+ aria-disabled={
+ leaveState.status === AsyncStatus.Loading ||
+ leaveState.status === AsyncStatus.Success
+ }
+ >
+ <Text size="B400">
+ {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
+ </Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ );
+}
--- /dev/null
+export * from './LeaveRoomPrompt';
--- /dev/null
+import React, { useCallback, useEffect } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Dialog,
+ Overlay,
+ OverlayCenter,
+ OverlayBackdrop,
+ Header,
+ config,
+ Box,
+ Text,
+ IconButton,
+ Icon,
+ Icons,
+ color,
+ Button,
+ Spinner,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+
+type LeaveSpacePromptProps = {
+ roomId: string;
+ onDone: () => void;
+ onCancel: () => void;
+};
+export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
+ const mx = useMatrixClient();
+
+ const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
+ useCallback(async () => {
+ mx.leave(roomId);
+ }, [mx, roomId])
+ );
+
+ const handleLeave = () => {
+ leaveRoom();
+ };
+
+ useEffect(() => {
+ if (leaveState.status === AsyncStatus.Success) {
+ onDone();
+ }
+ }, [leaveState, onDone]);
+
+ return (
+ <Overlay open backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: onCancel,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Leave Space</Text>
+ </Box>
+ <IconButton size="300" onClick={onCancel} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+ <Box direction="Column" gap="200">
+ <Text priority="400">Are you sure you want to leave this space?</Text>
+ {leaveState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T300">
+ Failed to leave space! {leaveState.error.message}
+ </Text>
+ )}
+ </Box>
+ <Button
+ type="submit"
+ variant="Critical"
+ onClick={handleLeave}
+ before={
+ leaveState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" variant="Critical" size="200" />
+ ) : undefined
+ }
+ aria-disabled={
+ leaveState.status === AsyncStatus.Loading ||
+ leaveState.status === AsyncStatus.Success
+ }
+ >
+ <Text size="B400">
+ {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
+ </Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ );
+}
--- /dev/null
+export * from './LeaveSpacePrompt';
--- /dev/null
+import { Badge, Box, Text, as, toRem } from 'folds';
+import React from 'react';
+import { mimeTypeToExt } from '../../utils/mimeTypes';
+
+const badgeStyles = { maxWidth: toRem(100) };
+
+export type FileHeaderProps = {
+ body: string;
+ mimeType: string;
+};
+export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
+ <Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
+ <Badge style={badgeStyles} variant="Secondary" radii="Pill">
+ <Text size="O400" truncate>
+ {mimeTypeToExt(mimeType)}
+ </Text>
+ </Badge>
+ <Text size="T300" truncate>
+ {body}
+ </Text>
+ </Box>
+));
+++ /dev/null
-import { Box, Icon, Icons, Text, as, color, config } from 'folds';
-import React from 'react';
-
-const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
-const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
-
-export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
- ({ reason, ...props }, ref) => (
- <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
- <Icon size="50" src={Icons.Delete} />
- {reason ? (
- <i>This message has been deleted. {reason}</i>
- ) : (
- <i>This message has been deleted</i>
- )}
- </Box>
- )
-);
-
-export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
- <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
- <Icon size="50" src={Icons.Warning} />
- <i>Unsupported message</i>
- </Box>
-));
-
-export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
- <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
- <Icon size="50" src={Icons.Warning} />
- <i>Failed to load message</i>
- </Box>
-));
-
-export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
- <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
- <Icon size="50" src={Icons.Lock} />
- <i>Unable to decrypt message</i>
- </Box>
-));
-
-export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
- <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
- <Icon size="50" src={Icons.Lock} />
- <i>This message is not decrypted yet</i>
- </Box>
-));
-
-export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
- <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
- <Icon size="50" src={Icons.Warning} />
- <i>Broken message</i>
- </Box>
-));
-
-export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
- <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
- <Icon size="50" src={Icons.Warning} />
- <i>Empty message</i>
- </Box>
-));
-
-export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
- <Text as="span" size="T200" priority="300" {...props} ref={ref}>
- {' (edited)'}
- </Text>
-));
--- /dev/null
+import React, { ReactNode } from 'react';
+import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
+import { IContent } from 'matrix-js-sdk';
+import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
+import { trimReplyFromBody } from '../../utils/room';
+import { MessageTextBody } from './layout';
+import {
+ MessageBadEncryptedContent,
+ MessageBrokenContent,
+ MessageDeletedContent,
+ MessageEditedContent,
+ MessageUnsupportedContent,
+} from './content';
+import {
+ IAudioContent,
+ IAudioInfo,
+ IEncryptedFile,
+ IFileContent,
+ IFileInfo,
+ IImageContent,
+ IImageInfo,
+ IThumbnailContent,
+ IVideoContent,
+ IVideoInfo,
+} from '../../../types/matrix/common';
+import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
+import { parseGeoUri, scaleYDimension } from '../../utils/common';
+import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
+import { FileHeader } from './FileHeader';
+
+export function MBadEncrypted() {
+ return (
+ <Text>
+ <MessageBadEncryptedContent />
+ </Text>
+ );
+}
+
+type RedactedContentProps = {
+ reason?: string;
+};
+export function RedactedContent({ reason }: RedactedContentProps) {
+ return (
+ <Text>
+ <MessageDeletedContent reason={reason} />
+ </Text>
+ );
+}
+
+export function UnsupportedContent() {
+ return (
+ <Text>
+ <MessageUnsupportedContent />
+ </Text>
+ );
+}
+
+export function BrokenContent() {
+ return (
+ <Text>
+ <MessageBrokenContent />
+ </Text>
+ );
+}
+
+type RenderBodyProps = {
+ body: string;
+ customBody?: string;
+};
+type MTextProps = {
+ edited?: boolean;
+ content: Record<string, unknown>;
+ renderBody: (props: RenderBodyProps) => ReactNode;
+ renderUrlsPreview?: (urls: string[]) => ReactNode;
+};
+export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextProps) {
+ const { body, formatted_body: customBody } = content;
+
+ if (typeof body !== 'string') return <BrokenContent />;
+ const trimmedBody = trimReplyFromBody(body);
+ const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
+ const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
+
+ return (
+ <>
+ <MessageTextBody
+ preWrap={typeof customBody !== 'string'}
+ jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+ >
+ {renderBody({
+ body: trimmedBody,
+ customBody: typeof customBody === 'string' ? customBody : undefined,
+ })}
+ {edited && <MessageEditedContent />}
+ </MessageTextBody>
+ {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
+ </>
+ );
+}
+
+type MEmoteProps = {
+ displayName: string;
+ edited?: boolean;
+ content: Record<string, unknown>;
+ renderBody: (props: RenderBodyProps) => ReactNode;
+ renderUrlsPreview?: (urls: string[]) => ReactNode;
+};
+export function MEmote({
+ displayName,
+ edited,
+ content,
+ renderBody,
+ renderUrlsPreview,
+}: MEmoteProps) {
+ const { body, formatted_body: customBody } = content;
+
+ if (typeof body !== 'string') return <BrokenContent />;
+ const trimmedBody = trimReplyFromBody(body);
+ const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
+ const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
+
+ return (
+ <>
+ <MessageTextBody
+ emote
+ preWrap={typeof customBody !== 'string'}
+ jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+ >
+ <b>{`${displayName} `}</b>
+ {renderBody({
+ body: trimmedBody,
+ customBody: typeof customBody === 'string' ? customBody : undefined,
+ })}
+ {edited && <MessageEditedContent />}
+ </MessageTextBody>
+ {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
+ </>
+ );
+}
+
+type MNoticeProps = {
+ edited?: boolean;
+ content: Record<string, unknown>;
+ renderBody: (props: RenderBodyProps) => ReactNode;
+ renderUrlsPreview?: (urls: string[]) => ReactNode;
+};
+export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
+ const { body, formatted_body: customBody } = content;
+
+ if (typeof body !== 'string') return <BrokenContent />;
+ const trimmedBody = trimReplyFromBody(body);
+ const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
+ const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
+
+ return (
+ <>
+ <MessageTextBody
+ notice
+ preWrap={typeof customBody !== 'string'}
+ jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+ >
+ {renderBody({
+ body: trimmedBody,
+ customBody: typeof customBody === 'string' ? customBody : undefined,
+ })}
+ {edited && <MessageEditedContent />}
+ </MessageTextBody>
+ {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
+ </>
+ );
+}
+
+type RenderImageContentProps = {
+ body: string;
+ info?: IImageInfo & IThumbnailContent;
+ mimeType?: string;
+ url: string;
+ encInfo?: IEncryptedFile;
+};
+type MImageProps = {
+ content: IImageContent;
+ renderImageContent: (props: RenderImageContentProps) => ReactNode;
+ outlined?: boolean;
+};
+export function MImage({ content, renderImageContent, outlined }: MImageProps) {
+ const imgInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+ if (typeof mxcUrl !== 'string') {
+ return <BrokenContent />;
+ }
+ const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
+
+ return (
+ <Attachment outlined={outlined}>
+ <AttachmentBox
+ style={{
+ height: toRem(height < 48 ? 48 : height),
+ }}
+ >
+ {renderImageContent({
+ body: content.body || 'Image',
+ info: imgInfo,
+ mimeType: imgInfo?.mimetype,
+ url: mxcUrl,
+ encInfo: content.file,
+ })}
+ </AttachmentBox>
+ </Attachment>
+ );
+}
+
+type RenderVideoContentProps = {
+ body: string;
+ info: IVideoInfo & IThumbnailContent;
+ mimeType: string;
+ url: string;
+ encInfo?: IEncryptedFile;
+};
+type MVideoProps = {
+ content: IVideoContent;
+ renderAsFile: () => ReactNode;
+ renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
+ outlined?: boolean;
+};
+export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) {
+ const videoInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+ const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
+
+ if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
+ if (mxcUrl) {
+ return renderAsFile();
+ }
+ return <BrokenContent />;
+ }
+
+ const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
+
+ return (
+ <Attachment outlined={outlined}>
+ <AttachmentBox
+ style={{
+ height: toRem(height < 48 ? 48 : height),
+ }}
+ >
+ {renderVideoContent({
+ body: content.body || 'Video',
+ info: videoInfo,
+ mimeType: safeMimeType,
+ url: mxcUrl,
+ encInfo: content.file,
+ })}
+ </AttachmentBox>
+ </Attachment>
+ );
+}
+
+type RenderAudioContentProps = {
+ info: IAudioInfo;
+ mimeType: string;
+ url: string;
+ encInfo?: IEncryptedFile;
+};
+type MAudioProps = {
+ content: IAudioContent;
+ renderAsFile: () => ReactNode;
+ renderAudioContent: (props: RenderAudioContentProps) => ReactNode;
+ outlined?: boolean;
+};
+export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: MAudioProps) {
+ const audioInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+ const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
+
+ if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
+ if (mxcUrl) {
+ return renderAsFile();
+ }
+ return <BrokenContent />;
+ }
+
+ return (
+ <Attachment outlined={outlined}>
+ <AttachmentHeader>
+ <FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
+ </AttachmentHeader>
+ <AttachmentBox>
+ <AttachmentContent>
+ {renderAudioContent({
+ info: audioInfo,
+ mimeType: safeMimeType,
+ url: mxcUrl,
+ encInfo: content.file,
+ })}
+ </AttachmentContent>
+ </AttachmentBox>
+ </Attachment>
+ );
+}
+
+type RenderFileContentProps = {
+ body: string;
+ info: IFileInfo & IThumbnailContent;
+ mimeType: string;
+ url: string;
+ encInfo?: IEncryptedFile;
+};
+type MFileProps = {
+ content: IFileContent;
+ renderFileContent: (props: RenderFileContentProps) => ReactNode;
+ outlined?: boolean;
+};
+export function MFile({ content, renderFileContent, outlined }: MFileProps) {
+ const fileInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+
+ if (typeof mxcUrl !== 'string') {
+ return <BrokenContent />;
+ }
+
+ return (
+ <Attachment outlined={outlined}>
+ <AttachmentHeader>
+ <FileHeader
+ body={content.body ?? 'Unnamed File'}
+ mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
+ />
+ </AttachmentHeader>
+ <AttachmentBox>
+ <AttachmentContent>
+ {renderFileContent({
+ body: content.body ?? 'File',
+ info: fileInfo ?? {},
+ mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
+ url: mxcUrl,
+ encInfo: content.file,
+ })}
+ </AttachmentContent>
+ </AttachmentBox>
+ </Attachment>
+ );
+}
+
+type MLocationProps = {
+ content: IContent;
+};
+export function MLocation({ content }: MLocationProps) {
+ const geoUri = content.geo_uri;
+ if (typeof geoUri !== 'string') return <BrokenContent />;
+ const location = parseGeoUri(geoUri);
+ return (
+ <Box direction="Column" alignItems="Start" gap="100">
+ <Text size="T400">{geoUri}</Text>
+ <Chip
+ as="a"
+ size="400"
+ href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ variant="Primary"
+ radii="Pill"
+ before={<Icon src={Icons.External} size="50" />}
+ >
+ <Text size="B300">Open Location</Text>
+ </Chip>
+ </Box>
+ );
+}
+
+type MStickerProps = {
+ content: IImageContent;
+ renderImageContent: (props: RenderImageContentProps) => ReactNode;
+};
+export function MSticker({ content, renderImageContent }: MStickerProps) {
+ const imgInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+ if (typeof mxcUrl !== 'string') {
+ return <MessageBrokenContent />;
+ }
+ const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
+
+ return (
+ <AttachmentBox
+ style={{
+ height: toRem(height < 48 ? 48 : height),
+ width: toRem(152),
+ }}
+ >
+ {renderImageContent({
+ body: content.body || 'Sticker',
+ info: imgInfo,
+ mimeType: imgInfo?.mimetype,
+ url: mxcUrl,
+ encInfo: content.file,
+ })}
+ </AttachmentBox>
+ );
+}
--- /dev/null
+import React from 'react';
+import parse, { HTMLReactParserOptions } from 'html-react-parser';
+import Linkify from 'linkify-react';
+import { MessageEmptyContent } from './content';
+import { sanitizeCustomHtml } from '../../utils/sanitize';
+import {
+ LINKIFY_OPTS,
+ highlightText,
+ scaleSystemEmoji,
+} from '../../plugins/react-custom-html-parser';
+
+type RenderBodyProps = {
+ body: string;
+ customBody?: string;
+
+ highlightRegex?: RegExp;
+ htmlReactParserOptions: HTMLReactParserOptions;
+};
+export function RenderBody({
+ body,
+ customBody,
+ highlightRegex,
+ htmlReactParserOptions,
+}: RenderBodyProps) {
+ if (body === '') <MessageEmptyContent />;
+ if (customBody) {
+ if (customBody === '') <MessageEmptyContent />;
+ return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
+ }
+ return (
+ <Linkify options={LINKIFY_OPTS}>
+ {highlightRegex
+ ? highlightText(highlightRegex, scaleSystemEmoji(body))
+ : scaleSystemEmoji(body)}
+ </Linkify>
+ );
+}
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
+export const ReplyBend = style({
+ flexShrink: 0,
+});
+
export const Reply = style({
- padding: `0 ${config.space.S100}`,
marginBottom: toRem(1),
- cursor: 'pointer',
minWidth: 0,
maxWidth: '100%',
minHeight: config.lineHeight.T300,
+ selectors: {
+ 'button&': {
+ cursor: 'pointer',
+ },
+ },
});
export const ReplyContent = style({
},
},
});
-
-export const ReplyContentText = style({
- paddingRight: config.space.S100,
-});
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
-import React, { useEffect, useState } from 'react';
+import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import to from 'await-to-js';
import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css';
-import {
- MessageBadEncryptedContent,
- MessageDeletedContent,
- MessageFailedContent,
-} from './MessageContentFallback';
+import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
+import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
+
+type ReplyLayoutProps = {
+ userColor?: string;
+ username?: ReactNode;
+};
+export const ReplyLayout = as<'div', ReplyLayoutProps>(
+ ({ username, userColor, className, children, ...props }, ref) => (
+ <Box
+ className={classNames(css.Reply, className)}
+ alignItems="Center"
+ gap="100"
+ {...props}
+ ref={ref}
+ >
+ <Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
+ <Icon size="100" src={Icons.ReplyArrow} />
+ {username}
+ </Box>
+ <Box grow="Yes" className={css.ReplyContent}>
+ {children}
+ </Box>
+ </Box>
+ )
+);
type ReplyProps = {
mx: MatrixClient;
room: Room;
- timelineSet: EventTimelineSet;
+ timelineSet?: EventTimelineSet;
eventId: string;
};
-export const Reply = as<'div', ReplyProps>(
- ({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
- const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
- timelineSet.findEventById(eventId)
- );
+export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ...props }, ref) => {
+ const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
+ timelineSet?.findEventById(eventId)
+ );
+ const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
- const { body } = replyEvent?.getContent() ?? {};
- const sender = replyEvent?.getSender();
+ const { body } = replyEvent?.getContent() ?? {};
+ const sender = replyEvent?.getSender();
- const fallbackBody = replyEvent?.isRedacted() ? (
- <MessageDeletedContent />
- ) : (
- <MessageFailedContent />
- );
+ const fallbackBody = replyEvent?.isRedacted() ? (
+ <MessageDeletedContent />
+ ) : (
+ <MessageFailedContent />
+ );
- useEffect(() => {
- let disposed = false;
- const loadEvent = async () => {
- const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
- const mEvent = new MatrixEvent(evt);
- if (disposed) return;
- if (err) {
- setReplyEvent(null);
- return;
- }
- if (mEvent.isEncrypted() && mx.getCrypto()) {
- await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
- }
- setReplyEvent(mEvent);
- };
- if (replyEvent === undefined) loadEvent();
- return () => {
- disposed = true;
- };
- }, [replyEvent, mx, room, eventId]);
+ useEffect(() => {
+ let disposed = false;
+ const loadEvent = async () => {
+ const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
+ const mEvent = new MatrixEvent(evt);
+ if (disposed) return;
+ if (err) {
+ setReplyEvent(null);
+ return;
+ }
+ if (mEvent.isEncrypted() && mx.getCrypto()) {
+ await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
+ }
+ setReplyEvent(mEvent);
+ };
+ if (replyEvent === undefined) loadEvent();
+ return () => {
+ disposed = true;
+ };
+ }, [replyEvent, mx, room, eventId]);
- const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
- const bodyJSX = body ? trimReplyFromBody(body) : fallbackBody;
+ const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
+ const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
- return (
- <Box
- className={classNames(css.Reply, className)}
- alignItems="Center"
- gap="100"
- {...props}
- ref={ref}
- >
- <Box
- style={{ color: colorMXID(sender ?? eventId), maxWidth: '50%' }}
- alignItems="Center"
- shrink="No"
- >
- <Icon src={Icons.ReplyArrow} size="50" />
- {sender && (
- <Text size="T300" truncate>
- {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
- </Text>
- )}
- </Box>
- <Box grow="Yes" className={css.ReplyContent}>
- {replyEvent !== undefined ? (
- <Text className={css.ReplyContentText} size="T300" truncate>
- {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
- </Text>
- ) : (
- <LinePlaceholder
- style={{
- backgroundColor: color.SurfaceVariant.ContainerActive,
- maxWidth: toRem(randomNumberBetween(40, 400)),
- width: '100%',
- }}
- />
- )}
- </Box>
- </Box>
- );
- }
-);
+ return (
+ <ReplyLayout
+ userColor={sender ? colorMXID(sender) : undefined}
+ username={
+ sender && (
+ <Text size="T300" truncate>
+ <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
+ </Text>
+ )
+ }
+ {...props}
+ ref={ref}
+ >
+ {replyEvent !== undefined ? (
+ <Text size="T300" truncate>
+ {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
+ </Text>
+ ) : (
+ <LinePlaceholder
+ style={{
+ backgroundColor: color.SurfaceVariant.ContainerActive,
+ maxWidth: toRem(placeholderWidth),
+ width: '100%',
+ }}
+ />
+ )}
+ </ReplyLayout>
+ );
+});
-import React from 'react';
+import React, { ComponentProps } from 'react';
import { Text, as } from 'folds';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
ts: number;
};
-export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
- let time = '';
- if (compact) {
- time = timeHourMinute(ts);
- } else if (today(ts)) {
- time = timeHourMinute(ts);
- } else if (yesterday(ts)) {
- time = `Yesterday ${timeHourMinute(ts)}`;
- } else {
- time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
- }
+export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
+ ({ compact, ts, ...props }, ref) => {
+ let time = '';
+ if (compact) {
+ time = timeHourMinute(ts);
+ } else if (today(ts)) {
+ time = timeHourMinute(ts);
+ } else if (yesterday(ts)) {
+ time = `Yesterday ${timeHourMinute(ts)}`;
+ } else {
+ time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
+ }
- return (
- <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
- {time}
- </Text>
- );
-});
+ return (
+ <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
+ {time}
+ </Text>
+ );
+ }
+);
--- /dev/null
+/* eslint-disable jsx-a11y/media-has-caption */
+import React, { ReactNode, useCallback, useRef, useState } from 'react';
+import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { Range } from 'react-range';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+import { IAudioInfo } from '../../../../types/matrix/common';
+import {
+ PlayTimeCallback,
+ useMediaLoading,
+ useMediaPlay,
+ useMediaPlayTimeCallback,
+ useMediaSeek,
+ useMediaVolume,
+} from '../../../hooks/media';
+import { useThrottle } from '../../../hooks/useThrottle';
+import { secondsToMinutesAndSeconds } from '../../../utils/common';
+
+const PLAY_TIME_THROTTLE_OPS = {
+ wait: 500,
+ immediate: true,
+};
+
+type RenderMediaControlProps = {
+ after: ReactNode;
+ leftControl: ReactNode;
+ rightControl: ReactNode;
+ children: ReactNode;
+};
+export type AudioContentProps = {
+ mimeType: string;
+ url: string;
+ info: IAudioInfo;
+ encInfo?: EncryptedAttachmentInfo;
+ renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
+};
+export function AudioContent({
+ mimeType,
+ url,
+ info,
+ encInfo,
+ renderMediaControl,
+}: AudioContentProps) {
+ const mx = useMatrixClient();
+
+ const [srcState, loadSrc] = useAsyncCallback(
+ useCallback(
+ () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+ [mx, url, mimeType, encInfo]
+ )
+ );
+
+ const audioRef = useRef<HTMLAudioElement | null>(null);
+
+ const [currentTime, setCurrentTime] = useState(0);
+ // duration in seconds. (NOTE: info.duration is in milliseconds)
+ const infoDuration = info.duration ?? 0;
+ const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
+
+ const getAudioRef = useCallback(() => audioRef.current, []);
+ const { loading } = useMediaLoading(getAudioRef);
+ const { playing, setPlaying } = useMediaPlay(getAudioRef);
+ const { seek } = useMediaSeek(getAudioRef);
+ const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
+ const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
+ setDuration(d);
+ setCurrentTime(ct);
+ }, []);
+ useMediaPlayTimeCallback(
+ getAudioRef,
+ useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
+ );
+
+ const handlePlay = () => {
+ if (srcState.status === AsyncStatus.Success) {
+ setPlaying(!playing);
+ } else if (srcState.status !== AsyncStatus.Loading) {
+ loadSrc();
+ }
+ };
+
+ return renderMediaControl({
+ after: (
+ <Range
+ step={1}
+ min={0}
+ max={duration || 1}
+ values={[currentTime]}
+ onChange={(values) => seek(values[0])}
+ renderTrack={(params) => (
+ <div {...params.props}>
+ {params.children}
+ <ProgressBar
+ as="div"
+ variant="Secondary"
+ size="300"
+ min={0}
+ max={duration}
+ value={currentTime}
+ radii="300"
+ />
+ </div>
+ )}
+ renderThumb={(params) => (
+ <Badge
+ size="300"
+ variant="Secondary"
+ fill="Solid"
+ radii="Pill"
+ outlined
+ {...params.props}
+ style={{
+ ...params.props.style,
+ zIndex: 0,
+ }}
+ />
+ )}
+ />
+ ),
+ leftControl: (
+ <>
+ <Chip
+ onClick={handlePlay}
+ variant="Secondary"
+ radii="300"
+ disabled={srcState.status === AsyncStatus.Loading}
+ before={
+ srcState.status === AsyncStatus.Loading || loading ? (
+ <Spinner variant="Secondary" size="50" />
+ ) : (
+ <Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
+ )
+ }
+ >
+ <Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
+ </Chip>
+
+ <Text size="T200">{`${secondsToMinutesAndSeconds(
+ currentTime
+ )} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
+ </>
+ ),
+ rightControl: (
+ <>
+ <IconButton
+ variant="SurfaceVariant"
+ size="300"
+ radii="Pill"
+ onClick={() => setMute(!mute)}
+ aria-pressed={mute}
+ >
+ <Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
+ </IconButton>
+ <Range
+ step={0.1}
+ min={0}
+ max={1}
+ values={[volume]}
+ onChange={(values) => setVolume(values[0])}
+ renderTrack={(params) => (
+ <div {...params.props}>
+ {params.children}
+ <ProgressBar
+ style={{ width: toRem(48) }}
+ variant="Secondary"
+ size="300"
+ min={0}
+ max={1}
+ value={volume}
+ radii="300"
+ />
+ </div>
+ )}
+ renderThumb={(params) => (
+ <Badge
+ size="300"
+ variant="Secondary"
+ fill="Solid"
+ radii="Pill"
+ outlined
+ {...params.props}
+ style={{
+ ...params.props.style,
+ zIndex: 0,
+ }}
+ />
+ )}
+ />
+ </>
+ ),
+ children: (
+ <audio controls={false} autoPlay ref={audioRef}>
+ {srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
+ </audio>
+ ),
+ });
+}
--- /dev/null
+import { Box, Icon, IconSrc } from 'folds';
+import React, { ReactNode } from 'react';
+import { CompactLayout, ModernLayout } from '..';
+
+export type EventContentProps = {
+ messageLayout: number;
+ time: ReactNode;
+ iconSrc: IconSrc;
+ content: ReactNode;
+};
+export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
+ const beforeJSX = (
+ <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+ {messageLayout === 1 && time}
+ <Box
+ grow={messageLayout === 1 ? undefined : 'Yes'}
+ alignItems="Center"
+ justifyContent="Center"
+ >
+ <Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
+ </Box>
+ </Box>
+ );
+
+ const msgContentJSX = (
+ <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
+ {content}
+ {messageLayout !== 1 && time}
+ </Box>
+ );
+
+ return messageLayout === 1 ? (
+ <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
+ ) : (
+ <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
+ );
+}
--- /dev/null
+import { Box, Icon, Icons, Text, as, color, config } from 'folds';
+import React from 'react';
+
+const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
+const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
+
+export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
+ ({ reason, ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Delete} />
+ {reason ? (
+ <i>This message has been deleted. {reason}</i>
+ ) : (
+ <i>This message has been deleted</i>
+ )}
+ </Box>
+ )
+);
+
+export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Warning} />
+ <i>Unsupported message</i>
+ </Box>
+));
+
+export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Warning} />
+ <i>Failed to load message</i>
+ </Box>
+));
+
+export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Lock} />
+ <i>Unable to decrypt message</i>
+ </Box>
+));
+
+export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Lock} />
+ <i>This message is not decrypted yet</i>
+ </Box>
+));
+
+export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Warning} />
+ <i>Broken message</i>
+ </Box>
+));
+
+export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Warning} />
+ <i>Empty message</i>
+ </Box>
+));
+
+export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
+ <Text as="span" size="T200" priority="300" {...props} ref={ref}>
+ {' (edited)'}
+ </Text>
+));
--- /dev/null
+import React, { ReactNode, useCallback, useState } from 'react';
+import {
+ Box,
+ Button,
+ Icon,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Spinner,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+} from 'folds';
+import FileSaver from 'file-saver';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import FocusTrap from 'focus-trap-react';
+import { IFileInfo } from '../../../../types/matrix/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getFileSrcUrl, getSrcFile } from './util';
+import { bytesToSize } from '../../../utils/common';
+import {
+ READABLE_EXT_TO_MIME_TYPE,
+ READABLE_TEXT_MIME_TYPES,
+ getFileNameExt,
+ mimeTypeToExt,
+} from '../../../utils/mimeTypes';
+import * as css from './style.css';
+
+const renderErrorButton = (retry: () => void, text: string) => (
+ <TooltipProvider
+ tooltip={
+ <Tooltip variant="Critical">
+ <Text>Failed to load file!</Text>
+ </Tooltip>
+ }
+ position="Top"
+ align="Center"
+ >
+ {(triggerRef) => (
+ <Button
+ ref={triggerRef}
+ size="400"
+ variant="Critical"
+ fill="Soft"
+ outlined
+ radii="300"
+ onClick={retry}
+ before={<Icon size="100" src={Icons.Warning} filled />}
+ >
+ <Text size="B400" truncate>
+ {text}
+ </Text>
+ </Button>
+ )}
+ </TooltipProvider>
+);
+
+type RenderTextViewerProps = {
+ name: string;
+ text: string;
+ langName: string;
+ requestClose: () => void;
+};
+type ReadTextFileProps = {
+ body: string;
+ mimeType: string;
+ url: string;
+ encInfo?: EncryptedAttachmentInfo;
+ renderViewer: (props: RenderTextViewerProps) => ReactNode;
+};
+export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
+ const mx = useMatrixClient();
+ const [textViewer, setTextViewer] = useState(false);
+
+ const loadSrc = useCallback(
+ () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+ [mx, url, mimeType, encInfo]
+ );
+
+ const [textState, loadText] = useAsyncCallback(
+ useCallback(async () => {
+ const src = await loadSrc();
+ const blob = await getSrcFile(src);
+ const text = blob.text();
+ setTextViewer(true);
+ return text;
+ }, [loadSrc])
+ );
+
+ return (
+ <>
+ {textState.status === AsyncStatus.Success && (
+ <Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setTextViewer(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal
+ className={css.ModalWide}
+ size="500"
+ onContextMenu={(evt: any) => evt.stopPropagation()}
+ >
+ {renderViewer({
+ name: body,
+ text: textState.data,
+ langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
+ ? mimeTypeToExt(mimeType)
+ : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
+ requestClose: () => setTextViewer(false),
+ })}
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ {textState.status === AsyncStatus.Error ? (
+ renderErrorButton(loadText, 'Open File')
+ ) : (
+ <Button
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ size="400"
+ onClick={() =>
+ textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
+ }
+ disabled={textState.status === AsyncStatus.Loading}
+ before={
+ textState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" size="100" variant="Secondary" />
+ ) : (
+ <Icon size="100" src={Icons.ArrowRight} filled />
+ )
+ }
+ >
+ <Text size="B400" truncate>
+ Open File
+ </Text>
+ </Button>
+ )}
+ </>
+ );
+}
+
+type RenderPdfViewerProps = {
+ name: string;
+ src: string;
+ requestClose: () => void;
+};
+export type ReadPdfFileProps = {
+ body: string;
+ mimeType: string;
+ url: string;
+ encInfo?: EncryptedAttachmentInfo;
+ renderViewer: (props: RenderPdfViewerProps) => ReactNode;
+};
+export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
+ const mx = useMatrixClient();
+ const [pdfViewer, setPdfViewer] = useState(false);
+
+ const [pdfState, loadPdf] = useAsyncCallback(
+ useCallback(async () => {
+ const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
+ setPdfViewer(true);
+ return httpUrl;
+ }, [mx, url, mimeType, encInfo])
+ );
+
+ return (
+ <>
+ {pdfState.status === AsyncStatus.Success && (
+ <Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setPdfViewer(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal
+ className={css.ModalWide}
+ size="500"
+ onContextMenu={(evt: any) => evt.stopPropagation()}
+ >
+ {renderViewer({
+ name: body,
+ src: pdfState.data,
+ requestClose: () => setPdfViewer(false),
+ })}
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ {pdfState.status === AsyncStatus.Error ? (
+ renderErrorButton(loadPdf, 'Open PDF')
+ ) : (
+ <Button
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ size="400"
+ onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
+ disabled={pdfState.status === AsyncStatus.Loading}
+ before={
+ pdfState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" size="100" variant="Secondary" />
+ ) : (
+ <Icon size="100" src={Icons.ArrowRight} filled />
+ )
+ }
+ >
+ <Text size="B400" truncate>
+ Open PDF
+ </Text>
+ </Button>
+ )}
+ </>
+ );
+}
+
+export type DownloadFileProps = {
+ body: string;
+ mimeType: string;
+ url: string;
+ info: IFileInfo;
+ encInfo?: EncryptedAttachmentInfo;
+};
+export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
+ const mx = useMatrixClient();
+
+ const [downloadState, download] = useAsyncCallback(
+ useCallback(async () => {
+ const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
+ FileSaver.saveAs(httpUrl, body);
+ return httpUrl;
+ }, [mx, url, mimeType, encInfo, body])
+ );
+
+ return downloadState.status === AsyncStatus.Error ? (
+ renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
+ ) : (
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ size="400"
+ onClick={() =>
+ downloadState.status === AsyncStatus.Success
+ ? FileSaver.saveAs(downloadState.data, body)
+ : download()
+ }
+ disabled={downloadState.status === AsyncStatus.Loading}
+ before={
+ downloadState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Soft" size="100" variant="Secondary" />
+ ) : (
+ <Icon size="100" src={Icons.Download} filled />
+ )
+ }
+ >
+ <Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
+ </Button>
+ );
+}
+
+type FileContentProps = {
+ body: string;
+ mimeType: string;
+ renderAsTextFile: () => ReactNode;
+ renderAsPdfFile: () => ReactNode;
+};
+export const FileContent = as<'div', FileContentProps>(
+ ({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
+ <Box direction="Column" gap="300" {...props} ref={ref}>
+ {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
+ READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
+ renderAsTextFile()}
+ {mimeType === 'application/pdf' && renderAsPdfFile()}
+ {children}
+ </Box>
+ )
+);
--- /dev/null
+import React, { ReactNode, useCallback, useEffect, useState } from 'react';
+import {
+ Badge,
+ Box,
+ Button,
+ Icon,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Spinner,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+} from 'folds';
+import classNames from 'classnames';
+import { BlurhashCanvas } from 'react-blurhash';
+import FocusTrap from 'focus-trap-react';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getFileSrcUrl } from './util';
+import * as css from './style.css';
+import { bytesToSize } from '../../../utils/common';
+import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
+
+type RenderViewerProps = {
+ src: string;
+ alt: string;
+ requestClose: () => void;
+};
+type RenderImageProps = {
+ alt: string;
+ title: string;
+ src: string;
+ onLoad: () => void;
+ onError: () => void;
+ onClick: () => void;
+ tabIndex: number;
+};
+export type ImageContentProps = {
+ body: string;
+ mimeType?: string;
+ url: string;
+ info?: IImageInfo;
+ encInfo?: EncryptedAttachmentInfo;
+ autoPlay?: boolean;
+ renderViewer: (props: RenderViewerProps) => ReactNode;
+ renderImage: (props: RenderImageProps) => ReactNode;
+};
+export const ImageContent = as<'div', ImageContentProps>(
+ (
+ {
+ className,
+ body,
+ mimeType,
+ url,
+ info,
+ encInfo,
+ autoPlay,
+ renderViewer,
+ renderImage,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
+
+ const [load, setLoad] = useState(false);
+ const [error, setError] = useState(false);
+ const [viewer, setViewer] = useState(false);
+
+ const [srcState, loadSrc] = useAsyncCallback(
+ useCallback(
+ () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType || FALLBACK_MIMETYPE, encInfo),
+ [mx, url, mimeType, encInfo]
+ )
+ );
+
+ const handleLoad = () => {
+ setLoad(true);
+ };
+ const handleError = () => {
+ setLoad(false);
+ setError(true);
+ };
+
+ const handleRetry = () => {
+ setError(false);
+ loadSrc();
+ };
+
+ useEffect(() => {
+ if (autoPlay) loadSrc();
+ }, [autoPlay, loadSrc]);
+
+ return (
+ <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
+ {srcState.status === AsyncStatus.Success && (
+ <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setViewer(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal
+ className={css.ModalWide}
+ size="500"
+ onContextMenu={(evt: any) => evt.stopPropagation()}
+ >
+ {renderViewer({
+ src: srcState.data,
+ alt: body,
+ requestClose: () => setViewer(false),
+ })}
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ {typeof blurHash === 'string' && !load && (
+ <BlurhashCanvas
+ style={{ width: '100%', height: '100%' }}
+ width={32}
+ height={32}
+ hash={blurHash}
+ punch={1}
+ />
+ )}
+ {!autoPlay && srcState.status === AsyncStatus.Idle && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Button
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ size="300"
+ onClick={loadSrc}
+ before={<Icon size="Inherit" src={Icons.Photo} filled />}
+ >
+ <Text size="B300">View</Text>
+ </Button>
+ </Box>
+ )}
+ {srcState.status === AsyncStatus.Success && (
+ <Box className={css.AbsoluteContainer}>
+ {renderImage({
+ alt: body,
+ title: body,
+ src: srcState.data,
+ onLoad: handleLoad,
+ onError: handleError,
+ onClick: () => setViewer(true),
+ tabIndex: 0,
+ })}
+ </Box>
+ )}
+ {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
+ !load && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Spinner variant="Secondary" />
+ </Box>
+ )}
+ {(error || srcState.status === AsyncStatus.Error) && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <TooltipProvider
+ tooltip={
+ <Tooltip variant="Critical">
+ <Text>Failed to load image!</Text>
+ </Tooltip>
+ }
+ position="Top"
+ align="Center"
+ >
+ {(triggerRef) => (
+ <Button
+ ref={triggerRef}
+ size="300"
+ variant="Critical"
+ fill="Soft"
+ outlined
+ radii="300"
+ onClick={handleRetry}
+ before={<Icon size="Inherit" src={Icons.Warning} filled />}
+ >
+ <Text size="B300">Retry</Text>
+ </Button>
+ )}
+ </TooltipProvider>
+ </Box>
+ )}
+ {!load && typeof info?.size === 'number' && (
+ <Box className={css.AbsoluteFooter} justifyContent="End" alignContent="Center" gap="200">
+ <Badge variant="Secondary" fill="Soft">
+ <Text size="L400">{bytesToSize(info.size)}</Text>
+ </Badge>
+ </Box>
+ )}
+ </Box>
+ );
+ }
+);
--- /dev/null
+import { ReactNode, useCallback, useEffect } from 'react';
+import { IThumbnailContent } from '../../../../types/matrix/common';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+
+export type ThumbnailContentProps = {
+ info: IThumbnailContent;
+ renderImage: (src: string) => ReactNode;
+};
+export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
+ const mx = useMatrixClient();
+
+ const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
+ useCallback(() => {
+ const thumbInfo = info.thumbnail_info;
+ const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
+ if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
+ throw new Error('Failed to load thumbnail');
+ }
+ return getFileSrcUrl(
+ mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
+ thumbInfo.mimetype,
+ info.thumbnail_file
+ );
+ }, [mx, info])
+ );
+
+ useEffect(() => {
+ loadThumbSrc();
+ }, [loadThumbSrc]);
+
+ return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
+}
--- /dev/null
+import React, { ReactNode, useCallback, useEffect, useState } from 'react';
+import {
+ Badge,
+ Box,
+ Button,
+ Icon,
+ Icons,
+ Spinner,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+} from 'folds';
+import classNames from 'classnames';
+import { BlurhashCanvas } from 'react-blurhash';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import {
+ IThumbnailContent,
+ IVideoInfo,
+ MATRIX_BLUR_HASH_PROPERTY_NAME,
+} from '../../../../types/matrix/common';
+import * as css from './style.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+import { bytesToSize } from '../../../../util/common';
+import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
+
+type RenderVideoProps = {
+ title: string;
+ src: string;
+ onLoadedMetadata: () => void;
+ onError: () => void;
+ autoPlay: boolean;
+ controls: boolean;
+};
+type VideoContentProps = {
+ body: string;
+ mimeType: string;
+ url: string;
+ info: IVideoInfo & IThumbnailContent;
+ encInfo?: EncryptedAttachmentInfo;
+ autoPlay?: boolean;
+ renderThumbnail?: () => ReactNode;
+ renderVideo: (props: RenderVideoProps) => ReactNode;
+};
+export const VideoContent = as<'div', VideoContentProps>(
+ (
+ {
+ className,
+ body,
+ mimeType,
+ url,
+ info,
+ encInfo,
+ autoPlay,
+ renderThumbnail,
+ renderVideo,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
+
+ const [load, setLoad] = useState(false);
+ const [error, setError] = useState(false);
+
+ const [srcState, loadSrc] = useAsyncCallback(
+ useCallback(
+ () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+ [mx, url, mimeType, encInfo]
+ )
+ );
+
+ const handleLoad = () => {
+ setLoad(true);
+ };
+ const handleError = () => {
+ setLoad(false);
+ setError(true);
+ };
+
+ const handleRetry = () => {
+ setError(false);
+ loadSrc();
+ };
+
+ useEffect(() => {
+ if (autoPlay) loadSrc();
+ }, [autoPlay, loadSrc]);
+
+ return (
+ <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
+ {typeof blurHash === 'string' && !load && (
+ <BlurhashCanvas
+ style={{ width: '100%', height: '100%' }}
+ width={32}
+ height={32}
+ hash={blurHash}
+ punch={1}
+ />
+ )}
+ {renderThumbnail && !load && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ {renderThumbnail()}
+ </Box>
+ )}
+ {!autoPlay && srcState.status === AsyncStatus.Idle && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Button
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ size="300"
+ onClick={loadSrc}
+ before={<Icon size="Inherit" src={Icons.Play} filled />}
+ >
+ <Text size="B300">Watch</Text>
+ </Button>
+ </Box>
+ )}
+ {srcState.status === AsyncStatus.Success && (
+ <Box className={css.AbsoluteContainer}>
+ {renderVideo({
+ title: body,
+ src: srcState.data,
+ onLoadedMetadata: handleLoad,
+ onError: handleError,
+ autoPlay: true,
+ controls: true,
+ })}
+ </Box>
+ )}
+ {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
+ !load && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Spinner variant="Secondary" />
+ </Box>
+ )}
+ {(error || srcState.status === AsyncStatus.Error) && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <TooltipProvider
+ tooltip={
+ <Tooltip variant="Critical">
+ <Text>Failed to load video!</Text>
+ </Tooltip>
+ }
+ position="Top"
+ align="Center"
+ >
+ {(triggerRef) => (
+ <Button
+ ref={triggerRef}
+ size="300"
+ variant="Critical"
+ fill="Soft"
+ outlined
+ radii="300"
+ onClick={handleRetry}
+ before={<Icon size="Inherit" src={Icons.Warning} filled />}
+ >
+ <Text size="B300">Retry</Text>
+ </Button>
+ )}
+ </TooltipProvider>
+ </Box>
+ )}
+ {!load && typeof info.size === 'number' && (
+ <Box
+ className={css.AbsoluteFooter}
+ justifyContent="SpaceBetween"
+ alignContent="Center"
+ gap="200"
+ >
+ <Badge variant="Secondary" fill="Soft">
+ <Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
+ </Badge>
+ <Badge variant="Secondary" fill="Soft">
+ <Text size="L400">{bytesToSize(info.size)}</Text>
+ </Badge>
+ </Box>
+ )}
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './ThumbnailContent';
+export * from './ImageContent';
+export * from './VideoContent';
+export * from './AudioContent';
+export * from './FileContent';
+export * from './FallbackContent';
+export * from './EventContent';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config } from 'folds';
+
+export const RelativeBase = style([
+ DefaultReset,
+ {
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ },
+]);
+
+export const AbsoluteContainer = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ },
+]);
+
+export const AbsoluteFooter = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ bottom: config.space.S100,
+ left: config.space.S100,
+ right: config.space.S100,
+ },
+]);
+
+export const ModalWide = style({
+ minWidth: '85vw',
+ minHeight: '90vh',
+});
--- /dev/null
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { decryptFile } from '../../../utils/matrix';
+
+export const getFileSrcUrl = async (
+ httpUrl: string,
+ mimeType: string,
+ encInfo?: EncryptedAttachmentInfo
+): Promise<string> => {
+ if (encInfo) {
+ if (typeof httpUrl !== 'string') throw new Error('Malformed event');
+ const encRes = await fetch(httpUrl, { method: 'GET' });
+ const encData = await encRes.arrayBuffer();
+ const decryptedBlob = await decryptFile(encData, mimeType, encInfo);
+ return URL.createObjectURL(decryptedBlob);
+ }
+ return httpUrl;
+};
+
+export const getSrcFile = async (src: string): Promise<Blob> => {
+ const res = await fetch(src, { method: 'GET' });
+ const blob = await res.blob();
+ return blob;
+};
export * from './Reaction';
export * from './attachment';
export * from './Reply';
-export * from './MessageContentFallback';
+export * from './content';
export * from './Time';
+export * from './MsgTypeRenderers';
+export * from './FileHeader';
+export * from './RenderBody';
const HighlightVariant = styleVariants({
true: {
animation: `${highlightAnime} 2000ms ease-in-out`,
+ animationIterationCount: 'infinite',
},
});
});
export const Username = style({
- cursor: 'pointer',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
selectors: {
- '&:hover, &:focus-visible': {
+ 'button&': {
+ cursor: 'pointer',
+ },
+ 'button&:hover, button&:focus-visible': {
textDecoration: 'underline',
},
},
--- /dev/null
+import React, { ReactNode } from 'react';
+import { as } from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+type NavCategoryProps = {
+ children: ReactNode;
+};
+export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
+ <div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
+));
--- /dev/null
+import React, { ReactNode } from 'react';
+import classNames from 'classnames';
+import { Header, as } from 'folds';
+import * as css from './styles.css';
+
+export type NavCategoryHeaderProps = {
+ children: ReactNode;
+};
+export const NavCategoryHeader = as<'div', NavCategoryHeaderProps>(
+ ({ className, ...props }, ref) => (
+ <Header
+ className={classNames(css.NavCategoryHeader, className)}
+ variant="Background"
+ size="300"
+ {...props}
+ ref={ref}
+ />
+ )
+);
--- /dev/null
+import { Box, config } from 'folds';
+import React, { ReactNode } from 'react';
+
+export function NavEmptyCenter({ children }: { children: ReactNode }) {
+ return (
+ <Box
+ style={{
+ padding: config.space.S500,
+ }}
+ grow="Yes"
+ direction="Column"
+ justifyContent="Center"
+ >
+ {children}
+ </Box>
+ );
+}
+
+type NavEmptyLayoutProps = {
+ icon?: ReactNode;
+ title?: ReactNode;
+ content?: ReactNode;
+ options?: ReactNode;
+};
+export function NavEmptyLayout({ icon, title, content, options }: NavEmptyLayoutProps) {
+ return (
+ <Box direction="Column" gap="400">
+ <Box direction="Column" alignItems="Center" gap="200">
+ {icon}
+ </Box>
+ <Box direction="Column" gap="100" alignItems="Center">
+ {title}
+ {content}
+ </Box>
+ <Box direction="Column" gap="200">
+ {options}
+ </Box>
+ </Box>
+ );
+}
--- /dev/null
+import classNames from 'classnames';
+import React, { ComponentProps, forwardRef } from 'react';
+import { Link } from 'react-router-dom';
+import { as } from 'folds';
+import * as css from './styles.css';
+
+export const NavItem = as<
+ 'div',
+ {
+ highlight?: boolean;
+ } & css.RoomSelectorVariants
+>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
+ <AsNavItem
+ className={classNames(css.NavItem({ variant, radii }), className)}
+ data-highlight={highlight}
+ {...props}
+ ref={ref}
+ >
+ {children}
+ </AsNavItem>
+));
+
+export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
+ ({ className, ...props }, ref) => (
+ <Link className={classNames(css.NavLink, className)} {...props} ref={ref} />
+ )
+);
+
+export const NavButton = as<'button'>(
+ ({ as: AsNavButton = 'button', className, ...props }, ref) => (
+ <AsNavButton className={classNames(css.NavLink, className)} {...props} ref={ref} />
+ )
+);
--- /dev/null
+import React, { ComponentProps } from 'react';
+import { Text, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export const NavItemContent = as<'p', ComponentProps<typeof Text>>(
+ ({ className, ...props }, ref) => (
+ <Text className={classNames(css.NavItemContent, className)} size="T400" {...props} ref={ref} />
+ )
+);
--- /dev/null
+import React, { ComponentProps } from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export const NavItemOptions = as<'div', ComponentProps<typeof Box>>(
+ ({ className, ...props }, ref) => (
+ <Box
+ className={classNames(css.NavItemOptions, className)}
+ alignItems="Center"
+ shrink="No"
+ gap="0"
+ {...props}
+ ref={ref}
+ />
+ )
+);
--- /dev/null
+export * from './NavCategory';
+export * from './NavCategoryHeader';
+export * from './NavEmptyLayout';
+export * from './NavItem';
+export * from './NavItemContent';
+export * from './NavItemOptions';
--- /dev/null
+import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css';
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds';
+
+export const NavCategory = style([
+ DefaultReset,
+ {
+ position: 'relative',
+ },
+]);
+
+export const NavCategoryHeader = style({
+ gap: config.space.S100,
+});
+
+export const NavLink = style({
+ color: 'inherit',
+ minWidth: 0,
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'pointer',
+ flexGrow: 1,
+ ':hover': {
+ textDecoration: 'unset',
+ },
+ ':focus': {
+ outline: 'none',
+ },
+});
+
+const Container = createVar();
+const ContainerHover = createVar();
+const ContainerActive = createVar();
+const ContainerLine = createVar();
+const OnContainer = createVar();
+
+const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
+ vars: {
+ [Container]: color[variant].Container,
+ [ContainerHover]: color[variant].ContainerHover,
+ [ContainerActive]: color[variant].ContainerActive,
+ [ContainerLine]: color[variant].ContainerLine,
+ [OnContainer]: color[variant].OnContainer,
+ },
+});
+
+const NavItemBase = style({
+ width: '100%',
+ display: 'flex',
+ justifyContent: 'start',
+ cursor: 'pointer',
+ backgroundColor: Container,
+ color: OnContainer,
+ outline: 'none',
+ minHeight: toRem(38),
+
+ selectors: {
+ '&:hover, &:focus-visible': {
+ backgroundColor: ContainerHover,
+ },
+ '&[data-hover=true]': {
+ backgroundColor: ContainerHover,
+ },
+ [`&:has(.${NavLink}:active)`]: {
+ backgroundColor: ContainerActive,
+ },
+ '&[aria-selected=true]': {
+ backgroundColor: ContainerActive,
+ },
+ [`&:has(.${NavLink}:focus-visible)`]: {
+ outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
+ outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
+ },
+ },
+ '@supports': {
+ [`not selector(:has(.${NavLink}:focus-visible))`]: {
+ ':focus-within': {
+ outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
+ outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
+ },
+ },
+ },
+});
+export const NavItem = recipe({
+ base: [DefaultReset, NavItemBase, Disabled],
+ variants: {
+ variant: {
+ Background: getVariant('Background'),
+ Surface: getVariant('Surface'),
+ SurfaceVariant: getVariant('SurfaceVariant'),
+ Primary: getVariant('Primary'),
+ Secondary: getVariant('Secondary'),
+ Success: getVariant('Success'),
+ Warning: getVariant('Warning'),
+ Critical: getVariant('Critical'),
+ },
+ radii: RadiiVariant,
+ },
+ defaultVariants: {
+ variant: 'Surface',
+ radii: '400',
+ },
+});
+
+export type RoomSelectorVariants = RecipeVariants<typeof NavItem>;
+export const NavItemContent = style({
+ paddingLeft: config.space.S200,
+ paddingRight: config.space.S300,
+ height: 'inherit',
+ minWidth: 0,
+ flexGrow: 1,
+ display: 'flex',
+ alignItems: 'center',
+
+ selectors: {
+ '&:hover': {
+ textDecoration: 'unset',
+ },
+ [`.${NavItemBase}[data-highlight=true] &`]: {
+ fontWeight: config.fontWeight.W600,
+ },
+ },
+});
+
+export const NavItemOptions = style({
+ paddingRight: config.space.S200,
+});
--- /dev/null
+import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
+import { Box, Header, Line, Scroll, Text, as } from 'folds';
+import classNames from 'classnames';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import * as css from './style.css';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+
+type PageRootProps = {
+ nav: ReactNode;
+ children: ReactNode;
+};
+
+export function PageRoot({ nav, children }: PageRootProps) {
+ const screenSize = useScreenSizeContext();
+
+ return (
+ <Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
+ {nav}
+ {screenSize !== ScreenSize.Mobile && (
+ <Line variant="Background" size="300" direction="Vertical" />
+ )}
+ {children}
+ </Box>
+ );
+}
+
+type ClientDrawerLayoutProps = {
+ children: ReactNode;
+};
+export function PageNav({ children }: ClientDrawerLayoutProps) {
+ const screenSize = useScreenSizeContext();
+ const isMobile = screenSize === ScreenSize.Mobile;
+
+ return (
+ <Box
+ grow={isMobile ? 'Yes' : undefined}
+ className={css.PageNav}
+ shrink={isMobile ? 'Yes' : 'No'}
+ >
+ <Box grow="Yes" direction="Column">
+ {children}
+ </Box>
+ </Box>
+ );
+}
+
+export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => (
+ <Header
+ className={classNames(css.PageNavHeader, className)}
+ variant="Background"
+ size="600"
+ {...props}
+ ref={ref}
+ />
+));
+
+export function PageNavContent({
+ scrollRef,
+ children,
+}: {
+ children: ReactNode;
+ scrollRef?: MutableRefObject<HTMLDivElement | null>;
+}) {
+ return (
+ <Box grow="Yes" direction="Column">
+ <Scroll
+ ref={scrollRef}
+ variant="Background"
+ direction="Vertical"
+ size="300"
+ hideTrack
+ visibility="Hover"
+ >
+ <div className={css.PageNavContent}>{children}</div>
+ </Scroll>
+ </Box>
+ );
+}
+
+export const Page = as<'div'>(({ className, ...props }, ref) => (
+ <Box
+ grow="Yes"
+ direction="Column"
+ className={classNames(ContainerColor({ variant: 'Surface' }), className)}
+ {...props}
+ ref={ref}
+ />
+));
+
+export const PageHeader = as<'div'>(({ className, ...props }, ref) => (
+ <Header
+ as="header"
+ size="600"
+ className={classNames(css.PageHeader, className)}
+ {...props}
+ ref={ref}
+ />
+));
+
+export const PageContent = as<'div'>(({ className, ...props }, ref) => (
+ <div className={classNames(css.PageContent, className)} {...props} ref={ref} />
+));
+
+export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
+ ({ className, ...props }, ref) => (
+ <Box
+ direction="Column"
+ className={classNames(css.PageHeroSection, className)}
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+export function PageHero({
+ icon,
+ title,
+ subTitle,
+ children,
+}: {
+ icon: ReactNode;
+ title: ReactNode;
+ subTitle: ReactNode;
+ children?: ReactNode;
+}) {
+ return (
+ <Box direction="Column" gap="400">
+ <Box direction="Column" alignItems="Center" gap="200">
+ {icon}
+ </Box>
+ <Box as="h2" direction="Column" gap="200" alignItems="Center">
+ <Text align="Center" size="H2">
+ {title}
+ </Text>
+ <Text align="Center" priority="400">
+ {subTitle}
+ </Text>
+ </Box>
+ {children}
+ </Box>
+ );
+}
+
+export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
+ <div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
+));
--- /dev/null
+export * from './Page';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const PageNav = style({
+ width: toRem(280),
+});
+
+export const PageNavHeader = style({
+ padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
+ flexShrink: 0,
+ borderBottomWidth: 1,
+
+ selectors: {
+ 'button&': {
+ cursor: 'pointer',
+ },
+ 'button&[aria-pressed=true]': {
+ backgroundColor: color.Background.ContainerActive,
+ },
+ 'button&:hover, button&:focus-visible': {
+ backgroundColor: color.Background.ContainerHover,
+ },
+ 'button&:active': {
+ backgroundColor: color.Background.ContainerActive,
+ },
+ },
+});
+
+export const PageNavContent = style({
+ minHeight: '100%',
+ padding: config.space.S200,
+ paddingRight: 0,
+ paddingBottom: config.space.S700,
+});
+
+export const PageHeader = style({
+ paddingLeft: config.space.S400,
+ paddingRight: config.space.S200,
+ borderBottomWidth: config.borderWidth.B300,
+});
+
+export const PageContent = style([
+ DefaultReset,
+ {
+ paddingTop: config.space.S400,
+ paddingLeft: config.space.S400,
+ paddingRight: 0,
+ paddingBottom: toRem(100),
+ },
+]);
+
+export const PageHeroSection = style([
+ DefaultReset,
+ {
+ padding: '40px 0',
+ maxWidth: toRem(466),
+ width: '100%',
+ margin: 'auto',
+ },
+]);
+
+export const PageContentCenter = style([
+ DefaultReset,
+ {
+ maxWidth: toRem(964),
+ width: '100%',
+ margin: 'auto',
+ },
+]);
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { color } from 'folds';
+
+export const RoomAvatar = style({
+ backgroundColor: color.Secondary.Container,
+ color: color.Secondary.OnContainer,
+ textTransform: 'capitalize',
+
+ selectors: {
+ '&[data-image-loaded="true"]': {
+ backgroundColor: 'transparent',
+ },
+ },
+});
--- /dev/null
+import { JoinRule } from 'matrix-js-sdk';
+import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
+import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
+import * as css from './RoomAvatar.css';
+import { joinRuleToIconSrc } from '../../utils/room';
+import colorMXID from '../../../util/colorMXID';
+
+type RoomAvatarProps = {
+ roomId: string;
+ src?: string;
+ alt?: string;
+ renderFallback: () => ReactNode;
+};
+export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
+ const [error, setError] = useState(false);
+
+ const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
+ evt.currentTarget.setAttribute('data-image-loaded', 'true');
+ };
+
+ if (!src || error) {
+ return (
+ <AvatarFallback
+ style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
+ className={css.RoomAvatar}
+ >
+ {renderFallback()}
+ </AvatarFallback>
+ );
+ }
+
+ return (
+ <AvatarImage
+ className={css.RoomAvatar}
+ src={src}
+ alt={alt}
+ onError={() => setError(true)}
+ onLoad={handleLoad}
+ draggable={false}
+ />
+ );
+}
+
+export const RoomIcon = forwardRef<
+ SVGSVGElement,
+ Omit<ComponentProps<typeof Icon>, 'src'> & {
+ joinRule: JoinRule;
+ space?: boolean;
+ }
+>(({ joinRule, space, ...props }, ref) => (
+ <Icon
+ src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
+ {...props}
+ ref={ref}
+ />
+));
--- /dev/null
+export * from './RoomAvatar';
--- /dev/null
+import React, { ReactNode, useCallback, useRef, useState } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import {
+ Avatar,
+ Badge,
+ Box,
+ Button,
+ Dialog,
+ Icon,
+ Icons,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Spinner,
+ Text,
+ as,
+ color,
+ config,
+} from 'folds';
+import classNames from 'classnames';
+import FocusTrap from 'focus-trap-react';
+import * as css from './style.css';
+import { RoomAvatar } from '../room-avatar';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { nameInitials } from '../../utils/common';
+import { millify } from '../../plugins/millify';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { onEnterOrSpace } from '../../utils/keyboard';
+import { RoomType, StateEvent } from '../../../types/matrix/room';
+import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
+import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
+import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
+import { useStateEventCallback } from '../../hooks/useStateEventCallback';
+
+type GridColumnCount = '1' | '2' | '3';
+const getGridColumnCount = (gridWidth: number): GridColumnCount => {
+ if (gridWidth <= 498) return '1';
+ if (gridWidth <= 748) return '2';
+ return '3';
+};
+
+const setGridColumnCount = (grid: HTMLElement, count: GridColumnCount): void => {
+ grid.style.setProperty('grid-template-columns', `repeat(${count}, 1fr)`);
+};
+
+export function RoomCardGrid({ children }: { children: ReactNode }) {
+ const gridRef = useRef<HTMLDivElement>(null);
+
+ useElementSizeObserver(
+ useCallback(() => gridRef.current, []),
+ useCallback((width, _, target) => setGridColumnCount(target, getGridColumnCount(width)), [])
+ );
+
+ return (
+ <Box className={css.CardGrid} direction="Row" gap="400" wrap="Wrap" ref={gridRef}>
+ {children}
+ </Box>
+ );
+}
+
+export const RoomCardBase = as<'div'>(({ className, ...props }, ref) => (
+ <Box
+ direction="Column"
+ gap="300"
+ className={classNames(css.RoomCardBase, className)}
+ {...props}
+ ref={ref}
+ />
+));
+
+export const RoomCardName = as<'h6'>(({ ...props }, ref) => (
+ <Text as="h6" size="H6" truncate {...props} ref={ref} />
+));
+
+export const RoomCardTopic = as<'p'>(({ className, ...props }, ref) => (
+ <Text
+ as="p"
+ size="T200"
+ className={classNames(css.RoomCardTopic, className)}
+ {...props}
+ priority="400"
+ ref={ref}
+ />
+));
+
+function ErrorDialog({
+ title,
+ message,
+ children,
+}: {
+ title: string;
+ message: string;
+ children: (openError: () => void) => ReactNode;
+}) {
+ const [viewError, setViewError] = useState(false);
+ const closeError = () => setViewError(false);
+ const openError = () => setViewError(true);
+
+ return (
+ <>
+ {children(openError)}
+ <Overlay open={viewError} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ clickOutsideDeactivates: true,
+ onDeactivate: closeError,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+ <Box direction="Column" gap="100">
+ <Text>{title}</Text>
+ <Text style={{ color: color.Critical.Main }} size="T300" priority="400">
+ {message}
+ </Text>
+ </Box>
+ <Button size="400" variant="Secondary" fill="Soft" onClick={closeError}>
+ <Text size="B400">Cancel</Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ </>
+ );
+}
+
+type RoomCardProps = {
+ roomIdOrAlias: string;
+ allRooms: string[];
+ avatarUrl?: string;
+ name?: string;
+ topic?: string;
+ memberCount?: number;
+ roomType?: string;
+ onView?: (roomId: string) => void;
+ renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode;
+};
+
+export const RoomCard = as<'div', RoomCardProps>(
+ (
+ {
+ roomIdOrAlias,
+ allRooms,
+ avatarUrl,
+ name,
+ topic,
+ memberCount,
+ roomType,
+ onView,
+ renderTopicViewer,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
+ const joinedRoom = mx.getRoom(joinedRoomId);
+ const [topicEvent, setTopicEvent] = useState(() =>
+ joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
+ );
+
+ const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
+ const fallbackTopic = roomIdOrAlias;
+
+ const avatar = joinedRoom
+ ? getRoomAvatarUrl(mx, joinedRoom, 96)
+ : avatarUrl && mx.mxcUrlToHttp(avatarUrl, 96, 96, 'crop');
+
+ const roomName = joinedRoom?.name || name || fallbackName;
+ const roomTopic =
+ (topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic;
+ const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
+
+ useStateEventCallback(
+ mx,
+ useCallback(
+ (event) => {
+ if (
+ joinedRoom &&
+ event.getRoomId() === joinedRoom.roomId &&
+ event.getType() === StateEvent.RoomTopic
+ ) {
+ setTopicEvent(getStateEvent(joinedRoom, StateEvent.RoomTopic));
+ }
+ },
+ [joinedRoom]
+ )
+ );
+
+ const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
+ useCallback(() => mx.joinRoom(roomIdOrAlias), [mx, roomIdOrAlias])
+ );
+ const joining =
+ joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
+
+ const [viewTopic, setViewTopic] = useState(false);
+ const closeTopic = () => setViewTopic(false);
+ const openTopic = () => setViewTopic(true);
+
+ return (
+ <RoomCardBase {...props} ref={ref}>
+ <Box gap="200" justifyContent="SpaceBetween">
+ <Avatar size="500">
+ <RoomAvatar
+ roomId={roomIdOrAlias}
+ src={avatar ?? undefined}
+ alt={roomIdOrAlias}
+ renderFallback={() => (
+ <Text as="span" size="H3">
+ {nameInitials(roomName)}
+ </Text>
+ )}
+ />
+ </Avatar>
+ {(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
+ <Badge variant="Secondary" fill="Soft" outlined>
+ <Text size="L400">Space</Text>
+ </Badge>
+ )}
+ </Box>
+ <Box grow="Yes" direction="Column" gap="100">
+ <RoomCardName>{roomName}</RoomCardName>
+ <RoomCardTopic onClick={openTopic} onKeyDown={onEnterOrSpace(openTopic)} tabIndex={0}>
+ {roomTopic}
+ </RoomCardTopic>
+
+ <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ clickOutsideDeactivates: true,
+ onDeactivate: closeTopic,
+ }}
+ >
+ {renderTopicViewer(roomName, roomTopic, closeTopic)}
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ </Box>
+ {typeof joinedMemberCount === 'number' && (
+ <Box gap="100">
+ <Icon size="50" src={Icons.User} />
+ <Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
+ </Box>
+ )}
+ {typeof joinedRoomId === 'string' && (
+ <Button
+ onClick={onView ? () => onView(joinedRoomId) : undefined}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ >
+ <Text size="B300" truncate>
+ View
+ </Text>
+ </Button>
+ )}
+ {typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && (
+ <Button
+ onClick={join}
+ variant="Secondary"
+ size="300"
+ disabled={joining}
+ before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
+ >
+ <Text size="B300" truncate>
+ {joining ? 'Joining' : 'Join'}
+ </Text>
+ </Button>
+ )}
+ {typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && (
+ <Box gap="200">
+ <Button
+ onClick={join}
+ className={css.ActionButton}
+ variant="Critical"
+ fill="Solid"
+ size="300"
+ >
+ <Text size="B300" truncate>
+ Retry
+ </Text>
+ </Button>
+ <ErrorDialog
+ title="Join Error"
+ message={joinState.error.message || 'Failed to join. Unknown Error.'}
+ >
+ {(openError) => (
+ <Button
+ onClick={openError}
+ className={css.ActionButton}
+ variant="Critical"
+ fill="Soft"
+ outlined
+ size="300"
+ >
+ <Text size="B300" truncate>
+ View Error
+ </Text>
+ </Button>
+ )}
+ </ErrorDialog>
+ </Box>
+ )}
+ </RoomCardBase>
+ );
+ }
+);
--- /dev/null
+export * from './RoomCard';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config } from 'folds';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+
+export const CardGrid = style({
+ display: 'grid',
+ gridTemplateColumns: 'repeat(3, 1fr)',
+ gap: config.space.S400,
+});
+
+export const RoomCardBase = style([
+ DefaultReset,
+ ContainerColor({ variant: 'SurfaceVariant' }),
+ {
+ padding: config.space.S500,
+ borderRadius: config.radii.R500,
+ },
+]);
+
+export const RoomCardTopic = style({
+ minHeight: `calc(3 * ${config.lineHeight.T200})`,
+ display: '-webkit-box',
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ cursor: 'pointer',
+
+ ':hover': {
+ textDecoration: 'underline',
+ },
+});
+
+export const ActionButton = style({
+ flex: '1 1 0',
+ minWidth: 1,
+});
import React, { useCallback } from 'react';
-import { Avatar, AvatarFallback, AvatarImage, Box, Button, Spinner, Text, as, color } from 'folds';
+import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk';
-import { openInviteUser, selectRoom } from '../../../client/action/navigation';
-import { useStateEvent } from '../../hooks/useStateEvent';
+import { useAtomValue } from 'jotai';
+import { openInviteUser } from '../../../client/action/navigation';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart } from '../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { RoomAvatar } from '../room-avatar';
+import { nameInitials } from '../../utils/common';
+import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../state/mDirectList';
export type RoomIntroProps = {
room: Room;
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
const mx = useMatrixClient();
+ const { navigateRoom } = useRoomNavigate();
+ const mDirects = useAtomValue(mDirectAtom);
+
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
- const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
- const nameEvent = useStateEvent(room, StateEvent.RoomName);
- const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
- const createContent = createEvent?.getContent<IRoomCreateContent>();
+ const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
+ const name = useRoomName(room);
+ const topic = useRoomTopic(room);
+ const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
+ const createContent = createEvent?.getContent<IRoomCreateContent>();
const ts = createEvent?.getTs();
const creatorId = createEvent?.getSender();
const creatorName =
creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
const prevRoomId = createContent?.predecessor?.room_id;
- const avatarMxc = (avatarEvent?.getContent().url as string) || undefined;
- const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
- const name = (nameEvent?.getContent().name || room.name) as string;
- const topic = (topicEvent?.getContent().topic as string) || undefined;
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box>
<Avatar size="500">
- {avatarHttpUrl ? (
- <AvatarImage src={avatarHttpUrl} alt={name} />
- ) : (
- <AvatarFallback
- style={{
- backgroundColor: color.SurfaceVariant.Container,
- color: color.SurfaceVariant.OnContainer,
- }}
- >
- <Text size="H2">{name[0]}</Text>
- </AvatarFallback>
- )}
+ <RoomAvatar
+ roomId={room.roomId}
+ src={avatarHttpUrl ?? undefined}
+ alt={name}
+ renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
+ />
</Avatar>
</Box>
<Box direction="Column" gap="300">
{typeof prevRoomId === 'string' &&
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
<Button
- onClick={() => selectRoom(prevRoomId)}
+ onClick={() => navigateRoom(prevRoomId)}
variant="Success"
size="300"
fill="Soft"
--- /dev/null
+import React from 'react';
+import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
+import classNames from 'classnames';
+import Linkify from 'linkify-react';
+import * as css from './style.css';
+import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
+
+export const RoomTopicViewer = as<
+ 'div',
+ {
+ name: string;
+ topic: string;
+ requestClose: () => void;
+ }
+>(({ name, topic, requestClose, className, ...props }, ref) => (
+ <Modal
+ size="300"
+ flexHeight
+ className={classNames(css.ModalFlex, className)}
+ {...props}
+ ref={ref}
+ >
+ <Header className={css.ModalHeader} variant="Surface" size="500">
+ <Box grow="Yes">
+ <Text size="H4" truncate>
+ {name}
+ </Text>
+ </Box>
+ <IconButton size="300" onClick={requestClose} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Scroll className={css.ModalScroll} size="300" hideTrack>
+ <Box className={css.ModalContent} direction="Column" gap="100">
+ <Text size="T300" className={css.ModalTopic} priority="400">
+ <Linkify options={LINKIFY_OPTS}>{scaleSystemEmoji(topic)}</Linkify>
+ </Text>
+ </Box>
+ </Scroll>
+ </Modal>
+));
--- /dev/null
+export * from './RoomTopicViewer';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const ModalFlex = style({
+ display: 'flex',
+ flexDirection: 'column',
+});
+export const ModalHeader = style({
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+});
+export const ModalScroll = style({
+ flexGrow: 1,
+});
+export const ModalContent = style({
+ padding: config.space.S400,
+ paddingRight: config.space.S200,
+ paddingBottom: config.space.S700,
+});
+export const ModalTopic = style({
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+});
--- /dev/null
+import React, { RefObject, useCallback, useState } from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './style.css';
+import {
+ getIntersectionObserverEntry,
+ useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+
+export const ScrollTopContainer = as<
+ 'div',
+ {
+ scrollRef?: RefObject<HTMLElement>;
+ anchorRef: RefObject<HTMLElement>;
+ onVisibilityChange?: (onTop: boolean) => void;
+ }
+>(({ className, scrollRef, anchorRef, onVisibilityChange, ...props }, ref) => {
+ const [onTop, setOnTop] = useState(true);
+
+ useIntersectionObserver(
+ useCallback(
+ (intersectionEntries) => {
+ if (!anchorRef.current) return;
+ const entry = getIntersectionObserverEntry(anchorRef.current, intersectionEntries);
+ if (entry) {
+ setOnTop(entry.isIntersecting);
+ onVisibilityChange?.(entry.isIntersecting);
+ }
+ },
+ [anchorRef, onVisibilityChange]
+ ),
+ useCallback(() => ({ root: scrollRef?.current }), [scrollRef]),
+ useCallback(() => anchorRef.current, [anchorRef])
+ );
+
+ if (onTop) return null;
+
+ return <Box className={classNames(css.ScrollTopContainer, className)} {...props} ref={ref} />;
+});
--- /dev/null
+export * from './ScrollTopContainer';
--- /dev/null
+import { keyframes, style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+const ScrollContainerAnime = keyframes({
+ '0%': {
+ transform: `translate(-50%, -100%) scale(0)`,
+ },
+ '100%': {
+ transform: `translate(-50%, 0) scale(1)`,
+ },
+});
+
+export const ScrollTopContainer = style({
+ position: 'absolute',
+ top: config.space.S200,
+ left: '50%',
+ transform: 'translateX(-50%)',
+ zIndex: config.zIndex.Z100,
+ animation: `${ScrollContainerAnime} 100ms`,
+});
--- /dev/null
+import React, { ComponentProps } from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import { ContainerColor, ContainerColorVariants } from '../../styles/ContainerColor.css';
+import * as css from './style.css';
+
+export const SequenceCard = as<
+ 'div',
+ ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
+>(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => (
+ <Box
+ className={classNames(css.SequenceCard({ outlined }), ContainerColor({ variant }), className)}
+ data-first-child={firstChild}
+ data-last-child={lastChild}
+ {...props}
+ ref={ref}
+ />
+));
--- /dev/null
+export * from './SequenceCard';
--- /dev/null
+import { createVar } from '@vanilla-extract/css';
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { config } from 'folds';
+
+const outlinedWidth = createVar('0');
+export const SequenceCard = recipe({
+ base: {
+ vars: {
+ [outlinedWidth]: '0',
+ },
+ borderStyle: 'solid',
+ borderWidth: outlinedWidth,
+ borderBottomWidth: 0,
+ selectors: {
+ '&:first-child, :not(&) + &': {
+ borderTopLeftRadius: config.radii.R400,
+ borderTopRightRadius: config.radii.R400,
+ },
+ '&:last-child, &:not(:has(+&))': {
+ borderBottomLeftRadius: config.radii.R400,
+ borderBottomRightRadius: config.radii.R400,
+ borderBottomWidth: outlinedWidth,
+ },
+ [`&[data-first-child="true"]`]: {
+ borderTopLeftRadius: config.radii.R400,
+ borderTopRightRadius: config.radii.R400,
+ },
+ [`&[data-first-child="false"]`]: {
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ },
+ [`&[data-last-child="true"]`]: {
+ borderBottomLeftRadius: config.radii.R400,
+ borderBottomRightRadius: config.radii.R400,
+ },
+ [`&[data-last-child="false"]`]: {
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ },
+ },
+ },
+ variants: {
+ outlined: {
+ true: {
+ vars: {
+ [outlinedWidth]: config.borderWidth.B300,
+ },
+ },
+ },
+ },
+});
+export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
-import { style } from '@vanilla-extract/css';
+import { createVar, style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
-import { color, config, DefaultReset, toRem } from 'folds';
+import { color, config, DefaultReset, Disabled, FocusOutline, toRem } from 'folds';
+import { ContainerColor } from '../../styles/ContainerColor.css';
export const Sidebar = style([
DefaultReset,
},
]);
+const DropLineDist = createVar();
+export const DropTarget = style({
+ vars: {
+ [DropLineDist]: toRem(-8),
+ },
+
+ selectors: {
+ '&[data-inside-folder=true]': {
+ vars: {
+ [DropLineDist]: toRem(-6),
+ },
+ },
+ '&[data-drop-child=true]': {
+ outline: `${config.borderWidth.B700} solid ${color.Success.Main}`,
+ borderRadius: config.radii.R400,
+ },
+ '&[data-drop-above=true]::after, &[data-drop-below=true]::after': {
+ content: '',
+ display: 'block',
+ position: 'absolute',
+ left: toRem(0),
+ width: '100%',
+ height: config.borderWidth.B700,
+ backgroundColor: color.Success.Main,
+ },
+ '&[data-drop-above=true]::after': {
+ top: DropLineDist,
+ },
+ '&[data-drop-below=true]::after': {
+ bottom: DropLineDist,
+ },
+ },
+});
+
const PUSH_X = 2;
-export const SidebarAvatarBox = recipe({
+export const SidebarItem = recipe({
base: [
DefaultReset,
{
+ minWidth: toRem(42),
display: 'flex',
alignItems: 'center',
+ justifyContent: 'center',
position: 'relative',
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
},
},
},
+ Disabled,
+ DropTarget,
],
variants: {
active: {
},
},
});
+export type SidebarItemVariants = RecipeVariants<typeof SidebarItem>;
-export type SidebarAvatarBoxVariants = RecipeVariants<typeof SidebarAvatarBox>;
-
-export const SidebarBadgeBox = recipe({
+export const SidebarItemBadge = recipe({
base: [
DefaultReset,
{
+ pointerEvents: 'none',
position: 'absolute',
zIndex: 1,
+ lineHeight: 0,
},
],
variants: {
hasCount: {
true: {
top: toRem(-6),
- right: toRem(-6),
+ left: toRem(-6),
},
false: {
top: toRem(-2),
- right: toRem(-2),
+ left: toRem(-2),
},
},
},
hasCount: false,
},
});
+export type SidebarItemBadgeVariants = RecipeVariants<typeof SidebarItemBadge>;
+
+export const SidebarAvatar = recipe({
+ base: [
+ {
+ selectors: {
+ 'button&': {
+ cursor: 'pointer',
+ },
+ },
+ },
+ ],
+ variants: {
+ size: {
+ '200': {
+ width: toRem(16),
+ height: toRem(16),
+ fontSize: toRem(10),
+ lineHeight: config.lineHeight.T200,
+ letterSpacing: config.letterSpacing.T200,
+ },
+ '300': {
+ width: toRem(34),
+ height: toRem(34),
+ },
+ '400': {
+ width: toRem(42),
+ height: toRem(42),
+ },
+ },
+ outlined: {
+ true: {
+ border: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
+ },
+ },
+ },
+ defaultVariants: {
+ size: '400',
+ },
+});
+export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
-export type SidebarBadgeBoxVariants = RecipeVariants<typeof SidebarBadgeBox>;
+export const SidebarFolder = recipe({
+ base: [
+ ContainerColor({ variant: 'Background' }),
+ {
+ padding: config.space.S100,
+ width: toRem(42),
+ minHeight: toRem(42),
+ display: 'flex',
+ flexWrap: 'wrap',
+ outline: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
+ position: 'relative',
-export const SidebarBadgeOutline = style({
- boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`,
+ selectors: {
+ 'button&': {
+ cursor: 'pointer',
+ },
+ },
+ },
+ FocusOutline,
+ DropTarget,
+ ],
+ variants: {
+ state: {
+ Close: {
+ gap: toRem(2),
+ borderRadius: config.radii.R400,
+ },
+ Open: {
+ paddingLeft: 0,
+ paddingRight: 0,
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: config.space.S200,
+ borderRadius: config.radii.R500,
+ },
+ },
+ },
+ defaultVariants: {
+ state: 'Close',
+ },
+});
+export type SidebarFolderVariants = RecipeVariants<typeof SidebarFolder>;
+
+export const SidebarFolderDropTarget = recipe({
+ base: {
+ width: '100%',
+ height: toRem(8),
+ position: 'absolute',
+ left: 0,
+ },
+ variants: {
+ position: {
+ Top: {
+ top: toRem(-4),
+ },
+ Bottom: {
+ bottom: toRem(-4),
+ },
+ },
+ },
});
+export type SidebarFolderDropTargetVariants = RecipeVariants<typeof SidebarFolderDropTarget>;
+++ /dev/null
-import classNames from 'classnames';
-import { as, Avatar, Box, color, config, Text, Tooltip, TooltipProvider } from 'folds';
-import React, { forwardRef, MouseEventHandler, ReactNode } from 'react';
-import * as css from './Sidebar.css';
-
-const SidebarAvatarBox = as<'div', css.SidebarAvatarBoxVariants>(
- ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
- <AsSidebarAvatarBox
- className={classNames(css.SidebarAvatarBox({ active }), className)}
- {...props}
- ref={ref}
- />
- )
-);
-
-export const SidebarAvatar = forwardRef<
- HTMLDivElement,
- css.SidebarAvatarBoxVariants &
- css.SidebarBadgeBoxVariants & {
- outlined?: boolean;
- avatarChildren: ReactNode;
- tooltip: ReactNode | string;
- notificationBadge?: (badgeClassName: string) => ReactNode;
- onClick?: MouseEventHandler<HTMLButtonElement>;
- onContextMenu?: MouseEventHandler<HTMLButtonElement>;
- }
->(
- (
- {
- active,
- hasCount,
- outlined,
- avatarChildren,
- tooltip,
- notificationBadge,
- onClick,
- onContextMenu,
- },
- ref
- ) => (
- <SidebarAvatarBox active={active} ref={ref}>
- <TooltipProvider
- delay={0}
- position="Right"
- tooltip={
- <Tooltip>
- <Text size="T300">{tooltip}</Text>
- </Tooltip>
- }
- >
- {(avRef) => (
- <Avatar
- ref={avRef}
- as="button"
- onClick={onClick}
- onContextMenu={onContextMenu}
- style={{
- border: outlined
- ? `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`
- : undefined,
- cursor: 'pointer',
- }}
- >
- {avatarChildren}
- </Avatar>
- )}
- </TooltipProvider>
- {notificationBadge && (
- <Box className={css.SidebarBadgeBox({ hasCount })}>
- {notificationBadge(css.SidebarBadgeOutline)}
- </Box>
- )}
- </SidebarAvatarBox>
- )
-);
import React, { ReactNode } from 'react';
-import { Box, Scroll } from 'folds';
+import { Box } from 'folds';
type SidebarContentProps = {
scrollable: ReactNode;
return (
<>
<Box direction="Column" grow="Yes">
- <Scroll variant="Background" size="0">
- {scrollable}
- </Scroll>
+ {scrollable}
</Box>
<Box direction="Column" shrink="No">
{sticky}
--- /dev/null
+import classNames from 'classnames';
+import { as, Avatar, Text, Tooltip, TooltipProvider, toRem } from 'folds';
+import React, { ComponentProps, ReactNode, RefCallback } from 'react';
+import * as css from './Sidebar.css';
+
+export const SidebarItem = as<'div', css.SidebarItemVariants>(
+ ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
+ <AsSidebarAvatarBox
+ className={classNames(css.SidebarItem({ active }), className)}
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+export const SidebarItemBadge = as<'div', css.SidebarItemBadgeVariants>(
+ ({ as: AsSidebarBadgeBox = 'div', className, hasCount, ...props }, ref) => (
+ <AsSidebarBadgeBox
+ className={classNames(css.SidebarItemBadge({ hasCount }), className)}
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+export function SidebarItemTooltip({
+ tooltip,
+ children,
+}: {
+ tooltip?: ReactNode | string;
+ children: (triggerRef: RefCallback<HTMLElement | SVGElement>) => ReactNode;
+}) {
+ if (!tooltip) {
+ return children(() => undefined);
+ }
+
+ return (
+ <TooltipProvider
+ delay={400}
+ position="Right"
+ tooltip={
+ <Tooltip style={{ maxWidth: toRem(280) }}>
+ <Text size="H5">{tooltip}</Text>
+ </Tooltip>
+ }
+ >
+ {children}
+ </TooltipProvider>
+ );
+}
+
+export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProps<typeof Avatar>>(
+ ({ className, size, outlined, radii, ...props }, ref) => (
+ <Avatar
+ className={classNames(css.SidebarAvatar({ size, outlined }), className)}
+ radii={radii}
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
+ ({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
+ <AsSidebarFolder
+ className={classNames(css.SidebarFolder({ state }), className)}
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+export const SidebarFolderDropTarget = as<'div', css.SidebarFolderDropTargetVariants>(
+ ({ as: AsSidebarFolderDropTarget = 'div', className, position, ...props }, ref) => (
+ <AsSidebarFolderDropTarget
+ className={classNames(css.SidebarFolderDropTarget({ position }), className)}
+ {...props}
+ ref={ref}
+ />
+ )
+);
export * from './Sidebar';
-export * from './SidebarAvatar';
+export * from './SidebarItem';
export * from './SidebarContent';
export * from './SidebarStack';
export * from './SidebarStackSeparator';
backgroundColor: 'currentColor',
borderRadius: '50%',
transform: 'translateY(15%)',
- animation: `${TypingDotAnime} 0.6s infinite alternate`,
},
],
variants: {
+ animated: {
+ true: {
+ animation: `${TypingDotAnime} 0.6s infinite alternate`,
+ },
+ },
size: {
'300': {
width: toRem(4),
},
defaultVariants: {
size: '400',
+ animated: true,
},
});
export type TypingIndicatorProps = {
size?: '300' | '400';
+ disableAnimation?: boolean;
};
-export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, ...props }, ref) => (
- <Box
- as="span"
- alignItems="Center"
- shrink="No"
- style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
- {...props}
- ref={ref}
- >
- <span className={css.TypingDot({ size, index: '0' })} />
- <span className={css.TypingDot({ size, index: '1' })} />
- <span className={css.TypingDot({ size, index: '2' })} />
- </Box>
-));
+export const TypingIndicator = as<'div', TypingIndicatorProps>(
+ ({ size, disableAnimation, style, ...props }, ref) => (
+ <Box
+ as="span"
+ alignItems="Center"
+ shrink="No"
+ style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
+ {...props}
+ ref={ref}
+ >
+ <span className={css.TypingDot({ size, index: '0', animated: !disableAnimation })} />
+ <span className={css.TypingDot({ size, index: '1', animated: !disableAnimation })} />
+ <span className={css.TypingDot({ size, index: '2', animated: !disableAnimation })} />
+ </Box>
+ )
+);
--- /dev/null
+import React, { CSSProperties, ReactNode } from 'react';
+import { Box, Badge, toRem, Text } from 'folds';
+import { millify } from '../../plugins/millify';
+
+type UnreadBadgeProps = {
+ highlight?: boolean;
+ count: number;
+};
+const styles: CSSProperties = {
+ minWidth: toRem(16),
+};
+export function UnreadBadgeCenter({ children }: { children: ReactNode }) {
+ return (
+ <Box as="span" style={styles} shrink="No" alignItems="Center" justifyContent="Center">
+ {children}
+ </Box>
+ );
+}
+
+export function UnreadBadge({ highlight, count }: UnreadBadgeProps) {
+ return (
+ <Badge
+ variant={highlight ? 'Success' : 'Secondary'}
+ size={count > 0 ? '400' : '200'}
+ fill={count > 0 ? 'Solid' : 'Soft'}
+ radii="Pill"
+ outlined
+ >
+ {count > 0 && (
+ <Text as="span" size="L400">
+ {millify(count)}
+ </Text>
+ )}
+ </Badge>
+ );
+}
--- /dev/null
+export * from './UnreadBadge';
--- /dev/null
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, toRem } from 'folds';
+
+export const UrlPreviewHolderGradient = recipe({
+ base: [
+ DefaultReset,
+ {
+ position: 'absolute',
+ height: '100%',
+ width: toRem(10),
+ zIndex: 1,
+ },
+ ],
+ variants: {
+ position: {
+ Left: {
+ left: 0,
+ background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
+ },
+ Right: {
+ right: 0,
+ background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
+ },
+ },
+ },
+});
+export const UrlPreviewHolderBtn = recipe({
+ base: [
+ DefaultReset,
+ {
+ position: 'absolute',
+ zIndex: 1,
+ },
+ ],
+ variants: {
+ position: {
+ Left: {
+ left: 0,
+ transform: 'translateX(-25%)',
+ },
+ Right: {
+ right: 0,
+ transform: 'translateX(25%)',
+ },
+ },
+ },
+});
--- /dev/null
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { IPreviewUrlResponse } from 'matrix-js-sdk';
+import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
+import {
+ getIntersectionObserverEntry,
+ useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+import * as css from './UrlPreviewCard.css';
+
+const linkStyles = { color: color.Success.Main };
+
+export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
+ ({ url, ts, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [previewStatus, loadPreview] = useAsyncCallback(
+ useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
+ );
+
+ useEffect(() => {
+ loadPreview();
+ }, [loadPreview]);
+
+ if (previewStatus.status === AsyncStatus.Error) return null;
+
+ const renderContent = (prev: IPreviewUrlResponse) => {
+ const imgUrl = mx.mxcUrlToHttp(prev['og:image'] || '', 256, 256, 'scale', false);
+
+ return (
+ <>
+ {imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
+ <UrlPreviewContent>
+ <Text
+ style={linkStyles}
+ truncate
+ as="a"
+ href={url}
+ target="_blank"
+ rel="no-referrer"
+ size="T200"
+ priority="300"
+ >
+ {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
+ {decodeURIComponent(url)}
+ </Text>
+ <Text truncate priority="400">
+ <b>{prev['og:title']}</b>
+ </Text>
+ <Text size="T200" priority="300">
+ <UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
+ </Text>
+ </UrlPreviewContent>
+ </>
+ );
+ };
+
+ return (
+ <UrlPreview {...props} ref={ref}>
+ {previewStatus.status === AsyncStatus.Success ? (
+ renderContent(previewStatus.data)
+ ) : (
+ <Box grow="Yes" alignItems="Center" justifyContent="Center">
+ <Spinner variant="Secondary" size="400" />
+ </Box>
+ )}
+ </UrlPreview>
+ );
+ }
+);
+
+export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const backAnchorRef = useRef<HTMLDivElement>(null);
+ const frontAnchorRef = useRef<HTMLDivElement>(null);
+ const [backVisible, setBackVisible] = useState(true);
+ const [frontVisible, setFrontVisible] = useState(true);
+
+ const intersectionObserver = useIntersectionObserver(
+ useCallback((entries) => {
+ const backAnchor = backAnchorRef.current;
+ const frontAnchor = frontAnchorRef.current;
+ const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
+ const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
+ if (backEntry) {
+ setBackVisible(backEntry.isIntersecting);
+ }
+ if (frontEntry) {
+ setFrontVisible(frontEntry.isIntersecting);
+ }
+ }, []),
+ useCallback(
+ () => ({
+ root: scrollRef.current,
+ rootMargin: '10px',
+ }),
+ []
+ )
+ );
+
+ useEffect(() => {
+ const backAnchor = backAnchorRef.current;
+ const frontAnchor = frontAnchorRef.current;
+ if (backAnchor) intersectionObserver?.observe(backAnchor);
+ if (frontAnchor) intersectionObserver?.observe(frontAnchor);
+ return () => {
+ if (backAnchor) intersectionObserver?.unobserve(backAnchor);
+ if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
+ };
+ }, [intersectionObserver]);
+
+ const handleScrollBack = () => {
+ const scroll = scrollRef.current;
+ if (!scroll) return;
+ const { offsetWidth, scrollLeft } = scroll;
+ scroll.scrollTo({
+ left: scrollLeft - offsetWidth / 1.3,
+ behavior: 'smooth',
+ });
+ };
+ const handleScrollFront = () => {
+ const scroll = scrollRef.current;
+ if (!scroll) return;
+ const { offsetWidth, scrollLeft } = scroll;
+ scroll.scrollTo({
+ left: scrollLeft + offsetWidth / 1.3,
+ behavior: 'smooth',
+ });
+ };
+
+ return (
+ <Box
+ direction="Column"
+ {...props}
+ ref={ref}
+ style={{ marginTop: config.space.S200, position: 'relative' }}
+ >
+ <Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
+ <Box shrink="No" alignItems="Center">
+ <div ref={backAnchorRef} />
+ {!backVisible && (
+ <>
+ <div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
+ <IconButton
+ className={css.UrlPreviewHolderBtn({ position: 'Left' })}
+ variant="Secondary"
+ radii="Pill"
+ size="300"
+ outlined
+ onClick={handleScrollBack}
+ >
+ <Icon size="300" src={Icons.ArrowLeft} />
+ </IconButton>
+ </>
+ )}
+ <Box alignItems="Inherit" gap="200">
+ {children}
+
+ {!frontVisible && (
+ <>
+ <div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
+ <IconButton
+ className={css.UrlPreviewHolderBtn({ position: 'Right' })}
+ variant="Primary"
+ radii="Pill"
+ size="300"
+ outlined
+ onClick={handleScrollFront}
+ >
+ <Icon size="300" src={Icons.ArrowRight} />
+ </IconButton>
+ </>
+ )}
+ <div ref={frontAnchorRef} />
+ </Box>
+ </Box>
+ </Scroll>
+ </Box>
+ );
+});
export * from './UrlPreview';
+export * from './UrlPreviewCard';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { color } from 'folds';
+
+export const UserAvatar = style({
+ backgroundColor: color.Secondary.Container,
+ color: color.Secondary.OnContainer,
+ textTransform: 'capitalize',
+
+ selectors: {
+ '&[data-image-loaded="true"]': {
+ backgroundColor: 'transparent',
+ },
+ },
+});
--- /dev/null
+import { AvatarFallback, AvatarImage, color } from 'folds';
+import React, { ReactEventHandler, ReactNode, useState } from 'react';
+import * as css from './UserAvatar.css';
+import colorMXID from '../../../util/colorMXID';
+
+type UserAvatarProps = {
+ userId: string;
+ src?: string;
+ alt?: string;
+ renderFallback: () => ReactNode;
+};
+export function UserAvatar({ userId, src, alt, renderFallback }: UserAvatarProps) {
+ const [error, setError] = useState(false);
+
+ const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
+ evt.currentTarget.setAttribute('data-image-loaded', 'true');
+ };
+
+ if (!src || error) {
+ return (
+ <AvatarFallback
+ style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
+ className={css.UserAvatar}
+ >
+ {renderFallback()}
+ </AvatarFallback>
+ );
+ }
+
+ return (
+ <AvatarImage
+ className={css.UserAvatar}
+ src={src}
+ alt={alt}
+ onError={() => setError(true)}
+ onLoad={handleLoad}
+ draggable={false}
+ />
+ );
+}
--- /dev/null
+export * from './UserAvatar';
--- /dev/null
+import { VirtualItem } from '@tanstack/react-virtual';
+import { as } from 'folds';
+import React from 'react';
+import classNames from 'classnames';
+import * as css from './style.css';
+
+type VirtualTileProps = {
+ virtualItem: VirtualItem;
+};
+export const VirtualTile = as<'div', VirtualTileProps>(
+ ({ className, virtualItem, style, ...props }, ref) => (
+ <div
+ className={classNames(css.VirtualTile, className)}
+ style={{ top: virtualItem.start, ...style }}
+ data-index={virtualItem.index}
+ {...props}
+ ref={ref}
+ />
+ )
+);
--- /dev/null
+export * from './VirtualTile';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset } from 'folds';
+
+export const VirtualTile = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ width: '100%',
+ left: 0,
+ },
+]);
request: typeof fetch,
baseUrl: string
): Promise<SpecVersions> => {
- const res = await request(`${baseUrl}/_matrix/client/versions`);
+ const res = await request(`${trimTrailingSlash(baseUrl)}/_matrix/client/versions`);
const data = (await res.json()) as unknown;
--- /dev/null
+import React from 'react';
+import { Box, Scroll, Text, toRem } from 'folds';
+import { useAtomValue } from 'jotai';
+import { RoomCard } from '../../components/room-card';
+import { RoomTopicViewer } from '../../components/room-topic-viewer';
+import { Page, PageHeader } from '../../components/page';
+import { RoomSummaryLoader } from '../../components/RoomSummaryLoader';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { allRoomsAtom } from '../../state/room-list/roomList';
+
+type JoinBeforeNavigateProps = { roomIdOrAlias: string };
+export function JoinBeforeNavigate({ roomIdOrAlias }: JoinBeforeNavigateProps) {
+ const mx = useMatrixClient();
+ const allRooms = useAtomValue(allRoomsAtom);
+ const { navigateRoom, navigateSpace } = useRoomNavigate();
+
+ const handleView = (roomId: string) => {
+ if (mx.getRoom(roomId)?.isSpaceRoom()) {
+ navigateSpace(roomId);
+ return;
+ }
+ navigateRoom(roomId);
+ };
+
+ return (
+ <Page>
+ <PageHeader>
+ <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+ <Text size="H3" truncate>
+ {roomIdOrAlias}
+ </Text>
+ </Box>
+ </PageHeader>
+ <Box grow="Yes">
+ <Scroll hideTrack visibility="Hover" size="0">
+ <Box style={{ height: '100%' }} grow="Yes" alignItems="Center" justifyContent="Center">
+ <RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
+ {(summary) => (
+ <RoomCard
+ style={{ maxWidth: toRem(364), width: '100%' }}
+ roomIdOrAlias={roomIdOrAlias}
+ allRooms={allRooms}
+ avatarUrl={summary?.avatar_url}
+ name={summary?.name}
+ topic={summary?.topic}
+ memberCount={summary?.num_joined_members}
+ roomType={summary?.room_type}
+ renderTopicViewer={(name, topic, requestClose) => (
+ <RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
+ )}
+ onView={handleView}
+ />
+ )}
+ </RoomSummaryLoader>
+ </Box>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './JoinBeforeNavigate';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+
+export const ItemDraggableTarget = style([
+ ContainerColor({ variant: 'SurfaceVariant' }),
+ {
+ height: '100%',
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ zIndex: 1,
+ cursor: 'grab',
+ borderRadius: config.radii.R400,
+ opacity: config.opacity.P300,
+
+ ':active': {
+ cursor: 'ns-resize',
+ },
+ },
+]);
+
+const LineHeight = 4;
+const DropTargetLine = style({
+ selectors: {
+ '&[data-hover=true]:before': {
+ content: '',
+ display: 'block',
+ width: '100%',
+
+ position: 'absolute',
+ left: 0,
+ top: '50%',
+ zIndex: 1,
+ transform: 'translateY(-50%)',
+
+ borderBottom: `${toRem(LineHeight)} solid currentColor`,
+ },
+ '&[data-hover=true]:after': {
+ content: '',
+ display: 'block',
+ width: toRem(LineHeight * 3),
+ height: toRem(LineHeight * 3),
+
+ position: 'absolute',
+ left: 0,
+ top: '50%',
+ zIndex: 1,
+ transform: 'translate(-50%, -50%)',
+
+ backgroundColor: color.Surface.Container,
+ border: `${toRem(LineHeight)} solid currentColor`,
+ borderRadius: '50%',
+ },
+ },
+});
+
+const BaseAfterRoomItemDropTarget = style({
+ width: '100%',
+
+ position: 'absolute',
+ left: 0,
+ bottom: 0,
+ zIndex: 99,
+
+ color: color.Success.Main,
+
+ selectors: {
+ '&[data-error=true]': {
+ color: color.Critical.Main,
+ },
+ },
+});
+const RoomTargetHeight = 32;
+export const AfterRoomItemDropTarget = style([
+ BaseAfterRoomItemDropTarget,
+ {
+ height: toRem(RoomTargetHeight),
+ transform: `translateY(${toRem(RoomTargetHeight / 2 + LineHeight / 2)})`,
+ },
+ DropTargetLine,
+]);
+const SpaceTargetHeight = 14;
+export const AfterSpaceItemDropTarget = style([
+ BaseAfterRoomItemDropTarget,
+ {
+ height: toRem(SpaceTargetHeight),
+ transform: `translateY(calc(100% - ${toRem(4)}))`,
+ },
+ DropTargetLine,
+]);
--- /dev/null
+import React, { RefObject, useEffect, useRef, useState } from 'react';
+import {
+ dropTargetForElements,
+ draggable,
+ monitorForElements,
+} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import classNames from 'classnames';
+import { Box, Icon, Icons, as } from 'folds';
+import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
+import * as css from './DnD.css';
+
+export type DropContainerData = {
+ item: HierarchyItem;
+ nextRoomId?: string;
+};
+export type CanDropCallback = (item: HierarchyItem, container: DropContainerData) => boolean;
+
+export const useDraggableItem = (
+ item: HierarchyItem,
+ targetRef: RefObject<HTMLElement>,
+ onDragging: (item?: HierarchyItem) => void,
+ dragHandleRef?: RefObject<HTMLElement>
+): boolean => {
+ const [dragging, setDragging] = useState(false);
+
+ useEffect(() => {
+ const target = targetRef.current;
+ const dragHandle = dragHandleRef?.current ?? undefined;
+
+ return !target
+ ? undefined
+ : draggable({
+ element: target,
+ dragHandle,
+ getInitialData: () => item,
+ onDragStart: () => {
+ setDragging(true);
+ onDragging(item);
+ },
+ onDrop: () => {
+ setDragging(false);
+ onDragging(undefined);
+ },
+ });
+ }, [targetRef, dragHandleRef, item, onDragging]);
+
+ return dragging;
+};
+
+export const ItemDraggableTarget = as<'div'>(({ className, ...props }, ref) => (
+ <Box
+ justifyContent="Center"
+ alignItems="Center"
+ className={classNames(css.ItemDraggableTarget, className)}
+ ref={ref}
+ {...props}
+ >
+ <Icon size="50" src={Icons.VerticalDots} />
+ </Box>
+));
+
+type AfterItemDropTargetProps = {
+ item: HierarchyItem;
+ afterSpace?: boolean;
+ nextRoomId?: string;
+ canDrop: CanDropCallback;
+};
+export function AfterItemDropTarget({
+ item,
+ afterSpace,
+ nextRoomId,
+ canDrop,
+}: AfterItemDropTargetProps) {
+ const targetRef = useRef<HTMLDivElement>(null);
+ const [dropState, setDropState] = useState<'idle' | 'allow' | 'not-allow'>('idle');
+
+ useEffect(() => {
+ const target = targetRef.current;
+ if (!target) {
+ throw Error('drop target ref is not set properly');
+ }
+
+ return dropTargetForElements({
+ element: target,
+ getData: () => {
+ const container: DropContainerData = {
+ item,
+ nextRoomId,
+ };
+ return container;
+ },
+ onDragEnter: ({ source }) => {
+ if (
+ canDrop(source.data as HierarchyItem, {
+ item,
+ nextRoomId,
+ })
+ ) {
+ setDropState('allow');
+ } else {
+ setDropState('not-allow');
+ }
+ },
+ onDragLeave: () => setDropState('idle'),
+ onDrop: () => setDropState('idle'),
+ });
+ }, [item, nextRoomId, canDrop]);
+
+ return (
+ <div
+ className={afterSpace ? css.AfterSpaceItemDropTarget : css.AfterRoomItemDropTarget}
+ data-hover={dropState !== 'idle'}
+ data-error={dropState === 'not-allow'}
+ ref={targetRef}
+ />
+ );
+}
+
+export const useDnDMonitor = (
+ scrollRef: RefObject<HTMLElement>,
+ onDragging: (item?: HierarchyItem) => void,
+ onReorder: (item: HierarchyItem, container: DropContainerData) => void
+) => {
+ useEffect(() => {
+ const scrollElement = scrollRef.current;
+ if (!scrollElement) {
+ throw Error('Scroll element ref not configured');
+ }
+
+ return combine(
+ monitorForElements({
+ onDrop: ({ source, location }) => {
+ onDragging(undefined);
+ const { dropTargets } = location.current;
+ if (dropTargets.length === 0) return;
+ onReorder(source.data as HierarchyItem, dropTargets[0].data as DropContainerData);
+ },
+ }),
+ autoScrollForElements({
+ element: scrollElement,
+ })
+ );
+ }, [scrollRef, onDragging, onReorder]);
+};
--- /dev/null
+import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ IconButton,
+ Icon,
+ Icons,
+ PopOut,
+ Menu,
+ MenuItem,
+ Text,
+ RectCords,
+ config,
+ Line,
+ Spinner,
+ toRem,
+} from 'folds';
+import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
+import {
+ openInviteUser,
+ openSpaceSettings,
+ toggleRoomSettings,
+} from '../../../client/action/navigation';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
+import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
+
+type HierarchyItemWithParent = HierarchyItem & {
+ parentId: string;
+};
+
+function SuggestMenuItem({
+ item,
+ requestClose,
+}: {
+ item: HierarchyItemWithParent;
+ requestClose: () => void;
+}) {
+ const mx = useMatrixClient();
+ const { roomId, parentId, content } = item;
+
+ const [toggleState, handleToggleSuggested] = useAsyncCallback(
+ useCallback(() => {
+ const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
+ return mx.sendStateEvent(parentId, StateEvent.SpaceChild, newContent, roomId);
+ }, [mx, parentId, roomId, content])
+ );
+
+ useEffect(() => {
+ if (toggleState.status === AsyncStatus.Success) {
+ requestClose();
+ }
+ }, [requestClose, toggleState]);
+
+ return (
+ <MenuItem
+ onClick={handleToggleSuggested}
+ size="300"
+ radii="300"
+ before={toggleState.status === AsyncStatus.Loading && <Spinner size="100" />}
+ disabled={toggleState.status === AsyncStatus.Loading}
+ >
+ <Text as="span" size="T300" truncate>
+ {content.suggested ? 'Unset Suggested' : 'Set Suggested'}
+ </Text>
+ </MenuItem>
+ );
+}
+
+function RemoveMenuItem({
+ item,
+ requestClose,
+}: {
+ item: HierarchyItemWithParent;
+ requestClose: () => void;
+}) {
+ const mx = useMatrixClient();
+ const { roomId, parentId } = item;
+
+ const [removeState, handleRemove] = useAsyncCallback(
+ useCallback(
+ () => mx.sendStateEvent(parentId, StateEvent.SpaceChild, {}, roomId),
+ [mx, parentId, roomId]
+ )
+ );
+
+ useEffect(() => {
+ if (removeState.status === AsyncStatus.Success) {
+ requestClose();
+ }
+ }, [requestClose, removeState]);
+
+ return (
+ <MenuItem
+ onClick={handleRemove}
+ variant="Critical"
+ fill="None"
+ size="300"
+ radii="300"
+ before={
+ removeState.status === AsyncStatus.Loading && (
+ <Spinner variant="Critical" fill="Soft" size="100" />
+ )
+ }
+ disabled={removeState.status === AsyncStatus.Loading}
+ >
+ <Text as="span" size="T300" truncate>
+ Remove
+ </Text>
+ </MenuItem>
+ );
+}
+
+function InviteMenuItem({
+ item,
+ requestClose,
+ disabled,
+}: {
+ item: HierarchyItemWithParent;
+ requestClose: () => void;
+ disabled?: boolean;
+}) {
+ const handleInvite = () => {
+ openInviteUser(item.roomId);
+ requestClose();
+ };
+
+ return (
+ <MenuItem
+ onClick={handleInvite}
+ size="300"
+ radii="300"
+ variant="Primary"
+ fill="None"
+ disabled={disabled}
+ >
+ <Text as="span" size="T300" truncate>
+ Invite
+ </Text>
+ </MenuItem>
+ );
+}
+
+function SettingsMenuItem({
+ item,
+ requestClose,
+ disabled,
+}: {
+ item: HierarchyItemWithParent;
+ requestClose: () => void;
+ disabled?: boolean;
+}) {
+ const handleSettings = () => {
+ if (item.space) {
+ openSpaceSettings(item.roomId);
+ } else {
+ toggleRoomSettings(item.roomId);
+ }
+ requestClose();
+ };
+
+ return (
+ <MenuItem onClick={handleSettings} size="300" radii="300" disabled={disabled}>
+ <Text as="span" size="T300" truncate>
+ Settings
+ </Text>
+ </MenuItem>
+ );
+}
+
+type HierarchyItemMenuProps = {
+ item: HierarchyItem & {
+ parentId: string;
+ };
+ joined: boolean;
+ canInvite: boolean;
+ canEditChild: boolean;
+ pinned?: boolean;
+ onTogglePin?: (roomId: string) => void;
+};
+export function HierarchyItemMenu({
+ item,
+ joined,
+ canInvite,
+ canEditChild,
+ pinned,
+ onTogglePin,
+}: HierarchyItemMenuProps) {
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []);
+
+ if (!joined && !canEditChild) {
+ return null;
+ }
+
+ return (
+ <Box gap="200" alignItems="Center" shrink="No">
+ <IconButton
+ onClick={handleOpenMenu}
+ size="300"
+ variant="SurfaceVariant"
+ fill="None"
+ radii="300"
+ aria-pressed={!!menuAnchor}
+ >
+ <Icon size="50" src={Icons.VerticalDots} />
+ </IconButton>
+ {menuAnchor && (
+ <PopOut
+ anchor={menuAnchor}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
+ {joined && (
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ {onTogglePin && (
+ <MenuItem
+ size="300"
+ radii="300"
+ onClick={() => {
+ onTogglePin(item.roomId);
+ handleRequestClose();
+ }}
+ >
+ <Text as="span" size="T300" truncate>
+ {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
+ </Text>
+ </MenuItem>
+ )}
+ <InviteMenuItem
+ item={item}
+ requestClose={handleRequestClose}
+ disabled={!canInvite}
+ />
+ <SettingsMenuItem item={item} requestClose={handleRequestClose} />
+ <UseStateProvider initial={false}>
+ {(promptLeave, setPromptLeave) => (
+ <>
+ <MenuItem
+ onClick={() => setPromptLeave(true)}
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+ radii="300"
+ aria-pressed={promptLeave}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Leave
+ </Text>
+ </MenuItem>
+ {promptLeave &&
+ (item.space ? (
+ <LeaveSpacePrompt
+ roomId={item.roomId}
+ onDone={handleRequestClose}
+ onCancel={() => setPromptLeave(false)}
+ />
+ ) : (
+ <LeaveRoomPrompt
+ roomId={item.roomId}
+ onDone={handleRequestClose}
+ onCancel={() => setPromptLeave(false)}
+ />
+ ))}
+ </>
+ )}
+ </UseStateProvider>
+ </Box>
+ )}
+ {(joined || canEditChild) && (
+ <Line size="300" variant="Surface" direction="Horizontal" />
+ )}
+ {canEditChild && (
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <SuggestMenuItem item={item} requestClose={handleRequestClose} />
+ <RemoveMenuItem item={item} requestClose={handleRequestClose} />
+ </Box>
+ )}
+ </Menu>
+ </FocusTrap>
+ }
+ />
+ )}
+ </Box>
+ );
+}
--- /dev/null
+import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
+import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useAtom, useAtomValue } from 'jotai';
+import { useNavigate } from 'react-router-dom';
+import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
+import { useSpace } from '../../hooks/useSpace';
+import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
+import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
+import { VirtualTile } from '../../components/virtualizer';
+import { spaceRoomsAtom } from '../../state/spaceRooms';
+import { MembersDrawer } from '../room/MembersDrawer';
+import { useSetting } from '../../state/hooks/settings';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { settingsAtom } from '../../state/settings';
+import { LobbyHeader } from './LobbyHeader';
+import { LobbyHero } from './LobbyHero';
+import { ScrollTopContainer } from '../../components/scroll-top-container';
+import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
+import {
+ IPowerLevels,
+ PowerLevelsContextProvider,
+ powerLevelAPI,
+ usePowerLevels,
+ useRoomsPowerLevels,
+} from '../../hooks/usePowerLevels';
+import { RoomItemCard } from './RoomItem';
+import { mDirectAtom } from '../../state/mDirectList';
+import { SpaceItemCard } from './SpaceItem';
+import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
+import { useCategoryHandler } from '../../hooks/useCategoryHandler';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { allRoomsAtom } from '../../state/room-list/roomList';
+import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
+import { getSpaceRoomPath } from '../../pages/pathUtils';
+import { HierarchyItemMenu } from './HierarchyItemMenu';
+import { StateEvent } from '../../../types/matrix/room';
+import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD';
+import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
+import { getStateEvent } from '../../utils/room';
+import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
+import {
+ makeCinnySpacesContent,
+ sidebarItemWithout,
+ useSidebarItems,
+} from '../../hooks/useSidebarItems';
+import { useOrphanSpaces } from '../../state/hooks/roomList';
+import { roomToParentsAtom } from '../../state/room/roomToParents';
+import { AccountDataEvent } from '../../../types/matrix/accountData';
+
+export function Lobby() {
+ const navigate = useNavigate();
+ const mx = useMatrixClient();
+ const mDirects = useAtomValue(mDirectAtom);
+ const allRooms = useAtomValue(allRoomsAtom);
+ const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
+ const space = useSpace();
+ const spacePowerLevels = usePowerLevels(space);
+ const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
+
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const heroSectionRef = useRef<HTMLDivElement>(null);
+ const [heroSectionHeight, setHeroSectionHeight] = useState<number>();
+ const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom);
+ const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+ const screenSize = useScreenSizeContext();
+ const [onTop, setOnTop] = useState(true);
+ const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
+ const [sidebarItems] = useSidebarItems(
+ useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
+ );
+ const sidebarSpaces = useMemo(() => {
+ const sideSpaces = sidebarItems.flatMap((item) => {
+ if (typeof item === 'string') return item;
+ return item.content;
+ });
+
+ return new Set(sideSpaces);
+ }, [sidebarItems]);
+
+ useElementSizeObserver(
+ useCallback(() => heroSectionRef.current, []),
+ useCallback((w, height) => setHeroSectionHeight(height), [])
+ );
+
+ const getRoom = useCallback(
+ (rId: string) => {
+ if (allJoinedRooms.has(rId)) {
+ return mx.getRoom(rId) ?? undefined;
+ }
+ return undefined;
+ },
+ [mx, allJoinedRooms]
+ );
+
+ const canEditSpaceChild = useCallback(
+ (powerLevels: IPowerLevels) =>
+ powerLevelAPI.canSendStateEvent(
+ powerLevels,
+ StateEvent.SpaceChild,
+ powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined)
+ ),
+ [mx]
+ );
+
+ const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
+ const flattenHierarchy = useSpaceHierarchy(
+ space.roomId,
+ spaceRooms,
+ getRoom,
+ useCallback(
+ (childId) =>
+ closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
+ [closedCategories, space.roomId, draggingItem]
+ )
+ );
+
+ const virtualizer = useVirtualizer({
+ count: flattenHierarchy.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 1,
+ overscan: 2,
+ paddingStart: heroSectionHeight ?? 258,
+ });
+ const vItems = virtualizer.getVirtualItems();
+
+ const roomsPowerLevels = useRoomsPowerLevels(
+ useMemo(
+ () => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[],
+ [mx, flattenHierarchy]
+ )
+ );
+
+ const canDrop: CanDropCallback = useCallback(
+ (item, container): boolean => {
+ const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
+ if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
+ // can not drop before or after itself
+ return false;
+ }
+
+ if (item.space) {
+ if (!container.item.space) return false;
+ const containerSpaceId = space.roomId;
+
+ if (
+ getRoom(containerSpaceId) === undefined ||
+ !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ const containerSpaceId = container.item.space
+ ? container.item.roomId
+ : container.item.parentId;
+
+ const dropOutsideSpace = item.parentId !== containerSpaceId;
+
+ if (dropOutsideSpace && restrictedItem) {
+ // do not allow restricted room to drop outside
+ // current space if can't change join rule allow
+ const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
+ const userPLInItem = powerLevelAPI.getPowerLevel(
+ itemPowerLevel,
+ mx.getUserId() ?? undefined
+ );
+ const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
+ itemPowerLevel,
+ StateEvent.RoomJoinRules,
+ userPLInItem
+ );
+ if (!canChangeJoinRuleAllow) {
+ return false;
+ }
+ }
+
+ if (
+ getRoom(containerSpaceId) === undefined ||
+ !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
+ ) {
+ return false;
+ }
+ return true;
+ },
+ [getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
+ );
+
+ const reorderSpace = useCallback(
+ (item: HierarchyItem, containerItem: HierarchyItem) => {
+ if (!item.parentId) return;
+
+ const childItems = flattenHierarchy
+ .filter((i) => i.parentId && i.space)
+ .filter((i) => i.roomId !== item.roomId);
+
+ const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId);
+ const insertIndex = beforeIndex + 1;
+
+ childItems.splice(insertIndex, 0, {
+ ...item,
+ content: { ...item.content, order: undefined },
+ });
+
+ const currentOrders = childItems.map((i) => {
+ if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
+ return i.content.order;
+ }
+ return undefined;
+ });
+
+ const newOrders = orderKeys(lex, currentOrders);
+
+ newOrders?.forEach((orderKey, index) => {
+ const itm = childItems[index];
+ if (!itm || !itm.parentId) return;
+ const parentPL = roomsPowerLevels.get(itm.parentId);
+ const canEdit = parentPL && canEditSpaceChild(parentPL);
+ if (canEdit && orderKey !== currentOrders[index]) {
+ mx.sendStateEvent(
+ itm.parentId,
+ StateEvent.SpaceChild,
+ { ...itm.content, order: orderKey },
+ itm.roomId
+ );
+ }
+ });
+ },
+ [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
+ );
+
+ const reorderRoom = useCallback(
+ (item: HierarchyItem, containerItem: HierarchyItem): void => {
+ const itemRoom = mx.getRoom(item.roomId);
+ if (!item.parentId) {
+ return;
+ }
+ const containerParentId: string = containerItem.space
+ ? containerItem.roomId
+ : containerItem.parentId;
+ const itemContent = item.content;
+
+ if (item.parentId !== containerParentId) {
+ mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
+ }
+
+ if (
+ itemRoom &&
+ itemRoom.getJoinRule() === JoinRule.Restricted &&
+ item.parentId !== containerParentId
+ ) {
+ // change join rule allow parameter when dragging
+ // restricted room from one space to another
+ const joinRuleContent = getStateEvent(
+ itemRoom,
+ StateEvent.RoomJoinRules
+ )?.getContent<IJoinRuleEventContent>();
+
+ if (joinRuleContent) {
+ const allow =
+ joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
+ allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
+ mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
+ ...joinRuleContent,
+ allow,
+ });
+ }
+ }
+
+ const childItems = flattenHierarchy
+ .filter((i) => i.parentId === containerParentId && !i.space)
+ .filter((i) => i.roomId !== item.roomId);
+
+ const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
+ const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
+ const insertIndex = beforeIndex + 1;
+
+ childItems.splice(insertIndex, 0, {
+ ...item,
+ parentId: containerParentId,
+ content: { ...itemContent, order: undefined },
+ });
+
+ const currentOrders = childItems.map((i) => {
+ if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
+ return i.content.order;
+ }
+ return undefined;
+ });
+
+ const newOrders = orderKeys(lex, currentOrders);
+
+ newOrders?.forEach((orderKey, index) => {
+ const itm = childItems[index];
+ if (itm && orderKey !== currentOrders[index]) {
+ mx.sendStateEvent(
+ containerParentId,
+ StateEvent.SpaceChild,
+ { ...itm.content, order: orderKey },
+ itm.roomId
+ );
+ }
+ });
+ },
+ [mx, flattenHierarchy, lex]
+ );
+
+ useDnDMonitor(
+ scrollRef,
+ setDraggingItem,
+ useCallback(
+ (item, container) => {
+ if (!canDrop(item, container)) {
+ return;
+ }
+ if (item.space) {
+ reorderSpace(item, container.item);
+ } else {
+ reorderRoom(item, container.item);
+ }
+ },
+ [reorderRoom, reorderSpace, canDrop]
+ )
+ );
+
+ const addSpaceRoom = useCallback(
+ (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
+ [setSpaceRooms]
+ );
+
+ const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+ closedCategories.has(categoryId)
+ );
+
+ const handleOpenRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const rId = evt.currentTarget.getAttribute('data-room-id');
+ if (!rId) return;
+ const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
+ navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
+ };
+
+ const togglePinToSidebar = useCallback(
+ (rId: string) => {
+ const newItems = sidebarItemWithout(sidebarItems, rId);
+ if (!sidebarSpaces.has(rId)) {
+ newItems.push(rId);
+ }
+ const newSpacesContent = makeCinnySpacesContent(mx, newItems);
+ mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
+ },
+ [mx, sidebarItems, sidebarSpaces]
+ );
+
+ return (
+ <PowerLevelsContextProvider value={spacePowerLevels}>
+ <Box grow="Yes">
+ <Page>
+ <LobbyHeader
+ showProfile={!onTop}
+ powerLevels={roomsPowerLevels.get(space.roomId) ?? {}}
+ />
+ <Box style={{ position: 'relative' }} grow="Yes">
+ <Scroll ref={scrollRef} hideTrack visibility="Hover">
+ <PageContent>
+ <PageContentCenter>
+ <ScrollTopContainer
+ scrollRef={scrollRef}
+ anchorRef={heroSectionRef}
+ onVisibilityChange={setOnTop}
+ >
+ <IconButton
+ onClick={() => virtualizer.scrollToOffset(0)}
+ variant="SurfaceVariant"
+ radii="Pill"
+ outlined
+ size="300"
+ aria-label="Scroll to Top"
+ >
+ <Icon src={Icons.ChevronTop} size="300" />
+ </IconButton>
+ </ScrollTopContainer>
+ <div
+ style={{
+ position: 'relative',
+ height: virtualizer.getTotalSize(),
+ }}
+ >
+ <PageHeroSection ref={heroSectionRef} style={{ paddingTop: 0 }}>
+ <LobbyHero />
+ </PageHeroSection>
+ {vItems.map((vItem) => {
+ const item = flattenHierarchy[vItem.index];
+ if (!item) return null;
+ const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
+ const userPLInItem = powerLevelAPI.getPowerLevel(
+ itemPowerLevel,
+ mx.getUserId() ?? undefined
+ );
+ const canInvite = powerLevelAPI.canDoAction(
+ itemPowerLevel,
+ 'invite',
+ userPLInItem
+ );
+ const isJoined = allJoinedRooms.has(item.roomId);
+
+ const nextRoomId: string | undefined =
+ flattenHierarchy[vItem.index + 1]?.roomId;
+
+ const dragging =
+ draggingItem?.roomId === item.roomId &&
+ draggingItem.parentId === item.parentId;
+
+ if (item.space) {
+ const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
+ const { parentId } = item;
+ const parentPowerLevels = parentId
+ ? roomsPowerLevels.get(parentId) ?? {}
+ : undefined;
+
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ style={{
+ paddingTop: vItem.index === 0 ? 0 : config.space.S500,
+ }}
+ ref={virtualizer.measureElement}
+ key={vItem.index}
+ >
+ <SpaceItemCard
+ item={item}
+ joined={allJoinedRooms.has(item.roomId)}
+ categoryId={categoryId}
+ closed={closedCategories.has(categoryId) || !!draggingItem?.space}
+ handleClose={handleCategoryClick}
+ getRoom={getRoom}
+ canEditChild={canEditSpaceChild(
+ roomsPowerLevels.get(item.roomId) ?? {}
+ )}
+ canReorder={
+ parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
+ }
+ options={
+ parentId &&
+ parentPowerLevels && (
+ <HierarchyItemMenu
+ item={{ ...item, parentId }}
+ canInvite={canInvite}
+ joined={isJoined}
+ canEditChild={canEditSpaceChild(parentPowerLevels)}
+ pinned={sidebarSpaces.has(item.roomId)}
+ onTogglePin={togglePinToSidebar}
+ />
+ )
+ }
+ before={item.parentId ? undefined : undefined}
+ after={
+ <AfterItemDropTarget
+ item={item}
+ nextRoomId={nextRoomId}
+ afterSpace
+ canDrop={canDrop}
+ />
+ }
+ onDragging={setDraggingItem}
+ data-dragging={dragging}
+ />
+ </VirtualTile>
+ );
+ }
+
+ const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
+ const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
+ const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ style={{ paddingTop: config.space.S100 }}
+ ref={virtualizer.measureElement}
+ key={vItem.index}
+ >
+ <RoomItemCard
+ item={item}
+ onSpaceFound={addSpaceRoom}
+ dm={mDirects.has(item.roomId)}
+ firstChild={!prevItem || prevItem.space === true}
+ lastChild={!nextItem || nextItem.space === true}
+ onOpen={handleOpenRoom}
+ getRoom={getRoom}
+ canReorder={canEditSpaceChild(parentPowerLevels)}
+ options={
+ <HierarchyItemMenu
+ item={item}
+ canInvite={canInvite}
+ joined={isJoined}
+ canEditChild={canEditSpaceChild(parentPowerLevels)}
+ />
+ }
+ after={
+ <AfterItemDropTarget
+ item={item}
+ nextRoomId={nextRoomId}
+ canDrop={canDrop}
+ />
+ }
+ data-dragging={dragging}
+ onDragging={setDraggingItem}
+ />
+ </VirtualTile>
+ );
+ })}
+ </div>
+ </PageContentCenter>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ {screenSize === ScreenSize.Desktop && isDrawer && (
+ <>
+ <Line variant="Background" direction="Vertical" size="300" />
+ <MembersDrawer room={space} />
+ </>
+ )}
+ </Box>
+ </PowerLevelsContextProvider>
+ );
+}
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const Header = style({
+ borderBottomColor: 'transparent',
+});
+export const HeaderTopic = style({
+ ':hover': {
+ cursor: 'pointer',
+ opacity: config.opacity.P500,
+ textDecoration: 'underline',
+ },
+});
--- /dev/null
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Icon,
+ IconButton,
+ Icons,
+ Line,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ config,
+ toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { PageHeader } from '../../components/page';
+import { useSetSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
+import { useSpace } from '../../hooks/useSpace';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { RoomAvatar } from '../../components/room-avatar';
+import { nameInitials } from '../../utils/common';
+import * as css from './LobbyHeader.css';
+import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
+import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
+
+type LobbyMenuProps = {
+ roomId: string;
+ powerLevels: IPowerLevels;
+ requestClose: () => void;
+};
+const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
+ ({ roomId, powerLevels, requestClose }, ref) => {
+ const mx = useMatrixClient();
+ const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+ const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+ const handleInvite = () => {
+ openInviteUser(roomId);
+ requestClose();
+ };
+
+ const handleRoomSettings = () => {
+ openSpaceSettings(roomId);
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleInvite}
+ variant="Primary"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.UserPlus} />}
+ radii="300"
+ disabled={!canInvite}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Invite
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleRoomSettings}
+ size="300"
+ after={<Icon size="100" src={Icons.Setting} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Space Settings
+ </Text>
+ </MenuItem>
+ </Box>
+ <Line variant="Surface" size="300" />
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <UseStateProvider initial={false}>
+ {(promptLeave, setPromptLeave) => (
+ <>
+ <MenuItem
+ onClick={() => setPromptLeave(true)}
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+ radii="300"
+ aria-pressed={promptLeave}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Leave Space
+ </Text>
+ </MenuItem>
+ {promptLeave && (
+ <LeaveSpacePrompt
+ roomId={roomId}
+ onDone={requestClose}
+ onCancel={() => setPromptLeave(false)}
+ />
+ )}
+ </>
+ )}
+ </UseStateProvider>
+ </Box>
+ </Menu>
+ );
+ }
+);
+
+type LobbyHeaderProps = {
+ showProfile?: boolean;
+ powerLevels: IPowerLevels;
+};
+export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
+ const mx = useMatrixClient();
+ const space = useSpace();
+ const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const name = useRoomName(space);
+ const avatarMxc = useRoomAvatar(space);
+ const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <PageHeader className={showProfile ? undefined : css.Header}>
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Box grow="Yes" basis="No" />
+ <Box justifyContent="Center" alignItems="Center" gap="300">
+ {showProfile && (
+ <>
+ <Avatar size="300">
+ <RoomAvatar
+ roomId={space.roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
+ />
+ </Avatar>
+ <Text size="H3" truncate>
+ {name}
+ </Text>
+ </>
+ )}
+ </Box>
+ <Box shrink="No" grow="Yes" basis="No" justifyContent="End">
+ <TooltipProvider
+ position="Bottom"
+ offset={4}
+ tooltip={
+ <Tooltip>
+ <Text>Members</Text>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
+ <Icon size="400" src={Icons.User} />
+ </IconButton>
+ )}
+ </TooltipProvider>
+ <TooltipProvider
+ position="Bottom"
+ align="End"
+ offset={4}
+ tooltip={
+ <Tooltip>
+ <Text>More Options</Text>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
+ <Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
+ </IconButton>
+ )}
+ </TooltipProvider>
+ <PopOut
+ anchor={menuAnchor}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <LobbyMenu
+ roomId={space.roomId}
+ powerLevels={powerLevels}
+ requestClose={() => setMenuAnchor(undefined)}
+ />
+ </FocusTrap>
+ }
+ />
+ </Box>
+ </Box>
+ </PageHeader>
+ );
+}
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const LobbyHeroTopic = style({
+ display: '-webkit-box',
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+
+ ':hover': {
+ cursor: 'pointer',
+ opacity: config.opacity.P500,
+ textDecoration: 'underline',
+ },
+});
--- /dev/null
+import React from 'react';
+import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
+import { useSpace } from '../../hooks/useSpace';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { RoomAvatar } from '../../components/room-avatar';
+import { nameInitials } from '../../utils/common';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { RoomTopicViewer } from '../../components/room-topic-viewer';
+import * as css from './LobbyHero.css';
+import { PageHero } from '../../components/page';
+import { onEnterOrSpace } from '../../utils/keyboard';
+
+export function LobbyHero() {
+ const mx = useMatrixClient();
+ const space = useSpace();
+
+ const name = useRoomName(space);
+ const topic = useRoomTopic(space);
+ const avatarMxc = useRoomAvatar(space);
+ const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
+
+ return (
+ <PageHero
+ icon={
+ <Avatar size="500">
+ <RoomAvatar
+ roomId={space.roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
+ />
+ </Avatar>
+ }
+ title={name}
+ subTitle={
+ topic && (
+ <UseStateProvider initial={false}>
+ {(viewTopic, setViewTopic) => (
+ <>
+ <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ clickOutsideDeactivates: true,
+ onDeactivate: () => setViewTopic(false),
+ }}
+ >
+ <RoomTopicViewer
+ name={name}
+ topic={topic}
+ requestClose={() => setViewTopic(false)}
+ />
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <Text
+ as="span"
+ onClick={() => setViewTopic(true)}
+ onKeyDown={onEnterOrSpace(() => setViewTopic(true))}
+ tabIndex={0}
+ className={css.LobbyHeroTopic}
+ size="Inherit"
+ priority="300"
+ >
+ {topic}
+ </Text>
+ </>
+ )}
+ </UseStateProvider>
+ )
+ }
+ />
+ );
+}
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config, toRem } from 'folds';
+
+export const RoomItemCard = style({
+ padding: config.space.S400,
+ borderRadius: 0,
+ position: 'relative',
+ selectors: {
+ '&[data-dragging=true]': {
+ opacity: config.opacity.Disabled,
+ },
+ },
+});
+export const RoomProfileTopic = style({
+ cursor: 'pointer',
+ ':hover': {
+ textDecoration: 'underline',
+ },
+});
+export const ErrorNameContainer = style({
+ gap: toRem(2),
+});
--- /dev/null
+import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
+import {
+ Avatar,
+ Badge,
+ Box,
+ Chip,
+ Icon,
+ Icons,
+ Line,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Spinner,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+ color,
+ toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { SequenceCard } from '../../components/sequence-card';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
+import { millify } from '../../plugins/millify';
+import {
+ HierarchyRoomSummaryLoader,
+ LocalRoomSummaryLoader,
+} from '../../components/RoomSummaryLoader';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { RoomTopicViewer } from '../../components/room-topic-viewer';
+import { onEnterOrSpace } from '../../utils/keyboard';
+import { Membership, RoomType } from '../../../types/matrix/room';
+import * as css from './RoomItem.css';
+import * as styleCss from './style.css';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { ErrorCode } from '../../cs-errorcode';
+import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
+import { ItemDraggableTarget, useDraggableItem } from './DnD';
+
+type RoomJoinButtonProps = {
+ roomId: string;
+ via?: string[];
+};
+function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
+ const mx = useMatrixClient();
+
+ const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
+ useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
+ );
+
+ const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
+
+ return (
+ <Box shrink="No" gap="200" alignItems="Center">
+ {joinState.status === AsyncStatus.Error && (
+ <TooltipProvider
+ tooltip={
+ <Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
+ <Box direction="Column" gap="100">
+ <Text style={{ wordBreak: 'break-word' }} size="T400">
+ {joinState.error.data?.error || joinState.error.message}
+ </Text>
+ <Text size="T200">{joinState.error.name}</Text>
+ </Box>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <Icon
+ ref={triggerRef}
+ style={{ color: color.Critical.Main, cursor: 'pointer' }}
+ src={Icons.Warning}
+ size="400"
+ filled
+ tabIndex={0}
+ aria-label={joinState.error.data?.error || joinState.error.message}
+ />
+ )}
+ </TooltipProvider>
+ )}
+ <Chip
+ variant="Secondary"
+ fill="Soft"
+ size="400"
+ radii="Pill"
+ before={
+ canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="100" />
+ }
+ onClick={join}
+ disabled={!canJoin}
+ >
+ <Text size="B300">Join</Text>
+ </Chip>
+ </Box>
+ );
+}
+
+function RoomProfileLoading() {
+ return (
+ <Box grow="Yes" gap="300">
+ <Avatar className={styleCss.AvatarPlaceholder} />
+ <Box grow="Yes" direction="Column" gap="100">
+ <Box gap="200" alignItems="Center">
+ <Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(80) }} />
+ </Box>
+ <Box gap="200" alignItems="Center">
+ <Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(40) }} />
+ <Box
+ className={styleCss.LinePlaceholder}
+ shrink="No"
+ style={{
+ maxWidth: toRem(120),
+ }}
+ />
+ </Box>
+ </Box>
+ </Box>
+ );
+}
+
+type RoomProfileErrorProps = {
+ roomId: string;
+ error: Error;
+ suggested?: boolean;
+ via?: string[];
+};
+function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
+ const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
+
+ return (
+ <Box grow="Yes" gap="300">
+ <Avatar>
+ <RoomAvatar
+ roomId={roomId}
+ src={undefined}
+ alt={roomId}
+ renderFallback={() => (
+ <RoomIcon
+ size="300"
+ joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
+ filled
+ />
+ )}
+ />
+ </Avatar>
+ <Box grow="Yes" direction="Column" className={css.ErrorNameContainer}>
+ <Box gap="200" alignItems="Center">
+ <Text size="H5" truncate>
+ Unknown
+ </Text>
+ {suggested && (
+ <Box shrink="No" alignItems="Center">
+ <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+ <Text size="L400">Suggested</Text>
+ </Badge>
+ </Box>
+ )}
+ </Box>
+ <Box gap="200" alignItems="Center">
+ {privateRoom && (
+ <>
+ <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
+ <Text size="L400">Private Room</Text>
+ </Badge>
+ <Line
+ variant="SurfaceVariant"
+ style={{ height: toRem(12) }}
+ direction="Vertical"
+ size="400"
+ />
+ </>
+ )}
+ <Text size="T200" truncate>
+ {roomId}
+ </Text>
+ </Box>
+ </Box>
+ {!privateRoom && <RoomJoinButton roomId={roomId} via={via} />}
+ </Box>
+ );
+}
+
+type RoomProfileProps = {
+ roomId: string;
+ name: string;
+ topic?: string;
+ avatarUrl?: string;
+ suggested?: boolean;
+ memberCount?: number;
+ joinRule?: JoinRule;
+ options?: ReactNode;
+};
+function RoomProfile({
+ roomId,
+ name,
+ topic,
+ avatarUrl,
+ suggested,
+ memberCount,
+ joinRule,
+ options,
+}: RoomProfileProps) {
+ return (
+ <Box grow="Yes" gap="300">
+ <Avatar>
+ <RoomAvatar
+ roomId={roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => (
+ <RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
+ )}
+ />
+ </Avatar>
+ <Box grow="Yes" direction="Column">
+ <Box gap="200" alignItems="Center">
+ <Text size="H5" truncate>
+ {name}
+ </Text>
+ {suggested && (
+ <Box shrink="No" alignItems="Center">
+ <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+ <Text size="L400">Suggested</Text>
+ </Badge>
+ </Box>
+ )}
+ </Box>
+ <Box gap="200" alignItems="Center">
+ {memberCount && (
+ <Box shrink="No" gap="200">
+ <Text size="T200" priority="300">{`${millify(memberCount)} Members`}</Text>
+ </Box>
+ )}
+ {memberCount && topic && (
+ <Line
+ variant="SurfaceVariant"
+ style={{ height: toRem(12) }}
+ direction="Vertical"
+ size="400"
+ />
+ )}
+ {topic && (
+ <UseStateProvider initial={false}>
+ {(view, setView) => (
+ <>
+ <Text
+ className={css.RoomProfileTopic}
+ size="T200"
+ priority="300"
+ truncate
+ onClick={() => setView(true)}
+ onKeyDown={onEnterOrSpace(() => setView(true))}
+ tabIndex={0}
+ >
+ {topic}
+ </Text>
+ <Overlay open={view} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ clickOutsideDeactivates: true,
+ onDeactivate: () => setView(false),
+ }}
+ >
+ <RoomTopicViewer
+ name={name}
+ topic={topic}
+ requestClose={() => setView(false)}
+ />
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ </>
+ )}
+ </UseStateProvider>
+ )}
+ </Box>
+ </Box>
+ {options}
+ </Box>
+ );
+}
+
+function CallbackOnFoundSpace({
+ roomId,
+ onSpaceFound,
+}: {
+ roomId: string;
+ onSpaceFound: (roomId: string) => void;
+}) {
+ useEffect(() => {
+ onSpaceFound(roomId);
+ }, [roomId, onSpaceFound]);
+
+ return null;
+}
+
+type RoomItemCardProps = {
+ item: HierarchyItem;
+ onSpaceFound: (roomId: string) => void;
+ dm?: boolean;
+ firstChild?: boolean;
+ lastChild?: boolean;
+ onOpen: MouseEventHandler<HTMLButtonElement>;
+ options?: ReactNode;
+ before?: ReactNode;
+ after?: ReactNode;
+ onDragging: (item?: HierarchyItem) => void;
+ canReorder: boolean;
+ getRoom: (roomId: string) => Room | undefined;
+};
+export const RoomItemCard = as<'div', RoomItemCardProps>(
+ (
+ {
+ item,
+ onSpaceFound,
+ dm,
+ firstChild,
+ lastChild,
+ onOpen,
+ options,
+ before,
+ after,
+ onDragging,
+ canReorder,
+ getRoom,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const { roomId, content } = item;
+ const room = getRoom(roomId);
+ const targetRef = useRef<HTMLDivElement>(null);
+ const targetHandleRef = useRef<HTMLDivElement>(null);
+ useDraggableItem(item, targetRef, onDragging, targetHandleRef);
+
+ const joined = room?.getMyMembership() === Membership.Join;
+
+ return (
+ <SequenceCard
+ className={css.RoomItemCard}
+ firstChild={firstChild}
+ lastChild={lastChild}
+ variant="SurfaceVariant"
+ gap="300"
+ alignItems="Center"
+ {...props}
+ ref={ref}
+ >
+ {before}
+ <Box ref={canReorder ? targetRef : null} grow="Yes">
+ {canReorder && <ItemDraggableTarget ref={targetHandleRef} />}
+ {room ? (
+ <LocalRoomSummaryLoader room={room}>
+ {(localSummary) => (
+ <RoomProfile
+ roomId={roomId}
+ name={localSummary.name}
+ topic={localSummary.topic}
+ avatarUrl={
+ dm ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)
+ }
+ memberCount={localSummary.memberCount}
+ suggested={content.suggested}
+ joinRule={localSummary.joinRule}
+ options={
+ joined ? (
+ <Box shrink="No" gap="100" alignItems="Center">
+ <Chip
+ data-room-id={roomId}
+ onClick={onOpen}
+ variant="Secondary"
+ fill="None"
+ size="400"
+ radii="Pill"
+ aria-label="Open Room"
+ >
+ <Icon size="50" src={Icons.ArrowRight} />
+ </Chip>
+ </Box>
+ ) : (
+ <RoomJoinButton roomId={roomId} via={content.via} />
+ )
+ }
+ />
+ )}
+ </LocalRoomSummaryLoader>
+ ) : (
+ <HierarchyRoomSummaryLoader roomId={roomId}>
+ {(summaryState) => (
+ <>
+ {summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />}
+ {summaryState.status === AsyncStatus.Error && (
+ <RoomProfileError
+ roomId={roomId}
+ error={summaryState.error}
+ suggested={content.suggested}
+ via={content.via}
+ />
+ )}
+ {summaryState.status === AsyncStatus.Success && (
+ <>
+ {summaryState.data.room_type === RoomType.Space && (
+ <CallbackOnFoundSpace
+ roomId={summaryState.data.room_id}
+ onSpaceFound={onSpaceFound}
+ />
+ )}
+ <RoomProfile
+ roomId={roomId}
+ name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
+ topic={summaryState.data.topic}
+ avatarUrl={
+ summaryState.data?.avatar_url
+ ? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
+ undefined
+ : undefined
+ }
+ memberCount={summaryState.data.num_joined_members}
+ suggested={content.suggested}
+ joinRule={summaryState.data.join_rule}
+ options={<RoomJoinButton roomId={roomId} via={content.via} />}
+ />
+ </>
+ )}
+ </>
+ )}
+ </HierarchyRoomSummaryLoader>
+ )}
+ </Box>
+ {options}
+ {after}
+ </SequenceCard>
+ );
+ }
+);
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+import { recipe } from '@vanilla-extract/recipes';
+
+export const SpaceItemCard = recipe({
+ base: {
+ paddingBottom: config.space.S100,
+ borderBottom: `${config.borderWidth.B300} solid transparent`,
+ position: 'relative',
+ selectors: {
+ '&[data-dragging=true]': {
+ opacity: config.opacity.Disabled,
+ },
+ },
+ },
+ variants: {
+ outlined: {
+ true: {
+ borderBottomColor: color.Surface.ContainerLine,
+ },
+ },
+ },
+});
+export const HeaderChip = style({
+ paddingLeft: config.space.S200,
+ selectors: {
+ [`&[data-ui-before="true"]`]: {
+ paddingLeft: config.space.S100,
+ },
+ },
+});
+export const HeaderChipPlaceholder = style([
+ {
+ borderRadius: config.radii.R400,
+ paddingLeft: config.space.S100,
+ paddingRight: config.space.S300,
+ height: toRem(32),
+ },
+]);
--- /dev/null
+import React, { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react';
+import {
+ Box,
+ Avatar,
+ Text,
+ Chip,
+ Icon,
+ Icons,
+ as,
+ Badge,
+ toRem,
+ Spinner,
+ PopOut,
+ Menu,
+ MenuItem,
+ RectCords,
+ config,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import classNames from 'classnames';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { RoomAvatar } from '../../components/room-avatar';
+import { nameInitials } from '../../utils/common';
+import {
+ HierarchyRoomSummaryLoader,
+ LocalRoomSummaryLoader,
+} from '../../components/RoomSummaryLoader';
+import { getRoomAvatarUrl } from '../../utils/room';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import * as css from './SpaceItem.css';
+import * as styleCss from './style.css';
+import { ErrorCode } from '../../cs-errorcode';
+import { useDraggableItem } from './DnD';
+import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
+
+function SpaceProfileLoading() {
+ return (
+ <Box gap="200" alignItems="Center">
+ <Box grow="Yes" gap="200" alignItems="Center" className={css.HeaderChipPlaceholder}>
+ <Avatar className={styleCss.AvatarPlaceholder} size="200" radii="300" />
+ <Box
+ className={styleCss.LinePlaceholder}
+ shrink="No"
+ style={{ width: '100vw', maxWidth: toRem(120) }}
+ />
+ </Box>
+ </Box>
+ );
+}
+
+type UnknownPrivateSpaceProfileProps = {
+ roomId: string;
+ name?: string;
+ avatarUrl?: string;
+ suggested?: boolean;
+};
+function UnknownPrivateSpaceProfile({
+ roomId,
+ name,
+ avatarUrl,
+ suggested,
+}: UnknownPrivateSpaceProfileProps) {
+ return (
+ <Chip
+ as="span"
+ className={css.HeaderChip}
+ variant="Surface"
+ size="500"
+ before={
+ <Avatar size="200" radii="300">
+ <RoomAvatar
+ roomId={roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => (
+ <Text as="span" size="H6">
+ {nameInitials(name)}
+ </Text>
+ )}
+ />
+ </Avatar>
+ }
+ >
+ <Box alignItems="Center" gap="200">
+ <Text size="H4" truncate>
+ {name || 'Unknown'}
+ </Text>
+
+ <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
+ <Text size="L400">Private Space</Text>
+ </Badge>
+ {suggested && (
+ <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+ <Text size="L400">Suggested</Text>
+ </Badge>
+ )}
+ </Box>
+ </Chip>
+ );
+}
+
+type UnknownSpaceProfileProps = {
+ roomId: string;
+ via?: string[];
+ name?: string;
+ avatarUrl?: string;
+ suggested?: boolean;
+};
+function UnknownSpaceProfile({
+ roomId,
+ via,
+ name,
+ avatarUrl,
+ suggested,
+}: UnknownSpaceProfileProps) {
+ const mx = useMatrixClient();
+
+ const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
+ useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
+ );
+
+ const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
+ return (
+ <Chip
+ className={css.HeaderChip}
+ variant="Surface"
+ size="500"
+ onClick={join}
+ disabled={!canJoin}
+ before={
+ <Avatar size="200" radii="300">
+ <RoomAvatar
+ roomId={roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => (
+ <Text as="span" size="H6">
+ {nameInitials(name)}
+ </Text>
+ )}
+ />
+ </Avatar>
+ }
+ after={
+ canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="200" />
+ }
+ >
+ <Box alignItems="Center" gap="200">
+ <Text size="H4" truncate>
+ {name || 'Unknown'}
+ </Text>
+ {suggested && (
+ <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+ <Text size="L400">Suggested</Text>
+ </Badge>
+ )}
+ {joinState.status === AsyncStatus.Error && (
+ <Badge variant="Critical" fill="Soft" radii="Pill" outlined>
+ <Text size="L400" truncate>
+ {joinState.error.name}
+ </Text>
+ </Badge>
+ )}
+ </Box>
+ </Chip>
+ );
+}
+
+type SpaceProfileProps = {
+ roomId: string;
+ name: string;
+ avatarUrl?: string;
+ suggested?: boolean;
+ closed: boolean;
+ categoryId: string;
+ handleClose?: MouseEventHandler<HTMLButtonElement>;
+};
+function SpaceProfile({
+ roomId,
+ name,
+ avatarUrl,
+ suggested,
+ closed,
+ categoryId,
+ handleClose,
+}: SpaceProfileProps) {
+ return (
+ <Chip
+ data-category-id={categoryId}
+ onClick={handleClose}
+ className={css.HeaderChip}
+ variant="Surface"
+ size="500"
+ before={
+ <Avatar size="200" radii="300">
+ <RoomAvatar
+ roomId={roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => (
+ <Text as="span" size="H6">
+ {nameInitials(name)}
+ </Text>
+ )}
+ />
+ </Avatar>
+ }
+ after={<Icon src={closed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />}
+ >
+ <Box alignItems="Center" gap="200">
+ <Text size="H4" truncate>
+ {name}
+ </Text>
+ {suggested && (
+ <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+ <Text size="L400">Suggested</Text>
+ </Badge>
+ )}
+ </Box>
+ </Chip>
+ );
+}
+
+type RootSpaceProfileProps = {
+ closed: boolean;
+ categoryId: string;
+ handleClose?: MouseEventHandler<HTMLButtonElement>;
+};
+function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) {
+ return (
+ <Chip
+ data-category-id={categoryId}
+ onClick={handleClose}
+ className={css.HeaderChip}
+ variant="Surface"
+ size="500"
+ after={<Icon src={closed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />}
+ >
+ <Box alignItems="Center" gap="200">
+ <Text size="H4" truncate>
+ Rooms
+ </Text>
+ </Box>
+ </Chip>
+ );
+}
+
+function AddRoomButton({ item }: { item: HierarchyItem }) {
+ const [cords, setCords] = useState<RectCords>();
+
+ const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleCreateRoom = () => {
+ openCreateRoom(false, item.roomId as any);
+ setCords(undefined);
+ };
+
+ const handleAddExisting = () => {
+ openSpaceAddExisting(item.roomId);
+ setCords(undefined);
+ };
+
+ return (
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <Menu style={{ padding: config.space.S100 }}>
+ <MenuItem
+ size="300"
+ radii="300"
+ variant="Primary"
+ fill="None"
+ onClick={handleCreateRoom}
+ >
+ <Text size="T300">New Room</Text>
+ </MenuItem>
+ <MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
+ <Text size="T300">Existing Room</Text>
+ </MenuItem>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ variant="Primary"
+ radii="Pill"
+ before={<Icon src={Icons.Plus} size="50" />}
+ onClick={handleAddRoom}
+ aria-pressed={!!cords}
+ >
+ <Text size="B300">Add Room</Text>
+ </Chip>
+ </PopOut>
+ );
+}
+
+function AddSpaceButton({ item }: { item: HierarchyItem }) {
+ const [cords, setCords] = useState<RectCords>();
+
+ const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleCreateSpace = () => {
+ openCreateRoom(true, item.roomId as any);
+ setCords(undefined);
+ };
+
+ const handleAddExisting = () => {
+ openSpaceAddExisting(item.roomId, true);
+ setCords(undefined);
+ };
+ return (
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <Menu style={{ padding: config.space.S100 }}>
+ <MenuItem
+ size="300"
+ radii="300"
+ variant="Primary"
+ fill="None"
+ onClick={handleCreateSpace}
+ >
+ <Text size="T300">New Space</Text>
+ </MenuItem>
+ <MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
+ <Text size="T300">Existing Space</Text>
+ </MenuItem>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ variant="SurfaceVariant"
+ radii="Pill"
+ before={<Icon src={Icons.Plus} size="50" />}
+ onClick={handleAddSpace}
+ aria-pressed={!!cords}
+ >
+ <Text size="B300">Add Space</Text>
+ </Chip>
+ </PopOut>
+ );
+}
+
+type SpaceItemCardProps = {
+ item: HierarchyItem;
+ joined?: boolean;
+ categoryId: string;
+ closed: boolean;
+ handleClose?: MouseEventHandler<HTMLButtonElement>;
+ options?: ReactNode;
+ before?: ReactNode;
+ after?: ReactNode;
+ canEditChild: boolean;
+ canReorder: boolean;
+ onDragging: (item?: HierarchyItem) => void;
+ getRoom: (roomId: string) => Room | undefined;
+};
+export const SpaceItemCard = as<'div', SpaceItemCardProps>(
+ (
+ {
+ className,
+ joined,
+ closed,
+ categoryId,
+ item,
+ handleClose,
+ options,
+ before,
+ after,
+ canEditChild,
+ canReorder,
+ onDragging,
+ getRoom,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const { roomId, content } = item;
+ const space = getRoom(roomId);
+ const targetRef = useRef<HTMLDivElement>(null);
+ useDraggableItem(item, targetRef, onDragging);
+
+ return (
+ <Box
+ shrink="No"
+ alignItems="Center"
+ gap="200"
+ className={classNames(css.SpaceItemCard({ outlined: !joined || closed }), className)}
+ {...props}
+ ref={ref}
+ >
+ {before}
+ <Box grow="Yes" gap="100" alignItems="Inherit" justifyContent="SpaceBetween">
+ <Box ref={canReorder ? targetRef : null}>
+ {space ? (
+ <LocalRoomSummaryLoader room={space}>
+ {(localSummary) =>
+ item.parentId ? (
+ <SpaceProfile
+ roomId={roomId}
+ name={localSummary.name}
+ avatarUrl={getRoomAvatarUrl(mx, space, 96)}
+ suggested={content.suggested}
+ closed={closed}
+ categoryId={categoryId}
+ handleClose={handleClose}
+ />
+ ) : (
+ <RootSpaceProfile
+ closed={closed}
+ categoryId={categoryId}
+ handleClose={handleClose}
+ />
+ )
+ }
+ </LocalRoomSummaryLoader>
+ ) : (
+ <HierarchyRoomSummaryLoader roomId={roomId}>
+ {(summaryState) => (
+ <>
+ {summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />}
+ {summaryState.status === AsyncStatus.Error &&
+ (summaryState.error.name === ErrorCode.M_FORBIDDEN ? (
+ <UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} />
+ ) : (
+ <UnknownSpaceProfile
+ roomId={roomId}
+ via={item.content.via}
+ suggested={content.suggested}
+ />
+ ))}
+ {summaryState.status === AsyncStatus.Success && (
+ <UnknownSpaceProfile
+ roomId={roomId}
+ via={item.content.via}
+ name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
+ avatarUrl={
+ summaryState.data?.avatar_url
+ ? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
+ undefined
+ : undefined
+ }
+ suggested={content.suggested}
+ />
+ )}
+ </>
+ )}
+ </HierarchyRoomSummaryLoader>
+ )}
+ </Box>
+ {canEditChild && (
+ <Box alignItems="Inherit" gap="200">
+ <AddRoomButton item={item} />
+ {item.parentId === undefined && <AddSpaceButton item={item} />}
+ </Box>
+ )}
+ </Box>
+ {options}
+ {after}
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './Lobby';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const AvatarPlaceholder = style({
+ backgroundColor: color.Secondary.Container,
+});
+export const LinePlaceholder = style([
+ DefaultReset,
+ {
+ width: '100%',
+ height: config.lineHeight.T200,
+ borderRadius: config.radii.R300,
+ backgroundColor: color.Secondary.Container,
+ },
+]);
--- /dev/null
+import React, { RefObject, useEffect, useMemo, useRef } from 'react';
+import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
+import { useAtomValue } from 'jotai';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useSearchParams } from 'react-router-dom';
+import { SearchOrderBy } from 'matrix-js-sdk';
+import { PageHero, PageHeroSection } from '../../components/page';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { _SearchPathSearchParams } from '../../pages/paths';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { SequenceCard } from '../../components/sequence-card';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { ScrollTopContainer } from '../../components/scroll-top-container';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
+import { useRooms } from '../../state/hooks/roomList';
+import { allRoomsAtom } from '../../state/room-list/roomList';
+import { mDirectAtom } from '../../state/mDirectList';
+import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
+import { SearchResultGroup } from './SearchResultGroup';
+import { SearchInput } from './SearchInput';
+import { SearchFilters } from './SearchFilters';
+import { VirtualTile } from '../../components/virtualizer';
+
+const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
+ useMemo(
+ () => ({
+ global: searchParams.get('global') ?? undefined,
+ term: searchParams.get('term') ?? undefined,
+ order: searchParams.get('order') ?? undefined,
+ rooms: searchParams.get('rooms') ?? undefined,
+ senders: searchParams.get('senders') ?? undefined,
+ }),
+ [searchParams]
+ );
+
+type MessageSearchProps = {
+ defaultRoomsFilterName: string;
+ allowGlobal?: boolean;
+ rooms: string[];
+ senders?: string[];
+ scrollRef: RefObject<HTMLDivElement>;
+};
+export function MessageSearch({
+ defaultRoomsFilterName,
+ allowGlobal,
+ rooms,
+ senders,
+ scrollRef,
+}: MessageSearchProps) {
+ const mx = useMatrixClient();
+ const mDirects = useAtomValue(mDirectAtom);
+ const allRooms = useRooms(mx, allRoomsAtom, mDirects);
+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+ const searchInputRef = useRef<HTMLInputElement>(null);
+ const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const searchPathSearchParams = useSearchPathSearchParams(searchParams);
+ const { navigateRoom } = useRoomNavigate();
+
+ const searchParamRooms = useMemo(() => {
+ if (searchPathSearchParams.rooms) {
+ const joinedRoomIds = decodeSearchParamValueArray(searchPathSearchParams.rooms).filter(
+ (rId) => allRooms.includes(rId)
+ );
+ return joinedRoomIds;
+ }
+ return undefined;
+ }, [allRooms, searchPathSearchParams.rooms]);
+ const searchParamsSenders = useMemo(() => {
+ if (searchPathSearchParams.senders) {
+ return decodeSearchParamValueArray(searchPathSearchParams.senders);
+ }
+ return undefined;
+ }, [searchPathSearchParams.senders]);
+
+ const msgSearchParams: MessageSearchParams = useMemo(() => {
+ const isGlobal = searchPathSearchParams.global === 'true';
+ const defaultRooms = isGlobal ? undefined : rooms;
+
+ return {
+ term: searchPathSearchParams.term,
+ order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
+ rooms: searchParamRooms ?? defaultRooms,
+ senders: searchParamsSenders ?? senders,
+ };
+ }, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
+
+ const searchMessages = useMessageSearch(msgSearchParams);
+
+ const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
+ enabled: !!msgSearchParams.term,
+ queryKey: [
+ 'search',
+ msgSearchParams.term,
+ msgSearchParams.order,
+ msgSearchParams.rooms,
+ msgSearchParams.senders,
+ ],
+ queryFn: ({ pageParam }) => searchMessages(pageParam),
+ initialPageParam: '',
+ getNextPageParam: (lastPage) => lastPage.nextToken,
+ });
+
+ const groups = useMemo(() => data?.pages.flatMap((result) => result.groups) ?? [], [data]);
+ const highlights = useMemo(() => {
+ const mixed = data?.pages.flatMap((result) => result.highlights);
+ return Array.from(new Set(mixed));
+ }, [data]);
+
+ const virtualizer = useVirtualizer({
+ count: groups.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 40,
+ overscan: 1,
+ });
+ const vItems = virtualizer.getVirtualItems();
+
+ const handleSearch = (term: string) => {
+ setSearchParams((prevParams) => {
+ const newParams = new URLSearchParams(prevParams);
+ newParams.delete('term');
+ newParams.append('term', term);
+ return newParams;
+ });
+ };
+ const handleSearchClear = () => {
+ if (searchInputRef.current) {
+ searchInputRef.current.value = '';
+ }
+ setSearchParams((prevParams) => {
+ const newParams = new URLSearchParams(prevParams);
+ newParams.delete('term');
+ return newParams;
+ });
+ };
+
+ const handleSelectedRoomsChange = (selectedRooms?: string[]) => {
+ setSearchParams((prevParams) => {
+ const newParams = new URLSearchParams(prevParams);
+ newParams.delete('rooms');
+ if (selectedRooms && selectedRooms.length > 0) {
+ newParams.append('rooms', encodeSearchParamValueArray(selectedRooms));
+ }
+ return newParams;
+ });
+ };
+ const handleGlobalChange = (global?: boolean) => {
+ setSearchParams((prevParams) => {
+ const newParams = new URLSearchParams(prevParams);
+ newParams.delete('global');
+ if (global) {
+ newParams.append('global', 'true');
+ }
+ return newParams;
+ });
+ };
+
+ const handleOrderChange = (order?: string) => {
+ setSearchParams((prevParams) => {
+ const newParams = new URLSearchParams(prevParams);
+ newParams.delete('order');
+ if (order) {
+ newParams.append('order', order);
+ }
+ return newParams;
+ });
+ };
+
+ const lastVItem = vItems[vItems.length - 1];
+ const lastVItemIndex: number | undefined = lastVItem?.index;
+ const lastGroupIndex = groups.length - 1;
+ useEffect(() => {
+ if (
+ lastGroupIndex > -1 &&
+ lastGroupIndex === lastVItemIndex &&
+ !isFetchingNextPage &&
+ hasNextPage
+ ) {
+ fetchNextPage();
+ }
+ }, [lastVItemIndex, lastGroupIndex, fetchNextPage, isFetchingNextPage, hasNextPage]);
+
+ return (
+ <Box direction="Column" gap="700">
+ <ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
+ <IconButton
+ onClick={() => virtualizer.scrollToOffset(0)}
+ variant="SurfaceVariant"
+ radii="Pill"
+ outlined
+ size="300"
+ aria-label="Scroll to Top"
+ >
+ <Icon src={Icons.ChevronTop} size="300" />
+ </IconButton>
+ </ScrollTopContainer>
+ <Box ref={scrollTopAnchorRef} direction="Column" gap="300">
+ <SearchInput
+ active={!!msgSearchParams.term}
+ loading={status === 'pending'}
+ searchInputRef={searchInputRef}
+ onSearch={handleSearch}
+ onReset={handleSearchClear}
+ />
+ <SearchFilters
+ defaultRoomsFilterName={defaultRoomsFilterName}
+ allowGlobal={allowGlobal}
+ roomList={searchPathSearchParams.global === 'true' ? allRooms : rooms}
+ selectedRooms={searchParamRooms}
+ onSelectedRoomsChange={handleSelectedRoomsChange}
+ global={searchPathSearchParams.global === 'true'}
+ onGlobalChange={handleGlobalChange}
+ order={msgSearchParams.order}
+ onOrderChange={handleOrderChange}
+ />
+ </Box>
+
+ {!msgSearchParams.term && status === 'pending' && (
+ <Box
+ className={ContainerColor({ variant: 'SurfaceVariant' })}
+ style={{
+ padding: config.space.S400,
+ borderRadius: config.radii.R400,
+ minHeight: toRem(450),
+ }}
+ direction="Column"
+ alignItems="Center"
+ justifyContent="Center"
+ gap="200"
+ >
+ <PageHeroSection>
+ <PageHero
+ icon={<Icon size="600" src={Icons.Message} />}
+ title="Search Messages"
+ subTitle="Find helpful messages in your community by searching with related keywords."
+ />
+ </PageHeroSection>
+ </Box>
+ )}
+
+ {msgSearchParams.term && groups.length === 0 && status === 'success' && (
+ <Box
+ className={ContainerColor({ variant: 'Warning' })}
+ style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
+ alignItems="Center"
+ gap="200"
+ >
+ <Icon size="200" src={Icons.Info} />
+ <Text>
+ No results found for <b>{`"${msgSearchParams.term}"`}</b>
+ </Text>
+ </Box>
+ )}
+
+ {((msgSearchParams.term && status === 'pending') ||
+ (groups.length > 0 && vItems.length === 0)) && (
+ <Box direction="Column" gap="100">
+ {[...Array(8).keys()].map((key) => (
+ <SequenceCard variant="SurfaceVariant" key={key} style={{ minHeight: toRem(80) }} />
+ ))}
+ </Box>
+ )}
+
+ {vItems.length > 0 && (
+ <Box direction="Column" gap="300">
+ <Box direction="Column" gap="200">
+ <Text size="H5">{`Results for "${msgSearchParams.term}"`}</Text>
+ <Line size="300" variant="Surface" />
+ </Box>
+ <div
+ style={{
+ position: 'relative',
+ height: virtualizer.getTotalSize(),
+ }}
+ >
+ {vItems.map((vItem) => {
+ const group = groups[vItem.index];
+ if (!group) return null;
+ const groupRoom = mx.getRoom(group.roomId);
+ if (!groupRoom) return null;
+
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ style={{ paddingBottom: config.space.S500 }}
+ ref={virtualizer.measureElement}
+ key={vItem.index}
+ >
+ <SearchResultGroup
+ room={groupRoom}
+ highlights={highlights}
+ items={group.items}
+ mediaAutoLoad={mediaAutoLoad}
+ urlPreview={urlPreview}
+ onOpen={navigateRoom}
+ />
+ </VirtualTile>
+ );
+ })}
+ </div>
+ {isFetchingNextPage && (
+ <Box justifyContent="Center" alignItems="Center">
+ <Spinner size="600" variant="Secondary" />
+ </Box>
+ )}
+ </Box>
+ )}
+
+ {error && (
+ <Box
+ className={ContainerColor({ variant: 'Critical' })}
+ style={{
+ padding: config.space.S300,
+ borderRadius: config.radii.R400,
+ }}
+ direction="Column"
+ gap="200"
+ >
+ <Text size="L400">{error.name}</Text>
+ <Text size="T300">{error.message}</Text>
+ </Box>
+ )}
+ </Box>
+ );
+}
--- /dev/null
+import React, {
+ ChangeEventHandler,
+ MouseEventHandler,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Box,
+ Chip,
+ Text,
+ Icon,
+ Icons,
+ Line,
+ config,
+ PopOut,
+ Menu,
+ MenuItem,
+ Header,
+ toRem,
+ Scroll,
+ Button,
+ Input,
+ Badge,
+ RectCords,
+} from 'folds';
+import { SearchOrderBy } from 'matrix-js-sdk';
+import FocusTrap from 'focus-trap-react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { joinRuleToIconSrc } from '../../utils/room';
+import { factoryRoomIdByAtoZ } from '../../utils/sort';
+import {
+ SearchItemStrGetter,
+ UseAsyncSearchOptions,
+ useAsyncSearch,
+} from '../../hooks/useAsyncSearch';
+import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
+import { VirtualTile } from '../../components/virtualizer';
+
+type OrderButtonProps = {
+ order?: string;
+ onChange: (order?: string) => void;
+};
+function OrderButton({ order, onChange }: OrderButtonProps) {
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+ const rankOrder = order === SearchOrderBy.Rank;
+
+ const setOrder = (o?: string) => {
+ setMenuAnchor(undefined);
+ onChange(o);
+ };
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <PopOut
+ anchor={menuAnchor}
+ align="End"
+ position="Bottom"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Menu variant="Surface">
+ <Header size="300" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
+ <Text size="L400">Sort by</Text>
+ </Header>
+ <Line variant="Surface" size="300" />
+ <div style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={() => setOrder()}
+ variant="Surface"
+ size="300"
+ radii="300"
+ aria-pressed={!rankOrder}
+ >
+ <Text size="T300">Recent</Text>
+ </MenuItem>
+ <MenuItem
+ onClick={() => setOrder(SearchOrderBy.Rank)}
+ variant="Surface"
+ size="300"
+ radii="300"
+ aria-pressed={rankOrder}
+ >
+ <Text size="T300">Relevance</Text>
+ </MenuItem>
+ </div>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ variant="SurfaceVariant"
+ radii="Pill"
+ after={<Icon size="50" src={Icons.Sort} />}
+ onClick={handleOpenMenu}
+ >
+ {rankOrder ? <Text size="T200">Relevance</Text> : <Text size="T200">Recent</Text>}
+ </Chip>
+ </PopOut>
+ );
+}
+
+const SEARCH_OPTS: UseAsyncSearchOptions = {
+ limit: 20,
+ matchOptions: {
+ contain: true,
+ },
+};
+const SEARCH_DEBOUNCE_OPTS: DebounceOptions = {
+ wait: 200,
+};
+
+type SelectRoomButtonProps = {
+ roomList: string[];
+ selectedRooms?: string[];
+ onChange: (rooms?: string[]) => void;
+};
+function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
+ const mx = useMatrixClient();
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+ const [localSelected, setLocalSelected] = useState(selectedRooms);
+
+ const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
+ (rId) => mx.getRoom(rId)?.name ?? rId,
+ [mx]
+ );
+
+ const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
+ roomList,
+ getRoomNameStr,
+ SEARCH_OPTS
+ );
+ const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx));
+
+ const virtualizer = useVirtualizer({
+ count: rooms.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 32,
+ overscan: 5,
+ });
+ const vItems = virtualizer.getVirtualItems();
+
+ const searchRoom = useDebounce(_searchRoom, SEARCH_DEBOUNCE_OPTS);
+ const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+ const value = evt.currentTarget.value.trim();
+ if (!value) {
+ resetSearch();
+ return;
+ }
+ searchRoom(value);
+ };
+
+ const handleRoomClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const roomId = evt.currentTarget.getAttribute('data-room-id');
+ if (!roomId) return;
+ if (localSelected?.includes(roomId)) {
+ setLocalSelected(localSelected?.filter((rId) => rId !== roomId));
+ return;
+ }
+ const addedRooms = [...(localSelected ?? [])];
+ addedRooms.push(roomId);
+ setLocalSelected(addedRooms);
+ };
+
+ const handleSave = () => {
+ setMenuAnchor(undefined);
+ onChange(localSelected);
+ };
+
+ const handleDeselectAll = () => {
+ setMenuAnchor(undefined);
+ onChange(undefined);
+ };
+
+ useEffect(() => {
+ setLocalSelected(selectedRooms);
+ resetSearch();
+ }, [menuAnchor, selectedRooms, resetSearch]);
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <PopOut
+ anchor={menuAnchor}
+ align="Center"
+ position="Bottom"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Menu variant="Surface" style={{ width: toRem(250) }}>
+ <Box direction="Column" style={{ maxHeight: toRem(450), maxWidth: toRem(300) }}>
+ <Box
+ shrink="No"
+ direction="Column"
+ gap="100"
+ style={{ padding: config.space.S200, paddingBottom: 0 }}
+ >
+ <Text size="L400">Search</Text>
+ <Input
+ onChange={handleSearchChange}
+ size="300"
+ radii="300"
+ after={
+ searchResult && searchResult.items.length > 0 ? (
+ <Badge variant="Secondary" size="400" radii="Pill">
+ <Text size="L400">{searchResult.items.length}</Text>
+ </Badge>
+ ) : null
+ }
+ />
+ </Box>
+ <Scroll ref={scrollRef} size="300" hideTrack>
+ <Box
+ direction="Column"
+ gap="100"
+ style={{
+ padding: config.space.S200,
+ paddingRight: 0,
+ }}
+ >
+ {!searchResult && <Text size="L400">Rooms</Text>}
+ {searchResult && <Text size="L400">{`Rooms for "${searchResult.query}"`}</Text>}
+ {searchResult && searchResult.items.length === 0 && (
+ <Text style={{ padding: config.space.S400 }} size="T300" align="Center">
+ No match found!
+ </Text>
+ )}
+ <div
+ style={{
+ position: 'relative',
+ height: virtualizer.getTotalSize(),
+ }}
+ >
+ {vItems.map((vItem) => {
+ const roomId = rooms[vItem.index];
+ const room = mx.getRoom(roomId);
+ if (!room) return null;
+ const selected = localSelected?.includes(roomId);
+
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ style={{ paddingBottom: config.space.S100 }}
+ ref={virtualizer.measureElement}
+ key={vItem.index}
+ >
+ <MenuItem
+ data-room-id={roomId}
+ onClick={handleRoomClick}
+ variant={selected ? 'Success' : 'Surface'}
+ size="300"
+ radii="300"
+ aria-pressed={selected}
+ before={
+ <Icon
+ size="50"
+ src={
+ joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
+ }
+ />
+ }
+ >
+ <Text truncate size="T300">
+ {room.name}
+ </Text>
+ </MenuItem>
+ </VirtualTile>
+ );
+ })}
+ </div>
+ </Box>
+ </Scroll>
+ <Line variant="Surface" size="300" />
+ <Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200 }}>
+ <Button size="300" variant="Secondary" radii="300" onClick={handleSave}>
+ {localSelected && localSelected.length > 0 ? (
+ <Text size="B300">Save ({localSelected.length})</Text>
+ ) : (
+ <Text size="B300">Save</Text>
+ )}
+ </Button>
+ <Button
+ size="300"
+ radii="300"
+ variant="Secondary"
+ fill="Soft"
+ onClick={handleDeselectAll}
+ disabled={!localSelected || localSelected.length === 0}
+ >
+ <Text size="B300">Deselect All</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ onClick={handleOpenMenu}
+ variant="SurfaceVariant"
+ radii="Pill"
+ before={<Icon size="100" src={Icons.PlusCircle} />}
+ >
+ <Text size="T200">Select Rooms</Text>
+ </Chip>
+ </PopOut>
+ );
+}
+
+type SearchFiltersProps = {
+ defaultRoomsFilterName: string;
+ allowGlobal?: boolean;
+ roomList: string[];
+ selectedRooms?: string[];
+ onSelectedRoomsChange: (selectedRooms?: string[]) => void;
+ global?: boolean;
+ onGlobalChange: (global?: boolean) => void;
+ order?: string;
+ onOrderChange: (order?: string) => void;
+};
+export function SearchFilters({
+ defaultRoomsFilterName,
+ allowGlobal,
+ roomList,
+ selectedRooms,
+ onSelectedRoomsChange,
+ global,
+ order,
+ onGlobalChange,
+ onOrderChange,
+}: SearchFiltersProps) {
+ const mx = useMatrixClient();
+
+ return (
+ <Box direction="Column" gap="100">
+ <Text size="L400">Filter</Text>
+ <Box gap="200" wrap="Wrap">
+ <Chip
+ variant={!global ? 'Success' : 'Surface'}
+ aria-pressed={!global}
+ before={!global && <Icon size="100" src={Icons.Check} />}
+ outlined
+ onClick={() => onGlobalChange()}
+ >
+ <Text size="T200">{defaultRoomsFilterName}</Text>
+ </Chip>
+ {allowGlobal && (
+ <Chip
+ variant={global ? 'Success' : 'Surface'}
+ aria-pressed={global}
+ before={global && <Icon size="100" src={Icons.Check} />}
+ outlined
+ onClick={() => onGlobalChange(true)}
+ >
+ <Text size="T200">Global</Text>
+ </Chip>
+ )}
+ <Line
+ style={{ margin: `${config.space.S100} 0` }}
+ direction="Vertical"
+ variant="Surface"
+ size="300"
+ />
+ {selectedRooms?.map((roomId) => {
+ const room = mx.getRoom(roomId);
+ if (!room) return null;
+
+ return (
+ <Chip
+ key={roomId}
+ variant="Success"
+ onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
+ radii="Pill"
+ before={
+ <Icon
+ size="50"
+ src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
+ />
+ }
+ after={<Icon size="50" src={Icons.Cross} />}
+ >
+ <Text size="T200">{room.name}</Text>
+ </Chip>
+ );
+ })}
+ <SelectRoomButton
+ roomList={roomList}
+ selectedRooms={selectedRooms}
+ onChange={onSelectedRoomsChange}
+ />
+ <Box grow="Yes" data-spacing-node />
+ <OrderButton order={order} onChange={onOrderChange} />
+ </Box>
+ </Box>
+ );
+}
--- /dev/null
+import React, { FormEventHandler, RefObject } from 'react';
+import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds';
+
+type SearchProps = {
+ active?: boolean;
+ loading?: boolean;
+ searchInputRef: RefObject<HTMLInputElement>;
+ onSearch: (term: string) => void;
+ onReset: () => void;
+};
+export function SearchInput({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) {
+ const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ const { searchInput } = evt.target as HTMLFormElement & {
+ searchInput: HTMLInputElement;
+ };
+
+ const searchTerm = searchInput.value.trim() || undefined;
+ if (searchTerm) {
+ onSearch(searchTerm);
+ }
+ };
+
+ return (
+ <Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
+ <span data-spacing-node />
+ <Text size="L400">Search</Text>
+ <Input
+ ref={searchInputRef}
+ style={{ paddingRight: config.space.S300 }}
+ name="searchInput"
+ size="500"
+ variant="Background"
+ placeholder="Search for keyword"
+ autoComplete="off"
+ before={
+ active && loading ? (
+ <Spinner variant="Secondary" size="200" />
+ ) : (
+ <Icon size="200" src={Icons.Search} />
+ )
+ }
+ after={
+ active ? (
+ <Chip
+ key="resetButton"
+ type="reset"
+ variant="Secondary"
+ size="400"
+ radii="Pill"
+ outlined
+ after={<Icon size="50" src={Icons.Cross} />}
+ onClick={onReset}
+ >
+ <Text size="B300">Clear</Text>
+ </Chip>
+ ) : (
+ <Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
+ <Text size="B300">Enter</Text>
+ </Chip>
+ )
+ }
+ />
+ </Box>
+ );
+}
--- /dev/null
+/* eslint-disable react/destructuring-assignment */
+import React, { MouseEventHandler, useMemo } from 'react';
+import { IEventWithRoomId, JoinRule, RelationType, Room } from 'matrix-js-sdk';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import {
+ getReactCustomHtmlParser,
+ makeHighlightRegex,
+} from '../../plugins/react-custom-html-parser';
+import { getMxIdLocalPart, isRoomId, isUserId } from '../../utils/matrix';
+import { openJoinAlias, openProfileViewer } from '../../../client/action/navigation';
+import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
+import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
+import {
+ AvatarBase,
+ ImageContent,
+ MSticker,
+ ModernLayout,
+ RedactedContent,
+ Reply,
+ Time,
+ Username,
+} from '../../components/message';
+import { RenderMessageContent } from '../../components/RenderMessageContent';
+import { Image } from '../../components/media';
+import { ImageViewer } from '../../components/image-viewer';
+import * as customHtmlCss from '../../styles/CustomHtml.css';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
+import colorMXID from '../../../util/colorMXID';
+import { ResultItem } from './useMessageSearch';
+import { SequenceCard } from '../../components/sequence-card';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { UserAvatar } from '../../components/user-avatar';
+
+type SearchResultGroupProps = {
+ room: Room;
+ highlights: string[];
+ items: ResultItem[];
+ mediaAutoLoad?: boolean;
+ urlPreview?: boolean;
+ onOpen: (roomId: string, eventId: string) => void;
+};
+export function SearchResultGroup({
+ room,
+ highlights,
+ items,
+ mediaAutoLoad,
+ urlPreview,
+ onOpen,
+}: SearchResultGroupProps) {
+ const mx = useMatrixClient();
+ const { navigateRoom, navigateSpace } = useRoomNavigate();
+ const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
+
+ const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+ () =>
+ getReactCustomHtmlParser(mx, room, {
+ highlightRegex,
+ handleSpoilerClick: (evt) => {
+ const target = evt.currentTarget;
+ if (target.getAttribute('aria-pressed') === 'true') {
+ evt.stopPropagation();
+ target.setAttribute('aria-pressed', 'false');
+ target.style.cursor = 'initial';
+ }
+ },
+ handleMentionClick: (evt) => {
+ const target = evt.currentTarget;
+ const mentionId = target.getAttribute('data-mention-id');
+ if (typeof mentionId !== 'string') return;
+ if (isUserId(mentionId)) {
+ openProfileViewer(mentionId, room.roomId);
+ return;
+ }
+ if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
+ if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
+ else navigateRoom(mentionId);
+ return;
+ }
+ openJoinAlias(mentionId);
+ },
+ }),
+ [mx, room, highlightRegex, navigateRoom, navigateSpace]
+ );
+
+ const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>(
+ {
+ [MessageEvent.RoomMessage]: (event, displayName, getContent) => {
+ if (event.unsigned?.redacted_because) {
+ return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+ }
+
+ return (
+ <RenderMessageContent
+ displayName={displayName}
+ msgType={event.content.msgtype ?? ''}
+ ts={event.origin_server_ts}
+ getContent={getContent}
+ mediaAutoLoad={mediaAutoLoad}
+ urlPreview={urlPreview}
+ htmlReactParserOptions={htmlReactParserOptions}
+ highlightRegex={highlightRegex}
+ outlineAttachment
+ />
+ );
+ },
+ [MessageEvent.Reaction]: (event, displayName, getContent) => {
+ if (event.unsigned?.redacted_because) {
+ return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+ }
+ return (
+ <MSticker
+ content={getContent()}
+ renderImageContent={(props) => (
+ <ImageContent
+ {...props}
+ autoPlay={mediaAutoLoad}
+ renderImage={(p) => <Image {...p} loading="lazy" />}
+ renderViewer={(p) => <ImageViewer {...p} />}
+ />
+ )}
+ />
+ );
+ },
+ [StateEvent.RoomTombstone]: (event) => {
+ const { content } = event;
+ return (
+ <Box grow="Yes" direction="Column">
+ <Text size="T400" priority="300">
+ Room Tombstone. {content.body}
+ </Text>
+ </Box>
+ );
+ },
+ },
+ undefined,
+ (event) => {
+ if (event.unsigned?.redacted_because) {
+ return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+ }
+ return (
+ <Box grow="Yes" direction="Column">
+ <Text size="T400" priority="300">
+ <code className={customHtmlCss.Code}>{event.type}</code>
+ {' event'}
+ </Text>
+ </Box>
+ );
+ }
+ );
+
+ const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const eventId = evt.currentTarget.getAttribute('data-event-id');
+ if (!eventId) return;
+ onOpen(room.roomId, eventId);
+ };
+
+ return (
+ <Box direction="Column" gap="200">
+ <Header size="300">
+ <Box gap="200" grow="Yes">
+ <Avatar size="200" radii="300">
+ <RoomAvatar
+ roomId={room.roomId}
+ src={getRoomAvatarUrl(mx, room, 96)}
+ alt={room.name}
+ renderFallback={() => (
+ <RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
+ )}
+ />
+ </Avatar>
+ <Text size="H4" truncate>
+ {room.name}
+ </Text>
+ </Box>
+ </Header>
+ <Box direction="Column" gap="100">
+ {items.map((item) => {
+ const { event } = item;
+
+ const displayName =
+ getMemberDisplayName(room, event.sender) ??
+ getMxIdLocalPart(event.sender) ??
+ event.sender;
+ const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
+
+ const mainEventId =
+ event.content['m.relates_to']?.rel_type === RelationType.Replace
+ ? event.content['m.relates_to'].event_id
+ : event.event_id;
+
+ const getContent = (() =>
+ event.content['m.new_content'] ?? event.content) as GetContentCallback;
+
+ const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
+
+ return (
+ <SequenceCard
+ key={event.event_id}
+ style={{ padding: config.space.S400 }}
+ variant="SurfaceVariant"
+ direction="Column"
+ >
+ <ModernLayout
+ before={
+ <AvatarBase>
+ <Avatar size="300">
+ <UserAvatar
+ userId={event.sender}
+ src={
+ senderAvatarMxc
+ ? mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? undefined
+ : undefined
+ }
+ alt={displayName}
+ renderFallback={() => <Icon size="200" src={Icons.User} filled />}
+ />
+ </Avatar>
+ </AvatarBase>
+ }
+ >
+ <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+ <Box gap="200" alignItems="Baseline">
+ <Username style={{ color: colorMXID(event.sender) }}>
+ <Text as="span" truncate>
+ <b>{displayName}</b>
+ </Text>
+ </Username>
+ <Time ts={event.origin_server_ts} />
+ </Box>
+ <Box shrink="No" gap="200" alignItems="Center">
+ <Chip
+ data-event-id={mainEventId}
+ onClick={handleOpenClick}
+ variant="Secondary"
+ radii="400"
+ >
+ <Text size="T200">Open</Text>
+ </Chip>
+ </Box>
+ </Box>
+ {replyEventId && (
+ <Reply
+ as="button"
+ mx={mx}
+ room={room}
+ eventId={replyEventId}
+ data-event-id={replyEventId}
+ onClick={handleOpenClick}
+ />
+ )}
+ {renderMatrixEvent(event.type, false, event, displayName, getContent)}
+ </ModernLayout>
+ </SequenceCard>
+ );
+ })}
+ </Box>
+ </Box>
+ );
+}
--- /dev/null
+export * from './MessageSearch';
--- /dev/null
+import {
+ IEventWithRoomId,
+ IResultContext,
+ ISearchRequestBody,
+ ISearchResponse,
+ ISearchResult,
+ SearchOrderBy,
+} from 'matrix-js-sdk';
+import { useCallback } from 'react';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+
+export type ResultItem = {
+ rank: number;
+ event: IEventWithRoomId;
+ context: IResultContext;
+};
+
+export type ResultGroup = {
+ roomId: string;
+ items: ResultItem[];
+};
+
+export type SearchResult = {
+ nextToken?: string;
+ highlights: string[];
+ groups: ResultGroup[];
+};
+
+const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
+ const groups: ResultGroup[] = [];
+
+ results.forEach((item) => {
+ const roomId = item.result.room_id;
+ const resultItem: ResultItem = {
+ rank: item.rank,
+ event: item.result,
+ context: item.context,
+ };
+
+ const lastAddedGroup: ResultGroup | undefined = groups[groups.length - 1];
+ if (lastAddedGroup && roomId === lastAddedGroup.roomId) {
+ lastAddedGroup.items.push(resultItem);
+ return;
+ }
+ groups.push({
+ roomId,
+ items: [resultItem],
+ });
+ });
+
+ return groups;
+};
+
+const parseSearchResult = (result: ISearchResponse): SearchResult => {
+ const roomEvents = result.search_categories.room_events;
+
+ const searchResult: SearchResult = {
+ nextToken: roomEvents?.next_batch,
+ highlights: roomEvents?.highlights ?? [],
+ groups: groupSearchResult(roomEvents?.results ?? []),
+ };
+
+ return searchResult;
+};
+
+export type MessageSearchParams = {
+ term?: string;
+ order?: string;
+ rooms?: string[];
+ senders?: string[];
+};
+export const useMessageSearch = (params: MessageSearchParams) => {
+ const mx = useMatrixClient();
+ const { term, order, rooms, senders } = params;
+
+ const searchMessages = useCallback(
+ async (nextBatch?: string) => {
+ if (!term)
+ return {
+ highlights: [],
+ groups: [],
+ };
+ const limit = 20;
+
+ const requestBody: ISearchRequestBody = {
+ search_categories: {
+ room_events: {
+ event_context: {
+ before_limit: 0,
+ after_limit: 0,
+ include_profile: false,
+ },
+ filter: {
+ limit,
+ rooms,
+ senders,
+ },
+ include_state: false,
+ order_by: order as SearchOrderBy.Recent,
+ search_term: term,
+ },
+ },
+ };
+
+ const r = await mx.search({
+ body: requestBody,
+ next_batch: nextBatch === '' ? undefined : nextBatch,
+ });
+ return parseSearchResult(r);
+ },
+ [mx, term, order, rooms, senders]
+ );
+
+ return searchMessages;
+};
--- /dev/null
+import React from 'react';
+import { as, Chip, Icon, Icons, Text } from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
+ ({ className, closed, children, ...props }, ref) => (
+ <Chip
+ className={classNames(css.CategoryButton, className)}
+ variant="Background"
+ radii="Pill"
+ before={
+ <Icon
+ className={css.CategoryButtonIcon}
+ size="50"
+ src={closed ? Icons.ChevronRight : Icons.ChevronBottom}
+ />
+ }
+ {...props}
+ ref={ref}
+ >
+ <Text size="O400" priority="400" truncate>
+ {children}
+ </Text>
+ </Chip>
+ )
+);
--- /dev/null
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import { Room } from 'matrix-js-sdk';
+import {
+ Avatar,
+ Box,
+ Icon,
+ IconButton,
+ Icons,
+ Text,
+ Menu,
+ MenuItem,
+ config,
+ PopOut,
+ toRem,
+ Line,
+ RectCords,
+ Badge,
+} from 'folds';
+import { useFocusWithin, useHover } from 'react-aria';
+import FocusTrap from 'focus-trap-react';
+import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
+import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
+import { nameInitials } from '../../utils/common';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomUnread } from '../../state/hooks/unread';
+import { roomToUnreadAtom } from '../../state/room/roomToUnread';
+import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { copyToClipboard } from '../../utils/dom';
+import { getOriginBaseUrl, withOriginBaseUrl } from '../../pages/pathUtils';
+import { markAsRead } from '../../../client/action/notifications';
+import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
+import { useClientConfig } from '../../hooks/useClientConfig';
+import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
+import { TypingIndicator } from '../../components/typing-indicator';
+
+type RoomNavItemMenuProps = {
+ room: Room;
+ linkPath: string;
+ requestClose: () => void;
+};
+const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
+ ({ room, linkPath, requestClose }, ref) => {
+ const mx = useMatrixClient();
+ const { hashRouter } = useClientConfig();
+ const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+ const powerLevels = usePowerLevels(room);
+ const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+ const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+ const handleMarkAsRead = () => {
+ markAsRead(room.roomId);
+ requestClose();
+ };
+
+ const handleInvite = () => {
+ openInviteUser(room.roomId);
+ requestClose();
+ };
+
+ const handleCopyLink = () => {
+ copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), linkPath));
+ requestClose();
+ };
+
+ const handleRoomSettings = () => {
+ toggleRoomSettings(room.roomId);
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleMarkAsRead}
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ disabled={!unread}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Mark as Read
+ </Text>
+ </MenuItem>
+ </Box>
+ <Line variant="Surface" size="300" />
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleInvite}
+ variant="Primary"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.UserPlus} />}
+ radii="300"
+ disabled={!canInvite}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Invite
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleCopyLink}
+ size="300"
+ after={<Icon size="100" src={Icons.Link} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Copy Link
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleRoomSettings}
+ size="300"
+ after={<Icon size="100" src={Icons.Setting} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Room Settings
+ </Text>
+ </MenuItem>
+ </Box>
+ <Line variant="Surface" size="300" />
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <UseStateProvider initial={false}>
+ {(promptLeave, setPromptLeave) => (
+ <>
+ <MenuItem
+ onClick={() => setPromptLeave(true)}
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+ radii="300"
+ aria-pressed={promptLeave}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Leave Room
+ </Text>
+ </MenuItem>
+ {promptLeave && (
+ <LeaveRoomPrompt
+ roomId={room.roomId}
+ onDone={requestClose}
+ onCancel={() => setPromptLeave(false)}
+ />
+ )}
+ </>
+ )}
+ </UseStateProvider>
+ </Box>
+ </Menu>
+ );
+ }
+);
+
+type RoomNavItemProps = {
+ room: Room;
+ selected: boolean;
+ linkPath: string;
+ muted?: boolean;
+ showAvatar?: boolean;
+ direct?: boolean;
+};
+export function RoomNavItem({
+ room,
+ selected,
+ showAvatar,
+ direct,
+ muted,
+ linkPath,
+}: RoomNavItemProps) {
+ const mx = useMatrixClient();
+ const [hover, setHover] = useState(false);
+ const { hoverProps } = useHover({ onHoverChange: setHover });
+ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+ const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+ const typingMember = useRoomTypingMember(room.roomId);
+
+ const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
+ evt.preventDefault();
+ setMenuAnchor({
+ x: evt.clientX,
+ y: evt.clientY,
+ width: 0,
+ height: 0,
+ });
+ };
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const optionsVisible = hover || !!menuAnchor;
+
+ return (
+ <NavItem
+ variant="Background"
+ radii="400"
+ highlight={unread !== undefined}
+ aria-selected={selected}
+ data-hover={!!menuAnchor}
+ onContextMenu={handleContextMenu}
+ {...hoverProps}
+ {...focusWithinProps}
+ >
+ <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) : getRoomAvatarUrl(mx, room, 96)
+ }
+ 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>
+ )}
+ {muted && !optionsVisible && <Icon size="50" src={Icons.BellMute} />}
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ {optionsVisible && (
+ <NavItemOptions>
+ <PopOut
+ anchor={menuAnchor}
+ offset={menuAnchor?.width === 0 ? 0 : undefined}
+ alignOffset={menuAnchor?.width === 0 ? 0 : -5}
+ position="Bottom"
+ align={menuAnchor?.width === 0 ? 'Start' : 'End'}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <RoomNavItemMenu
+ room={room}
+ linkPath={linkPath}
+ requestClose={() => setMenuAnchor(undefined)}
+ />
+ </FocusTrap>
+ }
+ >
+ <IconButton
+ onClick={handleOpenMenu}
+ aria-pressed={!!menuAnchor}
+ variant="Background"
+ fill="None"
+ size="300"
+ radii="300"
+ >
+ <Icon size="50" src={Icons.VerticalDots} />
+ </IconButton>
+ </PopOut>
+ </NavItemOptions>
+ )}
+ </NavItem>
+ );
+}
--- /dev/null
+export * from './RoomNavItem';
+export * from './RoomNavCategoryButton';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const CategoryButton = style({
+ flexGrow: 1,
+});
+export const CategoryButtonIcon = style({
+ opacity: config.opacity.P400,
+});
--- /dev/null
+import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
+import { Editor } from 'slate';
+import { Box, MenuItem, Text } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import { Command, useCommands } from '../../hooks/useCommands';
+import {
+ AutocompleteMenu,
+ AutocompleteQuery,
+ createCommandElement,
+ moveCursor,
+ replaceWithElement,
+} from '../../components/editor';
+import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useKeyDown } from '../../hooks/useKeyDown';
+import { onTabPress } from '../../utils/keyboard';
+
+type CommandAutoCompleteHandler = (commandName: string) => void;
+
+type CommandAutocompleteProps = {
+ room: Room;
+ editor: Editor;
+ query: AutocompleteQuery<string>;
+ requestClose: () => void;
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+ matchOptions: {
+ contain: true,
+ },
+};
+
+export function CommandAutocomplete({
+ room,
+ editor,
+ query,
+ requestClose,
+}: CommandAutocompleteProps) {
+ const mx = useMatrixClient();
+ const commands = useCommands(mx, room);
+ const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
+
+ const [result, search, resetSearch] = useAsyncSearch(
+ commandNames,
+ useCallback((commandName: string) => commandName, []),
+ SEARCH_OPTIONS
+ );
+
+ const autoCompleteNames = result ? result.items : commandNames;
+
+ useEffect(() => {
+ if (query.text) search(query.text);
+ else resetSearch();
+ }, [query.text, search, resetSearch]);
+
+ const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => {
+ const cmdEl = createCommandElement(commandName);
+ replaceWithElement(editor, query.range, cmdEl);
+ moveCursor(editor, true);
+ requestClose();
+ };
+
+ useKeyDown(window, (evt: KeyboardEvent) => {
+ onTabPress(evt, () => {
+ if (autoCompleteNames.length === 0) {
+ return;
+ }
+ const cmdName = autoCompleteNames[0];
+ handleAutocomplete(cmdName);
+ });
+ });
+
+ return autoCompleteNames.length === 0 ? null : (
+ <AutocompleteMenu
+ headerContent={
+ <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
+ <Text size="L400">Commands</Text>
+ <Text size="T200" priority="300" truncate>
+ Begin your message with command
+ </Text>
+ </Box>
+ }
+ requestClose={requestClose}
+ >
+ {autoCompleteNames.map((commandName) => (
+ <MenuItem
+ key={commandName}
+ as="button"
+ radii="300"
+ onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
+ onTabPress(evt, () => handleAutocomplete(commandName))
+ }
+ onClick={() => handleAutocomplete(commandName)}
+ >
+ <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
+ <Box shrink="No">
+ <Text style={{ flexGrow: 1 }} size="B400" truncate>
+ {`/${commandName}`}
+ </Text>
+ </Box>
+ <Text truncate priority="300" size="T200">
+ {commands[commandName].description}
+ </Text>
+ </Box>
+ </MenuItem>
+ ))}
+ </AutocompleteMenu>
+ );
+}
--- /dev/null
+import { keyframes, style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const MembersDrawer = style({
+ width: toRem(266),
+ backgroundColor: color.Background.Container,
+ color: color.Background.OnContainer,
+});
+
+export const MembersDrawerHeader = style({
+ flexShrink: 0,
+ padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
+ borderBottomWidth: config.borderWidth.B300,
+});
+
+export const MemberDrawerContentBase = style({
+ position: 'relative',
+ overflow: 'hidden',
+});
+
+export const MemberDrawerContent = style({
+ padding: `${config.space.S200} 0`,
+});
+
+const ScrollBtnAnime = keyframes({
+ '0%': {
+ transform: `translate(-50%, -100%) scale(0)`,
+ },
+ '100%': {
+ transform: `translate(-50%, 0) scale(1)`,
+ },
+});
+
+export const DrawerScrollTop = style({
+ position: 'absolute',
+ top: config.space.S200,
+ left: '50%',
+ transform: 'translateX(-50%)',
+ zIndex: 1,
+ animation: `${ScrollBtnAnime} 100ms`,
+});
+
+export const DrawerGroup = style({
+ paddingLeft: config.space.S200,
+});
+
+export const MembersGroup = style({
+ paddingLeft: config.space.S200,
+});
+export const MembersGroupLabel = style({
+ padding: config.space.S200,
+ selectors: {
+ '&:not(:first-child)': {
+ paddingTop: config.space.S500,
+ },
+ },
+});
+
+export const DrawerVirtualItem = style({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+});
--- /dev/null
+import React, {
+ ChangeEventHandler,
+ MouseEventHandler,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Avatar,
+ Badge,
+ Box,
+ Chip,
+ ContainerColor,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Scroll,
+ Spinner,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ config,
+} from 'folds';
+import { Room, RoomMember } from 'matrix-js-sdk';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import FocusTrap from 'focus-trap-react';
+import classNames from 'classnames';
+
+import { openProfileViewer } from '../../../client/action/navigation';
+import * as css from './MembersDrawer.css';
+import { useRoomMembers } from '../../hooks/useRoomMembers';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { Membership } from '../../../types/matrix/room';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import {
+ SearchItemStrGetter,
+ UseAsyncSearchOptions,
+ useAsyncSearch,
+} from '../../hooks/useAsyncSearch';
+import { useDebounce } from '../../hooks/useDebounce';
+import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
+import { TypingIndicator } from '../../components/typing-indicator';
+import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { useSetSetting, useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { millify } from '../../plugins/millify';
+import { ScrollTopContainer } from '../../components/scroll-top-container';
+import { UserAvatar } from '../../components/user-avatar';
+import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
+
+export const MembershipFilters = {
+ filterJoined: (m: RoomMember) => m.membership === Membership.Join,
+ filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
+ filterLeaved: (m: RoomMember) =>
+ m.membership === Membership.Leave &&
+ m.events.member?.getStateKey() === m.events.member?.getSender(),
+ filterKicked: (m: RoomMember) =>
+ m.membership === Membership.Leave &&
+ m.events.member?.getStateKey() !== m.events.member?.getSender(),
+ filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
+};
+
+export type MembershipFilterFn = (m: RoomMember) => boolean;
+
+export type MembershipFilter = {
+ name: string;
+ filterFn: MembershipFilterFn;
+ color: ContainerColor;
+};
+
+const useMembershipFilterMenu = (): MembershipFilter[] =>
+ useMemo(
+ () => [
+ {
+ name: 'Joined',
+ filterFn: MembershipFilters.filterJoined,
+ color: 'Background',
+ },
+ {
+ name: 'Invited',
+ filterFn: MembershipFilters.filterInvited,
+ color: 'Success',
+ },
+ {
+ name: 'Left',
+ filterFn: MembershipFilters.filterLeaved,
+ color: 'Secondary',
+ },
+ {
+ name: 'Kicked',
+ filterFn: MembershipFilters.filterKicked,
+ color: 'Warning',
+ },
+ {
+ name: 'Banned',
+ filterFn: MembershipFilters.filterBanned,
+ color: 'Critical',
+ },
+ ],
+ []
+ );
+
+export const SortFilters = {
+ filterAscending: (a: RoomMember, b: RoomMember) =>
+ a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
+ filterDescending: (a: RoomMember, b: RoomMember) =>
+ a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
+ filterNewestFirst: (a: RoomMember, b: RoomMember) =>
+ (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
+ filterOldest: (a: RoomMember, b: RoomMember) =>
+ (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
+};
+
+export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
+
+export type SortFilter = {
+ name: string;
+ filterFn: SortFilterFn;
+};
+
+const useSortFilterMenu = (): SortFilter[] =>
+ useMemo(
+ () => [
+ {
+ name: 'A to Z',
+ filterFn: SortFilters.filterAscending,
+ },
+ {
+ name: 'Z to A',
+ filterFn: SortFilters.filterDescending,
+ },
+ {
+ name: 'Newest',
+ filterFn: SortFilters.filterNewestFirst,
+ },
+ {
+ name: 'Oldest',
+ filterFn: SortFilters.filterOldest,
+ },
+ ],
+ []
+ );
+
+export type MembersFilterOptions = {
+ membershipFilter: MembershipFilter;
+ sortFilter: SortFilter;
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+ limit: 100,
+ matchOptions: {
+ contain: true,
+ },
+};
+
+const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
+const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
+ getMemberSearchStr(m, query, mxIdToName);
+
+type MembersDrawerProps = {
+ room: Room;
+};
+export function MembersDrawer({ room }: MembersDrawerProps) {
+ const mx = useMatrixClient();
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const searchInputRef = useRef<HTMLInputElement>(null);
+ const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+ const members = useRoomMembers(mx, room.roomId);
+ const getPowerLevelTag = usePowerLevelTags();
+ const fetchingMembers = members.length < room.getJoinedMemberCount();
+ const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+
+ const membershipFilterMenu = useMembershipFilterMenu();
+ const sortFilterMenu = useSortFilterMenu();
+ const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
+ const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
+
+ const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0];
+ const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0];
+
+ const typingMembers = useRoomTypingMember(room.roomId);
+
+ const filteredMembers = useMemo(
+ () =>
+ members
+ .filter(membershipFilter.filterFn)
+ .sort(sortFilter.filterFn)
+ .sort((a, b) => b.powerLevel - a.powerLevel),
+ [members, membershipFilter, sortFilter]
+ );
+
+ const [result, search, resetSearch] = useAsyncSearch(
+ filteredMembers,
+ getRoomMemberStr,
+ SEARCH_OPTIONS
+ );
+ if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
+
+ const processMembers = result ? result.items : filteredMembers;
+
+ const PLTagOrRoomMember = useMemo(() => {
+ let prevTag: PowerLevelTag | undefined;
+ const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
+ processMembers.forEach((m) => {
+ const plTag = getPowerLevelTag(m.powerLevel);
+ if (plTag !== prevTag) {
+ prevTag = plTag;
+ tagOrMember.push(plTag);
+ }
+ tagOrMember.push(m);
+ });
+ return tagOrMember;
+ }, [processMembers, getPowerLevelTag]);
+
+ const virtualizer = useVirtualizer({
+ count: PLTagOrRoomMember.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 40,
+ overscan: 10,
+ });
+
+ const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
+ useCallback(
+ (evt) => {
+ if (evt.target.value) search(evt.target.value);
+ else resetSearch();
+ },
+ [search, resetSearch]
+ ),
+ { wait: 200 }
+ );
+
+ const getName = (member: RoomMember) =>
+ getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+ const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const btn = evt.currentTarget as HTMLButtonElement;
+ const userId = btn.getAttribute('data-user-id');
+ openProfileViewer(userId, room.roomId);
+ };
+
+ return (
+ <Box className={css.MembersDrawer} shrink="No" direction="Column">
+ <Header className={css.MembersDrawerHeader} variant="Background" size="600">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Text title={`${room.getJoinedMemberCount()} Members`} size="H5" truncate>
+ {`${millify(room.getJoinedMemberCount())} Members`}
+ </Text>
+ </Box>
+ <Box shrink="No" alignItems="Center">
+ <TooltipProvider
+ position="Bottom"
+ align="End"
+ offset={4}
+ tooltip={
+ <Tooltip>
+ <Text>Close</Text>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <IconButton
+ ref={triggerRef}
+ variant="Background"
+ onClick={() => setPeopleDrawer(false)}
+ >
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ )}
+ </TooltipProvider>
+ </Box>
+ </Box>
+ </Header>
+ <Box className={css.MemberDrawerContentBase} grow="Yes">
+ <Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
+ <Box className={css.MemberDrawerContent} direction="Column" gap="200">
+ <Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="200">
+ <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
+ <UseStateProvider initial={undefined}>
+ {(anchor: RectCords | undefined, setAnchor) => (
+ <PopOut
+ anchor={anchor}
+ position="Bottom"
+ align="Start"
+ offset={4}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <Menu style={{ padding: config.space.S100 }}>
+ {membershipFilterMenu.map((menuItem, index) => (
+ <MenuItem
+ key={menuItem.name}
+ variant={
+ menuItem.name === membershipFilter.name
+ ? menuItem.color
+ : 'Surface'
+ }
+ aria-pressed={menuItem.name === membershipFilter.name}
+ size="300"
+ radii="300"
+ onClick={() => {
+ setMembershipFilterIndex(index);
+ setAnchor(undefined);
+ }}
+ >
+ <Text size="T300">{menuItem.name}</Text>
+ </MenuItem>
+ ))}
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ onClick={
+ ((evt) =>
+ setAnchor(
+ evt.currentTarget.getBoundingClientRect()
+ )) as MouseEventHandler<HTMLButtonElement>
+ }
+ variant={membershipFilter.color}
+ size="400"
+ radii="300"
+ before={<Icon src={Icons.Filter} size="50" />}
+ >
+ <Text size="T200">{membershipFilter.name}</Text>
+ </Chip>
+ </PopOut>
+ )}
+ </UseStateProvider>
+ <UseStateProvider initial={undefined}>
+ {(anchor: RectCords | undefined, setAnchor) => (
+ <PopOut
+ anchor={anchor}
+ position="Bottom"
+ align="End"
+ offset={4}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <Menu style={{ padding: config.space.S100 }}>
+ {sortFilterMenu.map((menuItem, index) => (
+ <MenuItem
+ key={menuItem.name}
+ variant="Surface"
+ aria-pressed={menuItem.name === sortFilter.name}
+ size="300"
+ radii="300"
+ onClick={() => {
+ setSortFilterIndex(index);
+ setAnchor(undefined);
+ }}
+ >
+ <Text size="T300">{menuItem.name}</Text>
+ </MenuItem>
+ ))}
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ onClick={
+ ((evt) =>
+ setAnchor(
+ evt.currentTarget.getBoundingClientRect()
+ )) as MouseEventHandler<HTMLButtonElement>
+ }
+ variant="Background"
+ size="400"
+ radii="300"
+ after={<Icon src={Icons.Sort} size="50" />}
+ >
+ <Text size="T200">{sortFilter.name}</Text>
+ </Chip>
+ </PopOut>
+ )}
+ </UseStateProvider>
+ </Box>
+ <Box direction="Column" gap="100">
+ <Input
+ ref={searchInputRef}
+ onChange={handleSearchChange}
+ style={{ paddingRight: config.space.S200 }}
+ placeholder="Type name..."
+ variant="Surface"
+ size="400"
+ radii="400"
+ before={<Icon size="50" src={Icons.Search} />}
+ after={
+ result && (
+ <Chip
+ variant={result.items.length > 0 ? 'Success' : 'Critical'}
+ size="400"
+ radii="Pill"
+ aria-pressed
+ onClick={() => {
+ if (searchInputRef.current) {
+ searchInputRef.current.value = '';
+ searchInputRef.current.focus();
+ }
+ resetSearch();
+ }}
+ after={<Icon size="50" src={Icons.Cross} />}
+ >
+ <Text size="B300">{`${result.items.length || 'No'} ${
+ result.items.length === 1 ? 'Result' : 'Results'
+ }`}</Text>
+ </Chip>
+ )
+ }
+ />
+ </Box>
+ </Box>
+
+ <ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
+ <IconButton
+ onClick={() => virtualizer.scrollToOffset(0)}
+ variant="Surface"
+ radii="Pill"
+ outlined
+ size="300"
+ aria-label="Scroll to Top"
+ >
+ <Icon src={Icons.ChevronTop} size="300" />
+ </IconButton>
+ </ScrollTopContainer>
+
+ {!fetchingMembers && !result && processMembers.length === 0 && (
+ <Text style={{ padding: config.space.S300 }} align="Center">
+ {`No "${membershipFilter.name}" Members`}
+ </Text>
+ )}
+
+ <Box className={css.MembersGroup} direction="Column" gap="100">
+ <div
+ style={{
+ position: 'relative',
+ height: virtualizer.getTotalSize(),
+ }}
+ >
+ {virtualizer.getVirtualItems().map((vItem) => {
+ const tagOrMember = PLTagOrRoomMember[vItem.index];
+ if (!('userId' in tagOrMember)) {
+ return (
+ <Text
+ style={{
+ transform: `translateY(${vItem.start}px)`,
+ }}
+ data-index={vItem.index}
+ ref={virtualizer.measureElement}
+ key={`${room.roomId}-${vItem.index}`}
+ className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
+ size="L400"
+ >
+ {tagOrMember.name}
+ </Text>
+ );
+ }
+
+ const member = tagOrMember;
+ const name = getName(member);
+ const avatarUrl = member.getAvatarUrl(
+ mx.baseUrl,
+ 100,
+ 100,
+ 'crop',
+ undefined,
+ false
+ );
+
+ return (
+ <MenuItem
+ style={{
+ padding: `0 ${config.space.S200}`,
+ transform: `translateY(${vItem.start}px)`,
+ }}
+ data-index={vItem.index}
+ data-user-id={member.userId}
+ ref={virtualizer.measureElement}
+ key={`${room.roomId}-${member.userId}`}
+ className={css.DrawerVirtualItem}
+ variant="Background"
+ radii="400"
+ onClick={handleMemberClick}
+ before={
+ <Avatar size="200">
+ <UserAvatar
+ userId={member.userId}
+ src={avatarUrl ?? undefined}
+ alt={name}
+ renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+ />
+ </Avatar>
+ }
+ after={
+ typingMembers.find((receipt) => receipt.userId === member.userId) && (
+ <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+ <TypingIndicator size="300" />
+ </Badge>
+ )
+ }
+ >
+ <Box grow="Yes">
+ <Text size="T400" truncate>
+ {name}
+ </Text>
+ </Box>
+ </MenuItem>
+ );
+ })}
+ </div>
+ </Box>
+
+ {fetchingMembers && (
+ <Box justifyContent="Center">
+ <Spinner />
+ </Box>
+ )}
+ </Box>
+ </Scroll>
+ </Box>
+ </Box>
+ );
+}
--- /dev/null
+import React from 'react';
+import { Box, Line } from 'folds';
+import { useParams } from 'react-router-dom';
+import { RoomView } from './RoomView';
+import { MembersDrawer } from './MembersDrawer';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
+import { useRoom } from '../../hooks/useRoom';
+
+export function Room() {
+ const { eventId } = useParams();
+ const room = useRoom();
+
+ const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+ const screenSize = useScreenSizeContext();
+ const powerLevels = usePowerLevels(room);
+
+ return (
+ <PowerLevelsContextProvider value={powerLevels}>
+ <Box grow="Yes">
+ <RoomView room={room} eventId={eventId} />
+ {screenSize === ScreenSize.Desktop && isDrawer && (
+ <>
+ <Line variant="Background" direction="Vertical" size="300" />
+ <MembersDrawer key={room.roomId} room={room} />
+ </>
+ )}
+ </Box>
+ </PowerLevelsContextProvider>
+ );
+}
--- /dev/null
+import React, {
+ KeyboardEventHandler,
+ RefObject,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useAtom } from 'jotai';
+import { isKeyHotkey } from 'is-hotkey';
+import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
+import { ReactEditor } from 'slate-react';
+import { Transforms, Editor } from 'slate';
+import {
+ Box,
+ Dialog,
+ Icon,
+ IconButton,
+ Icons,
+ Line,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ PopOut,
+ Scroll,
+ Text,
+ config,
+ toRem,
+} from 'folds';
+
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import {
+ CustomEditor,
+ Toolbar,
+ toMatrixCustomHTML,
+ toPlainText,
+ AUTOCOMPLETE_PREFIXES,
+ AutocompletePrefix,
+ AutocompleteQuery,
+ getAutocompleteQuery,
+ getPrevWorldRange,
+ resetEditor,
+ RoomMentionAutocomplete,
+ UserMentionAutocomplete,
+ EmoticonAutocomplete,
+ createEmoticonElement,
+ moveCursor,
+ resetEditorHistory,
+ customHtmlEqualsPlainText,
+ trimCustomHtml,
+ isEmptyEditor,
+ getBeginCommand,
+ trimCommand,
+} from '../../components/editor';
+import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import initMatrix from '../../../client/initMatrix';
+import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart } from '../../utils/matrix';
+import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
+import { useFilePicker } from '../../hooks/useFilePicker';
+import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
+import { useFileDropZone } from '../../hooks/useFileDrop';
+import {
+ TUploadItem,
+ roomIdToMsgDraftAtomFamily,
+ roomIdToReplyDraftAtomFamily,
+ roomIdToUploadItemsAtomFamily,
+ roomUploadAtomFamily,
+} from '../../state/room/roomInputDrafts';
+import { UploadCardRenderer } from '../../components/upload-card';
+import {
+ UploadBoard,
+ UploadBoardContent,
+ UploadBoardHeader,
+ UploadBoardImperativeHandlers,
+} from '../../components/upload-board';
+import {
+ Upload,
+ UploadStatus,
+ UploadSuccess,
+ createUploadFamilyObserverAtom,
+} from '../../state/upload';
+import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
+import { safeFile } from '../../utils/mimeTypes';
+import { fulfilledPromiseSettledResult } from '../../utils/common';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import {
+ getAudioMsgContent,
+ getFileMsgContent,
+ getImageMsgContent,
+ getVideoMsgContent,
+} from './msgContent';
+import colorMXID from '../../../util/colorMXID';
+import {
+ getMemberDisplayName,
+ parseReplyBody,
+ parseReplyFormattedBody,
+ trimReplyFromBody,
+ trimReplyFromFormattedBody,
+} from '../../utils/room';
+import { sanitizeText } from '../../utils/sanitize';
+import { CommandAutocomplete } from './CommandAutocomplete';
+import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
+import { mobileOrTablet } from '../../utils/user-agent';
+import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
+import { ReplyLayout } from '../../components/message';
+
+interface RoomInputProps {
+ editor: Editor;
+ fileDropContainerRef: RefObject<HTMLElement>;
+ roomId: string;
+ room: Room;
+}
+export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
+ ({ editor, fileDropContainerRef, roomId, room }, ref) => {
+ const mx = useMatrixClient();
+ const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
+ const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+ const commands = useCommands(mx, room);
+ const emojiBtnRef = useRef<HTMLButtonElement>(null);
+
+ const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
+ const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
+ const [uploadBoard, setUploadBoard] = useState(true);
+ const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
+ const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
+ roomUploadAtomFamily,
+ selectedFiles.map((f) => f.file)
+ );
+ const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
+
+ const imagePackRooms: Room[] = useMemo(() => {
+ const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
+ return allParentSpaces.reduce<Room[]>((list, rId) => {
+ const r = mx.getRoom(rId);
+ if (r) list.push(r);
+ return list;
+ }, []);
+ }, [mx, roomId]);
+
+ const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
+ const [autocompleteQuery, setAutocompleteQuery] =
+ useState<AutocompleteQuery<AutocompletePrefix>>();
+
+ const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
+
+ const handleFiles = useCallback(
+ async (files: File[]) => {
+ setUploadBoard(true);
+ const safeFiles = files.map(safeFile);
+ const fileItems: TUploadItem[] = [];
+
+ if (mx.isRoomEncrypted(roomId)) {
+ const encryptFiles = fulfilledPromiseSettledResult(
+ await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
+ );
+ encryptFiles.forEach((ef) => fileItems.push(ef));
+ } else {
+ safeFiles.forEach((f) =>
+ fileItems.push({ file: f, originalFile: f, encInfo: undefined })
+ );
+ }
+ setSelectedFiles({
+ type: 'PUT',
+ item: fileItems,
+ });
+ },
+ [setSelectedFiles, roomId, mx]
+ );
+ const pickFile = useFilePicker(handleFiles, true);
+ const handlePaste = useFilePasteHandler(handleFiles);
+ const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
+ const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
+
+ useElementSizeObserver(
+ useCallback(() => document.body, []),
+ useCallback((width) => setHideStickerBtn(width < 500), [])
+ );
+
+ useEffect(() => {
+ Transforms.insertFragment(editor, msgDraft);
+ }, [editor, msgDraft]);
+
+ useEffect(() => {
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
+ return () => {
+ if (!isEmptyEditor(editor)) {
+ const parsedDraft = JSON.parse(JSON.stringify(editor.children));
+ setMsgDraft(parsedDraft);
+ } else {
+ setMsgDraft([]);
+ }
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ };
+ }, [roomId, editor, setMsgDraft]);
+
+ const handleRemoveUpload = useCallback(
+ (upload: TUploadContent | TUploadContent[]) => {
+ const uploads = Array.isArray(upload) ? upload : [upload];
+ setSelectedFiles({
+ type: 'DELETE',
+ item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
+ });
+ uploads.forEach((u) => roomUploadAtomFamily.remove(u));
+ },
+ [setSelectedFiles, selectedFiles]
+ );
+
+ const handleCancelUpload = (uploads: Upload[]) => {
+ uploads.forEach((upload) => {
+ if (upload.status === UploadStatus.Loading) {
+ mx.cancelUpload(upload.promise);
+ }
+ });
+ handleRemoveUpload(uploads.map((upload) => upload.file));
+ };
+
+ const handleSendUpload = async (uploads: UploadSuccess[]) => {
+ const contentsPromises = uploads.map(async (upload) => {
+ const fileItem = selectedFiles.find((f) => f.file === upload.file);
+ if (!fileItem) throw new Error('Broken upload');
+
+ if (fileItem.file.type.startsWith('image')) {
+ return getImageMsgContent(mx, fileItem, upload.mxc);
+ }
+ if (fileItem.file.type.startsWith('video')) {
+ return getVideoMsgContent(mx, fileItem, upload.mxc);
+ }
+ if (fileItem.file.type.startsWith('audio')) {
+ return getAudioMsgContent(fileItem, upload.mxc);
+ }
+ return getFileMsgContent(fileItem, upload.mxc);
+ });
+ handleCancelUpload(uploads);
+ const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
+ contents.forEach((content) => mx.sendMessage(roomId, content));
+ };
+
+ const submit = useCallback(() => {
+ uploadBoardHandlers.current?.handleSend();
+
+ const commandName = getBeginCommand(editor);
+
+ let plainText = toPlainText(editor.children).trim();
+ let customHtml = trimCustomHtml(
+ toMatrixCustomHTML(editor.children, {
+ allowTextFormatting: true,
+ allowBlockMarkdown: isMarkdown,
+ allowInlineMarkdown: isMarkdown,
+ })
+ );
+ let msgType = MsgType.Text;
+
+ if (commandName) {
+ plainText = trimCommand(commandName, plainText);
+ customHtml = trimCommand(commandName, customHtml);
+ }
+ if (commandName === Command.Me) {
+ msgType = MsgType.Emote;
+ } else if (commandName === Command.Notice) {
+ msgType = MsgType.Notice;
+ } else if (commandName === Command.Shrug) {
+ plainText = `${SHRUG} ${plainText}`;
+ customHtml = `${SHRUG} ${customHtml}`;
+ } else if (commandName) {
+ const commandContent = commands[commandName as Command];
+ if (commandContent) {
+ commandContent.exe(plainText);
+ }
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ sendTypingStatus(false);
+ return;
+ }
+
+ if (plainText === '') return;
+
+ let body = plainText;
+ let formattedBody = customHtml;
+ if (replyDraft) {
+ body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
+ formattedBody =
+ parseReplyFormattedBody(
+ roomId,
+ replyDraft.userId,
+ replyDraft.eventId,
+ replyDraft.formattedBody
+ ? trimReplyFromFormattedBody(replyDraft.formattedBody)
+ : sanitizeText(replyDraft.body)
+ ) + formattedBody;
+ }
+
+ const content: IContent = {
+ msgtype: msgType,
+ body,
+ };
+ if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
+ content.format = 'org.matrix.custom.html';
+ content.formatted_body = formattedBody;
+ }
+ if (replyDraft) {
+ content['m.relates_to'] = {
+ 'm.in_reply_to': {
+ event_id: replyDraft.eventId,
+ },
+ };
+ }
+ mx.sendMessage(roomId, content);
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ setReplyDraft(undefined);
+ sendTypingStatus(false);
+ }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
+
+ const handleKeyDown: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
+ evt.preventDefault();
+ submit();
+ }
+ if (isKeyHotkey('escape', evt)) {
+ evt.preventDefault();
+ setReplyDraft(undefined);
+ }
+ },
+ [submit, setReplyDraft, enterForNewline]
+ );
+
+ const handleKeyUp: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isKeyHotkey('escape', evt)) {
+ evt.preventDefault();
+ return;
+ }
+
+ sendTypingStatus(!isEmptyEditor(editor));
+
+ const prevWordRange = getPrevWorldRange(editor);
+ const query = prevWordRange
+ ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
+ : undefined;
+ setAutocompleteQuery(query);
+ },
+ [editor, sendTypingStatus]
+ );
+
+ const handleCloseAutocomplete = useCallback(() => {
+ setAutocompleteQuery(undefined);
+ ReactEditor.focus(editor);
+ }, [editor]);
+
+ const handleEmoticonSelect = (key: string, shortcode: string) => {
+ editor.insertNode(createEmoticonElement(key, shortcode));
+ moveCursor(editor);
+ };
+
+ const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
+ const stickerUrl = mx.mxcUrlToHttp(mxc);
+ if (!stickerUrl) return;
+
+ const info = await getImageInfo(
+ await loadImageElement(stickerUrl),
+ await getImageUrlBlob(stickerUrl)
+ );
+
+ mx.sendEvent(roomId, EventType.Sticker, {
+ body: label,
+ url: mxc,
+ info,
+ });
+ };
+
+ return (
+ <div ref={ref}>
+ {selectedFiles.length > 0 && (
+ <UploadBoard
+ header={
+ <UploadBoardHeader
+ open={uploadBoard}
+ onToggle={() => setUploadBoard(!uploadBoard)}
+ uploadFamilyObserverAtom={uploadFamilyObserverAtom}
+ onSend={handleSendUpload}
+ imperativeHandlerRef={uploadBoardHandlers}
+ onCancel={handleCancelUpload}
+ />
+ }
+ >
+ {uploadBoard && (
+ <Scroll size="300" hideTrack visibility="Hover">
+ <UploadBoardContent>
+ {Array.from(selectedFiles)
+ .reverse()
+ .map((fileItem, index) => (
+ <UploadCardRenderer
+ // eslint-disable-next-line react/no-array-index-key
+ key={index}
+ file={fileItem.file}
+ isEncrypted={!!fileItem.encInfo}
+ uploadAtom={roomUploadAtomFamily(fileItem.file)}
+ onRemove={handleRemoveUpload}
+ />
+ ))}
+ </UploadBoardContent>
+ </Scroll>
+ )}
+ </UploadBoard>
+ )}
+ <Overlay
+ open={dropZoneVisible}
+ backdrop={<OverlayBackdrop />}
+ style={{ pointerEvents: 'none' }}
+ >
+ <OverlayCenter>
+ <Dialog variant="Primary">
+ <Box
+ direction="Column"
+ justifyContent="Center"
+ alignItems="Center"
+ gap="500"
+ style={{ padding: toRem(60) }}
+ >
+ <Icon size="600" src={Icons.File} />
+ <Text size="H4" align="Center">
+ {`Drop Files in "${room?.name || 'Room'}"`}
+ </Text>
+ <Text align="Center">Drag and drop files here or click for selection dialog</Text>
+ </Box>
+ </Dialog>
+ </OverlayCenter>
+ </Overlay>
+ {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
+ <RoomMentionAutocomplete
+ roomId={roomId}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
+ <UserMentionAutocomplete
+ room={room}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
+ <EmoticonAutocomplete
+ imagePackRooms={imagePackRooms}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.Command && (
+ <CommandAutocomplete
+ room={room}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ <CustomEditor
+ editableName="RoomInput"
+ editor={editor}
+ placeholder="Send a message..."
+ onKeyDown={handleKeyDown}
+ onKeyUp={handleKeyUp}
+ onPaste={handlePaste}
+ top={
+ replyDraft && (
+ <div>
+ <Box
+ alignItems="Center"
+ gap="300"
+ style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
+ >
+ <IconButton
+ onClick={() => setReplyDraft(undefined)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon src={Icons.Cross} size="50" />
+ </IconButton>
+ <ReplyLayout
+ userColor={colorMXID(replyDraft.userId)}
+ username={
+ <Text size="T300" truncate>
+ <b>
+ {getMemberDisplayName(room, replyDraft.userId) ??
+ getMxIdLocalPart(replyDraft.userId) ??
+ replyDraft.userId}
+ </b>
+ </Text>
+ }
+ >
+ <Text size="T300" truncate>
+ {trimReplyFromBody(replyDraft.body)}
+ </Text>
+ </ReplyLayout>
+ </Box>
+ </div>
+ )
+ }
+ before={
+ <IconButton
+ onClick={() => pickFile('*')}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon src={Icons.PlusCircle} />
+ </IconButton>
+ }
+ after={
+ <>
+ <IconButton
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ onClick={() => setToolbar(!toolbar)}
+ >
+ <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
+ </IconButton>
+ <UseStateProvider initial={undefined}>
+ {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
+ <PopOut
+ offset={16}
+ alignOffset={-44}
+ position="Top"
+ align="End"
+ anchor={
+ emojiBoardTab === undefined
+ ? undefined
+ : emojiBtnRef.current?.getBoundingClientRect() ?? undefined
+ }
+ content={
+ <EmojiBoard
+ tab={emojiBoardTab}
+ onTabChange={setEmojiBoardTab}
+ imagePackRooms={imagePackRooms}
+ returnFocusOnDeactivate={false}
+ onEmojiSelect={handleEmoticonSelect}
+ onCustomEmojiSelect={handleEmoticonSelect}
+ onStickerSelect={handleStickerSelect}
+ requestClose={() => {
+ setEmojiBoardTab(undefined);
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
+ }}
+ />
+ }
+ >
+ {!hideStickerBtn && (
+ <IconButton
+ aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
+ onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon
+ src={Icons.Sticker}
+ filled={emojiBoardTab === EmojiBoardTab.Sticker}
+ />
+ </IconButton>
+ )}
+ <IconButton
+ ref={emojiBtnRef}
+ aria-pressed={
+ hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
+ }
+ onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon
+ src={Icons.Smile}
+ filled={
+ hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
+ }
+ />
+ </IconButton>
+ </PopOut>
+ )}
+ </UseStateProvider>
+ <IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
+ <Icon src={Icons.Send} />
+ </IconButton>
+ </>
+ }
+ bottom={
+ toolbar && (
+ <div>
+ <Line variant="SurfaceVariant" size="300" />
+ <Toolbar />
+ </div>
+ )
+ }
+ />
+ </div>
+ );
+ }
+);
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const RoomInputPlaceholder = style({
+ minHeight: toRem(48),
+ backgroundColor: color.SurfaceVariant.Container,
+ color: color.SurfaceVariant.OnContainer,
+ boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
+ borderRadius: config.radii.R400,
+});
--- /dev/null
+import React, { ComponentProps } from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+
+import * as css from './RoomInputPlaceholder.css';
+
+export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box>>(
+ ({ className, ...props }, ref) => (
+ <Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
+ )
+);
--- /dev/null
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, config } from 'folds';
+
+export const TimelineFloat = recipe({
+ base: [
+ DefaultReset,
+ {
+ position: 'absolute',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ zIndex: 1,
+ minWidth: 'max-content',
+ },
+ ],
+ variants: {
+ position: {
+ Top: {
+ top: config.space.S400,
+ },
+ Bottom: {
+ bottom: config.space.S400,
+ },
+ },
+ },
+ defaultVariants: {
+ position: 'Top',
+ },
+});
+
+export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
--- /dev/null
+/* eslint-disable react/destructuring-assignment */
+import React, {
+ Dispatch,
+ MouseEventHandler,
+ RefObject,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Direction,
+ EventTimeline,
+ EventTimelineSet,
+ EventTimelineSetHandlerMap,
+ IEncryptedFile,
+ MatrixClient,
+ MatrixEvent,
+ Room,
+ RoomEvent,
+ RoomEventHandlerMap,
+} from 'matrix-js-sdk';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import classNames from 'classnames';
+import { ReactEditor } from 'slate-react';
+import { Editor } from 'slate';
+import to from 'await-to-js';
+import { useSetAtom } from 'jotai';
+import {
+ Badge,
+ Box,
+ Chip,
+ ContainerColor,
+ Icon,
+ Icons,
+ Line,
+ Scroll,
+ Text,
+ as,
+ color,
+ config,
+ toRem,
+} from 'folds';
+import { isKeyHotkey } from 'is-hotkey';
+import {
+ decryptFile,
+ eventWithShortcode,
+ factoryEventSentBy,
+ getMxIdLocalPart,
+ isRoomId,
+ isUserId,
+} from '../../utils/matrix';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
+import { useAlive } from '../../hooks/useAlive';
+import { editableActiveElement, scrollToBottom } from '../../utils/dom';
+import {
+ DefaultPlaceholder,
+ CompactPlaceholder,
+ Reply,
+ MessageBase,
+ MessageUnsupportedContent,
+ Time,
+ MessageNotDecryptedContent,
+ RedactedContent,
+ MSticker,
+ ImageContent,
+ EventContent,
+} from '../../components/message';
+import { getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
+import {
+ canEditEvent,
+ decryptAllTimelineEvent,
+ getEditedEvent,
+ getEventReactions,
+ getLatestEditableEvt,
+ getMemberDisplayName,
+ getReactionContent,
+ isMembershipChanged,
+ reactionOrEditEvent,
+} from '../../utils/room';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { openJoinAlias, openProfileViewer } from '../../../client/action/navigation';
+import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
+import { Reactions, Message, Event, EncryptedContent } from './message';
+import { useMemberEventParser } from '../../hooks/useMemberEventParser';
+import * as customHtmlCss from '../../styles/CustomHtml.css';
+import { RoomIntro } from '../../components/room-intro';
+import {
+ getIntersectionObserverEntry,
+ useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+import { markAsRead } from '../../../client/action/notifications';
+import { useDebounce } from '../../hooks/useDebounce';
+import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
+import * as css from './RoomTimeline.css';
+import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
+import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
+import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
+import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
+import initMatrix from '../../../client/initMatrix';
+import { useKeyDown } from '../../hooks/useKeyDown';
+import cons from '../../../client/state/cons';
+import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
+import { RenderMessageContent } from '../../components/RenderMessageContent';
+import { Image } from '../../components/media';
+import { ImageViewer } from '../../components/image-viewer';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+
+const TimelineFloat = as<'div', css.TimelineFloatVariants>(
+ ({ position, className, ...props }, ref) => (
+ <Box
+ className={classNames(css.TimelineFloat({ position }), className)}
+ justifyContent="Center"
+ alignItems="Center"
+ gap="200"
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
+ ({ variant, children, ...props }, ref) => (
+ <Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
+ <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
+ {children}
+ <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
+ </Box>
+ )
+);
+
+export const getLiveTimeline = (room: Room): EventTimeline =>
+ room.getUnfilteredTimelineSet().getLiveTimeline();
+
+export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
+ const timelineSet = room.getUnfilteredTimelineSet();
+ return timelineSet.getTimelineForEvent(eventId) ?? undefined;
+};
+
+export const getFirstLinkedTimeline = (
+ timeline: EventTimeline,
+ direction: Direction
+): EventTimeline => {
+ const linkedTm = timeline.getNeighbouringTimeline(direction);
+ if (!linkedTm) return timeline;
+ return getFirstLinkedTimeline(linkedTm, direction);
+};
+
+export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
+ const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
+ const timelines: EventTimeline[] = [];
+
+ for (
+ let nextTimeline: EventTimeline | null = firstTimeline;
+ nextTimeline;
+ nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
+ ) {
+ timelines.push(nextTimeline);
+ }
+ return timelines;
+};
+
+export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
+export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
+ const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
+ count + timelineToEventsCount(tm);
+ return timelines.reduce(timelineEventCountReducer, 0);
+};
+
+export const getTimelineAndBaseIndex = (
+ timelines: EventTimeline[],
+ index: number
+): [EventTimeline | undefined, number] => {
+ let uptoTimelineLen = 0;
+ const timeline = timelines.find((t) => {
+ uptoTimelineLen += t.getEvents().length;
+ if (index < uptoTimelineLen) return true;
+ return false;
+ });
+ if (!timeline) return [undefined, 0];
+ return [timeline, uptoTimelineLen - timeline.getEvents().length];
+};
+
+export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
+ absoluteIndex - timelineBaseIndex;
+
+export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
+ timeline.getEvents()[index];
+
+export const getEventIdAbsoluteIndex = (
+ timelines: EventTimeline[],
+ eventTimeline: EventTimeline,
+ eventId: string
+): number | undefined => {
+ const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
+ if (timelineIndex === -1) return undefined;
+ const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
+ if (eventIndex === -1) return undefined;
+ const baseIndex = timelines
+ .slice(0, timelineIndex)
+ .reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
+ return baseIndex + eventIndex;
+};
+
+export const factoryGetFileSrcUrl =
+ (httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
+ if (encFile) {
+ if (typeof httpUrl !== 'string') throw new Error('Malformed event');
+ const encRes = await fetch(httpUrl, { method: 'GET' });
+ const encData = await encRes.arrayBuffer();
+ const decryptedBlob = await decryptFile(encData, mimeType, encFile);
+ return URL.createObjectURL(decryptedBlob);
+ }
+ return httpUrl;
+ };
+
+type RoomTimelineProps = {
+ room: Room;
+ eventId?: string;
+ roomInputRef: RefObject<HTMLElement>;
+ editor: Editor;
+};
+
+const PAGINATION_LIMIT = 80;
+
+type Timeline = {
+ linkedTimelines: EventTimeline[];
+ range: ItemRange;
+};
+
+const useEventTimelineLoader = (
+ mx: MatrixClient,
+ room: Room,
+ onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
+ onError: (err: Error | null) => void
+) => {
+ const loadEventTimeline = useCallback(
+ async (eventId: string) => {
+ const [err, replyEvtTimeline] = await to(
+ mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
+ );
+ if (!replyEvtTimeline) {
+ onError(err ?? null);
+ return;
+ }
+ const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
+ const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
+
+ if (absIndex === undefined) {
+ onError(err ?? null);
+ return;
+ }
+
+ onLoad(eventId, linkedTimelines, absIndex);
+ },
+ [mx, room, onLoad, onError]
+ );
+
+ return loadEventTimeline;
+};
+
+const useTimelinePagination = (
+ mx: MatrixClient,
+ timeline: Timeline,
+ setTimeline: Dispatch<SetStateAction<Timeline>>,
+ limit: number
+) => {
+ const timelineRef = useRef(timeline);
+ timelineRef.current = timeline;
+ const alive = useAlive();
+
+ const handleTimelinePagination = useMemo(() => {
+ let fetching = false;
+
+ const recalibratePagination = (
+ linkedTimelines: EventTimeline[],
+ timelinesEventsCount: number[],
+ backwards: boolean
+ ) => {
+ const topTimeline = linkedTimelines[0];
+ const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
+
+ const newLTimelines = getLinkedTimelines(topTimeline);
+ const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
+ const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
+
+ const topTmAddedEvt =
+ timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
+ const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
+
+ setTimeline((currentTimeline) => ({
+ linkedTimelines: newLTimelines,
+ range:
+ offsetRange > 0
+ ? {
+ start: currentTimeline.range.start + offsetRange,
+ end: currentTimeline.range.end + offsetRange,
+ }
+ : { ...currentTimeline.range },
+ }));
+ };
+
+ return async (backwards: boolean) => {
+ if (fetching) return;
+ const { linkedTimelines: lTimelines } = timelineRef.current;
+ const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
+
+ const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
+ if (!timelineToPaginate) return;
+
+ const paginationToken = timelineToPaginate.getPaginationToken(
+ backwards ? Direction.Backward : Direction.Forward
+ );
+ if (
+ !paginationToken &&
+ getTimelinesEventsCount(lTimelines) !==
+ getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
+ ) {
+ recalibratePagination(lTimelines, timelinesEventsCount, backwards);
+ return;
+ }
+
+ fetching = true;
+ const [err] = await to(
+ mx.paginateEventTimeline(timelineToPaginate, {
+ backwards,
+ limit,
+ })
+ );
+ if (err) {
+ // TODO: handle pagination error.
+ return;
+ }
+ const fetchedTimeline =
+ timelineToPaginate.getNeighbouringTimeline(
+ backwards ? Direction.Backward : Direction.Forward
+ ) ?? timelineToPaginate;
+ // Decrypt all event ahead of render cycle
+ if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
+ await to(decryptAllTimelineEvent(mx, fetchedTimeline));
+ }
+
+ fetching = false;
+ if (alive()) {
+ recalibratePagination(lTimelines, timelinesEventsCount, backwards);
+ }
+ };
+ }, [mx, alive, setTimeline, limit]);
+ return handleTimelinePagination;
+};
+
+const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
+ useEffect(() => {
+ const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
+ mEvent,
+ eventRoom,
+ toStartOfTimeline,
+ removed,
+ data
+ ) => {
+ if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
+ onArrive(mEvent);
+ };
+ const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
+ if (eventRoom?.roomId !== room.roomId) return;
+ onArrive(mEvent);
+ };
+
+ room.on(RoomEvent.Timeline, handleTimelineEvent);
+ room.on(RoomEvent.Redaction, handleRedaction);
+ return () => {
+ room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+ room.removeListener(RoomEvent.Redaction, handleRedaction);
+ };
+ }, [room, onArrive]);
+};
+
+const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
+ useEffect(() => {
+ const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
+ if (r.roomId !== room.roomId) return;
+ onRefresh();
+ };
+
+ room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
+ return () => {
+ room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
+ };
+ }, [room, onRefresh]);
+};
+
+const getInitialTimeline = (room: Room) => {
+ const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
+ const evLength = getTimelinesEventsCount(linkedTimelines);
+ return {
+ linkedTimelines,
+ range: {
+ start: Math.max(evLength - PAGINATION_LIMIT, 0),
+ end: evLength,
+ },
+ };
+};
+
+const getEmptyTimeline = () => ({
+ range: { start: 0, end: 0 },
+ linkedTimelines: [],
+});
+
+const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
+ const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? '');
+ if (!readUptoEventId) return undefined;
+ const evtTimeline = getEventTimeline(room, readUptoEventId);
+ const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
+ return {
+ readUptoEventId,
+ inLiveTimeline: latestTimeline === room.getLiveTimeline(),
+ scrollTo,
+ };
+};
+
+export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
+ const mx = useMatrixClient();
+ const encryptedRoom = mx.isRoomEncrypted(room.roomId);
+ const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
+ const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
+ const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
+ const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+ const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
+ const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
+ const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
+ const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
+ const powerLevels = usePowerLevelsContext();
+ const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
+ const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
+ const canRedact = canDoAction('redact', myPowerLevel);
+ const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
+ const [editId, setEditId] = useState<string>();
+ const { navigateRoom, navigateSpace } = useRoomNavigate();
+
+ const imagePackRooms: Room[] = useMemo(() => {
+ const allParentSpaces = [
+ room.roomId,
+ ...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
+ ];
+ return allParentSpaces.reduce<Room[]>((list, rId) => {
+ const r = mx.getRoom(rId);
+ if (r) list.push(r);
+ return list;
+ }, []);
+ }, [mx, room]);
+
+ const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
+ const readUptoEventIdRef = useRef<string>();
+ if (unreadInfo) {
+ readUptoEventIdRef.current = unreadInfo.readUptoEventId;
+ }
+
+ const atBottomAnchorRef = useRef<HTMLElement>(null);
+ const [atBottom, setAtBottom] = useState<boolean>(true);
+ const atBottomRef = useRef(atBottom);
+ atBottomRef.current = atBottom;
+
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const scrollToBottomRef = useRef({
+ count: 0,
+ smooth: true,
+ });
+
+ const [focusItem, setFocusItem] = useState<
+ | {
+ index: number;
+ scrollTo: boolean;
+ highlight: boolean;
+ }
+ | undefined
+ >();
+ const alive = useAlive();
+
+ const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+ () =>
+ getReactCustomHtmlParser(mx, room, {
+ handleSpoilerClick: (evt) => {
+ const target = evt.currentTarget;
+ if (target.getAttribute('aria-pressed') === 'true') {
+ evt.stopPropagation();
+ target.setAttribute('aria-pressed', 'false');
+ target.style.cursor = 'initial';
+ }
+ },
+ handleMentionClick: (evt) => {
+ const target = evt.currentTarget;
+ const mentionId = target.getAttribute('data-mention-id');
+ if (typeof mentionId !== 'string') return;
+ if (isUserId(mentionId)) {
+ openProfileViewer(mentionId, room.roomId);
+ return;
+ }
+ if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
+ if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
+ else navigateRoom(mentionId);
+ return;
+ }
+ openJoinAlias(mentionId);
+ },
+ }),
+ [mx, room, navigateRoom, navigateSpace]
+ );
+ const parseMemberEvent = useMemberEventParser();
+
+ const [timeline, setTimeline] = useState<Timeline>(() =>
+ eventId ? getEmptyTimeline() : getInitialTimeline(room)
+ );
+ const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
+ const liveTimelineLinked =
+ timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
+ const canPaginateBack =
+ typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
+ const rangeAtStart = timeline.range.start === 0;
+ const rangeAtEnd = timeline.range.end === eventsLength;
+ const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
+ atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
+
+ const handleTimelinePagination = useTimelinePagination(
+ mx,
+ timeline,
+ setTimeline,
+ PAGINATION_LIMIT
+ );
+
+ const getScrollElement = useCallback(() => scrollRef.current, []);
+
+ const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
+ useVirtualPaginator({
+ count: eventsLength,
+ limit: PAGINATION_LIMIT,
+ range: timeline.range,
+ onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
+ getScrollElement,
+ getItemElement: useCallback(
+ (index: number) =>
+ (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
+ undefined,
+ []
+ ),
+ onEnd: handleTimelinePagination,
+ });
+
+ const loadEventTimeline = useEventTimelineLoader(
+ mx,
+ room,
+ useCallback(
+ (evtId, lTimelines, evtAbsIndex) => {
+ if (!alive()) return;
+ const evLength = getTimelinesEventsCount(lTimelines);
+
+ setFocusItem({
+ index: evtAbsIndex,
+ scrollTo: true,
+ highlight: evtId !== readUptoEventIdRef.current,
+ });
+ setTimeline({
+ linkedTimelines: lTimelines,
+ range: {
+ start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
+ end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
+ },
+ });
+ },
+ [alive]
+ ),
+ useCallback(() => {
+ if (!alive()) return;
+ setTimeline(getInitialTimeline(room));
+ scrollToBottomRef.current.count += 1;
+ scrollToBottomRef.current.smooth = false;
+ }, [alive, room])
+ );
+
+ useLiveEventArrive(
+ 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())) {
+ requestAnimationFrame(() => markAsRead(mEvt.getRoomId()));
+ }
+
+ if (document.hasFocus()) {
+ scrollToBottomRef.current.count += 1;
+ scrollToBottomRef.current.smooth = true;
+ } else if (!unreadInfo) {
+ setUnreadInfo(getRoomUnreadInfo(room));
+ }
+ setTimeline((ct) => ({
+ ...ct,
+ range: {
+ start: ct.range.start + 1,
+ end: ct.range.end + 1,
+ },
+ }));
+ return;
+ }
+ setTimeline((ct) => ({ ...ct }));
+ if (!unreadInfo) {
+ setUnreadInfo(getRoomUnreadInfo(room));
+ }
+ },
+ [mx, room, unreadInfo]
+ )
+ );
+
+ useLiveTimelineRefresh(
+ room,
+ useCallback(() => {
+ if (liveTimelineLinked) {
+ setTimeline(getInitialTimeline(room));
+ }
+ }, [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;
+ }
+ if (!roomInputRef.current) return;
+ const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
+ const scrollElement = getScrollElement();
+ if (!editorBaseEntry || !scrollElement) return;
+
+ if (atBottomRef.current) {
+ scrollToBottom(scrollElement);
+ }
+ };
+ }, [getScrollElement, roomInputRef]),
+ useCallback(() => roomInputRef.current, [roomInputRef])
+ );
+
+ const tryAutoMarkAsRead = useCallback(() => {
+ if (!unreadInfo) {
+ requestAnimationFrame(() => markAsRead(room.roomId));
+ return;
+ }
+ const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
+ const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
+ if (latestTimeline === room.getLiveTimeline()) {
+ requestAnimationFrame(() => markAsRead(room.roomId));
+ }
+ }, [room, unreadInfo]);
+
+ const debounceSetAtBottom = useDebounce(
+ useCallback((entry: IntersectionObserverEntry) => {
+ if (!entry.isIntersecting) setAtBottom(false);
+ }, []),
+ { wait: 1000 }
+ );
+ useIntersectionObserver(
+ useCallback(
+ (entries) => {
+ const target = atBottomAnchorRef.current;
+ if (!target) return;
+ const targetEntry = getIntersectionObserverEntry(target, entries);
+ if (targetEntry) debounceSetAtBottom(targetEntry);
+ if (targetEntry?.isIntersecting && atLiveEndRef.current) {
+ setAtBottom(true);
+ tryAutoMarkAsRead();
+ }
+ },
+ [debounceSetAtBottom, tryAutoMarkAsRead]
+ ),
+ useCallback(
+ () => ({
+ root: getScrollElement(),
+ rootMargin: '100px',
+ }),
+ [getScrollElement]
+ ),
+ useCallback(() => atBottomAnchorRef.current, [])
+ );
+
+ useDocumentFocusChange(
+ useCallback(
+ (inFocus) => {
+ if (inFocus && atBottomRef.current) {
+ tryAutoMarkAsRead();
+ }
+ },
+ [tryAutoMarkAsRead]
+ )
+ );
+
+ // Handle up arrow edit
+ useKeyDown(
+ window,
+ useCallback(
+ (evt) => {
+ if (
+ isKeyHotkey('arrowup', evt) &&
+ editableActiveElement() &&
+ document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
+ isEmptyEditor(editor)
+ ) {
+ const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
+ canEditEvent(mx, mEvt)
+ );
+ const editableEvtId = editableEvt?.getId();
+ if (!editableEvtId) return;
+ setEditId(editableEvtId);
+ }
+ },
+ [mx, room, editor]
+ )
+ );
+
+ useEffect(() => {
+ if (eventId) {
+ setTimeline(getEmptyTimeline());
+ loadEventTimeline(eventId);
+ }
+ }, [eventId, loadEventTimeline]);
+
+ // Scroll to bottom on initial timeline load
+ useLayoutEffect(() => {
+ const scrollEl = scrollRef.current;
+ if (scrollEl) {
+ scrollToBottom(scrollEl);
+ }
+ }, []);
+
+ // if live timeline is linked and unreadInfo change
+ // Scroll to last read message
+ useLayoutEffect(() => {
+ const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
+ if (readUptoEventId && inLiveTimeline && scrollTo) {
+ const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
+ const evtTimeline = getEventTimeline(room, readUptoEventId);
+ const absoluteIndex =
+ evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
+ if (absoluteIndex) {
+ scrollToItem(absoluteIndex, {
+ behavior: 'instant',
+ align: 'start',
+ stopInView: true,
+ });
+ }
+ }
+ }, [room, unreadInfo, scrollToItem]);
+
+ // scroll to focused message
+ useLayoutEffect(() => {
+ if (focusItem && focusItem.scrollTo) {
+ scrollToItem(focusItem.index, {
+ behavior: 'instant',
+ align: 'center',
+ stopInView: true,
+ });
+ }
+
+ setTimeout(() => {
+ if (!alive()) return;
+ setFocusItem((currentItem) => {
+ if (currentItem === focusItem) return undefined;
+ return currentItem;
+ });
+ }, 2000);
+ }, [alive, focusItem, scrollToItem]);
+
+ // scroll to bottom of timeline
+ const scrollToBottomCount = scrollToBottomRef.current.count;
+ useLayoutEffect(() => {
+ if (scrollToBottomCount > 0) {
+ const scrollEl = scrollRef.current;
+ if (scrollEl)
+ scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
+ }
+ }, [scrollToBottomCount]);
+
+ // Remove unreadInfo on mark as read
+ useEffect(() => {
+ const handleFullRead = (rId: string) => {
+ if (rId !== room.roomId) return;
+ setUnreadInfo(undefined);
+ };
+ initMatrix.notifications?.on(cons.events.notifications.FULL_READ, handleFullRead);
+ return () => {
+ initMatrix.notifications?.removeListener(cons.events.notifications.FULL_READ, handleFullRead);
+ };
+ }, [room]);
+
+ // scroll out of view msg editor in view.
+ useEffect(() => {
+ if (editId) {
+ const editMsgElement =
+ (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
+ undefined;
+ if (editMsgElement) {
+ scrollToElement(editMsgElement, {
+ align: 'center',
+ behavior: 'smooth',
+ stopInView: true,
+ });
+ }
+ }
+ }, [scrollToElement, editId]);
+
+ const handleJumpToLatest = () => {
+ setTimeline(getInitialTimeline(room));
+ scrollToBottomRef.current.count += 1;
+ scrollToBottomRef.current.smooth = false;
+ };
+
+ const handleJumpToUnread = () => {
+ if (unreadInfo?.readUptoEventId) {
+ setTimeline(getEmptyTimeline());
+ loadEventTimeline(unreadInfo.readUptoEventId);
+ }
+ };
+
+ const handleMarkAsRead = () => {
+ markAsRead(room.roomId);
+ };
+
+ const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
+ async (evt) => {
+ const replyId = evt.currentTarget.getAttribute('data-reply-id');
+ if (typeof replyId !== 'string') return;
+ const replyTimeline = getEventTimeline(room, replyId);
+ const absoluteIndex =
+ replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId);
+
+ if (typeof absoluteIndex === 'number') {
+ scrollToItem(absoluteIndex, {
+ behavior: 'smooth',
+ align: 'center',
+ stopInView: true,
+ });
+ setFocusItem({
+ index: absoluteIndex,
+ scrollTo: false,
+ highlight: true,
+ });
+ } else {
+ setTimeline(getEmptyTimeline());
+ loadEventTimeline(replyId);
+ }
+ },
+ [room, timeline, scrollToItem, loadEventTimeline]
+ );
+
+ const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+ (evt) => {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const userId = evt.currentTarget.getAttribute('data-user-id');
+ if (!userId) {
+ console.warn('Button should have "data-user-id" attribute!');
+ return;
+ }
+ openProfileViewer(userId, room.roomId);
+ },
+ [room]
+ );
+ const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+ (evt) => {
+ evt.preventDefault();
+ const userId = evt.currentTarget.getAttribute('data-user-id');
+ if (!userId) {
+ console.warn('Button should have "data-user-id" attribute!');
+ return;
+ }
+ const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
+ editor.insertNode(
+ createMentionElement(
+ userId,
+ name.startsWith('@') ? name : `@${name}`,
+ userId === mx.getUserId()
+ )
+ );
+ ReactEditor.focus(editor);
+ moveCursor(editor);
+ },
+ [mx, room, editor]
+ );
+
+ const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+ (evt) => {
+ const replyId = evt.currentTarget.getAttribute('data-event-id');
+ if (!replyId) {
+ console.warn('Button should have "data-event-id" attribute!');
+ return;
+ }
+ const replyEvt = room.findEventById(replyId);
+ if (!replyEvt) return;
+ const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
+ const { body, formatted_body: formattedBody }: Record<string, string> =
+ editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
+ const senderId = replyEvt.getSender();
+ if (senderId && typeof body === 'string') {
+ setReplyDraft({
+ userId: senderId,
+ eventId: replyId,
+ body,
+ formattedBody,
+ });
+ setTimeout(() => ReactEditor.focus(editor), 100);
+ }
+ },
+ [room, setReplyDraft, editor]
+ );
+
+ const handleReactionToggle = useCallback(
+ (targetEventId: string, key: string, shortcode?: string) => {
+ const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
+ const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
+ const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
+ const reactions = reactionsSet ? Array.from(reactionsSet) : [];
+ const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
+
+ if (myReaction && !!myReaction?.isRelation()) {
+ mx.redactEvent(room.roomId, myReaction.getId()!);
+ return;
+ }
+ const rShortcode =
+ shortcode ||
+ (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
+ mx.sendEvent(
+ room.roomId,
+ MessageEvent.Reaction,
+ getReactionContent(targetEventId, key, rShortcode)
+ );
+ },
+ [mx, room]
+ );
+ const handleEdit = useCallback(
+ (editEvtId?: string) => {
+ if (editEvtId) {
+ setEditId(editEvtId);
+ return;
+ }
+ setEditId(undefined);
+ ReactEditor.focus(editor);
+ },
+ [editor]
+ );
+
+ const renderMatrixEvent = useMatrixEventRenderer<
+ [string, MatrixEvent, number, EventTimelineSet, boolean]
+ >(
+ {
+ [MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
+ const reactionRelations = getEventReactions(timelineSet, mEventId);
+ const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+ const hasReactions = reactions && reactions.length > 0;
+ const { replyEventId } = mEvent;
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+
+ const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+ const getContent = (() =>
+ editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
+
+ const senderId = mEvent.getSender() ?? '';
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+
+ return (
+ <Message
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ messageSpacing={messageSpacing}
+ messageLayout={messageLayout}
+ collapse={collapse}
+ highlight={highlighted}
+ edit={editId === mEventId}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ canSendReaction={canSendReaction}
+ imagePackRooms={imagePackRooms}
+ relations={hasReactions ? reactionRelations : undefined}
+ onUserClick={handleUserClick}
+ onUsernameClick={handleUsernameClick}
+ onReplyClick={handleReplyClick}
+ onReactionToggle={handleReactionToggle}
+ onEditId={handleEdit}
+ reply={
+ replyEventId && (
+ <Reply
+ as="button"
+ mx={mx}
+ room={room}
+ timelineSet={timelineSet}
+ eventId={replyEventId}
+ data-reply-id={replyEventId}
+ onClick={handleOpenReply}
+ />
+ )
+ }
+ reactions={
+ reactionRelations && (
+ <Reactions
+ style={{ marginTop: config.space.S200 }}
+ room={room}
+ relations={reactionRelations}
+ mEventId={mEventId}
+ canSendReaction={canSendReaction}
+ onReactionToggle={handleReactionToggle}
+ />
+ )
+ }
+ >
+ {mEvent.isRedacted() ? (
+ <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
+ ) : (
+ <RenderMessageContent
+ displayName={senderDisplayName}
+ msgType={mEvent.getContent().msgtype ?? ''}
+ ts={mEvent.getTs()}
+ edited={!!editedEvent}
+ getContent={getContent}
+ mediaAutoLoad={mediaAutoLoad}
+ urlPreview={showUrlPreview}
+ htmlReactParserOptions={htmlReactParserOptions}
+ outlineAttachment={messageLayout === 2}
+ />
+ )}
+ </Message>
+ );
+ },
+ [MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => {
+ const reactionRelations = getEventReactions(timelineSet, mEventId);
+ const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+ const hasReactions = reactions && reactions.length > 0;
+ const { replyEventId } = mEvent;
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+
+ return (
+ <Message
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ messageSpacing={messageSpacing}
+ messageLayout={messageLayout}
+ collapse={collapse}
+ highlight={highlighted}
+ edit={editId === mEventId}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ canSendReaction={canSendReaction}
+ imagePackRooms={imagePackRooms}
+ relations={hasReactions ? reactionRelations : undefined}
+ onUserClick={handleUserClick}
+ onUsernameClick={handleUsernameClick}
+ onReplyClick={handleReplyClick}
+ onReactionToggle={handleReactionToggle}
+ onEditId={handleEdit}
+ reply={
+ replyEventId && (
+ <Reply
+ as="button"
+ mx={mx}
+ room={room}
+ timelineSet={timelineSet}
+ eventId={replyEventId}
+ data-reply-id={replyEventId}
+ onClick={handleOpenReply}
+ />
+ )
+ }
+ reactions={
+ reactionRelations && (
+ <Reactions
+ style={{ marginTop: config.space.S200 }}
+ room={room}
+ relations={reactionRelations}
+ mEventId={mEventId}
+ canSendReaction={canSendReaction}
+ onReactionToggle={handleReactionToggle}
+ />
+ )
+ }
+ >
+ <EncryptedContent mEvent={mEvent}>
+ {() => {
+ if (mEvent.isRedacted()) return <RedactedContent />;
+ if (mEvent.getType() === MessageEvent.Sticker)
+ return (
+ <MSticker
+ content={mEvent.getContent()}
+ renderImageContent={(props) => (
+ <ImageContent
+ {...props}
+ autoPlay={mediaAutoLoad}
+ renderImage={(p) => <Image {...p} loading="lazy" />}
+ renderViewer={(p) => <ImageViewer {...p} />}
+ />
+ )}
+ />
+ );
+ if (mEvent.getType() === MessageEvent.RoomMessage) {
+ const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+ const getContent = (() =>
+ editedEvent?.getContent()['m.new_content'] ??
+ mEvent.getContent()) as GetContentCallback;
+
+ const senderId = mEvent.getSender() ?? '';
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+ return (
+ <RenderMessageContent
+ displayName={senderDisplayName}
+ msgType={mEvent.getContent().msgtype ?? ''}
+ ts={mEvent.getTs()}
+ edited={!!editedEvent}
+ getContent={getContent}
+ mediaAutoLoad={mediaAutoLoad}
+ urlPreview={showUrlPreview}
+ htmlReactParserOptions={htmlReactParserOptions}
+ outlineAttachment={messageLayout === 2}
+ />
+ );
+ }
+ if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
+ return (
+ <Text>
+ <MessageNotDecryptedContent />
+ </Text>
+ );
+ return (
+ <Text>
+ <MessageUnsupportedContent />
+ </Text>
+ );
+ }}
+ </EncryptedContent>
+ </Message>
+ );
+ },
+ [MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => {
+ const reactionRelations = getEventReactions(timelineSet, mEventId);
+ const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+ const hasReactions = reactions && reactions.length > 0;
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+
+ return (
+ <Message
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ messageSpacing={messageSpacing}
+ messageLayout={messageLayout}
+ collapse={collapse}
+ highlight={highlighted}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ canSendReaction={canSendReaction}
+ imagePackRooms={imagePackRooms}
+ relations={hasReactions ? reactionRelations : undefined}
+ onUserClick={handleUserClick}
+ onUsernameClick={handleUsernameClick}
+ onReplyClick={handleReplyClick}
+ onReactionToggle={handleReactionToggle}
+ reactions={
+ reactionRelations && (
+ <Reactions
+ style={{ marginTop: config.space.S200 }}
+ room={room}
+ relations={reactionRelations}
+ mEventId={mEventId}
+ canSendReaction={canSendReaction}
+ onReactionToggle={handleReactionToggle}
+ />
+ )
+ }
+ >
+ {mEvent.isRedacted() ? (
+ <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
+ ) : (
+ <MSticker
+ content={mEvent.getContent()}
+ renderImageContent={(props) => (
+ <ImageContent
+ {...props}
+ autoPlay={mediaAutoLoad}
+ renderImage={(p) => <Image {...p} loading="lazy" />}
+ renderViewer={(p) => <ImageViewer {...p} />}
+ />
+ )}
+ />
+ )}
+ </Message>
+ );
+ },
+ [StateEvent.RoomMember]: (mEventId, mEvent, item) => {
+ const membershipChanged = isMembershipChanged(mEvent);
+ if (membershipChanged && hideMembershipEvents) return null;
+ if (!membershipChanged && hideNickAvatarEvents) return null;
+
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const parsed = parseMemberEvent(mEvent);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={parsed.icon}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ {parsed.body}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ [StateEvent.RoomName]: (mEventId, mEvent, item) => {
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Hash}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' changed room name'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ [StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Hash}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' changed room topic'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ [StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Hash}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' changed room avatar'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ },
+ (mEventId, mEvent, item) => {
+ if (!showHiddenEvents) return null;
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Code}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' sent '}
+ <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
+ {' state event'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ (mEventId, mEvent, item) => {
+ if (!showHiddenEvents) return null;
+ if (Object.keys(mEvent.getContent()).length === 0) return null;
+ if (mEvent.getRelation()) return null;
+ if (mEvent.isRedaction()) return null;
+
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ data-message-id={mEventId}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Code}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' sent '}
+ <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
+ {' event'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ }
+ );
+
+ let prevEvent: MatrixEvent | undefined;
+ let isPrevRendered = false;
+ let newDivider = false;
+ let dayDivider = false;
+ const eventRenderer = (item: number) => {
+ const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
+ if (!eventTimeline) return null;
+ const timelineSet = eventTimeline?.getTimelineSet();
+ const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
+ const mEventId = mEvent?.getId();
+
+ if (!mEvent || !mEventId) return null;
+
+ if (!newDivider && readUptoEventIdRef.current) {
+ newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
+ }
+ if (!dayDivider) {
+ dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
+ }
+
+ const collapsed =
+ isPrevRendered &&
+ !dayDivider &&
+ (!newDivider || mEvent.getSender() === mx.getUserId()) &&
+ prevEvent !== undefined &&
+ prevEvent.getSender() === mEvent.getSender() &&
+ prevEvent.getType() === mEvent.getType() &&
+ minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
+
+ const eventJSX = reactionOrEditEvent(mEvent)
+ ? null
+ : renderMatrixEvent(
+ mEvent.getType(),
+ typeof mEvent.getStateKey() === 'string',
+ mEventId,
+ mEvent,
+ item,
+ timelineSet,
+ collapsed
+ );
+ prevEvent = mEvent;
+ isPrevRendered = !!eventJSX;
+
+ const newDividerJSX =
+ newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? (
+ <MessageBase space={messageSpacing}>
+ <TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
+ <Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
+ <Text size="L400">New Messages</Text>
+ </Badge>
+ </TimelineDivider>
+ </MessageBase>
+ ) : null;
+
+ const dayDividerJSX =
+ dayDivider && eventJSX ? (
+ <MessageBase space={messageSpacing}>
+ <TimelineDivider variant="Surface">
+ <Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
+ <Text size="L400">
+ {(() => {
+ if (today(mEvent.getTs())) return 'Today';
+ if (yesterday(mEvent.getTs())) return 'Yesterday';
+ return timeDayMonthYear(mEvent.getTs());
+ })()}
+ </Text>
+ </Badge>
+ </TimelineDivider>
+ </MessageBase>
+ ) : null;
+
+ if (eventJSX && (newDividerJSX || dayDividerJSX)) {
+ if (newDividerJSX) newDivider = false;
+ if (dayDividerJSX) dayDivider = false;
+
+ return (
+ <React.Fragment key={mEventId}>
+ {newDividerJSX}
+ {dayDividerJSX}
+ {eventJSX}
+ </React.Fragment>
+ );
+ }
+
+ return eventJSX;
+ };
+
+ return (
+ <Box grow="Yes" style={{ position: 'relative' }}>
+ {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
+ <TimelineFloat position="Top">
+ <Chip
+ variant="Primary"
+ radii="Pill"
+ outlined
+ before={<Icon size="50" src={Icons.MessageUnread} />}
+ onClick={handleJumpToUnread}
+ >
+ <Text size="L400">Jump to Unread</Text>
+ </Chip>
+
+ <Chip
+ variant="SurfaceVariant"
+ radii="Pill"
+ outlined
+ before={<Icon size="50" src={Icons.CheckTwice} />}
+ onClick={handleMarkAsRead}
+ >
+ <Text size="L400">Mark as Read</Text>
+ </Chip>
+ </TimelineFloat>
+ )}
+ <Scroll ref={scrollRef} visibility="Hover">
+ <Box
+ direction="Column"
+ justifyContent="End"
+ style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
+ >
+ {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
+ <div
+ style={{
+ padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
+ messageLayout === 1 ? config.space.S400 : toRem(64)
+ }`,
+ }}
+ >
+ <RoomIntro room={room} />
+ </div>
+ )}
+ {(canPaginateBack || !rangeAtStart) &&
+ (messageLayout === 1 ? (
+ <>
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder ref={observeBackAnchor} />
+ </>
+ ) : (
+ <>
+ <DefaultPlaceholder />
+ <DefaultPlaceholder />
+ <DefaultPlaceholder ref={observeBackAnchor} />
+ </>
+ ))}
+
+ {getItems().map(eventRenderer)}
+
+ {(!liveTimelineLinked || !rangeAtEnd) &&
+ (messageLayout === 1 ? (
+ <>
+ <CompactPlaceholder ref={observeFrontAnchor} />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ </>
+ ) : (
+ <>
+ <DefaultPlaceholder ref={observeFrontAnchor} />
+ <DefaultPlaceholder />
+ <DefaultPlaceholder />
+ </>
+ ))}
+ <span ref={atBottomAnchorRef} />
+ </Box>
+ </Scroll>
+ {!atBottom && (
+ <TimelineFloat position="Bottom">
+ <Chip
+ variant="SurfaceVariant"
+ radii="Pill"
+ outlined
+ before={<Icon size="50" src={Icons.ArrowBottom} />}
+ onClick={handleJumpToLatest}
+ >
+ <Text size="L400">Jump to Latest</Text>
+ </Chip>
+ </TimelineFloat>
+ )}
+ </Box>
+ );
+}
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const RoomTombstone = style({
+ padding: config.space.S200,
+ paddingLeft: config.space.S400,
+});
--- /dev/null
+import React, { useCallback } from 'react';
+import { Box, Button, Spinner, Text, color } from 'folds';
+
+import * as css from './RoomTombstone.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { genRoomVia } from '../../../util/matrixUtil';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { Membership } from '../../../types/matrix/room';
+import { RoomInputPlaceholder } from './RoomInputPlaceholder';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+
+type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
+export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
+ const mx = useMatrixClient();
+ const { navigateRoom } = useRoomNavigate();
+
+ const [joinState, handleJoin] = useAsyncCallback(
+ useCallback(() => {
+ const currentRoom = mx.getRoom(roomId);
+ const via = currentRoom ? genRoomVia(currentRoom) : [];
+ return mx.joinRoom(replacementRoomId, {
+ viaServers: via,
+ });
+ }, [mx, roomId, replacementRoomId])
+ );
+ const replacementRoom = mx.getRoom(replacementRoomId);
+
+ const handleOpen = () => {
+ if (replacementRoom) navigateRoom(replacementRoom.roomId);
+ if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
+ };
+
+ return (
+ <RoomInputPlaceholder alignItems="Center" gap="600" className={css.RoomTombstone}>
+ <Box direction="Column" grow="Yes">
+ <Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
+ {joinState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
+ </Text>
+ )}
+ </Box>
+ {replacementRoom?.getMyMembership() === Membership.Join ||
+ joinState.status === AsyncStatus.Success ? (
+ <Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
+ <Text size="B300">Open New Room</Text>
+ </Button>
+ ) : (
+ <Button
+ onClick={handleJoin}
+ size="300"
+ variant="Primary"
+ fill="Solid"
+ radii="300"
+ before={
+ joinState.status === AsyncStatus.Loading && (
+ <Spinner size="100" variant="Primary" fill="Solid" />
+ )
+ }
+ disabled={joinState.status === AsyncStatus.Loading}
+ >
+ <Text size="B300">Join New Room</Text>
+ </Button>
+ )}
+ </RoomInputPlaceholder>
+ );
+}
--- /dev/null
+import React, { useRef } from 'react';
+import { Box, Text, config } from 'folds';
+import { EventType, Room } from 'matrix-js-sdk';
+
+import { useStateEvent } from '../../hooks/useStateEvent';
+import { StateEvent } from '../../../types/matrix/room';
+import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useEditor } from '../../components/editor';
+import { RoomInputPlaceholder } from './RoomInputPlaceholder';
+import { RoomTimeline } from './RoomTimeline';
+import { RoomViewTyping } from './RoomViewTyping';
+import { RoomTombstone } from './RoomTombstone';
+import { RoomInput } from './RoomInput';
+import { RoomViewFollowing } from './RoomViewFollowing';
+import { Page } from '../../components/page';
+import { RoomViewHeader } from './RoomViewHeader';
+
+export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
+ const roomInputRef = useRef(null);
+ const roomViewRef = useRef(null);
+
+ const { roomId } = room;
+ const editor = useEditor();
+
+ const mx = useMatrixClient();
+
+ const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
+ const powerLevels = usePowerLevelsContext();
+ const { getPowerLevel, canSendEvent } = usePowerLevelsAPI(powerLevels);
+ const myUserId = mx.getUserId();
+ const canMessage = myUserId
+ ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
+ : false;
+
+ return (
+ <Page ref={roomViewRef}>
+ <RoomViewHeader />
+ <Box grow="Yes" direction="Column">
+ <RoomTimeline
+ key={roomId}
+ room={room}
+ eventId={eventId}
+ roomInputRef={roomInputRef}
+ editor={editor}
+ />
+ <RoomViewTyping room={room} />
+ </Box>
+ <Box shrink="No" direction="Column">
+ <div style={{ padding: `0 ${config.space.S400}` }}>
+ {tombstoneEvent ? (
+ <RoomTombstone
+ roomId={roomId}
+ body={tombstoneEvent.getContent().body}
+ replacementRoomId={tombstoneEvent.getContent().replacement_room}
+ />
+ ) : (
+ <>
+ {canMessage && (
+ <RoomInput
+ room={room}
+ editor={editor}
+ roomId={roomId}
+ fileDropContainerRef={roomViewRef}
+ ref={roomInputRef}
+ />
+ )}
+ {!canMessage && (
+ <RoomInputPlaceholder
+ style={{ padding: config.space.S200 }}
+ alignItems="Center"
+ justifyContent="Center"
+ >
+ <Text align="Center">You do not have permission to post in this room</Text>
+ </RoomInputPlaceholder>
+ )}
+ </>
+ )}
+ </div>
+ <RoomViewFollowing room={room} />
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const RoomViewFollowing = recipe({
+ base: [
+ DefaultReset,
+ {
+ minHeight: toRem(28),
+ padding: `0 ${config.space.S400}`,
+ width: '100%',
+ backgroundColor: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ outline: 'none',
+ },
+ ],
+ variants: {
+ clickable: {
+ true: {
+ cursor: 'pointer',
+ selectors: {
+ '&:hover, &:focus-visible': {
+ color: color.Primary.Main,
+ },
+ '&:active': {
+ color: color.Primary.Main,
+ },
+ },
+ },
+ },
+ },
+});
--- /dev/null
+import React, { useState } from 'react';
+import {
+ Box,
+ Icon,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+ as,
+ config,
+} from 'folds';
+import { Room } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import FocusTrap from 'focus-trap-react';
+
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomViewFollowing.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
+import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
+import { EventReaders } from '../../components/event-readers';
+
+export type RoomViewFollowingProps = {
+ room: Room;
+};
+export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
+ ({ className, room, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [open, setOpen] = useState(false);
+ const latestEvent = useRoomLatestRenderedEvent(room);
+ const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
+ const names = latestEventReaders
+ .filter((readerId) => readerId !== mx.getUserId())
+ .map(
+ (readerId) => getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId
+ );
+
+ const eventId = latestEvent?.getId();
+
+ return (
+ <>
+ {eventId && (
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setOpen(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="300">
+ <EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ <Box
+ as={names.length > 0 ? 'button' : 'div'}
+ onClick={names.length > 0 ? () => setOpen(true) : undefined}
+ className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
+ alignItems="Center"
+ justifyContent="End"
+ gap="200"
+ {...props}
+ ref={ref}
+ >
+ {names.length > 0 && (
+ <>
+ <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
+ <Text size="T300" truncate>
+ {names.length === 1 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' is following the conversation.'}
+ </Text>
+ </>
+ )}
+ {names.length === 2 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are following the conversation.'}
+ </Text>
+ </>
+ )}
+ {names.length === 3 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are following the conversation.'}
+ </Text>
+ </>
+ )}
+ {names.length > 3 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names.length - 3} others</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are following the conversation.'}
+ </Text>
+ </>
+ )}
+ </Text>
+ </>
+ )}
+ </Box>
+ </>
+ );
+ }
+);
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const HeaderTopic = style({
+ ':hover': {
+ cursor: 'pointer',
+ opacity: config.opacity.P500,
+ textDecoration: 'underline',
+ },
+});
--- /dev/null
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Avatar,
+ Text,
+ Overlay,
+ OverlayCenter,
+ OverlayBackdrop,
+ IconButton,
+ Icon,
+ Icons,
+ Tooltip,
+ TooltipProvider,
+ Menu,
+ MenuItem,
+ toRem,
+ config,
+ Line,
+ PopOut,
+ RectCords,
+} from 'folds';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { JoinRule, Room } from 'matrix-js-sdk';
+import { useAtomValue } from 'jotai';
+
+import { useStateEvent } from '../../hooks/useStateEvent';
+import { PageHeader } from '../../components/page';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { RoomTopicViewer } from '../../components/room-topic-viewer';
+import { StateEvent } from '../../../types/matrix/room';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoom } from '../../hooks/useRoom';
+import { useSetSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { useSpaceOptionally } from '../../hooks/useSpace';
+import {
+ getHomeSearchPath,
+ getOriginBaseUrl,
+ getSpaceSearchPath,
+ joinPathComponent,
+ withOriginBaseUrl,
+ withSearchParam,
+} from '../../pages/pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
+import { _SearchPathSearchParams } from '../../pages/paths';
+import * as css from './RoomViewHeader.css';
+import { useRoomUnread } from '../../state/hooks/unread';
+import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { markAsRead } from '../../../client/action/notifications';
+import { roomToUnreadAtom } from '../../state/room/roomToUnread';
+import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
+import { copyToClipboard } from '../../utils/dom';
+import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
+import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../state/mDirectList';
+import { useClientConfig } from '../../hooks/useClientConfig';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+
+type RoomMenuProps = {
+ room: Room;
+ linkPath: string;
+ requestClose: () => void;
+};
+const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
+ ({ room, linkPath, requestClose }, ref) => {
+ const mx = useMatrixClient();
+ const { hashRouter } = useClientConfig();
+ const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+ const powerLevels = usePowerLevelsContext();
+ const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+ const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+ const handleMarkAsRead = () => {
+ markAsRead(room.roomId);
+ requestClose();
+ };
+
+ const handleInvite = () => {
+ openInviteUser(room.roomId);
+ requestClose();
+ };
+
+ const handleCopyLink = () => {
+ copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), linkPath));
+ requestClose();
+ };
+
+ const handleRoomSettings = () => {
+ toggleRoomSettings(room.roomId);
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleMarkAsRead}
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ disabled={!unread}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Mark as Read
+ </Text>
+ </MenuItem>
+ </Box>
+ <Line variant="Surface" size="300" />
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleInvite}
+ variant="Primary"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.UserPlus} />}
+ radii="300"
+ disabled={!canInvite}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Invite
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleCopyLink}
+ size="300"
+ after={<Icon size="100" src={Icons.Link} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Copy Link
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleRoomSettings}
+ size="300"
+ after={<Icon size="100" src={Icons.Setting} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Room Settings
+ </Text>
+ </MenuItem>
+ </Box>
+ <Line variant="Surface" size="300" />
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <UseStateProvider initial={false}>
+ {(promptLeave, setPromptLeave) => (
+ <>
+ <MenuItem
+ onClick={() => setPromptLeave(true)}
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+ radii="300"
+ aria-pressed={promptLeave}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Leave Room
+ </Text>
+ </MenuItem>
+ {promptLeave && (
+ <LeaveRoomPrompt
+ roomId={room.roomId}
+ onDone={requestClose}
+ onCancel={() => setPromptLeave(false)}
+ />
+ )}
+ </>
+ )}
+ </UseStateProvider>
+ </Box>
+ </Menu>
+ );
+ }
+);
+
+export function RoomViewHeader() {
+ const navigate = useNavigate();
+ const mx = useMatrixClient();
+ const screenSize = useScreenSizeContext();
+ const room = useRoom();
+ const space = useSpaceOptionally();
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+ const mDirects = useAtomValue(mDirectAtom);
+
+ const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
+ const ecryptedRoom = !!encryptionEvent;
+ const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
+ const name = useRoomName(room);
+ const topic = useRoomTopic(room);
+ const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
+
+ const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+ const location = useLocation();
+ const currentPath = joinPathComponent(location);
+
+ const handleSearchClick = () => {
+ const searchParams: _SearchPathSearchParams = {
+ rooms: room.roomId,
+ };
+ const path = space
+ ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
+ : getHomeSearchPath();
+ navigate(withSearchParam(path, searchParams));
+ };
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <PageHeader>
+ <Box grow="Yes" gap="300">
+ <Box grow="Yes" alignItems="Center" gap="300">
+ <Avatar size="300">
+ <RoomAvatar
+ roomId={room.roomId}
+ src={avatarUrl}
+ alt={name}
+ renderFallback={() => (
+ <RoomIcon size="200" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
+ )}
+ />
+ </Avatar>
+ <Box direction="Column">
+ <Text size={topic ? 'H5' : 'H3'} truncate>
+ {name}
+ </Text>
+ {topic && (
+ <UseStateProvider initial={false}>
+ {(viewTopic, setViewTopic) => (
+ <>
+ <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ clickOutsideDeactivates: true,
+ onDeactivate: () => setViewTopic(false),
+ }}
+ >
+ <RoomTopicViewer
+ name={name}
+ topic={topic}
+ requestClose={() => setViewTopic(false)}
+ />
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <Text
+ as="button"
+ type="button"
+ onClick={() => setViewTopic(true)}
+ className={css.HeaderTopic}
+ size="T200"
+ priority="300"
+ truncate
+ >
+ {topic}
+ </Text>
+ </>
+ )}
+ </UseStateProvider>
+ )}
+ </Box>
+ </Box>
+ <Box shrink="No">
+ {!ecryptedRoom && (
+ <TooltipProvider
+ position="Bottom"
+ offset={4}
+ tooltip={
+ <Tooltip>
+ <Text>Search</Text>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <IconButton ref={triggerRef} onClick={handleSearchClick}>
+ <Icon size="400" src={Icons.Search} />
+ </IconButton>
+ )}
+ </TooltipProvider>
+ )}
+ {screenSize === ScreenSize.Desktop && (
+ <TooltipProvider
+ position="Bottom"
+ offset={4}
+ tooltip={
+ <Tooltip>
+ <Text>Members</Text>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
+ <Icon size="400" src={Icons.User} />
+ </IconButton>
+ )}
+ </TooltipProvider>
+ )}
+ <TooltipProvider
+ position="Bottom"
+ align="End"
+ offset={4}
+ tooltip={
+ <Tooltip>
+ <Text>More Options</Text>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
+ <Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
+ </IconButton>
+ )}
+ </TooltipProvider>
+ <PopOut
+ anchor={menuAnchor}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <RoomMenu
+ room={room}
+ linkPath={currentPath}
+ requestClose={() => setMenuAnchor(undefined)}
+ />
+ </FocusTrap>
+ }
+ />
+ </Box>
+ </Box>
+ </PageHeader>
+ );
+}
--- /dev/null
+import { keyframes, style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+const SlideUpAnime = keyframes({
+ from: {
+ transform: 'translateY(100%)',
+ },
+ to: {
+ transform: 'translateY(0)',
+ },
+});
+
+export const RoomViewTyping = style([
+ DefaultReset,
+ {
+ padding: `0 ${config.space.S500}`,
+ width: '100%',
+ backgroundColor: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ position: 'absolute',
+ bottom: 0,
+ animation: `${SlideUpAnime} 100ms ease-in-out`,
+ },
+]);
+export const TypingText = style({
+ flexGrow: 1,
+});
--- /dev/null
+import React from 'react';
+import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import { useSetAtom } from 'jotai';
+import { roomIdToTypingMembersAtom } from '../../state/typingMembers';
+import { TypingIndicator } from '../../components/typing-indicator';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomViewTyping.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
+
+export type RoomViewTypingProps = {
+ room: Room;
+};
+export const RoomViewTyping = as<'div', RoomViewTypingProps>(
+ ({ className, room, ...props }, ref) => {
+ const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
+ const mx = useMatrixClient();
+ const typingMembers = useRoomTypingMember(room.roomId);
+
+ const typingNames = typingMembers
+ .filter((receipt) => receipt.userId !== mx.getUserId())
+ .map(
+ (receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
+ )
+ .reverse();
+
+ if (typingNames.length === 0) {
+ return null;
+ }
+
+ const handleDropAll = () => {
+ // some homeserver does not timeout typing status
+ // we have given option so user can drop their typing status
+ typingMembers.forEach((receipt) =>
+ setTypingMembers({
+ type: 'DELETE',
+ roomId: room.roomId,
+ userId: receipt.userId,
+ })
+ );
+ };
+
+ return (
+ <div style={{ position: 'relative' }}>
+ <Box
+ className={classNames(css.RoomViewTyping, className)}
+ alignItems="Center"
+ gap="400"
+ {...props}
+ ref={ref}
+ >
+ <TypingIndicator />
+ <Text className={css.TypingText} size="T300" truncate>
+ {typingNames.length === 1 && (
+ <>
+ <b>{typingNames[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' is typing...'}
+ </Text>
+ </>
+ )}
+ {typingNames.length === 2 && (
+ <>
+ <b>{typingNames[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{typingNames[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are typing...'}
+ </Text>
+ </>
+ )}
+ {typingNames.length === 3 && (
+ <>
+ <b>{typingNames[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{typingNames[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{typingNames[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are typing...'}
+ </Text>
+ </>
+ )}
+ {typingNames.length > 3 && (
+ <>
+ <b>{typingNames[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{typingNames[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{typingNames[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{typingNames.length - 3} others</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are typing...'}
+ </Text>
+ </>
+ )}
+ </Text>
+ <IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
+ <Icon size="50" src={Icons.Cross} />
+ </IconButton>
+ </Box>
+ </div>
+ );
+ }
+);
--- /dev/null
+export * from './Room';
--- /dev/null
+import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
+import React, { ReactNode, useEffect, useState } from 'react';
+
+type EncryptedContentProps = {
+ mEvent: MatrixEvent;
+ children: () => ReactNode;
+};
+
+export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
+ const [, toggleDecrypted] = useState(!mEvent.isBeingDecrypted());
+
+ useEffect(() => {
+ const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () => {
+ toggleDecrypted((s) => !s);
+ };
+ mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
+ return () => {
+ mEvent.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
+ };
+ }, [mEvent]);
+
+ return <>{children()}</>;
+}
--- /dev/null
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Line,
+ Menu,
+ MenuItem,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ PopOut,
+ RectCords,
+ Spinner,
+ Text,
+ as,
+ color,
+ config,
+} from 'folds';
+import React, {
+ FormEventHandler,
+ MouseEventHandler,
+ ReactNode,
+ useCallback,
+ useState,
+} from 'react';
+import FocusTrap from 'focus-trap-react';
+import { useHover, useFocusWithin } from 'react-aria';
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import classNames from 'classnames';
+import {
+ AvatarBase,
+ BubbleLayout,
+ CompactLayout,
+ MessageBase,
+ ModernLayout,
+ Time,
+ Username,
+} from '../../../components/message';
+import colorMXID from '../../../../util/colorMXID';
+import {
+ canEditEvent,
+ getEventEdits,
+ getMemberAvatarMxc,
+ getMemberDisplayName,
+} from '../../../utils/room';
+import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../../utils/matrix';
+import { MessageLayout, MessageSpacing } from '../../../state/settings';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
+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';
+import { useSpaceOptionally } from '../../../hooks/useSpace';
+import { useDirectSelected } from '../../../hooks/router/useDirectSelected';
+import {
+ getDirectRoomPath,
+ getHomeRoomPath,
+ getOriginBaseUrl,
+ getSpaceRoomPath,
+ withOriginBaseUrl,
+} from '../../../pages/pathUtils';
+import { copyToClipboard } from '../../../utils/dom';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+
+export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
+
+type MessageQuickReactionsProps = {
+ onReaction: ReactionHandler;
+};
+export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
+ ({ onReaction, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const recentEmojis = useRecentEmoji(mx, 4);
+
+ if (recentEmojis.length === 0) return <span />;
+ return (
+ <>
+ <Box
+ style={{ padding: config.space.S200 }}
+ alignItems="Center"
+ justifyContent="Center"
+ gap="200"
+ {...props}
+ ref={ref}
+ >
+ {recentEmojis.map((emoji) => (
+ <IconButton
+ key={emoji.unicode}
+ className={css.MessageQuickReaction}
+ size="300"
+ variant="SurfaceVariant"
+ radii="Pill"
+ title={emoji.shortcode}
+ aria-label={emoji.shortcode}
+ onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
+ >
+ <Text size="T500">{emoji.unicode}</Text>
+ </IconButton>
+ ))}
+ </Box>
+ <Line size="300" />
+ </>
+ );
+ }
+);
+
+export const MessageAllReactionItem = as<
+ 'button',
+ {
+ room: Room;
+ relations: Relations;
+ onClose?: () => void;
+ }
+>(({ room, relations, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay
+ onContextMenu={(evt: any) => {
+ evt.stopPropagation();
+ }}
+ open={open}
+ backdrop={<OverlayBackdrop />}
+ >
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => handleClose(),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="300">
+ <ReactionViewer
+ room={room}
+ relations={relations}
+ requestClose={() => setOpen(false)}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.Smile} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ View Reactions
+ </Text>
+ </MenuItem>
+ </>
+ );
+});
+
+export const MessageReadReceiptItem = as<
+ 'button',
+ {
+ room: Room;
+ eventId: string;
+ onClose?: () => void;
+ }
+>(({ room, eventId, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleClose,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="300">
+ <EventReaders room={room} eventId={eventId} requestClose={handleClose} />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ Read Receipts
+ </Text>
+ </MenuItem>
+ </>
+ );
+});
+
+export const MessageSourceCodeItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+
+ const getContent = (evt: MatrixEvent) =>
+ evt.isEncrypted()
+ ? {
+ [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
+ [`<== ORIGINAL_EVENT ==>`]: evt.event,
+ }
+ : evt.event;
+
+ const getText = (): string => {
+ const evtId = mEvent.getId()!;
+ const evtTimeline = room.getTimelineForEvent(evtId);
+ const edits =
+ evtTimeline &&
+ getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
+
+ if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
+
+ const content: Record<string, unknown> = {
+ '<== MAIN_EVENT ==>': getContent(mEvent),
+ };
+
+ edits.forEach((editEvt, index) => {
+ content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
+ });
+
+ return JSON.stringify(content, null, 2);
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleClose,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="500">
+ <TextViewer
+ name="Source Code"
+ langName="json"
+ text={getText()}
+ requestClose={handleClose}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.BlockCode} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ View Source
+ </Text>
+ </MenuItem>
+ </>
+ );
+});
+
+export const MessageCopyLinkItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const { hashRouter } = useClientConfig();
+ const space = useSpaceOptionally();
+ const directSelected = useDirectSelected();
+
+ const handleCopy = () => {
+ const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
+ let eventPath = getHomeRoomPath(roomIdOrAlias, mEvent.getId());
+ if (space) {
+ eventPath = getSpaceRoomPath(
+ getCanonicalAliasOrRoomId(mx, space.roomId),
+ roomIdOrAlias,
+ mEvent.getId()
+ );
+ } else if (directSelected) {
+ eventPath = getDirectRoomPath(roomIdOrAlias, mEvent.getId());
+ }
+ copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), eventPath));
+ onClose?.();
+ };
+
+ return (
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.Link} />}
+ radii="300"
+ onClick={handleCopy}
+ {...props}
+ ref={ref}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ Copy Link
+ </Text>
+ </MenuItem>
+ );
+});
+
+export const MessageDeleteItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [open, setOpen] = useState(false);
+
+ const [deleteState, deleteMessage] = useAsyncCallback(
+ useCallback(
+ (eventId: string, reason?: string) =>
+ mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
+ [mx, room]
+ )
+ );
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ const eventId = mEvent.getId();
+ if (
+ !eventId ||
+ deleteState.status === AsyncStatus.Loading ||
+ deleteState.status === AsyncStatus.Success
+ )
+ return;
+ const target = evt.target as HTMLFormElement | undefined;
+ const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+ const reason = reasonInput && reasonInput.value.trim();
+ deleteMessage(eventId, reason);
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleClose,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Delete Message</Text>
+ </Box>
+ <IconButton size="300" onClick={handleClose} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ style={{ padding: config.space.S400 }}
+ direction="Column"
+ gap="400"
+ >
+ <Text priority="400">
+ This action is irreversible! Are you sure that you want to delete this message?
+ </Text>
+ <Box direction="Column" gap="100">
+ <Text size="L400">
+ Reason{' '}
+ <Text as="span" size="T200">
+ (optional)
+ </Text>
+ </Text>
+ <Input name="reasonInput" variant="Background" />
+ {deleteState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T300">
+ Failed to delete message! Please try again.
+ </Text>
+ )}
+ </Box>
+ <Button
+ type="submit"
+ variant="Critical"
+ before={
+ deleteState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" variant="Critical" size="200" />
+ ) : undefined
+ }
+ aria-disabled={deleteState.status === AsyncStatus.Loading}
+ >
+ <Text size="B400">
+ {deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
+ </Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <Button
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.Delete} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ aria-pressed={open}
+ {...props}
+ ref={ref}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ Delete
+ </Text>
+ </Button>
+ </>
+ );
+});
+
+export const MessageReportItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [open, setOpen] = useState(false);
+
+ const [reportState, reportMessage] = useAsyncCallback(
+ useCallback(
+ (eventId: string, score: number, reason: string) =>
+ mx.reportEvent(room.roomId, eventId, score, reason),
+ [mx, room]
+ )
+ );
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ const eventId = mEvent.getId();
+ if (
+ !eventId ||
+ reportState.status === AsyncStatus.Loading ||
+ reportState.status === AsyncStatus.Success
+ )
+ return;
+ const target = evt.target as HTMLFormElement | undefined;
+ const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+ const reason = reasonInput && reasonInput.value.trim();
+ if (reasonInput) reasonInput.value = '';
+ reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleClose,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Report Message</Text>
+ </Box>
+ <IconButton size="300" onClick={handleClose} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ style={{ padding: config.space.S400 }}
+ direction="Column"
+ gap="400"
+ >
+ <Text priority="400">
+ Report this message to server, which may then notify the appropriate people to
+ take action.
+ </Text>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Reason</Text>
+ <Input name="reasonInput" variant="Background" required />
+ {reportState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T300">
+ Failed to report message! Please try again.
+ </Text>
+ )}
+ {reportState.status === AsyncStatus.Success && (
+ <Text style={{ color: color.Success.Main }} size="T300">
+ Message has been reported to server.
+ </Text>
+ )}
+ </Box>
+ <Button
+ type="submit"
+ variant="Critical"
+ before={
+ reportState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" variant="Critical" size="200" />
+ ) : undefined
+ }
+ aria-disabled={
+ reportState.status === AsyncStatus.Loading ||
+ reportState.status === AsyncStatus.Success
+ }
+ >
+ <Text size="B400">
+ {reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report'}
+ </Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <Button
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.Warning} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ aria-pressed={open}
+ {...props}
+ ref={ref}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ Report
+ </Text>
+ </Button>
+ </>
+ );
+});
+
+export type MessageProps = {
+ room: Room;
+ mEvent: MatrixEvent;
+ collapse: boolean;
+ highlight: boolean;
+ edit?: boolean;
+ canDelete?: boolean;
+ canSendReaction?: boolean;
+ imagePackRooms?: Room[];
+ relations?: Relations;
+ messageLayout: MessageLayout;
+ messageSpacing: MessageSpacing;
+ onUserClick: MouseEventHandler<HTMLButtonElement>;
+ onUsernameClick: MouseEventHandler<HTMLButtonElement>;
+ onReplyClick: MouseEventHandler<HTMLButtonElement>;
+ onEditId?: (eventId?: string) => void;
+ onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+ reply?: ReactNode;
+ reactions?: ReactNode;
+};
+export const Message = as<'div', MessageProps>(
+ (
+ {
+ className,
+ room,
+ mEvent,
+ collapse,
+ highlight,
+ edit,
+ canDelete,
+ canSendReaction,
+ imagePackRooms,
+ relations,
+ messageLayout,
+ messageSpacing,
+ onUserClick,
+ onUsernameClick,
+ onReplyClick,
+ onReactionToggle,
+ onEditId,
+ reply,
+ reactions,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const senderId = mEvent.getSender() ?? '';
+ const [hover, setHover] = useState(false);
+ const { hoverProps } = useHover({ onHoverChange: setHover });
+ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+ const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
+
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+ const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
+
+ const headerJSX = !collapse && (
+ <Box
+ gap="300"
+ direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
+ justifyContent="SpaceBetween"
+ alignItems="Baseline"
+ grow="Yes"
+ >
+ <Username
+ as="button"
+ style={{ color: colorMXID(senderId) }}
+ data-user-id={senderId}
+ onContextMenu={onUserClick}
+ onClick={onUsernameClick}
+ >
+ <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
+ <b>{senderDisplayName}</b>
+ </Text>
+ </Username>
+ <Box shrink="No" gap="100">
+ {messageLayout === 0 && hover && (
+ <>
+ <Text as="span" size="T200" priority="300">
+ {senderId}
+ </Text>
+ <Text as="span" size="T200" priority="300">
+ |
+ </Text>
+ </>
+ )}
+ <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
+ </Box>
+ </Box>
+ );
+
+ const avatarJSX = !collapse && messageLayout !== 1 && (
+ <AvatarBase>
+ <Avatar
+ className={css.MessageAvatar}
+ as="button"
+ size="300"
+ data-user-id={senderId}
+ onClick={onUserClick}
+ >
+ <UserAvatar
+ userId={senderId}
+ src={
+ senderAvatarMxc
+ ? mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? undefined
+ : undefined
+ }
+ alt={senderDisplayName}
+ renderFallback={() => <Icon size="200" src={Icons.User} filled />}
+ />
+ </Avatar>
+ </AvatarBase>
+ );
+
+ const msgContentJSX = (
+ <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
+ {reply}
+ {edit && onEditId ? (
+ <MessageEditor
+ style={{
+ maxWidth: '100%',
+ width: '100vw',
+ }}
+ roomId={room.roomId}
+ room={room}
+ mEvent={mEvent}
+ imagePackRooms={imagePackRooms}
+ onCancel={() => onEditId()}
+ />
+ ) : (
+ children
+ )}
+ {reactions}
+ </Box>
+ );
+
+ const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
+ if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
+ const tag = (evt.target as any).tagName;
+ if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+ evt.preventDefault();
+ setMenuAnchor({
+ x: evt.clientX,
+ y: evt.clientY,
+ width: 0,
+ height: 0,
+ });
+ };
+
+ 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)}
+ tabIndex={0}
+ space={messageSpacing}
+ collapse={collapse}
+ highlight={highlight}
+ selected={!!menuAnchor || !!emojiBoardAnchor}
+ {...props}
+ {...hoverProps}
+ {...focusWithinProps}
+ 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',
+ }}
+ >
+ <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>
+ )}
+ <MessageReadReceiptItem
+ room={room}
+ eventId={mEvent.getId() ?? ''}
+ onClose={closeMenu}
+ />
+ <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
+ <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
+ </Box>
+ {((!mEvent.isRedacted() && canDelete) ||
+ mEvent.getSender() !== mx.getUserId()) && (
+ <>
+ <Line size="300" />
+ <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+ {!mEvent.isRedacted() && canDelete && (
+ <MessageDeleteItem
+ room={room}
+ mEvent={mEvent}
+ onClose={closeMenu}
+ />
+ )}
+ {mEvent.getSender() !== mx.getUserId() && (
+ <MessageReportItem
+ room={room}
+ mEvent={mEvent}
+ onClose={closeMenu}
+ />
+ )}
+ </Box>
+ </>
+ )}
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <IconButton
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ onClick={handleOpenMenu}
+ aria-pressed={!!menuAnchor}
+ >
+ <Icon src={Icons.VerticalDots} size="100" />
+ </IconButton>
+ </PopOut>
+ </Box>
+ </Menu>
+ </div>
+ )}
+ {messageLayout === 1 && (
+ <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
+ {msgContentJSX}
+ </CompactLayout>
+ )}
+ {messageLayout === 2 && (
+ <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
+ {headerJSX}
+ {msgContentJSX}
+ </BubbleLayout>
+ )}
+ {messageLayout !== 1 && messageLayout !== 2 && (
+ <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
+ {headerJSX}
+ {msgContentJSX}
+ </ModernLayout>
+ )}
+ </MessageBase>
+ );
+ }
+);
+
+export type EventProps = {
+ room: Room;
+ mEvent: MatrixEvent;
+ highlight: boolean;
+ canDelete?: boolean;
+ messageSpacing: MessageSpacing;
+};
+export const Event = as<'div', EventProps>(
+ ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [hover, setHover] = useState(false);
+ const { hoverProps } = useHover({ onHoverChange: setHover });
+ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+ const stateEvent = typeof mEvent.getStateKey() === 'string';
+
+ const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
+ if (evt.altKey || !window.getSelection()?.isCollapsed) return;
+ const tag = (evt.target as any).tagName;
+ if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+ evt.preventDefault();
+ setMenuAnchor({
+ x: evt.clientX,
+ y: evt.clientY,
+ width: 0,
+ height: 0,
+ });
+ };
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
+ setMenuAnchor(target.getBoundingClientRect());
+ };
+
+ const closeMenu = () => {
+ setMenuAnchor(undefined);
+ };
+
+ return (
+ <MessageBase
+ className={classNames(css.MessageBase, className)}
+ tabIndex={0}
+ space={messageSpacing}
+ autoCollapse
+ highlight={highlight}
+ selected={!!menuAnchor}
+ {...props}
+ {...hoverProps}
+ {...focusWithinProps}
+ ref={ref}
+ >
+ {(hover || !!menuAnchor) && (
+ <div className={css.MessageOptionsBase}>
+ <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
+ <Box gap="100">
+ <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',
+ }}
+ >
+ <Menu {...props} ref={ref}>
+ <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+ <MessageReadReceiptItem
+ room={room}
+ eventId={mEvent.getId() ?? ''}
+ onClose={closeMenu}
+ />
+ <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
+ <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
+ </Box>
+ {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
+ (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
+ <>
+ <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>
+ )}
+ <div onContextMenu={handleContextMenu}>{children}</div>
+ </MessageBase>
+ );
+ }
+);
--- /dev/null
+import React, {
+ KeyboardEventHandler,
+ MouseEventHandler,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import {
+ Box,
+ Chip,
+ Icon,
+ IconButton,
+ Icons,
+ Line,
+ PopOut,
+ RectCords,
+ Spinner,
+ Text,
+ as,
+ config,
+} from 'folds';
+import { Editor, Transforms } from 'slate';
+import { ReactEditor } from 'slate-react';
+import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
+import { isKeyHotkey } from 'is-hotkey';
+import {
+ AUTOCOMPLETE_PREFIXES,
+ AutocompletePrefix,
+ AutocompleteQuery,
+ CustomEditor,
+ EmoticonAutocomplete,
+ RoomMentionAutocomplete,
+ Toolbar,
+ UserMentionAutocomplete,
+ createEmoticonElement,
+ customHtmlEqualsPlainText,
+ getAutocompleteQuery,
+ getPrevWorldRange,
+ htmlToEditorInput,
+ moveCursor,
+ plainToEditorInput,
+ toMatrixCustomHTML,
+ toPlainText,
+ trimCustomHtml,
+ useEditor,
+} from '../../../components/editor';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
+import { mobileOrTablet } from '../../../utils/user-agent';
+
+type MessageEditorProps = {
+ roomId: string;
+ room: Room;
+ mEvent: MatrixEvent;
+ imagePackRooms?: Room[];
+ onCancel: () => void;
+};
+export const MessageEditor = as<'div', MessageEditorProps>(
+ ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const editor = useEditor();
+ const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
+ const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
+ const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+ const [toolbar, setToolbar] = useState(globalToolbar);
+
+ const [autocompleteQuery, setAutocompleteQuery] =
+ useState<AutocompleteQuery<AutocompletePrefix>>();
+
+ const getPrevBodyAndFormattedBody = useCallback((): [
+ string | undefined,
+ string | undefined
+ ] => {
+ const evtId = mEvent.getId()!;
+ const evtTimeline = room.getTimelineForEvent(evtId);
+ const editedEvent =
+ evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
+
+ const { body, formatted_body: customHtml }: Record<string, unknown> =
+ editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
+
+ return [
+ typeof body === 'string' ? body : undefined,
+ typeof customHtml === 'string' ? customHtml : undefined,
+ ];
+ }, [room, mEvent]);
+
+ const [saveState, save] = useAsyncCallback(
+ useCallback(async () => {
+ const plainText = toPlainText(editor.children).trim();
+ const customHtml = trimCustomHtml(
+ toMatrixCustomHTML(editor.children, {
+ allowTextFormatting: true,
+ allowBlockMarkdown: isMarkdown,
+ allowInlineMarkdown: isMarkdown,
+ })
+ );
+
+ const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
+
+ if (plainText === '') return undefined;
+ if (prevBody) {
+ if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
+ return undefined;
+ }
+ if (
+ !prevCustomHtml &&
+ prevBody === plainText &&
+ customHtmlEqualsPlainText(customHtml, plainText)
+ ) {
+ return undefined;
+ }
+ }
+
+ const newContent: IContent = {
+ msgtype: mEvent.getContent().msgtype,
+ body: plainText,
+ };
+
+ if (!customHtmlEqualsPlainText(customHtml, plainText)) {
+ newContent.format = 'org.matrix.custom.html';
+ newContent.formatted_body = customHtml;
+ }
+
+ const content: IContent = {
+ ...newContent,
+ body: `* ${plainText}`,
+ 'm.new_content': newContent,
+ 'm.relates_to': {
+ event_id: mEvent.getId(),
+ rel_type: RelationType.Replace,
+ },
+ };
+
+ return mx.sendMessage(roomId, content);
+ }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
+ );
+
+ const handleSave = useCallback(() => {
+ if (saveState.status !== AsyncStatus.Loading) {
+ save();
+ }
+ }, [saveState, save]);
+
+ const handleKeyDown: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
+ evt.preventDefault();
+ handleSave();
+ }
+ if (isKeyHotkey('escape', evt)) {
+ evt.preventDefault();
+ onCancel();
+ }
+ },
+ [onCancel, handleSave, enterForNewline]
+ );
+
+ const handleKeyUp: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isKeyHotkey('escape', evt)) {
+ evt.preventDefault();
+ return;
+ }
+
+ const prevWordRange = getPrevWorldRange(editor);
+ const query = prevWordRange
+ ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
+ : undefined;
+ setAutocompleteQuery(query);
+ },
+ [editor]
+ );
+
+ const handleCloseAutocomplete = useCallback(() => {
+ ReactEditor.focus(editor);
+ setAutocompleteQuery(undefined);
+ }, [editor]);
+
+ const handleEmoticonSelect = (key: string, shortcode: string) => {
+ editor.insertNode(createEmoticonElement(key, shortcode));
+ moveCursor(editor);
+ };
+
+ useEffect(() => {
+ const [body, customHtml] = getPrevBodyAndFormattedBody();
+
+ const initialValue =
+ typeof customHtml === 'string'
+ ? htmlToEditorInput(customHtml)
+ : plainToEditorInput(typeof body === 'string' ? body : '');
+
+ Transforms.select(editor, {
+ anchor: Editor.start(editor, []),
+ focus: Editor.end(editor, []),
+ });
+
+ editor.insertFragment(initialValue);
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
+ }, [editor, getPrevBodyAndFormattedBody]);
+
+ useEffect(() => {
+ if (saveState.status === AsyncStatus.Success) {
+ onCancel();
+ }
+ }, [saveState, onCancel]);
+
+ return (
+ <div {...props} ref={ref}>
+ {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
+ <RoomMentionAutocomplete
+ roomId={roomId}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
+ <UserMentionAutocomplete
+ room={room}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
+ <EmoticonAutocomplete
+ imagePackRooms={imagePackRooms || []}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ <CustomEditor
+ editor={editor}
+ placeholder="Edit message..."
+ onKeyDown={handleKeyDown}
+ onKeyUp={handleKeyUp}
+ bottom={
+ <>
+ <Box
+ style={{ padding: config.space.S200, paddingTop: 0 }}
+ alignItems="End"
+ justifyContent="SpaceBetween"
+ gap="100"
+ >
+ <Box gap="Inherit">
+ <Chip
+ onClick={handleSave}
+ variant="Primary"
+ radii="Pill"
+ disabled={saveState.status === AsyncStatus.Loading}
+ outlined
+ before={
+ saveState.status === AsyncStatus.Loading ? (
+ <Spinner variant="Primary" fill="Soft" size="100" />
+ ) : undefined
+ }
+ >
+ <Text size="B300">Save</Text>
+ </Chip>
+ <Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
+ <Text size="B300">Cancel</Text>
+ </Chip>
+ </Box>
+ <Box gap="Inherit">
+ <IconButton
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ onClick={() => setToolbar(!toolbar)}
+ >
+ <Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
+ </IconButton>
+ <UseStateProvider initial={undefined}>
+ {(anchor: RectCords | undefined, setAnchor) => (
+ <PopOut
+ anchor={anchor}
+ alignOffset={-8}
+ position="Top"
+ align="End"
+ content={
+ <EmojiBoard
+ imagePackRooms={imagePackRooms ?? []}
+ returnFocusOnDeactivate={false}
+ onEmojiSelect={handleEmoticonSelect}
+ onCustomEmojiSelect={handleEmoticonSelect}
+ requestClose={() => {
+ setAnchor(undefined);
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
+ }}
+ />
+ }
+ >
+ <IconButton
+ aria-pressed={anchor !== undefined}
+ onClick={
+ ((evt) =>
+ setAnchor(
+ evt.currentTarget.getBoundingClientRect()
+ )) as MouseEventHandler<HTMLButtonElement>
+ }
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
+ </IconButton>
+ </PopOut>
+ )}
+ </UseStateProvider>
+ </Box>
+ </Box>
+ {toolbar && (
+ <div>
+ <Line variant="SurfaceVariant" size="300" />
+ <Toolbar />
+ </div>
+ )}
+ </>
+ }
+ />
+ </div>
+ );
+ }
+);
--- /dev/null
+import React, { MouseEventHandler, useCallback, useState } from 'react';
+import {
+ Box,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+ toRem,
+} from 'folds';
+import classNames from 'classnames';
+import { Room } from 'matrix-js-sdk';
+import { type Relations } from 'matrix-js-sdk/lib/models/relations';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { factoryEventSentBy } from '../../../utils/matrix';
+import { Reaction, ReactionTooltipMsg } from '../../../components/message';
+import { useRelations } from '../../../hooks/useRelations';
+import * as css from './styles.css';
+import { ReactionViewer } from '../reaction-viewer';
+
+export type ReactionsProps = {
+ room: Room;
+ mEventId: string;
+ canSendReaction?: boolean;
+ relations: Relations;
+ onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+};
+export const Reactions = as<'div', ReactionsProps>(
+ ({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [viewer, setViewer] = useState<boolean | string>(false);
+ const myUserId = mx.getUserId();
+ const reactions = useRelations(
+ relations,
+ useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
+ );
+
+ const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ evt.stopPropagation();
+ evt.preventDefault();
+ const key = evt.currentTarget.getAttribute('data-reaction-key');
+ if (!key) setViewer(true);
+ else setViewer(key);
+ };
+
+ return (
+ <Box
+ className={classNames(css.ReactionsContainer, className)}
+ gap="200"
+ wrap="Wrap"
+ {...props}
+ ref={ref}
+ >
+ {reactions.map(([key, events]) => {
+ const rEvents = Array.from(events);
+ if (rEvents.length === 0 || typeof key !== 'string') return null;
+ const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
+ const isPressed = !!myREvent?.getRelation();
+
+ return (
+ <TooltipProvider
+ key={key}
+ position="Top"
+ tooltip={
+ <Tooltip style={{ maxWidth: toRem(200) }}>
+ <Text className={css.ReactionsTooltipText} size="T300">
+ <ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
+ </Text>
+ </Tooltip>
+ }
+ >
+ {(targetRef) => (
+ <Reaction
+ ref={targetRef}
+ data-reaction-key={key}
+ aria-pressed={isPressed}
+ key={key}
+ mx={mx}
+ reaction={key}
+ count={events.size}
+ onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
+ onContextMenu={handleViewReaction}
+ aria-disabled={!canSendReaction}
+ />
+ )}
+ </TooltipProvider>
+ );
+ })}
+ {reactions.length > 0 && (
+ <Overlay
+ onContextMenu={(evt: any) => {
+ evt.stopPropagation();
+ }}
+ open={!!viewer}
+ backdrop={<OverlayBackdrop />}
+ >
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setViewer(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="300">
+ <ReactionViewer
+ room={room}
+ initialKey={typeof viewer === 'string' ? viewer : undefined}
+ relations={relations}
+ requestClose={() => setViewer(false)}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './Reactions';
+export * from './Message';
+export * from './EncryptedContent';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config, toRem } from 'folds';
+
+export const MessageBase = style({
+ position: 'relative',
+});
+
+export const MessageOptionsBase = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ top: toRem(-30),
+ right: 0,
+ zIndex: 1,
+ },
+]);
+export const MessageOptionsBar = style([
+ DefaultReset,
+ {
+ padding: config.space.S100,
+ },
+]);
+
+export const MessageAvatar = style({
+ cursor: 'pointer',
+});
+
+export const MessageQuickReaction = style({
+ minWidth: toRem(32),
+});
+
+export const MessageMenuGroup = style({
+ padding: config.space.S100,
+});
+
+export const MessageMenuItemText = style({
+ flexGrow: 1,
+});
+
+export const ReactionsContainer = style({
+ selectors: {
+ '&:empty': {
+ display: 'none',
+ },
+ },
+});
+
+export const ReactionsTooltipText = style({
+ wordBreak: 'break-word',
+});
--- /dev/null
+import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
+import to from 'await-to-js';
+import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
+import {
+ getImageFileUrl,
+ getThumbnail,
+ getThumbnailDimensions,
+ getVideoFileUrl,
+ loadImageElement,
+ loadVideoElement,
+} from '../../utils/dom';
+import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix';
+import { TUploadItem } from '../../state/room/roomInputDrafts';
+import { encodeBlurHash } from '../../utils/blurHash';
+import { scaleYDimension } from '../../utils/common';
+
+const generateThumbnailContent = async (
+ mx: MatrixClient,
+ img: HTMLImageElement | HTMLVideoElement,
+ dimensions: [number, number],
+ encrypt: boolean
+): Promise<IThumbnailContent> => {
+ const thumbnail = await getThumbnail(img, ...dimensions);
+ if (!thumbnail) throw new Error('Can not create thumbnail!');
+ const encThumbData = encrypt ? await encryptFile(thumbnail) : undefined;
+ const thumbnailFile = encThumbData?.file ?? thumbnail;
+ if (!thumbnailFile) throw new Error('Can not create thumbnail!');
+
+ const data = await mx.uploadContent(thumbnailFile);
+ const thumbMxc = data?.content_uri;
+ if (!thumbMxc) throw new Error('Failed when uploading thumbnail!');
+ const thumbnailContent = getThumbnailContent({
+ thumbnail: thumbnailFile,
+ encInfo: encThumbData?.encInfo,
+ mxc: thumbMxc,
+ width: dimensions[0],
+ height: dimensions[1],
+ });
+ return thumbnailContent;
+};
+
+export const getImageMsgContent = async (
+ mx: MatrixClient,
+ item: TUploadItem,
+ mxc: string
+): Promise<IContent> => {
+ const { file, originalFile, encInfo } = item;
+ const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
+ if (imgError) console.warn(imgError);
+
+ const content: IContent = {
+ msgtype: MsgType.Image,
+ body: file.name,
+ };
+ if (imgEl) {
+ const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
+
+ content.info = {
+ ...getImageInfo(imgEl, file),
+ [MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
+ };
+ }
+ if (encInfo) {
+ content.file = {
+ ...encInfo,
+ url: mxc,
+ };
+ } else {
+ content.url = mxc;
+ }
+ return content;
+};
+
+export const getVideoMsgContent = async (
+ mx: MatrixClient,
+ item: TUploadItem,
+ mxc: string
+): Promise<IContent> => {
+ const { file, originalFile, encInfo } = item;
+
+ const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
+ if (videoError) console.warn(videoError);
+
+ const content: IContent = {
+ msgtype: MsgType.Video,
+ body: file.name,
+ };
+ if (videoEl) {
+ const [thumbError, thumbContent] = await to(
+ generateThumbnailContent(
+ mx,
+ videoEl,
+ getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight),
+ !!encInfo
+ )
+ );
+ if (thumbContent && thumbContent.thumbnail_info) {
+ thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = encodeBlurHash(
+ videoEl,
+ 512,
+ scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight)
+ );
+ }
+ if (thumbError) console.warn(thumbError);
+ content.info = {
+ ...getVideoInfo(videoEl, file),
+ ...thumbContent,
+ };
+ }
+ if (encInfo) {
+ content.file = {
+ ...encInfo,
+ url: mxc,
+ };
+ } else {
+ content.url = mxc;
+ }
+ return content;
+};
+
+export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => {
+ const { file, encInfo } = item;
+ const content: IContent = {
+ msgtype: MsgType.Audio,
+ body: file.name,
+ info: {
+ mimetype: file.type,
+ size: file.size,
+ },
+ };
+ if (encInfo) {
+ content.file = {
+ ...encInfo,
+ url: mxc,
+ };
+ } else {
+ content.url = mxc;
+ }
+ return content;
+};
+
+export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => {
+ const { file, encInfo } = item;
+ const content: IContent = {
+ msgtype: MsgType.File,
+ body: file.name,
+ filename: file.name,
+ info: {
+ mimetype: file.type,
+ size: file.size,
+ },
+ };
+ if (encInfo) {
+ content.file = {
+ ...encInfo,
+ url: mxc,
+ };
+ } else {
+ content.url = mxc;
+ }
+ return content;
+};
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const ReactionViewer = style([
+ DefaultReset,
+ {
+ height: '100%',
+ },
+]);
+
+export const Sidebar = style({
+ backgroundColor: color.Background.Container,
+ color: color.Background.OnContainer,
+});
+export const SidebarContent = style({
+ padding: config.space.S200,
+ paddingRight: 0,
+});
+
+export const Header = style({
+ paddingLeft: config.space.S400,
+ paddingRight: config.space.S300,
+
+ flexShrink: 0,
+ gap: config.space.S200,
+});
+
+export const Content = style({
+ paddingLeft: config.space.S200,
+ paddingBottom: config.space.S400,
+});
--- /dev/null
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
+import {
+ Avatar,
+ Box,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Line,
+ MenuItem,
+ Scroll,
+ Text,
+ as,
+ config,
+} from 'folds';
+import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import { getMemberDisplayName } from '../../../utils/room';
+import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
+import * as css from './ReactionViewer.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { openProfileViewer } from '../../../../client/action/navigation';
+import { useRelations } from '../../../hooks/useRelations';
+import { Reaction } from '../../../components/message';
+import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
+import { UserAvatar } from '../../../components/user-avatar';
+
+export type ReactionViewerProps = {
+ room: Room;
+ initialKey?: string;
+ relations: Relations;
+ requestClose: () => void;
+};
+export const ReactionViewer = as<'div', ReactionViewerProps>(
+ ({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const reactions = useRelations(
+ relations,
+ useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
+ );
+
+ const [selectedKey, setSelectedKey] = useState<string>(() => {
+ if (initialKey) return initialKey;
+ const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
+ return defaultReaction ? defaultReaction[0] : '';
+ });
+
+ const getName = (member: RoomMember) =>
+ getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+ const getReactionsForKey = (key: string): MatrixEvent[] => {
+ const reactSet = reactions.find(([k]) => k === key)?.[1];
+ if (!reactSet) return [];
+ return Array.from(reactSet);
+ };
+
+ const selectedReactions = getReactionsForKey(selectedKey);
+ const selectedShortcode =
+ selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
+ getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
+ selectedKey;
+
+ return (
+ <Box
+ className={classNames(css.ReactionViewer, className)}
+ direction="Row"
+ {...props}
+ ref={ref}
+ >
+ <Box shrink="No" className={css.Sidebar}>
+ <Scroll visibility="Hover" hideTrack size="300">
+ <Box className={css.SidebarContent} direction="Column" gap="200">
+ {reactions.map(([key, evts]) => {
+ if (typeof key !== 'string') return null;
+ return (
+ <Reaction
+ key={key}
+ mx={mx}
+ reaction={key}
+ count={evts.size}
+ aria-selected={key === selectedKey}
+ onClick={() => setSelectedKey(key)}
+ />
+ );
+ })}
+ </Box>
+ </Scroll>
+ </Box>
+ <Line variant="Surface" direction="Vertical" size="300" />
+ <Box grow="Yes" direction="Column">
+ <Header className={css.Header} variant="Surface" size="600">
+ <Box grow="Yes">
+ <Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
+ </Box>
+ <IconButton size="300" onClick={requestClose}>
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+
+ <Box grow="Yes">
+ <Scroll visibility="Hover" hideTrack size="300">
+ <Box className={css.Content} direction="Column">
+ {selectedReactions.map((mEvent) => {
+ const senderId = mEvent.getSender();
+ if (!senderId) return null;
+ const member = room.getMember(senderId);
+ const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
+
+ const avatarUrl = member?.getAvatarUrl(
+ mx.baseUrl,
+ 100,
+ 100,
+ 'crop',
+ undefined,
+ false
+ );
+
+ return (
+ <MenuItem
+ key={senderId}
+ style={{ padding: `0 ${config.space.S200}` }}
+ radii="400"
+ onClick={() => {
+ requestClose();
+ openProfileViewer(senderId, room.roomId);
+ }}
+ before={
+ <Avatar size="200">
+ <UserAvatar
+ userId={senderId}
+ src={avatarUrl ?? undefined}
+ alt={name}
+ renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+ />
+ </Avatar>
+ }
+ >
+ <Box grow="Yes">
+ <Text size="T400" truncate>
+ {name}
+ </Text>
+ </Box>
+ </MenuItem>
+ );
+ })}
+ </Box>
+ </Scroll>
+ </Box>
+ </Box>
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './ReactionViewer';
--- /dev/null
+import { useMatch } from 'react-router-dom';
+import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
+
+export const useDirectSelected = (): boolean => {
+ const directMatch = useMatch({
+ path: getDirectPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!directMatch;
+};
+
+export const useDirectCreateSelected = (): boolean => {
+ const match = useMatch({
+ path: getDirectCreatePath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
--- /dev/null
+import { useMatch, useParams } from 'react-router-dom';
+import { getExploreFeaturedPath, getExplorePath } from '../../pages/pathUtils';
+
+export const useExploreSelected = (): boolean => {
+ const match = useMatch({
+ path: getExplorePath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
+
+export const useExploreFeaturedSelected = (): boolean => {
+ const match = useMatch({
+ path: getExploreFeaturedPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
+
+export const useExploreServer = (): string | undefined => {
+ const { server } = useParams();
+
+ return server;
+};
--- /dev/null
+import { useMatch } from 'react-router-dom';
+import {
+ getHomeCreatePath,
+ getHomeJoinPath,
+ getHomePath,
+ getHomeSearchPath,
+} from '../../pages/pathUtils';
+
+export const useHomeSelected = (): boolean => {
+ const homeMatch = useMatch({
+ path: getHomePath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!homeMatch;
+};
+
+export const useHomeCreateSelected = (): boolean => {
+ const match = useMatch({
+ path: getHomeCreatePath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
+
+export const useHomeJoinSelected = (): boolean => {
+ const match = useMatch({
+ path: getHomeJoinPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
+
+export const useHomeSearchSelected = (): boolean => {
+ const match = useMatch({
+ path: getHomeSearchPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
--- /dev/null
+import { useMatch } from 'react-router-dom';
+import {
+ getInboxInvitesPath,
+ getInboxNotificationsPath,
+ getInboxPath,
+} from '../../pages/pathUtils';
+
+export const useInboxSelected = (): boolean => {
+ const match = useMatch({
+ path: getInboxPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
+
+export const useInboxNotificationsSelected = (): boolean => {
+ const match = useMatch({
+ path: getInboxNotificationsPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
+
+export const useInboxInvitesSelected = (): boolean => {
+ const match = useMatch({
+ path: getInboxInvitesPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
--- /dev/null
+import { useParams } from 'react-router-dom';
+import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
+import { useMatrixClient } from '../useMatrixClient';
+
+export const useSelectedRoom = (): string | undefined => {
+ const mx = useMatrixClient();
+
+ const { roomIdOrAlias } = useParams();
+ const roomId =
+ roomIdOrAlias && isRoomAlias(roomIdOrAlias)
+ ? getCanonicalAliasRoomId(mx, roomIdOrAlias)
+ : roomIdOrAlias;
+
+ return roomId;
+};
--- /dev/null
+import { useMatch, useParams } from 'react-router-dom';
+import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
+import { useMatrixClient } from '../useMatrixClient';
+import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils';
+
+export const useSelectedSpace = (): string | undefined => {
+ const mx = useMatrixClient();
+
+ const { spaceIdOrAlias } = useParams();
+
+ const spaceId =
+ spaceIdOrAlias && isRoomAlias(spaceIdOrAlias)
+ ? getCanonicalAliasRoomId(mx, spaceIdOrAlias)
+ : spaceIdOrAlias;
+
+ return spaceId;
+};
+
+export const useSpaceLobbySelected = (spaceIdOrAlias: string): boolean => {
+ const match = useMatch({
+ path: getSpaceLobbyPath(spaceIdOrAlias),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
+
+export const useSpaceSearchSelected = (spaceIdOrAlias: string): boolean => {
+ const match = useMatch({
+ path: getSpaceSearchPath(spaceIdOrAlias),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
--- /dev/null
+import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+
+export const useAccountDataCallback = (
+ mx: MatrixClient,
+ onAccountData: ClientEventHandlerMap[ClientEvent.AccountData]
+) => {
+ useEffect(() => {
+ mx.on(ClientEvent.AccountData, onAccountData);
+ return () => {
+ mx.removeListener(ClientEvent.AccountData, onAccountData);
+ };
+ }, [mx, onAccountData]);
+};
throw new Error('AsyncCallbackHook: Request replaced!');
}
if (alive()) {
- setState({
- status: AsyncStatus.Success,
- data,
+ queueMicrotask(() => {
+ setState({
+ status: AsyncStatus.Success,
+ data,
+ });
});
}
return data;
if (currentReqNumber !== reqNumberRef.current) {
throw new Error('AsyncCallbackHook: Request replaced!');
}
+
if (alive()) {
- setState({
- status: AsyncStatus.Error,
- error: e as TError,
+ queueMicrotask(() => {
+ setState({
+ status: AsyncStatus.Error,
+ error: e as TError,
+ });
});
}
throw e;
--- /dev/null
+import { Capabilities } from 'matrix-js-sdk';
+import { createContext, useContext } from 'react';
+
+const CapabilitiesContext = createContext<Capabilities | null>(null);
+
+export const CapabilitiesProvider = CapabilitiesContext.Provider;
+
+export function useCapabilities(): Capabilities {
+ const capabilities = useContext(CapabilitiesContext);
+ if (!capabilities) throw new Error('Capabilities are not provided!');
+ return capabilities;
+}
--- /dev/null
+import { MouseEventHandler } from 'react';
+
+type CategoryAction =
+ | {
+ type: 'PUT';
+ categoryId: string;
+ }
+ | {
+ type: 'DELETE';
+ categoryId: string;
+ };
+export const useCategoryHandler = (
+ setAtom: (action: CategoryAction) => void,
+ closed: (categoryId: string) => boolean
+) => {
+ const handleCategoryClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const categoryId = evt.currentTarget.getAttribute('data-category-id');
+ if (!categoryId) return;
+ if (closed(categoryId)) {
+ setAtom({ type: 'DELETE', categoryId });
+ return;
+ }
+ setAtom({ type: 'PUT', categoryId });
+ };
+
+ return handleCategoryClick;
+};
import { createContext, useContext } from 'react';
+export type HashRouterConfig = {
+ enabled?: boolean;
+ basename?: string;
+};
+
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
allowCustomHomeservers?: boolean;
- hashRouter?: {
- enabled?: boolean;
- basename?: string;
+ featuredCommunities?: {
+ openAsDefault?: boolean;
+ spaces?: string[];
+ rooms?: string[];
+ servers?: string[];
};
+
+ hashRouter?: HashRouterConfig;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
-import { hasDMWith, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
-import { selectRoom } from '../../client/action/navigation';
+import { getDMRoomFor, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
import { hasDevices } from '../../util/matrixUtil';
import * as roomActions from '../../client/action/room';
+import { useRoomNavigate } from './useRoomNavigate';
export const SHRUG = '¯\\_(ツ)_/¯';
export type CommandRecord = Record<Command, CommandContent>;
export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
+ const { navigateRoom } = useRoomNavigate();
+
const commands: CommandRecord = useMemo(
() => ({
[Command.Me]: {
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
if (userIds.length === 0) return;
if (userIds.length === 1) {
- const dmRoomId = hasDMWith(mx, userIds[0]);
+ const dmRoomId = getDMRoomFor(mx, userIds[0])?.roomId;
if (dmRoomId) {
- selectRoom(dmRoomId);
+ navigateRoom(dmRoomId);
return;
}
}
const devices = await Promise.all(userIds.map(hasDevices));
const isEncrypt = devices.every((hasDevice) => hasDevice);
const result = await roomActions.createDM(userIds, isEncrypt);
- selectRoom(result.room_id);
+ navigateRoom(result.room_id);
},
},
[Command.Join]: {
},
},
}),
- [mx, room]
+ [mx, room, navigateRoom]
);
return commands;
--- /dev/null
+import { useCallback } from 'react';
+import { getResizeObserverEntry, useResizeObserver } from './useResizeObserver';
+
+export const useElementSizeObserver = <T extends Element>(
+ element: () => T | null,
+ onResize: (width: number, height: number, element: T) => void
+) => {
+ useResizeObserver(
+ useCallback(
+ (entries) => {
+ const target = element();
+ if (!target) return;
+ const targetEntry = getResizeObserverEntry(target, entries);
+ if (targetEntry) {
+ const { clientWidth, clientHeight } = targetEntry.target;
+ onResize(clientWidth, clientHeight, target);
+ }
+ },
+ [element, onResize]
+ ),
+ element
+ );
+};
--- /dev/null
+import { useEffect, useMemo } from 'react';
+
+export type IntervalCallback = () => void;
+
+/**
+ * @param callback interval callback.
+ * @param ms interval time in milliseconds. negative value will stop the interval.
+ * @returns interval id or undefined if not running.
+ */
+export const useInterval = (callback: IntervalCallback, ms: number): number | undefined => {
+ const id = useMemo(() => {
+ if (ms < 0) return undefined;
+ return window.setInterval(callback, ms);
+ }, [callback, ms]);
+
+ useEffect(
+ () => () => {
+ window.clearInterval(id);
+ },
+ [id]
+ );
+
+ return id;
+};
--- /dev/null
+import { useMemo } from 'react';
+import { useMatrixClient } from './useMatrixClient';
+
+import { getCanonicalAliasRoomId, isRoomAlias } from '../utils/matrix';
+
+export const useJoinedRoomId = (allRooms: string[], roomIdOrAlias: string): string | undefined => {
+ const mx = useMatrixClient();
+
+ const joinedRoomId = useMemo(() => {
+ const roomId = isRoomAlias(roomIdOrAlias)
+ ? getCanonicalAliasRoomId(mx, roomIdOrAlias)
+ : roomIdOrAlias;
+
+ if (roomId && allRooms.includes(roomId)) return roomId;
+ return undefined;
+ }, [mx, allRooms, roomIdOrAlias]);
+
+ return joinedRoomId;
+};
--- /dev/null
+import { GuestAccess, HistoryVisibility, JoinRule, Room } from 'matrix-js-sdk';
+import { getStateEvent } from '../utils/room';
+import { StateEvent } from '../../types/matrix/room';
+
+export type LocalRoomSummary = {
+ roomId: string;
+ name: string;
+ topic?: string;
+ avatarUrl?: string;
+ canonicalAlias?: string;
+ worldReadable?: boolean;
+ guestCanJoin?: boolean;
+ memberCount?: number;
+ roomType?: string;
+ joinRule?: JoinRule;
+};
+export const useLocalRoomSummary = (room: Room): LocalRoomSummary => {
+ const topicEvent = getStateEvent(room, StateEvent.RoomTopic);
+ const topicContent = topicEvent?.getContent();
+ const topic =
+ topicContent && typeof topicContent.topic === 'string' ? topicContent.topic : undefined;
+
+ const historyEvent = getStateEvent(room, StateEvent.RoomHistoryVisibility);
+ const historyContent = historyEvent?.getContent();
+ const worldReadable =
+ historyContent && typeof historyContent.history_visibility === 'string'
+ ? historyContent.history_visibility === HistoryVisibility.WorldReadable
+ : undefined;
+
+ const guestCanJoin = room.getGuestAccess() === GuestAccess.CanJoin;
+
+ return {
+ roomId: room.roomId,
+ name: room.name,
+ topic,
+ avatarUrl: room.getMxcAvatarUrl() ?? undefined,
+ canonicalAlias: room.getCanonicalAlias() ?? undefined,
+ worldReadable,
+ guestCanJoin,
+ memberCount: room.getJoinedMemberCount(),
+ roomType: room.getType(),
+ joinRule: room.getJoinRule(),
+ };
+};
import { ReactNode } from 'react';
-import { MatrixEvent } from 'matrix-js-sdk';
-import { MessageEvent, StateEvent } from '../../types/matrix/room';
-export type EventRenderer<T extends unknown[]> = (
- eventId: string,
- mEvent: MatrixEvent,
- ...args: T
-) => ReactNode;
+export type EventRenderer<T extends unknown[]> = (...args: T) => ReactNode;
-export type EventRendererOpts<T extends unknown[]> = {
- renderRoomMessage?: EventRenderer<T>;
- renderRoomEncrypted?: EventRenderer<T>;
- renderSticker?: EventRenderer<T>;
- renderRoomMember?: EventRenderer<T>;
- renderRoomName?: EventRenderer<T>;
- renderRoomTopic?: EventRenderer<T>;
- renderRoomAvatar?: EventRenderer<T>;
- renderStateEvent?: EventRenderer<T>;
- renderEvent?: EventRenderer<T>;
-};
+export type EventRendererOpts<T extends unknown[]> = Record<string, EventRenderer<T>>;
export type RenderMatrixEvent<T extends unknown[]> = (
- eventId: string,
- mEvent: MatrixEvent,
+ eventType: string,
+ isStateEvent: boolean,
...args: T
) => ReactNode;
export const useMatrixEventRenderer =
- <T extends unknown[]>({
- renderRoomMessage,
- renderRoomEncrypted,
- renderSticker,
- renderRoomMember,
- renderRoomName,
- renderRoomTopic,
- renderRoomAvatar,
- renderStateEvent,
- renderEvent,
- }: EventRendererOpts<T>): RenderMatrixEvent<T> =>
- (eventId, mEvent, ...args) => {
- const eventType = mEvent.getWireType();
-
- if (eventType === MessageEvent.RoomMessage && renderRoomMessage) {
- return renderRoomMessage(eventId, mEvent, ...args);
- }
-
- if (eventType === MessageEvent.RoomMessageEncrypted && renderRoomEncrypted) {
- return renderRoomEncrypted(eventId, mEvent, ...args);
- }
-
- if (eventType === MessageEvent.Sticker && renderSticker) {
- return renderSticker(eventId, mEvent, ...args);
- }
-
- if (eventType === StateEvent.RoomMember && renderRoomMember) {
- return renderRoomMember(eventId, mEvent, ...args);
- }
-
- if (eventType === StateEvent.RoomName && renderRoomName) {
- return renderRoomName(eventId, mEvent, ...args);
- }
-
- if (eventType === StateEvent.RoomTopic && renderRoomTopic) {
- return renderRoomTopic(eventId, mEvent, ...args);
- }
-
- if (eventType === StateEvent.RoomAvatar && renderRoomAvatar) {
- return renderRoomAvatar(eventId, mEvent, ...args);
- }
+ <T extends unknown[]>(
+ typeToRenderer: EventRendererOpts<T>,
+ renderStateEvent?: EventRenderer<T>,
+ renderEvent?: EventRenderer<T>
+ ): RenderMatrixEvent<T> =>
+ (eventType, isStateEvent, ...args) => {
+ const renderer = typeToRenderer[eventType];
+ if (typeToRenderer[eventType]) return renderer(...args);
- if (typeof mEvent.getStateKey() === 'string' && renderStateEvent) {
- return renderStateEvent(eventId, mEvent, ...args);
+ if (isStateEvent && renderStateEvent) {
+ return renderStateEvent(...args);
}
- if (typeof mEvent.getStateKey() !== 'string' && renderEvent) {
- return renderEvent(eventId, mEvent, ...args);
+ if (!isStateEvent && renderEvent) {
+ return renderEvent(...args);
}
return null;
};
--- /dev/null
+import { createContext, useContext } from 'react';
+
+export interface MediaConfig {
+ [key: string]: unknown;
+ 'm.upload.size'?: number;
+}
+
+const MediaConfigContext = createContext<MediaConfig | null>(null);
+
+export const MediaConfigProvider = MediaConfigContext.Provider;
+
+export function useMediaConfig(): MediaConfig {
+ const mediaConfig = useContext(MediaConfigContext);
+ if (!mediaConfig) throw new Error('Media configs are not provided!');
+ return mediaConfig;
+}
--- /dev/null
+import { useEffect } from 'react';
+import { useSetAtom } from 'jotai';
+import { useLocation } from 'react-router-dom';
+import { useNavToActivePathAtom } from '../state/hooks/navToActivePath';
+
+export const useNavToActivePathMapper = (navId: string) => {
+ const location = useLocation();
+ const setNavToActivePath = useSetAtom(useNavToActivePathAtom());
+
+ useEffect(() => {
+ const { pathname, search, hash } = location;
+ setNavToActivePath({
+ type: 'PUT',
+ navId,
+ path: { pathname, search, hash },
+ });
+ }, [location, setNavToActivePath, navId]);
+};
import { Room } from 'matrix-js-sdk';
-import { createContext, useCallback, useContext } from 'react';
+import { createContext, useCallback, useContext, useMemo } from 'react';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
+import { useForceUpdate } from './useForceUpdate';
+import { useStateEventCallback } from './useStateEventCallback';
+import { useMatrixClient } from './useMatrixClient';
+import { getStateEvent } from '../utils/room';
export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
historical = 0,
}
-interface IPowerLevels {
+export interface IPowerLevels {
users_default?: number;
state_default?: number;
events_default?: number;
notifications?: Record<string, number>;
}
-export type GetPowerLevel = (userId: string) => number;
-export type CanSend = (eventType: string | undefined, powerLevel: number) => boolean;
-export type CanDoAction = (action: PowerLevelActions, powerLevel: number) => boolean;
+export function usePowerLevels(room: Room): IPowerLevels {
+ const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
+ const powerLevels: IPowerLevels =
+ powerLevelsEvent?.getContent<IPowerLevels>() ?? DefaultPowerLevels;
+
+ return powerLevels;
+}
+
+export const PowerLevelsContext = createContext<IPowerLevels | null>(null);
+
+export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
+
+export const usePowerLevelsContext = (): IPowerLevels => {
+ const pl = useContext(PowerLevelsContext);
+ if (!pl) throw new Error('PowerLevelContext is not initialized!');
+ return pl;
+};
+
+export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
+ const mx = useMatrixClient();
+ const [updateCount, forceUpdate] = useForceUpdate();
+
+ useStateEventCallback(
+ mx,
+ useCallback(
+ (event) => {
+ const roomId = event.getRoomId();
+ if (
+ roomId &&
+ event.getType() === StateEvent.RoomPowerLevels &&
+ event.getStateKey() === '' &&
+ rooms.find((r) => r.roomId === roomId)
+ ) {
+ forceUpdate();
+ }
+ },
+ [rooms, forceUpdate]
+ )
+ );
+
+ const roomToPowerLevels = useMemo(
+ () => {
+ const rToPl = new Map<string, IPowerLevels>();
+
+ rooms.forEach((room) => {
+ const pl = getStateEvent(room, StateEvent.RoomPowerLevels, '')?.getContent<IPowerLevels>();
+ if (pl) rToPl.set(room.roomId, pl);
+ });
+
+ return rToPl;
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [rooms, updateCount]
+ );
+
+ return roomToPowerLevels;
+};
+
+export type GetPowerLevel = (powerLevels: IPowerLevels, userId: string | undefined) => number;
+export type CanSend = (
+ powerLevels: IPowerLevels,
+ eventType: string | undefined,
+ powerLevel: number
+) => boolean;
+export type CanDoAction = (
+ powerLevels: IPowerLevels,
+ action: PowerLevelActions,
+ powerLevel: number
+) => boolean;
export type PowerLevelsAPI = {
getPowerLevel: GetPowerLevel;
canDoAction: CanDoAction;
};
-export function usePowerLevels(room: Room): PowerLevelsAPI {
- const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
- const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels;
-
- const getPowerLevel: GetPowerLevel = useCallback(
- (userId) => {
- const { users_default: usersDefault, users } = powerLevels;
- if (users && typeof users[userId] === 'number') {
- return users[userId];
- }
- return usersDefault ?? DefaultPowerLevels.usersDefault;
- },
+export const powerLevelAPI: PowerLevelsAPI = {
+ getPowerLevel: (powerLevels, userId) => {
+ const { users_default: usersDefault, users } = powerLevels;
+ if (userId && users && typeof users[userId] === 'number') {
+ return users[userId];
+ }
+ return usersDefault ?? DefaultPowerLevels.usersDefault;
+ },
+ canSendEvent: (powerLevels, eventType, powerLevel) => {
+ const { events, events_default: eventsDefault } = powerLevels;
+ if (events && eventType && typeof events[eventType] === 'number') {
+ return powerLevel >= events[eventType];
+ }
+ return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
+ },
+ canSendStateEvent: (powerLevels, eventType, powerLevel) => {
+ const { events, state_default: stateDefault } = powerLevels;
+ if (events && eventType && typeof events[eventType] === 'number') {
+ return powerLevel >= events[eventType];
+ }
+ return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
+ },
+ canDoAction: (powerLevels, action, powerLevel) => {
+ const requiredPL = powerLevels[action];
+ if (typeof requiredPL === 'number') {
+ return powerLevel >= requiredPL;
+ }
+ return powerLevel >= DefaultPowerLevels[action];
+ },
+};
+
+export const usePowerLevelsAPI = (powerLevels: IPowerLevels) => {
+ const getPowerLevel = useCallback(
+ (userId: string | undefined) => powerLevelAPI.getPowerLevel(powerLevels, userId),
[powerLevels]
);
- const canSendEvent: CanSend = useCallback(
- (eventType, powerLevel) => {
- const { events, events_default: eventsDefault } = powerLevels;
- if (events && eventType && typeof events[eventType] === 'number') {
- return powerLevel >= events[eventType];
- }
- return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
- },
+ const canSendEvent = useCallback(
+ (eventType: string | undefined, powerLevel: number) =>
+ powerLevelAPI.canSendEvent(powerLevels, eventType, powerLevel),
[powerLevels]
);
- const canSendStateEvent: CanSend = useCallback(
- (eventType, powerLevel) => {
- const { events, state_default: stateDefault } = powerLevels;
- if (events && eventType && typeof events[eventType] === 'number') {
- return powerLevel >= events[eventType];
- }
- return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
- },
+ const canSendStateEvent = useCallback(
+ (eventType: string | undefined, powerLevel: number) =>
+ powerLevelAPI.canSendStateEvent(powerLevels, eventType, powerLevel),
[powerLevels]
);
- const canDoAction: CanDoAction = useCallback(
- (action, powerLevel) => {
- const requiredPL = powerLevels[action];
- if (typeof requiredPL === 'number') {
- return powerLevel >= requiredPL;
- }
- return powerLevel >= DefaultPowerLevels[action];
- },
+ const canDoAction = useCallback(
+ (action: PowerLevelActions, powerLevel: number) =>
+ powerLevelAPI.canDoAction(powerLevels, action, powerLevel),
[powerLevels]
);
canSendStateEvent,
canDoAction,
};
-}
-
-export const PowerLevelsContext = createContext<PowerLevelsAPI | null>(null);
-
-export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
-
-export const usePowerLevelsAPI = (): PowerLevelsAPI => {
- const api = useContext(PowerLevelsContext);
- if (!api) throw new Error('PowerLevelContext is not initialized!');
- return api;
};
--- /dev/null
+import { Room } from 'matrix-js-sdk';
+import { createContext, useContext } from 'react';
+
+const RoomContext = createContext<Room | null>(null);
+
+export const RoomProvider = RoomContext.Provider;
+
+export function useRoom(): Room {
+ const room = useContext(RoomContext);
+ if (!room) throw new Error('Room not provided!');
+ return room;
+}
--- /dev/null
+import { useEffect, useState } from 'react';
+import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { StateEvent } from '../../types/matrix/room';
+import { useStateEvent } from './useStateEvent';
+
+export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
+ const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
+
+ if (dm) {
+ return room.getAvatarFallbackMember()?.getMxcAvatarUrl();
+ }
+ const content = avatarEvent?.getContent();
+ const avatarMxc = content && typeof content.url === 'string' ? content.url : undefined;
+
+ return avatarMxc;
+};
+
+export const useRoomName = (room: Room): string => {
+ const [name, setName] = useState(room.name);
+
+ useEffect(() => {
+ const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
+ setName(room.name);
+ };
+ room.on(RoomEvent.Name, handleRoomNameChange);
+ return () => {
+ room.removeListener(RoomEvent.Name, handleRoomNameChange);
+ };
+ }, [room]);
+
+ return name;
+};
+
+export const useRoomTopic = (room: Room): string | undefined => {
+ const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
+
+ const content = topicEvent?.getContent();
+ const topic = content && typeof content.topic === 'string' ? content.topic : undefined;
+
+ return topic;
+};
+++ /dev/null
-import { ReactNode } from 'react';
-import { MatrixEvent, MsgType } from 'matrix-js-sdk';
-
-export type MsgContentRenderer<T extends unknown[]> = (
- eventId: string,
- mEvent: MatrixEvent,
- ...args: T
-) => ReactNode;
-
-export type RoomMsgContentRendererOpts<T extends unknown[]> = {
- renderText?: MsgContentRenderer<T>;
- renderEmote?: MsgContentRenderer<T>;
- renderNotice?: MsgContentRenderer<T>;
- renderImage?: MsgContentRenderer<T>;
- renderVideo?: MsgContentRenderer<T>;
- renderAudio?: MsgContentRenderer<T>;
- renderFile?: MsgContentRenderer<T>;
- renderLocation?: MsgContentRenderer<T>;
- renderBadEncrypted?: MsgContentRenderer<T>;
- renderUnsupported?: MsgContentRenderer<T>;
- renderBrokenFallback?: MsgContentRenderer<T>;
-};
-
-export type RenderRoomMsgContent<T extends unknown[]> = (
- eventId: string,
- mEvent: MatrixEvent,
- ...args: T
-) => ReactNode;
-
-export const useRoomMsgContentRenderer =
- <T extends unknown[]>({
- renderText,
- renderEmote,
- renderNotice,
- renderImage,
- renderVideo,
- renderAudio,
- renderFile,
- renderLocation,
- renderBadEncrypted,
- renderUnsupported,
- renderBrokenFallback,
- }: RoomMsgContentRendererOpts<T>): RenderRoomMsgContent<T> =>
- (eventId, mEvent, ...args) => {
- const msgType = mEvent.getContent().msgtype;
-
- let node: ReactNode = null;
-
- if (msgType === MsgType.Text && renderText) node = renderText(eventId, mEvent, ...args);
- else if (msgType === MsgType.Emote && renderEmote) node = renderEmote(eventId, mEvent, ...args);
- else if (msgType === MsgType.Notice && renderNotice)
- node = renderNotice(eventId, mEvent, ...args);
- else if (msgType === MsgType.Image && renderImage) node = renderImage(eventId, mEvent, ...args);
- else if (msgType === MsgType.Video && renderVideo) node = renderVideo(eventId, mEvent, ...args);
- else if (msgType === MsgType.Audio && renderAudio) node = renderAudio(eventId, mEvent, ...args);
- else if (msgType === MsgType.File && renderFile) node = renderFile(eventId, mEvent, ...args);
- else if (msgType === MsgType.Location && renderLocation)
- node = renderLocation(eventId, mEvent, ...args);
- else if (msgType === 'm.bad.encrypted' && renderBadEncrypted)
- node = renderBadEncrypted(eventId, mEvent, ...args);
- else if (renderUnsupported) {
- node = renderUnsupported(eventId, mEvent, ...args);
- }
-
- if (!node && renderBrokenFallback) node = renderBrokenFallback(eventId, mEvent, ...args);
-
- return node;
- };
--- /dev/null
+import { useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAtomValue } from 'jotai';
+import { getCanonicalAliasOrRoomId } from '../utils/matrix';
+import {
+ getDirectRoomPath,
+ getHomeRoomPath,
+ getSpacePath,
+ getSpaceRoomPath,
+} from '../pages/pathUtils';
+import { useMatrixClient } from './useMatrixClient';
+import { getOrphanParents } from '../utils/room';
+import { roomToParentsAtom } from '../state/room/roomToParents';
+import { mDirectAtom } from '../state/mDirectList';
+
+export const useRoomNavigate = () => {
+ const navigate = useNavigate();
+ const mx = useMatrixClient();
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const mDirects = useAtomValue(mDirectAtom);
+
+ const navigateSpace = useCallback(
+ (roomId: string) => {
+ const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
+ navigate(getSpacePath(roomIdOrAlias));
+ },
+ [mx, navigate]
+ );
+
+ const navigateRoom = useCallback(
+ (roomId: string, eventId?: string) => {
+ const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
+
+ const orphanParents = getOrphanParents(roomToParents, roomId);
+ if (orphanParents.length > 0) {
+ const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, orphanParents[0]);
+ navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId));
+ return;
+ }
+
+ if (mDirects.has(roomId)) {
+ navigate(getDirectRoomPath(roomIdOrAlias, eventId));
+ return;
+ }
+
+ navigate(getHomeRoomPath(roomIdOrAlias, eventId));
+ },
+ [mx, navigate, roomToParents, mDirects]
+ );
+
+ return {
+ navigateSpace,
+ navigateRoom,
+ };
+};
--- /dev/null
+import { useAtomValue } from 'jotai';
+import { useMemo } from 'react';
+import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../state/typingMembers';
+
+export const useRoomTypingMember = (roomId: string) => {
+ const typing = useAtomValue(
+ useMemo(() => selectRoomTypingMembersAtom(roomId, roomIdToTypingMembersAtom), [roomId])
+ );
+ return typing;
+};
-import { useCallback, useState } from 'react';
-import { getResizeObserverEntry, useResizeObserver } from './useResizeObserver';
+import { createContext, useCallback, useContext, useState } from 'react';
+import { useElementSizeObserver } from './useElementSizeObserver';
export const TABLET_BREAKPOINT = 1124;
export const MOBILE_BREAKPOINT = 750;
return ScreenSize.Mobile;
};
-export const useScreenSize = (): [ScreenSize, number] => {
- const [size, setSize] = useState<[ScreenSize, number]>([
- getScreenSize(document.body.clientWidth),
- document.body.clientWidth,
- ]);
- useResizeObserver(
- useCallback((entries) => {
- const bodyEntry = getResizeObserverEntry(document.body, entries);
- if (bodyEntry) {
- const bWidth = bodyEntry.contentRect.width;
- setSize([getScreenSize(bWidth), bWidth]);
- }
- }, []),
- document.body
+export const useScreenSize = (): ScreenSize => {
+ const [size, setSize] = useState<ScreenSize>(getScreenSize(document.body.clientWidth));
+
+ useElementSizeObserver(
+ useCallback(() => document.body, []),
+ useCallback((width) => setSize(getScreenSize(width)), [])
);
return size;
};
+
+const ScreenSizeContext = createContext<ScreenSize | null>(null);
+export const ScreenSizeProvider = ScreenSizeContext.Provider;
+
+export const useScreenSizeContext = (): ScreenSize => {
+ const screenSize = useContext(ScreenSizeContext);
+ if (screenSize === null) {
+ throw new Error('Screen size not provided!');
+ }
+ return screenSize;
+};
--- /dev/null
+import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
+import { MatrixClient } from 'matrix-js-sdk';
+import { AccountDataEvent } from '../../types/matrix/accountData';
+import { useMatrixClient } from './useMatrixClient';
+import { getAccountData, isSpace } from '../utils/room';
+import { Membership } from '../../types/matrix/room';
+import { useAccountDataCallback } from './useAccountDataCallback';
+
+export type ISidebarFolder = {
+ name?: string;
+ id: string;
+ content: string[];
+};
+export type TSidebarItem = string | ISidebarFolder;
+export type SidebarItems = Array<TSidebarItem>;
+
+export type InCinnySpacesContent = {
+ shortcut?: string[];
+ sidebar?: SidebarItems;
+};
+
+export const parseSidebar = (
+ mx: MatrixClient,
+ orphanSpaces: string[],
+ content?: InCinnySpacesContent
+) => {
+ const sidebar = content?.sidebar ?? content?.shortcut ?? [];
+ const orphans = new Set(orphanSpaces);
+
+ const items: SidebarItems = [];
+
+ const safeToAdd = (spaceId: string): boolean => {
+ if (typeof spaceId !== 'string') return false;
+ const space = mx.getRoom(spaceId);
+ if (space?.getMyMembership() !== Membership.Join) return false;
+ return isSpace(space);
+ };
+
+ sidebar.forEach((item) => {
+ if (typeof item === 'string') {
+ if (safeToAdd(item) && !items.includes(item)) {
+ orphans.delete(item);
+ items.push(item);
+ }
+ return;
+ }
+ if (
+ typeof item === 'object' &&
+ typeof item.id === 'string' &&
+ Array.isArray(item.content) &&
+ !items.find((i) => (typeof i === 'string' ? false : i.id === item.id))
+ ) {
+ const safeContent = item.content.filter(safeToAdd);
+ safeContent.forEach((i) => orphans.delete(i));
+ items.push({
+ ...item,
+ content: Array.from(new Set(safeContent)),
+ });
+ }
+ });
+
+ orphans.forEach((spaceId) => items.push(spaceId));
+ return items;
+};
+
+export const useSidebarItems = (
+ orphanSpaces: string[]
+): [SidebarItems, Dispatch<SetStateAction<SidebarItems>>] => {
+ const mx = useMatrixClient();
+
+ const [sidebarItems, setSidebarItems] = useState(() => {
+ const inCinnySpacesContent = getAccountData(
+ mx,
+ AccountDataEvent.CinnySpaces
+ )?.getContent<InCinnySpacesContent>();
+ return parseSidebar(mx, orphanSpaces, inCinnySpacesContent);
+ });
+
+ useEffect(() => {
+ const inCinnySpacesContent = getAccountData(
+ mx,
+ AccountDataEvent.CinnySpaces
+ )?.getContent<InCinnySpacesContent>();
+ setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent));
+ }, [mx, orphanSpaces]);
+
+ useAccountDataCallback(
+ mx,
+ useCallback(
+ (mEvent) => {
+ if (mEvent.getType() === AccountDataEvent.CinnySpaces) {
+ const newContent = mEvent.getContent<InCinnySpacesContent>();
+ setSidebarItems(parseSidebar(mx, orphanSpaces, newContent));
+ }
+ },
+ [mx, orphanSpaces]
+ )
+ );
+
+ return [sidebarItems, setSidebarItems];
+};
+
+export const sidebarItemWithout = (items: SidebarItems, roomId: string) => {
+ const newItems: SidebarItems = items
+ .map((item) => {
+ if (typeof item === 'string') {
+ if (item === roomId) return null;
+ return item;
+ }
+ if (item.content.includes(roomId)) {
+ const newContent = item.content.filter((id) => id !== roomId);
+ if (newContent.length === 0) return null;
+ return {
+ ...item,
+ content: newContent,
+ };
+ }
+ return item;
+ })
+ .filter((item) => item !== null) as SidebarItems;
+
+ return newItems;
+};
+
+export const makeCinnySpacesContent = (
+ mx: MatrixClient,
+ items: SidebarItems
+): InCinnySpacesContent => {
+ const currentInSpaces =
+ getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
+
+ const newSpacesContent: InCinnySpacesContent = {
+ ...currentInSpaces,
+ sidebar: items,
+ };
+
+ return newSpacesContent;
+};
--- /dev/null
+import { Room } from 'matrix-js-sdk';
+import { createContext, useContext } from 'react';
+
+const SpaceContext = createContext<Room | null>(null);
+
+export const SpaceProvider = SpaceContext.Provider;
+
+export function useSpace(): Room {
+ const space = useContext(SpaceContext);
+ if (!space) throw new Error('Space not provided!');
+ return space;
+}
+
+export function useSpaceOptionally(): Room | null {
+ const space = useContext(SpaceContext);
+ return space;
+}
--- /dev/null
+import { atom, useAtom, useAtomValue } from 'jotai';
+import { useCallback, useEffect, useState } from 'react';
+import { Room } from 'matrix-js-sdk';
+import { useMatrixClient } from './useMatrixClient';
+import { roomToParentsAtom } from '../state/room/roomToParents';
+import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
+import { getAllParents, getStateEvents, isValidChild } from '../utils/room';
+import { isRoomId } from '../utils/matrix';
+import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
+import { useStateEventCallback } from './useStateEventCallback';
+
+export type HierarchyItem =
+ | {
+ roomId: string;
+ content: MSpaceChildContent;
+ ts: number;
+ space: true;
+ parentId?: string;
+ }
+ | {
+ roomId: string;
+ content: MSpaceChildContent;
+ ts: number;
+ space?: false;
+ parentId: string;
+ };
+
+type GetRoomCallback = (roomId: string) => Room | undefined;
+
+const hierarchyItemTs: SortFunc<HierarchyItem> = (a, b) => byTsOldToNew(a.ts, b.ts);
+const hierarchyItemByOrder: SortFunc<HierarchyItem> = (a, b) =>
+ byOrderKey(a.content.order, b.content.order);
+
+const getHierarchySpaces = (
+ rootSpaceId: string,
+ getRoom: GetRoomCallback,
+ spaceRooms: Set<string>
+): HierarchyItem[] => {
+ const rootSpaceItem: HierarchyItem = {
+ roomId: rootSpaceId,
+ content: { via: [] },
+ ts: 0,
+ space: true,
+ };
+ let spaceItems: HierarchyItem[] = [];
+
+ const findAndCollectHierarchySpaces = (spaceItem: HierarchyItem) => {
+ if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return;
+ const space = getRoom(spaceItem.roomId);
+ spaceItems.push(spaceItem);
+
+ if (!space) return;
+ const childEvents = getStateEvents(space, StateEvent.SpaceChild);
+
+ childEvents.forEach((childEvent) => {
+ if (!isValidChild(childEvent)) return;
+ const childId = childEvent.getStateKey();
+ if (!childId || !isRoomId(childId)) return;
+
+ // because we can not find if a childId is space without joining
+ // or requesting room summary, we will look it into spaceRooms local
+ // cache which we maintain as we load summary in UI.
+ if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) {
+ const childItem: HierarchyItem = {
+ roomId: childId,
+ content: childEvent.getContent<MSpaceChildContent>(),
+ ts: childEvent.getTs(),
+ space: true,
+ parentId: spaceItem.roomId,
+ };
+ findAndCollectHierarchySpaces(childItem);
+ }
+ });
+ };
+ findAndCollectHierarchySpaces(rootSpaceItem);
+
+ spaceItems = [
+ rootSpaceItem,
+ ...spaceItems
+ .filter((item) => item.roomId !== rootSpaceId)
+ .sort(hierarchyItemTs)
+ .sort(hierarchyItemByOrder),
+ ];
+
+ return spaceItems;
+};
+
+const getSpaceHierarchy = (
+ rootSpaceId: string,
+ spaceRooms: Set<string>,
+ getRoom: (roomId: string) => Room | undefined,
+ closedCategory: (spaceId: string) => boolean
+): HierarchyItem[] => {
+ const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
+
+ const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
+ const space = getRoom(spaceItem.roomId);
+ if (!space || closedCategory(spaceItem.roomId)) {
+ return [spaceItem];
+ }
+ const childEvents = getStateEvents(space, StateEvent.SpaceChild);
+ const childItems: HierarchyItem[] = [];
+ childEvents.forEach((childEvent) => {
+ if (!isValidChild(childEvent)) return;
+ const childId = childEvent.getStateKey();
+ if (!childId || !isRoomId(childId)) return;
+ if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return;
+
+ const childItem: HierarchyItem = {
+ roomId: childId,
+ content: childEvent.getContent<MSpaceChildContent>(),
+ ts: childEvent.getTs(),
+ parentId: spaceItem.roomId,
+ };
+ childItems.push(childItem);
+ });
+ return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)];
+ });
+
+ return hierarchy;
+};
+
+export const useSpaceHierarchy = (
+ spaceId: string,
+ spaceRooms: Set<string>,
+ getRoom: (roomId: string) => Room | undefined,
+ closedCategory: (spaceId: string) => boolean
+): HierarchyItem[] => {
+ const mx = useMatrixClient();
+ const roomToParents = useAtomValue(roomToParentsAtom);
+
+ const [hierarchyAtom] = useState(() =>
+ atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory))
+ );
+ const [hierarchy, setHierarchy] = useAtom(hierarchyAtom);
+
+ useEffect(() => {
+ setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory));
+ }, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory]);
+
+ useStateEventCallback(
+ mx,
+ useCallback(
+ (mEvent) => {
+ if (mEvent.getType() !== StateEvent.SpaceChild) return;
+ const eventRoomId = mEvent.getRoomId();
+ if (!eventRoomId) return;
+
+ if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
+ setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory));
+ }
+ },
+ [spaceId, roomToParents, setHierarchy, spaceRooms, getRoom, closedCategory]
+ )
+ );
+
+ return hierarchy;
+};
+
+const getSpaceJoinedHierarchy = (
+ rootSpaceId: string,
+ getRoom: GetRoomCallback,
+ excludeRoom: (parentId: string, roomId: string) => boolean,
+ sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[]
+): HierarchyItem[] => {
+ const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, new Set());
+
+ const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
+ const space = getRoom(spaceItem.roomId);
+ if (!space) {
+ return [];
+ }
+ const joinedRoomEvents = getStateEvents(space, StateEvent.SpaceChild).filter((childEvent) => {
+ if (!isValidChild(childEvent)) return false;
+ const childId = childEvent.getStateKey();
+ if (!childId || !isRoomId(childId)) return false;
+ const room = getRoom(childId);
+ if (!room || room.isSpaceRoom()) return false;
+
+ return true;
+ });
+
+ if (joinedRoomEvents.length === 0) return [];
+
+ const childItems: HierarchyItem[] = [];
+ joinedRoomEvents.forEach((childEvent) => {
+ const childId = childEvent.getStateKey();
+ if (!childId) return;
+
+ if (excludeRoom(space.roomId, childId)) return;
+
+ const childItem: HierarchyItem = {
+ roomId: childId,
+ content: childEvent.getContent<MSpaceChildContent>(),
+ ts: childEvent.getTs(),
+ parentId: spaceItem.roomId,
+ };
+ childItems.push(childItem);
+ });
+ return [spaceItem, ...sortRoomItems(spaceItem.roomId, childItems)];
+ });
+
+ return hierarchy;
+};
+
+export const useSpaceJoinedHierarchy = (
+ spaceId: string,
+ getRoom: GetRoomCallback,
+ excludeRoom: (parentId: string, roomId: string) => boolean,
+ sortByActivity: (spaceId: string) => boolean
+): HierarchyItem[] => {
+ const mx = useMatrixClient();
+ const roomToParents = useAtomValue(roomToParentsAtom);
+
+ const sortRoomItems = useCallback(
+ (sId: string, items: HierarchyItem[]) => {
+ if (sortByActivity(sId)) {
+ items.sort((a, b) => factoryRoomIdByActivity(mx)(a.roomId, b.roomId));
+ return items;
+ }
+ items.sort(hierarchyItemTs).sort(hierarchyItemByOrder);
+ return items;
+ },
+ [mx, sortByActivity]
+ );
+
+ const [hierarchyAtom] = useState(() =>
+ atom(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems))
+ );
+ const [hierarchy, setHierarchy] = useAtom(hierarchyAtom);
+
+ useEffect(() => {
+ setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems));
+ }, [mx, spaceId, setHierarchy, getRoom, excludeRoom, sortRoomItems]);
+
+ useStateEventCallback(
+ mx,
+ useCallback(
+ (mEvent) => {
+ if (mEvent.getType() !== StateEvent.SpaceChild) return;
+ const eventRoomId = mEvent.getRoomId();
+ if (!eventRoomId) return;
+
+ if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
+ setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems));
+ }
+ },
+ [spaceId, roomToParents, setHierarchy, getRoom, excludeRoom, sortRoomItems]
+ )
+ );
+
+ return hierarchy;
+};
--- /dev/null
+import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+
+export const useSyncState = (
+ mx: MatrixClient,
+ onChange: ClientEventHandlerMap[ClientEvent.Sync]
+): void => {
+ useEffect(() => {
+ mx.on(ClientEvent.Sync, onChange);
+ return () => {
+ mx.removeListener(ClientEvent.Sync, onChange);
+ };
+ }, [mx, onChange]);
+};
import { MatrixClient } from 'matrix-js-sdk';
import { useMemo, useRef } from 'react';
+import { TYPING_TIMEOUT_MS } from '../state/typingMembers';
type TypingStatusUpdater = (typing: boolean) => void;
-const TYPING_TIMEOUT_MS = 5000; // 5 seconds
-
export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => {
const statusSentTsRef = useRef<number>(0);
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import './SpaceAddExisting.scss';
import { useStore } from '../../hooks/useStore';
-function SpaceAddExistingContent({ roomId }) {
+function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
const mountStore = useStore(roomId);
const [debounce] = useState(new Debounce());
const [process, setProcess] = useState(null);
const [selected, setSelected] = useState([]);
const [searchIds, setSearchIds] = useState(null);
const mx = initMatrix.matrixClient;
- const {
- spaces, rooms, directs, roomIdToParents,
- } = initMatrix.roomList;
+ const { spaces, rooms, directs, roomIdToParents } = initMatrix.roomList;
useEffect(() => {
- const allIds = [...spaces, ...rooms, ...directs].filter((rId) => (
- rId !== roomId && !roomIdToParents.get(rId)?.has(roomId)
- ));
+ const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
+ const allIds = roomIds.filter(
+ (rId) => rId !== roomId && !roomIdToParents.get(rId)?.has(roomId)
+ );
setAllRoomIds(allIds);
- }, [roomId]);
+ }, [roomId, onlySpaces]);
const toggleSelection = (rId) => {
if (process !== null) return;
via.push(getIdServer(rId));
}
- return mx.sendStateEvent(roomId, 'm.space.child', {
- auto_join: false,
- suggested: false,
- via,
- }, rId);
+ return mx.sendStateEvent(
+ roomId,
+ 'm.space.child',
+ {
+ auto_join: false,
+ suggested: false,
+ via,
+ },
+ rId
+ );
});
mountStore.setItem(true);
await Promise.allSettled(promises);
if (mountStore.getItem() !== true) return;
- const allIds = [...spaces, ...rooms, ...directs].filter((rId) => (
- rId !== roomId && !roomIdToParents.get(rId)?.has(roomId) && !selected.includes(rId)
- ));
+ const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
+ const allIds = roomIds.filter(
+ (rId) => rId !== roomId && !roomIdToParents.get(rId)?.has(roomId) && !selected.includes(rId)
+ );
setAllRoomIds(allIds);
setProcess(null);
setSelected([]);
const searchedIds = allRoomIds.filter((rId) => {
let name = mx.getRoom(rId)?.name;
if (!name) return false;
- name = name.normalize('NFKC')
- .toLocaleLowerCase()
- .replace(/\s/g, '');
+ name = name.normalize('NFKC').toLocaleLowerCase().replace(/\s/g, '');
return name.includes(term);
});
setSearchIds(searchedIds);
return (
<>
- <form onSubmit={(ev) => { ev.preventDefault(); }}>
+ <form
+ onSubmit={(ev) => {
+ ev.preventDefault();
+ }}
+ >
<RawIcon size="small" src={SearchIC} />
- <Input
- name="searchInput"
- onChange={handleSearch}
- placeholder="Search room"
- autoFocus
- />
+ <Input name="searchInput" onChange={handleSearch} placeholder="Search room" autoFocus />
<IconButton size="small" type="button" onClick={handleSearchClear} src={CrossIC} />
</form>
{searchIds?.length === 0 && <Text>No results found</Text>}
- {
- (searchIds || allRoomIds).map((rId) => {
- const room = mx.getRoom(rId);
- let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
- if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
-
- const parentSet = roomIdToParents.get(rId);
- const parentNames = parentSet
- ? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
- : undefined;
- const parents = parentNames ? parentNames.join(', ') : null;
-
- const handleSelect = () => toggleSelection(rId);
-
- return (
- <RoomSelector
- key={rId}
- name={room.name}
- parentName={parents}
- roomId={rId}
- imageSrc={directs.has(rId) ? imageSrc : null}
- iconSrc={
- directs.has(rId)
- ? null
- : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())
- }
- isUnread={false}
- notificationCount={0}
- isAlert={false}
- onClick={handleSelect}
- options={(
- <Checkbox
- isActive={selected.includes(rId)}
- variant="positive"
- onToggle={handleSelect}
- tabIndex={-1}
- disabled={process !== null}
- />
- )}
- />
- );
- })
- }
+ {(searchIds || allRoomIds).map((rId) => {
+ const room = mx.getRoom(rId);
+ let imageSrc =
+ room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+ if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+
+ const parentSet = roomIdToParents.get(rId);
+ const parentNames = parentSet
+ ? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
+ : undefined;
+ const parents = parentNames ? parentNames.join(', ') : null;
+
+ const handleSelect = () => toggleSelection(rId);
+
+ return (
+ <RoomSelector
+ key={rId}
+ name={room.name}
+ parentName={parents}
+ roomId={rId}
+ imageSrc={directs.has(rId) ? imageSrc : null}
+ iconSrc={
+ directs.has(rId) ? null : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())
+ }
+ isUnread={false}
+ notificationCount={0}
+ isAlert={false}
+ onClick={handleSelect}
+ options={
+ <Checkbox
+ isActive={selected.includes(rId)}
+ variant="positive"
+ onToggle={handleSelect}
+ tabIndex={-1}
+ disabled={process !== null}
+ />
+ }
+ />
+ );
+ })}
{selected.length !== 0 && (
<div className="space-add-existing__footer">
{process && <Spinner size="small" />}
<Text weight="medium">{process || `${selected.length} item selected`}</Text>
- { !process && (
- <Button onClick={handleAdd} variant="primary">Add</Button>
+ {!process && (
+ <Button onClick={handleAdd} variant="primary">
+ Add
+ </Button>
)}
</div>
)}
}
SpaceAddExistingContent.propTypes = {
roomId: PropTypes.string.isRequired,
+ spaces: PropTypes.bool.isRequired,
};
function useVisibilityToggle() {
- const [roomId, setRoomId] = useState(null);
+ const [data, setData] = useState(null);
useEffect(() => {
- const handleOpen = (rId) => setRoomId(rId);
+ const handleOpen = (roomId, spaces) =>
+ setData({
+ roomId,
+ spaces,
+ });
navigation.on(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
return () => {
navigation.removeListener(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
};
}, []);
- const requestClose = () => setRoomId(null);
+ const requestClose = () => setData(null);
- return [roomId, requestClose];
+ return [data, requestClose];
}
function SpaceAddExisting() {
- const [roomId, requestClose] = useVisibilityToggle();
+ const [data, requestClose] = useVisibilityToggle();
const mx = initMatrix.matrixClient;
- const room = mx.getRoom(roomId);
+ const room = mx.getRoom(data?.roomId);
return (
<Dialog
- isOpen={roomId !== null}
+ isOpen={!!room}
className="space-add-existing"
- title={(
+ title={
<Text variant="s1" weight="medium" primary>
- {roomId && twemojify(room.name)}
- <span style={{ color: 'var(--tc-surface-low)' }}> — add existing rooms</span>
+ {room && twemojify(room.name)}
+ <span style={{ color: 'var(--tc-surface-low)' }}>
+ {' '}
+ — add existing {data?.spaces ? 'spaces' : 'rooms'}
+ </span>
</Text>
- )}
+ }
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
onRequestClose={requestClose}
>
- {
- roomId
- ? <SpaceAddExistingContent roomId={roomId} />
- : <div />
- }
+ {room ? <SpaceAddExistingContent roomId={room.roomId} spaces={data.spaces} /> : <div />}
</Dialog>
);
}
<div className="rooms__wrapper">
<ScrollView ref={scrollRef} autoHide>
<div className="rooms-container">
- {
- selectedTab !== cons.tabs.DIRECTS
- ? <Home spaceId={spaceId} />
- : <Directs size={roomList.directs.size} />
- }
+ {selectedTab !== cons.tabs.DIRECTS ? (
+ <Home spaceId={spaceId} />
+ ) : (
+ <Directs size={roomList.directs.size} />
+ )}
</div>
</ScrollView>
</div>
</div>
- { systemState !== null && (
+ {systemState !== null && (
<div className="drawer__state">
<Text>{systemState.status}</Text>
</div>
+++ /dev/null
-import React from 'react';
-import { Icon, Icons, Badge, AvatarFallback, Text } from 'folds';
-import { useAtom } from 'jotai';
-
-import {
- Sidebar,
- SidebarContent,
- SidebarStackSeparator,
- SidebarStack,
- SidebarAvatar,
-} from '../../components/sidebar';
-import { selectedTabAtom, SidebarTab } from '../../state/selectedTab';
-
-export function Sidebar1() {
- const [selectedTab, setSelectedTab] = useAtom(selectedTabAtom);
-
- return (
- <Sidebar>
- <SidebarContent
- scrollable={
- <>
- <SidebarStack>
- <SidebarAvatar
- active={selectedTab === SidebarTab.Home}
- outlined
- tooltip="Home"
- avatarChildren={<Icon src={Icons.Home} filled />}
- onClick={() => setSelectedTab(SidebarTab.Home)}
- />
- <SidebarAvatar
- active={selectedTab === SidebarTab.People}
- outlined
- tooltip="People"
- avatarChildren={<Icon src={Icons.User} />}
- onClick={() => setSelectedTab(SidebarTab.People)}
- />
- </SidebarStack>
- <SidebarStackSeparator />
- <SidebarStack>
- <SidebarAvatar
- tooltip="Space A"
- notificationBadge={(badgeClassName) => (
- <Badge
- className={badgeClassName}
- size="200"
- variant="Secondary"
- fill="Solid"
- radii="Pill"
- />
- )}
- avatarChildren={
- <AvatarFallback
- style={{
- backgroundColor: 'red',
- color: 'white',
- }}
- >
- <Text size="T500">B</Text>
- </AvatarFallback>
- }
- />
- <SidebarAvatar
- tooltip="Space B"
- hasCount
- notificationBadge={(badgeClassName) => (
- <Badge className={badgeClassName} radii="Pill" fill="Solid" variant="Secondary">
- <Text size="L400">64</Text>
- </Badge>
- )}
- avatarChildren={
- <AvatarFallback
- style={{
- backgroundColor: 'green',
- color: 'white',
- }}
- >
- <Text size="T500">C</Text>
- </AvatarFallback>
- }
- />
- </SidebarStack>
- <SidebarStackSeparator />
- <SidebarStack>
- <SidebarAvatar
- outlined
- tooltip="Explore Community"
- avatarChildren={<Icon src={Icons.Explore} />}
- />
- <SidebarAvatar
- outlined
- tooltip="Create Space"
- avatarChildren={<Icon src={Icons.Plus} />}
- />
- </SidebarStack>
- </>
- }
- sticky={
- <>
- <SidebarStackSeparator />
- <SidebarStack>
- <SidebarAvatar
- outlined
- tooltip="Search"
- avatarChildren={<Icon src={Icons.Search} />}
- />
- <SidebarAvatar
- tooltip="User Settings"
- avatarChildren={
- <AvatarFallback
- style={{
- backgroundColor: 'blue',
- color: 'white',
- }}
- >
- <Text size="T500">A</Text>
- </AvatarFallback>
- }
- />
- </SidebarStack>
- </>
- }
- />
- </Sidebar>
- );
-}
import Settings from '../settings/Settings';
import SpaceSettings from '../space-settings/SpaceSettings';
import SpaceManage from '../space-manage/SpaceManage';
+import RoomSettings from '../room/RoomSettings';
function Windows() {
const [isInviteList, changeInviteList] = useState(false);
const [publicRooms, changePublicRooms] = useState({
- isOpen: false, searchTerm: undefined,
+ isOpen: false,
+ searchTerm: undefined,
});
const [inviteUser, changeInviteUser] = useState({
- isOpen: false, roomId: undefined, term: undefined,
+ isOpen: false,
+ roomId: undefined,
+ term: undefined,
});
function openInviteList() {
return (
<>
- <InviteList
- isOpen={isInviteList}
- onRequestClose={() => changeInviteList(false)}
- />
+ <InviteList isOpen={isInviteList} onRequestClose={() => changeInviteList(false)} />
<PublicRooms
isOpen={publicRooms.isOpen}
searchTerm={publicRooms.searchTerm}
/>
<Settings />
<SpaceSettings />
+ <RoomSettings />
<SpaceManage />
</>
);
+++ /dev/null
-import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
-import { Editor } from 'slate';
-import { Box, MenuItem, Text } from 'folds';
-import { Room } from 'matrix-js-sdk';
-import { Command, useCommands } from '../../hooks/useCommands';
-import {
- AutocompleteMenu,
- AutocompleteQuery,
- createCommandElement,
- moveCursor,
- replaceWithElement,
-} from '../../components/editor';
-import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useKeyDown } from '../../hooks/useKeyDown';
-import { onTabPress } from '../../utils/keyboard';
-
-type CommandAutoCompleteHandler = (commandName: string) => void;
-
-type CommandAutocompleteProps = {
- room: Room;
- editor: Editor;
- query: AutocompleteQuery<string>;
- requestClose: () => void;
-};
-
-const SEARCH_OPTIONS: UseAsyncSearchOptions = {
- matchOptions: {
- contain: true,
- },
-};
-
-export function CommandAutocomplete({
- room,
- editor,
- query,
- requestClose,
-}: CommandAutocompleteProps) {
- const mx = useMatrixClient();
- const commands = useCommands(mx, room);
- const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
-
- const [result, search, resetSearch] = useAsyncSearch(
- commandNames,
- useCallback((commandName: string) => commandName, []),
- SEARCH_OPTIONS
- );
-
- const autoCompleteNames = result ? result.items : commandNames;
-
- useEffect(() => {
- if (query.text) search(query.text);
- else resetSearch();
- }, [query.text, search, resetSearch]);
-
- const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => {
- const cmdEl = createCommandElement(commandName);
- replaceWithElement(editor, query.range, cmdEl);
- moveCursor(editor, true);
- requestClose();
- };
-
- useKeyDown(window, (evt: KeyboardEvent) => {
- onTabPress(evt, () => {
- if (autoCompleteNames.length === 0) {
- return;
- }
- const cmdName = autoCompleteNames[0];
- handleAutocomplete(cmdName);
- });
- });
-
- return autoCompleteNames.length === 0 ? null : (
- <AutocompleteMenu
- headerContent={
- <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
- <Text size="L400">Commands</Text>
- <Text size="T200" priority="300" truncate>
- Begin your message with command
- </Text>
- </Box>
- }
- requestClose={requestClose}
- >
- {autoCompleteNames.map((commandName) => (
- <MenuItem
- key={commandName}
- as="button"
- radii="300"
- onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
- onTabPress(evt, () => handleAutocomplete(commandName))
- }
- onClick={() => handleAutocomplete(commandName)}
- >
- <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
- <Box shrink="No">
- <Text style={{ flexGrow: 1 }} size="B400" truncate>
- {`/${commandName}`}
- </Text>
- </Box>
- <Text truncate priority="300" size="T200">
- {commands[commandName].description}
- </Text>
- </Box>
- </MenuItem>
- ))}
- </AutocompleteMenu>
- );
-}
+++ /dev/null
-import { keyframes, style } from '@vanilla-extract/css';
-import { color, config, toRem } from 'folds';
-
-export const MembersDrawer = style({
- width: toRem(266),
- backgroundColor: color.Background.Container,
- color: color.Background.OnContainer,
-});
-
-export const MembersDrawerHeader = style({
- flexShrink: 0,
- padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
- borderBottomWidth: config.borderWidth.B300,
-});
-
-export const MemberDrawerContentBase = style({
- position: 'relative',
- overflow: 'hidden',
-});
-
-export const MemberDrawerContent = style({
- padding: `${config.space.S200} 0`,
-});
-
-const ScrollBtnAnime = keyframes({
- '0%': {
- transform: `translate(-50%, -100%) scale(0)`,
- },
- '100%': {
- transform: `translate(-50%, 0) scale(1)`,
- },
-});
-
-export const DrawerScrollTop = style({
- position: 'absolute',
- top: config.space.S200,
- left: '50%',
- transform: 'translateX(-50%)',
- zIndex: 1,
- animation: `${ScrollBtnAnime} 100ms`,
-});
-
-export const DrawerGroup = style({
- paddingLeft: config.space.S200,
-});
-
-export const MembersGroup = style({
- paddingLeft: config.space.S200,
-});
-export const MembersGroupLabel = style({
- padding: config.space.S200,
- selectors: {
- '&:not(:first-child)': {
- paddingTop: config.space.S500,
- },
- },
-});
-
-export const DrawerVirtualItem = style({
- position: 'absolute',
- top: 0,
- left: 0,
- width: '100%',
-});
+++ /dev/null
-import React, {
- ChangeEventHandler,
- MouseEventHandler,
- useCallback,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
- Badge,
- Box,
- Chip,
- ContainerColor,
- Header,
- Icon,
- IconButton,
- Icons,
- Input,
- Menu,
- MenuItem,
- PopOut,
- Scroll,
- Spinner,
- Text,
- Tooltip,
- TooltipProvider,
- config,
-} from 'folds';
-import { Room, RoomMember } from 'matrix-js-sdk';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import FocusTrap from 'focus-trap-react';
-import millify from 'millify';
-import classNames from 'classnames';
-import { useAtomValue } from 'jotai';
-
-import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
-import * as css from './MembersDrawer.css';
-import { useRoomMembers } from '../../hooks/useRoomMembers';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import {
- getIntersectionObserverEntry,
- useIntersectionObserver,
-} from '../../hooks/useIntersectionObserver';
-import { Membership } from '../../../types/matrix/room';
-import { UseStateProvider } from '../../components/UseStateProvider';
-import {
- SearchItemStrGetter,
- UseAsyncSearchOptions,
- useAsyncSearch,
-} from '../../hooks/useAsyncSearch';
-import { useDebounce } from '../../hooks/useDebounce';
-import colorMXID from '../../../util/colorMXID';
-import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
-import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
-import { TypingIndicator } from '../../components/typing-indicator';
-import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
-import { getMxIdLocalPart } from '../../utils/matrix';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-
-export const MembershipFilters = {
- filterJoined: (m: RoomMember) => m.membership === Membership.Join,
- filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
- filterLeaved: (m: RoomMember) =>
- m.membership === Membership.Leave &&
- m.events.member?.getStateKey() === m.events.member?.getSender(),
- filterKicked: (m: RoomMember) =>
- m.membership === Membership.Leave &&
- m.events.member?.getStateKey() !== m.events.member?.getSender(),
- filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
-};
-
-export type MembershipFilterFn = (m: RoomMember) => boolean;
-
-export type MembershipFilter = {
- name: string;
- filterFn: MembershipFilterFn;
- color: ContainerColor;
-};
-
-const useMembershipFilterMenu = (): MembershipFilter[] =>
- useMemo(
- () => [
- {
- name: 'Joined',
- filterFn: MembershipFilters.filterJoined,
- color: 'Background',
- },
- {
- name: 'Invited',
- filterFn: MembershipFilters.filterInvited,
- color: 'Success',
- },
- {
- name: 'Left',
- filterFn: MembershipFilters.filterLeaved,
- color: 'Secondary',
- },
- {
- name: 'Kicked',
- filterFn: MembershipFilters.filterKicked,
- color: 'Warning',
- },
- {
- name: 'Banned',
- filterFn: MembershipFilters.filterBanned,
- color: 'Critical',
- },
- ],
- []
- );
-
-export const SortFilters = {
- filterAscending: (a: RoomMember, b: RoomMember) =>
- a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
- filterDescending: (a: RoomMember, b: RoomMember) =>
- a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
- filterNewestFirst: (a: RoomMember, b: RoomMember) =>
- (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
- filterOldest: (a: RoomMember, b: RoomMember) =>
- (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
-};
-
-export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
-
-export type SortFilter = {
- name: string;
- filterFn: SortFilterFn;
-};
-
-const useSortFilterMenu = (): SortFilter[] =>
- useMemo(
- () => [
- {
- name: 'A to Z',
- filterFn: SortFilters.filterAscending,
- },
- {
- name: 'Z to A',
- filterFn: SortFilters.filterDescending,
- },
- {
- name: 'Newest',
- filterFn: SortFilters.filterNewestFirst,
- },
- {
- name: 'Oldest',
- filterFn: SortFilters.filterOldest,
- },
- ],
- []
- );
-
-export type MembersFilterOptions = {
- membershipFilter: MembershipFilter;
- sortFilter: SortFilter;
-};
-
-const SEARCH_OPTIONS: UseAsyncSearchOptions = {
- limit: 100,
- matchOptions: {
- contain: true,
- },
-};
-
-const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
-const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
- getMemberSearchStr(m, query, mxIdToName);
-
-type MembersDrawerProps = {
- room: Room;
-};
-export function MembersDrawer({ room }: MembersDrawerProps) {
- const mx = useMatrixClient();
- const scrollRef = useRef<HTMLDivElement>(null);
- const searchInputRef = useRef<HTMLInputElement>(null);
- const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
- const members = useRoomMembers(mx, room.roomId);
- const getPowerLevelTag = usePowerLevelTags();
- const fetchingMembers = members.length < room.getJoinedMemberCount();
-
- const membershipFilterMenu = useMembershipFilterMenu();
- const sortFilterMenu = useSortFilterMenu();
- const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
- const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
-
- const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0];
- const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0];
-
- const [onTop, setOnTop] = useState(true);
-
- const typingMembers = useAtomValue(
- useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
- );
-
- const filteredMembers = useMemo(
- () =>
- members
- .filter(membershipFilter.filterFn)
- .sort(sortFilter.filterFn)
- .sort((a, b) => b.powerLevel - a.powerLevel),
- [members, membershipFilter, sortFilter]
- );
-
- const [result, search, resetSearch] = useAsyncSearch(
- filteredMembers,
- getRoomMemberStr,
- SEARCH_OPTIONS
- );
- if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
-
- const processMembers = result ? result.items : filteredMembers;
-
- const PLTagOrRoomMember = useMemo(() => {
- let prevTag: PowerLevelTag | undefined;
- const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
- processMembers.forEach((m) => {
- const plTag = getPowerLevelTag(m.powerLevel);
- if (plTag !== prevTag) {
- prevTag = plTag;
- tagOrMember.push(plTag);
- }
- tagOrMember.push(m);
- });
- return tagOrMember;
- }, [processMembers, getPowerLevelTag]);
-
- const virtualizer = useVirtualizer({
- count: PLTagOrRoomMember.length,
- getScrollElement: () => scrollRef.current,
- estimateSize: () => 40,
- overscan: 10,
- });
-
- useIntersectionObserver(
- useCallback((intersectionEntries) => {
- if (!scrollTopAnchorRef.current) return;
- const entry = getIntersectionObserverEntry(scrollTopAnchorRef.current, intersectionEntries);
- if (entry) setOnTop(entry.isIntersecting);
- }, []),
- useCallback(() => ({ root: scrollRef.current }), []),
- useCallback(() => scrollTopAnchorRef.current, [])
- );
-
- const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
- useCallback(
- (evt) => {
- if (evt.target.value) search(evt.target.value);
- else resetSearch();
- },
- [search, resetSearch]
- ),
- { wait: 200 }
- );
-
- const getName = (member: RoomMember) =>
- getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
-
- const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
- const btn = evt.currentTarget as HTMLButtonElement;
- const userId = btn.getAttribute('data-user-id');
- openProfileViewer(userId, room.roomId);
- };
-
- return (
- <Box className={css.MembersDrawer} direction="Column">
- <Header className={css.MembersDrawerHeader} variant="Background" size="600">
- <Box grow="Yes" alignItems="Center" gap="200">
- <Box grow="Yes" alignItems="Center" gap="200">
- <Text size="H5" truncate>
- {`${millify(room.getJoinedMemberCount(), { precision: 1, locales: [] })} Members`}
- </Text>
- </Box>
- <Box shrink="No" alignItems="Center">
- <TooltipProvider
- position="Bottom"
- align="End"
- offset={4}
- tooltip={
- <Tooltip>
- <Text>Invite Member</Text>
- </Tooltip>
- }
- >
- {(triggerRef) => (
- <IconButton
- ref={triggerRef}
- variant="Background"
- onClick={() => openInviteUser(room.roomId)}
- >
- <Icon src={Icons.UserPlus} />
- </IconButton>
- )}
- </TooltipProvider>
- </Box>
- </Box>
- </Header>
- <Box className={css.MemberDrawerContentBase} grow="Yes">
- <Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover">
- <Box className={css.MemberDrawerContent} direction="Column" gap="200">
- <Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="200">
- <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
- <UseStateProvider initial={false}>
- {(open, setOpen) => (
- <PopOut
- open={open}
- position="Bottom"
- align="Start"
- offset={4}
- content={
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setOpen(false),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- }}
- >
- <Menu style={{ padding: config.space.S100 }}>
- {membershipFilterMenu.map((menuItem, index) => (
- <MenuItem
- key={menuItem.name}
- variant={
- menuItem.name === membershipFilter.name
- ? menuItem.color
- : 'Surface'
- }
- aria-pressed={menuItem.name === membershipFilter.name}
- radii="300"
- onClick={() => {
- setMembershipFilterIndex(index);
- setOpen(false);
- }}
- >
- <Text>{menuItem.name}</Text>
- </MenuItem>
- ))}
- </Menu>
- </FocusTrap>
- }
- >
- {(anchorRef) => (
- <Chip
- ref={anchorRef}
- onClick={() => setOpen(!open)}
- variant={membershipFilter.color}
- size="400"
- radii="300"
- before={<Icon src={Icons.Filter} size="50" />}
- >
- <Text size="T200">{membershipFilter.name}</Text>
- </Chip>
- )}
- </PopOut>
- )}
- </UseStateProvider>
- <UseStateProvider initial={false}>
- {(open, setOpen) => (
- <PopOut
- open={open}
- position="Bottom"
- align="End"
- offset={4}
- content={
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setOpen(false),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- }}
- >
- <Menu style={{ padding: config.space.S100 }}>
- {sortFilterMenu.map((menuItem, index) => (
- <MenuItem
- key={menuItem.name}
- variant="Surface"
- aria-pressed={menuItem.name === sortFilter.name}
- radii="300"
- onClick={() => {
- setSortFilterIndex(index);
- setOpen(false);
- }}
- >
- <Text>{menuItem.name}</Text>
- </MenuItem>
- ))}
- </Menu>
- </FocusTrap>
- }
- >
- {(anchorRef) => (
- <Chip
- ref={anchorRef}
- onClick={() => setOpen(!open)}
- variant="Background"
- size="400"
- radii="300"
- after={<Icon src={Icons.Sort} size="50" />}
- >
- <Text size="T200">{sortFilter.name}</Text>
- </Chip>
- )}
- </PopOut>
- )}
- </UseStateProvider>
- </Box>
- <Box direction="Column" gap="100">
- <Input
- ref={searchInputRef}
- onChange={handleSearchChange}
- style={{ paddingRight: config.space.S200 }}
- placeholder="Type name..."
- variant="Surface"
- size="400"
- radii="400"
- before={<Icon size="50" src={Icons.Search} />}
- after={
- result && (
- <Chip
- variant={result.items.length > 0 ? 'Success' : 'Critical'}
- size="400"
- radii="Pill"
- aria-pressed
- onClick={() => {
- if (searchInputRef.current) {
- searchInputRef.current.value = '';
- searchInputRef.current.focus();
- }
- resetSearch();
- }}
- after={<Icon size="50" src={Icons.Cross} />}
- >
- <Text size="B300">{`${result.items.length || 'No'} ${
- result.items.length === 1 ? 'Result' : 'Results'
- }`}</Text>
- </Chip>
- )
- }
- />
- </Box>
- </Box>
-
- {!onTop && (
- <Box className={css.DrawerScrollTop}>
- <IconButton
- onClick={() => virtualizer.scrollToOffset(0)}
- variant="Surface"
- radii="Pill"
- outlined
- size="300"
- aria-label="Scroll to Top"
- >
- <Icon src={Icons.ChevronTop} size="300" />
- </IconButton>
- </Box>
- )}
-
- {!fetchingMembers && !result && processMembers.length === 0 && (
- <Text style={{ padding: config.space.S300 }} align="Center">
- {`No "${membershipFilter.name}" Members`}
- </Text>
- )}
-
- <Box className={css.MembersGroup} direction="Column" gap="100">
- <div
- style={{
- position: 'relative',
- height: virtualizer.getTotalSize(),
- }}
- >
- {virtualizer.getVirtualItems().map((vItem) => {
- const tagOrMember = PLTagOrRoomMember[vItem.index];
- if (!('userId' in tagOrMember)) {
- return (
- <Text
- style={{
- transform: `translateY(${vItem.start}px)`,
- }}
- data-index={vItem.index}
- ref={virtualizer.measureElement}
- key={`${room.roomId}-${vItem.index}`}
- className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
- size="L400"
- >
- {tagOrMember.name}
- </Text>
- );
- }
-
- const member = tagOrMember;
- const name = getName(member);
- const avatarUrl = member.getAvatarUrl(
- mx.baseUrl,
- 100,
- 100,
- 'crop',
- undefined,
- false
- );
-
- return (
- <MenuItem
- style={{
- padding: `0 ${config.space.S200}`,
- transform: `translateY(${vItem.start}px)`,
- }}
- data-index={vItem.index}
- data-user-id={member.userId}
- ref={virtualizer.measureElement}
- key={`${room.roomId}-${member.userId}`}
- className={css.DrawerVirtualItem}
- variant="Background"
- radii="400"
- onClick={handleMemberClick}
- before={
- <Avatar size="200">
- {avatarUrl ? (
- <AvatarImage src={avatarUrl} />
- ) : (
- <AvatarFallback
- style={{
- background: colorMXID(member.userId),
- color: 'white',
- }}
- >
- <Text size="H6">{name[0]}</Text>
- </AvatarFallback>
- )}
- </Avatar>
- }
- after={
- typingMembers.find((tm) => tm.userId === member.userId) && (
- <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
- <TypingIndicator size="300" />
- </Badge>
- )
- }
- >
- <Box grow="Yes">
- <Text size="T400" truncate>
- {name}
- </Text>
- </Box>
- </MenuItem>
- );
- })}
- </div>
- </Box>
-
- {fetchingMembers && (
- <Box justifyContent="Center">
- <Spinner />
- </Box>
- )}
- </Box>
- </Scroll>
- </Box>
- </Box>
- );
-}
.room {
@extend .cp-fx__row;
height: 100%;
+ flex-grow: 1;
&__content {
@extend .cp-fx__item-one;
+++ /dev/null
-import React from 'react';
-import './Room.scss';
-import { Room } from 'matrix-js-sdk';
-import { Line } from 'folds';
-
-import RoomView from './RoomView';
-import RoomSettings from './RoomSettings';
-import { MembersDrawer } from './MembersDrawer';
-import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
-import {
- roomIdToTypingMembersAtom,
- useBindRoomIdToTypingMembersAtom,
-} from '../../state/typingMembers';
-
-export type RoomBaseViewProps = {
- room: Room;
- eventId?: string;
-};
-export function RoomBaseView({ room, eventId }: RoomBaseViewProps) {
- useBindRoomIdToTypingMembersAtom(room.client, roomIdToTypingMembersAtom);
-
- const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
- const [screenSize] = useScreenSize();
- const powerLevelAPI = usePowerLevels(room);
-
- return (
- <PowerLevelsContextProvider value={powerLevelAPI}>
- <div className="room">
- <div className="room__content">
- <RoomSettings roomId={room.roomId} />
- <RoomView room={room} eventId={eventId} />
- </div>
-
- {screenSize === ScreenSize.Desktop && isDrawer && (
- <>
- <Line variant="Background" direction="Vertical" size="300" />
- <MembersDrawer key={room.roomId} room={room} />
- </>
- )}
- </div>
- </PowerLevelsContextProvider>
- );
-}
+++ /dev/null
-import React, {
- KeyboardEventHandler,
- RefObject,
- forwardRef,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import { useAtom } from 'jotai';
-import { isKeyHotkey } from 'is-hotkey';
-import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
-import { ReactEditor } from 'slate-react';
-import { Transforms, Editor } from 'slate';
-import {
- Box,
- Dialog,
- Icon,
- IconButton,
- Icons,
- Line,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- PopOut,
- Scroll,
- Text,
- config,
- toRem,
-} from 'folds';
-
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import {
- CustomEditor,
- Toolbar,
- toMatrixCustomHTML,
- toPlainText,
- AUTOCOMPLETE_PREFIXES,
- AutocompletePrefix,
- AutocompleteQuery,
- getAutocompleteQuery,
- getPrevWorldRange,
- resetEditor,
- RoomMentionAutocomplete,
- UserMentionAutocomplete,
- EmoticonAutocomplete,
- createEmoticonElement,
- moveCursor,
- resetEditorHistory,
- customHtmlEqualsPlainText,
- trimCustomHtml,
- isEmptyEditor,
- getBeginCommand,
- trimCommand,
-} from '../../components/editor';
-import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
-import { UseStateProvider } from '../../components/UseStateProvider';
-import initMatrix from '../../../client/initMatrix';
-import { TUploadContent, encryptFile, getImageInfo } from '../../utils/matrix';
-import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
-import { useFilePicker } from '../../hooks/useFilePicker';
-import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
-import { useFileDropZone } from '../../hooks/useFileDrop';
-import {
- TUploadItem,
- roomIdToMsgDraftAtomFamily,
- roomIdToReplyDraftAtomFamily,
- roomIdToUploadItemsAtomFamily,
- roomUploadAtomFamily,
-} from '../../state/roomInputDrafts';
-import { UploadCardRenderer } from '../../components/upload-card';
-import {
- UploadBoard,
- UploadBoardContent,
- UploadBoardHeader,
- UploadBoardImperativeHandlers,
-} from '../../components/upload-board';
-import {
- Upload,
- UploadStatus,
- UploadSuccess,
- createUploadFamilyObserverAtom,
-} from '../../state/upload';
-import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
-import { safeFile } from '../../utils/mimeTypes';
-import { fulfilledPromiseSettledResult } from '../../utils/common';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-import {
- getAudioMsgContent,
- getFileMsgContent,
- getImageMsgContent,
- getVideoMsgContent,
-} from './msgContent';
-import { MessageReply } from '../../molecules/message/Message';
-import colorMXID from '../../../util/colorMXID';
-import {
- parseReplyBody,
- parseReplyFormattedBody,
- trimReplyFromBody,
- trimReplyFromFormattedBody,
-} from '../../utils/room';
-import { sanitizeText } from '../../utils/sanitize';
-import { useScreenSize } from '../../hooks/useScreenSize';
-import { CommandAutocomplete } from './CommandAutocomplete';
-import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
-import { mobileOrTablet } from '../../utils/user-agent';
-
-interface RoomInputProps {
- editor: Editor;
- roomViewRef: RefObject<HTMLElement>;
- roomId: string;
- room: Room;
-}
-export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
- ({ editor, roomViewRef, roomId, room }, ref) => {
- const mx = useMatrixClient();
- const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
- const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
- const commands = useCommands(mx, room);
-
- const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
- const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
- const [uploadBoard, setUploadBoard] = useState(true);
- const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
- const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
- roomUploadAtomFamily,
- selectedFiles.map((f) => f.file)
- );
- const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
-
- const imagePackRooms: Room[] = useMemo(() => {
- const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
- return allParentSpaces.reduce<Room[]>((list, rId) => {
- const r = mx.getRoom(rId);
- if (r) list.push(r);
- return list;
- }, []);
- }, [mx, roomId]);
-
- const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
- const [autocompleteQuery, setAutocompleteQuery] =
- useState<AutocompleteQuery<AutocompletePrefix>>();
-
- const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
-
- const handleFiles = useCallback(
- async (files: File[]) => {
- setUploadBoard(true);
- const safeFiles = files.map(safeFile);
- const fileItems: TUploadItem[] = [];
-
- if (mx.isRoomEncrypted(roomId)) {
- const encryptFiles = fulfilledPromiseSettledResult(
- await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
- );
- encryptFiles.forEach((ef) => fileItems.push(ef));
- } else {
- safeFiles.forEach((f) =>
- fileItems.push({ file: f, originalFile: f, encInfo: undefined })
- );
- }
- setSelectedFiles({
- type: 'PUT',
- item: fileItems,
- });
- },
- [setSelectedFiles, roomId, mx]
- );
- const pickFile = useFilePicker(handleFiles, true);
- const handlePaste = useFilePasteHandler(handleFiles);
- const dropZoneVisible = useFileDropZone(roomViewRef, handleFiles);
-
- const [, screenWidth] = useScreenSize();
- const hideStickerBtn = screenWidth < 500;
-
- useEffect(() => {
- Transforms.insertFragment(editor, msgDraft);
- }, [editor, msgDraft]);
-
- useEffect(() => {
- if (!mobileOrTablet()) ReactEditor.focus(editor);
- return () => {
- if (!isEmptyEditor(editor)) {
- const parsedDraft = JSON.parse(JSON.stringify(editor.children));
- setMsgDraft(parsedDraft);
- } else {
- setMsgDraft([]);
- }
- resetEditor(editor);
- resetEditorHistory(editor);
- };
- }, [roomId, editor, setMsgDraft]);
-
- const handleRemoveUpload = useCallback(
- (upload: TUploadContent | TUploadContent[]) => {
- const uploads = Array.isArray(upload) ? upload : [upload];
- setSelectedFiles({
- type: 'DELETE',
- item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
- });
- uploads.forEach((u) => roomUploadAtomFamily.remove(u));
- },
- [setSelectedFiles, selectedFiles]
- );
-
- const handleCancelUpload = (uploads: Upload[]) => {
- uploads.forEach((upload) => {
- if (upload.status === UploadStatus.Loading) {
- mx.cancelUpload(upload.promise);
- }
- });
- handleRemoveUpload(uploads.map((upload) => upload.file));
- };
-
- const handleSendUpload = async (uploads: UploadSuccess[]) => {
- const contentsPromises = uploads.map(async (upload) => {
- const fileItem = selectedFiles.find((f) => f.file === upload.file);
- if (!fileItem) throw new Error('Broken upload');
-
- if (fileItem.file.type.startsWith('image')) {
- return getImageMsgContent(mx, fileItem, upload.mxc);
- }
- if (fileItem.file.type.startsWith('video')) {
- return getVideoMsgContent(mx, fileItem, upload.mxc);
- }
- if (fileItem.file.type.startsWith('audio')) {
- return getAudioMsgContent(fileItem, upload.mxc);
- }
- return getFileMsgContent(fileItem, upload.mxc);
- });
- handleCancelUpload(uploads);
- const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
- contents.forEach((content) => mx.sendMessage(roomId, content));
- };
-
- const submit = useCallback(() => {
- uploadBoardHandlers.current?.handleSend();
-
- const commandName = getBeginCommand(editor);
-
- let plainText = toPlainText(editor.children).trim();
- let customHtml = trimCustomHtml(
- toMatrixCustomHTML(editor.children, {
- allowTextFormatting: true,
- allowBlockMarkdown: isMarkdown,
- allowInlineMarkdown: isMarkdown,
- })
- );
- let msgType = MsgType.Text;
-
- if (commandName) {
- plainText = trimCommand(commandName, plainText);
- customHtml = trimCommand(commandName, customHtml);
- }
- if (commandName === Command.Me) {
- msgType = MsgType.Emote;
- } else if (commandName === Command.Notice) {
- msgType = MsgType.Notice;
- } else if (commandName === Command.Shrug) {
- plainText = `${SHRUG} ${plainText}`;
- customHtml = `${SHRUG} ${customHtml}`;
- } else if (commandName) {
- const commandContent = commands[commandName as Command];
- if (commandContent) {
- commandContent.exe(plainText);
- }
- resetEditor(editor);
- resetEditorHistory(editor);
- sendTypingStatus(false);
- return;
- }
-
- if (plainText === '') return;
-
- let body = plainText;
- let formattedBody = customHtml;
- if (replyDraft) {
- body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
- formattedBody =
- parseReplyFormattedBody(
- roomId,
- replyDraft.userId,
- replyDraft.eventId,
- replyDraft.formattedBody
- ? trimReplyFromFormattedBody(replyDraft.formattedBody)
- : sanitizeText(replyDraft.body)
- ) + formattedBody;
- }
-
- const content: IContent = {
- msgtype: msgType,
- body,
- };
- if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
- content.format = 'org.matrix.custom.html';
- content.formatted_body = formattedBody;
- }
- if (replyDraft) {
- content['m.relates_to'] = {
- 'm.in_reply_to': {
- event_id: replyDraft.eventId,
- },
- };
- }
- mx.sendMessage(roomId, content);
- resetEditor(editor);
- resetEditorHistory(editor);
- setReplyDraft();
- sendTypingStatus(false);
- }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
-
- const handleKeyDown: KeyboardEventHandler = useCallback(
- (evt) => {
- if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
- evt.preventDefault();
- submit();
- }
- if (isKeyHotkey('escape', evt)) {
- evt.preventDefault();
- setReplyDraft();
- }
- },
- [submit, setReplyDraft, enterForNewline]
- );
-
- const handleKeyUp: KeyboardEventHandler = useCallback(
- (evt) => {
- if (isKeyHotkey('escape', evt)) {
- evt.preventDefault();
- return;
- }
-
- sendTypingStatus(!isEmptyEditor(editor));
-
- const prevWordRange = getPrevWorldRange(editor);
- const query = prevWordRange
- ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
- : undefined;
- setAutocompleteQuery(query);
- },
- [editor, sendTypingStatus]
- );
-
- const handleCloseAutocomplete = useCallback(() => {
- setAutocompleteQuery(undefined);
- ReactEditor.focus(editor);
- }, [editor]);
-
- const handleEmoticonSelect = (key: string, shortcode: string) => {
- editor.insertNode(createEmoticonElement(key, shortcode));
- moveCursor(editor);
- };
-
- const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
- const stickerUrl = mx.mxcUrlToHttp(mxc);
- if (!stickerUrl) return;
-
- const info = await getImageInfo(
- await loadImageElement(stickerUrl),
- await getImageUrlBlob(stickerUrl)
- );
-
- mx.sendEvent(roomId, EventType.Sticker, {
- body: label,
- url: mxc,
- info,
- });
- };
-
- return (
- <div ref={ref}>
- {selectedFiles.length > 0 && (
- <UploadBoard
- header={
- <UploadBoardHeader
- open={uploadBoard}
- onToggle={() => setUploadBoard(!uploadBoard)}
- uploadFamilyObserverAtom={uploadFamilyObserverAtom}
- onSend={handleSendUpload}
- imperativeHandlerRef={uploadBoardHandlers}
- onCancel={handleCancelUpload}
- />
- }
- >
- {uploadBoard && (
- <Scroll size="300" hideTrack visibility="Hover">
- <UploadBoardContent>
- {Array.from(selectedFiles)
- .reverse()
- .map((fileItem, index) => (
- <UploadCardRenderer
- // eslint-disable-next-line react/no-array-index-key
- key={index}
- file={fileItem.file}
- isEncrypted={!!fileItem.encInfo}
- uploadAtom={roomUploadAtomFamily(fileItem.file)}
- onRemove={handleRemoveUpload}
- />
- ))}
- </UploadBoardContent>
- </Scroll>
- )}
- </UploadBoard>
- )}
- <Overlay
- open={dropZoneVisible}
- backdrop={<OverlayBackdrop />}
- style={{ pointerEvents: 'none' }}
- >
- <OverlayCenter>
- <Dialog variant="Primary">
- <Box
- direction="Column"
- justifyContent="Center"
- alignItems="Center"
- gap="500"
- style={{ padding: toRem(60) }}
- >
- <Icon size="600" src={Icons.File} />
- <Text size="H4" align="Center">
- {`Drop Files in "${room?.name || 'Room'}"`}
- </Text>
- <Text align="Center">Drag and drop files here or click for selection dialog</Text>
- </Box>
- </Dialog>
- </OverlayCenter>
- </Overlay>
- {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
- <RoomMentionAutocomplete
- roomId={roomId}
- editor={editor}
- query={autocompleteQuery}
- requestClose={handleCloseAutocomplete}
- />
- )}
- {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
- <UserMentionAutocomplete
- room={room}
- editor={editor}
- query={autocompleteQuery}
- requestClose={handleCloseAutocomplete}
- />
- )}
- {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
- <EmoticonAutocomplete
- imagePackRooms={imagePackRooms}
- editor={editor}
- query={autocompleteQuery}
- requestClose={handleCloseAutocomplete}
- />
- )}
- {autocompleteQuery?.prefix === AutocompletePrefix.Command && (
- <CommandAutocomplete
- room={room}
- editor={editor}
- query={autocompleteQuery}
- requestClose={handleCloseAutocomplete}
- />
- )}
- <CustomEditor
- editableName="RoomInput"
- editor={editor}
- placeholder="Send a message..."
- onKeyDown={handleKeyDown}
- onKeyUp={handleKeyUp}
- onPaste={handlePaste}
- top={
- replyDraft && (
- <div>
- <Box
- alignItems="Center"
- gap="300"
- style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
- >
- <IconButton
- onClick={() => setReplyDraft()}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- >
- <Icon src={Icons.Cross} size="50" />
- </IconButton>
- <MessageReply
- color={colorMXID(replyDraft.userId)}
- name={room?.getMember(replyDraft.userId)?.name ?? replyDraft.userId}
- body={replyDraft.body}
- />
- </Box>
- </div>
- )
- }
- before={
- <IconButton
- onClick={() => pickFile('*')}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- >
- <Icon src={Icons.PlusCircle} />
- </IconButton>
- }
- after={
- <>
- <IconButton
- variant="SurfaceVariant"
- size="300"
- radii="300"
- onClick={() => setToolbar(!toolbar)}
- >
- <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
- </IconButton>
- <UseStateProvider initial={undefined}>
- {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
- <PopOut
- offset={16}
- alignOffset={-44}
- position="Top"
- align="End"
- open={!!emojiBoardTab}
- content={
- <EmojiBoard
- tab={emojiBoardTab}
- onTabChange={setEmojiBoardTab}
- imagePackRooms={imagePackRooms}
- returnFocusOnDeactivate={false}
- onEmojiSelect={handleEmoticonSelect}
- onCustomEmojiSelect={handleEmoticonSelect}
- onStickerSelect={handleStickerSelect}
- requestClose={() => {
- setEmojiBoardTab(undefined);
- if (!mobileOrTablet()) ReactEditor.focus(editor);
- }}
- />
- }
- >
- {(anchorRef) => (
- <>
- {!hideStickerBtn && (
- <IconButton
- aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
- onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- >
- <Icon
- src={Icons.Sticker}
- filled={emojiBoardTab === EmojiBoardTab.Sticker}
- />
- </IconButton>
- )}
- <IconButton
- ref={anchorRef}
- aria-pressed={
- hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
- }
- onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- >
- <Icon
- src={Icons.Smile}
- filled={
- hideStickerBtn
- ? !!emojiBoardTab
- : emojiBoardTab === EmojiBoardTab.Emoji
- }
- />
- </IconButton>
- </>
- )}
- </PopOut>
- )}
- </UseStateProvider>
- <IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
- <Icon src={Icons.Send} />
- </IconButton>
- </>
- }
- bottom={
- toolbar && (
- <div>
- <Line variant="SurfaceVariant" size="300" />
- <Toolbar />
- </div>
- )
- }
- />
- </div>
- );
- }
-);
+++ /dev/null
-import { style } from '@vanilla-extract/css';
-import { color, config, toRem } from 'folds';
-
-export const RoomInputPlaceholder = style({
- minHeight: toRem(48),
- backgroundColor: color.SurfaceVariant.Container,
- color: color.SurfaceVariant.OnContainer,
- boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
- borderRadius: config.radii.R400,
-});
+++ /dev/null
-import React, { ComponentProps } from 'react';
-import { Box, as } from 'folds';
-import classNames from 'classnames';
-
-import * as css from './RoomInputPlaceholder.css';
-
-export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box>>(
- ({ className, ...props }, ref) => (
- <Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
- )
-);
import PropTypes from 'prop-types';
import './RoomSettings.scss';
-import { blurOnBubbling } from '../../atoms/button/script';
-
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
-import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
import * as roomActions from '../../../client/action/room';
import Text from '../../atoms/text/Text';
-import RawIcon from '../../atoms/system-icons/RawIcon';
-import Header, { TitleWrapper } from '../../atoms/header/Header';
-import ScrollView from '../../atoms/scroll/ScrollView';
import Tabs from '../../atoms/tabs/Tabs';
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import RoomProfile from '../../molecules/room-profile/RoomProfile';
-import RoomSearch from '../../molecules/room-search/RoomSearch';
import RoomNotification from '../../molecules/room-notification/RoomNotification';
import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
import RoomAliases from '../../molecules/room-aliases/RoomAliases';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
-import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
-import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
-import ChevronTopIC from '../../../../public/res/ic/outlined/chevron-top.svg';
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-import { useForceUpdate } from '../../hooks/useForceUpdate';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
+import PopupWindow from '../../molecules/popup-window/PopupWindow';
+import IconButton from '../../atoms/button/IconButton';
const tabText = {
GENERAL: 'General',
- SEARCH: 'Search',
MEMBERS: 'Members',
EMOJIS: 'Emojis',
PERMISSIONS: 'Permissions',
SECURITY: 'Security',
};
-const tabItems = [{
- iconSrc: SettingsIC,
- text: tabText.GENERAL,
- disabled: false,
-}, {
- iconSrc: SearchIC,
- text: tabText.SEARCH,
- disabled: false,
-}, {
- iconSrc: UserIC,
- text: tabText.MEMBERS,
- disabled: false,
-}, {
- iconSrc: EmojiIC,
- text: tabText.EMOJIS,
- disabled: false,
-}, {
- iconSrc: ShieldUserIC,
- text: tabText.PERMISSIONS,
- disabled: false,
-}, {
- iconSrc: LockIC,
- text: tabText.SECURITY,
- disabled: false,
-}];
+const tabItems = [
+ {
+ iconSrc: SettingsIC,
+ text: tabText.GENERAL,
+ disabled: false,
+ },
+ {
+ iconSrc: UserIC,
+ text: tabText.MEMBERS,
+ disabled: false,
+ },
+ {
+ iconSrc: EmojiIC,
+ text: tabText.EMOJIS,
+ disabled: false,
+ },
+ {
+ iconSrc: ShieldUserIC,
+ text: tabText.PERMISSIONS,
+ disabled: false,
+ },
+ {
+ iconSrc: LockIC,
+ text: tabText.SECURITY,
+ disabled: false,
+ },
+];
function GeneralSettings({ roomId }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
- const canInvite = room.canInvite(mx.getUserId());
return (
<>
<div className="room-settings__card">
<MenuHeader>Options</MenuHeader>
- <MenuItem
- disabled={!canInvite}
- onClick={() => openInviteUser(roomId)}
- iconSrc={AddUserIC}
- >
- Invite
- </MenuItem>
<MenuItem
variant="danger"
onClick={async () => {
'Leave room',
`Are you sure that you want to leave "${room.name}" room?`,
'Leave',
- 'danger',
+ 'danger'
);
if (!isConfirmed) return;
roomActions.leave(roomId);
roomId: PropTypes.string.isRequired,
};
-function RoomSettings({ roomId }) {
- const [, forceUpdate] = useForceUpdate();
- const [selectedTab, setSelectedTab] = useState(tabItems[0]);
- const room = initMatrix.matrixClient.getRoom(roomId);
-
- const handleTabChange = (tabItem) => {
- setSelectedTab(tabItem);
- };
+function useWindowToggle(setSelectedTab) {
+ const [window, setWindow] = useState(null);
useEffect(() => {
- let mounted = true;
- const settingsToggle = (isVisible, tab) => {
- if (!mounted) return;
- if (isVisible) {
- const tabItem = tabItems.find((item) => item.text === tab);
- if (tabItem) setSelectedTab(tabItem);
- forceUpdate();
- } else setTimeout(() => forceUpdate(), 200);
+ const openRoomSettings = (roomId, tab) => {
+ setWindow({ roomId, tabText });
+ const tabItem = tabItems.find((item) => item.text === tab);
+ if (tabItem) setSelectedTab(tabItem);
};
- navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
+ navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, openRoomSettings);
return () => {
- mounted = false;
- navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
+ navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, openRoomSettings);
};
- }, []);
+ }, [setSelectedTab]);
+
+ const requestClose = () => setWindow(null);
- if (!navigation.isRoomSettings) return null;
+ return [window, requestClose];
+}
+
+function RoomSettings() {
+ const [selectedTab, setSelectedTab] = useState(tabItems[0]);
+ const [window, requestClose] = useWindowToggle(setSelectedTab);
+ const isOpen = window !== null;
+ const roomId = window?.roomId;
+ const room = initMatrix.matrixClient.getRoom(roomId);
+
+ const handleTabChange = (tabItem) => {
+ setSelectedTab(tabItem);
+ };
return (
- <div className="room-settings">
- <ScrollView autoHide>
+ <PopupWindow
+ isOpen={isOpen}
+ className="room-settings"
+ title={
+ <Text variant="s1" weight="medium" primary>
+ {isOpen && room.name}
+ <span style={{ color: 'var(--tc-surface-low)' }}> — room settings</span>
+ </Text>
+ }
+ contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
+ onRequestClose={requestClose}
+ >
+ {isOpen && (
<div className="room-settings__content">
- <Header>
- <button
- className="room-settings__header-btn"
- onClick={() => toggleRoomSettings()}
- type="button"
- onMouseUp={(e) => blurOnBubbling(e, '.room-settings__header-btn')}
- >
- <TitleWrapper>
- <Text variant="s1" weight="medium" primary>
- {`${room.name}`}
- <span style={{ color: 'var(--tc-surface-low)' }}> — room settings</span>
- </Text>
- </TitleWrapper>
- <RawIcon size="small" src={ChevronTopIC} />
- </button>
- </Header>
<RoomProfile roomId={roomId} />
<Tabs
items={tabItems}
/>
<div className="room-settings__cards-wrapper">
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
- {selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
</div>
</div>
- </ScrollView>
- </div>
+ )}
+ </PopupWindow>
);
}
-RoomSettings.propTypes = {
- roomId: PropTypes.string.isRequired,
-};
-
export default RoomSettings;
export { tabText };
@use '../../partials/flex';
.room-settings {
- height: 100%;
- & .scrollbar {
- position: relative;
- }
-
- & .header {
- padding: 0 var(--sp-extra-tight);
+ & .pw {
+ background-color: var(--bg-surface-low);
}
- &__header-btn {
- min-width: 0;
- @extend .cp-fx__row--s-c;
- @include dir.side(margin, 0, auto);
- padding: var(--sp-ultra-tight) var(--sp-extra-tight);
- border-radius: calc(var(--bo-radius) / 2);
- cursor: pointer;
-
- @media (hover:hover) {
- &:hover {
- background-color: var(--bg-surface-hover);
- box-shadow: var(--bs-surface-outline);
- }
- }
- &:focus,
- &:active {
- background-color: var(--bg-surface-active);
- box-shadow: var(--bs-surface-outline);
- outline: none;
- }
+ & .room-profile {
+ padding: var(--sp-loose) var(--sp-extra-loose);
}
- &__content {
- padding-bottom: calc(2 * var(--sp-extra-loose));
-
- & .room-profile {
- margin: var(--sp-extra-loose);
- }
+ & .tabs__content {
+ padding: 0 var(--sp-normal);
}
-
- & .tabs {
- position: sticky;
- top: 0;
- z-index: 999;
- width: 100%;
- background-color: var(--bg-surface-low);
- box-shadow: 0 -4px 0 var(--bg-surface-low),
- inset 0 -1px 0 var(--bg-surface-border);
- &__content {
- padding: 0 var(--sp-normal);
- }
- }
-
&__cards-wrapper {
padding: 0 var(--sp-normal);
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
.room-settings .room-permissions__card,
.room-settings .room-search__form,
-.room-settings .room-search__result-item ,
+.room-settings .room-search__result-item,
.room-settings .room-members {
@extend .room-settings__card;
-}
\ No newline at end of file
+}
+++ /dev/null
-import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
-import { DefaultReset, config } from 'folds';
-
-export const TimelineFloat = recipe({
- base: [
- DefaultReset,
- {
- position: 'absolute',
- left: '50%',
- transform: 'translateX(-50%)',
- zIndex: 1,
- minWidth: 'max-content',
- },
- ],
- variants: {
- position: {
- Top: {
- top: config.space.S400,
- },
- Bottom: {
- bottom: config.space.S400,
- },
- },
- },
- defaultVariants: {
- position: 'Top',
- },
-});
-
-export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
+++ /dev/null
-import React, {
- Dispatch,
- MouseEventHandler,
- RefObject,
- SetStateAction,
- useCallback,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import {
- Direction,
- EventTimeline,
- EventTimelineSet,
- EventTimelineSetHandlerMap,
- IEncryptedFile,
- MatrixClient,
- MatrixEvent,
- Room,
- RoomEvent,
- RoomEventHandlerMap,
-} from 'matrix-js-sdk';
-import parse, { HTMLReactParserOptions } from 'html-react-parser';
-import classNames from 'classnames';
-import { ReactEditor } from 'slate-react';
-import { Editor } from 'slate';
-import to from 'await-to-js';
-import { useSetAtom } from 'jotai';
-import {
- Badge,
- Box,
- Chip,
- ContainerColor,
- Icon,
- Icons,
- Line,
- Scroll,
- Text,
- as,
- color,
- config,
- toRem,
-} from 'folds';
-import { isKeyHotkey } from 'is-hotkey';
-import {
- decryptFile,
- eventWithShortcode,
- factoryEventSentBy,
- getMxIdLocalPart,
- isRoomId,
- isUserId,
-} from '../../utils/matrix';
-import { sanitizeCustomHtml } from '../../utils/sanitize';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
-import { useAlive } from '../../hooks/useAlive';
-import { editableActiveElement, scrollToBottom } from '../../utils/dom';
-import {
- DefaultPlaceholder,
- CompactPlaceholder,
- Reply,
- MessageBase,
- MessageDeletedContent,
- MessageBrokenContent,
- MessageUnsupportedContent,
- MessageEditedContent,
- MessageEmptyContent,
- AttachmentBox,
- Attachment,
- AttachmentContent,
- AttachmentHeader,
- Time,
- MessageBadEncryptedContent,
- MessageNotDecryptedContent,
- MessageTextBody,
-} from '../../components/message';
-import {
- emojifyAndLinkify,
- getReactCustomHtmlParser,
-} from '../../plugins/react-custom-html-parser';
-import {
- canEditEvent,
- decryptAllTimelineEvent,
- getEditedEvent,
- getEventReactions,
- getLatestEditableEvt,
- getMemberDisplayName,
- getReactionContent,
- isMembershipChanged,
- reactionOrEditEvent,
- trimReplyFromBody,
-} from '../../utils/room';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-import {
- openJoinAlias,
- openProfileViewer,
- selectRoom,
- selectTab,
-} from '../../../client/action/navigation';
-import { useForceUpdate } from '../../hooks/useForceUpdate';
-import { parseGeoUri, scaleYDimension } from '../../utils/common';
-import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
-import { useRoomMsgContentRenderer } from '../../hooks/useRoomMsgContentRenderer';
-import { IAudioContent, IImageContent, IVideoContent } from '../../../types/matrix/common';
-import { getBlobSafeMimeType } from '../../utils/mimeTypes';
-import {
- ImageContent,
- VideoContent,
- FileHeader,
- fileRenderer,
- AudioContent,
- Reactions,
- EventContent,
- Message,
- Event,
- EncryptedContent,
- StickerContent,
-} from './message';
-import { useMemberEventParser } from '../../hooks/useMemberEventParser';
-import * as customHtmlCss from '../../styles/CustomHtml.css';
-import { RoomIntro } from '../../components/room-intro';
-import {
- getIntersectionObserverEntry,
- useIntersectionObserver,
-} from '../../hooks/useIntersectionObserver';
-import { markAsRead } from '../../../client/action/notifications';
-import { useDebounce } from '../../hooks/useDebounce';
-import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
-import * as css from './RoomTimeline.css';
-import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
-import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
-import { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
-import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
-import { MessageEvent } from '../../../types/matrix/room';
-import initMatrix from '../../../client/initMatrix';
-import { useKeyDown } from '../../hooks/useKeyDown';
-import cons from '../../../client/state/cons';
-import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
-import { EMOJI_PATTERN, HTTP_URL_PATTERN, VARIATION_SELECTOR_PATTERN } from '../../utils/regex';
-import { UrlPreviewCard, UrlPreviewHolder } from './message/UrlPreviewCard';
-
-// Thumbs up emoji found to have Variation Selector 16 at the end
-// so included variation selector pattern in regex
-const JUMBO_EMOJI_REG = new RegExp(
- `^(((${EMOJI_PATTERN})|(:.+?:))(${VARIATION_SELECTOR_PATTERN}|\\s)*){1,10}$`
-);
-const URL_REG = new RegExp(HTTP_URL_PATTERN, 'g');
-
-const TimelineFloat = as<'div', css.TimelineFloatVariants>(
- ({ position, className, ...props }, ref) => (
- <Box
- className={classNames(css.TimelineFloat({ position }), className)}
- justifyContent="Center"
- alignItems="Center"
- gap="200"
- {...props}
- ref={ref}
- />
- )
-);
-
-const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
- ({ variant, children, ...props }, ref) => (
- <Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
- <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
- {children}
- <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
- </Box>
- )
-);
-
-export const getLiveTimeline = (room: Room): EventTimeline =>
- room.getUnfilteredTimelineSet().getLiveTimeline();
-
-export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
- const timelineSet = room.getUnfilteredTimelineSet();
- return timelineSet.getTimelineForEvent(eventId) ?? undefined;
-};
-
-export const getFirstLinkedTimeline = (
- timeline: EventTimeline,
- direction: Direction
-): EventTimeline => {
- const linkedTm = timeline.getNeighbouringTimeline(direction);
- if (!linkedTm) return timeline;
- return getFirstLinkedTimeline(linkedTm, direction);
-};
-
-export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
- const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
- const timelines: EventTimeline[] = [];
-
- for (
- let nextTimeline: EventTimeline | null = firstTimeline;
- nextTimeline;
- nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
- ) {
- timelines.push(nextTimeline);
- }
- return timelines;
-};
-
-export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
-export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
- const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
- count + timelineToEventsCount(tm);
- return timelines.reduce(timelineEventCountReducer, 0);
-};
-
-export const getTimelineAndBaseIndex = (
- timelines: EventTimeline[],
- index: number
-): [EventTimeline | undefined, number] => {
- let uptoTimelineLen = 0;
- const timeline = timelines.find((t) => {
- uptoTimelineLen += t.getEvents().length;
- if (index < uptoTimelineLen) return true;
- return false;
- });
- if (!timeline) return [undefined, 0];
- return [timeline, uptoTimelineLen - timeline.getEvents().length];
-};
-
-export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
- absoluteIndex - timelineBaseIndex;
-
-export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
- timeline.getEvents()[index];
-
-export const getEventIdAbsoluteIndex = (
- timelines: EventTimeline[],
- eventTimeline: EventTimeline,
- eventId: string
-): number | undefined => {
- const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
- if (timelineIndex === -1) return undefined;
- const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
- if (eventIndex === -1) return undefined;
- const baseIndex = timelines
- .slice(0, timelineIndex)
- .reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
- return baseIndex + eventIndex;
-};
-
-export const factoryGetFileSrcUrl =
- (httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
- if (encFile) {
- if (typeof httpUrl !== 'string') throw new Error('Malformed event');
- const encRes = await fetch(httpUrl, { method: 'GET' });
- const encData = await encRes.arrayBuffer();
- const decryptedBlob = await decryptFile(encData, mimeType, encFile);
- return URL.createObjectURL(decryptedBlob);
- }
- return httpUrl;
- };
-
-type RoomTimelineProps = {
- room: Room;
- eventId?: string;
- roomInputRef: RefObject<HTMLElement>;
- editor: Editor;
-};
-
-const PAGINATION_LIMIT = 80;
-
-type Timeline = {
- linkedTimelines: EventTimeline[];
- range: ItemRange;
-};
-
-const useEventTimelineLoader = (
- mx: MatrixClient,
- room: Room,
- onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
- onError: (err: Error | null) => void
-) => {
- const loadEventTimeline = useCallback(
- async (eventId: string) => {
- const [err, replyEvtTimeline] = await to(
- mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
- );
- if (!replyEvtTimeline) {
- onError(err ?? null);
- return;
- }
- const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
- const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
-
- if (absIndex === undefined) {
- onError(err ?? null);
- return;
- }
-
- onLoad(eventId, linkedTimelines, absIndex);
- },
- [mx, room, onLoad, onError]
- );
-
- return loadEventTimeline;
-};
-
-const useTimelinePagination = (
- mx: MatrixClient,
- timeline: Timeline,
- setTimeline: Dispatch<SetStateAction<Timeline>>,
- limit: number
-) => {
- const timelineRef = useRef(timeline);
- timelineRef.current = timeline;
- const alive = useAlive();
-
- const handleTimelinePagination = useMemo(() => {
- let fetching = false;
-
- const recalibratePagination = (
- linkedTimelines: EventTimeline[],
- timelinesEventsCount: number[],
- backwards: boolean
- ) => {
- const topTimeline = linkedTimelines[0];
- const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
-
- const newLTimelines = getLinkedTimelines(topTimeline);
- const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
- const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
-
- const topTmAddedEvt =
- timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
- const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
-
- setTimeline((currentTimeline) => ({
- linkedTimelines: newLTimelines,
- range:
- offsetRange > 0
- ? {
- start: currentTimeline.range.start + offsetRange,
- end: currentTimeline.range.end + offsetRange,
- }
- : { ...currentTimeline.range },
- }));
- };
-
- return async (backwards: boolean) => {
- if (fetching) return;
- const { linkedTimelines: lTimelines } = timelineRef.current;
- const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
-
- const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
- if (!timelineToPaginate) return;
-
- const paginationToken = timelineToPaginate.getPaginationToken(
- backwards ? Direction.Backward : Direction.Forward
- );
- if (
- !paginationToken &&
- getTimelinesEventsCount(lTimelines) !==
- getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
- ) {
- recalibratePagination(lTimelines, timelinesEventsCount, backwards);
- return;
- }
-
- fetching = true;
- const [err] = await to(
- mx.paginateEventTimeline(timelineToPaginate, {
- backwards,
- limit,
- })
- );
- if (err) {
- // TODO: handle pagination error.
- return;
- }
- const fetchedTimeline =
- timelineToPaginate.getNeighbouringTimeline(
- backwards ? Direction.Backward : Direction.Forward
- ) ?? timelineToPaginate;
- // Decrypt all event ahead of render cycle
- if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
- await to(decryptAllTimelineEvent(mx, fetchedTimeline));
- }
-
- fetching = false;
- if (alive()) {
- recalibratePagination(lTimelines, timelinesEventsCount, backwards);
- }
- };
- }, [mx, alive, setTimeline, limit]);
- return handleTimelinePagination;
-};
-
-const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
- useEffect(() => {
- const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
- mEvent,
- eventRoom,
- toStartOfTimeline,
- removed,
- data
- ) => {
- if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
- onArrive(mEvent);
- };
- const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
- if (eventRoom?.roomId !== room.roomId) return;
- onArrive(mEvent);
- };
-
- room.on(RoomEvent.Timeline, handleTimelineEvent);
- room.on(RoomEvent.Redaction, handleRedaction);
- return () => {
- room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
- room.removeListener(RoomEvent.Redaction, handleRedaction);
- };
- }, [room, onArrive]);
-};
-
-const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
- useEffect(() => {
- const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
- if (r.roomId !== room.roomId) return;
- onRefresh();
- };
-
- room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
- return () => {
- room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
- };
- }, [room, onRefresh]);
-};
-
-const getInitialTimeline = (room: Room) => {
- const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
- const evLength = getTimelinesEventsCount(linkedTimelines);
- return {
- linkedTimelines,
- range: {
- start: Math.max(evLength - PAGINATION_LIMIT, 0),
- end: evLength,
- },
- };
-};
-
-const getEmptyTimeline = () => ({
- range: { start: 0, end: 0 },
- linkedTimelines: [],
-});
-
-const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
- const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? '');
- if (!readUptoEventId) return undefined;
- const evtTimeline = getEventTimeline(room, readUptoEventId);
- const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
- return {
- readUptoEventId,
- inLiveTimeline: latestTimeline === room.getLiveTimeline(),
- scrollTo,
- };
-};
-
-export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
- const mx = useMatrixClient();
- const encryptedRoom = mx.isRoomEncrypted(room.roomId);
- const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
- const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
- const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
- const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
- const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
- const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
- const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
- const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
- const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
- const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
- const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI();
- const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
- const canRedact = canDoAction('redact', myPowerLevel);
- const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
- const [editId, setEditId] = useState<string>();
-
- const imagePackRooms: Room[] = useMemo(() => {
- const allParentSpaces = [
- room.roomId,
- ...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
- ];
- return allParentSpaces.reduce<Room[]>((list, rId) => {
- const r = mx.getRoom(rId);
- if (r) list.push(r);
- return list;
- }, []);
- }, [mx, room]);
-
- const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
- const readUptoEventIdRef = useRef<string>();
- if (unreadInfo) {
- readUptoEventIdRef.current = unreadInfo.readUptoEventId;
- }
-
- const atBottomAnchorRef = useRef<HTMLElement>(null);
- const [atBottom, setAtBottom] = useState<boolean>(true);
- const atBottomRef = useRef(atBottom);
- atBottomRef.current = atBottom;
-
- const scrollRef = useRef<HTMLDivElement>(null);
- const scrollToBottomRef = useRef({
- count: 0,
- smooth: true,
- });
-
- const focusItem = useRef<{
- index: number;
- scrollTo: boolean;
- highlight: boolean;
- }>();
- const alive = useAlive();
- const [, forceUpdate] = useForceUpdate();
-
- const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
- () =>
- getReactCustomHtmlParser(mx, room, {
- handleSpoilerClick: (evt) => {
- const target = evt.currentTarget;
- if (target.getAttribute('aria-pressed') === 'true') {
- evt.stopPropagation();
- target.setAttribute('aria-pressed', 'false');
- target.style.cursor = 'initial';
- }
- },
- handleMentionClick: (evt) => {
- const target = evt.currentTarget;
- const mentionId = target.getAttribute('data-mention-id');
- if (typeof mentionId !== 'string') return;
- if (isUserId(mentionId)) {
- openProfileViewer(mentionId, room.roomId);
- return;
- }
- if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
- if (mx.getRoom(mentionId)?.isSpaceRoom()) selectTab(mentionId);
- else selectRoom(mentionId);
- return;
- }
- openJoinAlias(mentionId);
- },
- }),
- [mx, room]
- );
- const parseMemberEvent = useMemberEventParser();
-
- const [timeline, setTimeline] = useState<Timeline>(() =>
- eventId ? getEmptyTimeline() : getInitialTimeline(room)
- );
- const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
- const liveTimelineLinked =
- timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
- const canPaginateBack =
- typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
- const rangeAtStart = timeline.range.start === 0;
- const rangeAtEnd = timeline.range.end === eventsLength;
- const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
- atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
-
- const handleTimelinePagination = useTimelinePagination(
- mx,
- timeline,
- setTimeline,
- PAGINATION_LIMIT
- );
-
- const getScrollElement = useCallback(() => scrollRef.current, []);
-
- const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
- useVirtualPaginator({
- count: eventsLength,
- limit: PAGINATION_LIMIT,
- range: timeline.range,
- onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
- getScrollElement,
- getItemElement: useCallback(
- (index: number) =>
- (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
- undefined,
- []
- ),
- onEnd: handleTimelinePagination,
- });
-
- const loadEventTimeline = useEventTimelineLoader(
- mx,
- room,
- useCallback(
- (evtId, lTimelines, evtAbsIndex) => {
- if (!alive()) return;
- const evLength = getTimelinesEventsCount(lTimelines);
-
- focusItem.current = {
- index: evtAbsIndex,
- scrollTo: true,
- highlight: evtId !== readUptoEventIdRef.current,
- };
- setTimeline({
- linkedTimelines: lTimelines,
- range: {
- start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
- end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
- },
- });
- },
- [alive]
- ),
- useCallback(() => {
- if (!alive()) return;
- setTimeline(getInitialTimeline(room));
- scrollToBottomRef.current.count += 1;
- scrollToBottomRef.current.smooth = false;
- }, [alive, room])
- );
-
- useLiveEventArrive(
- 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())) {
- requestAnimationFrame(() => markAsRead(mEvt.getRoomId()));
- }
-
- if (document.hasFocus()) {
- scrollToBottomRef.current.count += 1;
- scrollToBottomRef.current.smooth = true;
- } else if (!unreadInfo) {
- setUnreadInfo(getRoomUnreadInfo(room));
- }
- setTimeline((ct) => ({
- ...ct,
- range: {
- start: ct.range.start + 1,
- end: ct.range.end + 1,
- },
- }));
- return;
- }
- setTimeline((ct) => ({ ...ct }));
- if (!unreadInfo) {
- setUnreadInfo(getRoomUnreadInfo(room));
- }
- },
- [mx, room, unreadInfo]
- )
- );
-
- useLiveTimelineRefresh(
- room,
- useCallback(() => {
- if (liveTimelineLinked) {
- setTimeline(getInitialTimeline(room));
- }
- }, [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;
- }
- if (!roomInputRef.current) return;
- const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
- const scrollElement = getScrollElement();
- if (!editorBaseEntry || !scrollElement) return;
-
- if (atBottomRef.current) {
- scrollToBottom(scrollElement);
- }
- };
- }, [getScrollElement, roomInputRef]),
- useCallback(() => roomInputRef.current, [roomInputRef])
- );
-
- const tryAutoMarkAsRead = useCallback(() => {
- if (!unreadInfo) {
- requestAnimationFrame(() => markAsRead(room.roomId));
- return;
- }
- const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
- const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
- if (latestTimeline === room.getLiveTimeline()) {
- requestAnimationFrame(() => markAsRead(room.roomId));
- }
- }, [room, unreadInfo]);
-
- const debounceSetAtBottom = useDebounce(
- useCallback((entry: IntersectionObserverEntry) => {
- if (!entry.isIntersecting) setAtBottom(false);
- }, []),
- { wait: 1000 }
- );
- useIntersectionObserver(
- useCallback(
- (entries) => {
- const target = atBottomAnchorRef.current;
- if (!target) return;
- const targetEntry = getIntersectionObserverEntry(target, entries);
- if (targetEntry) debounceSetAtBottom(targetEntry);
- if (targetEntry?.isIntersecting && atLiveEndRef.current) {
- setAtBottom(true);
- tryAutoMarkAsRead();
- }
- },
- [debounceSetAtBottom, tryAutoMarkAsRead]
- ),
- useCallback(
- () => ({
- root: getScrollElement(),
- rootMargin: '100px',
- }),
- [getScrollElement]
- ),
- useCallback(() => atBottomAnchorRef.current, [])
- );
-
- useDocumentFocusChange(
- useCallback(
- (inFocus) => {
- if (inFocus && atBottomRef.current) {
- tryAutoMarkAsRead();
- }
- },
- [tryAutoMarkAsRead]
- )
- );
-
- // Handle up arrow edit
- useKeyDown(
- window,
- useCallback(
- (evt) => {
- if (
- isKeyHotkey('arrowup', evt) &&
- editableActiveElement() &&
- document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
- isEmptyEditor(editor)
- ) {
- const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
- canEditEvent(mx, mEvt)
- );
- const editableEvtId = editableEvt?.getId();
- if (!editableEvtId) return;
- setEditId(editableEvtId);
- }
- },
- [mx, room, editor]
- )
- );
-
- useEffect(() => {
- if (eventId) {
- setTimeline(getEmptyTimeline());
- loadEventTimeline(eventId);
- }
- }, [eventId, loadEventTimeline]);
-
- // Scroll to bottom on initial timeline load
- useLayoutEffect(() => {
- const scrollEl = scrollRef.current;
- if (scrollEl) {
- scrollToBottom(scrollEl);
- }
- }, []);
-
- // if live timeline is linked and unreadInfo change
- // Scroll to last read message
- useLayoutEffect(() => {
- const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
- if (readUptoEventId && inLiveTimeline && scrollTo) {
- const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
- const evtTimeline = getEventTimeline(room, readUptoEventId);
- const absoluteIndex =
- evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
- if (absoluteIndex) {
- scrollToItem(absoluteIndex, {
- behavior: 'instant',
- align: 'start',
- stopInView: true,
- });
- }
- }
- }, [room, unreadInfo, scrollToItem]);
-
- // scroll to focused message
- const focusItm = focusItem.current;
- useLayoutEffect(() => {
- if (focusItm && focusItm.scrollTo) {
- scrollToItem(focusItm.index, {
- behavior: 'instant',
- align: 'center',
- stopInView: true,
- });
- }
-
- focusItem.current = undefined;
- }, [focusItm, scrollToItem]);
-
- // scroll to bottom of timeline
- const scrollToBottomCount = scrollToBottomRef.current.count;
- useLayoutEffect(() => {
- if (scrollToBottomCount > 0) {
- const scrollEl = scrollRef.current;
- if (scrollEl)
- scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
- }
- }, [scrollToBottomCount]);
-
- // Remove unreadInfo on mark as read
- useEffect(() => {
- const handleFullRead = (rId: string) => {
- if (rId !== room.roomId) return;
- setUnreadInfo(undefined);
- };
- initMatrix.notifications?.on(cons.events.notifications.FULL_READ, handleFullRead);
- return () => {
- initMatrix.notifications?.removeListener(cons.events.notifications.FULL_READ, handleFullRead);
- };
- }, [room]);
-
- // scroll out of view msg editor in view.
- useEffect(() => {
- if (editId) {
- const editMsgElement =
- (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
- undefined;
- if (editMsgElement) {
- scrollToElement(editMsgElement, {
- align: 'center',
- behavior: 'smooth',
- stopInView: true,
- });
- }
- }
- }, [scrollToElement, editId]);
-
- const handleJumpToLatest = () => {
- setTimeline(getInitialTimeline(room));
- scrollToBottomRef.current.count += 1;
- scrollToBottomRef.current.smooth = false;
- };
-
- const handleJumpToUnread = () => {
- if (unreadInfo?.readUptoEventId) {
- setTimeline(getEmptyTimeline());
- loadEventTimeline(unreadInfo.readUptoEventId);
- }
- };
-
- const handleMarkAsRead = () => {
- markAsRead(room.roomId);
- };
-
- const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
- async (evt) => {
- const replyId = evt.currentTarget.getAttribute('data-reply-id');
- if (typeof replyId !== 'string') return;
- const replyTimeline = getEventTimeline(room, replyId);
- const absoluteIndex =
- replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId);
-
- if (typeof absoluteIndex === 'number') {
- scrollToItem(absoluteIndex, {
- behavior: 'smooth',
- align: 'center',
- stopInView: true,
- });
- focusItem.current = {
- index: absoluteIndex,
- scrollTo: false,
- highlight: true,
- };
- forceUpdate();
- } else {
- setTimeline(getEmptyTimeline());
- loadEventTimeline(replyId);
- }
- },
- [room, timeline, scrollToItem, loadEventTimeline, forceUpdate]
- );
-
- const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
- (evt) => {
- evt.preventDefault();
- evt.stopPropagation();
- const userId = evt.currentTarget.getAttribute('data-user-id');
- if (!userId) {
- console.warn('Button should have "data-user-id" attribute!');
- return;
- }
- openProfileViewer(userId, room.roomId);
- },
- [room]
- );
- const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
- (evt) => {
- evt.preventDefault();
- const userId = evt.currentTarget.getAttribute('data-user-id');
- if (!userId) {
- console.warn('Button should have "data-user-id" attribute!');
- return;
- }
- const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
- editor.insertNode(
- createMentionElement(
- userId,
- name.startsWith('@') ? name : `@${name}`,
- userId === mx.getUserId()
- )
- );
- ReactEditor.focus(editor);
- moveCursor(editor);
- },
- [mx, room, editor]
- );
-
- const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
- (evt) => {
- const replyId = evt.currentTarget.getAttribute('data-event-id');
- if (!replyId) {
- console.warn('Button should have "data-event-id" attribute!');
- return;
- }
- const replyEvt = room.findEventById(replyId);
- if (!replyEvt) return;
- const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
- const { body, formatted_body: formattedBody }: Record<string, string> =
- editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
- const senderId = replyEvt.getSender();
- if (senderId && typeof body === 'string') {
- setReplyDraft({
- userId: senderId,
- eventId: replyId,
- body,
- formattedBody,
- });
- setTimeout(() => ReactEditor.focus(editor), 100);
- }
- },
- [room, setReplyDraft, editor]
- );
-
- const handleReactionToggle = useCallback(
- (targetEventId: string, key: string, shortcode?: string) => {
- const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
- const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
- const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
- const reactions = reactionsSet ? Array.from(reactionsSet) : [];
- const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
-
- if (myReaction && !!myReaction?.isRelation()) {
- mx.redactEvent(room.roomId, myReaction.getId()!);
- return;
- }
- const rShortcode =
- shortcode ||
- (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
- mx.sendEvent(
- room.roomId,
- MessageEvent.Reaction,
- getReactionContent(targetEventId, key, rShortcode)
- );
- },
- [mx, room]
- );
- const handleEdit = useCallback(
- (editEvtId?: string) => {
- if (editEvtId) {
- setEditId(editEvtId);
- return;
- }
- setEditId(undefined);
- ReactEditor.focus(editor);
- },
- [editor]
- );
-
- const renderBody = (body: string, customBody?: string) => {
- if (body === '') <MessageEmptyContent />;
- if (customBody) {
- if (customBody === '') <MessageEmptyContent />;
- return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
- }
- return emojifyAndLinkify(body, true);
- };
-
- const renderRoomMsgContent = useRoomMsgContentRenderer<[EventTimelineSet]>({
- renderText: (mEventId, mEvent, timelineSet) => {
- const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
- const { body, formatted_body: customBody }: Record<string, unknown> =
- editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
-
- if (typeof body !== 'string') return null;
- const trimmedBody = trimReplyFromBody(body);
- const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
- const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
-
- return (
- <>
- <MessageTextBody
- preWrap={typeof customBody !== 'string'}
- jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
- >
- {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
- {!!editedEvent && <MessageEditedContent />}
- </MessageTextBody>
- {urls && urls.length > 0 && (
- <UrlPreviewHolder>
- {urls.map((url) => (
- <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
- ))}
- </UrlPreviewHolder>
- )}
- </>
- );
- },
- renderEmote: (mEventId, mEvent, timelineSet) => {
- const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
- const { body, formatted_body: customBody } =
- editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
- const senderId = mEvent.getSender() ?? '';
-
- const senderDisplayName =
- getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
-
- if (typeof body !== 'string') return null;
- const trimmedBody = trimReplyFromBody(body);
- const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
- const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
-
- return (
- <>
- <MessageTextBody
- emote
- preWrap={typeof customBody !== 'string'}
- jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
- >
- <b>{`${senderDisplayName} `}</b>
- {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
- {!!editedEvent && <MessageEditedContent />}
- </MessageTextBody>
- {urls && urls.length > 0 && (
- <UrlPreviewHolder>
- {urls.map((url) => (
- <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
- ))}
- </UrlPreviewHolder>
- )}
- </>
- );
- },
- renderNotice: (mEventId, mEvent, timelineSet) => {
- const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
- const { body, formatted_body: customBody }: Record<string, unknown> =
- editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
-
- if (typeof body !== 'string') return null;
- const trimmedBody = trimReplyFromBody(body);
- const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
- const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
-
- return (
- <>
- <MessageTextBody
- notice
- preWrap={typeof customBody !== 'string'}
- jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
- >
- {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
- {!!editedEvent && <MessageEditedContent />}
- </MessageTextBody>
- {urls && urls.length > 0 && (
- <UrlPreviewHolder>
- {urls.map((url) => (
- <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
- ))}
- </UrlPreviewHolder>
- )}
- </>
- );
- },
- renderImage: (mEventId, mEvent) => {
- const content = mEvent.getContent<IImageContent>();
- const imgInfo = content?.info;
- const mxcUrl = content.file?.url ?? content.url;
- if (typeof mxcUrl !== 'string') {
- return null;
- }
- const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
-
- return (
- <Attachment>
- <AttachmentBox
- style={{
- height: toRem(height < 48 ? 48 : height),
- }}
- >
- <ImageContent
- body={content.body || 'Image'}
- info={imgInfo}
- mimeType={imgInfo?.mimetype}
- url={mxcUrl}
- encInfo={content.file}
- autoPlay={mediaAutoLoad}
- />
- </AttachmentBox>
- </Attachment>
- );
- },
- renderVideo: (mEventId, mEvent) => {
- const content = mEvent.getContent<IVideoContent>();
-
- const videoInfo = content?.info;
- const mxcUrl = content.file?.url ?? content.url;
- const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
-
- if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
- if (mxcUrl) {
- return fileRenderer(mEventId, mEvent);
- }
- return null;
- }
-
- const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
-
- return (
- <Attachment>
- <AttachmentBox
- style={{
- height: toRem(height < 48 ? 48 : height),
- }}
- >
- <VideoContent
- body={content.body || 'Video'}
- info={videoInfo}
- mimeType={safeMimeType}
- url={mxcUrl}
- encInfo={content.file}
- loadThumbnail={mediaAutoLoad}
- />
- </AttachmentBox>
- </Attachment>
- );
- },
- renderAudio: (mEventId, mEvent) => {
- const content = mEvent.getContent<IAudioContent>();
-
- const audioInfo = content?.info;
- const mxcUrl = content.file?.url ?? content.url;
- const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
-
- if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
- if (mxcUrl) {
- return fileRenderer(mEventId, mEvent);
- }
- return null;
- }
-
- return (
- <Attachment>
- <AttachmentHeader>
- <FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
- </AttachmentHeader>
- <AttachmentBox>
- <AttachmentContent>
- <AudioContent
- info={audioInfo}
- mimeType={safeMimeType}
- url={mxcUrl}
- encInfo={content.file}
- />
- </AttachmentContent>
- </AttachmentBox>
- </Attachment>
- );
- },
- renderLocation: (mEventId, mEvent) => {
- const content = mEvent.getContent();
- const geoUri = content.geo_uri;
- if (typeof geoUri !== 'string') return null;
- const location = parseGeoUri(geoUri);
- return (
- <Box direction="Column" alignItems="Start" gap="100">
- <Text size="T400">{geoUri}</Text>
- <Chip
- as="a"
- size="400"
- href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
- target="_blank"
- rel="noreferrer noopener"
- variant="Primary"
- radii="Pill"
- before={<Icon src={Icons.External} size="50" />}
- >
- <Text size="B300">Open Location</Text>
- </Chip>
- </Box>
- );
- },
- renderFile: fileRenderer,
- renderBadEncrypted: () => (
- <Text>
- <MessageBadEncryptedContent />
- </Text>
- ),
- renderUnsupported: (mEventId, mEvent) => {
- if (mEvent.isRedacted()) {
- const redactedEvt = mEvent.getRedactionEvent();
- const reason =
- redactedEvt && 'content' in redactedEvt ? redactedEvt.content.reason : undefined;
-
- return (
- <Text>
- <MessageDeletedContent reason={reason} />
- </Text>
- );
- }
- return (
- <Text>
- <MessageUnsupportedContent />
- </Text>
- );
- },
- renderBrokenFallback: (mEventId, mEvent) => {
- if (mEvent.isRedacted()) {
- const redactedEvt = mEvent.getRedactionEvent();
- const reason =
- redactedEvt && 'content' in redactedEvt ? redactedEvt.content.reason : undefined;
- return (
- <Text>
- <MessageDeletedContent reason={reason} />
- </Text>
- );
- }
- return (
- <Text>
- <MessageBrokenContent />
- </Text>
- );
- },
- });
-
- const renderMatrixEvent = useMatrixEventRenderer<[number, EventTimelineSet, boolean]>({
- renderRoomMessage: (mEventId, mEvent, item, timelineSet, collapse) => {
- const reactionRelations = getEventReactions(timelineSet, mEventId);
- const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
- const hasReactions = reactions && reactions.length > 0;
- const { replyEventId } = mEvent;
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-
- return (
- <Message
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- messageSpacing={messageSpacing}
- messageLayout={messageLayout}
- collapse={collapse}
- highlight={highlighted}
- edit={editId === mEventId}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- canSendReaction={canSendReaction}
- imagePackRooms={imagePackRooms}
- relations={hasReactions ? reactionRelations : undefined}
- onUserClick={handleUserClick}
- onUsernameClick={handleUsernameClick}
- onReplyClick={handleReplyClick}
- onReactionToggle={handleReactionToggle}
- onEditId={handleEdit}
- reply={
- replyEventId && (
- <Reply
- as="button"
- mx={mx}
- room={room}
- timelineSet={timelineSet}
- eventId={replyEventId}
- data-reply-id={replyEventId}
- onClick={handleOpenReply}
- />
- )
- }
- reactions={
- reactionRelations && (
- <Reactions
- style={{ marginTop: config.space.S200 }}
- room={room}
- relations={reactionRelations}
- mEventId={mEventId}
- canSendReaction={canSendReaction}
- onReactionToggle={handleReactionToggle}
- />
- )
- }
- >
- {renderRoomMsgContent(mEventId, mEvent, timelineSet)}
- </Message>
- );
- },
- renderRoomEncrypted: (mEventId, mEvent, item, timelineSet, collapse) => {
- const reactionRelations = getEventReactions(timelineSet, mEventId);
- const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
- const hasReactions = reactions && reactions.length > 0;
- const { replyEventId } = mEvent;
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-
- return (
- <Message
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- messageSpacing={messageSpacing}
- messageLayout={messageLayout}
- collapse={collapse}
- highlight={highlighted}
- edit={editId === mEventId}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- canSendReaction={canSendReaction}
- imagePackRooms={imagePackRooms}
- relations={hasReactions ? reactionRelations : undefined}
- onUserClick={handleUserClick}
- onUsernameClick={handleUsernameClick}
- onReplyClick={handleReplyClick}
- onReactionToggle={handleReactionToggle}
- onEditId={handleEdit}
- reply={
- replyEventId && (
- <Reply
- as="button"
- mx={mx}
- room={room}
- timelineSet={timelineSet}
- eventId={replyEventId}
- data-reply-id={replyEventId}
- onClick={handleOpenReply}
- />
- )
- }
- reactions={
- reactionRelations && (
- <Reactions
- style={{ marginTop: config.space.S200 }}
- room={room}
- relations={reactionRelations}
- mEventId={mEventId}
- canSendReaction={canSendReaction}
- onReactionToggle={handleReactionToggle}
- />
- )
- }
- >
- <EncryptedContent mEvent={mEvent}>
- {() => {
- if (mEvent.isRedacted()) return <MessageDeletedContent />;
- if (mEvent.getType() === MessageEvent.Sticker)
- return <StickerContent mEvent={mEvent} autoPlay={mediaAutoLoad} />;
- if (mEvent.getType() === MessageEvent.RoomMessage)
- return renderRoomMsgContent(mEventId, mEvent, timelineSet);
- if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
- return (
- <Text>
- <MessageNotDecryptedContent />
- </Text>
- );
- return (
- <Text>
- <MessageUnsupportedContent />
- </Text>
- );
- }}
- </EncryptedContent>
- </Message>
- );
- },
- renderSticker: (mEventId, mEvent, item, timelineSet, collapse) => {
- const reactionRelations = getEventReactions(timelineSet, mEventId);
- const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
- const hasReactions = reactions && reactions.length > 0;
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-
- return (
- <Message
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- messageSpacing={messageSpacing}
- messageLayout={messageLayout}
- collapse={collapse}
- highlight={highlighted}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- canSendReaction={canSendReaction}
- imagePackRooms={imagePackRooms}
- relations={hasReactions ? reactionRelations : undefined}
- onUserClick={handleUserClick}
- onUsernameClick={handleUsernameClick}
- onReplyClick={handleReplyClick}
- onReactionToggle={handleReactionToggle}
- reactions={
- reactionRelations && (
- <Reactions
- style={{ marginTop: config.space.S200 }}
- room={room}
- relations={reactionRelations}
- mEventId={mEventId}
- canSendReaction={canSendReaction}
- onReactionToggle={handleReactionToggle}
- />
- )
- }
- >
- <StickerContent mEvent={mEvent} autoPlay={mediaAutoLoad} />
- </Message>
- );
- },
- renderRoomMember: (mEventId, mEvent, item) => {
- const membershipChanged = isMembershipChanged(mEvent);
- if (membershipChanged && hideMembershipEvents) return null;
- if (!membershipChanged && hideNickAvatarEvents) return null;
-
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
- const parsed = parseMemberEvent(mEvent);
-
- const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
- return (
- <Event
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- highlight={highlighted}
- messageSpacing={messageSpacing}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- >
- <EventContent
- messageLayout={messageLayout}
- time={timeJSX}
- iconSrc={parsed.icon}
- content={
- <Box grow="Yes" direction="Column">
- <Text size="T300" priority="300">
- {parsed.body}
- </Text>
- </Box>
- }
- />
- </Event>
- );
- },
- renderRoomName: (mEventId, mEvent, item) => {
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
- const senderId = mEvent.getSender() ?? '';
- const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
- const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
- return (
- <Event
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- highlight={highlighted}
- messageSpacing={messageSpacing}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- >
- <EventContent
- messageLayout={messageLayout}
- time={timeJSX}
- iconSrc={Icons.Hash}
- content={
- <Box grow="Yes" direction="Column">
- <Text size="T300" priority="300">
- <b>{senderName}</b>
- {' changed room name'}
- </Text>
- </Box>
- }
- />
- </Event>
- );
- },
- renderRoomTopic: (mEventId, mEvent, item) => {
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
- const senderId = mEvent.getSender() ?? '';
- const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
- const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
- return (
- <Event
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- highlight={highlighted}
- messageSpacing={messageSpacing}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- >
- <EventContent
- messageLayout={messageLayout}
- time={timeJSX}
- iconSrc={Icons.Hash}
- content={
- <Box grow="Yes" direction="Column">
- <Text size="T300" priority="300">
- <b>{senderName}</b>
- {' changed room topic'}
- </Text>
- </Box>
- }
- />
- </Event>
- );
- },
- renderRoomAvatar: (mEventId, mEvent, item) => {
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
- const senderId = mEvent.getSender() ?? '';
- const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
- const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
- return (
- <Event
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- highlight={highlighted}
- messageSpacing={messageSpacing}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- >
- <EventContent
- messageLayout={messageLayout}
- time={timeJSX}
- iconSrc={Icons.Hash}
- content={
- <Box grow="Yes" direction="Column">
- <Text size="T300" priority="300">
- <b>{senderName}</b>
- {' changed room avatar'}
- </Text>
- </Box>
- }
- />
- </Event>
- );
- },
- renderStateEvent: (mEventId, mEvent, item) => {
- if (!showHiddenEvents) return null;
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
- const senderId = mEvent.getSender() ?? '';
- const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
- const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
- return (
- <Event
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- highlight={highlighted}
- messageSpacing={messageSpacing}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- >
- <EventContent
- messageLayout={messageLayout}
- time={timeJSX}
- iconSrc={Icons.Code}
- content={
- <Box grow="Yes" direction="Column">
- <Text size="T300" priority="300">
- <b>{senderName}</b>
- {' sent '}
- <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
- {' state event'}
- </Text>
- </Box>
- }
- />
- </Event>
- );
- },
- renderEvent: (mEventId, mEvent, item) => {
- if (!showHiddenEvents) return null;
- if (Object.keys(mEvent.getContent()).length === 0) return null;
- if (mEvent.getRelation()) return null;
- if (mEvent.isRedaction()) return null;
-
- const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
- const senderId = mEvent.getSender() ?? '';
- const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
- const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
- return (
- <Event
- key={mEvent.getId()}
- data-message-item={item}
- data-message-id={mEventId}
- room={room}
- mEvent={mEvent}
- highlight={highlighted}
- messageSpacing={messageSpacing}
- canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
- >
- <EventContent
- messageLayout={messageLayout}
- time={timeJSX}
- iconSrc={Icons.Code}
- content={
- <Box grow="Yes" direction="Column">
- <Text size="T300" priority="300">
- <b>{senderName}</b>
- {' sent '}
- <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
- {' event'}
- </Text>
- </Box>
- }
- />
- </Event>
- );
- },
- });
-
- let prevEvent: MatrixEvent | undefined;
- let isPrevRendered = false;
- let newDivider = false;
- let dayDivider = false;
- const eventRenderer = (item: number) => {
- const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
- if (!eventTimeline) return null;
- const timelineSet = eventTimeline?.getTimelineSet();
- const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
- const mEventId = mEvent?.getId();
-
- if (!mEvent || !mEventId) return null;
-
- if (!newDivider && readUptoEventIdRef.current) {
- newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
- }
- if (!dayDivider) {
- dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
- }
-
- const collapsed =
- isPrevRendered &&
- !dayDivider &&
- (!newDivider || mEvent.getSender() === mx.getUserId()) &&
- prevEvent !== undefined &&
- prevEvent.getSender() === mEvent.getSender() &&
- prevEvent.getType() === mEvent.getType() &&
- minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
-
- const eventJSX = reactionOrEditEvent(mEvent)
- ? null
- : renderMatrixEvent(mEventId, mEvent, item, timelineSet, collapsed);
- prevEvent = mEvent;
- isPrevRendered = !!eventJSX;
-
- const newDividerJSX =
- newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? (
- <MessageBase space={messageSpacing}>
- <TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
- <Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
- <Text size="L400">New Messages</Text>
- </Badge>
- </TimelineDivider>
- </MessageBase>
- ) : null;
-
- const dayDividerJSX =
- dayDivider && eventJSX ? (
- <MessageBase space={messageSpacing}>
- <TimelineDivider variant="Surface">
- <Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
- <Text size="L400">
- {(() => {
- if (today(mEvent.getTs())) return 'Today';
- if (yesterday(mEvent.getTs())) return 'Yesterday';
- return timeDayMonthYear(mEvent.getTs());
- })()}
- </Text>
- </Badge>
- </TimelineDivider>
- </MessageBase>
- ) : null;
-
- if (eventJSX && (newDividerJSX || dayDividerJSX)) {
- if (newDividerJSX) newDivider = false;
- if (dayDividerJSX) dayDivider = false;
-
- return (
- <React.Fragment key={mEventId}>
- {newDividerJSX}
- {dayDividerJSX}
- {eventJSX}
- </React.Fragment>
- );
- }
-
- return eventJSX;
- };
-
- return (
- <Box style={{ height: '100%', color: color.Surface.OnContainer }} grow="Yes">
- {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
- <TimelineFloat position="Top">
- <Chip
- variant="Primary"
- radii="Pill"
- outlined
- before={<Icon size="50" src={Icons.MessageUnread} />}
- onClick={handleJumpToUnread}
- >
- <Text size="L400">Jump to Unread</Text>
- </Chip>
-
- <Chip
- variant="SurfaceVariant"
- radii="Pill"
- outlined
- before={<Icon size="50" src={Icons.CheckTwice} />}
- onClick={handleMarkAsRead}
- >
- <Text size="L400">Mark as Read</Text>
- </Chip>
- </TimelineFloat>
- )}
- <Scroll ref={scrollRef} visibility="Hover">
- <Box
- direction="Column"
- justifyContent="End"
- style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
- >
- {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
- <div
- style={{
- padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
- messageLayout === 1 ? config.space.S400 : toRem(64)
- }`,
- }}
- >
- <RoomIntro room={room} />
- </div>
- )}
- {(canPaginateBack || !rangeAtStart) &&
- (messageLayout === 1 ? (
- <>
- <CompactPlaceholder />
- <CompactPlaceholder />
- <CompactPlaceholder />
- <CompactPlaceholder />
- <CompactPlaceholder ref={observeBackAnchor} />
- </>
- ) : (
- <>
- <DefaultPlaceholder />
- <DefaultPlaceholder />
- <DefaultPlaceholder ref={observeBackAnchor} />
- </>
- ))}
-
- {getItems().map(eventRenderer)}
-
- {(!liveTimelineLinked || !rangeAtEnd) &&
- (messageLayout === 1 ? (
- <>
- <CompactPlaceholder ref={observeFrontAnchor} />
- <CompactPlaceholder />
- <CompactPlaceholder />
- <CompactPlaceholder />
- <CompactPlaceholder />
- </>
- ) : (
- <>
- <DefaultPlaceholder ref={observeFrontAnchor} />
- <DefaultPlaceholder />
- <DefaultPlaceholder />
- </>
- ))}
- <span ref={atBottomAnchorRef} />
- </Box>
- </Scroll>
- {!atBottom && (
- <TimelineFloat position="Bottom">
- <Chip
- variant="SurfaceVariant"
- radii="Pill"
- outlined
- before={<Icon size="50" src={Icons.ArrowBottom} />}
- onClick={handleJumpToLatest}
- >
- <Text size="L400">Jump to Latest</Text>
- </Chip>
- </TimelineFloat>
- )}
- </Box>
- );
-}
+++ /dev/null
-import { style } from '@vanilla-extract/css';
-import { config } from 'folds';
-
-export const RoomTombstone = style({
- padding: config.space.S200,
- paddingLeft: config.space.S400,
-});
+++ /dev/null
-import React, { useCallback } from 'react';
-import { Box, Button, Spinner, Text, color } from 'folds';
-
-import { selectRoom } from '../../../client/action/navigation';
-
-import * as css from './RoomTombstone.css';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { genRoomVia } from '../../../util/matrixUtil';
-import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import { Membership } from '../../../types/matrix/room';
-import { RoomInputPlaceholder } from './RoomInputPlaceholder';
-
-type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
-export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
- const mx = useMatrixClient();
-
- const [joinState, handleJoin] = useAsyncCallback(
- useCallback(() => {
- const currentRoom = mx.getRoom(roomId);
- const via = currentRoom ? genRoomVia(currentRoom) : [];
- return mx.joinRoom(replacementRoomId, {
- viaServers: via,
- });
- }, [mx, roomId, replacementRoomId])
- );
- const replacementRoom = mx.getRoom(replacementRoomId);
-
- const handleOpen = () => {
- if (replacementRoom) selectRoom(replacementRoom.roomId);
- if (joinState.status === AsyncStatus.Success) selectRoom(joinState.data.roomId);
- };
-
- return (
- <RoomInputPlaceholder alignItems="Center" gap="600" className={css.RoomTombstone}>
- <Box direction="Column" grow="Yes">
- <Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
- {joinState.status === AsyncStatus.Error && (
- <Text style={{ color: color.Critical.Main }} size="T200">
- {(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
- </Text>
- )}
- </Box>
- {replacementRoom?.getMyMembership() === Membership.Join ||
- joinState.status === AsyncStatus.Success ? (
- <Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
- <Text size="B300">Open New Room</Text>
- </Button>
- ) : (
- <Button
- onClick={handleJoin}
- size="300"
- variant="Primary"
- fill="Solid"
- radii="300"
- before={
- joinState.status === AsyncStatus.Loading && (
- <Spinner size="100" variant="Primary" fill="Solid" />
- )
- }
- disabled={joinState.status === AsyncStatus.Loading}
- >
- <Text size="B300">Join New Room</Text>
- </Button>
- )}
- </RoomInputPlaceholder>
- );
-}
+++ /dev/null
-import React, { useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './RoomView.scss';
-import { Text, config } from 'folds';
-import { EventType } from 'matrix-js-sdk';
-
-import cons from '../../../client/state/cons';
-import navigation from '../../../client/state/navigation';
-
-import RoomViewHeader from './RoomViewHeader';
-import { RoomInput } from './RoomInput';
-import { useStateEvent } from '../../hooks/useStateEvent';
-import { StateEvent } from '../../../types/matrix/room';
-import { RoomTombstone } from './RoomTombstone';
-import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { RoomInputPlaceholder } from './RoomInputPlaceholder';
-import { RoomTimeline } from './RoomTimeline';
-import { RoomViewTyping } from './RoomViewTyping';
-import { RoomViewFollowing } from './RoomViewFollowing';
-import { useEditor } from '../../components/editor';
-
-function RoomView({ room, eventId }) {
- const roomInputRef = useRef(null);
- const roomViewRef = useRef(null);
-
- // eslint-disable-next-line react/prop-types
- const { roomId } = room;
- const editor = useEditor();
-
- const mx = useMatrixClient();
- const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
- const { getPowerLevel, canSendEvent } = usePowerLevelsAPI();
- const myUserId = mx.getUserId();
- const canMessage = myUserId
- ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
- : false;
-
- useEffect(() => {
- const settingsToggle = (isVisible) => {
- const roomView = roomViewRef.current;
- roomView.classList.toggle('room-view--dropped');
-
- const roomViewContent = roomView.children[1];
- if (isVisible) {
- setTimeout(() => {
- if (!navigation.isRoomSettings) return;
- roomViewContent.style.visibility = 'hidden';
- }, 200);
- } else roomViewContent.style.visibility = 'visible';
- };
- navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
- return () => {
- navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
- };
- }, []);
-
- return (
- <div className="room-view" ref={roomViewRef}>
- <RoomViewHeader roomId={roomId} />
- <div className="room-view__content-wrapper">
- <div className="room-view__scrollable">
- <RoomTimeline
- key={roomId}
- room={room}
- eventId={eventId}
- roomInputRef={roomInputRef}
- editor={editor}
- />
- <RoomViewTyping room={room} />
- </div>
- <div className="room-view__sticky">
- <div className="room-view__editor">
- {tombstoneEvent ? (
- <RoomTombstone
- roomId={roomId}
- body={tombstoneEvent.getContent().body}
- replacementRoomId={tombstoneEvent.getContent().replacement_room}
- />
- ) : (
- <>
- {canMessage && (
- <RoomInput
- room={room}
- editor={editor}
- roomId={roomId}
- roomViewRef={roomViewRef}
- ref={roomInputRef}
- />
- )}
- {!canMessage && (
- <RoomInputPlaceholder
- style={{ padding: config.space.S200 }}
- alignItems="Center"
- justifyContent="Center"
- >
- <Text align="Center">You do not have permission to post in this room</Text>
- </RoomInputPlaceholder>
- )}
- </>
- )}
- </div>
- <RoomViewFollowing room={room} />
- </div>
- </div>
- </div>
- );
-}
-
-RoomView.defaultProps = {
- eventId: null,
-};
-RoomView.propTypes = {
- room: PropTypes.shape({}).isRequired,
- eventId: PropTypes.string,
-};
-
-export default RoomView;
+++ /dev/null
-import { recipe } from '@vanilla-extract/recipes';
-import { DefaultReset, color, config, toRem } from 'folds';
-
-export const RoomViewFollowing = recipe({
- base: [
- DefaultReset,
- {
- minHeight: toRem(28),
- padding: `0 ${config.space.S400}`,
- width: '100%',
- backgroundColor: color.Surface.Container,
- color: color.Surface.OnContainer,
- outline: 'none',
- },
- ],
- variants: {
- clickable: {
- true: {
- cursor: 'pointer',
- selectors: {
- '&:hover, &:focus-visible': {
- color: color.Primary.Main,
- },
- '&:active': {
- color: color.Primary.Main,
- },
- },
- },
- },
- },
-});
+++ /dev/null
-import React, { useState } from 'react';
-import {
- Box,
- Icon,
- Icons,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
- as,
- config,
-} from 'folds';
-import { Room } from 'matrix-js-sdk';
-import classNames from 'classnames';
-import FocusTrap from 'focus-trap-react';
-
-import { getMemberDisplayName } from '../../utils/room';
-import { getMxIdLocalPart } from '../../utils/matrix';
-import * as css from './RoomViewFollowing.css';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
-import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
-import { EventReaders } from '../../components/event-readers';
-
-export type RoomViewFollowingProps = {
- room: Room;
-};
-export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
- ({ className, room, ...props }, ref) => {
- const mx = useMatrixClient();
- const [open, setOpen] = useState(false);
- const latestEvent = useRoomLatestRenderedEvent(room);
- const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
- const names = latestEventReaders
- .filter((readerId) => readerId !== mx.getUserId())
- .map(
- (readerId) => getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId
- );
-
- const eventId = latestEvent?.getId();
-
- return (
- <>
- {eventId && (
- <Overlay open={open} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setOpen(false),
- clickOutsideDeactivates: true,
- }}
- >
- <Modal variant="Surface" size="300">
- <EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
- <Box
- as={names.length > 0 ? 'button' : 'div'}
- onClick={names.length > 0 ? () => setOpen(true) : undefined}
- className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
- alignItems="Center"
- justifyContent="End"
- gap="200"
- {...props}
- ref={ref}
- >
- {names.length > 0 && (
- <>
- <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
- <Text size="T300" truncate>
- {names.length === 1 && (
- <>
- <b>{names[0]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' is following the conversation.'}
- </Text>
- </>
- )}
- {names.length === 2 && (
- <>
- <b>{names[0]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' and '}
- </Text>
- <b>{names[1]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' are following the conversation.'}
- </Text>
- </>
- )}
- {names.length === 3 && (
- <>
- <b>{names[0]}</b>
- <Text as="span" size="Inherit" priority="300">
- {', '}
- </Text>
- <b>{names[1]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' and '}
- </Text>
- <b>{names[2]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' are following the conversation.'}
- </Text>
- </>
- )}
- {names.length > 3 && (
- <>
- <b>{names[0]}</b>
- <Text as="span" size="Inherit" priority="300">
- {', '}
- </Text>
- <b>{names[1]}</b>
- <Text as="span" size="Inherit" priority="300">
- {', '}
- </Text>
- <b>{names[2]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' and '}
- </Text>
- <b>{names.length - 3} others</b>
- <Text as="span" size="Inherit" priority="300">
- {' are following the conversation.'}
- </Text>
- </>
- )}
- </Text>
- </>
- )}
- </Box>
- </>
- );
- }
-);
+++ /dev/null
-import { keyframes, style } from '@vanilla-extract/css';
-import { DefaultReset, color, config } from 'folds';
-
-const SlideUpAnime = keyframes({
- from: {
- transform: 'translateY(100%)',
- },
- to: {
- transform: 'translateY(0)',
- },
-});
-
-export const RoomViewTyping = style([
- DefaultReset,
- {
- padding: `0 ${config.space.S500}`,
- width: '100%',
- backgroundColor: color.Surface.Container,
- color: color.Surface.OnContainer,
- position: 'absolute',
- bottom: 0,
- animation: `${SlideUpAnime} 100ms ease-in-out`,
- },
-]);
-export const TypingText = style({
- flexGrow: 1,
-});
+++ /dev/null
-import React, { useMemo } from 'react';
-import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
-import { Room } from 'matrix-js-sdk';
-import classNames from 'classnames';
-import { useAtomValue, useSetAtom } from 'jotai';
-import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
-import { TypingIndicator } from '../../components/typing-indicator';
-import { getMemberDisplayName } from '../../utils/room';
-import { getMxIdLocalPart } from '../../utils/matrix';
-import * as css from './RoomViewTyping.css';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-
-export type RoomViewTypingProps = {
- room: Room;
-};
-export const RoomViewTyping = as<'div', RoomViewTypingProps>(
- ({ className, room, ...props }, ref) => {
- const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
- const mx = useMatrixClient();
- const typingMembers = useAtomValue(
- useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
- );
-
- const typingNames = typingMembers
- .filter((member) => member.userId !== mx.getUserId())
- .map((member) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId))
- .reverse();
-
- if (typingNames.length === 0) {
- return null;
- }
-
- const handleDropAll = () => {
- // some homeserver does not timeout typing status
- // we have given option so user can drop their typing status
- typingMembers.forEach((member) =>
- setTypingMembers({
- type: 'DELETE',
- roomId: room.roomId,
- member,
- })
- );
- };
-
- return (
- <Box
- className={classNames(css.RoomViewTyping, className)}
- alignItems="Center"
- gap="400"
- {...props}
- ref={ref}
- >
- <TypingIndicator />
- <Text className={css.TypingText} size="T300" truncate>
- {typingNames.length === 1 && (
- <>
- <b>{typingNames[0]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' is typing...'}
- </Text>
- </>
- )}
- {typingNames.length === 2 && (
- <>
- <b>{typingNames[0]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' and '}
- </Text>
- <b>{typingNames[1]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' are typing...'}
- </Text>
- </>
- )}
- {typingNames.length === 3 && (
- <>
- <b>{typingNames[0]}</b>
- <Text as="span" size="Inherit" priority="300">
- {', '}
- </Text>
- <b>{typingNames[1]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' and '}
- </Text>
- <b>{typingNames[2]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' are typing...'}
- </Text>
- </>
- )}
- {typingNames.length > 3 && (
- <>
- <b>{typingNames[0]}</b>
- <Text as="span" size="Inherit" priority="300">
- {', '}
- </Text>
- <b>{typingNames[1]}</b>
- <Text as="span" size="Inherit" priority="300">
- {', '}
- </Text>
- <b>{typingNames[2]}</b>
- <Text as="span" size="Inherit" priority="300">
- {' and '}
- </Text>
- <b>{typingNames.length - 3} others</b>
- <Text as="span" size="Inherit" priority="300">
- {' are typing...'}
- </Text>
- </>
- )}
- </Text>
- <IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
- <Icon size="50" src={Icons.Cross} />
- </IconButton>
- </Box>
- );
- }
-);
+++ /dev/null
-/* eslint-disable jsx-a11y/media-has-caption */
-import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, as, toRem } from 'folds';
-import React, { useCallback, useRef, useState } from 'react';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { Range } from 'react-range';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { getFileSrcUrl } from './util';
-import { IAudioInfo } from '../../../../types/matrix/common';
-import { MediaControl } from '../../../components/media';
-import {
- PlayTimeCallback,
- useMediaLoading,
- useMediaPlay,
- useMediaPlayTimeCallback,
- useMediaSeek,
- useMediaVolume,
-} from '../../../hooks/media';
-import { useThrottle } from '../../../hooks/useThrottle';
-import { secondsToMinutesAndSeconds } from '../../../utils/common';
-
-const PLAY_TIME_THROTTLE_OPS = {
- wait: 500,
- immediate: true,
-};
-
-export type AudioContentProps = {
- mimeType: string;
- url: string;
- info: IAudioInfo;
- encInfo?: EncryptedAttachmentInfo;
-};
-export const AudioContent = as<'div', AudioContentProps>(
- ({ mimeType, url, info, encInfo, ...props }, ref) => {
- const mx = useMatrixClient();
-
- const [srcState, loadSrc] = useAsyncCallback(
- useCallback(
- () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
- [mx, url, mimeType, encInfo]
- )
- );
-
- const audioRef = useRef<HTMLAudioElement | null>(null);
-
- const [currentTime, setCurrentTime] = useState(0);
- // duration in seconds. (NOTE: info.duration is in milliseconds)
- const infoDuration = info.duration ?? 0;
- const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
-
- const getAudioRef = useCallback(() => audioRef.current, []);
- const { loading } = useMediaLoading(getAudioRef);
- const { playing, setPlaying } = useMediaPlay(getAudioRef);
- const { seek } = useMediaSeek(getAudioRef);
- const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
- const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
- setDuration(d);
- setCurrentTime(ct);
- }, []);
- useMediaPlayTimeCallback(
- getAudioRef,
- useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
- );
-
- const handlePlay = () => {
- if (srcState.status === AsyncStatus.Success) {
- setPlaying(!playing);
- } else if (srcState.status !== AsyncStatus.Loading) {
- loadSrc();
- }
- };
-
- return (
- <MediaControl
- after={
- <Range
- step={1}
- min={0}
- max={duration || 1}
- values={[currentTime]}
- onChange={(values) => seek(values[0])}
- renderTrack={(params) => (
- <div {...params.props}>
- {params.children}
- <ProgressBar
- as="div"
- variant="Secondary"
- size="300"
- min={0}
- max={duration}
- value={currentTime}
- radii="300"
- />
- </div>
- )}
- renderThumb={(params) => (
- <Badge
- size="300"
- variant="Secondary"
- fill="Solid"
- radii="Pill"
- outlined
- {...params.props}
- style={{
- ...params.props.style,
- zIndex: 0,
- }}
- />
- )}
- />
- }
- leftControl={
- <>
- <Chip
- onClick={handlePlay}
- variant="Secondary"
- radii="300"
- disabled={srcState.status === AsyncStatus.Loading}
- before={
- srcState.status === AsyncStatus.Loading || loading ? (
- <Spinner variant="Secondary" size="50" />
- ) : (
- <Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
- )
- }
- >
- <Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
- </Chip>
-
- <Text size="T200">{`${secondsToMinutesAndSeconds(
- currentTime
- )} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
- </>
- }
- rightControl={
- <>
- <IconButton
- variant="SurfaceVariant"
- size="300"
- radii="Pill"
- onClick={() => setMute(!mute)}
- aria-pressed={mute}
- >
- <Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
- </IconButton>
- <Range
- step={0.1}
- min={0}
- max={1}
- values={[volume]}
- onChange={(values) => setVolume(values[0])}
- renderTrack={(params) => (
- <div {...params.props}>
- {params.children}
- <ProgressBar
- style={{ width: toRem(48) }}
- variant="Secondary"
- size="300"
- min={0}
- max={1}
- value={volume}
- radii="300"
- />
- </div>
- )}
- renderThumb={(params) => (
- <Badge
- size="300"
- variant="Secondary"
- fill="Solid"
- radii="Pill"
- outlined
- {...params.props}
- style={{
- ...params.props.style,
- zIndex: 0,
- }}
- />
- )}
- />
- </>
- }
- {...props}
- ref={ref}
- >
- <audio controls={false} autoPlay ref={audioRef}>
- {srcState.status === AsyncStatus.Success && (
- <source src={srcState.data} type={mimeType} />
- )}
- </audio>
- </MediaControl>
- );
- }
-);
+++ /dev/null
-import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
-import React, { ReactNode, useEffect, useState } from 'react';
-
-type EncryptedContentProps = {
- mEvent: MatrixEvent;
- children: () => ReactNode;
-};
-
-export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
- const [, toggleDecrypted] = useState(!mEvent.isBeingDecrypted());
-
- useEffect(() => {
- const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () => {
- toggleDecrypted((s) => !s);
- };
- mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
- return () => {
- mEvent.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
- };
- }, [mEvent]);
-
- return <>{children()}</>;
-}
+++ /dev/null
-import { Box, Icon, IconSrc } from 'folds';
-import React, { ReactNode } from 'react';
-import { CompactLayout, ModernLayout } from '../../../components/message';
-
-export type EventContentProps = {
- messageLayout: number;
- time: ReactNode;
- iconSrc: IconSrc;
- content: ReactNode;
-};
-export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
- const beforeJSX = (
- <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
- {messageLayout === 1 && time}
- <Box
- grow={messageLayout === 1 ? undefined : 'Yes'}
- alignItems="Center"
- justifyContent="Center"
- >
- <Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
- </Box>
- </Box>
- );
-
- const msgContentJSX = (
- <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
- {content}
- {messageLayout !== 1 && time}
- </Box>
- );
-
- return messageLayout === 1 ? (
- <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
- ) : (
- <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
- );
-}
+++ /dev/null
-import React, { useCallback, useState } from 'react';
-import {
- Box,
- Button,
- Icon,
- Icons,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Spinner,
- Text,
- Tooltip,
- TooltipProvider,
- as,
-} from 'folds';
-import FileSaver from 'file-saver';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import FocusTrap from 'focus-trap-react';
-import { IFileInfo } from '../../../../types/matrix/common';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { getFileSrcUrl, getSrcFile } from './util';
-import { bytesToSize } from '../../../utils/common';
-import { TextViewer } from '../../../components/text-viewer';
-import {
- READABLE_EXT_TO_MIME_TYPE,
- READABLE_TEXT_MIME_TYPES,
- getFileNameExt,
- mimeTypeToExt,
-} from '../../../utils/mimeTypes';
-import { PdfViewer } from '../../../components/Pdf-viewer';
-import * as css from './styles.css';
-
-export type FileContentProps = {
- body: string;
- mimeType: string;
- url: string;
- info: IFileInfo;
- encInfo?: EncryptedAttachmentInfo;
-};
-
-const renderErrorButton = (retry: () => void, text: string) => (
- <TooltipProvider
- tooltip={
- <Tooltip variant="Critical">
- <Text>Failed to load file!</Text>
- </Tooltip>
- }
- position="Top"
- align="Center"
- >
- {(triggerRef) => (
- <Button
- ref={triggerRef}
- size="400"
- variant="Critical"
- fill="Soft"
- outlined
- radii="300"
- onClick={retry}
- before={<Icon size="100" src={Icons.Warning} filled />}
- >
- <Text size="B400" truncate>
- {text}
- </Text>
- </Button>
- )}
- </TooltipProvider>
-);
-
-function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
- const mx = useMatrixClient();
- const [textViewer, setTextViewer] = useState(false);
-
- const loadSrc = useCallback(
- () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
- [mx, url, mimeType, encInfo]
- );
-
- const [textState, loadText] = useAsyncCallback(
- useCallback(async () => {
- const src = await loadSrc();
- const blob = await getSrcFile(src);
- const text = blob.text();
- setTextViewer(true);
- return text;
- }, [loadSrc])
- );
-
- return (
- <>
- {textState.status === AsyncStatus.Success && (
- <Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setTextViewer(false),
- clickOutsideDeactivates: true,
- }}
- >
- <Modal
- className={css.ModalWide}
- size="500"
- onContextMenu={(evt: any) => evt.stopPropagation()}
- >
- <TextViewer
- name={body}
- text={textState.data}
- langName={
- READABLE_TEXT_MIME_TYPES.includes(mimeType)
- ? mimeTypeToExt(mimeType)
- : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType)
- }
- requestClose={() => setTextViewer(false)}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
- {textState.status === AsyncStatus.Error ? (
- renderErrorButton(loadText, 'Open File')
- ) : (
- <Button
- variant="Secondary"
- fill="Solid"
- radii="300"
- size="400"
- onClick={() =>
- textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
- }
- disabled={textState.status === AsyncStatus.Loading}
- before={
- textState.status === AsyncStatus.Loading ? (
- <Spinner fill="Solid" size="100" variant="Secondary" />
- ) : (
- <Icon size="100" src={Icons.ArrowRight} filled />
- )
- }
- >
- <Text size="B400" truncate>
- Open File
- </Text>
- </Button>
- )}
- </>
- );
-}
-
-function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
- const mx = useMatrixClient();
- const [pdfViewer, setPdfViewer] = useState(false);
-
- const [pdfState, loadPdf] = useAsyncCallback(
- useCallback(async () => {
- const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
- setPdfViewer(true);
- return httpUrl;
- }, [mx, url, mimeType, encInfo])
- );
-
- return (
- <>
- {pdfState.status === AsyncStatus.Success && (
- <Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setPdfViewer(false),
- clickOutsideDeactivates: true,
- }}
- >
- <Modal
- className={css.ModalWide}
- size="500"
- onContextMenu={(evt: any) => evt.stopPropagation()}
- >
- <PdfViewer
- name={body}
- src={pdfState.data}
- requestClose={() => setPdfViewer(false)}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
- {pdfState.status === AsyncStatus.Error ? (
- renderErrorButton(loadPdf, 'Open PDF')
- ) : (
- <Button
- variant="Secondary"
- fill="Solid"
- radii="300"
- size="400"
- onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
- disabled={pdfState.status === AsyncStatus.Loading}
- before={
- pdfState.status === AsyncStatus.Loading ? (
- <Spinner fill="Solid" size="100" variant="Secondary" />
- ) : (
- <Icon size="100" src={Icons.ArrowRight} filled />
- )
- }
- >
- <Text size="B400" truncate>
- Open PDF
- </Text>
- </Button>
- )}
- </>
- );
-}
-
-function DownloadFile({ body, mimeType, url, info, encInfo }: FileContentProps) {
- const mx = useMatrixClient();
-
- const [downloadState, download] = useAsyncCallback(
- useCallback(async () => {
- const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
- FileSaver.saveAs(httpUrl, body);
- return httpUrl;
- }, [mx, url, mimeType, encInfo, body])
- );
-
- return downloadState.status === AsyncStatus.Error ? (
- renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
- ) : (
- <Button
- variant="Secondary"
- fill="Soft"
- radii="300"
- size="400"
- onClick={() =>
- downloadState.status === AsyncStatus.Success
- ? FileSaver.saveAs(downloadState.data, body)
- : download()
- }
- disabled={downloadState.status === AsyncStatus.Loading}
- before={
- downloadState.status === AsyncStatus.Loading ? (
- <Spinner fill="Soft" size="100" variant="Secondary" />
- ) : (
- <Icon size="100" src={Icons.Download} filled />
- )
- }
- >
- <Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
- </Button>
- );
-}
-
-export const FileContent = as<'div', FileContentProps>(
- ({ body, mimeType, url, info, encInfo, ...props }, ref) => (
- <Box direction="Column" gap="300" {...props} ref={ref}>
- {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
- READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && (
- <ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
- )}
- {mimeType === 'application/pdf' && (
- <ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
- )}
- <DownloadFile body={body} mimeType={mimeType} url={url} info={info} encInfo={encInfo} />
- </Box>
- )
-);
+++ /dev/null
-import { Badge, Box, Text, as, toRem } from 'folds';
-import React from 'react';
-import { mimeTypeToExt } from '../../../utils/mimeTypes';
-
-const badgeStyles = { maxWidth: toRem(100) };
-
-export type FileHeaderProps = {
- body: string;
- mimeType: string;
-};
-export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
- <Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
- <Badge style={badgeStyles} variant="Secondary" radii="Pill">
- <Text size="O400" truncate>
- {mimeTypeToExt(mimeType)}
- </Text>
- </Badge>
- <Text size="T300" truncate>
- {body}
- </Text>
- </Box>
-));
+++ /dev/null
-import React, { useCallback, useEffect, useState } from 'react';
-import {
- Badge,
- Box,
- Button,
- Icon,
- Icons,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Spinner,
- Text,
- Tooltip,
- TooltipProvider,
- as,
-} from 'folds';
-import classNames from 'classnames';
-import { BlurhashCanvas } from 'react-blurhash';
-import FocusTrap from 'focus-trap-react';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { getFileSrcUrl } from './util';
-import { Image } from '../../../components/media';
-import * as css from './styles.css';
-import { bytesToSize } from '../../../utils/common';
-import { ImageViewer } from '../../../components/image-viewer';
-import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
-
-export type ImageContentProps = {
- body: string;
- mimeType?: string;
- url: string;
- info?: IImageInfo;
- encInfo?: EncryptedAttachmentInfo;
- autoPlay?: boolean;
-};
-export const ImageContent = as<'div', ImageContentProps>(
- ({ className, body, mimeType, url, info, encInfo, autoPlay, ...props }, ref) => {
- const mx = useMatrixClient();
- const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
-
- const [load, setLoad] = useState(false);
- const [error, setError] = useState(false);
- const [viewer, setViewer] = useState(false);
-
- const [srcState, loadSrc] = useAsyncCallback(
- useCallback(
- () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType || FALLBACK_MIMETYPE, encInfo),
- [mx, url, mimeType, encInfo]
- )
- );
-
- const handleLoad = () => {
- setLoad(true);
- };
- const handleError = () => {
- setLoad(false);
- setError(true);
- };
-
- const handleRetry = () => {
- setError(false);
- loadSrc();
- };
-
- useEffect(() => {
- if (autoPlay) loadSrc();
- }, [autoPlay, loadSrc]);
-
- return (
- <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
- {srcState.status === AsyncStatus.Success && (
- <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setViewer(false),
- clickOutsideDeactivates: true,
- }}
- >
- <Modal
- className={css.ModalWide}
- size="500"
- onContextMenu={(evt: any) => evt.stopPropagation()}
- >
- <ImageViewer
- src={srcState.data}
- alt={body}
- requestClose={() => setViewer(false)}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
- {typeof blurHash === 'string' && !load && (
- <BlurhashCanvas
- style={{ width: '100%', height: '100%' }}
- width={32}
- height={32}
- hash={blurHash}
- punch={1}
- />
- )}
- {!autoPlay && srcState.status === AsyncStatus.Idle && (
- <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
- <Button
- variant="Secondary"
- fill="Solid"
- radii="300"
- size="300"
- onClick={loadSrc}
- before={<Icon size="Inherit" src={Icons.Photo} filled />}
- >
- <Text size="B300">View</Text>
- </Button>
- </Box>
- )}
- {srcState.status === AsyncStatus.Success && (
- <Box className={css.AbsoluteContainer}>
- <Image
- alt={body}
- title={body}
- src={srcState.data}
- loading="lazy"
- onLoad={handleLoad}
- onError={handleError}
- onClick={() => setViewer(true)}
- tabIndex={0}
- />
- </Box>
- )}
- {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
- !load && (
- <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
- <Spinner variant="Secondary" />
- </Box>
- )}
- {(error || srcState.status === AsyncStatus.Error) && (
- <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
- <TooltipProvider
- tooltip={
- <Tooltip variant="Critical">
- <Text>Failed to load image!</Text>
- </Tooltip>
- }
- position="Top"
- align="Center"
- >
- {(triggerRef) => (
- <Button
- ref={triggerRef}
- size="300"
- variant="Critical"
- fill="Soft"
- outlined
- radii="300"
- onClick={handleRetry}
- before={<Icon size="Inherit" src={Icons.Warning} filled />}
- >
- <Text size="B300">Retry</Text>
- </Button>
- )}
- </TooltipProvider>
- </Box>
- )}
- {!load && typeof info?.size === 'number' && (
- <Box className={css.AbsoluteFooter} justifyContent="End" alignContent="Center" gap="200">
- <Badge variant="Secondary" fill="Soft">
- <Text size="L400">{bytesToSize(info.size)}</Text>
- </Badge>
- </Box>
- )}
- </Box>
- );
- }
-);
+++ /dev/null
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
- Box,
- Button,
- Dialog,
- Header,
- Icon,
- IconButton,
- Icons,
- Input,
- Line,
- Menu,
- MenuItem,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- PopOut,
- Spinner,
- Text,
- as,
- color,
- config,
-} from 'folds';
-import React, {
- FormEventHandler,
- MouseEventHandler,
- ReactNode,
- useCallback,
- useState,
-} from 'react';
-import FocusTrap from 'focus-trap-react';
-import { useHover, useFocusWithin } from 'react-aria';
-import { MatrixEvent, Room } from 'matrix-js-sdk';
-import { Relations } from 'matrix-js-sdk/lib/models/relations';
-import classNames from 'classnames';
-import {
- AvatarBase,
- BubbleLayout,
- CompactLayout,
- MessageBase,
- ModernLayout,
- Time,
- Username,
-} from '../../../components/message';
-import colorMXID from '../../../../util/colorMXID';
-import {
- canEditEvent,
- getEventEdits,
- getMemberAvatarMxc,
- getMemberDisplayName,
-} from '../../../utils/room';
-import { getMxIdLocalPart } from '../../../utils/matrix';
-import { MessageLayout, MessageSpacing } from '../../../state/settings';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
-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';
-
-export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
-
-type MessageQuickReactionsProps = {
- onReaction: ReactionHandler;
-};
-export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
- ({ onReaction, ...props }, ref) => {
- const mx = useMatrixClient();
- const recentEmojis = useRecentEmoji(mx, 4);
-
- if (recentEmojis.length === 0) return <span />;
- return (
- <>
- <Box
- style={{ padding: config.space.S200 }}
- alignItems="Center"
- justifyContent="Center"
- gap="200"
- {...props}
- ref={ref}
- >
- {recentEmojis.map((emoji) => (
- <IconButton
- key={emoji.unicode}
- className={css.MessageQuickReaction}
- size="300"
- variant="SurfaceVariant"
- radii="Pill"
- title={emoji.shortcode}
- aria-label={emoji.shortcode}
- onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
- >
- <Text size="T500">{emoji.unicode}</Text>
- </IconButton>
- ))}
- </Box>
- <Line size="300" />
- </>
- );
- }
-);
-
-export const MessageAllReactionItem = as<
- 'button',
- {
- room: Room;
- relations: Relations;
- onClose?: () => void;
- }
->(({ room, relations, onClose, ...props }, ref) => {
- const [open, setOpen] = useState(false);
-
- const handleClose = () => {
- setOpen(false);
- onClose?.();
- };
-
- return (
- <>
- <Overlay
- onContextMenu={(evt: any) => {
- evt.stopPropagation();
- }}
- open={open}
- backdrop={<OverlayBackdrop />}
- >
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- returnFocusOnDeactivate: false,
- onDeactivate: () => handleClose(),
- clickOutsideDeactivates: true,
- }}
- >
- <Modal variant="Surface" size="300">
- <ReactionViewer
- room={room}
- relations={relations}
- requestClose={() => setOpen(false)}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- <MenuItem
- size="300"
- after={<Icon size="100" src={Icons.Smile} />}
- radii="300"
- onClick={() => setOpen(true)}
- {...props}
- ref={ref}
- aria-pressed={open}
- >
- <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
- View Reactions
- </Text>
- </MenuItem>
- </>
- );
-});
-
-export const MessageReadReceiptItem = as<
- 'button',
- {
- room: Room;
- eventId: string;
- onClose?: () => void;
- }
->(({ room, eventId, onClose, ...props }, ref) => {
- const [open, setOpen] = useState(false);
-
- const handleClose = () => {
- setOpen(false);
- onClose?.();
- };
-
- return (
- <>
- <Overlay open={open} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: handleClose,
- clickOutsideDeactivates: true,
- }}
- >
- <Modal variant="Surface" size="300">
- <EventReaders room={room} eventId={eventId} requestClose={handleClose} />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- <MenuItem
- size="300"
- after={<Icon size="100" src={Icons.CheckTwice} />}
- radii="300"
- onClick={() => setOpen(true)}
- {...props}
- ref={ref}
- aria-pressed={open}
- >
- <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
- Read Receipts
- </Text>
- </MenuItem>
- </>
- );
-});
-
-export const MessageSourceCodeItem = as<
- 'button',
- {
- room: Room;
- mEvent: MatrixEvent;
- onClose?: () => void;
- }
->(({ room, mEvent, onClose, ...props }, ref) => {
- const [open, setOpen] = useState(false);
-
- const getContent = (evt: MatrixEvent) =>
- evt.isEncrypted()
- ? {
- [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
- [`<== ORIGINAL_EVENT ==>`]: evt.event,
- }
- : evt.event;
-
- const getText = (): string => {
- const evtId = mEvent.getId()!;
- const evtTimeline = room.getTimelineForEvent(evtId);
- const edits =
- evtTimeline &&
- getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
-
- if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
-
- const content: Record<string, unknown> = {
- '<== MAIN_EVENT ==>': getContent(mEvent),
- };
-
- edits.forEach((editEvt, index) => {
- content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
- });
-
- return JSON.stringify(content, null, 2);
- };
-
- const handleClose = () => {
- setOpen(false);
- onClose?.();
- };
-
- return (
- <>
- <Overlay open={open} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: handleClose,
- clickOutsideDeactivates: true,
- }}
- >
- <Modal variant="Surface" size="500">
- <TextViewer
- name="Source Code"
- langName="json"
- text={getText()}
- requestClose={handleClose}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- <MenuItem
- size="300"
- after={<Icon size="100" src={Icons.BlockCode} />}
- radii="300"
- onClick={() => setOpen(true)}
- {...props}
- ref={ref}
- aria-pressed={open}
- >
- <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
- View Source
- </Text>
- </MenuItem>
- </>
- );
-});
-
-export const MessageDeleteItem = as<
- 'button',
- {
- room: Room;
- mEvent: MatrixEvent;
- onClose?: () => void;
- }
->(({ room, mEvent, onClose, ...props }, ref) => {
- const mx = useMatrixClient();
- const [open, setOpen] = useState(false);
-
- const [deleteState, deleteMessage] = useAsyncCallback(
- useCallback(
- (eventId: string, reason?: string) =>
- mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
- [mx, room]
- )
- );
-
- const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
- evt.preventDefault();
- const eventId = mEvent.getId();
- if (
- !eventId ||
- deleteState.status === AsyncStatus.Loading ||
- deleteState.status === AsyncStatus.Success
- )
- return;
- const target = evt.target as HTMLFormElement | undefined;
- const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
- const reason = reasonInput && reasonInput.value.trim();
- deleteMessage(eventId, reason);
- };
-
- const handleClose = () => {
- setOpen(false);
- onClose?.();
- };
-
- return (
- <>
- <Overlay open={open} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: handleClose,
- clickOutsideDeactivates: true,
- }}
- >
- <Dialog variant="Surface">
- <Header
- style={{
- padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
- borderBottomWidth: config.borderWidth.B300,
- }}
- variant="Surface"
- size="500"
- >
- <Box grow="Yes">
- <Text size="H4">Delete Message</Text>
- </Box>
- <IconButton size="300" onClick={handleClose} radii="300">
- <Icon src={Icons.Cross} />
- </IconButton>
- </Header>
- <Box
- as="form"
- onSubmit={handleSubmit}
- style={{ padding: config.space.S400 }}
- direction="Column"
- gap="400"
- >
- <Text priority="400">
- This action is irreversible! Are you sure that you want to delete this message?
- </Text>
- <Box direction="Column" gap="100">
- <Text size="L400">
- Reason{' '}
- <Text as="span" size="T200">
- (optional)
- </Text>
- </Text>
- <Input name="reasonInput" variant="Background" />
- {deleteState.status === AsyncStatus.Error && (
- <Text style={{ color: color.Critical.Main }} size="T300">
- Failed to delete message! Please try again.
- </Text>
- )}
- </Box>
- <Button
- type="submit"
- variant="Critical"
- before={
- deleteState.status === AsyncStatus.Loading ? (
- <Spinner fill="Solid" variant="Critical" size="200" />
- ) : undefined
- }
- aria-disabled={deleteState.status === AsyncStatus.Loading}
- >
- <Text size="B400">
- {deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
- </Text>
- </Button>
- </Box>
- </Dialog>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- <Button
- variant="Critical"
- fill="None"
- size="300"
- after={<Icon size="100" src={Icons.Delete} />}
- radii="300"
- onClick={() => setOpen(true)}
- aria-pressed={open}
- {...props}
- ref={ref}
- >
- <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
- Delete
- </Text>
- </Button>
- </>
- );
-});
-
-export const MessageReportItem = as<
- 'button',
- {
- room: Room;
- mEvent: MatrixEvent;
- onClose?: () => void;
- }
->(({ room, mEvent, onClose, ...props }, ref) => {
- const mx = useMatrixClient();
- const [open, setOpen] = useState(false);
-
- const [reportState, reportMessage] = useAsyncCallback(
- useCallback(
- (eventId: string, score: number, reason: string) =>
- mx.reportEvent(room.roomId, eventId, score, reason),
- [mx, room]
- )
- );
-
- const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
- evt.preventDefault();
- const eventId = mEvent.getId();
- if (
- !eventId ||
- reportState.status === AsyncStatus.Loading ||
- reportState.status === AsyncStatus.Success
- )
- return;
- const target = evt.target as HTMLFormElement | undefined;
- const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
- const reason = reasonInput && reasonInput.value.trim();
- if (reasonInput) reasonInput.value = '';
- reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
- };
-
- const handleClose = () => {
- setOpen(false);
- onClose?.();
- };
-
- return (
- <>
- <Overlay open={open} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: handleClose,
- clickOutsideDeactivates: true,
- }}
- >
- <Dialog variant="Surface">
- <Header
- style={{
- padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
- borderBottomWidth: config.borderWidth.B300,
- }}
- variant="Surface"
- size="500"
- >
- <Box grow="Yes">
- <Text size="H4">Report Message</Text>
- </Box>
- <IconButton size="300" onClick={handleClose} radii="300">
- <Icon src={Icons.Cross} />
- </IconButton>
- </Header>
- <Box
- as="form"
- onSubmit={handleSubmit}
- style={{ padding: config.space.S400 }}
- direction="Column"
- gap="400"
- >
- <Text priority="400">
- Report this message to server, which may then notify the appropriate people to
- take action.
- </Text>
- <Box direction="Column" gap="100">
- <Text size="L400">Reason</Text>
- <Input name="reasonInput" variant="Background" required />
- {reportState.status === AsyncStatus.Error && (
- <Text style={{ color: color.Critical.Main }} size="T300">
- Failed to report message! Please try again.
- </Text>
- )}
- {reportState.status === AsyncStatus.Success && (
- <Text style={{ color: color.Success.Main }} size="T300">
- Message has been reported to server.
- </Text>
- )}
- </Box>
- <Button
- type="submit"
- variant="Critical"
- before={
- reportState.status === AsyncStatus.Loading ? (
- <Spinner fill="Solid" variant="Critical" size="200" />
- ) : undefined
- }
- aria-disabled={
- reportState.status === AsyncStatus.Loading ||
- reportState.status === AsyncStatus.Success
- }
- >
- <Text size="B400">
- {reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report'}
- </Text>
- </Button>
- </Box>
- </Dialog>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- <Button
- variant="Critical"
- fill="None"
- size="300"
- after={<Icon size="100" src={Icons.Warning} />}
- radii="300"
- onClick={() => setOpen(true)}
- aria-pressed={open}
- {...props}
- ref={ref}
- >
- <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
- Report
- </Text>
- </Button>
- </>
- );
-});
-
-export type MessageProps = {
- room: Room;
- mEvent: MatrixEvent;
- collapse: boolean;
- highlight: boolean;
- edit?: boolean;
- canDelete?: boolean;
- canSendReaction?: boolean;
- imagePackRooms?: Room[];
- relations?: Relations;
- messageLayout: MessageLayout;
- messageSpacing: MessageSpacing;
- onUserClick: MouseEventHandler<HTMLButtonElement>;
- onUsernameClick: MouseEventHandler<HTMLButtonElement>;
- onReplyClick: MouseEventHandler<HTMLButtonElement>;
- onEditId?: (eventId?: string) => void;
- onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
- reply?: ReactNode;
- reactions?: ReactNode;
-};
-export const Message = as<'div', MessageProps>(
- (
- {
- className,
- room,
- mEvent,
- collapse,
- highlight,
- edit,
- canDelete,
- canSendReaction,
- imagePackRooms,
- relations,
- messageLayout,
- messageSpacing,
- onUserClick,
- onUsernameClick,
- onReplyClick,
- onReactionToggle,
- onEditId,
- reply,
- reactions,
- children,
- ...props
- },
- ref
- ) => {
- const mx = useMatrixClient();
- const senderId = mEvent.getSender() ?? '';
- const [hover, setHover] = useState(false);
- const { hoverProps } = useHover({ onHoverChange: setHover });
- const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
- const [menu, setMenu] = useState(false);
- const [emojiBoard, setEmojiBoard] = useState(false);
-
- const senderDisplayName =
- getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
- const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
-
- const headerJSX = !collapse && (
- <Box
- gap="300"
- direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
- justifyContent="SpaceBetween"
- alignItems="Baseline"
- grow="Yes"
- >
- <Username
- as="button"
- style={{ color: colorMXID(senderId) }}
- data-user-id={senderId}
- onContextMenu={onUserClick}
- onClick={onUsernameClick}
- >
- <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
- <b>{senderDisplayName}</b>
- </Text>
- </Username>
- <Box shrink="No" gap="100">
- {messageLayout !== 1 && hover && (
- <>
- <Text as="span" size="T200" priority="300">
- {senderId}
- </Text>
- <Text as="span" size="T200" priority="300">
- |
- </Text>
- </>
- )}
- <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
- </Box>
- </Box>
- );
-
- const avatarJSX = !collapse && messageLayout !== 1 && (
- <AvatarBase>
- <Avatar
- className={css.MessageAvatar}
- as="button"
- size="300"
- data-user-id={senderId}
- onClick={onUserClick}
- >
- {senderAvatarMxc ? (
- <AvatarImage
- src={mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? senderAvatarMxc}
- />
- ) : (
- <AvatarFallback
- style={{
- background: colorMXID(senderId),
- color: 'white',
- }}
- >
- <Text size="H4">{senderDisplayName[0]}</Text>
- </AvatarFallback>
- )}
- </Avatar>
- </AvatarBase>
- );
-
- const msgContentJSX = (
- <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
- {reply}
- {edit && onEditId ? (
- <MessageEditor
- style={{
- maxWidth: '100%',
- width: '100vw',
- }}
- roomId={room.roomId}
- room={room}
- mEvent={mEvent}
- imagePackRooms={imagePackRooms}
- onCancel={() => onEditId()}
- />
- ) : (
- children
- )}
- {reactions}
- </Box>
- );
-
- const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
- if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
- const tag = (evt.target as any).tagName;
- if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
- evt.preventDefault();
- setMenu(true);
- };
-
- const closeMenu = () => {
- setMenu(false);
- };
-
- return (
- <MessageBase
- className={classNames(css.MessageBase, className)}
- tabIndex={0}
- space={messageSpacing}
- collapse={collapse}
- highlight={highlight}
- selected={menu || emojiBoard}
- {...props}
- {...hoverProps}
- {...focusWithinProps}
- ref={ref}
- >
- {!edit && (hover || menu || emojiBoard) && (
- <div className={css.MessageOptionsBase}>
- <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
- <Box gap="100">
- {canSendReaction && (
- <PopOut
- alignOffset={-65}
- position="Bottom"
- align="End"
- open={emojiBoard}
- content={
- <EmojiBoard
- imagePackRooms={imagePackRooms ?? []}
- returnFocusOnDeactivate={false}
- allowTextCustomEmoji
- onEmojiSelect={(key) => {
- onReactionToggle(mEvent.getId()!, key);
- setEmojiBoard(false);
- }}
- onCustomEmojiSelect={(mxc, shortcode) => {
- onReactionToggle(mEvent.getId()!, mxc, shortcode);
- setEmojiBoard(false);
- }}
- requestClose={() => {
- setEmojiBoard(false);
- }}
- />
- }
- >
- {(anchorRef) => (
- <IconButton
- ref={anchorRef}
- onClick={() => setEmojiBoard(true)}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- aria-pressed={emojiBoard}
- >
- <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
- open={menu}
- alignOffset={-5}
- position="Bottom"
- align="End"
- content={
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setMenu(false),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- }}
- >
- <Menu {...props} 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={() => {
- closeMenu();
- // open it with timeout because closeMenu
- // FocusTrap will return focus from emojiBoard
- setTimeout(() => setEmojiBoard(true), 100);
- }}
- >
- <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>
- )}
- <MessageReadReceiptItem
- room={room}
- eventId={mEvent.getId() ?? ''}
- onClose={closeMenu}
- />
- <MessageSourceCodeItem 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>
- }
- >
- {(targetRef) => (
- <IconButton
- ref={targetRef}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- onClick={() => setMenu((v) => !v)}
- aria-pressed={menu}
- >
- <Icon src={Icons.VerticalDots} size="100" />
- </IconButton>
- )}
- </PopOut>
- </Box>
- </Menu>
- </div>
- )}
- {messageLayout === 1 && (
- <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
- {msgContentJSX}
- </CompactLayout>
- )}
- {messageLayout === 2 && (
- <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
- {headerJSX}
- {msgContentJSX}
- </BubbleLayout>
- )}
- {messageLayout !== 1 && messageLayout !== 2 && (
- <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
- {headerJSX}
- {msgContentJSX}
- </ModernLayout>
- )}
- </MessageBase>
- );
- }
-);
-
-export type EventProps = {
- room: Room;
- mEvent: MatrixEvent;
- highlight: boolean;
- canDelete?: boolean;
- messageSpacing: MessageSpacing;
-};
-export const Event = as<'div', EventProps>(
- ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => {
- const mx = useMatrixClient();
- const [hover, setHover] = useState(false);
- const { hoverProps } = useHover({ onHoverChange: setHover });
- const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
- const [menu, setMenu] = useState(false);
- const stateEvent = typeof mEvent.getStateKey() === 'string';
-
- const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
- if (evt.altKey || !window.getSelection()?.isCollapsed) return;
- const tag = (evt.target as any).tagName;
- if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
- evt.preventDefault();
- setMenu(true);
- };
-
- const closeMenu = () => {
- setMenu(false);
- };
-
- return (
- <MessageBase
- className={classNames(css.MessageBase, className)}
- tabIndex={0}
- space={messageSpacing}
- autoCollapse
- highlight={highlight}
- selected={menu}
- {...props}
- {...hoverProps}
- {...focusWithinProps}
- ref={ref}
- >
- {(hover || menu) && (
- <div className={css.MessageOptionsBase}>
- <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
- <Box gap="100">
- <PopOut
- open={menu}
- alignOffset={-5}
- position="Bottom"
- align="End"
- content={
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setMenu(false),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- }}
- >
- <Menu {...props} ref={ref}>
- <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
- <MessageReadReceiptItem
- room={room}
- eventId={mEvent.getId() ?? ''}
- onClose={closeMenu}
- />
- <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
- </Box>
- {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
- (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
- <>
- <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>
- }
- >
- {(targetRef) => (
- <IconButton
- ref={targetRef}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- onClick={() => setMenu((v) => !v)}
- aria-pressed={menu}
- >
- <Icon src={Icons.VerticalDots} size="100" />
- </IconButton>
- )}
- </PopOut>
- </Box>
- </Menu>
- </div>
- )}
- <div onContextMenu={handleContextMenu}>{children}</div>
- </MessageBase>
- );
- }
-);
+++ /dev/null
-import React, { KeyboardEventHandler, useCallback, useEffect, useState } from 'react';
-import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, Spinner, Text, as, config } from 'folds';
-import { Editor, Transforms } from 'slate';
-import { ReactEditor } from 'slate-react';
-import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
-import { isKeyHotkey } from 'is-hotkey';
-import {
- AUTOCOMPLETE_PREFIXES,
- AutocompletePrefix,
- AutocompleteQuery,
- CustomEditor,
- EmoticonAutocomplete,
- RoomMentionAutocomplete,
- Toolbar,
- UserMentionAutocomplete,
- createEmoticonElement,
- customHtmlEqualsPlainText,
- getAutocompleteQuery,
- getPrevWorldRange,
- htmlToEditorInput,
- moveCursor,
- plainToEditorInput,
- toMatrixCustomHTML,
- toPlainText,
- trimCustomHtml,
- useEditor,
-} from '../../../components/editor';
-import { useSetting } from '../../../state/hooks/settings';
-import { settingsAtom } from '../../../state/settings';
-import { UseStateProvider } from '../../../components/UseStateProvider';
-import { EmojiBoard } from '../../../components/emoji-board';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
-import { mobileOrTablet } from '../../../utils/user-agent';
-
-type MessageEditorProps = {
- roomId: string;
- room: Room;
- mEvent: MatrixEvent;
- imagePackRooms?: Room[];
- onCancel: () => void;
-};
-export const MessageEditor = as<'div', MessageEditorProps>(
- ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
- const mx = useMatrixClient();
- const editor = useEditor();
- const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
- const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
- const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
- const [toolbar, setToolbar] = useState(globalToolbar);
-
- const [autocompleteQuery, setAutocompleteQuery] =
- useState<AutocompleteQuery<AutocompletePrefix>>();
-
- const getPrevBodyAndFormattedBody = useCallback((): [
- string | undefined,
- string | undefined
- ] => {
- const evtId = mEvent.getId()!;
- const evtTimeline = room.getTimelineForEvent(evtId);
- const editedEvent =
- evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
-
- const { body, formatted_body: customHtml }: Record<string, unknown> =
- editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
-
- return [
- typeof body === 'string' ? body : undefined,
- typeof customHtml === 'string' ? customHtml : undefined,
- ];
- }, [room, mEvent]);
-
- const [saveState, save] = useAsyncCallback(
- useCallback(async () => {
- const plainText = toPlainText(editor.children).trim();
- const customHtml = trimCustomHtml(
- toMatrixCustomHTML(editor.children, {
- allowTextFormatting: true,
- allowBlockMarkdown: isMarkdown,
- allowInlineMarkdown: isMarkdown,
- })
- );
-
- const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
-
- if (plainText === '') return undefined;
- if (prevBody) {
- if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
- return undefined;
- }
- if (
- !prevCustomHtml &&
- prevBody === plainText &&
- customHtmlEqualsPlainText(customHtml, plainText)
- ) {
- return undefined;
- }
- }
-
- const newContent: IContent = {
- msgtype: mEvent.getContent().msgtype,
- body: plainText,
- };
-
- if (!customHtmlEqualsPlainText(customHtml, plainText)) {
- newContent.format = 'org.matrix.custom.html';
- newContent.formatted_body = customHtml;
- }
-
- const content: IContent = {
- ...newContent,
- body: `* ${plainText}`,
- 'm.new_content': newContent,
- 'm.relates_to': {
- event_id: mEvent.getId(),
- rel_type: RelationType.Replace,
- },
- };
-
- return mx.sendMessage(roomId, content);
- }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
- );
-
- const handleSave = useCallback(() => {
- if (saveState.status !== AsyncStatus.Loading) {
- save();
- }
- }, [saveState, save]);
-
- const handleKeyDown: KeyboardEventHandler = useCallback(
- (evt) => {
- if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
- evt.preventDefault();
- handleSave();
- }
- if (isKeyHotkey('escape', evt)) {
- evt.preventDefault();
- onCancel();
- }
- },
- [onCancel, handleSave, enterForNewline]
- );
-
- const handleKeyUp: KeyboardEventHandler = useCallback(
- (evt) => {
- if (isKeyHotkey('escape', evt)) {
- evt.preventDefault();
- return;
- }
-
- const prevWordRange = getPrevWorldRange(editor);
- const query = prevWordRange
- ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
- : undefined;
- setAutocompleteQuery(query);
- },
- [editor]
- );
-
- const handleCloseAutocomplete = useCallback(() => {
- ReactEditor.focus(editor);
- setAutocompleteQuery(undefined);
- }, [editor]);
-
- const handleEmoticonSelect = (key: string, shortcode: string) => {
- editor.insertNode(createEmoticonElement(key, shortcode));
- moveCursor(editor);
- };
-
- useEffect(() => {
- const [body, customHtml] = getPrevBodyAndFormattedBody();
-
- const initialValue =
- typeof customHtml === 'string'
- ? htmlToEditorInput(customHtml)
- : plainToEditorInput(typeof body === 'string' ? body : '');
-
- Transforms.select(editor, {
- anchor: Editor.start(editor, []),
- focus: Editor.end(editor, []),
- });
-
- editor.insertFragment(initialValue);
- if (!mobileOrTablet()) ReactEditor.focus(editor);
- }, [editor, getPrevBodyAndFormattedBody]);
-
- useEffect(() => {
- if (saveState.status === AsyncStatus.Success) {
- onCancel();
- }
- }, [saveState, onCancel]);
-
- return (
- <div {...props} ref={ref}>
- {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
- <RoomMentionAutocomplete
- roomId={roomId}
- editor={editor}
- query={autocompleteQuery}
- requestClose={handleCloseAutocomplete}
- />
- )}
- {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
- <UserMentionAutocomplete
- room={room}
- editor={editor}
- query={autocompleteQuery}
- requestClose={handleCloseAutocomplete}
- />
- )}
- {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
- <EmoticonAutocomplete
- imagePackRooms={imagePackRooms || []}
- editor={editor}
- query={autocompleteQuery}
- requestClose={handleCloseAutocomplete}
- />
- )}
- <CustomEditor
- editor={editor}
- placeholder="Edit message..."
- onKeyDown={handleKeyDown}
- onKeyUp={handleKeyUp}
- bottom={
- <>
- <Box
- style={{ padding: config.space.S200, paddingTop: 0 }}
- alignItems="End"
- justifyContent="SpaceBetween"
- gap="100"
- >
- <Box gap="Inherit">
- <Chip
- onClick={handleSave}
- variant="Primary"
- radii="Pill"
- disabled={saveState.status === AsyncStatus.Loading}
- outlined
- before={
- saveState.status === AsyncStatus.Loading ? (
- <Spinner variant="Primary" fill="Soft" size="100" />
- ) : undefined
- }
- >
- <Text size="B300">Save</Text>
- </Chip>
- <Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
- <Text size="B300">Cancel</Text>
- </Chip>
- </Box>
- <Box gap="Inherit">
- <IconButton
- variant="SurfaceVariant"
- size="300"
- radii="300"
- onClick={() => setToolbar(!toolbar)}
- >
- <Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
- </IconButton>
- <UseStateProvider initial={false}>
- {(emojiBoard: boolean, setEmojiBoard) => (
- <PopOut
- alignOffset={-8}
- position="Top"
- align="End"
- open={!!emojiBoard}
- content={
- <EmojiBoard
- imagePackRooms={imagePackRooms ?? []}
- returnFocusOnDeactivate={false}
- onEmojiSelect={handleEmoticonSelect}
- onCustomEmojiSelect={handleEmoticonSelect}
- requestClose={() => {
- setEmojiBoard(false);
- if (!mobileOrTablet()) ReactEditor.focus(editor);
- }}
- />
- }
- >
- {(anchorRef) => (
- <IconButton
- ref={anchorRef}
- aria-pressed={emojiBoard}
- onClick={() => setEmojiBoard(true)}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- >
- <Icon size="400" src={Icons.Smile} filled={emojiBoard} />
- </IconButton>
- )}
- </PopOut>
- )}
- </UseStateProvider>
- </Box>
- </Box>
- {toolbar && (
- <div>
- <Line variant="SurfaceVariant" size="300" />
- <Toolbar />
- </div>
- )}
- </>
- }
- />
- </div>
- );
- }
-);
+++ /dev/null
-import React, { MouseEventHandler, useCallback, useState } from 'react';
-import {
- Box,
- Modal,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
- Tooltip,
- TooltipProvider,
- as,
- toRem,
-} from 'folds';
-import classNames from 'classnames';
-import { Room } from 'matrix-js-sdk';
-import { type Relations } from 'matrix-js-sdk/lib/models/relations';
-import FocusTrap from 'focus-trap-react';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { factoryEventSentBy } from '../../../utils/matrix';
-import { Reaction, ReactionTooltipMsg } from '../../../components/message';
-import { useRelations } from '../../../hooks/useRelations';
-import * as css from './styles.css';
-import { ReactionViewer } from '../reaction-viewer';
-
-export type ReactionsProps = {
- room: Room;
- mEventId: string;
- canSendReaction?: boolean;
- relations: Relations;
- onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
-};
-export const Reactions = as<'div', ReactionsProps>(
- ({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => {
- const mx = useMatrixClient();
- const [viewer, setViewer] = useState<boolean | string>(false);
- const myUserId = mx.getUserId();
- const reactions = useRelations(
- relations,
- useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
- );
-
- const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
- evt.stopPropagation();
- evt.preventDefault();
- const key = evt.currentTarget.getAttribute('data-reaction-key');
- if (!key) setViewer(true);
- else setViewer(key);
- };
-
- return (
- <Box
- className={classNames(css.ReactionsContainer, className)}
- gap="200"
- wrap="Wrap"
- {...props}
- ref={ref}
- >
- {reactions.map(([key, events]) => {
- const rEvents = Array.from(events);
- if (rEvents.length === 0 || typeof key !== 'string') return null;
- const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
- const isPressed = !!myREvent?.getRelation();
-
- return (
- <TooltipProvider
- key={key}
- position="Top"
- tooltip={
- <Tooltip style={{ maxWidth: toRem(200) }}>
- <Text className={css.ReactionsTooltipText} size="T300">
- <ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
- </Text>
- </Tooltip>
- }
- >
- {(targetRef) => (
- <Reaction
- ref={targetRef}
- data-reaction-key={key}
- aria-pressed={isPressed}
- key={key}
- mx={mx}
- reaction={key}
- count={events.size}
- onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
- onContextMenu={handleViewReaction}
- aria-disabled={!canSendReaction}
- />
- )}
- </TooltipProvider>
- );
- })}
- {reactions.length > 0 && (
- <Overlay
- onContextMenu={(evt: any) => {
- evt.stopPropagation();
- }}
- open={!!viewer}
- backdrop={<OverlayBackdrop />}
- >
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- returnFocusOnDeactivate: false,
- onDeactivate: () => setViewer(false),
- clickOutsideDeactivates: true,
- }}
- >
- <Modal variant="Surface" size="300">
- <ReactionViewer
- room={room}
- initialKey={typeof viewer === 'string' ? viewer : undefined}
- relations={relations}
- requestClose={() => setViewer(false)}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
- </Box>
- );
- }
-);
+++ /dev/null
-import React from 'react';
-import { as, toRem } from 'folds';
-import { MatrixEvent } from 'matrix-js-sdk';
-import {
- AttachmentBox,
- MessageBrokenContent,
- MessageDeletedContent,
-} from '../../../components/message';
-import { ImageContent } from './ImageContent';
-import { scaleYDimension } from '../../../utils/common';
-import { IImageContent } from '../../../../types/matrix/common';
-
-type StickerContentProps = {
- mEvent: MatrixEvent;
- autoPlay: boolean;
-};
-export const StickerContent = as<'div', StickerContentProps>(
- ({ mEvent, autoPlay, ...props }, ref) => {
- if (mEvent.isRedacted()) return <MessageDeletedContent />;
- const content = mEvent.getContent<IImageContent>();
- const imgInfo = content?.info;
- const mxcUrl = content.file?.url ?? content.url;
- if (typeof mxcUrl !== 'string') {
- return <MessageBrokenContent />;
- }
- const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
-
- return (
- <AttachmentBox
- style={{
- height: toRem(height < 48 ? 48 : height),
- width: toRem(152),
- }}
- {...props}
- ref={ref}
- >
- <ImageContent
- autoPlay={autoPlay}
- body={content.body || 'Image'}
- info={imgInfo}
- mimeType={imgInfo?.mimetype}
- url={mxcUrl}
- encInfo={content.file}
- />
- </AttachmentBox>
- );
- }
-);
+++ /dev/null
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { IPreviewUrlResponse } from 'matrix-js-sdk';
-import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import {
- UrlPreview,
- UrlPreviewContent,
- UrlPreviewDescription,
- UrlPreviewImg,
-} from '../../../components/url-preview';
-import {
- getIntersectionObserverEntry,
- useIntersectionObserver,
-} from '../../../hooks/useIntersectionObserver';
-import * as css from './styles.css';
-
-const linkStyles = { color: color.Success.Main };
-
-export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
- ({ url, ts, ...props }, ref) => {
- const mx = useMatrixClient();
- const [previewStatus, loadPreview] = useAsyncCallback(
- useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
- );
-
- useEffect(() => {
- loadPreview();
- }, [loadPreview]);
-
- if (previewStatus.status === AsyncStatus.Error) return null;
-
- const renderContent = (prev: IPreviewUrlResponse) => {
- const imgUrl = mx.mxcUrlToHttp(prev['og:image'] || '', 256, 256, 'scale', false);
-
- return (
- <>
- {imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
- <UrlPreviewContent>
- <Text
- style={linkStyles}
- truncate
- as="a"
- href={url}
- target="_blank"
- rel="no-referrer"
- size="T200"
- priority="300"
- >
- {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
- {decodeURIComponent(url)}
- </Text>
- <Text truncate priority="400">
- <b>{prev['og:title']}</b>
- </Text>
- <Text size="T200" priority="300">
- <UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
- </Text>
- </UrlPreviewContent>
- </>
- );
- };
-
- return (
- <UrlPreview {...props} ref={ref}>
- {previewStatus.status === AsyncStatus.Success ? (
- renderContent(previewStatus.data)
- ) : (
- <Box grow="Yes" alignItems="Center" justifyContent="Center">
- <Spinner variant="Secondary" size="400" />
- </Box>
- )}
- </UrlPreview>
- );
- }
-);
-
-export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
- const scrollRef = useRef<HTMLDivElement>(null);
- const backAnchorRef = useRef<HTMLDivElement>(null);
- const frontAnchorRef = useRef<HTMLDivElement>(null);
- const [backVisible, setBackVisible] = useState(true);
- const [frontVisible, setFrontVisible] = useState(true);
-
- const intersectionObserver = useIntersectionObserver(
- useCallback((entries) => {
- const backAnchor = backAnchorRef.current;
- const frontAnchor = frontAnchorRef.current;
- const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
- const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
- if (backEntry) {
- setBackVisible(backEntry.isIntersecting);
- }
- if (frontEntry) {
- setFrontVisible(frontEntry.isIntersecting);
- }
- }, []),
- useCallback(
- () => ({
- root: scrollRef.current,
- rootMargin: '10px',
- }),
- []
- )
- );
-
- useEffect(() => {
- const backAnchor = backAnchorRef.current;
- const frontAnchor = frontAnchorRef.current;
- if (backAnchor) intersectionObserver?.observe(backAnchor);
- if (frontAnchor) intersectionObserver?.observe(frontAnchor);
- return () => {
- if (backAnchor) intersectionObserver?.unobserve(backAnchor);
- if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
- };
- }, [intersectionObserver]);
-
- const handleScrollBack = () => {
- const scroll = scrollRef.current;
- if (!scroll) return;
- const { offsetWidth, scrollLeft } = scroll;
- scroll.scrollTo({
- left: scrollLeft - offsetWidth / 1.3,
- behavior: 'smooth',
- });
- };
- const handleScrollFront = () => {
- const scroll = scrollRef.current;
- if (!scroll) return;
- const { offsetWidth, scrollLeft } = scroll;
- scroll.scrollTo({
- left: scrollLeft + offsetWidth / 1.3,
- behavior: 'smooth',
- });
- };
-
- return (
- <Box
- direction="Column"
- {...props}
- ref={ref}
- style={{ marginTop: config.space.S200, position: 'relative' }}
- >
- <Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
- <Box shrink="No" alignItems="Center">
- <div ref={backAnchorRef} />
- {!backVisible && (
- <>
- <div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
- <IconButton
- className={css.UrlPreviewHolderBtn({ position: 'Left' })}
- variant="Secondary"
- radii="Pill"
- size="300"
- outlined
- onClick={handleScrollBack}
- >
- <Icon size="300" src={Icons.ArrowLeft} />
- </IconButton>
- </>
- )}
- <Box alignItems="Inherit" gap="200">
- {children}
-
- {!frontVisible && (
- <>
- <div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
- <IconButton
- className={css.UrlPreviewHolderBtn({ position: 'Right' })}
- variant="Primary"
- radii="Pill"
- size="300"
- outlined
- onClick={handleScrollFront}
- >
- <Icon size="300" src={Icons.ArrowRight} />
- </IconButton>
- </>
- )}
- <div ref={frontAnchorRef} />
- </Box>
- </Box>
- </Scroll>
- </Box>
- );
-});
+++ /dev/null
-import React, { useCallback, useEffect, useState } from 'react';
-import {
- Badge,
- Box,
- Button,
- Icon,
- Icons,
- Spinner,
- Text,
- Tooltip,
- TooltipProvider,
- as,
-} from 'folds';
-import classNames from 'classnames';
-import { BlurhashCanvas } from 'react-blurhash';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import {
- IThumbnailContent,
- IVideoInfo,
- MATRIX_BLUR_HASH_PROPERTY_NAME,
-} from '../../../../types/matrix/common';
-import * as css from './styles.css';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { getFileSrcUrl } from './util';
-import { Image, Video } from '../../../components/media';
-import { bytesToSize } from '../../../../util/common';
-import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
-
-export type VideoContentProps = {
- body: string;
- mimeType: string;
- url: string;
- info: IVideoInfo & IThumbnailContent;
- encInfo?: EncryptedAttachmentInfo;
- autoPlay?: boolean;
- loadThumbnail?: boolean;
-};
-export const VideoContent = as<'div', VideoContentProps>(
- ({ className, body, mimeType, url, info, encInfo, autoPlay, loadThumbnail, ...props }, ref) => {
- const mx = useMatrixClient();
- const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
-
- const [load, setLoad] = useState(false);
- const [error, setError] = useState(false);
-
- const [srcState, loadSrc] = useAsyncCallback(
- useCallback(
- () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
- [mx, url, mimeType, encInfo]
- )
- );
- const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
- useCallback(() => {
- const thumbInfo = info.thumbnail_info;
- const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
- if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
- throw new Error('Failed to load thumbnail');
- }
- return getFileSrcUrl(
- mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
- thumbInfo.mimetype,
- info.thumbnail_file
- );
- }, [mx, info])
- );
-
- const handleLoad = () => {
- setLoad(true);
- };
- const handleError = () => {
- setLoad(false);
- setError(true);
- };
-
- const handleRetry = () => {
- setError(false);
- loadSrc();
- };
-
- useEffect(() => {
- if (autoPlay) loadSrc();
- }, [autoPlay, loadSrc]);
- useEffect(() => {
- if (loadThumbnail) loadThumbSrc();
- }, [loadThumbnail, loadThumbSrc]);
-
- return (
- <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
- {typeof blurHash === 'string' && !load && (
- <BlurhashCanvas
- style={{ width: '100%', height: '100%' }}
- width={32}
- height={32}
- hash={blurHash}
- punch={1}
- />
- )}
- {thumbSrcState.status === AsyncStatus.Success && !load && (
- <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
- <Image alt={body} title={body} src={thumbSrcState.data} loading="lazy" />
- </Box>
- )}
- {!autoPlay && srcState.status === AsyncStatus.Idle && (
- <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
- <Button
- variant="Secondary"
- fill="Solid"
- radii="300"
- size="300"
- onClick={loadSrc}
- before={<Icon size="Inherit" src={Icons.Play} filled />}
- >
- <Text size="B300">Watch</Text>
- </Button>
- </Box>
- )}
- {srcState.status === AsyncStatus.Success && (
- <Box className={css.AbsoluteContainer}>
- <Video
- title={body}
- src={srcState.data}
- onLoadedMetadata={handleLoad}
- onError={handleError}
- autoPlay
- controls
- />
- </Box>
- )}
- {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
- !load && (
- <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
- <Spinner variant="Secondary" />
- </Box>
- )}
- {(error || srcState.status === AsyncStatus.Error) && (
- <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
- <TooltipProvider
- tooltip={
- <Tooltip variant="Critical">
- <Text>Failed to load video!</Text>
- </Tooltip>
- }
- position="Top"
- align="Center"
- >
- {(triggerRef) => (
- <Button
- ref={triggerRef}
- size="300"
- variant="Critical"
- fill="Soft"
- outlined
- radii="300"
- onClick={handleRetry}
- before={<Icon size="Inherit" src={Icons.Warning} filled />}
- >
- <Text size="B300">Retry</Text>
- </Button>
- )}
- </TooltipProvider>
- </Box>
- )}
- {!load && typeof info.size === 'number' && (
- <Box
- className={css.AbsoluteFooter}
- justifyContent="SpaceBetween"
- alignContent="Center"
- gap="200"
- >
- <Badge variant="Secondary" fill="Soft">
- <Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
- </Badge>
- <Badge variant="Secondary" fill="Soft">
- <Text size="L400">{bytesToSize(info.size)}</Text>
- </Badge>
- </Box>
- )}
- </Box>
- );
- }
-);
+++ /dev/null
-import React from 'react';
-import { MatrixEvent } from 'matrix-js-sdk';
-import { IFileContent } from '../../../../types/matrix/common';
-import {
- Attachment,
- AttachmentBox,
- AttachmentContent,
- AttachmentHeader,
-} from '../../../components/message';
-import { FileHeader } from './FileHeader';
-import { FileContent } from './FileContent';
-import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
-
-export const fileRenderer = (mEventId: string, mEvent: MatrixEvent) => {
- const content = mEvent.getContent<IFileContent>();
-
- const fileInfo = content?.info;
- const mxcUrl = content.file?.url ?? content.url;
-
- if (typeof mxcUrl !== 'string') {
- return null;
- }
-
- return (
- <Attachment>
- <AttachmentHeader>
- <FileHeader
- body={content.body ?? 'Unnamed File'}
- mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
- />
- </AttachmentHeader>
- <AttachmentBox>
- <AttachmentContent>
- <FileContent
- body={content.body ?? 'File'}
- info={fileInfo ?? {}}
- mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
- url={mxcUrl}
- encInfo={content.file}
- />
- </AttachmentContent>
- </AttachmentBox>
- </Attachment>
- );
-};
+++ /dev/null
-export * from './ImageContent';
-export * from './VideoContent';
-export * from './FileHeader';
-export * from './fileRenderer';
-export * from './AudioContent';
-export * from './Reactions';
-export * from './EventContent';
-export * from './Message';
-export * from './EncryptedContent';
-export * from './StickerContent';
+++ /dev/null
-import { style } from '@vanilla-extract/css';
-import { recipe } from '@vanilla-extract/recipes';
-import { DefaultReset, color, config, toRem } from 'folds';
-
-export const RelativeBase = style([
- DefaultReset,
- {
- position: 'relative',
- width: '100%',
- height: '100%',
- },
-]);
-
-export const AbsoluteContainer = style([
- DefaultReset,
- {
- position: 'absolute',
- top: 0,
- left: 0,
- width: '100%',
- height: '100%',
- },
-]);
-
-export const AbsoluteFooter = style([
- DefaultReset,
- {
- position: 'absolute',
- bottom: config.space.S100,
- left: config.space.S100,
- right: config.space.S100,
- },
-]);
-
-export const ModalWide = style({
- minWidth: '85vw',
- minHeight: '90vh',
-});
-
-export const MessageBase = style({
- position: 'relative',
-});
-
-export const MessageOptionsBase = style([
- DefaultReset,
- {
- position: 'absolute',
- top: toRem(-30),
- right: 0,
- zIndex: 1,
- },
-]);
-export const MessageOptionsBar = style([
- DefaultReset,
- {
- padding: config.space.S100,
- },
-]);
-
-export const MessageAvatar = style({
- cursor: 'pointer',
-});
-
-export const MessageQuickReaction = style({
- minWidth: toRem(32),
-});
-
-export const MessageMenuGroup = style({
- padding: config.space.S100,
-});
-
-export const MessageMenuItemText = style({
- flexGrow: 1,
-});
-
-export const ReactionsContainer = style({
- selectors: {
- '&:empty': {
- display: 'none',
- },
- },
-});
-
-export const ReactionsTooltipText = style({
- wordBreak: 'break-word',
-});
-
-export const UrlPreviewHolderGradient = recipe({
- base: [
- DefaultReset,
- {
- position: 'absolute',
- height: '100%',
- width: toRem(10),
- zIndex: 1,
- },
- ],
- variants: {
- position: {
- Left: {
- left: 0,
- background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
- },
- Right: {
- right: 0,
- background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
- },
- },
- },
-});
-export const UrlPreviewHolderBtn = recipe({
- base: [
- DefaultReset,
- {
- position: 'absolute',
- zIndex: 1,
- },
- ],
- variants: {
- position: {
- Left: {
- left: 0,
- transform: 'translateX(-25%)',
- },
- Right: {
- right: 0,
- transform: 'translateX(25%)',
- },
- },
- },
-});
+++ /dev/null
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { decryptFile } from '../../../utils/matrix';
-
-export const getFileSrcUrl = async (
- httpUrl: string,
- mimeType: string,
- encInfo?: EncryptedAttachmentInfo
-): Promise<string> => {
- if (encInfo) {
- if (typeof httpUrl !== 'string') throw new Error('Malformed event');
- const encRes = await fetch(httpUrl, { method: 'GET' });
- const encData = await encRes.arrayBuffer();
- const decryptedBlob = await decryptFile(encData, mimeType, encInfo);
- return URL.createObjectURL(decryptedBlob);
- }
- return httpUrl;
-};
-
-export const getSrcFile = async (src: string): Promise<Blob> => {
- const res = await fetch(src, { method: 'GET' });
- const blob = await res.blob();
- return blob;
-};
+++ /dev/null
-import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
-import to from 'await-to-js';
-import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
-import {
- getImageFileUrl,
- getThumbnail,
- getThumbnailDimensions,
- getVideoFileUrl,
- loadImageElement,
- loadVideoElement,
-} from '../../utils/dom';
-import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix';
-import { TUploadItem } from '../../state/roomInputDrafts';
-import { encodeBlurHash } from '../../utils/blurHash';
-import { scaleYDimension } from '../../utils/common';
-
-const generateThumbnailContent = async (
- mx: MatrixClient,
- img: HTMLImageElement | HTMLVideoElement,
- dimensions: [number, number],
- encrypt: boolean
-): Promise<IThumbnailContent> => {
- const thumbnail = await getThumbnail(img, ...dimensions);
- if (!thumbnail) throw new Error('Can not create thumbnail!');
- const encThumbData = encrypt ? await encryptFile(thumbnail) : undefined;
- const thumbnailFile = encThumbData?.file ?? thumbnail;
- if (!thumbnailFile) throw new Error('Can not create thumbnail!');
-
- const data = await mx.uploadContent(thumbnailFile);
- const thumbMxc = data?.content_uri;
- if (!thumbMxc) throw new Error('Failed when uploading thumbnail!');
- const thumbnailContent = getThumbnailContent({
- thumbnail: thumbnailFile,
- encInfo: encThumbData?.encInfo,
- mxc: thumbMxc,
- width: dimensions[0],
- height: dimensions[1],
- });
- return thumbnailContent;
-};
-
-export const getImageMsgContent = async (
- mx: MatrixClient,
- item: TUploadItem,
- mxc: string
-): Promise<IContent> => {
- const { file, originalFile, encInfo } = item;
- const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
- if (imgError) console.warn(imgError);
-
- const content: IContent = {
- msgtype: MsgType.Image,
- body: file.name,
- };
- if (imgEl) {
- const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
-
- content.info = {
- ...getImageInfo(imgEl, file),
- [MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
- };
- }
- if (encInfo) {
- content.file = {
- ...encInfo,
- url: mxc,
- };
- } else {
- content.url = mxc;
- }
- return content;
-};
-
-export const getVideoMsgContent = async (
- mx: MatrixClient,
- item: TUploadItem,
- mxc: string
-): Promise<IContent> => {
- const { file, originalFile, encInfo } = item;
-
- const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
- if (videoError) console.warn(videoError);
-
- const content: IContent = {
- msgtype: MsgType.Video,
- body: file.name,
- };
- if (videoEl) {
- const [thumbError, thumbContent] = await to(
- generateThumbnailContent(
- mx,
- videoEl,
- getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight),
- !!encInfo
- )
- );
- if (thumbContent && thumbContent.thumbnail_info) {
- thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = encodeBlurHash(
- videoEl,
- 512,
- scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight)
- );
- }
- if (thumbError) console.warn(thumbError);
- content.info = {
- ...getVideoInfo(videoEl, file),
- ...thumbContent,
- };
- }
- if (encInfo) {
- content.file = {
- ...encInfo,
- url: mxc,
- };
- } else {
- content.url = mxc;
- }
- return content;
-};
-
-export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => {
- const { file, encInfo } = item;
- const content: IContent = {
- msgtype: MsgType.Audio,
- body: file.name,
- info: {
- mimetype: file.type,
- size: file.size,
- },
- };
- if (encInfo) {
- content.file = {
- ...encInfo,
- url: mxc,
- };
- } else {
- content.url = mxc;
- }
- return content;
-};
-
-export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => {
- const { file, encInfo } = item;
- const content: IContent = {
- msgtype: MsgType.File,
- body: file.name,
- filename: file.name,
- info: {
- mimetype: file.type,
- size: file.size,
- },
- };
- if (encInfo) {
- content.file = {
- ...encInfo,
- url: mxc,
- };
- } else {
- content.url = mxc;
- }
- return content;
-};
+++ /dev/null
-import { style } from '@vanilla-extract/css';
-import { DefaultReset, color, config } from 'folds';
-
-export const ReactionViewer = style([
- DefaultReset,
- {
- height: '100%',
- },
-]);
-
-export const Sidebar = style({
- backgroundColor: color.Background.Container,
- color: color.Background.OnContainer,
-});
-export const SidebarContent = style({
- padding: config.space.S200,
- paddingRight: 0,
-});
-
-export const Header = style({
- paddingLeft: config.space.S400,
- paddingRight: config.space.S300,
-
- flexShrink: 0,
- gap: config.space.S200,
-});
-
-export const Content = style({
- paddingLeft: config.space.S200,
- paddingBottom: config.space.S400,
-});
+++ /dev/null
-import React, { useCallback, useState } from 'react';
-import classNames from 'classnames';
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
- Box,
- Header,
- Icon,
- IconButton,
- Icons,
- Line,
- MenuItem,
- Scroll,
- Text,
- as,
- config,
-} from 'folds';
-import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
-import { Relations } from 'matrix-js-sdk/lib/models/relations';
-import { getMemberDisplayName } from '../../../utils/room';
-import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
-import * as css from './ReactionViewer.css';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import colorMXID from '../../../../util/colorMXID';
-import { openProfileViewer } from '../../../../client/action/navigation';
-import { useRelations } from '../../../hooks/useRelations';
-import { Reaction } from '../../../components/message';
-import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
-
-export type ReactionViewerProps = {
- room: Room;
- initialKey?: string;
- relations: Relations;
- requestClose: () => void;
-};
-export const ReactionViewer = as<'div', ReactionViewerProps>(
- ({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
- const mx = useMatrixClient();
- const reactions = useRelations(
- relations,
- useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
- );
-
- const [selectedKey, setSelectedKey] = useState<string>(() => {
- if (initialKey) return initialKey;
- const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
- return defaultReaction ? defaultReaction[0] : '';
- });
-
- const getName = (member: RoomMember) =>
- getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
-
- const getReactionsForKey = (key: string): MatrixEvent[] => {
- const reactSet = reactions.find(([k]) => k === key)?.[1];
- if (!reactSet) return [];
- return Array.from(reactSet);
- };
-
- const selectedReactions = getReactionsForKey(selectedKey);
- const selectedShortcode =
- selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
- getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
- selectedKey;
-
- return (
- <Box
- className={classNames(css.ReactionViewer, className)}
- direction="Row"
- {...props}
- ref={ref}
- >
- <Box shrink="No" className={css.Sidebar}>
- <Scroll visibility="Hover" hideTrack size="300">
- <Box className={css.SidebarContent} direction="Column" gap="200">
- {reactions.map(([key, evts]) => {
- if (typeof key !== 'string') return null;
- return (
- <Reaction
- key={key}
- mx={mx}
- reaction={key}
- count={evts.size}
- aria-selected={key === selectedKey}
- onClick={() => setSelectedKey(key)}
- />
- );
- })}
- </Box>
- </Scroll>
- </Box>
- <Line variant="Surface" direction="Vertical" size="300" />
- <Box grow="Yes" direction="Column">
- <Header className={css.Header} variant="Surface" size="600">
- <Box grow="Yes">
- <Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
- </Box>
- <IconButton size="300" onClick={requestClose}>
- <Icon src={Icons.Cross} />
- </IconButton>
- </Header>
-
- <Box grow="Yes">
- <Scroll visibility="Hover" hideTrack size="300">
- <Box className={css.Content} direction="Column">
- {selectedReactions.map((mEvent) => {
- const senderId = mEvent.getSender();
- if (!senderId) return null;
- const member = room.getMember(senderId);
- const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
-
- const avatarUrl = member?.getAvatarUrl(
- mx.baseUrl,
- 100,
- 100,
- 'crop',
- undefined,
- false
- );
-
- return (
- <MenuItem
- key={senderId}
- style={{ padding: `0 ${config.space.S200}` }}
- radii="400"
- onClick={() => {
- requestClose();
- openProfileViewer(senderId, room.roomId);
- }}
- before={
- <Avatar size="200">
- {avatarUrl ? (
- <AvatarImage src={avatarUrl} />
- ) : (
- <AvatarFallback
- style={{
- background: colorMXID(senderId),
- color: 'white',
- }}
- >
- <Text size="H6">{name[0]}</Text>
- </AvatarFallback>
- )}
- </Avatar>
- }
- >
- <Box grow="Yes">
- <Text size="T400" truncate>
- {name}
- </Text>
- </Box>
- </MenuItem>
- );
- })}
- </Box>
- </Scroll>
- </Box>
- </Box>
- </Box>
- );
- }
-);
+++ /dev/null
-export * from './ReactionViewer';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import AsyncSearch from '../../../util/AsyncSearch';
-import { selectRoom, selectTab } from '../../../client/action/navigation';
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
import { roomIdByActivity } from '../../../util/sort';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
function useVisiblityToggle(setResult) {
const [isOpen, setIsOpen] = useState(false);
if (room.isSpaceRoom()) type = 'space';
else if (directs.has(roomId)) type = 'direct';
- return ({
+ return {
type,
name: room.name,
parents,
roomId,
room,
- });
+ };
});
}
const [isOpen, requestClose] = useVisiblityToggle(setResult);
const searchRef = useRef(null);
const mx = initMatrix.matrixClient;
+ const { navigateRoom, navigateSpace } = useRoomNavigate();
const handleSearchResults = (chunk, term) => {
setResult({
};
const openItem = (roomId, type) => {
- if (type === 'space') selectTab(roomId);
- else selectRoom(roomId);
+ if (type === 'space') navigateSpace(roomId);
+ else navigateRoom(roomId);
requestClose();
};
let imageSrc = null;
let iconSrc = null;
if (item.type === 'direct') {
- imageSrc = item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+ imageSrc =
+ item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
} else {
iconSrc = joinRuleToIconSrc(item.room.getJoinRule(), item.type === 'space');
}
size="small"
>
<div className="search-dialog">
- <form className="search-dialog__input" onSubmit={(e) => { e.preventDefault(); openFirstResult(); }}>
+ <form
+ className="search-dialog__input"
+ onSubmit={(e) => {
+ e.preventDefault();
+ openFirstResult();
+ }}
+ >
<RawIcon src={SearchIC} size="small" />
- <Input
- onChange={handleOnChange}
- forwardRef={searchRef}
- placeholder="Search"
- />
+ <Input onChange={handleOnChange} forwardRef={searchRef} placeholder="Search" />
<IconButton size="small" src={CrossIC} type="reset" onClick={handleCross} tabIndex={-1} />
</form>
<div className="search-dialog__content-wrapper">
<ScrollView autoHide>
<div className="search-dialog__content">
- { Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector) }
+ {Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector)}
</div>
</ScrollView>
</div>
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { leave } from '../../../client/action/room';
-import {
- createSpaceShortcut,
- deleteSpaceShortcut,
- categorizeSpace,
- unCategorizeSpace,
-} from '../../../client/action/accountData';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
-import PinIC from '../../../../public/res/ic/outlined/pin.svg';
-import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
-import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
-import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
-import { useForceUpdate } from '../../hooks/useForceUpdate';
const tabText = {
GENERAL: 'General',
PERMISSIONS: 'Permissions',
};
-const tabItems = [{
- iconSrc: SettingsIC,
- text: tabText.GENERAL,
- disabled: false,
-}, {
- iconSrc: UserIC,
- text: tabText.MEMBERS,
- disabled: false,
-}, {
- iconSrc: EmojiIC,
- text: tabText.EMOJIS,
- disabled: false,
-}, {
- iconSrc: ShieldUserIC,
- text: tabText.PERMISSIONS,
- disabled: false,
-}];
+const tabItems = [
+ {
+ iconSrc: SettingsIC,
+ text: tabText.GENERAL,
+ disabled: false,
+ },
+ {
+ iconSrc: UserIC,
+ text: tabText.MEMBERS,
+ disabled: false,
+ },
+ {
+ iconSrc: EmojiIC,
+ text: tabText.EMOJIS,
+ disabled: false,
+ },
+ {
+ iconSrc: ShieldUserIC,
+ text: tabText.PERMISSIONS,
+ disabled: false,
+ },
+];
function GeneralSettings({ roomId }) {
- const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
- const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
const roomName = initMatrix.matrixClient.getRoom(roomId)?.name;
- const [, forceUpdate] = useForceUpdate();
return (
<>
<div className="room-settings__card">
<MenuHeader>Options</MenuHeader>
- <MenuItem
- onClick={() => {
- if (isCategorized) unCategorizeSpace(roomId);
- else categorizeSpace(roomId);
- forceUpdate();
- }}
- iconSrc={isCategorized ? CategoryFilledIC : CategoryIC}
- >
- {isCategorized ? 'Uncategorize subspaces' : 'Categorize subspaces'}
- </MenuItem>
- <MenuItem
- onClick={() => {
- if (isPinned) deleteSpaceShortcut(roomId);
- else createSpaceShortcut(roomId);
- forceUpdate();
- }}
- iconSrc={isPinned ? PinFilledIC : PinIC}
- >
- {isPinned ? 'Unpin from sidebar' : 'Pin to sidebar'}
- </MenuItem>
<MenuItem
variant="danger"
onClick={async () => {
'Leave space',
`Are you sure that you want to leave "${roomName}" space?`,
'Leave',
- 'danger',
+ 'danger'
);
if (isConfirmed) leave(roomId);
}}
<PopupWindow
isOpen={isOpen}
className="space-settings"
- title={(
+ title={
<Text variant="s1" weight="medium" primary>
{isOpen && twemojify(room.name)}
<span style={{ color: 'var(--tc-surface-low)' }}> — space settings</span>
</Text>
- )}
+ }
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
onRequestClose={requestClose}
>
import React from 'react';
import { Provider as JotaiProvider } from 'jotai';
-import {
- Route,
- RouterProvider,
- createBrowserRouter,
- createHashRouter,
- createRoutesFromElements,
- redirect,
-} from 'react-router-dom';
+import { RouterProvider } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ClientConfigLoader } from '../components/ClientConfigLoader';
-import { ClientConfig, ClientConfigProvider } from '../hooks/useClientConfig';
-import { AuthLayout, Login, Register, ResetPassword, authLayoutLoader } from './auth';
-import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
-import { isAuthenticated } from '../../client/state/auth';
-import Client from '../templates/client/Client';
-import { getLoginPath } from './pathUtils';
+import { ClientConfigProvider } from '../hooks/useClientConfig';
import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
import { FeatureCheck } from './FeatureCheck';
+import { createRouter } from './Router';
+import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
-const createRouter = (clientConfig: ClientConfig) => {
- const { hashRouter } = clientConfig;
+const queryClient = new QueryClient();
- const routes = createRoutesFromElements(
- <Route>
- <Route
- path={ROOT_PATH}
- loader={() => {
- if (isAuthenticated()) return redirect('/home');
- return redirect(getLoginPath());
- }}
- />
- <Route loader={authLayoutLoader} element={<AuthLayout />}>
- <Route path={LOGIN_PATH} element={<Login />} />
- <Route path={REGISTER_PATH} element={<Register />} />
- <Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
- </Route>
-
- <Route
- loader={() => {
- if (!isAuthenticated()) return redirect(getLoginPath());
- return null;
- }}
- >
- <Route path="/home" element={<Client />} />
- <Route path="/direct" element={<p>direct</p>} />
- <Route path="/:spaceIdOrAlias" element={<p>:spaceIdOrAlias</p>} />
- <Route path="/explore" element={<p>explore</p>} />
- </Route>
- <Route path="/*" element={<p>Page not found</p>} />
- </Route>
- );
-
- if (hashRouter?.enabled) {
- return createHashRouter(routes, { basename: hashRouter.basename });
- }
- return createBrowserRouter(routes, {
- basename: import.meta.env.BASE_URL,
- });
-};
-
-// TODO: app crash boundary
function App() {
+ const screenSize = useScreenSize();
+
return (
- <FeatureCheck>
- <ClientConfigLoader
- fallback={() => <ConfigConfigLoading />}
- error={(err, retry, ignore) => (
- <ConfigConfigError error={err} retry={retry} ignore={ignore} />
- )}
- >
- {(clientConfig) => (
- <ClientConfigProvider value={clientConfig}>
- <JotaiProvider>
- <RouterProvider router={createRouter(clientConfig)} />
- </JotaiProvider>
- </ClientConfigProvider>
- )}
- </ClientConfigLoader>
- </FeatureCheck>
+ <ScreenSizeProvider value={screenSize}>
+ <FeatureCheck>
+ <ClientConfigLoader
+ fallback={() => <ConfigConfigLoading />}
+ error={(err, retry, ignore) => (
+ <ConfigConfigError error={err} retry={retry} ignore={ignore} />
+ )}
+ >
+ {(clientConfig) => (
+ <ClientConfigProvider value={clientConfig}>
+ <QueryClientProvider client={queryClient}>
+ <JotaiProvider>
+ <RouterProvider router={createRouter(clientConfig, screenSize)} />
+ </JotaiProvider>
+ <ReactQueryDevtools initialIsOpen={false} />
+ </QueryClientProvider>
+ </ClientConfigProvider>
+ )}
+ </ClientConfigLoader>
+ </FeatureCheck>
+ </ScreenSizeProvider>
);
}
--- /dev/null
+import { ReactNode } from 'react';
+import { useMatch } from 'react-router-dom';
+import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
+import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
+
+type MobileFriendlyClientNavProps = {
+ children: ReactNode;
+};
+export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
+ const screenSize = useScreenSizeContext();
+ const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
+ const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
+ const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
+ const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
+ const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
+
+ if (
+ screenSize === ScreenSize.Mobile &&
+ !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)
+ ) {
+ return null;
+ }
+
+ return children;
+}
+
+type MobileFriendlyPageNavProps = {
+ path: string;
+ children: ReactNode;
+};
+export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavProps) {
+ const screenSize = useScreenSizeContext();
+ const exactPath = useMatch({
+ path,
+ caseSensitive: true,
+ end: true,
+ });
+
+ if (screenSize === ScreenSize.Mobile && !exactPath) {
+ return null;
+ }
+
+ return children;
+}
--- /dev/null
+import React from 'react';
+import {
+ Outlet,
+ Route,
+ createBrowserRouter,
+ createHashRouter,
+ createRoutesFromElements,
+ redirect,
+} from 'react-router-dom';
+
+import { ClientConfig } from '../hooks/useClientConfig';
+import { AuthLayout, Login, Register, ResetPassword } from './auth';
+import {
+ DIRECT_PATH,
+ EXPLORE_PATH,
+ HOME_PATH,
+ LOGIN_PATH,
+ INBOX_PATH,
+ REGISTER_PATH,
+ RESET_PASSWORD_PATH,
+ SPACE_PATH,
+ _CREATE_PATH,
+ _FEATURED_PATH,
+ _INVITES_PATH,
+ _JOIN_PATH,
+ _LOBBY_PATH,
+ _NOTIFICATIONS_PATH,
+ _ROOM_PATH,
+ _SEARCH_PATH,
+ _SERVER_PATH,
+} from './paths';
+import { isAuthenticated } from '../../client/state/auth';
+import {
+ getAppPathFromHref,
+ getExploreFeaturedPath,
+ getHomePath,
+ getInboxNotificationsPath,
+ getLoginPath,
+ getOriginBaseUrl,
+ getSpaceLobbyPath,
+} from './pathUtils';
+import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
+import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
+import { Direct, DirectRouteRoomProvider } from './client/direct';
+import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
+import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
+import { Notifications, Inbox, Invites } from './client/inbox';
+import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
+import { Room } from '../features/room';
+import { Lobby } from '../features/lobby';
+import { WelcomePage } from './client/WelcomePage';
+import { SidebarNav } from './client/SidebarNav';
+import { PageRoot } from '../components/page';
+import { ScreenSize } from '../hooks/useScreenSize';
+import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly';
+import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
+
+export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
+ const { hashRouter } = clientConfig;
+ const mobile = screenSize === ScreenSize.Mobile;
+
+ const routes = createRoutesFromElements(
+ <Route>
+ <Route
+ index
+ loader={() => {
+ if (isAuthenticated()) return redirect(getHomePath());
+ const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
+ if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
+ return redirect(getLoginPath());
+ }}
+ />
+ <Route
+ loader={() => {
+ if (isAuthenticated()) {
+ return redirect(getHomePath());
+ }
+
+ return null;
+ }}
+ element={<AuthLayout />}
+ >
+ <Route path={LOGIN_PATH} element={<Login />} />
+ <Route path={REGISTER_PATH} element={<Register />} />
+ <Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
+ </Route>
+
+ <Route
+ loader={() => {
+ if (!isAuthenticated()) {
+ const afterLoginPath = getAppPathFromHref(
+ getOriginBaseUrl(hashRouter),
+ window.location.href
+ );
+ if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
+ return redirect(getLoginPath());
+ }
+ return null;
+ }}
+ element={
+ <ClientRoot>
+ <ClientInitStorageAtom>
+ <ClientBindAtoms>
+ <ClientLayout
+ nav={
+ <MobileFriendlyClientNav>
+ <SidebarNav />
+ </MobileFriendlyClientNav>
+ }
+ >
+ <Outlet />
+ </ClientLayout>
+ </ClientBindAtoms>
+ </ClientInitStorageAtom>
+ </ClientRoot>
+ }
+ >
+ <Route
+ path={HOME_PATH}
+ element={
+ <PageRoot
+ nav={
+ <MobileFriendlyPageNav path={HOME_PATH}>
+ <Home />
+ </MobileFriendlyPageNav>
+ }
+ >
+ <Outlet />
+ </PageRoot>
+ }
+ >
+ {mobile ? null : <Route index element={<WelcomePage />} />}
+ <Route path={_CREATE_PATH} element={<p>create</p>} />
+ <Route path={_JOIN_PATH} element={<p>join</p>} />
+ <Route path={_SEARCH_PATH} element={<HomeSearch />} />
+ <Route
+ path={_ROOM_PATH}
+ element={
+ <HomeRouteRoomProvider>
+ <Room />
+ </HomeRouteRoomProvider>
+ }
+ />
+ </Route>
+ <Route
+ path={DIRECT_PATH}
+ element={
+ <PageRoot
+ nav={
+ <MobileFriendlyPageNav path={DIRECT_PATH}>
+ <Direct />
+ </MobileFriendlyPageNav>
+ }
+ >
+ <Outlet />
+ </PageRoot>
+ }
+ >
+ {mobile ? null : <Route index element={<WelcomePage />} />}
+ <Route path={_CREATE_PATH} element={<p>create</p>} />
+ <Route
+ path={_ROOM_PATH}
+ element={
+ <DirectRouteRoomProvider>
+ <Room />
+ </DirectRouteRoomProvider>
+ }
+ />
+ </Route>
+ <Route
+ path={SPACE_PATH}
+ element={
+ <RouteSpaceProvider>
+ <PageRoot
+ nav={
+ <MobileFriendlyPageNav path={SPACE_PATH}>
+ <Space />
+ </MobileFriendlyPageNav>
+ }
+ >
+ <Outlet />
+ </PageRoot>
+ </RouteSpaceProvider>
+ }
+ >
+ {mobile ? null : (
+ <Route
+ index
+ loader={({ params }) => {
+ const { spaceIdOrAlias } = params;
+ if (spaceIdOrAlias) {
+ return redirect(getSpaceLobbyPath(spaceIdOrAlias));
+ }
+ return null;
+ }}
+ element={<WelcomePage />}
+ />
+ )}
+ <Route path={_LOBBY_PATH} element={<Lobby />} />
+ <Route path={_SEARCH_PATH} element={<SpaceSearch />} />
+ <Route
+ path={_ROOM_PATH}
+ element={
+ <SpaceRouteRoomProvider>
+ <Room />
+ </SpaceRouteRoomProvider>
+ }
+ />
+ </Route>
+ <Route
+ path={EXPLORE_PATH}
+ element={
+ <PageRoot
+ nav={
+ <MobileFriendlyPageNav path={EXPLORE_PATH}>
+ <Explore />
+ </MobileFriendlyPageNav>
+ }
+ >
+ <Outlet />
+ </PageRoot>
+ }
+ >
+ {mobile ? null : (
+ <Route
+ index
+ loader={() => redirect(getExploreFeaturedPath())}
+ element={<WelcomePage />}
+ />
+ )}
+ <Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
+ <Route path={_SERVER_PATH} element={<PublicRooms />} />
+ </Route>
+ <Route
+ path={INBOX_PATH}
+ element={
+ <PageRoot
+ nav={
+ <MobileFriendlyPageNav path={INBOX_PATH}>
+ <Inbox />
+ </MobileFriendlyPageNav>
+ }
+ >
+ <Outlet />
+ </PageRoot>
+ }
+ >
+ {mobile ? null : (
+ <Route
+ index
+ loader={() => redirect(getInboxNotificationsPath())}
+ element={<WelcomePage />}
+ />
+ )}
+ <Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
+ <Route path={_INVITES_PATH} element={<Invites />} />
+ </Route>
+ </Route>
+ <Route path="/*" element={<p>Page not found</p>} />
+ </Route>
+ );
+
+ if (hashRouter?.enabled) {
+ return createHashRouter(routes, { basename: hashRouter.basename });
+ }
+ return createBrowserRouter(routes, {
+ basename: import.meta.env.BASE_URL,
+ });
+};
--- /dev/null
+const AFTER_LOGIN_REDIRECT_PATH_KEY = 'after_login_redirect_url';
+
+export const setAfterLoginRedirectPath = (url: string): void => {
+ localStorage.setItem(AFTER_LOGIN_REDIRECT_PATH_KEY, url);
+};
+export const getAfterLoginRedirectPath = (): string | undefined => {
+ const url = localStorage.getItem(AFTER_LOGIN_REDIRECT_PATH_KEY);
+ return url ?? undefined;
+};
+export const deleteAfterLoginRedirectPath = (): void => {
+ localStorage.removeItem(AFTER_LOGIN_REDIRECT_PATH_KEY);
+};
import React, { useCallback, useEffect } from 'react';
import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
import {
- LoaderFunction,
Outlet,
generatePath,
matchPath,
- redirect,
useLocation,
useNavigate,
useParams,
import { AuthFooter } from './AuthFooter';
import * as css from './styles.css';
import * as PatternsCss from '../../styles/Patterns.css';
-import { isAuthenticated } from '../../../client/state/auth';
import {
clientAllowedServer,
clientDefaultServer,
useClientConfig,
} from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import { LOGIN_PATH, REGISTER_PATH } from '../paths';
+import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
-export const authLayoutLoader: LoaderFunction = () => {
- if (isAuthenticated()) {
- return redirect('/');
- }
-
- return null;
-};
-
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) {
return LOGIN_PATH;
}
+ if (matchPath(RESET_PASSWORD_PATH, pathname)) {
+ return RESET_PASSWORD_PATH;
+ }
if (matchPath(REGISTER_PATH, pathname)) {
return REGISTER_PATH;
}
<AuthServerProvider value={discoveryState.data.serverName}>
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
<SpecVersionsLoader
+ baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
fallback={() => (
<AuthLayoutLoading
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
const getSSOIdUrl = (ssoId: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId);
+ const anyAsBtn = providers.find(
+ (provider) => !provider.icon || !mx.mxcUrlToHttp(provider.icon, 96, 96, 'crop', false)
+ );
+
return (
<Box justifyContent="Center" gap="600" wrap="Wrap">
{providers.map((provider) => {
const buttonTitle = `Continue with ${name}`;
- if (iconUrl && asIcons) {
+ if (!anyAsBtn && iconUrl && asIcons) {
return (
<Avatar
style={{ cursor: 'pointer' }}
Menu,
MenuItem,
PopOut,
+ RectCords,
Text,
config,
} from 'folds';
allowCustomServer?: boolean;
onServerChange: (server: string) => void;
}) {
- const [serverMenu, setServerMenu] = useState(false);
+ const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
- setServerMenu(true);
+ setServerMenuAnchor(undefined);
}
if (evt.key === 'Enter') {
evt.preventDefault();
if (selectedServer) {
onServerChange(selectedServer);
}
- setServerMenu(false);
+ setServerMenuAnchor(undefined);
+ };
+
+ const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
+ const target = evt.currentTarget.parentElement ?? evt.currentTarget;
+ setServerMenuAnchor(target.getBoundingClientRect());
};
return (
onKeyDown={handleKeyDown}
size="500"
readOnly={!allowCustomServer}
- onClick={allowCustomServer ? undefined : () => setServerMenu(true)}
+ onClick={allowCustomServer ? undefined : handleOpenServerMenu}
after={
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
<PopOut
- open={serverMenu}
+ anchor={serverMenuAnchor}
position="Bottom"
align="End"
offset={4}
<FocusTrap
focusTrapOptions={{
initialFocus: false,
- onDeactivate: () => setServerMenu(false),
+ onDeactivate: () => setServerMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
</FocusTrap>
}
>
- {(anchorRef) => (
- <IconButton
- ref={anchorRef}
- onClick={() => setServerMenu(true)}
- variant={allowCustomServer ? 'Background' : 'Surface'}
- size="300"
- aria-pressed={serverMenu}
- radii="300"
- >
- <Icon src={Icons.ChevronBottom} />
- </IconButton>
- )}
+ <IconButton
+ onClick={handleOpenServerMenu}
+ variant={allowCustomServer ? 'Background' : 'Surface'}
+ size="300"
+ aria-pressed={!!serverMenuAnchor}
+ radii="300"
+ >
+ <Icon src={Icons.ChevronBottom} />
+ </IconButton>
</PopOut>
)
}
-import React from 'react';
+import React, { useMemo } from 'react';
import { Box, Text, color } from 'folds';
import { Link, useSearchParams } from 'react-router-dom';
import { useAuthFlows } from '../../../hooks/useAuthFlows';
return loginToken ?? undefined;
};
-const getLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams => ({
- username: searchParams.get('username') ?? undefined,
- email: searchParams.get('email') ?? undefined,
- loginToken: searchParams.get('loginToken') ?? undefined,
-});
+const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams =>
+ useMemo(
+ () => ({
+ username: searchParams.get('username') ?? undefined,
+ email: searchParams.get('email') ?? undefined,
+ loginToken: searchParams.get('loginToken') ?? undefined,
+ }),
+ [searchParams]
+ );
export function Login() {
const server = useAuthServer();
const { hashRouter } = useClientConfig();
const { loginFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
- const loginSearchParams = getLoginSearchParams(searchParams);
+ const loginSearchParams = useLoginSearchParams(searchParams);
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
const loginTokenForHashRouter = getLoginTokenSearchParam();
const absoluteLoginPath = usePathWithOrigin(getLoginPath(server));
-import React, { FormEventHandler, useCallback, useState } from 'react';
+import React, { FormEventHandler, MouseEventHandler, useCallback, useState } from 'react';
import {
Box,
Button,
OverlayBackdrop,
OverlayCenter,
PopOut,
+ RectCords,
Spinner,
Text,
config,
import { getResetPasswordPath } from '../../pathUtils';
function UsernameHint({ server }: { server: string }) {
- const [open, setOpen] = useState(false);
+ const [anchor, setAnchor] = useState<RectCords>();
+
+ const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
+ setAnchor(evt.currentTarget.getBoundingClientRect());
+ };
return (
<PopOut
- open={open}
+ anchor={anchor}
position="Top"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
- onDeactivate: () => setOpen(false),
+ onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
}}
>
</FocusTrap>
}
>
- {(targetRef) => (
- <IconButton
- tabIndex={-1}
- onClick={() => setOpen(true)}
- ref={targetRef}
- type="button"
- variant="Background"
- size="300"
- radii="300"
- aria-pressed={open}
- >
- <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
- </IconButton>
- )}
+ <IconButton
+ tabIndex={-1}
+ onClick={handleOpenMenu}
+ type="button"
+ variant="Background"
+ size="300"
+ radii="300"
+ aria-pressed={!!anchor}
+ >
+ <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
+ </IconButton>
</PopOut>
);
}
import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
import { autoDiscovery, specVersions } from '../../../cs-api';
import { updateLocalStore } from '../../../../client/action/auth';
-import { ROOT_PATH } from '../../paths';
import { ErrorCode } from '../../../cs-errorcode';
+import {
+ deleteAfterLoginRedirectPath,
+ getAfterLoginRedirectPath,
+} from '../../afterLoginRedirectPath';
+import { getHomePath } from '../../pathUtils';
export enum GetBaseUrlError {
NotAllow = 'NotAllow',
if (data) {
const { response: loginRes, baseUrl: loginBaseUrl } = data;
updateLocalStore(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
- // TODO: add after login redirect url
- navigate(ROOT_PATH, { replace: true });
+ const afterLoginRedirectUrl = getAfterLoginRedirectPath();
+ deleteAfterLoginRedirectPath();
+ navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true });
}
}, [data, navigate]);
};
-import React from 'react';
+import React, { useMemo } from 'react';
import { Box, Text, color } from 'folds';
import { Link, useSearchParams } from 'react-router-dom';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { RegisterPathSearchParams } from '../../paths';
-const getRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams => ({
- username: searchParams.get('username') ?? undefined,
- email: searchParams.get('email') ?? undefined,
- token: searchParams.get('token') ?? undefined,
-});
+const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
+ useMemo(
+ () => ({
+ username: searchParams.get('username') ?? undefined,
+ email: searchParams.get('email') ?? undefined,
+ token: searchParams.get('token') ?? undefined,
+ }),
+ [searchParams]
+ );
export function Register() {
const server = useAuthServer();
const { loginFlows, registerFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
- const registerSearchParams = getRegisterSearchParams(searchParams);
+ const registerSearchParams = useRegisterSearchParams(searchParams);
const { sso } = useParsedLoginFlows(loginFlows.flows);
// redirect to /login because only that path handle m.login.token
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { updateLocalStore } from '../../../../client/action/auth';
-import { ROOT_PATH } from '../../paths';
+import { LoginPathSearchParams } from '../../paths';
import { ErrorCode } from '../../../cs-errorcode';
+import {
+ deleteAfterLoginRedirectPath,
+ getAfterLoginRedirectPath,
+} from '../../afterLoginRedirectPath';
+import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils';
+import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
export enum RegisterError {
UserTaken = 'UserTaken',
if (accessToken && deviceId) {
updateLocalStore(accessToken, deviceId, userId, baseUrl);
- // TODO: add after register redirect url
- navigate(ROOT_PATH, { replace: true });
+ const afterLoginRedirectPath = getAfterLoginRedirectPath();
+ deleteAfterLoginRedirectPath();
+ navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true });
} else {
- // TODO: navigate to login with userId
- navigate(ROOT_PATH, { replace: true });
+ const username = getMxIdLocalPart(userId);
+ const userServer = getMxIdServer(userId);
+ navigate(
+ withSearchParam<LoginPathSearchParams>(getLoginPath(userServer), {
+ username,
+ }),
+ { replace: true }
+ );
}
}
}, [data, navigate]);
import { Box, Text } from 'folds';
-import React from 'react';
+import React, { useMemo } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { getLoginPath } from '../../pathUtils';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { PasswordResetForm } from './PasswordResetForm';
+import { ResetPasswordPathSearchParams } from '../../paths';
-export type ResetPasswordSearchParams = {
- email?: string;
-};
-
-const getResetPasswordSearchParams = (
+const useResetPasswordSearchParams = (
searchParams: URLSearchParams
-): ResetPasswordSearchParams => ({
- email: searchParams.get('email') ?? undefined,
-});
+): ResetPasswordPathSearchParams =>
+ useMemo(
+ () => ({
+ email: searchParams.get('email') ?? undefined,
+ }),
+ [searchParams]
+ );
export function ResetPassword() {
const server = useAuthServer();
const [searchParams] = useSearchParams();
- const resetPasswordSearchParams = getResetPasswordSearchParams(searchParams);
+ const resetPasswordSearchParams = useResetPasswordSearchParams(searchParams);
return (
<Box direction="Column" gap="500">
--- /dev/null
+import { ReactNode } from 'react';
+
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useBindAtoms } from '../../state/hooks/useBindAtoms';
+
+type ClientBindAtomsProps = {
+ children: ReactNode;
+};
+export function ClientBindAtoms({ children }: ClientBindAtomsProps) {
+ const mx = useMatrixClient();
+ useBindAtoms(mx);
+
+ return children;
+}
--- /dev/null
+import React, { ReactNode, useMemo } from 'react';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { makeClosedNavCategoriesAtom } from '../../state/closedNavCategories';
+import { ClosedNavCategoriesProvider } from '../../state/hooks/closedNavCategories';
+import { makeClosedLobbyCategoriesAtom } from '../../state/closedLobbyCategories';
+import { ClosedLobbyCategoriesProvider } from '../../state/hooks/closedLobbyCategories';
+import { makeNavToActivePathAtom } from '../../state/navToActivePath';
+import { NavToActivePathProvider } from '../../state/hooks/navToActivePath';
+import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder';
+import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder';
+
+type ClientInitStorageAtomProps = {
+ children: ReactNode;
+};
+export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps) {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+
+ const closedNavCategoriesAtom = useMemo(() => makeClosedNavCategoriesAtom(userId), [userId]);
+
+ const closedLobbyCategoriesAtom = useMemo(() => makeClosedLobbyCategoriesAtom(userId), [userId]);
+
+ const navToActivePathAtom = useMemo(() => makeNavToActivePathAtom(userId), [userId]);
+
+ const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]);
+
+ return (
+ <ClosedNavCategoriesProvider value={closedNavCategoriesAtom}>
+ <ClosedLobbyCategoriesProvider value={closedLobbyCategoriesAtom}>
+ <NavToActivePathProvider value={navToActivePathAtom}>
+ <OpenedSidebarFolderProvider value={openedSidebarFolderAtom}>
+ {children}
+ </OpenedSidebarFolderProvider>
+ </NavToActivePathProvider>
+ </ClosedLobbyCategoriesProvider>
+ </ClosedNavCategoriesProvider>
+ );
+}
--- /dev/null
+import React, { ReactNode } from 'react';
+import { Box } from 'folds';
+
+type ClientLayoutProps = {
+ nav: ReactNode;
+ children: ReactNode;
+};
+export function ClientLayout({ nav, children }: ClientLayoutProps) {
+ return (
+ <Box style={{ height: '100%' }}>
+ <Box shrink="No">{nav}</Box>
+ <Box grow="Yes">{children}</Box>
+ </Box>
+ );
+}
--- /dev/null
+import { Box, Spinner, Text } from 'folds';
+import React, { ReactNode, useEffect, useState } from 'react';
+import initMatrix from '../../../client/initMatrix';
+import { initHotkeys } from '../../../client/event/hotkeys';
+import { initRoomListListener } from '../../../client/event/roomList';
+import { getSecret } from '../../../client/state/auth';
+import { SplashScreen } from '../../components/splash-screen';
+import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
+import { CapabilitiesProvider } from '../../hooks/useCapabilities';
+import { MediaConfigProvider } from '../../hooks/useMediaConfig';
+import { MatrixClientProvider } from '../../hooks/useMatrixClient';
+import { SpecVersions } from './SpecVersions';
+import Windows from '../../organisms/pw/Windows';
+import Dialogs from '../../organisms/pw/Dialogs';
+import ReusableContextMenu from '../../atoms/context-menu/ReusableContextMenu';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+
+function SystemEmojiFeature() {
+ const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
+
+ if (twitterEmoji) {
+ document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
+ } else {
+ document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
+ }
+
+ return null;
+}
+
+function ClientRootLoading() {
+ return (
+ <SplashScreen>
+ <Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
+ <Spinner variant="Secondary" size="600" />
+ <Text>Heating up</Text>
+ </Box>
+ </SplashScreen>
+ );
+}
+
+type ClientRootProps = {
+ children: ReactNode;
+};
+export function ClientRoot({ children }: ClientRootProps) {
+ const [loading, setLoading] = useState(true);
+ const { baseUrl } = getSecret();
+
+ useEffect(() => {
+ const handleStart = () => {
+ initHotkeys();
+ initRoomListListener(initMatrix.roomList);
+ setLoading(false);
+ };
+ initMatrix.once('init_loading_finished', handleStart);
+ if (!initMatrix.matrixClient) initMatrix.init();
+ return () => {
+ initMatrix.removeListener('init_loading_finished', handleStart);
+ };
+ }, []);
+
+ return (
+ <SpecVersions baseUrl={baseUrl!}>
+ {loading ? (
+ <ClientRootLoading />
+ ) : (
+ <MatrixClientProvider value={initMatrix.matrixClient!}>
+ <CapabilitiesAndMediaConfigLoader>
+ {(capabilities, mediaConfig) => (
+ <CapabilitiesProvider value={capabilities ?? {}}>
+ <MediaConfigProvider value={mediaConfig ?? {}}>
+ {children}
+
+ {/* TODO: remove these components after navigation refactor */}
+ <Windows />
+ <Dialogs />
+ <ReusableContextMenu />
+ <SystemEmojiFeature />
+ </MediaConfigProvider>
+ </CapabilitiesProvider>
+ )}
+ </CapabilitiesAndMediaConfigLoader>
+ </MatrixClientProvider>
+ )}
+ </SpecVersions>
+ );
+}
--- /dev/null
+import React, { useRef } from 'react';
+import { Icon, Icons, Scroll } from 'folds';
+
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarStackSeparator,
+ SidebarStack,
+ SidebarAvatar,
+ SidebarItemTooltip,
+ SidebarItem,
+} from '../../components/sidebar';
+import { DirectTab, HomeTab, SpaceTabs, InboxTab, ExploreTab, UserTab } from './sidebar';
+import { openCreateRoom, openSearch } from '../../../client/action/navigation';
+
+export function SidebarNav() {
+ const scrollRef = useRef<HTMLDivElement>(null);
+
+ return (
+ <Sidebar>
+ <SidebarContent
+ scrollable={
+ <Scroll ref={scrollRef} variant="Background" size="0">
+ <SidebarStack>
+ <HomeTab />
+ <DirectTab />
+ </SidebarStack>
+ <SpaceTabs scrollRef={scrollRef} />
+ <SidebarStackSeparator />
+ <SidebarStack>
+ <ExploreTab />
+ <SidebarItem>
+ <SidebarItemTooltip tooltip="Create Space">
+ {(triggerRef) => (
+ <SidebarAvatar
+ as="button"
+ ref={triggerRef}
+ outlined
+ onClick={() => openCreateRoom(true)}
+ >
+ <Icon src={Icons.Plus} />
+ </SidebarAvatar>
+ )}
+ </SidebarItemTooltip>
+ </SidebarItem>
+ </SidebarStack>
+ </Scroll>
+ }
+ sticky={
+ <>
+ <SidebarStackSeparator />
+ <SidebarStack>
+ <SidebarItem>
+ <SidebarItemTooltip tooltip="Search">
+ {(triggerRef) => (
+ <SidebarAvatar
+ as="button"
+ ref={triggerRef}
+ outlined
+ onClick={() => openSearch()}
+ >
+ <Icon src={Icons.Search} />
+ </SidebarAvatar>
+ )}
+ </SidebarItemTooltip>
+ </SidebarItem>
+
+ <InboxTab />
+ <UserTab />
+ </SidebarStack>
+ </>
+ }
+ />
+ </Sidebar>
+ );
+}
--- /dev/null
+import React, { ReactNode } from 'react';
+import { Box, Dialog, config, Text, Button, Spinner } from 'folds';
+import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
+import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
+import { SplashScreen } from '../../components/splash-screen';
+
+export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
+ return (
+ <SpecVersionsLoader
+ baseUrl={baseUrl}
+ fallback={() => (
+ <SplashScreen>
+ <Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
+ <Spinner variant="Secondary" size="600" />
+ <Text>Connecting to server</Text>
+ </Box>
+ </SplashScreen>
+ )}
+ error={(err, retry, ignore) => (
+ <SplashScreen>
+ <Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
+ <Dialog>
+ <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
+ <Text>
+ Failed to connect to homeserver. Either homeserver is down or your internet.
+ </Text>
+ <Button variant="Critical" onClick={retry}>
+ <Text as="span" size="B400">
+ Retry
+ </Text>
+ </Button>
+ <Button variant="Critical" onClick={ignore} fill="Soft">
+ <Text as="span" size="B400">
+ Continue
+ </Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </Box>
+ </SplashScreen>
+ )}
+ >
+ {(versions) => <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>}
+ </SpecVersionsLoader>
+ );
+}
--- /dev/null
+import React from 'react';
+import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
+import { Page, PageHero, PageHeroSection } from '../../components/page';
+import CinnySVG from '../../../../public/res/svg/cinny.svg';
+
+export function WelcomePage() {
+ return (
+ <Page>
+ <Box
+ grow="Yes"
+ style={{ padding: config.space.S400, paddingBottom: config.space.S700 }}
+ alignItems="Center"
+ justifyContent="Center"
+ >
+ <PageHeroSection>
+ <PageHero
+ icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
+ title="Welcome to Cinny"
+ subTitle={
+ <span>
+ Yet anothor matrix client.{' '}
+ <a
+ href="https://github.com/cinnyapp/cinny/releases"
+ target="_blank"
+ rel="noreferrer noopener"
+ >
+ v3.2.0
+ </a>
+ </span>
+ }
+ >
+ <Box justifyContent="Center">
+ <Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
+ <Button
+ as="a"
+ href="https://github.com/cinnyapp/cinny"
+ target="_blank"
+ rel="noreferrer noopener"
+ before={<Icon size="200" src={Icons.Code} />}
+ >
+ <Text as="span" size="B400" truncate>
+ Source Code
+ </Text>
+ </Button>
+ <Button
+ as="a"
+ href="https://cinny.in/#sponsor"
+ target="_blank"
+ rel="noreferrer noopener"
+ fill="Soft"
+ before={<Icon size="200" src={Icons.Heart} />}
+ >
+ <Text as="span" size="B400" truncate>
+ Support
+ </Text>
+ </Button>
+ </Box>
+ </Box>
+ </PageHero>
+ </PageHeroSection>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
+import { useAtom, useAtomValue } from 'jotai';
+import {
+ Avatar,
+ Box,
+ Button,
+ Icon,
+ IconButton,
+ Icons,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Text,
+ config,
+ toRem,
+} from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { factoryRoomIdByActivity } from '../../../utils/sort';
+import {
+ NavButton,
+ NavCategory,
+ NavCategoryHeader,
+ NavEmptyCenter,
+ NavEmptyLayout,
+ NavItem,
+ NavItemContent,
+} from '../../../components/nav';
+import { getDirectRoomPath } from '../../pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { VirtualTile } from '../../../components/virtualizer';
+import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
+import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
+import { makeNavCategoryId } from '../../../state/closedNavCategories';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { useDirectRooms } from './useDirectRooms';
+import { openInviteUser } from '../../../../client/action/navigation';
+import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
+import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import { markAsRead } from '../../../../client/action/notifications';
+
+type DirectMenuProps = {
+ requestClose: () => void;
+};
+const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
+ const orphanRooms = useDirectRooms();
+ const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+
+ const handleMarkAsRead = () => {
+ if (!unread) return;
+ orphanRooms.forEach((rId) => markAsRead(rId));
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleMarkAsRead}
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ aria-disabled={!unread}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Mark as Read
+ </Text>
+ </MenuItem>
+ </Box>
+ </Menu>
+ );
+});
+
+function DirectHeader() {
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const cords = evt.currentTarget.getBoundingClientRect();
+ setMenuAnchor((currentState) => {
+ if (currentState) return undefined;
+ return cords;
+ });
+ };
+
+ return (
+ <>
+ <PageNavHeader>
+ <Box alignItems="Center" grow="Yes" gap="300">
+ <Box grow="Yes">
+ <Text size="H4" truncate>
+ Direct Messages
+ </Text>
+ </Box>
+ <Box>
+ <IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
+ <Icon src={Icons.VerticalDots} size="200" />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageNavHeader>
+ <PopOut
+ anchor={menuAnchor}
+ position="Bottom"
+ align="End"
+ offset={6}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <DirectMenu requestClose={() => setMenuAnchor(undefined)} />
+ </FocusTrap>
+ }
+ />
+ </>
+ );
+}
+
+function DirectEmpty() {
+ return (
+ <NavEmptyCenter>
+ <NavEmptyLayout
+ icon={<Icon size="600" src={Icons.Mention} />}
+ title={
+ <Text size="H5" align="Center">
+ No Direct Messages
+ </Text>
+ }
+ content={
+ <Text size="T300" align="Center">
+ You do not have any direct messages yet.
+ </Text>
+ }
+ options={
+ <Button variant="Secondary" size="300" onClick={() => openInviteUser()}>
+ <Text size="B300" truncate>
+ Direct Message
+ </Text>
+ </Button>
+ }
+ />
+ </NavEmptyCenter>
+ );
+}
+
+const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
+export function Direct() {
+ const mx = useMatrixClient();
+ useNavToActivePathMapper('direct');
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const directs = useDirectRooms();
+ const muteChanges = useAtomValue(muteChangesAtom);
+ const mutedRooms = muteChanges.added;
+ const roomToUnread = useAtomValue(roomToUnreadAtom);
+
+ const selectedRoomId = useSelectedRoom();
+ const noRoomToDisplay = directs.length === 0;
+ const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
+
+ const sortedDirects = useMemo(() => {
+ const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
+ if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
+ return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
+ }
+ return items;
+ }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
+
+ const virtualizer = useVirtualizer({
+ count: sortedDirects.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 38,
+ overscan: 10,
+ });
+
+ const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+ closedCategories.has(categoryId)
+ );
+
+ return (
+ <PageNav>
+ <DirectHeader />
+ {noRoomToDisplay ? (
+ <DirectEmpty />
+ ) : (
+ <PageNavContent scrollRef={scrollRef}>
+ <Box direction="Column" gap="300">
+ <NavCategory>
+ <NavItem variant="Background" radii="400">
+ <NavButton onClick={() => openInviteUser()}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.Plus} size="100" />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Create Chat
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavButton>
+ </NavItem>
+ </NavCategory>
+ <NavCategory>
+ <NavCategoryHeader>
+ <RoomNavCategoryButton
+ closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
+ data-category-id={DEFAULT_CATEGORY_ID}
+ onClick={handleCategoryClick}
+ >
+ Chats
+ </RoomNavCategoryButton>
+ </NavCategoryHeader>
+ <div
+ style={{
+ position: 'relative',
+ height: virtualizer.getTotalSize(),
+ }}
+ >
+ {virtualizer.getVirtualItems().map((vItem) => {
+ const roomId = sortedDirects[vItem.index];
+ const room = mx.getRoom(roomId);
+ if (!room) return null;
+ const selected = selectedRoomId === roomId;
+
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ key={vItem.index}
+ ref={virtualizer.measureElement}
+ >
+ <RoomNavItem
+ room={room}
+ selected={selected}
+ showAvatar
+ direct
+ linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
+ muted={mutedRooms.includes(roomId)}
+ />
+ </VirtualTile>
+ );
+ })}
+ </div>
+ </NavCategory>
+ </Box>
+ </PageNavContent>
+ )}
+ </PageNav>
+ );
+}
--- /dev/null
+import React, { ReactNode } from 'react';
+import { useParams } from 'react-router-dom';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { RoomProvider } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
+import { useDirectRooms } from './useDirectRooms';
+
+export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
+ const mx = useMatrixClient();
+ const rooms = useDirectRooms();
+
+ const { roomIdOrAlias } = useParams();
+ const roomId = useSelectedRoom();
+ const room = mx.getRoom(roomId);
+
+ if (!room || !rooms.includes(room.roomId)) {
+ return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+ }
+
+ return (
+ <RoomProvider key={room.roomId} value={room}>
+ {children}
+ </RoomProvider>
+ );
+}
--- /dev/null
+export * from './Direct';
+export * from './RoomProvider';
--- /dev/null
+import { useAtomValue } from 'jotai';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { useDirects } from '../../../state/hooks/roomList';
+
+export const useDirectRooms = () => {
+ const mx = useMatrixClient();
+ const mDirects = useAtomValue(mDirectAtom);
+ const directs = useDirects(mx, allRoomsAtom, mDirects);
+ return directs;
+};
--- /dev/null
+import React, { FormEventHandler, useCallback, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import FocusTrap from 'focus-trap-react';
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+ color,
+ config,
+} from 'folds';
+import {
+ NavCategory,
+ NavCategoryHeader,
+ NavItem,
+ NavItemContent,
+ NavLink,
+} from '../../../components/nav';
+import { getExploreFeaturedPath, getExploreServerPath } from '../../pathUtils';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import {
+ useExploreFeaturedSelected,
+ useExploreServer,
+} from '../../../hooks/router/useExploreSelected';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdServer } from '../../../utils/matrix';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
+
+export function AddServer() {
+ const mx = useMatrixClient();
+ const navigate = useNavigate();
+ const [dialog, setDialog] = useState(false);
+ const serverInputRef = useRef<HTMLInputElement>(null);
+
+ const [exploreState] = useAsyncCallback(
+ useCallback((server: string) => mx.publicRooms({ server, limit: 1 }), [mx])
+ );
+
+ const getInputServer = (): string | undefined => {
+ const serverInput = serverInputRef.current;
+ if (!serverInput) return undefined;
+ const server = serverInput.value.trim();
+ return server || undefined;
+ };
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ const server = getInputServer();
+ if (!server) return;
+ // explore(server);
+
+ navigate(getExploreServerPath(server));
+ setDialog(false);
+ };
+
+ const handleView = () => {
+ const server = getInputServer();
+ if (!server) return;
+ navigate(getExploreServerPath(server));
+ setDialog(false);
+ };
+
+ return (
+ <>
+ <Overlay open={dialog} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ clickOutsideDeactivates: true,
+ onDeactivate: () => setDialog(false),
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Add Server</Text>
+ </Box>
+ <IconButton size="300" onClick={() => setDialog(false)} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ style={{ padding: config.space.S400 }}
+ direction="Column"
+ gap="400"
+ >
+ <Text priority="400">Add server name to explore public communities.</Text>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Server Name</Text>
+ <Input ref={serverInputRef} name="serverInput" variant="Background" required />
+ {exploreState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T300">
+ Failed to load public rooms. Please try again.
+ </Text>
+ )}
+ </Box>
+ <Box direction="Column" gap="200">
+ {/* <Button
+ type="submit"
+ variant="Secondary"
+ before={
+ exploreState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" variant="Secondary" size="200" />
+ ) : undefined
+ }
+ aria-disabled={exploreState.status === AsyncStatus.Loading}
+ >
+ <Text size="B400">Save</Text>
+ </Button> */}
+
+ <Button type="submit" onClick={handleView} variant="Secondary" fill="Soft">
+ <Text size="B400">View</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ before={<Icon size="100" src={Icons.Plus} />}
+ onClick={() => setDialog(true)}
+ >
+ <Text size="B300" truncate>
+ Add Server
+ </Text>
+ </Button>
+ </>
+ );
+}
+
+export function Explore() {
+ const mx = useMatrixClient();
+ useNavToActivePathMapper('explore');
+ const userId = mx.getUserId();
+ const clientConfig = useClientConfig();
+ const userServer = userId ? getMxIdServer(userId) : undefined;
+ const servers =
+ clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? [];
+
+ const featuredSelected = useExploreFeaturedSelected();
+ const selectedServer = useExploreServer();
+
+ return (
+ <PageNav>
+ <PageNavHeader>
+ <Box grow="Yes" gap="300">
+ <Box grow="Yes">
+ <Text size="H4" truncate>
+ Explore Community
+ </Text>
+ </Box>
+ </Box>
+ </PageNavHeader>
+
+ <PageNavContent>
+ <Box direction="Column" gap="300">
+ <NavCategory>
+ <NavItem variant="Background" radii="400" aria-selected={featuredSelected}>
+ <NavLink to={getExploreFeaturedPath()}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.Bulb} size="100" filled={featuredSelected} />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Featured
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ </NavItem>
+ {userServer && (
+ <NavItem
+ variant="Background"
+ radii="400"
+ aria-selected={selectedServer === userServer}
+ >
+ <NavLink to={getExploreServerPath(userServer)}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon
+ src={Icons.Category}
+ size="100"
+ filled={selectedServer === userServer}
+ />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ {userServer}
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ </NavItem>
+ )}
+ </NavCategory>
+ {servers.length > 0 && (
+ <NavCategory>
+ <NavCategoryHeader>
+ <Text size="O400" style={{ paddingLeft: config.space.S200 }}>
+ Servers
+ </Text>
+ </NavCategoryHeader>
+ {servers.map((server) => (
+ <NavItem
+ key={server}
+ variant="Background"
+ radii="400"
+ aria-selected={server === selectedServer}
+ >
+ <NavLink to={getExploreServerPath(server)}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon
+ src={Icons.Category}
+ size="100"
+ filled={server === selectedServer}
+ />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ {server}
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ </NavItem>
+ ))}
+ </NavCategory>
+ )}
+ <Box direction="Column">
+ <AddServer />
+ </Box>
+ </Box>
+ </PageNavContent>
+ </PageNav>
+ );
+}
--- /dev/null
+import React from 'react';
+import { Box, Icon, Icons, Scroll, Text } from 'folds';
+import { useAtomValue } from 'jotai';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import { RoomCard, RoomCardGrid } from '../../../components/room-card';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { RoomSummaryLoader } from '../../../components/RoomSummaryLoader';
+import {
+ Page,
+ PageContent,
+ PageContentCenter,
+ PageHero,
+ PageHeroSection,
+} from '../../../components/page';
+import { RoomTopicViewer } from '../../../components/room-topic-viewer';
+import * as css from './style.css';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+
+export function FeaturedRooms() {
+ const { featuredCommunities } = useClientConfig();
+ const { rooms, spaces } = featuredCommunities ?? {};
+ const allRooms = useAtomValue(allRoomsAtom);
+ const { navigateSpace, navigateRoom } = useRoomNavigate();
+
+ return (
+ <Page>
+ <Box grow="Yes">
+ <Scroll hideTrack visibility="Hover">
+ <PageContent>
+ <PageContentCenter>
+ <Box direction="Column" gap="200">
+ <PageHeroSection>
+ <PageHero
+ icon={<Icon size="600" src={Icons.Bulb} />}
+ title="Featured by Client"
+ subTitle="Find and explore public rooms and spaces featured by client provider."
+ />
+ </PageHeroSection>
+ <Box direction="Column" gap="700">
+ {spaces && spaces.length > 0 && (
+ <Box direction="Column" gap="400">
+ <Text size="H4">Featured Spaces</Text>
+ <RoomCardGrid>
+ {spaces.map((roomIdOrAlias) => (
+ <RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
+ {(roomSummary) => (
+ <RoomCard
+ roomIdOrAlias={roomIdOrAlias}
+ allRooms={allRooms}
+ avatarUrl={roomSummary?.avatar_url}
+ name={roomSummary?.name}
+ topic={roomSummary?.topic}
+ memberCount={roomSummary?.num_joined_members}
+ onView={navigateSpace}
+ renderTopicViewer={(name, topic, requestClose) => (
+ <RoomTopicViewer
+ name={name}
+ topic={topic}
+ requestClose={requestClose}
+ />
+ )}
+ />
+ )}
+ </RoomSummaryLoader>
+ ))}
+ </RoomCardGrid>
+ </Box>
+ )}
+ {rooms && rooms.length > 0 && (
+ <Box direction="Column" gap="400">
+ <Text size="H4">Featured Rooms</Text>
+ <RoomCardGrid>
+ {rooms.map((roomIdOrAlias) => (
+ <RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
+ {(roomSummary) => (
+ <RoomCard
+ roomIdOrAlias={roomIdOrAlias}
+ allRooms={allRooms}
+ avatarUrl={roomSummary?.avatar_url}
+ name={roomSummary?.name}
+ topic={roomSummary?.topic}
+ memberCount={roomSummary?.num_joined_members}
+ onView={navigateRoom}
+ renderTopicViewer={(name, topic, requestClose) => (
+ <RoomTopicViewer
+ name={name}
+ topic={topic}
+ requestClose={requestClose}
+ />
+ )}
+ />
+ )}
+ </RoomSummaryLoader>
+ ))}
+ </RoomCardGrid>
+ </Box>
+ )}
+ {((spaces && spaces.length === 0 && rooms && rooms.length === 0) ||
+ (!spaces && !rooms)) && (
+ <Box
+ className={css.RoomsInfoCard}
+ direction="Column"
+ justifyContent="Center"
+ alignItems="Center"
+ gap="200"
+ >
+ <Icon size="400" src={Icons.Info} />
+ <Text size="T300" align="Center">
+ No rooms or spaces featured by client provider.
+ </Text>
+ </Box>
+ )}
+ </Box>
+ </Box>
+ </PageContentCenter>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+import React, {
+ FormEventHandler,
+ MouseEventHandler,
+ RefObject,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Box,
+ Button,
+ Chip,
+ Icon,
+ Icons,
+ Input,
+ Line,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Scroll,
+ Spinner,
+ Text,
+ config,
+ toRem,
+} from 'folds';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
+import FocusTrap from 'focus-trap-react';
+import { useAtomValue } from 'jotai';
+import { useQuery } from '@tanstack/react-query';
+import { MatrixClient, Method, RoomType } from 'matrix-js-sdk';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { RoomTopicViewer } from '../../../components/room-topic-viewer';
+import { RoomCard, RoomCardBase, RoomCardGrid } from '../../../components/room-card';
+import { ExploreServerPathSearchParams } from '../../paths';
+import { getExploreServerPath, withSearchParam } from '../../pathUtils';
+import * as css from './style.css';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { getMxIdServer } from '../../../utils/matrix';
+
+const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams =>
+ useMemo(
+ () => ({
+ limit: searchParams.get('limit') ?? undefined,
+ since: searchParams.get('since') ?? undefined,
+ term: searchParams.get('term') ?? undefined,
+ type: searchParams.get('type') ?? undefined,
+ instance: searchParams.get('instance') ?? undefined,
+ }),
+ [searchParams]
+ );
+
+type RoomTypeFilter = {
+ title: string;
+ value: string | undefined;
+};
+const useRoomTypeFilters = (): RoomTypeFilter[] =>
+ useMemo(
+ () => [
+ {
+ title: 'All',
+ value: undefined,
+ },
+ {
+ title: 'Spaces',
+ value: RoomType.Space,
+ },
+ {
+ title: 'Rooms',
+ value: 'null',
+ },
+ ],
+ []
+ );
+
+const FALLBACK_ROOMS_LIMIT = 24;
+
+type SearchProps = {
+ active?: boolean;
+ loading?: boolean;
+ searchInputRef: RefObject<HTMLInputElement>;
+ onSearch: (term: string) => void;
+ onReset: () => void;
+};
+function Search({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) {
+ const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ const { searchInput } = evt.target as HTMLFormElement & {
+ searchInput: HTMLInputElement;
+ };
+
+ const searchTerm = searchInput.value.trim() || undefined;
+ if (searchTerm) {
+ onSearch(searchTerm);
+ }
+ };
+
+ return (
+ <Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
+ <span data-spacing-node />
+ <Text size="L400">Search</Text>
+ <Input
+ ref={searchInputRef}
+ style={{ paddingRight: config.space.S300 }}
+ name="searchInput"
+ size="500"
+ variant="Background"
+ placeholder="Search for keyword"
+ before={
+ active && loading ? (
+ <Spinner variant="Secondary" size="200" />
+ ) : (
+ <Icon size="200" src={Icons.Search} />
+ )
+ }
+ after={
+ active ? (
+ <Chip
+ type="button"
+ variant="Secondary"
+ size="400"
+ radii="Pill"
+ outlined
+ after={<Icon size="50" src={Icons.Cross} />}
+ onClick={onReset}
+ >
+ <Text size="B300">Clear</Text>
+ </Chip>
+ ) : (
+ <Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
+ <Text size="B300">Enter</Text>
+ </Chip>
+ )
+ }
+ />
+ </Box>
+ );
+}
+
+const DEFAULT_INSTANCE_NAME = 'Matrix';
+function ThirdPartyProtocolsSelector({
+ instanceId,
+ onChange,
+}: {
+ instanceId?: string;
+ onChange: (instanceId?: string) => void;
+}) {
+ const mx = useMatrixClient();
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const { data } = useQuery({
+ queryKey: ['thirdparty', 'protocols'],
+ queryFn: () => mx.getThirdpartyProtocols(),
+ });
+
+ const handleInstanceSelect: MouseEventHandler<HTMLButtonElement> = (evt): void => {
+ const insId = evt.currentTarget.getAttribute('data-instance-id') ?? undefined;
+ onChange(insId);
+ setMenuAnchor(undefined);
+ };
+
+ const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const instances = data && Object.keys(data).flatMap((protocol) => data[protocol].instances);
+ if (!instances || instances.length === 0) return null;
+ const selectedInstance = instances.find((instance) => instanceId === instance.instance_id);
+
+ return (
+ <PopOut
+ anchor={menuAnchor}
+ align="End"
+ position="Bottom"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Menu variant="Surface">
+ <Box
+ direction="Column"
+ gap="100"
+ style={{ padding: config.space.S100, minWidth: toRem(100) }}
+ >
+ <Text style={{ padding: config.space.S100 }} size="L400" truncate>
+ Protocols
+ </Text>
+ <Box direction="Column">
+ <MenuItem
+ size="300"
+ variant="Surface"
+ aria-pressed={instanceId === undefined}
+ radii="300"
+ onClick={handleInstanceSelect}
+ >
+ <Text size="T200" truncate>
+ {DEFAULT_INSTANCE_NAME}
+ </Text>
+ </MenuItem>
+ {instances.map((instance) => (
+ <MenuItem
+ size="300"
+ key={instance.instance_id}
+ data-instance-id={instance.instance_id}
+ aria-pressed={instanceId === instance.instance_id}
+ variant="Surface"
+ radii="300"
+ onClick={handleInstanceSelect}
+ >
+ <Text size="T200" truncate>
+ {instance.desc}
+ </Text>
+ </MenuItem>
+ ))}
+ </Box>
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ onClick={handleOpenMenu}
+ aria-pressed={!!menuAnchor}
+ radii="Pill"
+ size="400"
+ variant={instanceId ? 'Success' : 'SurfaceVariant'}
+ after={<Icon size="100" src={Icons.ChevronBottom} />}
+ >
+ <Text size="T200" truncate>
+ {selectedInstance?.desc ?? DEFAULT_INSTANCE_NAME}
+ </Text>
+ </Chip>
+ </PopOut>
+ );
+}
+
+type LimitButtonProps = {
+ limit: number;
+ onLimitChange: (limit: string) => void;
+};
+function LimitButton({ limit, onLimitChange }: LimitButtonProps) {
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const handleLimitSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ const limitInput = evt.currentTarget.limitInput as HTMLInputElement;
+ if (!limitInput) return;
+ const newLimit = limitInput.value.trim();
+ if (!newLimit) return;
+ onLimitChange(newLimit);
+ };
+
+ const setLimit = (l: string) => {
+ setMenuAnchor(undefined);
+ onLimitChange(l);
+ };
+ const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <PopOut
+ anchor={menuAnchor}
+ align="End"
+ position="Bottom"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Menu variant="Surface">
+ <Box direction="Column" gap="400" style={{ padding: config.space.S300 }}>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Presets</Text>
+ <Box gap="100" wrap="Wrap">
+ <Chip variant="SurfaceVariant" onClick={() => setLimit('24')} radii="Pill">
+ <Text size="T200">24</Text>
+ </Chip>
+ <Chip variant="SurfaceVariant" onClick={() => setLimit('48')} radii="Pill">
+ <Text size="T200">48</Text>
+ </Chip>
+ <Chip variant="SurfaceVariant" onClick={() => setLimit('96')} radii="Pill">
+ <Text size="T200">96</Text>
+ </Chip>
+ </Box>
+ </Box>
+ <Box as="form" onSubmit={handleLimitSubmit} direction="Column" gap="300">
+ <Box direction="Column" gap="100">
+ <Text size="L400">Custom Limit</Text>
+ <Input
+ name="limitInput"
+ size="300"
+ variant="Background"
+ defaultValue={limit}
+ min={1}
+ step={1}
+ outlined
+ type="number"
+ radii="400"
+ aria-label="Per Page Item Limit"
+ />
+ </Box>
+ <Button type="submit" size="300" variant="Primary" radii="400">
+ <Text size="B300">Change Limit</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ onClick={handleOpenMenu}
+ aria-pressed={!!menuAnchor}
+ radii="Pill"
+ size="400"
+ variant="SurfaceVariant"
+ after={<Icon size="100" src={Icons.ChevronBottom} />}
+ >
+ <Text size="T200" truncate>{`Page Limit: ${limit}`}</Text>
+ </Chip>
+ </PopOut>
+ );
+}
+
+export function PublicRooms() {
+ const { server } = useParams();
+ const mx = useMatrixClient();
+ const userId = mx.getUserId();
+ const userServer = userId && getMxIdServer(userId);
+ const allRooms = useAtomValue(allRoomsAtom);
+ const { navigateSpace, navigateRoom } = useRoomNavigate();
+
+ const [searchParams] = useSearchParams();
+ const serverSearchParams = useServerSearchParams(searchParams);
+ const isSearch = !!serverSearchParams.term;
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const searchInputRef = useRef<HTMLInputElement>(null);
+ const navigate = useNavigate();
+ const roomTypeFilters = useRoomTypeFilters();
+
+ const currentLimit: number = useMemo(() => {
+ const limitParam = serverSearchParams.limit;
+ if (!limitParam) return FALLBACK_ROOMS_LIMIT;
+ return parseInt(limitParam, 10) || FALLBACK_ROOMS_LIMIT;
+ }, [serverSearchParams.limit]);
+
+ const resetScroll = useCallback(() => {
+ const scroll = scrollRef.current;
+ if (scroll) scroll.scrollTop = 0;
+ }, []);
+
+ const fetchPublicRooms = useCallback(() => {
+ const limit =
+ typeof serverSearchParams.limit === 'string'
+ ? parseInt(serverSearchParams.limit, 10)
+ : FALLBACK_ROOMS_LIMIT;
+ const roomType: string | null | undefined =
+ serverSearchParams.type === 'null' ? null : serverSearchParams.type;
+
+ return mx.http.authedRequest<Awaited<ReturnType<MatrixClient['publicRooms']>>>(
+ Method.Post,
+ '/publicRooms',
+ {
+ server,
+ },
+ {
+ limit,
+ since: serverSearchParams.since,
+ filter: {
+ generic_search_term: serverSearchParams.term,
+ room_types: roomType !== undefined ? [roomType] : undefined,
+ },
+ third_party_instance_id: serverSearchParams.instance,
+ }
+ );
+ }, [mx, server, serverSearchParams]);
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: [
+ server,
+ 'publicRooms',
+ serverSearchParams.limit,
+ serverSearchParams.since,
+ serverSearchParams.term,
+ serverSearchParams.type,
+ serverSearchParams.instance,
+ ],
+ queryFn: fetchPublicRooms,
+ });
+
+ useEffect(() => {
+ if (isLoading) resetScroll();
+ }, [isLoading, resetScroll]);
+
+ const explore = (newSearchParams: ExploreServerPathSearchParams) => {
+ if (!server) return;
+
+ const sParams: Record<string, string> = {
+ ...serverSearchParams,
+ ...newSearchParams,
+ };
+ Object.keys(sParams).forEach((key) => {
+ if (sParams[key] === undefined) delete sParams[key];
+ });
+ const path = withSearchParam(getExploreServerPath(server), sParams);
+ navigate(path);
+ };
+
+ const paginateBack = () => {
+ const token = data?.prev_batch;
+ explore({ since: token });
+ };
+
+ const paginateFront = () => {
+ const token = data?.next_batch;
+ explore({ since: token });
+ };
+
+ const handleSearch = (term: string) => {
+ explore({
+ term,
+ since: undefined,
+ });
+ };
+
+ const handleSearchClear = () => {
+ if (searchInputRef.current) {
+ searchInputRef.current.value = '';
+ }
+ explore({
+ term: undefined,
+ since: undefined,
+ });
+ };
+
+ const handleRoomFilterClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const filter = evt.currentTarget.getAttribute('data-room-filter');
+ explore({
+ type: filter ?? undefined,
+ since: undefined,
+ });
+ };
+
+ const handleLimitChange = (limit: string) => {
+ explore({ limit });
+ };
+
+ const handleInstanceIdChange = (instanceId?: string) => {
+ explore({ instance: instanceId, since: undefined });
+ };
+
+ return (
+ <Page>
+ <PageHeader>
+ {isSearch ? (
+ <>
+ <Box grow="Yes" basis="No">
+ <Chip
+ size="500"
+ variant="Surface"
+ radii="Pill"
+ before={<Icon size="100" src={Icons.ArrowLeft} />}
+ onClick={handleSearchClear}
+ >
+ <Text size="T300">{server}</Text>
+ </Chip>
+ </Box>
+
+ <Box grow="No" justifyContent="Center" alignItems="Center" gap="200">
+ <Icon size="400" src={Icons.Search} />
+ <Text size="H3" truncate>
+ Search
+ </Text>
+ </Box>
+ <Box grow="Yes" />
+ </>
+ ) : (
+ <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+ <Icon size="400" src={Icons.Category} />
+ <Text size="H3" truncate>
+ {server}
+ </Text>
+ </Box>
+ )}
+ </PageHeader>
+ <Box grow="Yes">
+ <Scroll ref={scrollRef} hideTrack visibility="Hover">
+ <PageContent>
+ <PageContentCenter>
+ <Box direction="Column" gap="600">
+ <Search
+ key={server}
+ active={isSearch}
+ loading={isLoading}
+ searchInputRef={searchInputRef}
+ onSearch={handleSearch}
+ onReset={handleSearchClear}
+ />
+ <Box direction="Column" gap="400">
+ <Box direction="Column" gap="300">
+ {isSearch ? (
+ <Text size="H4">{`Results for "${serverSearchParams.term}"`}</Text>
+ ) : (
+ <Text size="H4">Popular Communities</Text>
+ )}
+ <Box gap="200">
+ {roomTypeFilters.map((filter) => (
+ <Chip
+ key={filter.title}
+ onClick={handleRoomFilterClick}
+ data-room-filter={filter.value}
+ variant={filter.value === serverSearchParams.type ? 'Success' : 'Surface'}
+ aria-pressed={filter.value === serverSearchParams.type}
+ before={
+ filter.value === serverSearchParams.type && (
+ <Icon size="100" src={Icons.Check} />
+ )
+ }
+ outlined
+ >
+ <Text size="T200">{filter.title}</Text>
+ </Chip>
+ ))}
+ {userServer === server && (
+ <>
+ <Line
+ style={{ margin: `${config.space.S100} 0` }}
+ direction="Vertical"
+ variant="Surface"
+ size="300"
+ />
+ <ThirdPartyProtocolsSelector
+ instanceId={serverSearchParams.instance}
+ onChange={handleInstanceIdChange}
+ />
+ </>
+ )}
+ <Box grow="Yes" data-spacing-node />
+ <LimitButton limit={currentLimit} onLimitChange={handleLimitChange} />
+ </Box>
+ </Box>
+ {isLoading && (
+ <RoomCardGrid>
+ {[...Array(currentLimit).keys()].map((item) => (
+ <RoomCardBase key={item} style={{ minHeight: toRem(260) }} />
+ ))}
+ </RoomCardGrid>
+ )}
+ {error && (
+ <Box direction="Column" className={css.PublicRoomsError} gap="200">
+ <Text size="L400">{error.name}</Text>
+ <Text size="T300">{error.message}</Text>
+ </Box>
+ )}
+ {data &&
+ (data.chunk.length > 0 ? (
+ <>
+ <RoomCardGrid>
+ {data?.chunk.map((chunkRoom) => (
+ <RoomCard
+ key={chunkRoom.room_id}
+ roomIdOrAlias={chunkRoom.canonical_alias ?? chunkRoom.room_id}
+ allRooms={allRooms}
+ avatarUrl={chunkRoom.avatar_url}
+ name={chunkRoom.name}
+ topic={chunkRoom.topic}
+ memberCount={chunkRoom.num_joined_members}
+ roomType={chunkRoom.room_type}
+ onView={
+ chunkRoom.room_type === RoomType.Space
+ ? navigateSpace
+ : navigateRoom
+ }
+ renderTopicViewer={(name, topic, requestClose) => (
+ <RoomTopicViewer
+ name={name}
+ topic={topic}
+ requestClose={requestClose}
+ />
+ )}
+ />
+ ))}
+ </RoomCardGrid>
+
+ {(data.prev_batch || data.next_batch) && (
+ <Box justifyContent="Center" gap="200">
+ <Button
+ onClick={paginateBack}
+ size="300"
+ fill="Soft"
+ disabled={!data.prev_batch}
+ >
+ <Text size="B300" truncate>
+ Previous Page
+ </Text>
+ </Button>
+ <Box data-spacing-node grow="Yes" />
+ <Button
+ onClick={paginateFront}
+ size="300"
+ fill="Solid"
+ disabled={!data.next_batch}
+ >
+ <Text size="B300" truncate>
+ Next Page
+ </Text>
+ </Button>
+ </Box>
+ )}
+ </>
+ ) : (
+ <Box
+ className={css.RoomsInfoCard}
+ direction="Column"
+ justifyContent="Center"
+ alignItems="Center"
+ gap="200"
+ >
+ <Icon size="400" src={Icons.Info} />
+ <Text size="T300" align="Center">
+ No communities found!
+ </Text>
+ </Box>
+ ))}
+ </Box>
+ </Box>
+ </PageContentCenter>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './Explore';
+export * from './Server';
+export * from './Featured';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+import { ContainerColor } from '../../../styles/ContainerColor.css';
+
+export const RoomsInfoCard = style([
+ ContainerColor({ variant: 'SurfaceVariant' }),
+ {
+ padding: `${config.space.S700} ${config.space.S300}`,
+ borderRadius: config.radii.R400,
+ },
+]);
+
+export const PublicRoomsError = style([
+ ContainerColor({ variant: 'Critical' }),
+ {
+ padding: config.space.S300,
+ borderRadius: config.radii.R400,
+ },
+]);
--- /dev/null
+import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Avatar,
+ Box,
+ Button,
+ Icon,
+ IconButton,
+ Icons,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Text,
+ config,
+ toRem,
+} from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useAtom, useAtomValue } from 'jotai';
+import FocusTrap from 'focus-trap-react';
+import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
+import {
+ NavButton,
+ NavCategory,
+ NavCategoryHeader,
+ NavEmptyCenter,
+ NavEmptyLayout,
+ NavItem,
+ NavItemContent,
+ NavLink,
+} from '../../../components/nav';
+import { getExplorePath, getHomeRoomPath, getHomeSearchPath } from '../../pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { useHomeSearchSelected } from '../../../hooks/router/useHomeSelected';
+import { useHomeRooms } from './useHomeRooms';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { VirtualTile } from '../../../components/virtualizer';
+import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
+import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
+import { makeNavCategoryId } from '../../../state/closedNavCategories';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { openCreateRoom, openJoinAlias } from '../../../../client/action/navigation';
+import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import { markAsRead } from '../../../../client/action/notifications';
+import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
+
+type HomeMenuProps = {
+ requestClose: () => void;
+};
+const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
+ const orphanRooms = useHomeRooms();
+ const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+
+ const handleMarkAsRead = () => {
+ if (!unread) return;
+ orphanRooms.forEach((rId) => markAsRead(rId));
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleMarkAsRead}
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ aria-disabled={!unread}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Mark as Read
+ </Text>
+ </MenuItem>
+ </Box>
+ </Menu>
+ );
+});
+
+function HomeHeader() {
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const cords = evt.currentTarget.getBoundingClientRect();
+ setMenuAnchor((currentState) => {
+ if (currentState) return undefined;
+ return cords;
+ });
+ };
+
+ return (
+ <>
+ <PageNavHeader>
+ <Box alignItems="Center" grow="Yes" gap="300">
+ <Box grow="Yes">
+ <Text size="H4" truncate>
+ Home
+ </Text>
+ </Box>
+ <Box>
+ <IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
+ <Icon src={Icons.VerticalDots} size="200" />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageNavHeader>
+ <PopOut
+ anchor={menuAnchor}
+ position="Bottom"
+ align="End"
+ offset={6}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <HomeMenu requestClose={() => setMenuAnchor(undefined)} />
+ </FocusTrap>
+ }
+ />
+ </>
+ );
+}
+
+function HomeEmpty() {
+ const navigate = useNavigate();
+
+ return (
+ <NavEmptyCenter>
+ <NavEmptyLayout
+ icon={<Icon size="600" src={Icons.Hash} />}
+ title={
+ <Text size="H5" align="Center">
+ No Rooms
+ </Text>
+ }
+ content={
+ <Text size="T300" align="Center">
+ You do not have any rooms yet.
+ </Text>
+ }
+ options={
+ <>
+ <Button onClick={() => openCreateRoom()} variant="Secondary" size="300">
+ <Text size="B300" truncate>
+ Create Room
+ </Text>
+ </Button>
+ <Button
+ onClick={() => navigate(getExplorePath())}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ >
+ <Text size="B300" truncate>
+ Explore Community Rooms
+ </Text>
+ </Button>
+ </>
+ }
+ />
+ </NavEmptyCenter>
+ );
+}
+
+const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
+export function Home() {
+ const mx = useMatrixClient();
+ useNavToActivePathMapper('home');
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const rooms = useHomeRooms();
+ const muteChanges = useAtomValue(muteChangesAtom);
+ const mutedRooms = muteChanges.added;
+ const roomToUnread = useAtomValue(roomToUnreadAtom);
+
+ const selectedRoomId = useSelectedRoom();
+ const searchSelected = useHomeSearchSelected();
+ const noRoomToDisplay = rooms.length === 0;
+ const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
+
+ const sortedRooms = useMemo(() => {
+ const items = Array.from(rooms).sort(
+ closedCategories.has(DEFAULT_CATEGORY_ID)
+ ? factoryRoomIdByActivity(mx)
+ : factoryRoomIdByAtoZ(mx)
+ );
+ if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
+ return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
+ }
+ return items;
+ }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
+
+ const virtualizer = useVirtualizer({
+ count: sortedRooms.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 38,
+ overscan: 10,
+ });
+
+ const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+ closedCategories.has(categoryId)
+ );
+
+ return (
+ <PageNav>
+ <HomeHeader />
+ {noRoomToDisplay ? (
+ <HomeEmpty />
+ ) : (
+ <PageNavContent scrollRef={scrollRef}>
+ <Box direction="Column" gap="300">
+ <NavCategory>
+ <NavItem variant="Background" radii="400">
+ <NavButton onClick={() => openCreateRoom()}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.Plus} size="100" />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Create Room
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavButton>
+ </NavItem>
+ <NavItem variant="Background" radii="400">
+ <NavButton onClick={() => openJoinAlias()}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.Link} size="100" />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Join with Address
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavButton>
+ </NavItem>
+ <NavItem variant="Background" radii="400" aria-selected={searchSelected}>
+ <NavLink to={getHomeSearchPath()}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.Search} size="100" filled={searchSelected} />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Message Search
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ </NavItem>
+ </NavCategory>
+ <NavCategory>
+ <NavCategoryHeader>
+ <RoomNavCategoryButton
+ closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
+ data-category-id={DEFAULT_CATEGORY_ID}
+ onClick={handleCategoryClick}
+ >
+ Rooms
+ </RoomNavCategoryButton>
+ </NavCategoryHeader>
+ <div
+ style={{
+ position: 'relative',
+ height: virtualizer.getTotalSize(),
+ }}
+ >
+ {virtualizer.getVirtualItems().map((vItem) => {
+ const roomId = sortedRooms[vItem.index];
+ const room = mx.getRoom(roomId);
+ if (!room) return null;
+ const selected = selectedRoomId === roomId;
+
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ key={vItem.index}
+ ref={virtualizer.measureElement}
+ >
+ <RoomNavItem
+ room={room}
+ selected={selected}
+ linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
+ muted={mutedRooms.includes(roomId)}
+ />
+ </VirtualTile>
+ );
+ })}
+ </div>
+ </NavCategory>
+ </Box>
+ </PageNavContent>
+ )}
+ </PageNav>
+ );
+}
--- /dev/null
+import React, { ReactNode } from 'react';
+import { useParams } from 'react-router-dom';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { RoomProvider } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
+import { useHomeRooms } from './useHomeRooms';
+
+export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
+ const mx = useMatrixClient();
+ const rooms = useHomeRooms();
+
+ const { roomIdOrAlias } = useParams();
+ const roomId = useSelectedRoom();
+ const room = mx.getRoom(roomId);
+
+ if (!room || !rooms.includes(room.roomId)) {
+ return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+ }
+
+ return (
+ <RoomProvider key={room.roomId} value={room}>
+ {children}
+ </RoomProvider>
+ );
+}
--- /dev/null
+import React, { useRef } from 'react';
+import { Box, Icon, Icons, Text, Scroll } from 'folds';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { MessageSearch } from '../../../features/message-search';
+import { useHomeRooms } from './useHomeRooms';
+
+export function HomeSearch() {
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const rooms = useHomeRooms();
+
+ return (
+ <Page>
+ <PageHeader>
+ <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+ <Icon size="400" src={Icons.Search} />
+ <Text size="H3" truncate>
+ Message Search
+ </Text>
+ </Box>
+ </PageHeader>
+ <Box style={{ position: 'relative' }} grow="Yes">
+ <Scroll ref={scrollRef} hideTrack visibility="Hover">
+ <PageContent>
+ <PageContentCenter>
+ <MessageSearch
+ defaultRoomsFilterName="Home"
+ allowGlobal
+ rooms={rooms}
+ scrollRef={scrollRef}
+ />
+ </PageContentCenter>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './Home';
+export * from './Search';
+export * from './RoomProvider';
--- /dev/null
+import { useAtomValue } from 'jotai';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { useOrphanRooms } from '../../../state/hooks/roomList';
+
+export const useHomeRooms = () => {
+ const mx = useMatrixClient();
+ const mDirects = useAtomValue(mDirectAtom);
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const rooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
+ return rooms;
+};
--- /dev/null
+import React from 'react';
+import { Avatar, Box, Icon, Icons, Text } from 'folds';
+import { useAtomValue } from 'jotai';
+import { NavCategory, NavItem, NavItemContent, NavLink } from '../../../components/nav';
+import { getInboxInvitesPath, getInboxNotificationsPath } from '../../pathUtils';
+import {
+ useInboxInvitesSelected,
+ useInboxNotificationsSelected,
+} from '../../../hooks/router/useInbox';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { allInvitesAtom } from '../../../state/room-list/inviteList';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
+
+function InvitesNavItem() {
+ const invitesSelected = useInboxInvitesSelected();
+ const allInvites = useAtomValue(allInvitesAtom);
+ const inviteCount = allInvites.length;
+
+ return (
+ <NavItem
+ variant="Background"
+ radii="400"
+ highlight={inviteCount > 0}
+ aria-selected={invitesSelected}
+ >
+ <NavLink to={getInboxInvitesPath()}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.Mail} size="100" filled={invitesSelected} />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Invitations
+ </Text>
+ </Box>
+ {inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ </NavItem>
+ );
+}
+
+export function Inbox() {
+ useNavToActivePathMapper('inbox');
+ const notificationsSelected = useInboxNotificationsSelected();
+
+ return (
+ <PageNav>
+ <PageNavHeader>
+ <Box grow="Yes" gap="300">
+ <Box grow="Yes">
+ <Text size="H4" truncate>
+ Inbox
+ </Text>
+ </Box>
+ </Box>
+ </PageNavHeader>
+
+ <PageNavContent>
+ <Box direction="Column" gap="300">
+ <NavCategory>
+ <NavItem variant="Background" radii="400" aria-selected={notificationsSelected}>
+ <NavLink to={getInboxNotificationsPath()}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.MessageUnread} size="100" filled={notificationsSelected} />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Notifications
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ </NavItem>
+ <InvitesNavItem />
+ </NavCategory>
+ </Box>
+ </PageNavContent>
+ </PageNav>
+ );
+}
--- /dev/null
+import React, { useCallback, useRef, useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Button,
+ Icon,
+ Icons,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Spinner,
+ Text,
+ color,
+ config,
+} from 'folds';
+import { useAtomValue } from 'jotai';
+import FocusTrap from 'focus-trap-react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { allInvitesAtom } from '../../../state/room-list/inviteList';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { SequenceCard } from '../../../components/sequence-card';
+import {
+ getDirectRoomAvatarUrl,
+ getMemberDisplayName,
+ getRoomAvatarUrl,
+ isDirectInvite,
+} from '../../../utils/room';
+import { nameInitials } from '../../../utils/common';
+import { RoomAvatar } from '../../../components/room-avatar';
+import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix';
+import { Time } from '../../../components/message';
+import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
+import { onEnterOrSpace } from '../../../utils/keyboard';
+import { RoomTopicViewer } from '../../../components/room-topic-viewer';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { useRoomTopic } from '../../../hooks/useRoomMeta';
+
+const COMPACT_CARD_WIDTH = 548;
+
+type InviteCardProps = {
+ room: Room;
+ userId: string;
+ direct?: boolean;
+ compact?: boolean;
+ onNavigate: (roomId: string) => void;
+};
+function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) {
+ const mx = useMatrixClient();
+ const roomName = room.name || room.getCanonicalAlias() || room.roomId;
+ const member = room.getMember(userId);
+ const memberEvent = member?.events.member;
+ const memberTs = memberEvent?.getTs() ?? 0;
+ const senderId = memberEvent?.getSender();
+ const senderName = senderId
+ ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
+ : undefined;
+
+ const topic = useRoomTopic(room);
+
+ const [viewTopic, setViewTopic] = useState(false);
+ const closeTopic = () => setViewTopic(false);
+ const openTopic = () => setViewTopic(true);
+
+ const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
+ useCallback(async () => {
+ const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined;
+
+ await mx.joinRoom(room.roomId);
+ if (dmUserId) {
+ await addRoomIdToMDirect(mx, room.roomId, dmUserId);
+ }
+ onNavigate(room.roomId);
+ }, [mx, room, userId, onNavigate])
+ );
+ const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
+ useCallback(() => mx.leave(room.roomId), [mx, room])
+ );
+
+ const joining =
+ joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
+ const leaving =
+ leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success;
+
+ return (
+ <SequenceCard
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="200"
+ style={{ padding: config.space.S400, paddingTop: config.space.S200 }}
+ >
+ <Box gap="200" alignItems="Baseline">
+ <Box grow="Yes">
+ <Text size="T200" priority="300" truncate>
+ Invited by <b>{senderName}</b>
+ </Text>
+ </Box>
+ <Box shrink="No">
+ <Time size="T200" ts={memberTs} priority="300" />
+ </Box>
+ </Box>
+ <Box gap="300">
+ <Avatar size="300">
+ <RoomAvatar
+ roomId={room.roomId}
+ src={direct ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)}
+ alt={roomName}
+ renderFallback={() => (
+ <Text as="span" size="H6">
+ {nameInitials(roomName)}
+ </Text>
+ )}
+ />
+ </Avatar>
+ <Box direction={compact ? 'Column' : 'Row'} grow="Yes" gap="200">
+ <Box grow="Yes" direction="Column" gap="200">
+ <Box direction="Column">
+ <Text size="T300" truncate>
+ <b>{roomName}</b>
+ </Text>
+ {topic && (
+ <Text
+ size="T200"
+ onClick={openTopic}
+ onKeyDown={onEnterOrSpace(openTopic)}
+ tabIndex={0}
+ truncate
+ >
+ {topic}
+ </Text>
+ )}
+ <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ clickOutsideDeactivates: true,
+ onDeactivate: closeTopic,
+ }}
+ >
+ <RoomTopicViewer
+ name={roomName}
+ topic={topic ?? ''}
+ requestClose={closeTopic}
+ />
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ </Box>
+ {joinState.status === AsyncStatus.Error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ {joinState.error.message}
+ </Text>
+ )}
+ {leaveState.status === AsyncStatus.Error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ {leaveState.error.message}
+ </Text>
+ )}
+ </Box>
+ <Box gap="200" shrink="No" alignItems="Center">
+ <Button
+ onClick={leave}
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ disabled={joining || leaving}
+ before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
+ >
+ <Text size="B300">Decline</Text>
+ </Button>
+ <Button
+ onClick={join}
+ size="300"
+ variant="Primary"
+ fill="Soft"
+ outlined
+ disabled={joining || leaving}
+ before={joining ? <Spinner variant="Primary" fill="Soft" size="100" /> : undefined}
+ >
+ <Text size="B300">Accept</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Box>
+ </SequenceCard>
+ );
+}
+
+export function Invites() {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+ const mDirects = useAtomValue(mDirectAtom);
+ const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects);
+ const spaceInvites = useSpaceInvites(mx, allInvitesAtom);
+ const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
+ useElementSizeObserver(
+ useCallback(() => containerRef.current, []),
+ useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), [])
+ );
+
+ const { navigateRoom, navigateSpace } = useRoomNavigate();
+
+ const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => {
+ const room = mx.getRoom(roomId);
+ if (!room) return null;
+ return (
+ <InviteCard
+ key={roomId}
+ room={room}
+ userId={userId}
+ compact={compact}
+ direct={direct}
+ onNavigate={handleNavigate}
+ />
+ );
+ };
+
+ return (
+ <Page>
+ <PageHeader>
+ <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+ <Icon size="400" src={Icons.Mail} />
+ <Text size="H3" truncate>
+ Invitations
+ </Text>
+ </Box>
+ </PageHeader>
+ <Box grow="Yes">
+ <Scroll hideTrack visibility="Hover">
+ <PageContent>
+ <PageContentCenter>
+ <Box ref={containerRef} direction="Column" gap="600">
+ {directInvites.length > 0 && (
+ <Box direction="Column" gap="200">
+ <Text size="H4">Direct Messages</Text>
+ <Box direction="Column" gap="100">
+ {directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))}
+ </Box>
+ </Box>
+ )}
+ {spaceInvites.length > 0 && (
+ <Box direction="Column" gap="200">
+ <Text size="H4">Spaces</Text>
+ <Box direction="Column" gap="100">
+ {spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))}
+ </Box>
+ </Box>
+ )}
+ {roomInvites.length > 0 && (
+ <Box direction="Column" gap="200">
+ <Text size="H4">Rooms</Text>
+ <Box direction="Column" gap="100">
+ {roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))}
+ </Box>
+ </Box>
+ )}
+ {directInvites.length === 0 &&
+ spaceInvites.length === 0 &&
+ roomInvites.length === 0 && (
+ <div>
+ <SequenceCard
+ variant="SurfaceVariant"
+ style={{ padding: config.space.S400 }}
+ direction="Column"
+ gap="200"
+ >
+ <Text>No Pending Invitations</Text>
+ <Text size="T200">
+ You don't have any new pending invitations to display yet.
+ </Text>
+ </SequenceCard>
+ </div>
+ )}
+ </Box>
+ </PageContentCenter>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+/* eslint-disable react/destructuring-assignment */
+import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Chip,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Scroll,
+ Text,
+ config,
+ toRem,
+} from 'folds';
+import { useSearchParams } from 'react-router-dom';
+import {
+ INotification,
+ INotificationsResponse,
+ IRoomEvent,
+ JoinRule,
+ Method,
+ Room,
+} from 'matrix-js-sdk';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdLocalPart, isRoomId, isUserId } from '../../../utils/matrix';
+import { InboxNotificationsPathSearchParams } from '../../paths';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { SequenceCard } from '../../../components/sequence-card';
+import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
+import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../../utils/room';
+import { ScrollTopContainer } from '../../../components/scroll-top-container';
+import { useInterval } from '../../../hooks/useInterval';
+import {
+ AvatarBase,
+ ImageContent,
+ MSticker,
+ ModernLayout,
+ RedactedContent,
+ Reply,
+ Time,
+ Username,
+} from '../../../components/message';
+import colorMXID from '../../../../util/colorMXID';
+import { getReactCustomHtmlParser } from '../../../plugins/react-custom-html-parser';
+import { openJoinAlias, openProfileViewer } from '../../../../client/action/navigation';
+import { RenderMessageContent } from '../../../components/RenderMessageContent';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { Image } from '../../../components/media';
+import { ImageViewer } from '../../../components/image-viewer';
+import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
+import { useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer';
+import * as customHtmlCss from '../../../styles/CustomHtml.css';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { useRoomUnread } from '../../../state/hooks/unread';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { markAsRead } from '../../../../client/action/notifications';
+import { ContainerColor } from '../../../styles/ContainerColor.css';
+import { VirtualTile } from '../../../components/virtualizer';
+import { UserAvatar } from '../../../components/user-avatar';
+
+type RoomNotificationsGroup = {
+ roomId: string;
+ notifications: INotification[];
+};
+type NotificationTimeline = {
+ nextToken?: string;
+ groups: RoomNotificationsGroup[];
+};
+type LoadTimeline = (from?: string) => Promise<void>;
+type SilentReloadTimeline = () => Promise<void>;
+
+const groupNotifications = (notifications: INotification[]): RoomNotificationsGroup[] => {
+ const groups: RoomNotificationsGroup[] = [];
+ notifications.forEach((notification) => {
+ const groupIndex = groups.length - 1;
+ const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex];
+ if (lastAddedGroup && notification.room_id === lastAddedGroup.roomId) {
+ lastAddedGroup.notifications.push(notification);
+ return;
+ }
+ groups.push({
+ roomId: notification.room_id,
+ notifications: [notification],
+ });
+ });
+ return groups;
+};
+
+const useNotificationTimeline = (
+ paginationLimit: number,
+ onlyHighlight?: boolean
+): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => {
+ const mx = useMatrixClient();
+ const [notificationTimeline, setNotificationTimeline] = useState<NotificationTimeline>({
+ groups: [],
+ });
+
+ const fetchNotifications = useCallback(
+ (from?: string, limit?: number, only?: 'highlight') => {
+ const queryParams = { from, limit, only };
+ return mx.http.authedRequest<INotificationsResponse>(
+ Method.Get,
+ '/notifications',
+ queryParams
+ );
+ },
+ [mx]
+ );
+
+ const loadTimeline: LoadTimeline = useCallback(
+ async (from) => {
+ if (!from) {
+ setNotificationTimeline({ groups: [] });
+ }
+ const data = await fetchNotifications(
+ from,
+ paginationLimit,
+ onlyHighlight ? 'highlight' : undefined
+ );
+ const groups = groupNotifications(data.notifications);
+
+ setNotificationTimeline((currentTimeline) => {
+ if (currentTimeline.nextToken === from) {
+ return {
+ nextToken: data.next_token,
+ groups: from ? currentTimeline.groups.concat(groups) : groups,
+ };
+ }
+ return currentTimeline;
+ });
+ },
+ [paginationLimit, onlyHighlight, fetchNotifications]
+ );
+
+ /**
+ * Reload timeline silently i.e without setting to default
+ * before fetching notifications from start
+ */
+ const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => {
+ const data = await fetchNotifications(
+ undefined,
+ paginationLimit,
+ onlyHighlight ? 'highlight' : undefined
+ );
+ const groups = groupNotifications(data.notifications);
+ setNotificationTimeline({
+ nextToken: data.next_token,
+ groups,
+ });
+ }, [paginationLimit, onlyHighlight, fetchNotifications]);
+
+ return [notificationTimeline, loadTimeline, silentReloadTimeline];
+};
+
+type RoomNotificationsGroupProps = {
+ room: Room;
+ notifications: INotification[];
+ mediaAutoLoad?: boolean;
+ urlPreview?: boolean;
+ onOpen: (roomId: string, eventId: string) => void;
+};
+function RoomNotificationsGroupComp({
+ room,
+ notifications,
+ mediaAutoLoad,
+ urlPreview,
+ onOpen,
+}: RoomNotificationsGroupProps) {
+ const mx = useMatrixClient();
+ const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+ const { navigateRoom, navigateSpace } = useRoomNavigate();
+
+ const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+ () =>
+ getReactCustomHtmlParser(mx, room, {
+ handleSpoilerClick: (evt) => {
+ const target = evt.currentTarget;
+ if (target.getAttribute('aria-pressed') === 'true') {
+ evt.stopPropagation();
+ target.setAttribute('aria-pressed', 'false');
+ target.style.cursor = 'initial';
+ }
+ },
+ handleMentionClick: (evt) => {
+ const target = evt.currentTarget;
+ const mentionId = target.getAttribute('data-mention-id');
+ if (typeof mentionId !== 'string') return;
+ if (isUserId(mentionId)) {
+ openProfileViewer(mentionId, room.roomId);
+ return;
+ }
+ if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
+ if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
+ else navigateRoom(mentionId);
+ return;
+ }
+ openJoinAlias(mentionId);
+ },
+ }),
+ [mx, room, navigateRoom, navigateSpace]
+ );
+
+ const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(
+ {
+ [MessageEvent.RoomMessage]: (event, displayName, getContent) => {
+ if (event.unsigned?.redacted_because) {
+ return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+ }
+
+ return (
+ <RenderMessageContent
+ displayName={displayName}
+ msgType={event.content.msgtype ?? ''}
+ ts={event.origin_server_ts}
+ getContent={getContent}
+ mediaAutoLoad={mediaAutoLoad}
+ urlPreview={urlPreview}
+ htmlReactParserOptions={htmlReactParserOptions}
+ outlineAttachment
+ />
+ );
+ },
+ [MessageEvent.Sticker]: (event, displayName, getContent) => {
+ if (event.unsigned?.redacted_because) {
+ return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+ }
+ return (
+ <MSticker
+ content={getContent()}
+ renderImageContent={(props) => (
+ <ImageContent
+ {...props}
+ autoPlay={mediaAutoLoad}
+ renderImage={(p) => <Image {...p} loading="lazy" />}
+ renderViewer={(p) => <ImageViewer {...p} />}
+ />
+ )}
+ />
+ );
+ },
+ [StateEvent.RoomTombstone]: (event) => {
+ const { content } = event;
+ return (
+ <Box grow="Yes" direction="Column">
+ <Text size="T400" priority="300">
+ Room Tombstone. {content.body}
+ </Text>
+ </Box>
+ );
+ },
+ },
+ undefined,
+ (event) => {
+ if (event.unsigned?.redacted_because) {
+ return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+ }
+ return (
+ <Box grow="Yes" direction="Column">
+ <Text size="T400" priority="300">
+ <code className={customHtmlCss.Code}>{event.type}</code>
+ {' event'}
+ </Text>
+ </Box>
+ );
+ }
+ );
+
+ const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const eventId = evt.currentTarget.getAttribute('data-event-id');
+ if (!eventId) return;
+ onOpen(room.roomId, eventId);
+ };
+ const handleMarkAsRead = () => {
+ markAsRead(room.roomId);
+ };
+
+ return (
+ <Box direction="Column" gap="200">
+ <Header size="300">
+ <Box gap="200" grow="Yes">
+ <Avatar size="200" radii="300">
+ <RoomAvatar
+ roomId={room.roomId}
+ src={getRoomAvatarUrl(mx, room, 96)}
+ alt={room.name}
+ renderFallback={() => (
+ <RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
+ )}
+ />
+ </Avatar>
+ <Text size="H4" truncate>
+ {room.name}
+ </Text>
+ </Box>
+ <Box shrink="No">
+ {unread && (
+ <Chip
+ variant="Primary"
+ radii="Pill"
+ onClick={handleMarkAsRead}
+ before={<Icon size="100" src={Icons.CheckTwice} />}
+ >
+ <Text size="T200">Mark as Read</Text>
+ </Chip>
+ )}
+ </Box>
+ </Header>
+ <Box direction="Column" gap="100">
+ {notifications.map((notification) => {
+ const { event } = notification;
+
+ const displayName =
+ getMemberDisplayName(room, event.sender) ??
+ getMxIdLocalPart(event.sender) ??
+ event.sender;
+ const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
+ const getContent = (() => event.content) as GetContentCallback;
+
+ const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
+
+ return (
+ <SequenceCard
+ key={notification.event.event_id}
+ style={{ padding: config.space.S400 }}
+ variant="SurfaceVariant"
+ direction="Column"
+ >
+ <ModernLayout
+ before={
+ <AvatarBase>
+ <Avatar size="300">
+ <UserAvatar
+ userId={event.sender}
+ src={
+ senderAvatarMxc
+ ? mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? undefined
+ : undefined
+ }
+ alt={displayName}
+ renderFallback={() => <Icon size="200" src={Icons.User} filled />}
+ />
+ </Avatar>
+ </AvatarBase>
+ }
+ >
+ <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+ <Box gap="200" alignItems="Baseline">
+ <Username style={{ color: colorMXID(event.sender) }}>
+ <Text as="span" truncate>
+ <b>{displayName}</b>
+ </Text>
+ </Username>
+ <Time ts={event.origin_server_ts} />
+ </Box>
+ <Box shrink="No" gap="200" alignItems="Center">
+ <Chip
+ data-event-id={event.event_id}
+ onClick={handleOpenClick}
+ variant="Secondary"
+ radii="400"
+ >
+ <Text size="T200">Open</Text>
+ </Chip>
+ </Box>
+ </Box>
+ {replyEventId && (
+ <Reply
+ as="button"
+ mx={mx}
+ room={room}
+ eventId={replyEventId}
+ data-event-id={replyEventId}
+ onClick={handleOpenClick}
+ />
+ )}
+ {renderMatrixEvent(event.type, false, event, displayName, getContent)}
+ </ModernLayout>
+ </SequenceCard>
+ );
+ })}
+ </Box>
+ </Box>
+ );
+}
+
+const useNotificationsSearchParams = (
+ searchParams: URLSearchParams
+): InboxNotificationsPathSearchParams =>
+ useMemo(
+ () => ({
+ only: searchParams.get('only') ?? undefined,
+ }),
+ [searchParams]
+ );
+
+const DEFAULT_REFRESH_MS = 10000;
+
+export function Notifications() {
+ const mx = useMatrixClient();
+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+
+ const { navigateRoom } = useRoomNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const notificationsSearchParams = useNotificationsSearchParams(searchParams);
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+ const [refreshIntervalTime, setRefreshIntervalTime] = useState(DEFAULT_REFRESH_MS);
+
+ const onlyHighlight = notificationsSearchParams.only === 'highlight';
+ const setOnlyHighlighted = (highlight: boolean) => {
+ if (highlight) {
+ setSearchParams(
+ new URLSearchParams({
+ only: 'highlight',
+ })
+ );
+ return;
+ }
+ setSearchParams();
+ };
+
+ const [notificationTimeline, _loadTimeline, silentReloadTimeline] = useNotificationTimeline(
+ 24,
+ onlyHighlight
+ );
+ const [timelineState, loadTimeline] = useAsyncCallback(_loadTimeline);
+
+ const virtualizer = useVirtualizer({
+ count: notificationTimeline.groups.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 40,
+ overscan: 4,
+ });
+ const vItems = virtualizer.getVirtualItems();
+
+ useInterval(
+ useCallback(() => {
+ if (document.hasFocus()) {
+ silentReloadTimeline();
+ }
+ }, [silentReloadTimeline]),
+ refreshIntervalTime
+ );
+
+ const handleScrollTopVisibility = useCallback(
+ (onTop: boolean) => setRefreshIntervalTime(onTop ? DEFAULT_REFRESH_MS : -1),
+ []
+ );
+
+ useEffect(() => {
+ loadTimeline();
+ }, [loadTimeline]);
+
+ const lastVItem = vItems[vItems.length - 1];
+ const lastVItemIndex: number | undefined = lastVItem?.index;
+ useEffect(() => {
+ if (
+ timelineState.status === AsyncStatus.Success &&
+ notificationTimeline.groups.length - 1 === lastVItemIndex &&
+ notificationTimeline.nextToken
+ ) {
+ loadTimeline(notificationTimeline.nextToken);
+ }
+ }, [timelineState, notificationTimeline, lastVItemIndex, loadTimeline]);
+
+ return (
+ <Page>
+ <PageHeader>
+ <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+ <Icon size="400" src={Icons.Message} />
+ <Text size="H3" truncate>
+ Notification Messages
+ </Text>
+ </Box>
+ </PageHeader>
+
+ <Box style={{ position: 'relative' }} grow="Yes">
+ <Scroll ref={scrollRef} hideTrack visibility="Hover">
+ <PageContent>
+ <PageContentCenter>
+ <Box direction="Column" gap="200">
+ <Box ref={scrollTopAnchorRef} direction="Column" gap="100">
+ <span data-spacing-node />
+ <Text size="L400">Filter</Text>
+ <Box gap="200">
+ <Chip
+ onClick={() => setOnlyHighlighted(false)}
+ variant={!onlyHighlight ? 'Success' : 'Surface'}
+ aria-pressed={!onlyHighlight}
+ before={!onlyHighlight && <Icon size="100" src={Icons.Check} />}
+ outlined
+ >
+ <Text size="T200">All Notifications</Text>
+ </Chip>
+ <Chip
+ onClick={() => setOnlyHighlighted(true)}
+ variant={onlyHighlight ? 'Success' : 'Surface'}
+ aria-pressed={onlyHighlight}
+ before={onlyHighlight && <Icon size="100" src={Icons.Check} />}
+ outlined
+ >
+ <Text size="T200">Highlighted</Text>
+ </Chip>
+ </Box>
+ </Box>
+ <ScrollTopContainer
+ scrollRef={scrollRef}
+ anchorRef={scrollTopAnchorRef}
+ onVisibilityChange={handleScrollTopVisibility}
+ >
+ <IconButton
+ onClick={() => virtualizer.scrollToOffset(0)}
+ variant="SurfaceVariant"
+ radii="Pill"
+ outlined
+ size="300"
+ aria-label="Scroll to Top"
+ >
+ <Icon src={Icons.ChevronTop} size="300" />
+ </IconButton>
+ </ScrollTopContainer>
+ <div
+ style={{
+ position: 'relative',
+ height: virtualizer.getTotalSize(),
+ }}
+ >
+ {vItems.map((vItem) => {
+ const group = notificationTimeline.groups[vItem.index];
+ if (!group) return null;
+ const groupRoom = mx.getRoom(group.roomId);
+ if (!groupRoom) return null;
+
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ style={{ paddingTop: config.space.S500 }}
+ ref={virtualizer.measureElement}
+ key={vItem.index}
+ >
+ <RoomNotificationsGroupComp
+ room={groupRoom}
+ notifications={group.notifications}
+ mediaAutoLoad={mediaAutoLoad}
+ urlPreview={urlPreview}
+ onOpen={navigateRoom}
+ />
+ </VirtualTile>
+ );
+ })}
+ </div>
+
+ {timelineState.status === AsyncStatus.Success &&
+ notificationTimeline.groups.length === 0 && (
+ <Box
+ className={ContainerColor({ variant: 'SurfaceVariant' })}
+ style={{
+ padding: config.space.S300,
+ borderRadius: config.radii.R400,
+ }}
+ direction="Column"
+ gap="200"
+ >
+ <Text>No Notifications</Text>
+ <Text size="T200">
+ You don't have any new notifications to display yet.
+ </Text>
+ </Box>
+ )}
+
+ {timelineState.status === AsyncStatus.Loading && (
+ <Box direction="Column" gap="100">
+ {[...Array(8).keys()].map((key) => (
+ <SequenceCard
+ variant="SurfaceVariant"
+ key={key}
+ style={{ minHeight: toRem(80) }}
+ />
+ ))}
+ </Box>
+ )}
+ {timelineState.status === AsyncStatus.Error && (
+ <Box
+ className={ContainerColor({ variant: 'Critical' })}
+ style={{
+ padding: config.space.S300,
+ borderRadius: config.radii.R400,
+ }}
+ direction="Column"
+ gap="200"
+ >
+ <Text size="L400">{(timelineState.error as Error).name}</Text>
+ <Text size="T300">{(timelineState.error as Error).message}</Text>
+ </Box>
+ )}
+ </Box>
+ </PageContentCenter>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './Inbox';
+export * from './Notifications';
+export * from './Invites';
--- /dev/null
+export * from './ClientRoot';
+export * from './ClientBindAtoms';
+export * from './ClientLayout';
--- /dev/null
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { useAtomValue } from 'jotai';
+import { useDirects } from '../../../state/hooks/roomList';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { getDirectPath, joinPathComponent } from '../../pathUtils';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import {
+ SidebarAvatar,
+ SidebarItem,
+ SidebarItemBadge,
+ SidebarItemTooltip,
+} from '../../../components/sidebar';
+import { useDirectSelected } from '../../../hooks/router/useDirectSelected';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+import { useDirectRooms } from '../direct/useDirectRooms';
+import { markAsRead } from '../../../../client/action/notifications';
+
+type DirectMenuProps = {
+ requestClose: () => void;
+};
+const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
+ const orphanRooms = useDirectRooms();
+ const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+
+ const handleMarkAsRead = () => {
+ if (!unread) return;
+ orphanRooms.forEach((rId) => markAsRead(rId));
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleMarkAsRead}
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ aria-disabled={!unread}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Mark as Read
+ </Text>
+ </MenuItem>
+ </Box>
+ </Menu>
+ );
+});
+
+export function DirectTab() {
+ const navigate = useNavigate();
+ const mx = useMatrixClient();
+ const screenSize = useScreenSizeContext();
+ const navToActivePath = useAtomValue(useNavToActivePathAtom());
+
+ const mDirects = useAtomValue(mDirectAtom);
+ const directs = useDirects(mx, allRoomsAtom, mDirects);
+ const directUnread = useRoomsUnread(directs, roomToUnreadAtom);
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const directSelected = useDirectSelected();
+
+ const handleDirectClick = () => {
+ const activePath = navToActivePath.get('direct');
+ if (activePath && screenSize !== ScreenSize.Mobile) {
+ navigate(joinPathComponent(activePath));
+ return;
+ }
+
+ navigate(getDirectPath());
+ };
+
+ const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ evt.preventDefault();
+ const cords = evt.currentTarget.getBoundingClientRect();
+ setMenuAnchor((currentState) => {
+ if (currentState) return undefined;
+ return cords;
+ });
+ };
+ return (
+ <SidebarItem active={directSelected}>
+ <SidebarItemTooltip tooltip="Direct Messages">
+ {(triggerRef) => (
+ <SidebarAvatar
+ as="button"
+ ref={triggerRef}
+ outlined
+ onClick={handleDirectClick}
+ onContextMenu={handleContextMenu}
+ >
+ <Icon src={Icons.User} filled={directSelected} />
+ </SidebarAvatar>
+ )}
+ </SidebarItemTooltip>
+ {directUnread && (
+ <SidebarItemBadge hasCount={directUnread.total > 0}>
+ <UnreadBadge highlight={directUnread.highlight > 0} count={directUnread.total} />
+ </SidebarItemBadge>
+ )}
+ {menuAnchor && (
+ <PopOut
+ anchor={menuAnchor}
+ position="Right"
+ align="Start"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <DirectMenu requestClose={() => setMenuAnchor(undefined)} />
+ </FocusTrap>
+ }
+ />
+ )}
+ </SidebarItem>
+ );
+}
--- /dev/null
+import React from 'react';
+import { Icon, Icons } from 'folds';
+import { useNavigate } from 'react-router-dom';
+import { useAtomValue } from 'jotai';
+import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
+import { useExploreSelected } from '../../../hooks/router/useExploreSelected';
+import {
+ getExploreFeaturedPath,
+ getExplorePath,
+ getExploreServerPath,
+ joinPathComponent,
+} from '../../pathUtils';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdServer } from '../../../utils/matrix';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+
+export function ExploreTab() {
+ const mx = useMatrixClient();
+ const screenSize = useScreenSizeContext();
+ const clientConfig = useClientConfig();
+ const navigate = useNavigate();
+ const navToActivePath = useAtomValue(useNavToActivePathAtom());
+
+ const exploreSelected = useExploreSelected();
+
+ const handleExploreClick = () => {
+ if (screenSize === ScreenSize.Mobile) {
+ navigate(getExplorePath());
+ return;
+ }
+
+ const activePath = navToActivePath.get('explore');
+ if (activePath) {
+ navigate(joinPathComponent(activePath));
+ return;
+ }
+
+ if (clientConfig.featuredCommunities?.openAsDefault) {
+ navigate(getExploreFeaturedPath());
+ return;
+ }
+ const userId = mx.getUserId();
+ const userServer = userId ? getMxIdServer(userId) : undefined;
+ if (userServer) {
+ navigate(getExploreServerPath(userServer));
+ return;
+ }
+ navigate(getExplorePath());
+ };
+
+ return (
+ <SidebarItem active={exploreSelected}>
+ <SidebarItemTooltip tooltip="Explore Community">
+ {(triggerRef) => (
+ <SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleExploreClick}>
+ <Icon src={Icons.Explore} filled={exploreSelected} />
+ </SidebarAvatar>
+ )}
+ </SidebarItemTooltip>
+ </SidebarItem>
+ );
+}
--- /dev/null
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { useAtomValue } from 'jotai';
+import FocusTrap from 'focus-trap-react';
+import { useOrphanRooms } from '../../../state/hooks/roomList';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { getHomePath, joinPathComponent } from '../../pathUtils';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import {
+ SidebarAvatar,
+ SidebarItem,
+ SidebarItemBadge,
+ SidebarItemTooltip,
+} from '../../../components/sidebar';
+import { useHomeSelected } from '../../../hooks/router/useHomeSelected';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+import { useHomeRooms } from '../home/useHomeRooms';
+import { markAsRead } from '../../../../client/action/notifications';
+
+type HomeMenuProps = {
+ requestClose: () => void;
+};
+const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
+ const orphanRooms = useHomeRooms();
+ const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+
+ const handleMarkAsRead = () => {
+ if (!unread) return;
+ orphanRooms.forEach((rId) => markAsRead(rId));
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleMarkAsRead}
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ aria-disabled={!unread}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Mark as Read
+ </Text>
+ </MenuItem>
+ </Box>
+ </Menu>
+ );
+});
+
+export function HomeTab() {
+ const navigate = useNavigate();
+ const mx = useMatrixClient();
+ const screenSize = useScreenSizeContext();
+ const navToActivePath = useAtomValue(useNavToActivePathAtom());
+
+ const mDirects = useAtomValue(mDirectAtom);
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
+ const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+ const homeSelected = useHomeSelected();
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const handleHomeClick = () => {
+ const activePath = navToActivePath.get('home');
+ if (activePath && screenSize !== ScreenSize.Mobile) {
+ navigate(joinPathComponent(activePath));
+ return;
+ }
+
+ navigate(getHomePath());
+ };
+
+ const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ evt.preventDefault();
+ const cords = evt.currentTarget.getBoundingClientRect();
+ setMenuAnchor((currentState) => {
+ if (currentState) return undefined;
+ return cords;
+ });
+ };
+
+ return (
+ <SidebarItem active={homeSelected}>
+ <SidebarItemTooltip tooltip="Home">
+ {(triggerRef) => (
+ <SidebarAvatar
+ as="button"
+ ref={triggerRef}
+ outlined
+ onClick={handleHomeClick}
+ onContextMenu={handleContextMenu}
+ >
+ <Icon src={Icons.Home} filled={homeSelected} />
+ </SidebarAvatar>
+ )}
+ </SidebarItemTooltip>
+ {homeUnread && (
+ <SidebarItemBadge hasCount={homeUnread.total > 0}>
+ <UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
+ </SidebarItemBadge>
+ )}
+ {menuAnchor && (
+ <PopOut
+ anchor={menuAnchor}
+ position="Right"
+ align="Start"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <HomeMenu requestClose={() => setMenuAnchor(undefined)} />
+ </FocusTrap>
+ }
+ />
+ )}
+ </SidebarItem>
+ );
+}
--- /dev/null
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Icon, Icons } from 'folds';
+import { useAtomValue } from 'jotai';
+import {
+ SidebarAvatar,
+ SidebarItem,
+ SidebarItemBadge,
+ SidebarItemTooltip,
+} from '../../../components/sidebar';
+import { allInvitesAtom } from '../../../state/room-list/inviteList';
+import {
+ getInboxInvitesPath,
+ getInboxNotificationsPath,
+ getInboxPath,
+ joinPathComponent,
+} from '../../pathUtils';
+import { useInboxSelected } from '../../../hooks/router/useInbox';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+
+export function InboxTab() {
+ const screenSize = useScreenSizeContext();
+ const navigate = useNavigate();
+ const navToActivePath = useAtomValue(useNavToActivePathAtom());
+ const inboxSelected = useInboxSelected();
+ const allInvites = useAtomValue(allInvitesAtom);
+ const inviteCount = allInvites.length;
+
+ const handleInboxClick = () => {
+ if (screenSize === ScreenSize.Mobile) {
+ navigate(getInboxPath());
+ return;
+ }
+ const activePath = navToActivePath.get('inbox');
+ if (activePath) {
+ navigate(joinPathComponent(activePath));
+ return;
+ }
+
+ const path = inviteCount > 0 ? getInboxInvitesPath() : getInboxNotificationsPath();
+ navigate(path);
+ };
+
+ return (
+ <SidebarItem active={inboxSelected}>
+ <SidebarItemTooltip tooltip="Inbox">
+ {(triggerRef) => (
+ <SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleInboxClick}>
+ <Icon src={Icons.Inbox} filled={inboxSelected} />
+ </SidebarAvatar>
+ )}
+ </SidebarItemTooltip>
+ {inviteCount > 0 && (
+ <SidebarItemBadge hasCount>
+ <UnreadBadge highlight count={inviteCount} />
+ </SidebarItemBadge>
+ )}
+ </SidebarItem>
+ );
+}
--- /dev/null
+import React, {
+ MouseEventHandler,
+ ReactNode,
+ RefObject,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Box,
+ Icon,
+ IconButton,
+ Icons,
+ Line,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Text,
+ config,
+ toRem,
+} from 'folds';
+import { useAtom, useAtomValue } from 'jotai';
+import { Room } from 'matrix-js-sdk';
+import {
+ draggable,
+ dropTargetForElements,
+ monitorForElements,
+} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import {
+ attachInstruction,
+ extractInstruction,
+ Instruction,
+} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
+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 {
+ useOrphanSpaces,
+ useRecursiveChildScopeFactory,
+ useSpaceChildren,
+} from '../../../state/hooks/roomList';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import {
+ getOriginBaseUrl,
+ getSpaceLobbyPath,
+ getSpacePath,
+ joinPathComponent,
+ withOriginBaseUrl,
+} from '../../pathUtils';
+import {
+ SidebarAvatar,
+ SidebarItem,
+ SidebarItemBadge,
+ SidebarItemTooltip,
+ SidebarStack,
+ SidebarStackSeparator,
+ SidebarFolder,
+ SidebarFolderDropTarget,
+} from '../../../components/sidebar';
+import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider';
+import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
+import { RoomAvatar } from '../../../components/room-avatar';
+import { nameInitials, randomStr } from '../../../utils/common';
+import {
+ ISidebarFolder,
+ SidebarItems,
+ TSidebarItem,
+ makeCinnySpacesContent,
+ parseSidebar,
+ sidebarItemWithout,
+ useSidebarItems,
+} from '../../../hooks/useSidebarItems';
+import { AccountDataEvent } from '../../../../types/matrix/accountData';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { markAsRead } from '../../../../client/action/notifications';
+import { copyToClipboard } from '../../../utils/dom';
+import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
+
+type SpaceMenuProps = {
+ room: Room;
+ requestClose: () => void;
+ onUnpin?: (roomId: string) => void;
+};
+const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
+ ({ room, requestClose, onUnpin }, ref) => {
+ const mx = useMatrixClient();
+ const { hashRouter } = useClientConfig();
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const powerLevels = usePowerLevels(room);
+ const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+ const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+ const allChild = useSpaceChildren(
+ allRoomsAtom,
+ room.roomId,
+ useRecursiveChildScopeFactory(mx, roomToParents)
+ );
+ const unread = useRoomsUnread(allChild, roomToUnreadAtom);
+
+ const handleMarkAsRead = () => {
+ allChild.forEach((childRoomId) => markAsRead(childRoomId));
+ requestClose();
+ };
+
+ const handleUnpin = () => {
+ onUnpin?.(room.roomId);
+ requestClose();
+ };
+
+ const handleCopyLink = () => {
+ const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId));
+ copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath));
+ requestClose();
+ };
+
+ const handleInvite = () => {
+ openInviteUser(room.roomId);
+ requestClose();
+ };
+
+ const handleRoomSettings = () => {
+ openSpaceSettings(room.roomId);
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleMarkAsRead}
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ disabled={!unread}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Mark as Read
+ </Text>
+ </MenuItem>
+ {onUnpin && (
+ <MenuItem
+ size="300"
+ radii="300"
+ onClick={handleUnpin}
+ after={<Icon size="100" src={Icons.Pin} />}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Unpin
+ </Text>
+ </MenuItem>
+ )}
+ </Box>
+ <Line variant="Surface" size="300" />
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleInvite}
+ variant="Primary"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.UserPlus} />}
+ radii="300"
+ disabled={!canInvite}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Invite
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleCopyLink}
+ size="300"
+ after={<Icon size="100" src={Icons.Link} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Copy Link
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleRoomSettings}
+ size="300"
+ after={<Icon size="100" src={Icons.Setting} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Space Settings
+ </Text>
+ </MenuItem>
+ </Box>
+ </Menu>
+ );
+ }
+);
+
+type InstructionType = Instruction['type'];
+type FolderDraggable = {
+ folder: ISidebarFolder;
+ spaceId?: string;
+ open?: boolean;
+};
+type SidebarDraggable = string | FolderDraggable;
+
+const useDraggableItem = (
+ item: SidebarDraggable,
+ targetRef: RefObject<HTMLElement>,
+ onDragging: (item?: SidebarDraggable) => void,
+ dragHandleRef?: RefObject<HTMLElement>
+): boolean => {
+ const [dragging, setDragging] = useState(false);
+
+ useEffect(() => {
+ const target = targetRef.current;
+ const dragHandle = dragHandleRef?.current ?? undefined;
+
+ return !target
+ ? undefined
+ : draggable({
+ element: target,
+ dragHandle,
+ getInitialData: () => ({ item }),
+ onDragStart: () => {
+ setDragging(true);
+ onDragging?.(item);
+ },
+ onDrop: () => {
+ setDragging(false);
+ onDragging?.(undefined);
+ },
+ });
+ }, [targetRef, dragHandleRef, item, onDragging]);
+
+ return dragging;
+};
+
+const useDropTarget = (
+ item: SidebarDraggable,
+ targetRef: RefObject<HTMLElement>
+): Instruction | undefined => {
+ const [dropState, setDropState] = useState<Instruction>();
+
+ useEffect(() => {
+ const target = targetRef.current;
+ if (!target) return undefined;
+
+ return dropTargetForElements({
+ element: target,
+ canDrop: ({ source }) => {
+ const dragItem = source.data.item as SidebarDraggable;
+ return dragItem !== item;
+ },
+ getData: ({ input, element }) => {
+ const block: Instruction['type'][] = ['reparent'];
+ if (typeof item === 'object' && item.spaceId) block.push('make-child');
+
+ const insData = attachInstruction(
+ {},
+ {
+ input,
+ element,
+ currentLevel: 0,
+ indentPerLevel: 0,
+ mode: 'standard',
+ block,
+ }
+ );
+
+ const instruction: Instruction | null = extractInstruction(insData);
+ setDropState(instruction ?? undefined);
+
+ return {
+ item,
+ instructionType: instruction ? instruction.type : undefined,
+ };
+ },
+ onDragLeave: () => setDropState(undefined),
+ onDrop: () => setDropState(undefined),
+ });
+ }, [item, targetRef]);
+
+ return dropState;
+};
+
+function useDropTargetInstruction<T extends InstructionType>(
+ item: SidebarDraggable,
+ targetRef: RefObject<HTMLElement>,
+ instructionType: T
+): T | undefined {
+ const [dropState, setDropState] = useState<T>();
+
+ useEffect(() => {
+ const target = targetRef.current;
+ if (!target) return undefined;
+
+ return dropTargetForElements({
+ element: target,
+ canDrop: ({ source }) => {
+ const dragItem = source.data.item as SidebarDraggable;
+ return dragItem !== item;
+ },
+ getData: () => {
+ setDropState(instructionType);
+
+ return {
+ item,
+ instructionType,
+ };
+ },
+ onDragLeave: () => setDropState(undefined),
+ onDrop: () => setDropState(undefined),
+ });
+ }, [item, targetRef, instructionType]);
+
+ return dropState;
+}
+
+const useDnDMonitor = (
+ scrollRef: RefObject<HTMLElement>,
+ onDragging: (dragItem?: SidebarDraggable) => void,
+ onReorder: (
+ draggable: SidebarDraggable,
+ container: SidebarDraggable,
+ instruction: InstructionType
+ ) => void
+) => {
+ useEffect(() => {
+ const scrollElement = scrollRef.current;
+ if (!scrollElement) {
+ throw Error('Scroll element ref not configured');
+ }
+
+ return combine(
+ monitorForElements({
+ onDrop: ({ source, location }) => {
+ onDragging(undefined);
+ const { dropTargets } = location.current;
+ if (dropTargets.length === 0) return;
+ const item = source.data.item as SidebarDraggable;
+ const containerItem = dropTargets[0].data.item as SidebarDraggable;
+ const instructionType = dropTargets[0].data.instructionType as
+ | InstructionType
+ | undefined;
+ if (!instructionType) return;
+ onReorder(item, containerItem, instructionType);
+ },
+ }),
+ autoScrollForElements({
+ element: scrollElement,
+ })
+ );
+ }, [scrollRef, onDragging, onReorder]);
+};
+
+type SpaceTabProps = {
+ space: Room;
+ selected: boolean;
+ onClick: MouseEventHandler<HTMLButtonElement>;
+ folder?: ISidebarFolder;
+ onDragging: (dragItem?: SidebarDraggable) => void;
+ disabled?: boolean;
+ onUnpin?: (roomId: string) => void;
+};
+function SpaceTab({
+ space,
+ selected,
+ onClick,
+ folder,
+ onDragging,
+ disabled,
+ onUnpin,
+}: SpaceTabProps) {
+ const mx = useMatrixClient();
+ const targetRef = useRef<HTMLDivElement>(null);
+
+ const spaceDraggable: SidebarDraggable = useMemo(
+ () =>
+ folder
+ ? {
+ folder,
+ spaceId: space.roomId,
+ }
+ : space.roomId,
+ [folder, space]
+ );
+
+ useDraggableItem(spaceDraggable, targetRef, onDragging);
+ 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;
+ });
+ };
+
+ return (
+ <RoomUnreadProvider roomId={space.roomId}>
+ {(unread) => (
+ <SidebarItem
+ active={selected}
+ ref={targetRef}
+ aria-disabled={disabled}
+ data-drop-child={dropType === 'make-child'}
+ data-drop-above={dropType === 'reorder-above'}
+ data-drop-below={dropType === 'reorder-below'}
+ data-inside-folder={!!folder}
+ >
+ <SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
+ {(triggerRef) => (
+ <SidebarAvatar
+ as="button"
+ data-id={space.roomId}
+ ref={triggerRef}
+ size={folder ? '300' : '400'}
+ onClick={onClick}
+ onContextMenu={handleContextMenu}
+ >
+ <RoomAvatar
+ roomId={space.roomId}
+ src={space.getAvatarUrl(mx.baseUrl, 96, 96, 'crop') ?? undefined}
+ alt={space.name}
+ renderFallback={() => (
+ <Text size={folder ? 'H6' : 'H4'}>{nameInitials(space.name, 2)}</Text>
+ )}
+ />
+ </SidebarAvatar>
+ )}
+ </SidebarItemTooltip>
+ {unread && (
+ <SidebarItemBadge hasCount={unread.total > 0}>
+ <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
+ </SidebarItemBadge>
+ )}
+ {menuAnchor && (
+ <PopOut
+ anchor={menuAnchor}
+ position="Right"
+ align="Start"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <SpaceMenu
+ room={space}
+ requestClose={() => setMenuAnchor(undefined)}
+ onUnpin={onUnpin}
+ />
+ </FocusTrap>
+ }
+ />
+ )}
+ </SidebarItem>
+ )}
+ </RoomUnreadProvider>
+ );
+}
+
+type OpenedSpaceFolderProps = {
+ folder: ISidebarFolder;
+ onClose: MouseEventHandler<HTMLButtonElement>;
+ children?: ReactNode;
+};
+function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) {
+ const aboveTargetRef = useRef<HTMLDivElement>(null);
+ const belowTargetRef = useRef<HTMLDivElement>(null);
+
+ const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]);
+
+ const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above');
+ const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below');
+
+ return (
+ <SidebarFolder
+ state="Open"
+ data-drop-above={orderAbove === 'reorder-above'}
+ data-drop-below={orderBelow === 'reorder-below'}
+ >
+ <SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
+ <SidebarAvatar size="300">
+ <IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose}>
+ <Icon size="400" src={Icons.ChevronTop} filled />
+ </IconButton>
+ </SidebarAvatar>
+ {children}
+ <SidebarFolderDropTarget ref={belowTargetRef} position="Bottom" />
+ </SidebarFolder>
+ );
+}
+
+type ClosedSpaceFolderProps = {
+ folder: ISidebarFolder;
+ selected: boolean;
+ onOpen: MouseEventHandler<HTMLButtonElement>;
+ onDragging: (dragItem?: SidebarDraggable) => void;
+ disabled?: boolean;
+};
+function ClosedSpaceFolder({
+ folder,
+ selected,
+ onOpen,
+ onDragging,
+ disabled,
+}: ClosedSpaceFolderProps) {
+ const mx = useMatrixClient();
+ const handlerRef = useRef<HTMLDivElement>(null);
+
+ const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]);
+ useDraggableItem(spaceDraggable, handlerRef, onDragging);
+ const dropState = useDropTarget(spaceDraggable, handlerRef);
+ const dropType = dropState?.type;
+
+ const tooltipName =
+ folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed';
+
+ return (
+ <RoomsUnreadProvider rooms={folder.content}>
+ {(unread) => (
+ <SidebarItem
+ active={selected}
+ ref={handlerRef}
+ aria-disabled={disabled}
+ data-drop-child={dropType === 'make-child'}
+ data-drop-above={dropType === 'reorder-above'}
+ data-drop-below={dropType === 'reorder-below'}
+ >
+ <SidebarItemTooltip tooltip={disabled ? undefined : tooltipName}>
+ {(tooltipRef) => (
+ <SidebarFolder data-id={folder.id} as="button" ref={tooltipRef} onClick={onOpen}>
+ {folder.content.map((sId) => {
+ const space = mx.getRoom(sId);
+ if (!space) return null;
+
+ return (
+ <SidebarAvatar key={sId} size="200" radii="300">
+ <RoomAvatar
+ roomId={space.roomId}
+ src={space.getAvatarUrl(mx.baseUrl, 96, 96, 'crop') ?? undefined}
+ alt={space.name}
+ renderFallback={() => (
+ <Text size="Inherit">
+ <b>{nameInitials(space.name, 2)}</b>
+ </Text>
+ )}
+ />
+ </SidebarAvatar>
+ );
+ })}
+ </SidebarFolder>
+ )}
+ </SidebarItemTooltip>
+ {unread && (
+ <SidebarItemBadge hasCount={unread.total > 0}>
+ <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
+ </SidebarItemBadge>
+ )}
+ </SidebarItem>
+ )}
+ </RoomsUnreadProvider>
+ );
+}
+
+type SpaceTabsProps = {
+ scrollRef: RefObject<HTMLDivElement>;
+};
+export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
+ const navigate = useNavigate();
+ const mx = useMatrixClient();
+ const screenSize = useScreenSizeContext();
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
+ const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
+ const navToActivePath = useAtomValue(useNavToActivePathAtom());
+ const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
+ const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
+
+ useDnDMonitor(
+ scrollRef,
+ setDraggingItem,
+ useCallback(
+ (item, containerItem, instructionType) => {
+ const newItems: SidebarItems = [];
+
+ const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => {
+ if (typeof sI === 'string' && typeof dI === 'string') {
+ return sI === dI;
+ }
+ if (typeof sI === 'object' && typeof dI === 'object') {
+ return sI.id === dI.folder.id;
+ }
+ return false;
+ };
+ const itemAsFolderContent = (i: SidebarDraggable): string[] => {
+ if (typeof i === 'string') {
+ return [i];
+ }
+ if (i.spaceId) {
+ return [i.spaceId];
+ }
+ return [...i.folder.content];
+ };
+
+ sidebarItems.forEach((i) => {
+ const sameFolders =
+ typeof item === 'object' &&
+ typeof containerItem === 'object' &&
+ item.folder.id === containerItem.folder.id;
+
+ // remove draggable space from current position or folder
+ if (!sameFolders && matchDest(i, item)) {
+ if (typeof item === 'object' && item.spaceId) {
+ const folderContent = item.folder.content.filter((s) => s !== item.spaceId);
+ if (folderContent.length === 0) {
+ // remove open state from local storage
+ setOpenedFolder({ type: 'DELETE', id: item.folder.id });
+ return;
+ }
+ newItems.push({
+ ...item.folder,
+ content: folderContent,
+ });
+ }
+ return;
+ }
+ if (matchDest(i, containerItem)) {
+ // we can make child only if
+ // container item is space or closed folder
+ if (instructionType === 'make-child') {
+ const child: string[] = itemAsFolderContent(item);
+ if (typeof containerItem === 'string') {
+ const folder: ISidebarFolder = {
+ id: randomStr(),
+ content: [containerItem].concat(child),
+ };
+ newItems.push(folder);
+ return;
+ }
+ newItems.push({
+ ...containerItem.folder,
+ content: containerItem.folder.content.concat(child),
+ });
+ return;
+ }
+
+ // drop inside opened folder
+ // or reordering inside same folder
+ if (typeof containerItem === 'object' && containerItem.spaceId) {
+ const child = itemAsFolderContent(item);
+ const newContent: string[] = [];
+ containerItem.folder.content
+ .filter((sId) => !child.includes(sId))
+ .forEach((sId) => {
+ if (sId === containerItem.spaceId) {
+ if (instructionType === 'reorder-below') {
+ newContent.push(sId, ...child);
+ }
+ if (instructionType === 'reorder-above') {
+ newContent.push(...child, sId);
+ }
+ return;
+ }
+ newContent.push(sId);
+ });
+ const folder = {
+ ...containerItem.folder,
+ content: newContent,
+ };
+
+ newItems.push(folder);
+ return;
+ }
+
+ // drop above or below space or closed/opened folder
+ if (typeof item === 'string') {
+ if (instructionType === 'reorder-below') newItems.push(i);
+ newItems.push(item);
+ if (instructionType === 'reorder-above') newItems.push(i);
+ } else if (item.spaceId) {
+ if (instructionType === 'reorder-above') {
+ newItems.push(item.spaceId);
+ }
+ if (sameFolders && typeof i === 'object') {
+ // remove from folder if placing around itself
+ const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) };
+ if (newI.content.length > 0) newItems.push(newI);
+ } else {
+ newItems.push(i);
+ }
+ if (instructionType === 'reorder-below') {
+ newItems.push(item.spaceId);
+ }
+ } else {
+ if (instructionType === 'reorder-below') newItems.push(i);
+ newItems.push(item.folder);
+ if (instructionType === 'reorder-above') newItems.push(i);
+ }
+ return;
+ }
+ newItems.push(i);
+ });
+
+ const newSpacesContent = makeCinnySpacesContent(mx, newItems);
+ localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
+ mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
+ },
+ [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces]
+ )
+ );
+
+ const selectedSpaceId = useSelectedSpace();
+
+ const handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const target = evt.currentTarget;
+ const targetSpaceId = target.getAttribute('data-id');
+ if (!targetSpaceId) return;
+
+ if (screenSize === ScreenSize.Mobile) {
+ navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
+ return;
+ }
+
+ const activePath = navToActivePath.get(targetSpaceId);
+ if (activePath) {
+ navigate(joinPathComponent(activePath));
+ return;
+ }
+
+ navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
+ };
+
+ const handleFolderToggle: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const target = evt.currentTarget;
+ const targetFolderId = target.getAttribute('data-id');
+ if (!targetFolderId) return;
+
+ setOpenedFolder({
+ type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT',
+ id: targetFolderId,
+ });
+ };
+
+ const handleUnpin = useCallback(
+ (roomId: string) => {
+ if (orphanSpaces.includes(roomId)) return;
+ const newItems = sidebarItemWithout(sidebarItems, roomId);
+
+ const newSpacesContent = makeCinnySpacesContent(mx, newItems);
+ localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
+ mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
+ },
+ [mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
+ );
+
+ if (sidebarItems.length === 0) return null;
+ return (
+ <>
+ <SidebarStackSeparator />
+ <SidebarStack>
+ {sidebarItems.map((item) => {
+ if (typeof item === 'object') {
+ if (openedFolder.has(item.id)) {
+ return (
+ <OpenedSpaceFolder key={item.id} folder={item} onClose={handleFolderToggle}>
+ {item.content.map((sId) => {
+ const space = mx.getRoom(sId);
+ if (!space) return null;
+ return (
+ <SpaceTab
+ key={space.roomId}
+ space={space}
+ selected={space.roomId === selectedSpaceId}
+ onClick={handleSpaceClick}
+ folder={item}
+ onDragging={setDraggingItem}
+ disabled={
+ typeof draggingItem === 'object'
+ ? draggingItem.spaceId === space.roomId
+ : false
+ }
+ onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
+ />
+ );
+ })}
+ </OpenedSpaceFolder>
+ );
+ }
+
+ return (
+ <ClosedSpaceFolder
+ key={item.id}
+ folder={item}
+ selected={!!selectedSpaceId && item.content.includes(selectedSpaceId)}
+ onOpen={handleFolderToggle}
+ onDragging={setDraggingItem}
+ disabled={
+ typeof draggingItem === 'object' ? draggingItem.folder.id === item.id : false
+ }
+ />
+ );
+ }
+
+ const space = mx.getRoom(item);
+ if (!space) return null;
+
+ return (
+ <SpaceTab
+ key={space.roomId}
+ space={space}
+ selected={space.roomId === selectedSpaceId}
+ onClick={handleSpaceClick}
+ onDragging={setDraggingItem}
+ disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
+ onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
+ />
+ );
+ })}
+ </SidebarStack>
+ </>
+ );
+}
--- /dev/null
+import React, { useEffect, useState } from 'react';
+import { Text } from 'folds';
+import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
+import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
+import { openSettings } from '../../../../client/action/navigation';
+import { UserAvatar } from '../../../components/user-avatar';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdLocalPart } from '../../../utils/matrix';
+import { nameInitials } from '../../../utils/common';
+
+type UserProfile = {
+ avatar_url?: string;
+ displayname?: string;
+};
+export function UserTab() {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+
+ const [profile, setProfile] = useState<UserProfile>({});
+ const displayName = profile.displayname ?? getMxIdLocalPart(userId) ?? userId;
+ const avatarUrl = profile.avatar_url
+ ? mx.mxcUrlToHttp(profile.avatar_url, 96, 96, 'crop') ?? undefined
+ : undefined;
+
+ useEffect(() => {
+ const user = mx.getUser(userId);
+ const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (event, myUser) => {
+ setProfile((cp) => ({
+ ...cp,
+ avatar_url: myUser.avatarUrl,
+ }));
+ };
+ const onDisplayNameChange: UserEventHandlerMap[UserEvent.DisplayName] = (event, myUser) => {
+ setProfile((cp) => ({
+ ...cp,
+ avatar_url: myUser.displayName,
+ }));
+ };
+ mx.getProfileInfo(userId).then((info) => setProfile(() => ({ ...info })));
+ user?.on(UserEvent.AvatarUrl, onAvatarChange);
+ user?.on(UserEvent.DisplayName, onDisplayNameChange);
+ return () => {
+ user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
+ user?.removeListener(UserEvent.DisplayName, onDisplayNameChange);
+ };
+ }, [mx, userId]);
+
+ return (
+ <SidebarItem>
+ <SidebarItemTooltip tooltip="User Settings">
+ {(triggerRef) => (
+ <SidebarAvatar as="button" ref={triggerRef} onClick={() => openSettings()}>
+ <UserAvatar
+ userId={userId}
+ src={avatarUrl}
+ renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
+ />
+ </SidebarAvatar>
+ )}
+ </SidebarItemTooltip>
+ </SidebarItem>
+ );
+}
--- /dev/null
+export * from './HomeTab';
+export * from './DirectTab';
+export * from './SpaceTabs';
+export * from './InboxTab';
+export * from './ExploreTab';
+export * from './UserTab';
--- /dev/null
+import React, { ReactNode } from 'react';
+import { useParams } from 'react-router-dom';
+import { useAtomValue } from 'jotai';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { RoomProvider } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
+import { useSpace } from '../../../hooks/useSpace';
+import { getAllParents } from '../../../utils/room';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+
+export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
+ const mx = useMatrixClient();
+ const space = useSpace();
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const allRooms = useAtomValue(allRoomsAtom);
+
+ const { roomIdOrAlias } = useParams();
+ const roomId = useSelectedRoom();
+ const room = mx.getRoom(roomId);
+
+ if (
+ !room ||
+ room.isSpaceRoom() ||
+ !allRooms.includes(room.roomId) ||
+ !getAllParents(roomToParents, room.roomId).has(space.roomId)
+ ) {
+ return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+ }
+
+ return (
+ <RoomProvider key={room.roomId} value={room}>
+ {children}
+ </RoomProvider>
+ );
+}
--- /dev/null
+import React, { useRef } from 'react';
+import { Box, Icon, Icons, Text, Scroll } from 'folds';
+import { useAtomValue } from 'jotai';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { MessageSearch } from '../../../features/message-search';
+import { useSpace } from '../../../hooks/useSpace';
+import { useRecursiveChildRoomScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+
+export function SpaceSearch() {
+ const mx = useMatrixClient();
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const space = useSpace();
+
+ const mDirects = useAtomValue(mDirectAtom);
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const rooms = useSpaceChildren(
+ allRoomsAtom,
+ space.roomId,
+ useRecursiveChildRoomScopeFactory(mx, mDirects, roomToParents)
+ );
+
+ return (
+ <Page>
+ <PageHeader>
+ <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+ <Icon size="400" src={Icons.Search} />
+ <Text size="H3" truncate>
+ Message Search
+ </Text>
+ </Box>
+ </PageHeader>
+ <Box style={{ position: 'relative' }} grow="Yes">
+ <Scroll ref={scrollRef} hideTrack visibility="Hover">
+ <PageContent>
+ <PageContentCenter>
+ <MessageSearch
+ defaultRoomsFilterName={space.name}
+ allowGlobal
+ rooms={rooms}
+ scrollRef={scrollRef}
+ />
+ </PageContentCenter>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+import React, {
+ MouseEventHandler,
+ forwardRef,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useAtom, useAtomValue } from 'jotai';
+import {
+ Avatar,
+ Box,
+ Icon,
+ IconButton,
+ Icons,
+ Line,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Text,
+ config,
+ toRem,
+} from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { IJoinRuleEventContent, JoinRule, Room } from 'matrix-js-sdk';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import {
+ NavCategory,
+ NavCategoryHeader,
+ NavItem,
+ NavItemContent,
+ NavLink,
+} from '../../../components/nav';
+import {
+ getOriginBaseUrl,
+ getSpaceLobbyPath,
+ getSpacePath,
+ getSpaceRoomPath,
+ getSpaceSearchPath,
+ withOriginBaseUrl,
+} from '../../pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import {
+ useSpaceLobbySelected,
+ useSpaceSearchSelected,
+} from '../../../hooks/router/useSelectedSpace';
+import { useSpace } from '../../../hooks/useSpace';
+import { VirtualTile } from '../../../components/virtualizer';
+import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
+import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
+import { makeNavCategoryId } from '../../../state/closedNavCategories';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { useRoomName } from '../../../hooks/useRoomMeta';
+import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
+import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { markAsRead } from '../../../../client/action/notifications';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
+import { copyToClipboard } from '../../../utils/dom';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { StateEvent } from '../../../../types/matrix/room';
+
+type SpaceMenuProps = {
+ room: Room;
+ requestClose: () => void;
+};
+const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
+ const mx = useMatrixClient();
+ const { hashRouter } = useClientConfig();
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const powerLevels = usePowerLevels(room);
+ const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+ const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+ const allChild = useSpaceChildren(
+ allRoomsAtom,
+ room.roomId,
+ useRecursiveChildScopeFactory(mx, roomToParents)
+ );
+ const unread = useRoomsUnread(allChild, roomToUnreadAtom);
+
+ const handleMarkAsRead = () => {
+ allChild.forEach((childRoomId) => markAsRead(childRoomId));
+ requestClose();
+ };
+
+ const handleCopyLink = () => {
+ const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId));
+ copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath));
+ requestClose();
+ };
+
+ const handleInvite = () => {
+ openInviteUser(room.roomId);
+ requestClose();
+ };
+
+ const handleRoomSettings = () => {
+ openSpaceSettings(room.roomId);
+ requestClose();
+ };
+
+ return (
+ <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleMarkAsRead}
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ disabled={!unread}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Mark as Read
+ </Text>
+ </MenuItem>
+ </Box>
+ <Line variant="Surface" size="300" />
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <MenuItem
+ onClick={handleInvite}
+ variant="Primary"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.UserPlus} />}
+ radii="300"
+ disabled={!canInvite}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Invite
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleCopyLink}
+ size="300"
+ after={<Icon size="100" src={Icons.Link} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Copy Link
+ </Text>
+ </MenuItem>
+ <MenuItem
+ onClick={handleRoomSettings}
+ size="300"
+ after={<Icon size="100" src={Icons.Setting} />}
+ radii="300"
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Space Settings
+ </Text>
+ </MenuItem>
+ </Box>
+ <Line variant="Surface" size="300" />
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ <UseStateProvider initial={false}>
+ {(promptLeave, setPromptLeave) => (
+ <>
+ <MenuItem
+ onClick={() => setPromptLeave(true)}
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+ radii="300"
+ aria-pressed={promptLeave}
+ >
+ <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+ Leave Space
+ </Text>
+ </MenuItem>
+ {promptLeave && (
+ <LeaveSpacePrompt
+ roomId={room.roomId}
+ onDone={requestClose}
+ onCancel={() => setPromptLeave(false)}
+ />
+ )}
+ </>
+ )}
+ </UseStateProvider>
+ </Box>
+ </Menu>
+ );
+});
+
+function SpaceHeader() {
+ const space = useSpace();
+ const spaceName = useRoomName(space);
+ const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+ const joinRules = useStateEvent(
+ space,
+ StateEvent.RoomJoinRules
+ )?.getContent<IJoinRuleEventContent>();
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const cords = evt.currentTarget.getBoundingClientRect();
+ setMenuAnchor((currentState) => {
+ if (currentState) return undefined;
+ return cords;
+ });
+ };
+
+ return (
+ <>
+ <PageNavHeader>
+ <Box alignItems="Center" grow="Yes" gap="300">
+ <Box grow="Yes" alignItems="Center" gap="100">
+ <Text size="H4" truncate>
+ {spaceName}
+ </Text>
+ {joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
+ </Box>
+ <Box>
+ <IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
+ <Icon src={Icons.VerticalDots} size="200" />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageNavHeader>
+ {menuAnchor && (
+ <PopOut
+ anchor={menuAnchor}
+ position="Bottom"
+ align="End"
+ offset={6}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
+ </FocusTrap>
+ }
+ />
+ )}
+ </>
+ );
+}
+
+export function Space() {
+ const mx = useMatrixClient();
+ const space = useSpace();
+ useNavToActivePathMapper(space.roomId);
+ const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const mDirects = useAtomValue(mDirectAtom);
+ const roomToUnread = useAtomValue(roomToUnreadAtom);
+ const allRooms = useAtomValue(allRoomsAtom);
+ const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
+ const muteChanges = useAtomValue(muteChangesAtom);
+ const mutedRooms = muteChanges.added;
+
+ const selectedRoomId = useSelectedRoom();
+ const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
+ const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
+
+ const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
+
+ const getRoom = useCallback(
+ (rId: string) => {
+ if (allJoinedRooms.has(rId)) {
+ return mx.getRoom(rId) ?? undefined;
+ }
+ return undefined;
+ },
+ [mx, allJoinedRooms]
+ );
+
+ const hierarchy = useSpaceJoinedHierarchy(
+ space.roomId,
+ getRoom,
+ useCallback(
+ (parentId, roomId) => {
+ if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
+ return false;
+ }
+ const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId;
+ if (showRoom) return false;
+ return true;
+ },
+ [space.roomId, closedCategories, roomToUnread, selectedRoomId]
+ ),
+ useCallback(
+ (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
+ [closedCategories, space.roomId]
+ )
+ );
+
+ const virtualizer = useVirtualizer({
+ count: hierarchy.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 0,
+ overscan: 10,
+ });
+
+ const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+ closedCategories.has(categoryId)
+ );
+
+ const getToLink = (roomId: string) =>
+ getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
+
+ return (
+ <PageNav>
+ <SpaceHeader />
+ <PageNavContent scrollRef={scrollRef}>
+ <Box direction="Column" gap="300">
+ <NavCategory>
+ <NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
+ <NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.Flag} size="100" filled={lobbySelected} />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Lobby
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ </NavItem>
+ <NavItem variant="Background" radii="400" aria-selected={searchSelected}>
+ <NavLink to={getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
+ <NavItemContent>
+ <Box as="span" grow="Yes" alignItems="Center" gap="200">
+ <Avatar size="200" radii="400">
+ <Icon src={Icons.Search} size="100" filled={searchSelected} />
+ </Avatar>
+ <Box as="span" grow="Yes">
+ <Text as="span" size="Inherit" truncate>
+ Message Search
+ </Text>
+ </Box>
+ </Box>
+ </NavItemContent>
+ </NavLink>
+ </NavItem>
+ </NavCategory>
+ <NavCategory
+ style={{
+ height: virtualizer.getTotalSize(),
+ position: 'relative',
+ }}
+ >
+ {virtualizer.getVirtualItems().map((vItem) => {
+ const { roomId } = hierarchy[vItem.index] ?? {};
+ const room = mx.getRoom(roomId);
+ if (!room) return null;
+
+ if (room.isSpaceRoom()) {
+ const categoryId = makeNavCategoryId(space.roomId, roomId);
+
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ key={vItem.index}
+ ref={virtualizer.measureElement}
+ >
+ <div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
+ <NavCategoryHeader>
+ <RoomNavCategoryButton
+ data-category-id={categoryId}
+ onClick={handleCategoryClick}
+ closed={closedCategories.has(categoryId)}
+ >
+ {roomId === space.roomId ? 'Rooms' : room?.name}
+ </RoomNavCategoryButton>
+ </NavCategoryHeader>
+ </div>
+ </VirtualTile>
+ );
+ }
+
+ return (
+ <VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
+ <RoomNavItem
+ room={room}
+ selected={selectedRoomId === roomId}
+ showAvatar={mDirects.has(roomId)}
+ direct={mDirects.has(roomId)}
+ linkPath={getToLink(roomId)}
+ muted={mutedRooms.includes(roomId)}
+ />
+ </VirtualTile>
+ );
+ })}
+ </NavCategory>
+ </Box>
+ </PageNavContent>
+ </PageNav>
+ );
+}
--- /dev/null
+import React, { ReactNode } from 'react';
+import { useParams } from 'react-router-dom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useSpaces } from '../../../state/hooks/roomList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
+import { SpaceProvider } from '../../../hooks/useSpace';
+import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
+
+type RouteSpaceProviderProps = {
+ children: ReactNode;
+};
+export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) {
+ const mx = useMatrixClient();
+ const joinedSpaces = useSpaces(mx, allRoomsAtom);
+ const { spaceIdOrAlias } = useParams();
+
+ const selectedSpaceId = useSelectedSpace();
+ const space = mx.getRoom(selectedSpaceId);
+
+ if (!space || !joinedSpaces.includes(space.roomId)) {
+ return <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} />;
+ }
+
+ return (
+ <SpaceProvider key={space.roomId} value={space}>
+ {children}
+ </SpaceProvider>
+ );
+}
--- /dev/null
+export * from './SpaceProvider';
+export * from './Space';
+export * from './Search';
+export * from './RoomProvider';
-import { generatePath } from 'react-router-dom';
-import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
+import { generatePath, Path } from 'react-router-dom';
+import {
+ DIRECT_CREATE_PATH,
+ DIRECT_PATH,
+ DIRECT_ROOM_PATH,
+ EXPLORE_FEATURED_PATH,
+ EXPLORE_PATH,
+ EXPLORE_SERVER_PATH,
+ HOME_CREATE_PATH,
+ HOME_JOIN_PATH,
+ HOME_PATH,
+ HOME_ROOM_PATH,
+ HOME_SEARCH_PATH,
+ LOGIN_PATH,
+ INBOX_INVITES_PATH,
+ INBOX_NOTIFICATIONS_PATH,
+ INBOX_PATH,
+ REGISTER_PATH,
+ RESET_PASSWORD_PATH,
+ ROOT_PATH,
+ SPACE_LOBBY_PATH,
+ SPACE_PATH,
+ SPACE_ROOM_PATH,
+ SPACE_SEARCH_PATH,
+} from './paths';
+import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
+import { HashRouterConfig } from '../hooks/useClientConfig';
+
+export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash;
export const withSearchParam = <T extends Record<string, string>>(
path: string,
return `${path}?${params}`;
};
+export const encodeSearchParamValueArray = (ids: string[]): string => ids.join(',');
+export const decodeSearchParamValueArray = (idsParam: string): string[] => idsParam.split(',');
+
+export const getOriginBaseUrl = (hashRouterConfig?: HashRouterConfig): string => {
+ const baseUrl = `${trimTrailingSlash(window.location.origin)}${import.meta.env.BASE_URL}`;
+
+ if (hashRouterConfig?.enabled) {
+ return `${trimTrailingSlash(baseUrl)}/#${hashRouterConfig.basename}`;
+ }
+
+ return baseUrl;
+};
+
+export const withOriginBaseUrl = (baseUrl: string, path: string): string =>
+ `${trimTrailingSlash(baseUrl)}${path}`;
+
+export const getAppPathFromHref = (baseUrl: string, href: string): string => {
+ // if hash is in baseUrl means we are using hashRouter
+ const baseHashIndex = baseUrl.indexOf('#');
+ if (baseHashIndex > -1) {
+ const hrefHashIndex = href.indexOf('#');
+ // href may/not have "/" around "#"
+ // we need to take care of this when extracting app path
+ const trimmedBaseUrl = trimLeadingSlash(baseUrl.slice(baseHashIndex + 1));
+ const trimmedHref = trimLeadingSlash(href.slice(hrefHashIndex + 1));
+
+ const appPath = trimmedHref.slice(trimmedBaseUrl.length);
+ return `/${trimLeadingSlash(appPath)}`;
+ }
+
+ return href.slice(trimTrailingSlash(baseUrl).length);
+};
export const getRootPath = (): string => ROOT_PATH;
const params = server ? { server: encodeURIComponent(server) } : undefined;
return generatePath(RESET_PASSWORD_PATH, params);
};
+
+export const getHomePath = (): string => HOME_PATH;
+export const getHomeCreatePath = (): string => HOME_CREATE_PATH;
+export const getHomeJoinPath = (): string => HOME_JOIN_PATH;
+export const getHomeSearchPath = (): string => HOME_SEARCH_PATH;
+export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
+ const params = {
+ roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
+ eventId: eventId ? encodeURIComponent(eventId) : null,
+ };
+
+ return generatePath(HOME_ROOM_PATH, params);
+};
+
+export const getDirectPath = (): string => DIRECT_PATH;
+export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH;
+export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
+ const params = {
+ roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
+ eventId: eventId ? encodeURIComponent(eventId) : null,
+ };
+
+ return generatePath(DIRECT_ROOM_PATH, params);
+};
+
+export const getSpacePath = (spaceIdOrAlias: string): string => {
+ const params = {
+ spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+ };
+
+ return generatePath(SPACE_PATH, params);
+};
+export const getSpaceLobbyPath = (spaceIdOrAlias: string): string => {
+ const params = {
+ spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+ };
+ return generatePath(SPACE_LOBBY_PATH, params);
+};
+export const getSpaceSearchPath = (spaceIdOrAlias: string): string => {
+ const params = {
+ spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+ };
+ return generatePath(SPACE_SEARCH_PATH, params);
+};
+export const getSpaceRoomPath = (
+ spaceIdOrAlias: string,
+ roomIdOrAlias: string,
+ eventId?: string
+): string => {
+ const params = {
+ spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+ roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
+ eventId: eventId ? encodeURIComponent(eventId) : null,
+ };
+
+ return generatePath(SPACE_ROOM_PATH, params);
+};
+
+export const getExplorePath = (): string => EXPLORE_PATH;
+export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
+export const getExploreServerPath = (server: string): string => {
+ const params = {
+ server: encodeURIComponent(server),
+ };
+ return generatePath(EXPLORE_SERVER_PATH, params);
+};
+
+export const getInboxPath = (): string => INBOX_PATH;
+export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
+export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
};
export const REGISTER_PATH = '/register/:server?/';
+export type ResetPasswordPathSearchParams = {
+ email?: string;
+};
export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
+
+export const _CREATE_PATH = 'create/';
+export const _JOIN_PATH = 'join/';
+export const _LOBBY_PATH = 'lobby/';
+/**
+ * array of rooms and senders mxId assigned
+ * to search param as string should be "," separated
+ * Like: url?rooms=!one:server,!two:server
+ */
+export type _SearchPathSearchParams = {
+ global?: string;
+ term?: string;
+ order?: string;
+ rooms?: string;
+ senders?: string;
+};
+export const _SEARCH_PATH = 'search/';
+export const _ROOM_PATH = ':roomIdOrAlias/:eventId?/';
+
+export const HOME_PATH = '/home/';
+export const HOME_CREATE_PATH = `/home/${_CREATE_PATH}`;
+export const HOME_JOIN_PATH = `/home/${_JOIN_PATH}`;
+export const HOME_SEARCH_PATH = `/home/${_SEARCH_PATH}`;
+export const HOME_ROOM_PATH = `/home/${_ROOM_PATH}`;
+
+export const DIRECT_PATH = '/direct/';
+export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`;
+export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
+
+export const SPACE_PATH = '/:spaceIdOrAlias/';
+export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${_LOBBY_PATH}`;
+export const SPACE_SEARCH_PATH = `/:spaceIdOrAlias/${_SEARCH_PATH}`;
+export const SPACE_ROOM_PATH = `/:spaceIdOrAlias/${_ROOM_PATH}`;
+
+export const _FEATURED_PATH = 'featured/';
+export const _SERVER_PATH = ':server/';
+export const EXPLORE_PATH = '/explore/';
+export const EXPLORE_FEATURED_PATH = `/explore/${_FEATURED_PATH}`;
+
+export type ExploreServerPathSearchParams = {
+ limit?: string;
+ since?: string;
+ term?: string;
+ type?: string;
+ instance?: string;
+};
+export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
+
+export const _NOTIFICATIONS_PATH = 'notifications/';
+export const _INVITES_PATH = 'invites/';
+export const INBOX_PATH = '/inbox/';
+export type InboxNotificationsPathSearchParams = {
+ only?: string;
+};
+export const INBOX_NOTIFICATIONS_PATH = `/inbox/${_NOTIFICATIONS_PATH}`;
+export const INBOX_INVITES_PATH = `/inbox/${_INVITES_PATH}`;
+
+export const USER_SETTINGS_PATH = '/user-settings/';
+
+export const SPACE_SETTINGS_PATH = '/space-settings/';
+
+export const ROOM_SETTINGS_PATH = '/room-settings/';
--- /dev/null
+import millifyPlugin from 'millify';
+import { MillifyOptions } from 'millify/dist/options';
+
+export const millify = (count: number, options?: Partial<MillifyOptions>): string =>
+ millifyPlugin(count, {
+ precision: 1,
+ locales: [],
+ ...options,
+ });
import type * as PdfJsDist from 'pdfjs-dist';
import type { GetViewportParameters } from 'pdfjs-dist/types/src/display/api';
import { useAsyncCallback } from '../hooks/useAsyncCallback';
+import { trimTrailingSlash } from '../utils/common';
export const usePdfJSLoader = () =>
useAsyncCallback(
useCallback(async () => {
const pdf = await import('pdfjs-dist');
- pdf.GlobalWorkerOptions.workerSrc = 'pdf.worker.min.js';
+ pdf.GlobalWorkerOptions.workerSrc = `${trimTrailingSlash(
+ import.meta.env.BASE_URL
+ )}/pdf.worker.min.js`;
return pdf;
}, [])
);
import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary';
import * as css from '../styles/CustomHtml.css';
-import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
+import { getMxIdLocalPart, getCanonicalAliasRoomId } from '../utils/matrix';
import { getMemberDisplayName } from '../utils/room';
import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex';
import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
ignoreTags: ['span'],
};
-const textToEmojifyJSX = (text: string): (string | JSX.Element)[] =>
+export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
findAndReplace(
text,
EMOJI_REG_G,
(match, pushIndex) => (
- <span key={pushIndex} className={css.EmoticonBase}>
+ <span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
{match[0]}
</span>
(txt) => txt
);
-export const emojifyAndLinkify = (text: string, linkify?: boolean) => {
- const emojifyJSX = textToEmojifyJSX(text);
-
- if (linkify) {
- return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>;
- }
- return emojifyJSX;
+export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => {
+ const pattern = highlights.join('|');
+ if (!pattern) return undefined;
+ return new RegExp(pattern, 'gi');
};
+export const highlightText = (
+ regex: RegExp,
+ data: (string | JSX.Element)[]
+): (string | JSX.Element)[] =>
+ data.flatMap((text) => {
+ if (typeof text !== 'string') return text;
+
+ return findAndReplace(
+ text,
+ regex,
+ (match, pushIndex) => (
+ <span key={`highlight-${pushIndex}`} className={css.highlightText}>
+ {match[0]}
+ </span>
+ ),
+ (txt) => txt
+ );
+ });
+
export const getReactCustomHtmlParser = (
mx: MatrixClient,
room: Room,
params: {
+ highlightRegex?: RegExp;
handleSpoilerClick?: ReactEventHandler<HTMLElement>;
handleMentionClick?: ReactEventHandler<HTMLElement>;
}
const mentionId = mention[1];
const mentionPrefix = mention[2];
if (mentionPrefix === '#' || mentionPrefix === '!') {
- const mentionRoom =
- mentionPrefix === '#'
- ? getRoomWithCanonicalAlias(mx, mentionId)
- : mx.getRoom(mentionId);
+ const mentionRoom = mx.getRoom(
+ mentionPrefix === '#' ? getCanonicalAliasRoomId(mx, mentionId) : mentionId
+ );
return (
<span
const linkify =
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');
- return emojifyAndLinkify(domNode.data, linkify);
+
+ let jsx = scaleSystemEmoji(domNode.data);
+
+ if (params.highlightRegex) {
+ jsx = highlightText(params.highlightRegex, jsx);
+ }
+
+ if (linkify) {
+ return <Linkify options={LINKIFY_OPTS}>{jsx}</Linkify>;
+ }
+ return jsx;
}
return undefined;
},
--- /dev/null
+import { WritableAtom, atom } from 'jotai';
+import produce from 'immer';
+import {
+ atomWithLocalStorage,
+ getLocalStorageItem,
+ setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const CLOSED_LOBBY_CATEGORY = 'closedLobbyCategories';
+
+type ClosedLobbyCategoriesAction =
+ | {
+ type: 'PUT';
+ categoryId: string;
+ }
+ | {
+ type: 'DELETE';
+ categoryId: string;
+ };
+
+export type ClosedLobbyCategoriesAtom = WritableAtom<
+ Set<string>,
+ [ClosedLobbyCategoriesAction],
+ undefined
+>;
+
+export const makeClosedLobbyCategoriesAtom = (userId: string): ClosedLobbyCategoriesAtom => {
+ const storeKey = `${CLOSED_LOBBY_CATEGORY}${userId}`;
+
+ const baseClosedLobbyCategoriesAtom = atomWithLocalStorage<Set<string>>(
+ storeKey,
+ (key) => {
+ const arrayValue = getLocalStorageItem<string[]>(key, []);
+ return new Set(arrayValue);
+ },
+ (key, value) => {
+ const arrayValue = Array.from(value);
+ setLocalStorageItem(key, arrayValue);
+ }
+ );
+
+ const closedLobbyCategoriesAtom = atom<Set<string>, [ClosedLobbyCategoriesAction], undefined>(
+ (get) => get(baseClosedLobbyCategoriesAtom),
+ (get, set, action) => {
+ if (action.type === 'DELETE') {
+ set(
+ baseClosedLobbyCategoriesAtom,
+ produce(get(baseClosedLobbyCategoriesAtom), (draft) => {
+ draft.delete(action.categoryId);
+ })
+ );
+ return;
+ }
+ if (action.type === 'PUT') {
+ set(
+ baseClosedLobbyCategoriesAtom,
+ produce(get(baseClosedLobbyCategoriesAtom), (draft) => {
+ draft.add(action.categoryId);
+ })
+ );
+ }
+ }
+ );
+
+ return closedLobbyCategoriesAtom;
+};
+
+export const makeLobbyCategoryId = (...args: string[]): string => args.join('|');
--- /dev/null
+import { WritableAtom, atom } from 'jotai';
+import produce from 'immer';
+import {
+ atomWithLocalStorage,
+ getLocalStorageItem,
+ setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const CLOSED_NAV_CATEGORY = 'closedNavCategories';
+
+type ClosedNavCategoriesAction =
+ | {
+ type: 'PUT';
+ categoryId: string;
+ }
+ | {
+ type: 'DELETE';
+ categoryId: string;
+ };
+
+export type ClosedNavCategoriesAtom = WritableAtom<
+ Set<string>,
+ [ClosedNavCategoriesAction],
+ undefined
+>;
+
+export const makeClosedNavCategoriesAtom = (userId: string): ClosedNavCategoriesAtom => {
+ const storeKey = `${CLOSED_NAV_CATEGORY}${userId}`;
+
+ const baseClosedNavCategoriesAtom = atomWithLocalStorage<Set<string>>(
+ storeKey,
+ (key) => {
+ const arrayValue = getLocalStorageItem<string[]>(key, []);
+ return new Set(arrayValue);
+ },
+ (key, value) => {
+ const arrayValue = Array.from(value);
+ setLocalStorageItem(key, arrayValue);
+ }
+ );
+
+ const closedNavCategoriesAtom = atom<Set<string>, [ClosedNavCategoriesAction], undefined>(
+ (get) => get(baseClosedNavCategoriesAtom),
+ (get, set, action) => {
+ if (action.type === 'DELETE') {
+ set(
+ baseClosedNavCategoriesAtom,
+ produce(get(baseClosedNavCategoriesAtom), (draft) => {
+ draft.delete(action.categoryId);
+ })
+ );
+ return;
+ }
+ if (action.type === 'PUT') {
+ set(
+ baseClosedNavCategoriesAtom,
+ produce(get(baseClosedNavCategoriesAtom), (draft) => {
+ draft.add(action.categoryId);
+ })
+ );
+ }
+ }
+ );
+
+ return closedNavCategoriesAtom;
+};
+
+export const makeNavCategoryId = (...args: string[]): string => args.join('|');
--- /dev/null
+import { createContext, useContext } from 'react';
+import { ClosedLobbyCategoriesAtom } from '../closedLobbyCategories';
+
+const ClosedLobbyCategoriesAtomContext = createContext<ClosedLobbyCategoriesAtom | null>(null);
+export const ClosedLobbyCategoriesProvider = ClosedLobbyCategoriesAtomContext.Provider;
+
+export const useClosedLobbyCategoriesAtom = (): ClosedLobbyCategoriesAtom => {
+ const anAtom = useContext(ClosedLobbyCategoriesAtomContext);
+
+ if (!anAtom) {
+ throw new Error('ClosedLobbyCategoriesAtom is not provided!');
+ }
+
+ return anAtom;
+};
--- /dev/null
+import { createContext, useContext } from 'react';
+import { ClosedNavCategoriesAtom } from '../closedNavCategories';
+
+const ClosedNavCategoriesAtomContext = createContext<ClosedNavCategoriesAtom | null>(null);
+export const ClosedNavCategoriesProvider = ClosedNavCategoriesAtomContext.Provider;
+
+export const useClosedNavCategoriesAtom = (): ClosedNavCategoriesAtom => {
+ const anAtom = useContext(ClosedNavCategoriesAtomContext);
+
+ if (!anAtom) {
+ throw new Error('ClosedNavCategoriesAtom is not provided!');
+ }
+
+ return anAtom;
+};
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
-import { compareRoomsEqual } from '../utils';
-import { mDirectAtom } from '../mDirectList';
-import { allInvitesAtom } from '../inviteList';
+import { compareRoomsEqual } from '../room-list/utils';
+import { allInvitesAtom } from '../room-list/inviteList';
export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
const selector = useCallback(
export const useRoomInvites = (
mx: MatrixClient,
invitesAtom: typeof allInvitesAtom,
- directAtom: typeof mDirectAtom
+ mDirects: Set<string>
) => {
- const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
export const useDirectInvites = (
mx: MatrixClient,
invitesAtom: typeof allInvitesAtom,
- directAtom: typeof mDirectAtom
+ mDirects: Set<string>
) => {
- const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
--- /dev/null
+import { createContext, useContext } from 'react';
+import { NavToActivePathAtom } from '../navToActivePath';
+
+const NavToActivePathAtomContext = createContext<NavToActivePathAtom | null>(null);
+export const NavToActivePathProvider = NavToActivePathAtomContext.Provider;
+
+export const useNavToActivePathAtom = (): NavToActivePathAtom => {
+ const anAtom = useContext(NavToActivePathAtomContext);
+
+ if (!anAtom) {
+ throw new Error('NavToActivePathAtom is not provided!');
+ }
+
+ return anAtom;
+};
--- /dev/null
+import { createContext, useContext } from 'react';
+import { OpenedSidebarFolderAtom } from '../openedSidebarFolder';
+
+const OpenedSidebarFolderAtomContext = createContext<OpenedSidebarFolderAtom | null>(null);
+export const OpenedSidebarFolderProvider = OpenedSidebarFolderAtomContext.Provider;
+
+export const useOpenedSidebarFolderAtom = (): OpenedSidebarFolderAtom => {
+ const anAtom = useContext(OpenedSidebarFolderAtomContext);
+
+ if (!anAtom) {
+ throw new Error('OpenedSidebarFolderAtom is not provided!');
+ }
+
+ return anAtom;
+};
-import { useAtomValue } from 'jotai';
+import { Atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { MatrixClient } from 'matrix-js-sdk';
-import { useCallback } from 'react';
-import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
-import { compareRoomsEqual } from '../utils';
-import { mDirectAtom } from '../mDirectList';
-import { allRoomsAtom } from '../roomList';
-
-export const useSpaces = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
- const selector = useCallback(
- (rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
- [mx]
+import { useCallback, useMemo } from 'react';
+import { getAllParents, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
+import { compareRoomsEqual } from '../room-list/utils';
+import { RoomToParents } from '../../../types/matrix/room';
+
+export type RoomsAtom = Atom<string[]>;
+export type RoomSelector = (roomId: string) => boolean | undefined;
+
+export const selectedRoomsAtom = (
+ roomsAtom: RoomsAtom,
+ selector: (roomId: string) => boolean | undefined
+) => selectAtom(roomsAtom, (rooms) => rooms.filter(selector), compareRoomsEqual);
+
+export const useSelectedRooms = (roomsAtom: RoomsAtom, selector: RoomSelector) => {
+ const anAtom = useMemo(() => selectedRoomsAtom(roomsAtom, selector), [roomsAtom, selector]);
+
+ return useAtomValue(anAtom);
+};
+
+export type SpaceChildSelectorFactory = (parentId: string) => RoomSelector;
+
+export const useRecursiveChildScopeFactory = (
+ mx: MatrixClient,
+ roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+ useCallback(
+ (parentId: string) => (roomId) =>
+ isRoom(mx.getRoom(roomId)) &&
+ roomToParents.has(roomId) &&
+ getAllParents(roomToParents, roomId).has(parentId),
+ [mx, roomToParents]
+ );
+
+export const useChildSpaceScopeFactory = (
+ mx: MatrixClient,
+ roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+ useCallback(
+ (parentId: string) => (roomId) =>
+ isSpace(mx.getRoom(roomId)) && roomToParents.get(roomId)?.has(parentId),
+ [mx, roomToParents]
+ );
+
+export const useRecursiveChildSpaceScopeFactory = (
+ mx: MatrixClient,
+ roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+ useCallback(
+ (parentId: string) => (roomId) =>
+ isSpace(mx.getRoom(roomId)) &&
+ roomToParents.has(roomId) &&
+ getAllParents(roomToParents, roomId).has(parentId),
+ [mx, roomToParents]
+ );
+
+export const useChildRoomScopeFactory = (
+ mx: MatrixClient,
+ mDirects: Set<string>,
+ roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+ useCallback(
+ (parentId: string) => (roomId) =>
+ isRoom(mx.getRoom(roomId)) &&
+ !mDirects.has(roomId) &&
+ roomToParents.get(roomId)?.has(parentId),
+ [mx, mDirects, roomToParents]
+ );
+
+export const useRecursiveChildRoomScopeFactory = (
+ mx: MatrixClient,
+ mDirects: Set<string>,
+ roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+ useCallback(
+ (parentId: string) => (roomId) =>
+ isRoom(mx.getRoom(roomId)) &&
+ !mDirects.has(roomId) &&
+ roomToParents.has(roomId) &&
+ getAllParents(roomToParents, roomId).has(parentId),
+ [mx, mDirects, roomToParents]
+ );
+
+export const useChildDirectScopeFactory = (
+ mx: MatrixClient,
+ mDirects: Set<string>,
+ roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+ useCallback(
+ (parentId: string) => (roomId) =>
+ isRoom(mx.getRoom(roomId)) &&
+ mDirects.has(roomId) &&
+ roomToParents.get(roomId)?.has(parentId),
+ [mx, mDirects, roomToParents]
);
- return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
+
+export const useRecursiveChildDirectScopeFactory = (
+ mx: MatrixClient,
+ mDirects: Set<string>,
+ roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+ useCallback(
+ (parentId: string) => (roomId) =>
+ isRoom(mx.getRoom(roomId)) &&
+ mDirects.has(roomId) &&
+ roomToParents.has(roomId) &&
+ getAllParents(roomToParents, roomId).has(parentId),
+ [mx, mDirects, roomToParents]
+ );
+
+export const useSpaceChildren = (
+ roomsAtom: RoomsAtom,
+ spaceId: string,
+ selectorFactory: SpaceChildSelectorFactory
+) => {
+ const recursiveChildRoomSelector = useMemo(
+ () => selectorFactory(spaceId),
+ [selectorFactory, spaceId]
+ );
+ return useSelectedRooms(roomsAtom, recursiveChildRoomSelector);
+};
+
+export const useSpaces = (mx: MatrixClient, roomsAtom: RoomsAtom) => {
+ const selector: RoomSelector = useCallback((roomId) => isSpace(mx.getRoom(roomId)), [mx]);
+ return useSelectedRooms(roomsAtom, selector);
};
-export const useRooms = (
+export const useOrphanSpaces = (
mx: MatrixClient,
- roomsAtom: typeof allRoomsAtom,
- directAtom: typeof mDirectAtom
+ roomsAtom: RoomsAtom,
+ roomToParents: RoomToParents
) => {
- const mDirects = useAtomValue(directAtom);
- const selector = useCallback(
- (rooms: string[]) =>
- rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)),
+ const selector: RoomSelector = useCallback(
+ (roomId) => isSpace(mx.getRoom(roomId)) && !roomToParents.has(roomId),
+ [mx, roomToParents]
+ );
+ return useSelectedRooms(roomsAtom, selector);
+};
+
+export const useRooms = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set<string>) => {
+ const selector: RoomSelector = useCallback(
+ (roomId: string) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId),
[mx, mDirects]
);
- return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
+ return useSelectedRooms(roomsAtom, selector);
};
-export const useDirects = (
+export const useOrphanRooms = (
mx: MatrixClient,
- roomsAtom: typeof allRoomsAtom,
- directAtom: typeof mDirectAtom
+ roomsAtom: RoomsAtom,
+ mDirects: Set<string>,
+ roomToParents: RoomToParents
) => {
- const mDirects = useAtomValue(directAtom);
- const selector = useCallback(
- (rooms: string[]) =>
- rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)),
+ const selector: RoomSelector = useCallback(
+ (roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId) && !roomToParents.has(roomId),
+ [mx, mDirects, roomToParents]
+ );
+ return useSelectedRooms(roomsAtom, selector);
+};
+
+export const useDirects = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set<string>) => {
+ const selector: RoomSelector = useCallback(
+ (roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId),
[mx, mDirects]
);
- return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
+ return useSelectedRooms(roomsAtom, selector);
};
-export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
- const selector = useCallback(
- (rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
+export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: RoomsAtom) => {
+ const selector: RoomSelector = useCallback(
+ (roomId) => isUnsupportedRoom(mx.getRoom(roomId)),
[mx]
);
- return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
+ return useSelectedRooms(roomsAtom, selector);
};
--- /dev/null
+import { useCallback } from 'react';
+import { useAtomValue } from 'jotai';
+import { selectAtom } from 'jotai/utils';
+import { RoomToUnread, Unread } from '../../../types/matrix/room';
+import { roomToUnreadAtom, unreadEqual } from '../room/roomToUnread';
+
+const compareUnreadEqual = (u1?: Unread, u2?: Unread): boolean => {
+ if (!u1 || !u2) return false;
+ return unreadEqual(u1, u2);
+};
+
+const getRoomsUnread = (rooms: string[], roomToUnread: RoomToUnread): Unread | undefined => {
+ const unread = rooms.reduce<Unread | undefined>((u, roomId) => {
+ const roomUnread = roomToUnread.get(roomId);
+ if (!roomUnread) return u;
+ const newUnread: Unread = u ?? {
+ total: 0,
+ highlight: 0,
+ from: new Set(),
+ };
+ newUnread.total += roomUnread.total;
+ newUnread.highlight += roomUnread.highlight;
+ newUnread.from?.add(roomId);
+ return newUnread;
+ }, undefined);
+ return unread;
+};
+
+export const useRoomsUnread = (
+ rooms: string[],
+ roomToUnreadAtm: typeof roomToUnreadAtom
+): Unread | undefined => {
+ const selector = useCallback(
+ (roomToUnread: RoomToUnread) => getRoomsUnread(rooms, roomToUnread),
+ [rooms]
+ );
+ return useAtomValue(selectAtom(roomToUnreadAtm, selector, compareUnreadEqual));
+};
+
+export const useRoomUnread = (
+ roomId: string,
+ roomToUnreadAtm: typeof roomToUnreadAtom
+): Unread | undefined => {
+ const selector = useCallback((roomToUnread: RoomToUnread) => roomToUnread.get(roomId), [roomId]);
+ return useAtomValue(selectAtom(roomToUnreadAtm, selector, compareUnreadEqual));
+};
import { MatrixClient } from 'matrix-js-sdk';
-import { allInvitesAtom, useBindAllInvitesAtom } from '../inviteList';
-import { allRoomsAtom, useBindAllRoomsAtom } from '../roomList';
+import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
+import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
-import { muteChangesAtom, mutedRoomsAtom, useBindMutedRoomsAtom } from '../mutedRoomList';
-import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../roomToUnread';
-import { roomToParentsAtom, useBindRoomToParentsAtom } from '../roomToParents';
+import { muteChangesAtom, mutedRoomsAtom, useBindMutedRoomsAtom } from '../room-list/mutedRoomList';
+import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
+import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
+import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
export const useBindAtoms = (mx: MatrixClient) => {
useBindMDirectAtom(mx, mDirectAtom);
useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindMutedRoomsAtom(mx, mutedRoomsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom, muteChangesAtom);
+
+ useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
};
+++ /dev/null
-import { atom, WritableAtom } from 'jotai';
-import { MatrixClient } from 'matrix-js-sdk';
-import { useMemo } from 'react';
-import { Membership } from '../../types/matrix/room';
-import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
-
-const baseRoomsAtom = atom<string[]>([]);
-export const allInvitesAtom = atom<string[], [RoomsAction], undefined>(
- (get) => get(baseRoomsAtom),
- (get, set, action) => {
- if (action.type === 'INITIALIZE') {
- set(baseRoomsAtom, action.rooms);
- return;
- }
- set(baseRoomsAtom, (ids) => {
- const newIds = ids.filter((id) => id !== action.roomId);
- if (action.type === 'PUT') newIds.push(action.roomId);
- return newIds;
- });
- }
-);
-
-export const useBindAllInvitesAtom = (
- mx: MatrixClient,
- allRooms: WritableAtom<string[], [RoomsAction], undefined>
-) => {
- useBindRoomsWithMembershipsAtom(
- mx,
- allRooms,
- useMemo(() => [Membership.Invite], [])
- );
-};
}
const handleAccountData = (event: MatrixEvent) => {
- setMDirect({
- type: 'UPDATE',
- rooms: getMDirects(event),
- });
+ if (event.getType() === AccountDataEvent.Direct) {
+ setMDirect({
+ type: 'UPDATE',
+ rooms: getMDirects(event),
+ });
+ }
};
mx.on(ClientEvent.AccountData, handleAccountData);
+++ /dev/null
-import { atom, useSetAtom } from 'jotai';
-import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
-import { useEffect } from 'react';
-import { MuteChanges } from '../../types/matrix/room';
-import { findMutedRule, isMutedRule } from '../utils/room';
-
-export type MutedRoomsUpdate =
- | {
- type: 'INITIALIZE';
- addRooms: string[];
- }
- | {
- type: 'UPDATE';
- addRooms: string[];
- removeRooms: string[];
- };
-
-export const muteChangesAtom = atom<MuteChanges>({
- added: [],
- removed: [],
-});
-
-const baseMutedRoomsAtom = atom(new Set<string>());
-export const mutedRoomsAtom = atom<Set<string>, [MutedRoomsUpdate], undefined>(
- (get) => get(baseMutedRoomsAtom),
- (get, set, action) => {
- const mutedRooms = new Set([...get(mutedRoomsAtom)]);
- if (action.type === 'INITIALIZE') {
- set(baseMutedRoomsAtom, new Set([...action.addRooms]));
- set(muteChangesAtom, {
- added: [...action.addRooms],
- removed: [],
- });
- return;
- }
- if (action.type === 'UPDATE') {
- action.removeRooms.forEach((roomId) => mutedRooms.delete(roomId));
- action.addRooms.forEach((roomId) => mutedRooms.add(roomId));
- set(baseMutedRoomsAtom, mutedRooms);
- set(muteChangesAtom, {
- added: [...action.addRooms],
- removed: [...action.removeRooms],
- });
- }
- }
-);
-
-export const useBindMutedRoomsAtom = (mx: MatrixClient, mutedAtom: typeof mutedRoomsAtom) => {
- const setMuted = useSetAtom(mutedAtom);
-
- useEffect(() => {
- const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
- ?.global?.override;
- if (overrideRules) {
- const mutedRooms = overrideRules.reduce<string[]>((rooms, rule) => {
- if (isMutedRule(rule)) rooms.push(rule.rule_id);
- return rooms;
- }, []);
- setMuted({
- type: 'INITIALIZE',
- addRooms: mutedRooms,
- });
- }
- }, [mx, setMuted]);
-
- useEffect(() => {
- const handlePushRules = (mEvent: MatrixEvent, oldMEvent?: MatrixEvent) => {
- if (mEvent.getType() === 'm.push_rules') {
- const override = mEvent?.getContent()?.global?.override as IPushRule[] | undefined;
- const oldOverride = oldMEvent?.getContent()?.global?.override as IPushRule[] | undefined;
- if (!override || !oldOverride) return;
-
- const isMuteToggled = (rule: IPushRule, otherOverride: IPushRule[]) => {
- const roomId = rule.rule_id;
-
- const isMuted = isMutedRule(rule);
- if (!isMuted) return false;
- const isOtherMuted = findMutedRule(otherOverride, roomId);
- if (isOtherMuted) return false;
- return true;
- };
-
- const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride));
- const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override));
-
- setMuted({
- type: 'UPDATE',
- addRooms: mutedRules.map((rule) => rule.rule_id),
- removeRooms: unMutedRules.map((rule) => rule.rule_id),
- });
- }
- };
- mx.on(ClientEvent.AccountData, handlePushRules);
- return () => {
- mx.removeListener(ClientEvent.AccountData, handlePushRules);
- };
- }, [mx, setMuted]);
-};
--- /dev/null
+import { WritableAtom, atom } from 'jotai';
+import produce from 'immer';
+import { Path } from 'react-router-dom';
+import {
+ atomWithLocalStorage,
+ getLocalStorageItem,
+ setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const NAV_TO_ACTIVE_PATH = 'navToActivePath';
+
+type NavToActivePath = Map<string, Path>;
+
+type NavToActivePathAction =
+ | {
+ type: 'PUT';
+ navId: string;
+ path: Path;
+ }
+ | {
+ type: 'DELETE';
+ navId: string;
+ };
+
+export type NavToActivePathAtom = WritableAtom<NavToActivePath, [NavToActivePathAction], undefined>;
+
+export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => {
+ const storeKey = `${NAV_TO_ACTIVE_PATH}${userId}`;
+
+ const baseNavToActivePathAtom = atomWithLocalStorage<NavToActivePath>(
+ storeKey,
+ (key) => {
+ const obj: Record<string, Path> = getLocalStorageItem(key, {});
+ return new Map(Object.entries(obj));
+ },
+ (key, value) => {
+ const obj: Record<string, Path> = Object.fromEntries(value);
+ setLocalStorageItem(key, obj);
+ }
+ );
+
+ const navToActivePathAtom = atom<NavToActivePath, [NavToActivePathAction], undefined>(
+ (get) => get(baseNavToActivePathAtom),
+ (get, set, action) => {
+ if (action.type === 'DELETE') {
+ set(
+ baseNavToActivePathAtom,
+ produce(get(baseNavToActivePathAtom), (draft) => {
+ draft.delete(action.navId);
+ })
+ );
+ return;
+ }
+ if (action.type === 'PUT') {
+ set(
+ baseNavToActivePathAtom,
+ produce(get(baseNavToActivePathAtom), (draft) => {
+ draft.set(action.navId, action.path);
+ })
+ );
+ }
+ }
+ );
+
+ return navToActivePathAtom;
+};
--- /dev/null
+import { WritableAtom, atom } from 'jotai';
+import produce from 'immer';
+import {
+ atomWithLocalStorage,
+ getLocalStorageItem,
+ setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const OPENED_SIDEBAR_FOLDER = 'openedSidebarFolder';
+
+type OpenedSidebarFolderAction =
+ | {
+ type: 'PUT';
+ id: string;
+ }
+ | {
+ type: 'DELETE';
+ id: string;
+ };
+
+export type OpenedSidebarFolderAtom = WritableAtom<
+ Set<string>,
+ [OpenedSidebarFolderAction],
+ undefined
+>;
+
+export const makeOpenedSidebarFolderAtom = (userId: string): OpenedSidebarFolderAtom => {
+ const storeKey = `${OPENED_SIDEBAR_FOLDER}${userId}`;
+
+ const baseOpenedSidebarFolderAtom = atomWithLocalStorage<Set<string>>(
+ storeKey,
+ (key) => {
+ const arrayValue = getLocalStorageItem<string[]>(key, []);
+ return new Set(arrayValue);
+ },
+ (key, value) => {
+ const arrayValue = Array.from(value);
+ setLocalStorageItem(key, arrayValue);
+ }
+ );
+
+ const openedSidebarFolderAtom = atom<Set<string>, [OpenedSidebarFolderAction], undefined>(
+ (get) => get(baseOpenedSidebarFolderAtom),
+ (get, set, action) => {
+ if (action.type === 'DELETE') {
+ set(
+ baseOpenedSidebarFolderAtom,
+ produce(get(baseOpenedSidebarFolderAtom), (draft) => {
+ draft.delete(action.id);
+ })
+ );
+ return;
+ }
+ if (action.type === 'PUT') {
+ set(
+ baseOpenedSidebarFolderAtom,
+ produce(get(baseOpenedSidebarFolderAtom), (draft) => {
+ draft.add(action.id);
+ })
+ );
+ }
+ }
+ );
+
+ return openedSidebarFolderAtom;
+};
--- /dev/null
+import { atom, WritableAtom } from 'jotai';
+import { MatrixClient } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+import { Membership } from '../../../types/matrix/room';
+import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
+
+const baseRoomsAtom = atom<string[]>([]);
+export const allInvitesAtom = atom<string[], [RoomsAction], undefined>(
+ (get) => get(baseRoomsAtom),
+ (get, set, action) => {
+ if (action.type === 'INITIALIZE') {
+ set(baseRoomsAtom, action.rooms);
+ return;
+ }
+ set(baseRoomsAtom, (ids) => {
+ const newIds = ids.filter((id) => id !== action.roomId);
+ if (action.type === 'PUT') newIds.push(action.roomId);
+ return newIds;
+ });
+ }
+);
+
+export const useBindAllInvitesAtom = (
+ mx: MatrixClient,
+ allRooms: WritableAtom<string[], [RoomsAction], undefined>
+) => {
+ useBindRoomsWithMembershipsAtom(
+ mx,
+ allRooms,
+ useMemo(() => [Membership.Invite], [])
+ );
+};
--- /dev/null
+import { atom, useSetAtom } from 'jotai';
+import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { MuteChanges } from '../../../types/matrix/room';
+import { findMutedRule, isMutedRule } from '../../utils/room';
+
+export type MutedRoomsUpdate =
+ | {
+ type: 'INITIALIZE';
+ addRooms: string[];
+ }
+ | {
+ type: 'UPDATE';
+ addRooms: string[];
+ removeRooms: string[];
+ };
+
+export const muteChangesAtom = atom<MuteChanges>({
+ added: [],
+ removed: [],
+});
+
+const baseMutedRoomsAtom = atom(new Set<string>());
+export const mutedRoomsAtom = atom<Set<string>, [MutedRoomsUpdate], undefined>(
+ (get) => get(baseMutedRoomsAtom),
+ (get, set, action) => {
+ const mutedRooms = new Set([...get(mutedRoomsAtom)]);
+ if (action.type === 'INITIALIZE') {
+ set(baseMutedRoomsAtom, new Set([...action.addRooms]));
+ set(muteChangesAtom, {
+ added: [...action.addRooms],
+ removed: [],
+ });
+ return;
+ }
+ if (action.type === 'UPDATE') {
+ action.removeRooms.forEach((roomId) => mutedRooms.delete(roomId));
+ action.addRooms.forEach((roomId) => mutedRooms.add(roomId));
+ set(baseMutedRoomsAtom, mutedRooms);
+ set(muteChangesAtom, {
+ added: [...action.addRooms],
+ removed: [...action.removeRooms],
+ });
+ }
+ }
+);
+
+export const useBindMutedRoomsAtom = (mx: MatrixClient, mutedAtom: typeof mutedRoomsAtom) => {
+ const setMuted = useSetAtom(mutedAtom);
+
+ useEffect(() => {
+ const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
+ ?.global?.override;
+ if (overrideRules) {
+ const mutedRooms = overrideRules.reduce<string[]>((rooms, rule) => {
+ if (isMutedRule(rule)) rooms.push(rule.rule_id);
+ return rooms;
+ }, []);
+ setMuted({
+ type: 'INITIALIZE',
+ addRooms: mutedRooms,
+ });
+ }
+ }, [mx, setMuted]);
+
+ useEffect(() => {
+ const handlePushRules = (mEvent: MatrixEvent, oldMEvent?: MatrixEvent) => {
+ if (mEvent.getType() === 'm.push_rules') {
+ const override = mEvent?.getContent()?.global?.override as IPushRule[] | undefined;
+ const oldOverride = oldMEvent?.getContent()?.global?.override as IPushRule[] | undefined;
+ if (!override || !oldOverride) return;
+
+ const isMuteToggled = (rule: IPushRule, otherOverride: IPushRule[]) => {
+ const roomId = rule.rule_id;
+
+ const isMuted = isMutedRule(rule);
+ if (!isMuted) return false;
+ const isOtherMuted = findMutedRule(otherOverride, roomId);
+ if (isOtherMuted) return false;
+ return true;
+ };
+
+ const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride));
+ const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override));
+
+ setMuted({
+ type: 'UPDATE',
+ addRooms: mutedRules.map((rule) => rule.rule_id),
+ removeRooms: unMutedRules.map((rule) => rule.rule_id),
+ });
+ }
+ };
+ mx.on(ClientEvent.AccountData, handlePushRules);
+ return () => {
+ mx.removeListener(ClientEvent.AccountData, handlePushRules);
+ };
+ }, [mx, setMuted]);
+};
--- /dev/null
+import { atom } from 'jotai';
+import { MatrixClient } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+import { Membership } from '../../../types/matrix/room';
+import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
+
+const baseRoomsAtom = atom<string[]>([]);
+export const allRoomsAtom = atom<string[], [RoomsAction], undefined>(
+ (get) => get(baseRoomsAtom),
+ (get, set, action) => {
+ if (action.type === 'INITIALIZE') {
+ set(baseRoomsAtom, action.rooms);
+ return;
+ }
+ set(baseRoomsAtom, (ids) => {
+ const newIds = ids.filter((id) => id !== action.roomId);
+ if (action.type === 'PUT') newIds.push(action.roomId);
+ return newIds;
+ });
+ }
+);
+export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
+ useBindRoomsWithMembershipsAtom(
+ mx,
+ allRooms,
+ useMemo(() => [Membership.Join], [])
+ );
+};
--- /dev/null
+import { useSetAtom, WritableAtom } from 'jotai';
+import { ClientEvent, MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { Membership } from '../../../types/matrix/room';
+
+export type RoomsAction =
+ | {
+ type: 'INITIALIZE';
+ rooms: string[];
+ }
+ | {
+ type: 'PUT' | 'DELETE';
+ roomId: string;
+ };
+
+export const useBindRoomsWithMembershipsAtom = (
+ mx: MatrixClient,
+ roomsAtom: WritableAtom<string[], [RoomsAction], undefined>,
+ memberships: Membership[]
+) => {
+ const setRoomsAtom = useSetAtom(roomsAtom);
+
+ useEffect(() => {
+ const satisfyMembership = (room: Room): boolean =>
+ !!memberships.find((membership) => membership === room.getMyMembership());
+ setRoomsAtom({
+ type: 'INITIALIZE',
+ rooms: mx
+ .getRooms()
+ .filter(satisfyMembership)
+ .map((room) => room.roomId),
+ });
+
+ const handleAddRoom = (room: Room) => {
+ if (satisfyMembership(room)) {
+ setRoomsAtom({ type: 'PUT', roomId: room.roomId });
+ }
+ };
+
+ const handleMembershipChange = (room: Room) => {
+ if (satisfyMembership(room)) {
+ setRoomsAtom({ type: 'PUT', roomId: room.roomId });
+ } else {
+ setRoomsAtom({ type: 'DELETE', roomId: room.roomId });
+ }
+ };
+
+ const handleDeleteRoom = (roomId: string) => {
+ setRoomsAtom({ type: 'DELETE', roomId });
+ };
+
+ mx.on(ClientEvent.Room, handleAddRoom);
+ mx.on(RoomEvent.MyMembership, handleMembershipChange);
+ mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
+ return () => {
+ mx.removeListener(ClientEvent.Room, handleAddRoom);
+ mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
+ mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
+ };
+ }, [mx, memberships, setRoomsAtom]);
+};
+
+export const compareRoomsEqual = (a: string[], b: string[]) => {
+ if (a.length !== b.length) return false;
+ return a.every((roomId, roomIdIndex) => roomId === b[roomIdIndex]);
+};
--- /dev/null
+import { atom } from 'jotai';
+import { atomFamily } from 'jotai/utils';
+import { Descendant } from 'slate';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { TListAtom, createListAtom } from '../list';
+import { createUploadAtomFamily } from '../upload';
+import { TUploadContent } from '../../utils/matrix';
+
+export const roomUploadAtomFamily = createUploadAtomFamily();
+
+export type TUploadItem = {
+ file: TUploadContent;
+ originalFile: TUploadContent;
+ encInfo: EncryptedAttachmentInfo | undefined;
+};
+
+export const roomIdToUploadItemsAtomFamily = atomFamily<string, TListAtom<TUploadItem>>(
+ createListAtom
+);
+
+export type RoomIdToMsgAction =
+ | {
+ type: 'PUT';
+ roomId: string;
+ msg: Descendant[];
+ }
+ | {
+ type: 'DELETE';
+ roomId: string;
+ };
+
+const createMsgDraftAtom = () => atom<Descendant[]>([]);
+export type TMsgDraftAtom = ReturnType<typeof createMsgDraftAtom>;
+export const roomIdToMsgDraftAtomFamily = atomFamily<string, TMsgDraftAtom>(() =>
+ createMsgDraftAtom()
+);
+
+export type IReplyDraft = {
+ userId: string;
+ eventId: string;
+ body: string;
+ formattedBody?: string;
+};
+const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
+export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
+export const roomIdToReplyDraftAtomFamily = atomFamily<string, TReplyDraftAtom>(() =>
+ createReplyDraftAtom()
+);
--- /dev/null
+import produce from 'immer';
+import { atom, useSetAtom } from 'jotai';
+import {
+ ClientEvent,
+ MatrixClient,
+ MatrixEvent,
+ Room,
+ RoomEvent,
+ RoomStateEvent,
+} from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { Membership, RoomToParents, StateEvent } from '../../../types/matrix/room';
+import {
+ getRoomToParents,
+ getSpaceChildren,
+ isSpace,
+ isValidChild,
+ mapParentWithChildren,
+} from '../../utils/room';
+
+export type RoomToParentsAction =
+ | {
+ type: 'INITIALIZE';
+ roomToParents: RoomToParents;
+ }
+ | {
+ type: 'PUT';
+ parent: string;
+ children: string[];
+ }
+ | {
+ type: 'DELETE';
+ roomId: string;
+ };
+
+const baseRoomToParents = atom<RoomToParents>(new Map());
+export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
+ (get) => get(baseRoomToParents),
+ (get, set, action) => {
+ if (action.type === 'INITIALIZE') {
+ set(baseRoomToParents, action.roomToParents);
+ return;
+ }
+ if (action.type === 'PUT') {
+ set(
+ baseRoomToParents,
+ produce(get(baseRoomToParents), (draftRoomToParents) => {
+ mapParentWithChildren(draftRoomToParents, action.parent, action.children);
+ })
+ );
+ return;
+ }
+ if (action.type === 'DELETE') {
+ set(
+ baseRoomToParents,
+ produce(get(baseRoomToParents), (draftRoomToParents) => {
+ const noParentRooms: string[] = [];
+ draftRoomToParents.delete(action.roomId);
+ draftRoomToParents.forEach((parents, child) => {
+ parents.delete(action.roomId);
+ if (parents.size === 0) noParentRooms.push(child);
+ });
+ noParentRooms.forEach((room) => draftRoomToParents.delete(room));
+ })
+ );
+ }
+ }
+);
+
+export const useBindRoomToParentsAtom = (
+ mx: MatrixClient,
+ roomToParents: typeof roomToParentsAtom
+) => {
+ const setRoomToParents = useSetAtom(roomToParents);
+
+ useEffect(() => {
+ setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) });
+
+ const handleAddRoom = (room: Room) => {
+ if (isSpace(room) && room.getMyMembership() !== Membership.Invite) {
+ setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
+ }
+ };
+
+ const handleMembershipChange = (room: Room, membership: string) => {
+ if (room.getMyMembership() === Membership.Leave) {
+ setRoomToParents({ type: 'DELETE', roomId: room.roomId });
+ return;
+ }
+ if (isSpace(room) && membership === Membership.Join) {
+ setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
+ }
+ };
+
+ const handleStateChange = (mEvent: MatrixEvent) => {
+ if (mEvent.getType() === StateEvent.SpaceChild) {
+ const childId = mEvent.getStateKey();
+ const roomId = mEvent.getRoomId();
+ if (childId && roomId) {
+ if (isValidChild(mEvent)) {
+ setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
+ } else {
+ setRoomToParents({ type: 'DELETE', roomId: childId });
+ }
+ }
+ }
+ };
+
+ const handleDeleteRoom = (roomId: string) => {
+ setRoomToParents({ type: 'DELETE', roomId });
+ };
+
+ mx.on(ClientEvent.Room, handleAddRoom);
+ mx.on(RoomEvent.MyMembership, handleMembershipChange);
+ mx.on(RoomStateEvent.Events, handleStateChange);
+ mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
+ return () => {
+ mx.removeListener(ClientEvent.Room, handleAddRoom);
+ mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
+ mx.removeListener(RoomStateEvent.Events, handleStateChange);
+ mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
+ };
+ }, [mx, setRoomToParents]);
+};
--- /dev/null
+import produce from 'immer';
+import { atom, useSetAtom, PrimitiveAtom, useAtomValue } from 'jotai';
+import {
+ IRoomTimelineData,
+ MatrixClient,
+ MatrixEvent,
+ Room,
+ RoomEvent,
+ SyncState,
+} from 'matrix-js-sdk';
+import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
+import { useCallback, useEffect } from 'react';
+import {
+ MuteChanges,
+ Membership,
+ NotificationType,
+ RoomToUnread,
+ UnreadInfo,
+ Unread,
+ StateEvent,
+} from '../../../types/matrix/room';
+import {
+ getAllParents,
+ getNotificationType,
+ getUnreadInfo,
+ getUnreadInfos,
+ isNotificationEvent,
+ roomHaveUnread,
+} from '../../utils/room';
+import { roomToParentsAtom } from './roomToParents';
+import { useStateEventCallback } from '../../hooks/useStateEventCallback';
+import { useSyncState } from '../../hooks/useSyncState';
+
+export type RoomToUnreadAction =
+ | {
+ type: 'RESET';
+ unreadInfos: UnreadInfo[];
+ }
+ | {
+ type: 'PUT';
+ unreadInfo: UnreadInfo;
+ }
+ | {
+ type: 'DELETE';
+ roomId: string;
+ };
+
+const unreadInfoToUnread = (unreadInfo: UnreadInfo): Unread => ({
+ highlight: unreadInfo.highlight,
+ total: unreadInfo.total,
+ from: null,
+});
+
+const putUnreadInfo = (
+ roomToUnread: RoomToUnread,
+ allParents: Set<string>,
+ unreadInfo: UnreadInfo
+) => {
+ const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null };
+ roomToUnread.set(unreadInfo.roomId, unreadInfoToUnread(unreadInfo));
+
+ const newH = unreadInfo.highlight - oldUnread.highlight;
+ const newT = unreadInfo.total - oldUnread.total;
+
+ allParents.forEach((parentId) => {
+ const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null };
+ roomToUnread.set(parentId, {
+ highlight: (oldParentUnread.highlight += newH),
+ total: (oldParentUnread.total += newT),
+ from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]),
+ });
+ });
+};
+
+const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, roomId: string) => {
+ const oldUnread = roomToUnread.get(roomId);
+ if (!oldUnread) return;
+ roomToUnread.delete(roomId);
+
+ allParents.forEach((parentId) => {
+ const oldParentUnread = roomToUnread.get(parentId);
+ if (!oldParentUnread) return;
+ const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
+ newFrom.delete(roomId);
+ if (newFrom.size === 0) {
+ roomToUnread.delete(parentId);
+ return;
+ }
+ roomToUnread.set(parentId, {
+ highlight: oldParentUnread.highlight - oldUnread.highlight,
+ total: oldParentUnread.total - oldUnread.total,
+ from: newFrom,
+ });
+ });
+};
+
+export const unreadEqual = (u1: Unread, u2: Unread): boolean => {
+ const countEqual = u1.highlight === u2.highlight && u1.total === u2.total;
+
+ if (!countEqual) return false;
+
+ const f1 = u1.from;
+ const f2 = u2.from;
+ if (f1 === null && f2 === null) return true;
+ if (f1 === null || f2 === null) return false;
+
+ if (f1.size !== f2.size) return false;
+
+ let fromEqual = true;
+ f1?.forEach((item) => {
+ if (!f2?.has(item)) {
+ fromEqual = false;
+ }
+ });
+
+ return fromEqual;
+};
+
+const baseRoomToUnread = atom<RoomToUnread>(new Map());
+export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
+ (get) => get(baseRoomToUnread),
+ (get, set, action) => {
+ if (action.type === 'RESET') {
+ const draftRoomToUnread: RoomToUnread = new Map();
+ action.unreadInfos.forEach((unreadInfo) => {
+ putUnreadInfo(
+ draftRoomToUnread,
+ getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
+ unreadInfo
+ );
+ });
+ set(baseRoomToUnread, draftRoomToUnread);
+ return;
+ }
+ if (action.type === 'PUT') {
+ const { unreadInfo } = action;
+ const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
+ if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
+ // Do not update if unread data has not changes
+ // like total & highlight
+ return;
+ }
+ set(
+ baseRoomToUnread,
+ produce(get(baseRoomToUnread), (draftRoomToUnread) =>
+ putUnreadInfo(
+ draftRoomToUnread,
+ getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
+ unreadInfo
+ )
+ )
+ );
+ return;
+ }
+ if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) {
+ set(
+ baseRoomToUnread,
+ produce(get(baseRoomToUnread), (draftRoomToUnread) =>
+ deleteUnreadInfo(
+ draftRoomToUnread,
+ getAllParents(get(roomToParentsAtom), action.roomId),
+ action.roomId
+ )
+ )
+ );
+ }
+ }
+);
+
+export const useBindRoomToUnreadAtom = (
+ mx: MatrixClient,
+ unreadAtom: typeof roomToUnreadAtom,
+ muteChangesAtom: PrimitiveAtom<MuteChanges>
+) => {
+ const setUnreadAtom = useSetAtom(unreadAtom);
+ const muteChanges = useAtomValue(muteChangesAtom);
+
+ useEffect(() => {
+ setUnreadAtom({
+ type: 'RESET',
+ unreadInfos: getUnreadInfos(mx),
+ });
+ }, [mx, setUnreadAtom]);
+
+ useSyncState(
+ mx,
+ useCallback(
+ (state) => {
+ if (state === SyncState.Prepared) {
+ setUnreadAtom({
+ type: 'RESET',
+ unreadInfos: getUnreadInfos(mx),
+ });
+ }
+ },
+ [mx, setUnreadAtom]
+ )
+ );
+
+ useEffect(() => {
+ const handleTimelineEvent = (
+ mEvent: MatrixEvent,
+ room: Room | undefined,
+ toStartOfTimeline: boolean | undefined,
+ removed: boolean,
+ data: IRoomTimelineData
+ ) => {
+ if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
+ if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
+ setUnreadAtom({
+ type: 'DELETE',
+ roomId: room.roomId,
+ });
+ return;
+ }
+
+ if (mEvent.getSender() === mx.getUserId()) return;
+ setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
+ };
+ mx.on(RoomEvent.Timeline, handleTimelineEvent);
+ return () => {
+ mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+ };
+ }, [mx, setUnreadAtom]);
+
+ useEffect(() => {
+ const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
+ if (mEvent.getType() === 'm.receipt') {
+ const myUserId = mx.getUserId();
+ if (!myUserId) return;
+ if (room.isSpaceRoom()) return;
+ const content = mEvent.getContent<ReceiptContent>();
+
+ const isMyReceipt = Object.keys(content).find((eventId) =>
+ (Object.keys(content[eventId]) as ReceiptType[]).find(
+ (receiptType) => content[eventId][receiptType][myUserId]
+ )
+ );
+ if (isMyReceipt) {
+ setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
+ }
+ }
+ };
+ mx.on(RoomEvent.Receipt, handleReceipt);
+ return () => {
+ mx.removeListener(RoomEvent.Receipt, handleReceipt);
+ };
+ }, [mx, setUnreadAtom]);
+
+ useEffect(() => {
+ muteChanges.removed.forEach((roomId) => {
+ const room = mx.getRoom(roomId);
+ if (!room) return;
+ if (!roomHaveUnread(mx, room)) return;
+ setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
+ });
+ muteChanges.added.forEach((roomId) => {
+ setUnreadAtom({ type: 'DELETE', roomId });
+ });
+ }, [mx, setUnreadAtom, muteChanges]);
+
+ useEffect(() => {
+ const handleMembershipChange = (room: Room, membership: string) => {
+ if (membership !== Membership.Join) {
+ setUnreadAtom({
+ type: 'DELETE',
+ roomId: room.roomId,
+ });
+ }
+ };
+ mx.on(RoomEvent.MyMembership, handleMembershipChange);
+ return () => {
+ mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
+ };
+ }, [mx, setUnreadAtom]);
+
+ useStateEventCallback(
+ mx,
+ useCallback(
+ (mEvent) => {
+ if (mEvent.getType() === StateEvent.SpaceChild) {
+ setUnreadAtom({
+ type: 'RESET',
+ unreadInfos: getUnreadInfos(mx),
+ });
+ }
+ },
+ [mx, setUnreadAtom]
+ )
+ );
+};
+++ /dev/null
-import { atom } from 'jotai';
-import { atomFamily } from 'jotai/utils';
-import { Descendant } from 'slate';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { TListAtom, createListAtom } from './list';
-import { createUploadAtomFamily } from './upload';
-import { TUploadContent } from '../utils/matrix';
-
-export const roomUploadAtomFamily = createUploadAtomFamily();
-
-export type TUploadItem = {
- file: TUploadContent;
- originalFile: TUploadContent;
- encInfo: EncryptedAttachmentInfo | undefined;
-};
-
-export const roomIdToUploadItemsAtomFamily = atomFamily<string, TListAtom<TUploadItem>>(
- createListAtom
-);
-
-export type RoomIdToMsgAction =
- | {
- type: 'PUT';
- roomId: string;
- msg: Descendant[];
- }
- | {
- type: 'DELETE';
- roomId: string;
- };
-
-const createMsgDraftAtom = () => atom<Descendant[]>([]);
-export type TMsgDraftAtom = ReturnType<typeof createMsgDraftAtom>;
-export const roomIdToMsgDraftAtomFamily = atomFamily<string, TMsgDraftAtom>(() =>
- createMsgDraftAtom()
-);
-
-export type IReplyDraft = {
- userId: string;
- eventId: string;
- body: string;
- formattedBody?: string;
-};
-const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
-export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
-export const roomIdToReplyDraftAtomFamily = atomFamily<string, TReplyDraftAtom>(() =>
- createReplyDraftAtom()
-);
+++ /dev/null
-import { atom } from 'jotai';
-import { MatrixClient } from 'matrix-js-sdk';
-import { useMemo } from 'react';
-import { Membership } from '../../types/matrix/room';
-import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
-
-const baseRoomsAtom = atom<string[]>([]);
-export const allRoomsAtom = atom<string[], [RoomsAction], undefined>(
- (get) => get(baseRoomsAtom),
- (get, set, action) => {
- if (action.type === 'INITIALIZE') {
- set(baseRoomsAtom, action.rooms);
- return;
- }
- set(baseRoomsAtom, (ids) => {
- const newIds = ids.filter((id) => id !== action.roomId);
- if (action.type === 'PUT') newIds.push(action.roomId);
- return newIds;
- });
- }
-);
-export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
- useBindRoomsWithMembershipsAtom(
- mx,
- allRooms,
- useMemo(() => [Membership.Join], [])
- );
-};
+++ /dev/null
-import produce from 'immer';
-import { atom, useSetAtom } from 'jotai';
-import {
- ClientEvent,
- MatrixClient,
- MatrixEvent,
- Room,
- RoomEvent,
- RoomStateEvent,
-} from 'matrix-js-sdk';
-import { useEffect } from 'react';
-import { Membership, RoomToParents, StateEvent } from '../../types/matrix/room';
-import {
- getRoomToParents,
- getSpaceChildren,
- isSpace,
- isValidChild,
- mapParentWithChildren,
-} from '../utils/room';
-
-export type RoomToParentsAction =
- | {
- type: 'INITIALIZE';
- roomToParents: RoomToParents;
- }
- | {
- type: 'PUT';
- parent: string;
- children: string[];
- }
- | {
- type: 'DELETE';
- roomId: string;
- };
-
-const baseRoomToParents = atom<RoomToParents>(new Map());
-export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
- (get) => get(baseRoomToParents),
- (get, set, action) => {
- if (action.type === 'INITIALIZE') {
- set(baseRoomToParents, action.roomToParents);
- return;
- }
- if (action.type === 'PUT') {
- set(
- baseRoomToParents,
- produce(get(baseRoomToParents), (draftRoomToParents) => {
- mapParentWithChildren(draftRoomToParents, action.parent, action.children);
- })
- );
- return;
- }
- if (action.type === 'DELETE') {
- set(
- baseRoomToParents,
- produce(get(baseRoomToParents), (draftRoomToParents) => {
- const noParentRooms: string[] = [];
- draftRoomToParents.delete(action.roomId);
- draftRoomToParents.forEach((parents, child) => {
- parents.delete(action.roomId);
- if (parents.size === 0) noParentRooms.push(child);
- });
- noParentRooms.forEach((room) => draftRoomToParents.delete(room));
- })
- );
- }
- }
-);
-
-export const useBindRoomToParentsAtom = (
- mx: MatrixClient,
- roomToParents: typeof roomToParentsAtom
-) => {
- const setRoomToParents = useSetAtom(roomToParents);
-
- useEffect(() => {
- setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) });
-
- const handleAddRoom = (room: Room) => {
- if (isSpace(room) && room.getMyMembership() !== Membership.Invite) {
- setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
- }
- };
-
- const handleMembershipChange = (room: Room, membership: string) => {
- if (isSpace(room) && membership === Membership.Join) {
- setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
- }
- };
-
- const handleStateChange = (mEvent: MatrixEvent) => {
- if (mEvent.getType() === StateEvent.SpaceChild) {
- const childId = mEvent.getStateKey();
- const roomId = mEvent.getRoomId();
- if (childId && roomId) {
- if (isValidChild(mEvent)) {
- setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
- } else {
- setRoomToParents({ type: 'DELETE', roomId: childId });
- }
- }
- }
- };
-
- const handleDeleteRoom = (roomId: string) => {
- setRoomToParents({ type: 'DELETE', roomId });
- };
-
- mx.on(ClientEvent.Room, handleAddRoom);
- mx.on(RoomEvent.MyMembership, handleMembershipChange);
- mx.on(RoomStateEvent.Events, handleStateChange);
- mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
- return () => {
- mx.removeListener(ClientEvent.Room, handleAddRoom);
- mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
- mx.removeListener(RoomStateEvent.Events, handleStateChange);
- mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
- };
- }, [mx, setRoomToParents]);
-};
+++ /dev/null
-import produce from 'immer';
-import { atom, useSetAtom, PrimitiveAtom, useAtomValue } from 'jotai';
-import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
-import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
-import { useEffect } from 'react';
-import {
- MuteChanges,
- Membership,
- NotificationType,
- RoomToUnread,
- UnreadInfo,
-} from '../../types/matrix/room';
-import {
- getAllParents,
- getNotificationType,
- getUnreadInfo,
- getUnreadInfos,
- isNotificationEvent,
- roomHaveUnread,
-} from '../utils/room';
-import { roomToParentsAtom } from './roomToParents';
-
-export type RoomToUnreadAction =
- | {
- type: 'RESET';
- unreadInfos: UnreadInfo[];
- }
- | {
- type: 'PUT';
- unreadInfo: UnreadInfo;
- }
- | {
- type: 'DELETE';
- roomId: string;
- };
-
-const putUnreadInfo = (
- roomToUnread: RoomToUnread,
- allParents: Set<string>,
- unreadInfo: UnreadInfo
-) => {
- const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null };
- roomToUnread.set(unreadInfo.roomId, {
- highlight: unreadInfo.highlight,
- total: unreadInfo.total,
- from: null,
- });
-
- const newH = unreadInfo.highlight - oldUnread.highlight;
- const newT = unreadInfo.total - oldUnread.total;
-
- allParents.forEach((parentId) => {
- const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null };
- roomToUnread.set(parentId, {
- highlight: (oldParentUnread.highlight += newH),
- total: (oldParentUnread.total += newT),
- from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]),
- });
- });
-};
-
-const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, roomId: string) => {
- const oldUnread = roomToUnread.get(roomId);
- if (!oldUnread) return;
- roomToUnread.delete(roomId);
-
- allParents.forEach((parentId) => {
- const oldParentUnread = roomToUnread.get(parentId);
- if (!oldParentUnread) return;
- const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
- newFrom.delete(roomId);
- if (newFrom.size === 0) {
- roomToUnread.delete(parentId);
- return;
- }
- roomToUnread.set(parentId, {
- highlight: oldParentUnread.highlight - oldUnread.highlight,
- total: oldParentUnread.total - oldUnread.total,
- from: newFrom,
- });
- });
-};
-
-const baseRoomToUnread = atom<RoomToUnread>(new Map());
-export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
- (get) => get(baseRoomToUnread),
- (get, set, action) => {
- if (action.type === 'RESET') {
- const draftRoomToUnread: RoomToUnread = new Map();
- action.unreadInfos.forEach((unreadInfo) => {
- putUnreadInfo(
- draftRoomToUnread,
- getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
- unreadInfo
- );
- });
- set(baseRoomToUnread, draftRoomToUnread);
- return;
- }
- if (action.type === 'PUT') {
- set(
- baseRoomToUnread,
- produce(get(baseRoomToUnread), (draftRoomToUnread) =>
- putUnreadInfo(
- draftRoomToUnread,
- getAllParents(get(roomToParentsAtom), action.unreadInfo.roomId),
- action.unreadInfo
- )
- )
- );
- return;
- }
- if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) {
- set(
- baseRoomToUnread,
- produce(get(baseRoomToUnread), (draftRoomToUnread) =>
- deleteUnreadInfo(
- draftRoomToUnread,
- getAllParents(get(roomToParentsAtom), action.roomId),
- action.roomId
- )
- )
- );
- }
- }
-);
-
-export const useBindRoomToUnreadAtom = (
- mx: MatrixClient,
- unreadAtom: typeof roomToUnreadAtom,
- muteChangesAtom: PrimitiveAtom<MuteChanges>
-) => {
- const setUnreadAtom = useSetAtom(unreadAtom);
- const muteChanges = useAtomValue(muteChangesAtom);
-
- useEffect(() => {
- setUnreadAtom({
- type: 'RESET',
- unreadInfos: getUnreadInfos(mx),
- });
- }, [mx, setUnreadAtom]);
-
- useEffect(() => {
- const handleTimelineEvent = (
- mEvent: MatrixEvent,
- room: Room | undefined,
- toStartOfTimeline: boolean | undefined,
- removed: boolean,
- data: IRoomTimelineData
- ) => {
- if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
- if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
- setUnreadAtom({
- type: 'DELETE',
- roomId: room.roomId,
- });
- return;
- }
-
- if (mEvent.getSender() === mx.getUserId()) return;
- setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
- };
- mx.on(RoomEvent.Timeline, handleTimelineEvent);
- return () => {
- mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
- };
- }, [mx, setUnreadAtom]);
-
- useEffect(() => {
- const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
- if (mEvent.getType() === 'm.receipt') {
- const myUserId = mx.getUserId();
- if (!myUserId) return;
- if (room.isSpaceRoom()) return;
- const content = mEvent.getContent<ReceiptContent>();
-
- const isMyReceipt = Object.keys(content).find((eventId) =>
- (Object.keys(content[eventId]) as ReceiptType[]).find(
- (receiptType) => content[eventId][receiptType][myUserId]
- )
- );
- if (isMyReceipt) {
- setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
- }
- }
- };
- mx.on(RoomEvent.Receipt, handleReceipt);
- return () => {
- mx.removeListener(RoomEvent.Receipt, handleReceipt);
- };
- }, [mx, setUnreadAtom]);
-
- useEffect(() => {
- muteChanges.removed.forEach((roomId) => {
- const room = mx.getRoom(roomId);
- if (!room) return;
- if (!roomHaveUnread(mx, room)) return;
- setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
- });
- muteChanges.added.forEach((roomId) => {
- setUnreadAtom({ type: 'DELETE', roomId });
- });
- }, [mx, setUnreadAtom, muteChanges]);
-
- useEffect(() => {
- const handleMembershipChange = (room: Room, membership: string) => {
- if (membership !== Membership.Join) {
- setUnreadAtom({
- type: 'DELETE',
- roomId: room.roomId,
- });
- }
- };
- mx.on(RoomEvent.MyMembership, handleMembershipChange);
- return () => {
- mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
- };
- }, [mx, setUnreadAtom]);
-};
+++ /dev/null
-import { atom } from 'jotai';
-
-export const selectedRoomAtom = atom<string | undefined>(undefined);
+++ /dev/null
-import { atom } from 'jotai';
-
-export enum SidebarTab {
- Home = 'Home',
- People = 'People',
-}
-
-export const selectedTabAtom = atom<SidebarTab | string>(SidebarTab.Home);
--- /dev/null
+import { atom } from 'jotai';
+import produce from 'immer';
+import {
+ atomWithLocalStorage,
+ getLocalStorageItem,
+ setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const SPACE_ROOMS = 'spaceRooms';
+
+const baseSpaceRoomsAtom = atomWithLocalStorage<Set<string>>(
+ SPACE_ROOMS,
+ (key) => {
+ const arrayValue = getLocalStorageItem<string[]>(key, []);
+ return new Set(arrayValue);
+ },
+ (key, value) => {
+ const arrayValue = Array.from(value);
+ setLocalStorageItem(key, arrayValue);
+ }
+);
+
+type SpaceRoomsAction =
+ | {
+ type: 'PUT';
+ roomId: string;
+ }
+ | {
+ type: 'DELETE';
+ roomId: string;
+ };
+
+export const spaceRoomsAtom = atom<Set<string>, [SpaceRoomsAction], undefined>(
+ (get) => get(baseSpaceRoomsAtom),
+ (get, set, action) => {
+ if (action.type === 'DELETE') {
+ set(
+ baseSpaceRoomsAtom,
+ produce(get(baseSpaceRoomsAtom), (draft) => {
+ draft.delete(action.roomId);
+ })
+ );
+ return;
+ }
+ if (action.type === 'PUT') {
+ set(
+ baseSpaceRoomsAtom,
+ produce(get(baseSpaceRoomsAtom), (draft) => {
+ draft.add(action.roomId);
+ })
+ );
+ }
+ }
+);
+++ /dev/null
-import produce from 'immer';
-import { atom } from 'jotai';
-import { MatrixClient } from 'matrix-js-sdk';
-
-type RoomInfo = {
- roomId: string;
- timestamp: number;
-};
-type TabToRoom = Map<string, RoomInfo>;
-
-type TabToRoomAction = {
- type: 'PUT';
- tabInfo: { tabId: string; roomInfo: RoomInfo };
-};
-
-const baseTabToRoom = atom<TabToRoom>(new Map());
-export const tabToRoomAtom = atom<TabToRoom, [TabToRoomAction], undefined>(
- (get) => get(baseTabToRoom),
- (get, set, action) => {
- if (action.type === 'PUT') {
- set(
- baseTabToRoom,
- produce(get(baseTabToRoom), (draft) => {
- draft.set(action.tabInfo.tabId, action.tabInfo.roomInfo);
- })
- );
- }
- }
-);
-
-export const useBindTabToRoomAtom = (mx: MatrixClient) => {
- console.log(mx);
- // TODO:
-};
+import produce from 'immer';
import { atom, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
-import {
- MatrixClient,
- RoomMember,
- RoomMemberEvent,
- RoomMemberEventHandlerMap,
-} from 'matrix-js-sdk';
+import { MatrixClient, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk';
import { useEffect } from 'react';
-export type IRoomIdToTypingMembers = Map<string, RoomMember[]>;
+export const TYPING_TIMEOUT_MS = 5000; // 5 seconds
-export type IRoomIdToTypingMembersAction =
- | {
- type: 'PUT';
- roomId: string;
- member: RoomMember;
- }
- | {
- type: 'DELETE';
- roomId: string;
- member: RoomMember;
- };
+export type TypingReceipt = {
+ userId: string;
+ ts: number;
+};
+export type IRoomIdToTypingMembers = Map<string, TypingReceipt[]>;
+
+type TypingMemberPutAction = {
+ type: 'PUT';
+ roomId: string;
+ userId: string;
+ ts: number;
+};
+type TypingMemberDeleteAction = {
+ type: 'DELETE';
+ roomId: string;
+ userId: string;
+};
+export type IRoomIdToTypingMembersAction = TypingMemberPutAction | TypingMemberDeleteAction;
const baseRoomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers>(new Map());
+
+const putTypingMember = (
+ roomToMembers: IRoomIdToTypingMembers,
+ action: TypingMemberPutAction
+): IRoomIdToTypingMembers => {
+ let typingMembers = roomToMembers.get(action.roomId) ?? [];
+
+ typingMembers = typingMembers.filter((receipt) => receipt.userId !== action.userId);
+ typingMembers.push({
+ userId: action.userId,
+ ts: action.ts,
+ });
+ roomToMembers.set(action.roomId, typingMembers);
+ return roomToMembers;
+};
+
+const deleteTypingMember = (
+ roomToMembers: IRoomIdToTypingMembers,
+ action: TypingMemberDeleteAction
+): IRoomIdToTypingMembers => {
+ let typingMembers = roomToMembers.get(action.roomId) ?? [];
+
+ typingMembers = typingMembers.filter((receipt) => receipt.userId !== action.userId);
+ if (typingMembers.length === 0) {
+ roomToMembers.delete(action.roomId);
+ } else {
+ roomToMembers.set(action.roomId, typingMembers);
+ }
+ return roomToMembers;
+};
+
+const timeoutReceipt = (
+ roomToMembers: IRoomIdToTypingMembers,
+ roomId: string,
+ userId: string,
+ timeout: number
+): boolean | undefined => {
+ const typingMembers = roomToMembers.get(roomId) ?? [];
+
+ const target = typingMembers.find((receipt) => receipt.userId === userId);
+ if (!target) return undefined;
+
+ return Date.now() - target.ts >= timeout;
+};
+
export const roomIdToTypingMembersAtom = atom<
IRoomIdToTypingMembers,
[IRoomIdToTypingMembersAction],
>(
(get) => get(baseRoomIdToTypingMembersAtom),
(get, set, action) => {
- const roomIdToTypingMembers = get(baseRoomIdToTypingMembersAtom);
- let typingMembers = roomIdToTypingMembers.get(action.roomId) ?? [];
-
- typingMembers = typingMembers.filter((member) => member.userId !== action.member.userId);
+ const rToTyping = get(baseRoomIdToTypingMembersAtom);
if (action.type === 'PUT') {
- typingMembers = [...typingMembers, action.member];
+ set(
+ baseRoomIdToTypingMembersAtom,
+ produce(rToTyping, (draft) => putTypingMember(draft, action))
+ );
+
+ // remove typing receipt after some timeout
+ // to prevent stuck typing members
+ setTimeout(() => {
+ const { roomId, userId } = action;
+ const timeout = timeoutReceipt(
+ get(baseRoomIdToTypingMembersAtom),
+ roomId,
+ userId,
+ TYPING_TIMEOUT_MS
+ );
+ if (timeout) {
+ set(
+ baseRoomIdToTypingMembersAtom,
+ produce(get(baseRoomIdToTypingMembersAtom), (draft) =>
+ deleteTypingMember(draft, {
+ type: 'DELETE',
+ roomId,
+ userId,
+ })
+ )
+ );
+ }
+ }, TYPING_TIMEOUT_MS);
+ }
+
+ if (
+ action.type === 'DELETE' &&
+ rToTyping.get(action.roomId)?.find((receipt) => receipt.userId === action.userId)
+ ) {
+ set(
+ baseRoomIdToTypingMembersAtom,
+ produce(rToTyping, (draft) => deleteTypingMember(draft, action))
+ );
}
- roomIdToTypingMembers.set(action.roomId, typingMembers);
- set(baseRoomIdToTypingMembersAtom, new Map([...roomIdToTypingMembers]));
}
);
setTypingMembers({
type: member.typing ? 'PUT' : 'DELETE',
roomId: member.roomId,
- member,
+ userId: member.userId,
+ ts: Date.now(),
});
};
+++ /dev/null
-import { useSetAtom, WritableAtom } from 'jotai';
-import { ClientEvent, MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
-import { useEffect } from 'react';
-import { Membership } from '../../types/matrix/room';
-
-export type RoomsAction =
- | {
- type: 'INITIALIZE';
- rooms: string[];
- }
- | {
- type: 'PUT' | 'DELETE';
- roomId: string;
- };
-
-export const useBindRoomsWithMembershipsAtom = (
- mx: MatrixClient,
- roomsAtom: WritableAtom<string[], [RoomsAction], undefined>,
- memberships: Membership[]
-) => {
- const setRoomsAtom = useSetAtom(roomsAtom);
-
- useEffect(() => {
- const satisfyMembership = (room: Room): boolean =>
- !!memberships.find((membership) => membership === room.getMyMembership());
- setRoomsAtom({
- type: 'INITIALIZE',
- rooms: mx
- .getRooms()
- .filter(satisfyMembership)
- .map((room) => room.roomId),
- });
-
- const handleAddRoom = (room: Room) => {
- if (satisfyMembership(room)) {
- setRoomsAtom({ type: 'PUT', roomId: room.roomId });
- }
- };
-
- const handleMembershipChange = (room: Room) => {
- if (!satisfyMembership(room)) {
- setRoomsAtom({ type: 'DELETE', roomId: room.roomId });
- }
- };
-
- const handleDeleteRoom = (roomId: string) => {
- setRoomsAtom({ type: 'DELETE', roomId });
- };
-
- mx.on(ClientEvent.Room, handleAddRoom);
- mx.on(RoomEvent.MyMembership, handleMembershipChange);
- mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
- return () => {
- mx.removeListener(ClientEvent.Room, handleAddRoom);
- mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
- mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
- };
- }, [mx, memberships, setRoomsAtom]);
-};
-
-export const compareRoomsEqual = (a: string[], b: string[]) => {
- if (a.length !== b.length) return false;
- return a.every((roomId, roomIdIndex) => roomId === b[roomIdIndex]);
-};
--- /dev/null
+import { ComplexStyleRule } from '@vanilla-extract/css';
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { ContainerColor as TContainerColor, DefaultReset, color } from 'folds';
+
+const getVariant = (variant: TContainerColor): ComplexStyleRule => ({
+ vars: {
+ backgroundColor: color[variant].Container,
+ borderColor: color[variant].ContainerLine,
+ outlineColor: color[variant].ContainerLine,
+ color: color[variant].OnContainer,
+ },
+});
+
+export const ContainerColor = recipe({
+ base: [DefaultReset],
+ variants: {
+ variant: {
+ Background: getVariant('Background'),
+ Surface: getVariant('Surface'),
+ SurfaceVariant: getVariant('SurfaceVariant'),
+ Primary: getVariant('Primary'),
+ Secondary: getVariant('Secondary'),
+ Success: getVariant('Success'),
+ Warning: getVariant('Warning'),
+ Critical: getVariant('Critical'),
+ },
+ },
+ defaultVariants: {
+ variant: 'Surface',
+ },
+});
+
+export type ContainerColorVariants = RecipeVariants<typeof ContainerColor>;
cursor: 'default',
},
]);
+
+export const highlightText = style([
+ DefaultReset,
+ {
+ backgroundColor: 'yellow',
+ color: 'black',
+ },
+]);
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useEffect, useRef } from 'react';
import './Client.scss';
-import { initHotkeys } from '../../../client/event/hotkeys';
-import { initRoomListListener } from '../../../client/event/roomList';
-
-import Text from '../../atoms/text/Text';
-import Spinner from '../../atoms/spinner/Spinner';
-import Navigation from '../../organisms/navigation/Navigation';
-import ContextMenu, { MenuItem } from '../../atoms/context-menu/ContextMenu';
-import IconButton from '../../atoms/button/IconButton';
import ReusableContextMenu from '../../atoms/context-menu/ReusableContextMenu';
import Windows from '../../organisms/pw/Windows';
import Dialogs from '../../organisms/pw/Dialogs';
-import initMatrix from '../../../client/initMatrix';
import navigation from '../../../client/state/navigation';
import cons from '../../../client/state/cons';
-import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
-import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { ClientContent } from './ClientContent';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
}
function Client() {
- const [isLoading, changeLoading] = useState(true);
- const [loadingMsg, setLoadingMsg] = useState('Heating up');
const classNameHidden = 'client__item-hidden';
const navWrapperRef = useRef(null);
};
}, []);
- useEffect(() => {
- changeLoading(true);
- let counter = 0;
- const iId = setInterval(() => {
- const msgList = ['Almost there...', 'Looks like you have a lot of stuff to heat up!'];
- if (counter === msgList.length - 1) {
- setLoadingMsg(msgList[msgList.length - 1]);
- clearInterval(iId);
- return;
- }
- setLoadingMsg(msgList[counter]);
- counter += 1;
- }, 15000);
- initMatrix.once('init_loading_finished', () => {
- clearInterval(iId);
- initHotkeys();
- initRoomListListener(initMatrix.roomList);
- changeLoading(false);
- });
- initMatrix.init();
- }, []);
-
- if (isLoading) {
- return (
- <div className="loading-display">
- <div className="loading__menu">
- <ContextMenu
- placement="bottom"
- content={
- <>
- <MenuItem onClick={() => initMatrix.clearCacheAndReload()}>
- Clear cache & reload
- </MenuItem>
- <MenuItem onClick={() => initMatrix.logout()}>Logout</MenuItem>
- </>
- }
- render={(toggle) => (
- <IconButton size="extra-small" onClick={toggle} src={VerticalMenuIC} />
- )}
- />
- </div>
- <Spinner />
- <Text className="loading__message" variant="b2">
- {loadingMsg}
- </Text>
-
- <div className="loading__appname">
- <Text variant="h2" weight="medium">
- Cinny
- </Text>
- </div>
- </div>
- );
- }
-
return (
- <MatrixClientProvider value={initMatrix.matrixClient}>
- <div className="client-container">
- <div className="navigation__wrapper" ref={navWrapperRef}>
- <Navigation />
- </div>
- <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
- <ClientContent />
- </div>
- <Windows />
- <Dialogs />
- <ReusableContextMenu />
- <SystemEmojiFeature />
+ <div className="client-container">
+ {/* <div className="navigation__wrapper" ref={navWrapperRef}>
+ <Navigation />
+ </div> */}
+ <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
+ <ClientContent />
</div>
- </MatrixClientProvider>
+ <Windows />
+ <Dialogs />
+ <ReusableContextMenu />
+ <SystemEmojiFeature />
+ </div>
);
}
.client-container {
display: flex;
height: 100%;
+ flex-grow: 1;
}
.navigation__wrapper {
width: var(--navigation-width);
-
+
@include screen.smallerThan(mobileBreakpoint) {
width: 100%;
}
import { openNavigation } from '../../../client/action/navigation';
import Welcome from '../../organisms/welcome/Welcome';
-import { RoomBaseView } from '../../organisms/room/Room';
+import { RoomBaseView } from '../../features/room/Room';
export function ClientContent() {
const [roomInfo, setRoomInfo] = useState({
--- /dev/null
+export class ASCIILexicalTable {
+ readonly startCode: number;
+
+ readonly endCode: number;
+
+ readonly maxStrWidth: number;
+
+ private readonly widthToSize: number[];
+
+ private readonly rangeCount: (i: number, j: number) => number;
+
+ constructor(startCode: number, endCode: number, maxStrWidth: number) {
+ if (startCode > endCode) {
+ throw new Error('Invalid ASCII code! startCode is greater than endCode.');
+ }
+ if (startCode < 0 || endCode > 128) {
+ throw new Error('Invalid ASCII code range!');
+ }
+
+ if (maxStrWidth <= 0) {
+ throw new Error('Invalid max string width!');
+ }
+
+ this.maxStrWidth = maxStrWidth;
+ this.startCode = startCode;
+ this.endCode = endCode;
+
+ /**
+ * @param i smaller - inclusive
+ * @param j larger - inclusive
+ * @returns number
+ */
+ this.rangeCount = (i: number, j: number): number => j - i + 1;
+
+ this.widthToSize = [];
+ this.populateWidthToSize();
+
+ if (this.size() > Number.MAX_SAFE_INTEGER) {
+ console.warn(
+ `[!] Warning: ASCIILexicalTable size is larger than the Number.MAX_SAFE_INTEGER: ${this.size()} > ${
+ Number.MAX_SAFE_INTEGER
+ }`
+ );
+ }
+ }
+
+ private populateWidthToSize() {
+ const chars = this.rangeCount(this.startCode, this.endCode);
+ for (let i = 0, count = 0; i < this.maxStrWidth; i += 1) {
+ count = count * chars + chars;
+ this.widthToSize[i] = count;
+ }
+ }
+
+ private getWidthToSize(width: number): number {
+ return this.widthToSize[width - 1];
+ }
+
+ first(): string {
+ return String.fromCharCode(this.startCode);
+ }
+
+ last(): string {
+ let str = '';
+ for (let i = 0; i < this.maxStrWidth; i += 1) {
+ str += String.fromCharCode(this.endCode);
+ }
+ return str;
+ }
+
+ hasIndex(index: number): boolean {
+ return index >= 0 && index < this.size();
+ }
+
+ has(str: string): boolean {
+ if (str.length === 0 || str.length > this.maxStrWidth) {
+ return false;
+ }
+
+ let charCode: number;
+ for (let i = 0; i < str.length; i += 1) {
+ charCode = str.charCodeAt(i);
+ if (charCode < this.startCode || charCode > this.endCode) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ size(): number {
+ return this.getWidthToSize(this.maxStrWidth);
+ }
+
+ index(str: string): number {
+ if (!this.has(str)) {
+ return -1;
+ }
+
+ let index = 0;
+ const chars = this.rangeCount(this.startCode, this.endCode);
+
+ for (let i = 0; i < this.maxStrWidth; i += 1) {
+ const code = str.charCodeAt(i);
+
+ if (Number.isNaN(code)) {
+ return index;
+ }
+
+ const opStrWidth = this.maxStrWidth - i;
+ const opStrTableSize = this.getWidthToSize(opStrWidth);
+
+ const segmentSize = opStrTableSize / chars;
+
+ const codeIndex = code - this.startCode;
+ const emptyCount = i === 0 ? 0 : 1;
+
+ index += segmentSize * codeIndex + emptyCount;
+ }
+
+ return index;
+ }
+
+ get(index: number): string | undefined {
+ if (!this.hasIndex(index)) {
+ return undefined;
+ }
+
+ let str = '';
+ const chars = this.rangeCount(this.startCode, this.endCode);
+
+ for (let toIndex = index, i = 0; i < this.maxStrWidth; i += 1) {
+ const opStrWidth = this.maxStrWidth - i;
+ const opStrTableSize = this.getWidthToSize(opStrWidth);
+
+ const segmentSize = opStrTableSize / chars;
+
+ const segmentIndex = Math.floor(toIndex / segmentSize);
+ str += String.fromCharCode(this.startCode + segmentIndex);
+
+ toIndex -= segmentIndex * segmentSize;
+ if (toIndex === 0) {
+ break;
+ }
+ toIndex -= 1;
+ }
+
+ return str;
+ }
+
+ previous(str: string): string | undefined {
+ if (!this.has(str)) return undefined;
+ let prev = str;
+ const lastCode = prev.charCodeAt(prev.length - 1);
+ prev = prev.slice(0, prev.length - 1);
+
+ if (lastCode === this.startCode) {
+ if (prev.length === 0) return undefined;
+ return prev;
+ }
+
+ prev += String.fromCharCode(lastCode - 1);
+ while (prev.length < this.maxStrWidth) {
+ prev += String.fromCharCode(this.endCode);
+ }
+ return prev;
+ }
+
+ next(str: string): string | undefined {
+ if (!this.has(str)) return undefined;
+ let next = str;
+
+ if (next.length < this.maxStrWidth) {
+ next += String.fromCharCode(this.startCode);
+ return next;
+ }
+
+ for (let i = next.length - 1; i >= 0; i -= 1) {
+ const lastCode = next.charCodeAt(i);
+ if (lastCode !== this.endCode) {
+ next = next.slice(0, i) + String.fromCharCode(lastCode + 1);
+ return next;
+ }
+ next = next.slice(0, i);
+ }
+ return undefined;
+ }
+
+ between(a: string, b: string): string | undefined {
+ if (!this.has(a) || !this.has(b)) {
+ return undefined;
+ }
+
+ const centerIndex = Math.floor((this.index(a) + this.index(b)) / 2);
+
+ const str = this.get(centerIndex);
+ if (str === a || str === b) return undefined;
+ return str;
+ }
+
+ nBetween(n: number, a: string, b: string): string[] | undefined {
+ if (n <= 0 || !this.has(a) || !this.has(b)) {
+ return undefined;
+ }
+
+ const indexA = this.index(a);
+ const indexB = this.index(b);
+
+ const nBetween = Math.max(indexA, indexB) - Math.min(indexA, indexB);
+ if (nBetween < n) {
+ return undefined;
+ }
+ const segmentSize = Math.floor(nBetween / (n + 1));
+ if (segmentSize === 0) return undefined;
+
+ const items: string[] = [];
+
+ for (
+ let segmentIndex = indexA + segmentSize;
+ segmentIndex < indexB;
+ segmentIndex += segmentSize
+ ) {
+ if (items.length === n) break;
+
+ const str = this.get(segmentIndex);
+
+ if (!str) break;
+ items.push(str);
+ }
+
+ if (items.length < n) {
+ return undefined;
+ }
+
+ return items;
+ }
+}
+
+// const printLex = (lex: ASCIILexicalTable) => {
+// const padRight = (s: string, maxWidth: number, padding: string): string => {
+// let ns = s;
+// for (let i = s.length; i < maxWidth; i += 1) {
+// ns += padding;
+// }
+// return ns;
+// };
+
+// const formattedLine = (n: number, item: string): string =>
+// `|${padRight(n.toString(), lex.size().toString().length, ' ')}|${item}|`;
+
+// const hr = `|${padRight('-', lex.size().toString().length, '-')}|${padRight(
+// '-',
+// lex.maxStrWidth,
+// '-'
+// )}|`;
+
+// console.log(`All lexicographic string combination in order.`);
+// console.log(`Start ASCII code: "${lex.startCode}"`);
+// console.log(`End ASCII code: "${lex.endCode}"`);
+// console.log(`Max string width: ${lex.maxStrWidth}`);
+// console.log(`Total String Combination Count: ${lex.size()}\n`);
+// console.log('Table:');
+// console.log(hr);
+// for (let i = 0; i < lex.size(); i += 1) {
+// const str = lex.get(i);
+// if (str) {
+// console.log(formattedLine(i, padRight(str, lex.maxStrWidth, '_')));
+// }
+// }
+// console.log(hr);
+// };
+
+// console.log('\n');
+
+// const lex = new ASCIILexicalTable('a'.charCodeAt(0), 'c'.charCodeAt(0), 3);
+// printLex(lex);
+// console.log(lex.size());
+// console.log(lex.nBetween(8, ' ', '~~~~~'));
+// console.log(lex.between('a', 'ccc'));
+// console.log(lex.get(11));
+// console.log(lex.get(11) === 'aaac');
+
+// const lex4 = new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 5);
+// console.log('Size: ', lex4.size());
+// console.log('Between: ', lex4.between('7g7g5', 'caccc'));
+// printLex(lex4);
+
+// console.log('\n');
+
+// const perf = () => {
+// const loopLength = 99999;
+// const lexT = new ASCIILexicalTable('a'.charCodeAt(0), 'z'.charCodeAt(0), 9);
+// console.log(lexT.size());
+// const str = 'bcbba';
+// const strI = lexT.index(str);
+// console.log('================');
+// console.time('index');
+// console.log(lexT.index(str));
+// for (let i = 0; i < loopLength; i += 1) {
+// lexT.index(str);
+// }
+// console.timeEnd('index');
+// console.log('================');
+// console.time('get');
+// console.log(lexT.get(strI));
+// for (let i = 0; i < loopLength; i += 1) {
+// lexT.get(strI);
+// }
+// console.timeEnd('get');
+// console.log('================');
+// console.time('previous');
+// console.log(lexT.previous(str));
+// for (let i = 0; i < loopLength; i += 1) {
+// lexT.previous(str);
+// }
+// console.timeEnd('previous');
+// console.log('================');
+// console.time('next');
+// console.log(lexT.next(str));
+// for (let i = 0; i < loopLength; i += 1) {
+// lexT.next(str);
+// }
+// console.timeEnd('next');
+// console.log('================');
+// console.time('between');
+// console.log(lexT.between(str, 'cbbca'));
+// for (let i = 0; i < loopLength; i += 1) {
+// lexT.between(str, 'cbbca');
+// }
+// console.timeEnd('between');
+// };
+
+// perf();
+
+const findNextFilledKey = (
+ fromIndex: number,
+ keys: Array<string | undefined>
+): [number, string] | [-1, undefined] => {
+ for (let j = fromIndex; j < keys.length; j += 1) {
+ const key = keys[j];
+ if (typeof key === 'string') {
+ return [j, key];
+ }
+ }
+
+ return [-1, undefined];
+};
+
+export const orderKeys = (
+ lex: ASCIILexicalTable,
+ keys: Array<string | undefined>
+): Array<string> | undefined => {
+ const newKeys: string[] = [];
+
+ for (let i = 0; i < keys.length; ) {
+ const key = keys[i];
+ const collectedKeys: string[] = [];
+ const [nextKeyIndex, nextKey] = findNextFilledKey(i + 1, keys);
+ const isKey = typeof key === 'string';
+
+ if (isKey) {
+ collectedKeys.push(key);
+ }
+
+ const keyToGenerateCount =
+ (nextKeyIndex === -1 ? keys.length : nextKeyIndex) - (key ? i + 1 : i + 0);
+
+ if (keyToGenerateCount > 0) {
+ const generatedKeys = lex.nBetween(
+ keyToGenerateCount,
+ key ?? lex.first(),
+ nextKey ?? lex.last()
+ );
+ if (generatedKeys) {
+ collectedKeys.push(...generatedKeys);
+ } else {
+ return lex.nBetween(keys?.length, lex.first(), lex.last());
+ }
+ }
+
+ newKeys.push(...collectedKeys);
+ i += collectedKeys.length;
+ }
+
+ if (newKeys.length !== keys.length) {
+ return undefined;
+ }
+
+ return newKeys;
+};
+
+// const lex = new ASCIILexicalTable('a'.charCodeAt(0), 'b'.charCodeAt(0), 2);
+// const keys = [undefined, undefined];
+// console.log(orderKeys(lex, keys));
export const trimTrailingSlash = (str: string): string => str.replace(END_SLASHES_REG, '');
export const trimSlash = (str: string): string => trimLeadingSlash(trimTrailingSlash(str));
+
+export const nameInitials = (str: string | undefined | null, len = 1): string => {
+ if (!str) return '�';
+ return [...str].slice(0, len).join('') || '�';
+};
+
+export const randomStr = (len = 12): string => {
+ let str = '';
+ const minCode = 'A'.charCodeAt(0);
+ const maxCode = 'Z'.charCodeAt(0);
+
+ for (let i = 0; i < len; i += 1) {
+ const code = Math.floor(Math.random() * (maxCode - minCode + 1) + minCode);
+ str += String.fromCharCode(code);
+ }
+ return str;
+};
evt.preventDefault();
}
};
+
+export const onEnterOrSpace = (callback: () => void) => (evt: KeyboardEventLike) => {
+ if (isKeyHotkey('enter', evt) || isKeyHotkey('space', evt)) {
+ evt.preventDefault();
+ callback();
+ }
+};
MatrixError,
MatrixEvent,
Room,
+ RoomMember,
UploadProgress,
UploadResponse,
} from 'matrix-js-sdk';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
+import { AccountDataEvent } from '../../types/matrix/accountData';
export const matchMxId = (id: string): RegExpMatchArray | null =>
id.match(/^([@!$+#])(\S+):(\S+)$/);
return [g1AsMxId, g3AsVia];
};
-export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined =>
- mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
+export const getCanonicalAliasRoomId = (mx: MatrixClient, alias: string): string | undefined =>
+ mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias)?.roomId;
+
+export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): string =>
+ mx.getRoom(roomId)?.getCanonicalAlias() || roomId;
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
const info: IImageInfo = {};
export const eventWithShortcode = (ev: MatrixEvent) =>
typeof ev.getContent().shortcode === 'string';
-export function hasDMWith(mx: MatrixClient, userId: string) {
+export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => {
const dmLikeRooms = mx
.getRooms()
.filter((room) => mx.isRoomEncrypted(room.roomId) && room.getMembers().length <= 2);
return dmLikeRooms.find((room) => room.getMember(userId));
-}
+};
+
+export const guessDmRoomUserId = (room: Room, myUserId: string): string => {
+ const getOldestMember = (members: RoomMember[]): RoomMember | undefined => {
+ let oldestMemberTs: number | undefined;
+ let oldestMember: RoomMember | undefined;
+
+ const pickOldestMember = (member: RoomMember) => {
+ if (member.userId === myUserId) return;
+
+ if (
+ oldestMemberTs === undefined ||
+ (member.events.member && member.events.member.getTs() < oldestMemberTs)
+ ) {
+ oldestMember = member;
+ oldestMemberTs = member.events.member?.getTs();
+ }
+ };
+
+ members.forEach(pickOldestMember);
+
+ return oldestMember;
+ };
+
+ // Pick the joined user who's been here longest (and isn't us),
+ const member = getOldestMember(room.getJoinedMembers());
+ if (member) return member.userId;
+
+ // if there are no joined members other than us, use the oldest member
+ const member1 = getOldestMember(room.currentState.getMembers());
+ return member1?.userId ?? myUserId;
+};
+
+export const addRoomIdToMDirect = async (
+ mx: MatrixClient,
+ roomId: string,
+ userId: string
+): Promise<void> => {
+ const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct);
+ const userIdToRoomIds: Record<string, string[]> = mDirectsEvent?.getContent() ?? {};
+
+ // remove it from the lists of any others users
+ // (it can only be a DM room for one person)
+ Object.keys(userIdToRoomIds).forEach((targetUserId) => {
+ const roomIds = userIdToRoomIds[targetUserId];
+
+ if (targetUserId !== userId) {
+ const indexOfRoomId = roomIds.indexOf(roomId);
+ if (indexOfRoomId > -1) {
+ roomIds.splice(indexOfRoomId, 1);
+ }
+ }
+ });
+
+ const roomIds = userIdToRoomIds[userId] || [];
+ if (roomIds.indexOf(roomId) === -1) {
+ roomIds.push(roomId);
+ }
+ userIdToRoomIds[userId] = roomIds;
+
+ await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds);
+};
+
+export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
+ const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct);
+ const userIdToRoomIds: Record<string, string[]> = mDirectsEvent?.getContent() ?? {};
+
+ Object.keys(userIdToRoomIds).forEach((targetUserId) => {
+ const roomIds = userIdToRoomIds[targetUserId];
+ const indexOfRoomId = roomIds.indexOf(roomId);
+ if (indexOfRoomId > -1) {
+ roomIds.splice(indexOfRoomId, 1);
+ }
+ });
+
+ await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds);
+};
export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(?<![.,:;!/?()[\\]\\s]+)`;
+export const URL_REG = new RegExp(HTTP_URL_PATTERN, 'g');
+
export const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@\\"]+(\.[^<>()[\]\\.,;:\s@\\"]+)*)|(\\".+\\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
// https://github.com/mathiasbynens/emoji-regex
export const EMOJI_PATTERN = `[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26D3\uFE0F?(?:\u200D\uD83D\uDCA5)?|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF43\uDF45-\uDF4A\uDF4C-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDF44(?:\u200D\uD83D\uDFEB)?|\uDF4B(?:\u200D\uD83D\uDFE9)?|\uDFC3(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4\uDEB5](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE41\uDE43\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC08(?:\u200D\u2B1B)?|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC26(?:\u200D(?:\u2B1B|\uD83D\uDD25))?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?|\uDE42(?:\u200D[\u2194\u2195]\uFE0F?)?|\uDEB6(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDCE(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1|\uDDD1\u200D\uD83E\uDDD2(?:\u200D\uD83E\uDDD2)?|\uDDD2(?:\u200D\uD83E\uDDD2)?))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)`;
+
+// Thumbs up emoji found to have Variation Selector 16 at the end
+// so included variation selector pattern in regex
+export const JUMBO_EMOJI_REG = new RegExp(
+ `^(((${EMOJI_PATTERN})|(:.+?:))(${VARIATION_SELECTOR_PATTERN}|\\s)*){1,10}$`
+);
export const isRoom = (room: Room | null): boolean => {
if (!room) return false;
const event = getStateEvent(room, StateEvent.RoomCreate);
- if (!event) return false;
- return event.getContent().type === undefined;
+ if (!event) return true;
+ return event.getContent().type !== RoomType.Space;
};
export const isUnsupportedRoom = (room: Room | null): boolean => {
};
export function isValidChild(mEvent: MatrixEvent): boolean {
- return mEvent.getType() === StateEvent.SpaceChild && Object.keys(mEvent.getContent()).length > 0;
+ return (
+ mEvent.getType() === StateEvent.SpaceChild &&
+ Array.isArray(mEvent.getContent<{ via: string[] }>().via)
+ );
}
export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set<string> => {
return map;
};
+export const getOrphanParents = (roomToParents: RoomToParents, roomId: string): string[] => {
+ const parents = getAllParents(roomToParents, roomId);
+ const orphanParents = Array.from(parents).filter(
+ (parentRoomId) => !roomToParents.has(parentRoomId)
+ );
+
+ return orphanParents;
+};
+
export const isMutedRule = (rule: IPushRule) =>
rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match';
return NotificationType.MentionsAndKeywords;
};
+const NOTIFICATION_EVENT_TYPES = [
+ 'm.room.create',
+ 'm.room.message',
+ 'm.room.encrypted',
+ 'm.room.member',
+ 'm.sticker',
+];
export const isNotificationEvent = (mEvent: MatrixEvent) => {
const eType = mEvent.getType();
- if (
- ['m.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker'].find(
- (type) => type === eType
- )
- )
+ if (!NOTIFICATION_EVENT_TYPES.includes(eType)) {
return false;
+ }
if (eType === 'm.room.member') return false;
if (mEvent.isRedacted()) return false;
return true;
};
+export const roomHaveNotification = (room: Room): boolean => {
+ const total = room.getUnreadNotificationCount(NotificationCountType.Total);
+ const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
+
+ return total > 0 || highlight > 0;
+};
+
export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
const userId = mx.getUserId();
if (!userId) return false;
if (room.getMyMembership() !== 'join') return unread;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
- if (roomHaveUnread(mx, room)) {
+ if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
unread.push(getUnreadInfo(room));
}
return undefined;
};
-export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefined => {
- const url =
- room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false) ??
- undefined;
- if (url) return url;
- return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined;
-};
+export const getRoomAvatarUrl = (
+ mx: MatrixClient,
+ room: Room,
+ size: 32 | 96 = 32
+): string | undefined => room.getAvatarUrl(mx.baseUrl, size, size, 'crop') ?? undefined;
+
+export const getDirectRoomAvatarUrl = (
+ mx: MatrixClient,
+ room: Room,
+ size: 32 | 96 = 32
+): string | undefined =>
+ room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, size, size, 'crop', undefined, false) ??
+ undefined;
export const trimReplyFromBody = (body: string): string => {
const match = body.match(/^> <.+?> .+\n(>.*\n)*?\n/m);
--- /dev/null
+import { MatrixClient } from 'matrix-js-sdk';
+
+export type SortFunc<T> = (a: T, b: T) => number;
+
+export const factoryRoomIdByActivity =
+ (mx: MatrixClient): SortFunc<string> =>
+ (a, b) => {
+ const room1 = mx.getRoom(a);
+ const room2 = mx.getRoom(b);
+
+ return (
+ (room2?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER) -
+ (room1?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER)
+ );
+ };
+
+export const factoryRoomIdByAtoZ =
+ (mx: MatrixClient): SortFunc<string> =>
+ (a, b) => {
+ let aName = mx.getRoom(a)?.name ?? '';
+ let bName = mx.getRoom(b)?.name ?? '';
+
+ // remove "#" from the room name
+ // To ignore it in sorting
+ aName = aName.replace(/#/g, '');
+ bName = bName.replace(/#/g, '');
+
+ if (aName.toLowerCase() < bName.toLowerCase()) {
+ return -1;
+ }
+ if (aName.toLowerCase() > bName.toLowerCase()) {
+ return 1;
+ }
+ return 0;
+ };
+
+export const factoryRoomIdByUnreadCount =
+ (getUnreadCount: (roomId: string) => number): SortFunc<string> =>
+ (a, b) => {
+ const aT = getUnreadCount(a) ?? 0;
+ const bT = getUnreadCount(b) ?? 0;
+ return bT - aT;
+ };
+
+export const byTsOldToNew: SortFunc<number> = (a, b) => a - b;
+
+export const byOrderKey: SortFunc<string | undefined> = (a, b) => {
+ if (!a && !b) {
+ return 0;
+ }
+
+ if (!b) return -1;
+ if (!a) return 1;
+
+ if (a < b) {
+ return -1;
+ }
+ return 1;
+};
});
}
-export function openSpaceAddExisting(roomId) {
+export function openSpaceAddExisting(roomId, spaces = false) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SPACE_ADDEXISTING,
roomId,
+ spaces,
});
}
-export function toggleRoomSettings(tabText) {
+export function toggleRoomSettings(roomId, tabText) {
appDispatcher.dispatch({
type: cons.actions.navigation.TOGGLE_ROOM_SETTINGS,
- tabText,
+ roomId,
+ tabText
});
}
-import { openSearch, toggleRoomSettings } from '../action/navigation';
+import { openSearch } from '../action/navigation';
import navigation from '../state/navigation';
import { markAsRead } from '../action/notifications';
if (navigation.isRawModalVisible) return;
if (event.key === 'Escape') {
- if (navigation.isRoomSettings) {
- toggleRoomSettings();
- return;
- }
if (navigation.selectedRoomId) {
markAsRead(navigation.selectedRoomId);
return;
import EventEmitter from 'events';
import * as sdk from 'matrix-js-sdk';
import Olm from '@matrix-org/olm';
-// import { logger } from 'matrix-js-sdk/lib/logger';
+import { logger } from 'matrix-js-sdk/lib/logger';
import { getSecret } from './state/auth';
import RoomList from './state/RoomList';
global.Olm = Olm;
-// logger.disableAll();
+if (import.meta.env.PROD) {
+ logger.disableAll();
+}
class InitMatrix extends EventEmitter {
constructor() {
}
async init() {
- if (this.matrixClient) {
+ if (this.matrixClient || this.initializing) {
console.warn('Client is already initialized!')
return;
}
+ this.initializing = true;
- await this.startClient();
- this.setupSync();
- this.listenEvents();
+ try {
+ await this.startClient();
+ this.setupSync();
+ this.listenEvents();
+ this.initializing = false;
+ } catch {
+ this.initializing = false;
+ }
}
async startClient() {
lazyLoadMembers: true,
});
this.matrixClient.setGlobalErrorOnUnknownDevices(false);
+ this.matrixClient.setMaxListeners(50);
}
setupSync() {
this.selectedSpacePath = [cons.tabs.HOME];
this.selectedRoomId = null;
- this.isRoomSettings = false;
this.recentRooms = [];
this.spaceToRoom = new Map();
this.removeRecentRoom(prevSelectedRoomId);
this.addRecentRoom(prevSelectedRoomId);
this.removeRecentRoom(this.selectedRoomId);
- if (this.isRoomSettings && typeof this.selectedRoomId === 'string') {
- this.isRoomSettings = !this.isRoomSettings;
- this.emit(cons.events.navigation.ROOM_SETTINGS_TOGGLED, this.isRoomSettings);
- }
this.emit(
cons.events.navigation.ROOM_SELECTED,
this.selectedRoomId,
this.emit(cons.events.navigation.SPACE_MANAGE_OPENED, action.roomId);
},
[cons.actions.navigation.OPEN_SPACE_ADDEXISTING]: () => {
- this.emit(cons.events.navigation.SPACE_ADDEXISTING_OPENED, action.roomId);
+ this.emit(cons.events.navigation.SPACE_ADDEXISTING_OPENED, action.roomId, action.spaces);
},
[cons.actions.navigation.TOGGLE_ROOM_SETTINGS]: () => {
- this.isRoomSettings = !this.isRoomSettings;
this.emit(
cons.events.navigation.ROOM_SETTINGS_TOGGLED,
- this.isRoomSettings,
- action.tabText,
+ action.roomId,
+ action.tabText
);
},
[cons.actions.navigation.OPEN_SHORTCUT_SPACES]: () => {
--tc-danger-low: rgba(240, 71, 71, 60%);
--tc-code: #e62498;
- --tc-link: hsl(213deg 76% 56%);
+ --tc-link: hsl(213deg 100% 45%);
--tc-tooltip: white;
--tc-badge: white;
--ic-danger-normal: rgba(240, 71, 71, 0.7);
/* user mxid colors */
- --mx-uc-1: hsl(208, 66%, 53%);
- --mx-uc-2: hsl(302, 49%, 45%);
- --mx-uc-3: hsl(163, 97%, 36%);
- --mx-uc-4: hsl(343, 75%, 61%);
- --mx-uc-5: hsl(24, 100%, 59%);
- --mx-uc-6: hsl(181, 63%, 47%);
- --mx-uc-7: hsl(242, 89%, 65%);
- --mx-uc-8: hsl(94, 65%, 50%);
+ --mx-uc-1: hsl(208, 100%, 45%);
+ --mx-uc-2: hsl(302, 100%, 30%);
+ --mx-uc-3: hsl(163, 100%, 30%);
+ --mx-uc-4: hsl(343, 100%, 45%);
+ --mx-uc-5: hsl(24, 100%, 45%);
+ --mx-uc-6: hsl(181, 100%, 30%);
+ --mx-uc-7: hsl(242, 100%, 45%);
+ --mx-uc-8: hsl(94, 100%, 35%);
/* system icon size | -ic-[size]: value */
--ic-large: 38px;
--tc-primary-low: rgba(255, 255, 255, 0.4);
--tc-code: #e565b1;
- --tc-link: hsl(213deg 94% 73%);
+ --tc-link: hsl(213deg 100% 80%);
--tc-badge: black;
/* system icons | --ic-[background type]-[priority]: value */
--ic-surface-low: rgba(255, 255, 255, 64%);
--ic-primary-normal: #ffffff;
- & .text {
- /* override user mxid colors for texts */
- --mx-uc-1: hsl(208, 100%, 58%);
- --mx-uc-2: hsl(301, 80%, 70%);
- --mx-uc-3: hsl(163, 93%, 41%);
- --mx-uc-4: hsl(343, 91%, 66%);
- --mx-uc-5: hsl(24, 90%, 67%);
- --mx-uc-6: hsl(181, 90%, 50%);
- --mx-uc-7: hsl(243, 100%, 74%);
- --mx-uc-8: hsl(94, 66%, 50%);
- }
+ --mx-uc-1: hsl(208, 100%, 75%);
+ --mx-uc-2: hsl(301, 100%, 80%);
+ --mx-uc-3: hsl(163, 100%, 70%);
+ --mx-uc-4: hsl(343, 100%, 75%);
+ --mx-uc-5: hsl(24, 100%, 70%);
+ --mx-uc-6: hsl(181, 100%, 60%);
+ --mx-uc-7: hsl(243, 100%, 80%);
+ --mx-uc-8: hsl(94, 100%, 80%);
/* shadow and overlay */
--bg-overlay: rgba(0, 0, 0, 60%);
margin: 0;
padding: 0;
background-color: transparent;
+ color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
Space = 'm.space',
}
+export type MSpaceChildContent = {
+ via: string[];
+ suggested?: boolean;
+ order?: string;
+};
+
export enum NotificationType {
Default = 'default',
AllMessages = 'all_messages',
};
};
+export type GetContentCallback = <T>() => T;
+
export type RoomToParents = Map<string, Set<string>>;
-export type RoomToUnread = Map<
- string,
- {
- total: number;
- highlight: number;
- from: Set<string> | null;
- }
->;
+export type Unread = {
+ total: number;
+ highlight: number;
+ from: Set<string> | null;
+};
+export type RoomToUnread = Map<string, Unread>;
export type UnreadInfo = {
roomId: string;
total: number;
server: {
port: 8080,
host: true,
+ proxy: {
+ "^\\/.*?\\/olm\\.wasm$": {
+ target: 'http://localhost:8080',
+ rewrite: () => '/olm.wasm'
+ }
+ }
},
plugins: [
topLevelAwait({