add neurone - wip
This commit is contained in:
parent
c88cb4bca9
commit
c27f45161e
313 changed files with 15684 additions and 8101 deletions
|
|
@ -31,12 +31,15 @@ module.exports = {
|
|||
'linebreak-style': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
"no-shadow": "off",
|
||||
"max-classes-per-file": "off",
|
||||
"no-useless-constructor": "off",
|
||||
"no-empty-function": "off",
|
||||
|
||||
"import/prefer-default-export": "off",
|
||||
"import/extensions": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
"off",
|
||||
{
|
||||
devDependencies: true,
|
||||
},
|
||||
|
|
@ -58,8 +61,10 @@ module.exports = {
|
|||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-empty-function": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-shadow": "error"
|
||||
"@typescript-eslint/no-shadow": "error",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
|
|
|||
7
.zed/settings.json
Normal file
7
.zed/settings.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"format_on_save": "on",
|
||||
"prettier": {
|
||||
"parser": "",
|
||||
"allowed": true
|
||||
}
|
||||
}
|
||||
47
package-lock.json
generated
47
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.1",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -32,12 +33,13 @@
|
|||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.4.0",
|
||||
"folds": "2.7.0",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-http-backend": "2.5.2",
|
||||
"idb": "8.0.3",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.6.0",
|
||||
|
|
@ -2256,9 +2258,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"version": "15.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz",
|
||||
"integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
|
||||
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
|
|
@ -7157,9 +7159,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/folds": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.4.0.tgz",
|
||||
"integrity": "sha512-Q5xCmvU3SIM8etQ9qLF6Y5Jtv01c9JpG3QcnF+Z3nlbMvtktfE13Pj7p0XgSPBcA3OuoU0zXiRwiTlMcbU7KhA==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.7.0.tgz",
|
||||
"integrity": "sha512-YOWx0raqLignz7Jgw7G5f90fczKSVEUiNuLwgZo9MvZcHOlRvf6+77Oa/1B6Baq1Qj5mO/cNTLBNiA5yF9pMdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@vanilla-extract/css": "1.9.2",
|
||||
|
|
@ -7713,10 +7715,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"dev": true
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
|
|
@ -8649,6 +8651,15 @@
|
|||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-js-sdk/node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"version": "15.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz",
|
||||
"integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-js-sdk/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
|
|
@ -11789,6 +11800,13 @@
|
|||
"workbox-core": "7.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-background-sync/node_modules/idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/workbox-broadcast-update": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz",
|
||||
|
|
@ -11935,6 +11953,13 @@
|
|||
"workbox-core": "7.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-expiration/node_modules/idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/workbox-google-analytics": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.1",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -43,12 +44,13 @@
|
|||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.4.0",
|
||||
"folds": "2.7.0",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-http-backend": "2.5.2",
|
||||
"idb": "8.0.3",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.6.0",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { ReactNode, useCallback, useEffect, useMemo } from 'react';
|
||||
import { MatrixError, createClient } from 'matrix-js-sdk';
|
||||
import { getLogin, GetLoginOK } from 'neurone/lib/api/matrix/client/v3/login';
|
||||
import { postRegister } from 'neurone/lib/api/matrix/client/v3/register';
|
||||
import { MatrixErrorData, parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
|
||||
import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common';
|
||||
import { promiseFulfilledResult } from '../utils/common';
|
||||
import {
|
||||
AuthFlows,
|
||||
RegisterFlowStatus,
|
||||
RegisterFlowsResponse,
|
||||
parseRegisterErrResp,
|
||||
} from '../hooks/useAuthFlows';
|
||||
import { getJSONRequest } from '../../client/request';
|
||||
import { AuthResponse } from '../utils/matrix-uia';
|
||||
|
||||
type AuthFlowsLoaderProps = {
|
||||
fallback?: () => ReactNode;
|
||||
|
|
@ -19,24 +23,33 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
|||
const autoDiscoveryInfo = useAutoDiscoveryInfo();
|
||||
const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
|
||||
|
||||
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
||||
const requestJSON = useMemo(() => getJSONRequest(baseUrl), [baseUrl]);
|
||||
|
||||
const [state, load] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
|
||||
const loginFlows = promiseFulfilledResult(result[0]);
|
||||
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
||||
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
||||
const loginEndpoint = getLogin({});
|
||||
const registerEndpoint = postRegister({});
|
||||
|
||||
if (typeof registerResp === 'object' && registerResp.httpStatus) {
|
||||
registerFlows = parseRegisterErrResp(registerResp);
|
||||
const [loginResponse, registerResponse] = (
|
||||
await Promise.allSettled([requestJSON(loginEndpoint), requestJSON(registerEndpoint)])
|
||||
).map(promiseFulfilledResult);
|
||||
|
||||
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
||||
const [registerError] =
|
||||
typeof registerResponse === 'object'
|
||||
? await parseMatrixResp<undefined, AuthResponse | MatrixErrorData>(registerResponse)
|
||||
: [];
|
||||
|
||||
if (registerError) {
|
||||
registerFlows = parseRegisterErrResp(registerError);
|
||||
}
|
||||
|
||||
if (!loginFlows) {
|
||||
if (!loginResponse) {
|
||||
throw new Error('Missing auth flow!');
|
||||
}
|
||||
if ('errcode' in loginFlows) {
|
||||
throw new Error('Failed to load auth flow!');
|
||||
const [loginError, loginFlows] = await parseMatrixResp<GetLoginOK>(loginResponse);
|
||||
if (loginError) {
|
||||
throw new Error(`Failed to load auth flow! ${printMatrixError(loginError)}`);
|
||||
}
|
||||
|
||||
const authFlows: AuthFlows = {
|
||||
|
|
@ -45,7 +58,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
|||
};
|
||||
|
||||
return authFlows;
|
||||
}, [mx])
|
||||
}, [requestJSON])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import { ReactNode, useCallback, useEffect } from 'react';
|
||||
import { Capabilities } from 'matrix-js-sdk';
|
||||
import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities';
|
||||
import { parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
import { Capabilities } from '../hooks/useCapabilities';
|
||||
|
||||
type CapabilitiesLoaderProps = {
|
||||
children: (capabilities: Capabilities | undefined) => ReactNode;
|
||||
};
|
||||
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
|
||||
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
|
||||
const [state, load] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const resp = await ct.requestAuthJSON(getCapabilities({}));
|
||||
const [, result] = await parseMatrixResp<GetCapabilitiesOK>(resp);
|
||||
return result?.capabilities;
|
||||
}, [ct])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
|
|
|||
|
|
@ -1,33 +1,19 @@
|
|||
import React, { forwardRef, useCallback } from 'react';
|
||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { logoutClient } from '../../client/initMatrix';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||
import { InfoCard } from './info-card';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
} from '../hooks/useDeviceVerificationStatus';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
|
||||
type LogoutDialogProps = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
({ handleClose }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
const verificationStatus = useDeviceVerificationStatus(
|
||||
mx.getCrypto(),
|
||||
mx.getSafeUserId(),
|
||||
mx.getDeviceId() ?? undefined
|
||||
);
|
||||
const ct = useClient();
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
await logoutClient(mx);
|
||||
}, [mx])
|
||||
await ct.logout();
|
||||
}, [ct])
|
||||
);
|
||||
|
||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||
|
|
@ -47,7 +33,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||
</Box>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{hasEncryptedRoom &&
|
||||
{/* {hasEncryptedRoom &&
|
||||
(crossSigningActive ? (
|
||||
verificationStatus === VerificationStatus.Unverified && (
|
||||
<InfoCard
|
||||
|
|
@ -62,7 +48,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||
title="Alert"
|
||||
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
||||
/>
|
||||
))}
|
||||
))} */}
|
||||
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
||||
{logoutState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
import { ReactNode, useCallback, useEffect } from 'react';
|
||||
import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media';
|
||||
import { parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
|
||||
type MediaConfigLoaderProps = {
|
||||
children: (mediaConfig: MediaConfig | undefined) => ReactNode;
|
||||
};
|
||||
export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
|
||||
const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
|
||||
const [state, load] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const resp = await ct.requestAuthJSON(getConfig({}));
|
||||
|
||||
const [, config] = await parseMatrixResp<GetConfigOK>(resp);
|
||||
|
||||
return config;
|
||||
}, [ct])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
|
|
|||
|
|
@ -1,147 +1,101 @@
|
|||
import React from 'react';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts } from 'linkifyjs';
|
||||
import { config } from 'folds';
|
||||
import {
|
||||
AudioContent,
|
||||
DownloadFile,
|
||||
FileContent,
|
||||
ImageContent,
|
||||
MAudio,
|
||||
MBadEncrypted,
|
||||
MEmote,
|
||||
MFile,
|
||||
MImage,
|
||||
MLocation,
|
||||
MNotice,
|
||||
MSticker,
|
||||
MText,
|
||||
MVideo,
|
||||
ReadPdfFile,
|
||||
ReadTextFile,
|
||||
RenderBody,
|
||||
ThumbnailContent,
|
||||
RedactedContent,
|
||||
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';
|
||||
import { testMatrixTo } from '../plugins/matrix-to';
|
||||
import { IImageContent } from '../../types/matrix/common';
|
||||
import { MsgType } from '../../types/matrix/common';
|
||||
import { rContent } from '../../client/reader';
|
||||
import { MessageEvent } from '../../types/matrix/room';
|
||||
|
||||
type RenderMessageContentProps = {
|
||||
displayName: string;
|
||||
msgType: string;
|
||||
eventType: string;
|
||||
ts: number;
|
||||
edited?: boolean;
|
||||
getContent: <T>() => T;
|
||||
content: object;
|
||||
mediaAutoLoad?: boolean;
|
||||
urlPreview?: boolean;
|
||||
highlightRegex?: RegExp;
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
outlineAttachment?: boolean;
|
||||
redacted?: boolean;
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
displayName,
|
||||
msgType,
|
||||
eventType,
|
||||
ts,
|
||||
edited,
|
||||
getContent,
|
||||
content,
|
||||
mediaAutoLoad,
|
||||
urlPreview,
|
||||
highlightRegex,
|
||||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
redacted,
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
if (filteredUrls.length === 0) return undefined;
|
||||
if (redacted) return <RedactedContent />;
|
||||
|
||||
if (eventType === MessageEvent.Sticker) {
|
||||
return (
|
||||
<UrlPreviewHolder>
|
||||
{filteredUrls.map((url) => (
|
||||
<UrlPreviewCard key={url} url={url} ts={ts} />
|
||||
))}
|
||||
</UrlPreviewHolder>
|
||||
<MSticker
|
||||
content={content}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
outlineAttachment={outlineAttachment}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
if (eventType !== MessageEvent.RoomMessage) {
|
||||
return <UnsupportedContent />;
|
||||
}
|
||||
|
||||
const renderCaption = () => {
|
||||
const content: IImageContent = getContent();
|
||||
if (content.filename && content.filename !== content.body) {
|
||||
const filename = rContent.filename(content);
|
||||
const body = rContent.body(content);
|
||||
|
||||
if (filename && filename !== body && body) {
|
||||
return (
|
||||
<MText
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
style={{ marginTop: config.space.S100 }}
|
||||
edited={edited}
|
||||
content={content}
|
||||
renderBody={(props) => (
|
||||
<RenderBody
|
||||
{...props}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
urlPreview={urlPreview}
|
||||
ts={ts}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
{renderCaption()}
|
||||
</>
|
||||
);
|
||||
const msgType = rContent.msgtype(content);
|
||||
|
||||
if (msgType === MsgType.Text) {
|
||||
return (
|
||||
<MText
|
||||
edited={edited}
|
||||
content={getContent()}
|
||||
renderBody={(props) => (
|
||||
<RenderBody
|
||||
{...props}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
content={content}
|
||||
urlPreview={urlPreview}
|
||||
ts={ts}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -151,16 +105,12 @@ export function RenderMessageContent({
|
|||
<MEmote
|
||||
displayName={displayName}
|
||||
edited={edited}
|
||||
content={getContent()}
|
||||
renderBody={(props) => (
|
||||
<RenderBody
|
||||
{...props}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
content={content}
|
||||
urlPreview={urlPreview}
|
||||
ts={ts}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -169,16 +119,12 @@ export function RenderMessageContent({
|
|||
return (
|
||||
<MNotice
|
||||
edited={edited}
|
||||
content={getContent()}
|
||||
renderBody={(props) => (
|
||||
<RenderBody
|
||||
{...props}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
content={content}
|
||||
urlPreview={urlPreview}
|
||||
ts={ts}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -186,18 +132,7 @@ export function RenderMessageContent({
|
|||
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}
|
||||
/>
|
||||
<MImage content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
|
||||
{renderCaption()}
|
||||
</>
|
||||
);
|
||||
|
|
@ -206,31 +141,7 @@ export function RenderMessageContent({
|
|||
if (msgType === MsgType.Video) {
|
||||
return (
|
||||
<>
|
||||
<MVideo
|
||||
content={getContent()}
|
||||
renderAsFile={renderFile}
|
||||
renderVideoContent={({ body, info, ...props }) => (
|
||||
<VideoContent
|
||||
body={body}
|
||||
info={info}
|
||||
{...props}
|
||||
renderThumbnail={
|
||||
mediaAutoLoad
|
||||
? () => (
|
||||
<ThumbnailContent
|
||||
info={info}
|
||||
renderImage={(src) => (
|
||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
renderVideo={(p) => <Video {...p} />}
|
||||
/>
|
||||
)}
|
||||
outlined={outlineAttachment}
|
||||
/>
|
||||
<MVideo content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
|
||||
{renderCaption()}
|
||||
</>
|
||||
);
|
||||
|
|
@ -239,30 +150,34 @@ export function RenderMessageContent({
|
|||
if (msgType === MsgType.Audio) {
|
||||
return (
|
||||
<>
|
||||
<MAudio
|
||||
content={getContent()}
|
||||
renderAsFile={renderFile}
|
||||
renderAudioContent={(props) => (
|
||||
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
|
||||
)}
|
||||
outlined={outlineAttachment}
|
||||
/>
|
||||
<MAudio content={content} outlined={outlineAttachment} />
|
||||
{renderCaption()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (msgType === MsgType.File) {
|
||||
return renderFile();
|
||||
return (
|
||||
<>
|
||||
<MFile content={content} outlined={outlineAttachment} />
|
||||
{renderCaption()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (msgType === MsgType.Location) {
|
||||
return <MLocation content={getContent()} />;
|
||||
return <MLocation content={content} />;
|
||||
}
|
||||
|
||||
if (msgType === 'm.bad.encrypted') {
|
||||
return <MBadEncrypted />;
|
||||
}
|
||||
|
||||
return <UnsupportedContent />;
|
||||
return (
|
||||
<MText
|
||||
edited={edited}
|
||||
content={content}
|
||||
urlPreview={urlPreview}
|
||||
ts={ts}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,36 @@
|
|||
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 { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { getRoomSummary, GetRoomSummaryOK } from 'neurone/lib/api/matrix/client/v1/roomSummary';
|
||||
import { getHierarchy, GetHierarchyOK } from 'neurone/lib/api/matrix/client/v1/rooms';
|
||||
import { SpaceHierarchyRoomChunk } from 'neurone/lib/api/types';
|
||||
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
|
||||
|
||||
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
|
||||
import { useClient } from '../hooks/useClient';
|
||||
import { useSpecVersionSupport } from '../hooks/useSpecVersions';
|
||||
|
||||
type RoomSummaryLoaderProps = {
|
||||
roomIdOrAlias: string;
|
||||
children: (roomSummary?: IRoomSummary) => ReactNode;
|
||||
children: (roomSummary?: GetRoomSummaryOK) => ReactNode;
|
||||
};
|
||||
|
||||
export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const roomSummarySupported = useSpecVersionSupport('v1.15');
|
||||
|
||||
const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]);
|
||||
const fetchSummary = useCallback(async (): Promise<GetRoomSummaryOK> => {
|
||||
const endpoint = getRoomSummary({ pathParams: { roomIdOrAlias } });
|
||||
if (!roomSummarySupported) {
|
||||
endpoint.path = `/_matrix/client/unstable/im.nheko.summary/summary/${encodeURIComponent(
|
||||
roomIdOrAlias
|
||||
)}`;
|
||||
}
|
||||
const response = await ct.requestAuthJSON(endpoint);
|
||||
const [err, result] = await parseMatrixResp<GetRoomSummaryOK>(response);
|
||||
if (err) {
|
||||
throw new Error(printMatrixError(err));
|
||||
}
|
||||
return result;
|
||||
}, [ct, roomIdOrAlias, roomSummarySupported]);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: [roomIdOrAlias, `summary`],
|
||||
|
|
@ -26,28 +40,35 @@ export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoader
|
|||
return children(data);
|
||||
}
|
||||
|
||||
export function LocalRoomSummaryLoader({
|
||||
room,
|
||||
children,
|
||||
}: {
|
||||
room: Room;
|
||||
children: (roomSummary: LocalRoomSummary) => ReactNode;
|
||||
}) {
|
||||
const summary = useLocalRoomSummary(room);
|
||||
|
||||
return children(summary);
|
||||
export function LocalRoomSummaryLoader({ children }: { children: () => ReactNode }) {
|
||||
return children();
|
||||
}
|
||||
|
||||
export function HierarchyRoomSummaryLoader({
|
||||
roomId,
|
||||
children,
|
||||
}: {
|
||||
roomId: string;
|
||||
children: (state: AsyncState<IHierarchyRoom, Error>) => ReactNode;
|
||||
children: (state: AsyncState<SpaceHierarchyRoomChunk, Error>) => ReactNode;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
|
||||
const fetchSummary = useCallback(() => mx.getRoomHierarchy(roomId, 1, 1), [mx, roomId]);
|
||||
const fetchSummary = useCallback(async (): Promise<GetHierarchyOK> => {
|
||||
const response = await ct.requestAuthJSON(
|
||||
getHierarchy({
|
||||
pathParams: { roomId },
|
||||
searchParams: {
|
||||
limit: 1,
|
||||
max_depth: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [err, result] = await parseMatrixResp<GetHierarchyOK>(response);
|
||||
if (err) {
|
||||
throw new Error(printMatrixError(err));
|
||||
}
|
||||
return result;
|
||||
}, [ct, roomId]);
|
||||
const [errorMemo, setError] = useState<Error>();
|
||||
|
||||
const { data, error } = useQuery({
|
||||
|
|
@ -62,7 +83,7 @@ export function HierarchyRoomSummaryLoader({
|
|||
},
|
||||
});
|
||||
|
||||
let state: AsyncState<IHierarchyRoom, Error> = {
|
||||
let state: AsyncState<SpaceHierarchyRoomChunk, Error> = {
|
||||
status: AsyncStatus.Loading,
|
||||
};
|
||||
if (error) {
|
||||
|
|
@ -78,7 +99,7 @@ export function HierarchyRoomSummaryLoader({
|
|||
};
|
||||
}
|
||||
|
||||
const summary = data?.rooms[0] ?? undefined;
|
||||
const summary = data?.rooms[0];
|
||||
if (summary) {
|
||||
state = {
|
||||
status: AsyncStatus.Success,
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,48 +1,48 @@
|
|||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities';
|
||||
import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media';
|
||||
import { getAuthMetadata, GetAuthMetadataOK } from 'neurone/lib/api/matrix/client/v1/authMetadata';
|
||||
import { parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||
import { promiseFulfilledResult } from '../utils/common';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||
import { Capabilities } from '../hooks/useCapabilities';
|
||||
import { getJSONRequest } from '../../client/request';
|
||||
|
||||
export type ServerConfigs = {
|
||||
capabilities?: Capabilities;
|
||||
mediaConfig?: MediaConfig;
|
||||
authMetadata?: ValidatedAuthMetadata;
|
||||
authMetadata?: GetAuthMetadataOK;
|
||||
};
|
||||
|
||||
type ServerConfigsLoaderProps = {
|
||||
children: (configs: ServerConfigs) => ReactNode;
|
||||
};
|
||||
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const fallbackConfigs = useMemo(() => ({}), []);
|
||||
|
||||
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
|
||||
useCallback(async () => {
|
||||
const result = await Promise.allSettled([
|
||||
mx.getCapabilities(),
|
||||
mx.getMediaConfig(),
|
||||
mx.getAuthMetadata(),
|
||||
ct.requestAuthJSON(getCapabilities({})).then(parseMatrixResp<GetCapabilitiesOK>),
|
||||
ct.requestAuthJSON(getConfig({})).then(parseMatrixResp<GetConfigOK>),
|
||||
getJSONRequest(ct.session.baseUrl)(getAuthMetadata({})).then(
|
||||
parseMatrixResp<GetAuthMetadataOK>
|
||||
),
|
||||
]);
|
||||
|
||||
const capabilities = promiseFulfilledResult(result[0]);
|
||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
||||
const authMetadata = promiseFulfilledResult(result[2]);
|
||||
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||
|
||||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const [, capResult] = promiseFulfilledResult(result[0]) ?? [];
|
||||
const [, mediaConfig] = promiseFulfilledResult(result[1]) ?? [];
|
||||
const [, authMetadata] = promiseFulfilledResult(result[2]) ?? [];
|
||||
|
||||
return {
|
||||
capabilities,
|
||||
capabilities: capResult?.capabilities,
|
||||
mediaConfig,
|
||||
authMetadata: validatedAuthMetadata,
|
||||
authMetadata,
|
||||
};
|
||||
}, [mx])
|
||||
}, [ct])
|
||||
);
|
||||
|
||||
const configs: ServerConfigs =
|
||||
|
|
|
|||
|
|
@ -2,6 +2,29 @@ import { ReactNode, useCallback, useEffect, useState } from 'react';
|
|||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { SpecVersions, specVersions } from '../cs-api';
|
||||
|
||||
const getSpecVersionKey = (baseUrl: string) => `specVersions:${baseUrl}`;
|
||||
const setSpecVersions = (baseUrl: string, data: SpecVersions) => {
|
||||
localStorage.setItem(getSpecVersionKey(baseUrl), JSON.stringify(data));
|
||||
};
|
||||
|
||||
const getSpecVersions = (baseUrl: string): SpecVersions | undefined => {
|
||||
try {
|
||||
const dataStr = localStorage.getItem(getSpecVersionKey(baseUrl));
|
||||
if (dataStr === null) return undefined;
|
||||
const data = JSON.parse(dataStr);
|
||||
if (typeof data === 'object' && 'versions' in data && Array.isArray(data.versions)) {
|
||||
return data;
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const FALLBACK_DATA: SpecVersions = {
|
||||
versions: [],
|
||||
};
|
||||
|
||||
type SpecVersionsLoaderProps = {
|
||||
baseUrl: string;
|
||||
fallback?: () => ReactNode;
|
||||
|
|
@ -15,8 +38,15 @@ export function SpecVersionsLoader({
|
|||
children,
|
||||
}: SpecVersionsLoaderProps) {
|
||||
const [state, load] = useAsyncCallback(
|
||||
useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
|
||||
useCallback(async () => {
|
||||
const data = await specVersions(fetch, baseUrl);
|
||||
setSpecVersions(baseUrl, data);
|
||||
return data;
|
||||
}, [baseUrl])
|
||||
);
|
||||
|
||||
const data = state.status === AsyncStatus.Success ? state.data : getSpecVersions(baseUrl);
|
||||
|
||||
const [ignoreError, setIgnoreError] = useState(false);
|
||||
|
||||
const ignoreCallback = useCallback(() => setIgnoreError(true), []);
|
||||
|
|
@ -25,19 +55,13 @@ export function SpecVersionsLoader({
|
|||
load();
|
||||
}, [load]);
|
||||
|
||||
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
|
||||
if (!data && (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading)) {
|
||||
return fallback?.();
|
||||
}
|
||||
|
||||
if (!ignoreError && state.status === AsyncStatus.Error) {
|
||||
if (!data && !ignoreError && state.status === AsyncStatus.Error) {
|
||||
return error?.(state.error, load, ignoreCallback);
|
||||
}
|
||||
|
||||
return children(
|
||||
state.status === AsyncStatus.Success
|
||||
? state.data
|
||||
: {
|
||||
versions: [],
|
||||
}
|
||||
);
|
||||
return children(state.status === AsyncStatus.Success ? state.data : data ?? FALLBACK_DATA);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { UIAFlow } from 'matrix-js-sdk';
|
||||
import { useSupportedUIAFlows } from '../hooks/useUIAFlows';
|
||||
import { UIAFlow } from '../utils/matrix-uia';
|
||||
|
||||
export function SupportedUIAFlowsLoader({
|
||||
flows,
|
||||
|
|
|
|||
|
|
@ -27,19 +27,19 @@ import React, {
|
|||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
|
||||
import { useDirectUsers } from '../../hooks/useDirectUsers';
|
||||
import { SettingTile } from '../setting-tile';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
|
||||
export const useAdditionalCreators = (defaultCreators?: string[]) => {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const [additionalCreators, setAdditionalCreators] = useState<string[]>(
|
||||
() => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? []
|
||||
() => defaultCreators?.filter((id) => id !== ct.session.userId) ?? []
|
||||
);
|
||||
|
||||
const addAdditionalCreator = (userId: string) => {
|
||||
if (userId === mx.getSafeUserId()) return;
|
||||
if (userId === ct.session.userId) return;
|
||||
|
||||
setAdditionalCreators((creators) => {
|
||||
const creatorsSet = new Set(creators);
|
||||
|
|
@ -83,7 +83,7 @@ export function AdditionalCreatorInput({
|
|||
onRemove,
|
||||
disabled,
|
||||
}: AdditionalCreatorInputProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const directUsers = useDirectUsers();
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ export function AdditionalCreatorInput({
|
|||
<Box shrink="No" direction="Column" gap="100">
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Chip type="button" variant="Primary" radii="Pill" outlined>
|
||||
<Text size="B300">{mx.getSafeUserId()}</Text>
|
||||
<Text size="B300">{ct.session.userId}</Text>
|
||||
</Chip>
|
||||
{additionalCreators.map((creator) => (
|
||||
<Chip
|
||||
|
|
|
|||
|
|
@ -6,17 +6,18 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { getRoom, GetRoomOK } from 'neurone/lib/api/matrix/client/v3/directory';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { replaceSpaceWithDash } from '../../utils/common';
|
||||
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
|
||||
export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const aliasInputRef = useRef<HTMLInputElement>(null);
|
||||
const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({
|
||||
status: AsyncStatus.Idle,
|
||||
|
|
@ -31,18 +32,20 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
|||
const checkAliasAvail = useAsync(
|
||||
useCallback(
|
||||
async (aliasLocalPart: string) => {
|
||||
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`;
|
||||
try {
|
||||
const result = await mx.getRoomIdForAlias(roomAlias);
|
||||
return typeof result.room_id !== 'string';
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.httpStatus === 404) {
|
||||
return true;
|
||||
}
|
||||
throw e;
|
||||
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(ct.session.userId)}`;
|
||||
const response = await ct.requestAuthJSON(
|
||||
getRoom({
|
||||
pathParams: { roomAlias },
|
||||
})
|
||||
);
|
||||
const [err, result] = await parseMatrixResp<GetRoomOK>(response);
|
||||
if (err) {
|
||||
if (err.status === 404) return true;
|
||||
throw new Error(printMatrixError(err));
|
||||
}
|
||||
return typeof result.room_id !== 'string';
|
||||
},
|
||||
[mx]
|
||||
[ct]
|
||||
),
|
||||
setAliasAvail
|
||||
);
|
||||
|
|
@ -94,7 +97,7 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
|||
}
|
||||
after={
|
||||
<Text style={{ maxWidth: toRem(150) }} truncate>
|
||||
:{getMxIdServer(mx.getSafeUserId())}
|
||||
:{getMxIdServer(ct.session.userId)}
|
||||
</Text>
|
||||
}
|
||||
onKeyDown={handleAliasKeyDown}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import {
|
||||
ICreateRoomOpts,
|
||||
ICreateRoomStateEvent,
|
||||
JoinRule,
|
||||
MatrixClient,
|
||||
RestrictedAllowType,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
CreateRoomStateEvent,
|
||||
postCreateRoom,
|
||||
PostCreateRoomBody,
|
||||
PostCreateRoomOK,
|
||||
} from 'neurone/lib/api/matrix/client/v3/createRoom';
|
||||
import { MatrixResponseResult, parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms';
|
||||
import { CreateRoomKind } from './CreateRoomKindSelector';
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { JoinRulesContent, RoomType, StateEvent, StateEventType } from '../../../types/matrix/room';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
import { ClientContext } from '../../../client/context';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { getJoinRoomStateId } from '../../../client/storage/collections';
|
||||
|
||||
export const createRoomCreationContent = (
|
||||
const createRoomCreationContent = (
|
||||
type: RoomType | undefined,
|
||||
allowFederation: boolean,
|
||||
additionalCreators: string[] | undefined
|
||||
|
|
@ -31,28 +32,28 @@ export const createRoomCreationContent = (
|
|||
return content;
|
||||
};
|
||||
|
||||
export const createRoomJoinRulesState = (
|
||||
const createRoomJoinRulesState = (
|
||||
kind: CreateRoomKind,
|
||||
parent: Room | undefined,
|
||||
parentId: string | undefined,
|
||||
knock: boolean
|
||||
) => {
|
||||
let content: RoomJoinRulesEventContent = {
|
||||
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
|
||||
let content: JoinRulesContent = {
|
||||
join_rule: knock ? 'knock' : 'invite',
|
||||
};
|
||||
|
||||
if (kind === CreateRoomKind.Public) {
|
||||
content = {
|
||||
join_rule: JoinRule.Public,
|
||||
join_rule: 'public',
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === CreateRoomKind.Restricted && parent) {
|
||||
if (kind === CreateRoomKind.Restricted && parentId) {
|
||||
content = {
|
||||
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
|
||||
join_rule: knock ? 'knock_restricted' : 'restricted',
|
||||
allow: [
|
||||
{
|
||||
type: RestrictedAllowType.RoomMembership,
|
||||
room_id: parent.roomId,
|
||||
type: [StateEventType.RoomMember],
|
||||
room_id: parentId,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -65,12 +66,12 @@ export const createRoomJoinRulesState = (
|
|||
};
|
||||
};
|
||||
|
||||
export const createRoomParentState = (parent: Room) => ({
|
||||
const createRoomParentState = (parentId: string, via: string[]) => ({
|
||||
type: StateEvent.SpaceParent,
|
||||
state_key: parent.roomId,
|
||||
state_key: parentId,
|
||||
content: {
|
||||
canonical: true,
|
||||
via: getViaServers(parent),
|
||||
via,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -85,7 +86,7 @@ export const createRoomEncryptionState = () => ({
|
|||
export type CreateRoomData = {
|
||||
version: string;
|
||||
type?: RoomType;
|
||||
parent?: Room;
|
||||
parentId?: string;
|
||||
kind: CreateRoomKind;
|
||||
name: string;
|
||||
topic?: string;
|
||||
|
|
@ -95,20 +96,29 @@ export type CreateRoomData = {
|
|||
allowFederation: boolean;
|
||||
additionalCreators?: string[];
|
||||
};
|
||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||
const initialState: ICreateRoomStateEvent[] = [];
|
||||
export const createRoom = async (
|
||||
ct: ClientContext,
|
||||
data: CreateRoomData
|
||||
): Promise<MatrixResponseResult<string>> => {
|
||||
const initialState: CreateRoomStateEvent[] = [];
|
||||
|
||||
if (data.encryption) {
|
||||
initialState.push(createRoomEncryptionState());
|
||||
}
|
||||
|
||||
if (data.parent) {
|
||||
initialState.push(createRoomParentState(data.parent));
|
||||
if (data.parentId) {
|
||||
const creators = ct.roomToCreators.read.get(data.parentId);
|
||||
const powerLevelsObj = await ct.storage.joinRoomState.read.get(
|
||||
getJoinRoomStateId(data.parentId, StateEventType.RoomPowerLevels, '')
|
||||
);
|
||||
const powerLevelsContent = powerLevelsObj?.event.content;
|
||||
const via = getViaServers(creators, powerLevelsContent);
|
||||
initialState.push(createRoomParentState(data.parentId, via));
|
||||
}
|
||||
|
||||
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
|
||||
initialState.push(createRoomJoinRulesState(data.kind, data.parentId, data.knock));
|
||||
|
||||
const options: ICreateRoomOpts = {
|
||||
const body: PostCreateRoomBody = {
|
||||
room_version: data.version,
|
||||
name: data.name,
|
||||
topic: data.topic,
|
||||
|
|
@ -121,20 +131,31 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||
initial_state: initialState,
|
||||
};
|
||||
|
||||
const result = await mx.createRoom(options);
|
||||
const response = await ct.requestAuthJSON(postCreateRoom({ body }));
|
||||
|
||||
if (data.parent) {
|
||||
await mx.sendStateEvent(
|
||||
data.parent.roomId,
|
||||
StateEvent.SpaceChild as any,
|
||||
{
|
||||
auto_join: false,
|
||||
suggested: false,
|
||||
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
|
||||
},
|
||||
result.room_id
|
||||
);
|
||||
const [err, result] = await parseMatrixResp<PostCreateRoomOK>(response);
|
||||
|
||||
if (err) {
|
||||
return [err, undefined];
|
||||
}
|
||||
|
||||
return result.room_id;
|
||||
if (data.parentId) {
|
||||
const resp = await ct.requestAuthJSON(
|
||||
putStateEvent({
|
||||
pathParams: {
|
||||
roomId: data.parentId,
|
||||
eventType: StateEventType.SpaceChild,
|
||||
stateKey: result.room_id,
|
||||
},
|
||||
body: {
|
||||
auto_join: false,
|
||||
suggested: false,
|
||||
via: [getMxIdServer(ct.session.userId) ?? ''],
|
||||
},
|
||||
})
|
||||
);
|
||||
await parseMatrixResp(resp);
|
||||
}
|
||||
|
||||
return [undefined, result.room_id];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import {
|
|||
|
||||
import * as css from '../../styles/CustomHtml.css';
|
||||
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getBeginCommand } from './utils';
|
||||
import { BlockType } from './types';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
|
||||
// Put this at the start and end of an inline component to work around this Chromium bug:
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
|
||||
|
|
@ -77,7 +77,7 @@ function RenderEmoticonElement({
|
|||
element,
|
||||
children,
|
||||
}: { element: EmoticonElement } & RenderElementProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
|
|
@ -93,7 +93,7 @@ function RenderEmoticonElement({
|
|||
{element.key.startsWith('mxc://') ? (
|
||||
<img
|
||||
className={css.EmoticonImg}
|
||||
src={mxcUrlToHttp(mx, element.key, useAuthentication) ?? element.key}
|
||||
src={mxcToHttp(ct.session.baseUrl, element.key, useAuthentication).url() ?? element.key}
|
||||
alt={element.shortcode}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { Box, MenuItem, Text, toRem } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
|
||||
import { AutocompleteQuery } from './autocompleteQuery';
|
||||
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
||||
import { onTabPress } from '../../../utils/keyboard';
|
||||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||
|
|
@ -13,17 +10,18 @@ import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
|||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||
import { IEmoji, emojis } from '../../../plugins/emoji';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
|
||||
import { getEmoticonSearchStr } from '../../../plugins/utils';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
|
||||
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
|
||||
|
||||
type EmoticonSearchItem = PackImageReader | IEmoji;
|
||||
|
||||
type EmoticonAutocompleteProps = {
|
||||
imagePackRooms: Room[];
|
||||
imagePackRooms: string[];
|
||||
editor: Editor;
|
||||
query: AutocompleteQuery<string>;
|
||||
requestClose: () => void;
|
||||
|
|
@ -41,11 +39,11 @@ export function EmoticonAutocomplete({
|
|||
query,
|
||||
requestClose,
|
||||
}: EmoticonAutocompleteProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
||||
const recentEmoji = useRecentEmoji(mx, 20);
|
||||
const [imagePacks] = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
||||
const recentEmojiResult = useRecentEmoji(20);
|
||||
|
||||
const searchList = useMemo(() => {
|
||||
const list: Array<EmoticonSearchItem> = [];
|
||||
|
|
@ -60,7 +58,9 @@ export function EmoticonAutocomplete({
|
|||
getEmoticonSearchStr,
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji;
|
||||
const autoCompleteEmoticon: EmoticonSearchItem[] = result
|
||||
? result.items.slice(0, 20)
|
||||
: recentEmojiResult.value ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (query.text) search(query.text);
|
||||
|
|
@ -102,7 +102,7 @@ export function EmoticonAutocomplete({
|
|||
<Box
|
||||
shrink="No"
|
||||
as="img"
|
||||
src={mxcUrlToHttp(mx, key, useAuthentication) || key}
|
||||
src={mxcToHttp(ct.session.baseUrl, key, useAuthentication).url() || key}
|
||||
alt={emoticon.shortcode}
|
||||
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,34 @@
|
|||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
|
||||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
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 { getDirectRoomAvatarUrl } from '../../../utils/room';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AutocompleteQuery } from './autocompleteQuery';
|
||||
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
|
||||
import { getMxIdServer, isRoomAlias, isRoomId } from '../../../utils/matrix';
|
||||
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 { factoryRoomIdByAtoZ } from '../../../utils/sort';
|
||||
import { RoomAvatar, RoomIcon } from '../../room-avatar';
|
||||
import { getViaServers } from '../../../plugins/via-servers';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros';
|
||||
import { useGetRoomName } from '../../../hooks/storage/useGetRoomName';
|
||||
import { useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
|
||||
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
||||
|
||||
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
||||
const roomAliasFromQueryText = (myUserId: string, text: string) =>
|
||||
isRoomAlias(`#${text}`)
|
||||
? `#${text}`
|
||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`;
|
||||
|
||||
function UnknownRoomMentionItem({
|
||||
query,
|
||||
|
|
@ -33,8 +37,8 @@ function UnknownRoomMentionItem({
|
|||
query: AutocompleteQuery<string>;
|
||||
handleAutocomplete: MentionAutoCompleteHandler;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const roomAlias: string = roomAliasFromQueryText(mx, query.text);
|
||||
const ct = useClient();
|
||||
const roomAlias: string = roomAliasFromQueryText(ct.session.userId, query.text);
|
||||
|
||||
const handleSelect = () => handleAutocomplete(roomAlias, roomAlias);
|
||||
|
||||
|
|
@ -76,22 +80,30 @@ export function RoomMentionAutocomplete({
|
|||
query,
|
||||
requestClose,
|
||||
}: RoomMentionAutocompleteProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const rooms = useAtomValue(allRoomsAtom);
|
||||
|
||||
const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
|
||||
const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(rooms);
|
||||
const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value);
|
||||
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value);
|
||||
|
||||
const allRooms = useMemo(
|
||||
() => Array.from(rooms).sort(factoryRoomIdByAtoZ(getRoomName)),
|
||||
[rooms, getRoomName]
|
||||
);
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
allRooms,
|
||||
useCallback(
|
||||
(rId) => {
|
||||
const r = mx.getRoom(rId);
|
||||
if (!r) return 'Unknown Room';
|
||||
const alias = r.getCanonicalAlias();
|
||||
if (alias) return [r.name, alias];
|
||||
return r.name;
|
||||
const name = getRoomName(rId);
|
||||
if (!name) return 'Unknown Room';
|
||||
const alias = ct.roomToAlias.read.get(rId);
|
||||
if (alias) return [name, alias];
|
||||
return name;
|
||||
},
|
||||
[mx]
|
||||
[ct, getRoomName]
|
||||
),
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
|
|
@ -104,12 +116,14 @@ export function RoomMentionAutocomplete({
|
|||
}, [query.text, search, resetSearch]);
|
||||
|
||||
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
|
||||
const mentionRoom = mx.getRoom(roomAliasOrId);
|
||||
const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined;
|
||||
const creators = isRoomId(roomAliasOrId)
|
||||
? ct.roomToCreators.read.get(roomAliasOrId)
|
||||
: undefined;
|
||||
const viaServers = creators ? getViaServers(creators) : undefined;
|
||||
const mentionEl = createMentionElement(
|
||||
roomAliasOrId,
|
||||
name.startsWith('#') ? name : `#${name}`,
|
||||
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId,
|
||||
roomId === roomAliasOrId || ct.roomToAlias.read.get(roomId) === roomAliasOrId,
|
||||
undefined,
|
||||
viaServers
|
||||
);
|
||||
|
|
@ -121,14 +135,13 @@ export function RoomMentionAutocomplete({
|
|||
useKeyDown(window, (evt: KeyboardEvent) => {
|
||||
onTabPress(evt, () => {
|
||||
if (autoCompleteRoomIds.length === 0) {
|
||||
const alias = roomAliasFromQueryText(mx, query.text);
|
||||
const alias = roomAliasFromQueryText(ct.session.userId, query.text);
|
||||
handleAutocomplete(alias, alias);
|
||||
return;
|
||||
}
|
||||
const rId = autoCompleteRoomIds[0];
|
||||
const r = mx.getRoom(rId);
|
||||
const name = r?.name ?? rId;
|
||||
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
|
||||
const name = getRoomName(rId) ?? 'Unknown';
|
||||
handleAutocomplete(ct.roomToAlias.read.get(rId) ?? rId, name);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -138,11 +151,19 @@ export function RoomMentionAutocomplete({
|
|||
<UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
|
||||
) : (
|
||||
autoCompleteRoomIds.map((rId) => {
|
||||
const room = mx.getRoom(rId);
|
||||
if (!room) return null;
|
||||
const dm = mDirects.has(room.roomId);
|
||||
const dm = mDirects.has(rId);
|
||||
const name = getRoomName(rId) ?? 'Unknown';
|
||||
const alias = ct.roomToAlias.read.get(rId);
|
||||
const avatarMxc = getRoomAvatar(rId);
|
||||
const avatar = avatarMxc
|
||||
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({
|
||||
width: 32,
|
||||
height: 32,
|
||||
method: 'crop',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
|
||||
const handleSelect = () => handleAutocomplete(alias ?? rId, name);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
|
|
@ -155,32 +176,34 @@ export function RoomMentionAutocomplete({
|
|||
onClick={handleSelect}
|
||||
after={
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{room.getCanonicalAlias() ?? ''}
|
||||
{alias}
|
||||
</Text>
|
||||
}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
{dm ? (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={getDirectRoomAvatarUrl(mx, room)}
|
||||
alt={room.name}
|
||||
roomId={rId}
|
||||
src={avatar}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
size="50"
|
||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||
filled
|
||||
/>
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
|
||||
<RoomIcon
|
||||
size="100"
|
||||
joinRule={ct.roomToJoinRule.read.get(rId)}
|
||||
space={ct.spaceRooms.read.has(rId)}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||
{room.name}
|
||||
{name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,32 +1,29 @@
|
|||
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent, useCallback } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
|
||||
import { AutocompleteQuery } from './autocompleteQuery';
|
||||
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||
import { useRoomMembers } from '../../../hooks/useRoomMembers';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
UseAsyncSearchOptions,
|
||||
useAsyncSearch,
|
||||
} from '../../../hooks/useAsyncSearch';
|
||||
import { GetRoomMemberMembershipCallback, useRoomMembersAPI } from '../../../hooks/useRoomMembers';
|
||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
||||
import { onTabPress } from '../../../utils/keyboard';
|
||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
|
||||
import { getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||
import { getMemberSearchStr } from '../../../utils/room';
|
||||
import { UserAvatar } from '../../user-avatar';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { Membership } from '../../../../types/matrix/room';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { JoinRoomMemberObject } from '../../../../client/storage/collections';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
|
||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||
|
||||
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
||||
const userIdFromQueryText = (myUserId: string, text: string) =>
|
||||
isUserId(`@${text}`)
|
||||
? `@${text}`
|
||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`;
|
||||
|
||||
function UnknownMentionItem({
|
||||
userId,
|
||||
|
|
@ -62,16 +59,18 @@ function UnknownMentionItem({
|
|||
}
|
||||
|
||||
type UserMentionAutocompleteProps = {
|
||||
room: Room;
|
||||
roomId: string;
|
||||
editor: Editor;
|
||||
query: AutocompleteQuery<string>;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
const withAllowedMembership = (member: RoomMember): boolean =>
|
||||
member.membership === Membership.Join ||
|
||||
member.membership === Membership.Invite ||
|
||||
member.membership === Membership.Knock;
|
||||
const withAllowedMembership =
|
||||
(getMembership: GetRoomMemberMembershipCallback) =>
|
||||
(member: JoinRoomMemberObject): boolean =>
|
||||
getMembership(member.userId) === 'join' ||
|
||||
getMembership(member.userId) === 'invite' ||
|
||||
getMembership(member.userId) === 'knock';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
|
|
@ -80,25 +79,28 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
|||
},
|
||||
};
|
||||
|
||||
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
|
||||
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
|
||||
getMemberSearchStr(m, query, mxIdToName);
|
||||
const EMPTY_MEMBER_LIST: JoinRoomMemberObject[] = [];
|
||||
|
||||
export function UserMentionAutocomplete({
|
||||
room,
|
||||
roomId,
|
||||
editor,
|
||||
query,
|
||||
requestClose,
|
||||
}: UserMentionAutocompleteProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const roomId: string = room.roomId!;
|
||||
const roomAliasOrId = room.getCanonicalAlias() || roomId;
|
||||
const members = useRoomMembers(mx, roomId);
|
||||
const roomAliasOrId = ct.roomToAlias.read.get(roomId) || roomId;
|
||||
const membersAPI = useRoomMembersAPI();
|
||||
const members = membersAPI.memberEventsResult.value ?? EMPTY_MEMBER_LIST;
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
members,
|
||||
useCallback((m, q) => getMemberSearchStr(m, q, membersAPI.getName), [membersAPI.getName]),
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
|
||||
const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter(
|
||||
withAllowedMembership
|
||||
withAllowedMembership(membersAPI.getMembership)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -110,7 +112,7 @@ export function UserMentionAutocomplete({
|
|||
const mentionEl = createMentionElement(
|
||||
uId,
|
||||
name.startsWith('@') ? name : `@${name}`,
|
||||
mx.getUserId() === uId || roomAliasOrId === uId
|
||||
uId === ct.session.userId || roomAliasOrId === uId
|
||||
);
|
||||
replaceWithElement(editor, query.range, mentionEl);
|
||||
moveCursor(editor, true);
|
||||
|
|
@ -124,18 +126,15 @@ export function UserMentionAutocomplete({
|
|||
return;
|
||||
}
|
||||
if (autoCompleteMembers.length === 0) {
|
||||
const userId = userIdFromQueryText(mx, query.text);
|
||||
const userId = userIdFromQueryText(ct.session.userId, query.text);
|
||||
handleAutocomplete(userId, userId);
|
||||
return;
|
||||
}
|
||||
const roomMember = autoCompleteMembers[0];
|
||||
handleAutocomplete(roomMember.userId, roomMember.name);
|
||||
handleAutocomplete(roomMember.userId, membersAPI.getName(roomMember.userId));
|
||||
});
|
||||
});
|
||||
|
||||
const getName = (member: RoomMember) =>
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
|
||||
return (
|
||||
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
|
||||
{query.text === 'room' && (
|
||||
|
|
@ -147,15 +146,20 @@ export function UserMentionAutocomplete({
|
|||
)}
|
||||
{autoCompleteMembers.length === 0 ? (
|
||||
<UnknownMentionItem
|
||||
userId={userIdFromQueryText(mx, query.text)}
|
||||
name={userIdFromQueryText(mx, query.text)}
|
||||
userId={userIdFromQueryText(ct.session.userId, query.text)}
|
||||
name={userIdFromQueryText(ct.session.userId, query.text)}
|
||||
handleAutocomplete={handleAutocomplete}
|
||||
/>
|
||||
) : (
|
||||
autoCompleteMembers.map((roomMember) => {
|
||||
const avatarMxcUrl = roomMember.getMxcAvatarUrl();
|
||||
const name = membersAPI.getName(roomMember.userId);
|
||||
const avatarMxcUrl = membersAPI.getAvatar(roomMember.userId);
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
|
||||
? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
|
||||
width: 32,
|
||||
height: 32,
|
||||
method: 'crop',
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<MenuItem
|
||||
|
|
@ -163,9 +167,9 @@ export function UserMentionAutocomplete({
|
|||
as="button"
|
||||
radii="300"
|
||||
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||
onTabPress(evt, () => handleAutocomplete(roomMember.userId, getName(roomMember)))
|
||||
onTabPress(evt, () => handleAutocomplete(roomMember.userId, name))
|
||||
}
|
||||
onClick={() => handleAutocomplete(roomMember.userId, getName(roomMember))}
|
||||
onClick={() => handleAutocomplete(roomMember.userId, name)}
|
||||
after={
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{roomMember.userId}
|
||||
|
|
@ -176,14 +180,18 @@ export function UserMentionAutocomplete({
|
|||
<UserAvatar
|
||||
userId={roomMember.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName(roomMember)}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||
{getName(roomMember)}
|
||||
{name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Descendant, Editor, Text } from 'slate';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { sanitizeText } from '../../utils/sanitize';
|
||||
import { BlockType } from './types';
|
||||
import { CustomElement } from './slate';
|
||||
|
|
@ -11,7 +10,8 @@ import {
|
|||
} from '../../plugins/markdown';
|
||||
import { findAndReplace } from '../../utils/findAndReplace';
|
||||
import { sanitizeForRegex } from '../../utils/regex';
|
||||
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
|
||||
import { isUserId } from '../../utils/matrix';
|
||||
import { ClientContext } from '../../../client/context';
|
||||
|
||||
export type OutputOptions = {
|
||||
allowTextFormatting?: boolean;
|
||||
|
|
@ -201,7 +201,7 @@ export type MentionsData = {
|
|||
room: boolean;
|
||||
users: Set<string>;
|
||||
};
|
||||
export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => {
|
||||
export const getMentions = (ct: ClientContext, editor: Editor): MentionsData => {
|
||||
const mentionData: MentionsData = {
|
||||
room: false,
|
||||
users: new Set(),
|
||||
|
|
@ -212,10 +212,10 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
|
|||
if (node.type === BlockType.CodeBlock) return;
|
||||
|
||||
if (node.type === BlockType.Mention) {
|
||||
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
|
||||
if (node.name === '@room') {
|
||||
mentionData.room = true;
|
||||
}
|
||||
if (isUserId(node.id) && node.id !== mx.getUserId()) {
|
||||
if (isUserId(node.id) && node.id !== ct.session.userId) {
|
||||
mentionData.users.add(node.id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import React, {
|
|||
import { Box, config, Icons, Scroll } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
||||
|
|
@ -20,9 +19,8 @@ import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
|||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { isUserId } from '../../utils/matrix';
|
||||
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
|
||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
|
|
@ -52,6 +50,10 @@ import {
|
|||
} from './components';
|
||||
import { EmojiBoardTab, EmojiType } from './types';
|
||||
import { VirtualTile } from '../virtualizer';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { useRoomHeroes } from '../../hooks/storage/useRoomHeros';
|
||||
import { GetRoomNameCallback, useGetRoomName } from '../../hooks/storage/useGetRoomName';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
const SEARCH_GROUP_ID = 'search_group';
|
||||
|
|
@ -69,11 +71,10 @@ type StickerGroupItem = {
|
|||
|
||||
const useGroups = (
|
||||
tab: EmojiBoardTab,
|
||||
imagePacks: ImagePack[]
|
||||
imagePacks: ImagePack[],
|
||||
getRoomName: GetRoomNameCallback
|
||||
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
const recentEmojisResult = useRecentEmoji(21);
|
||||
const labels = useEmojiGroupLabels();
|
||||
|
||||
const emojiGroupItems = useMemo(() => {
|
||||
|
|
@ -83,12 +84,12 @@ const useGroups = (
|
|||
g.push({
|
||||
id: RECENT_GROUP_ID,
|
||||
name: 'Recent',
|
||||
items: recentEmojis,
|
||||
items: recentEmojisResult.value ?? [],
|
||||
});
|
||||
|
||||
imagePacks.forEach((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id);
|
||||
|
||||
g.push({
|
||||
id: pack.id,
|
||||
|
|
@ -108,7 +109,7 @@ const useGroups = (
|
|||
});
|
||||
|
||||
return g;
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
||||
}, [getRoomName, recentEmojisResult, labels, imagePacks, tab]);
|
||||
|
||||
const stickerGroupItems = useMemo(() => {
|
||||
const g: StickerGroupItem[] = [];
|
||||
|
|
@ -116,7 +117,7 @@ const useGroups = (
|
|||
|
||||
imagePacks.forEach((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id);
|
||||
|
||||
g.push({
|
||||
id: pack.id,
|
||||
|
|
@ -128,37 +129,24 @@ const useGroups = (
|
|||
});
|
||||
|
||||
return g;
|
||||
}, [mx, imagePacks, tab]);
|
||||
}, [getRoomName, imagePacks, tab]);
|
||||
|
||||
return [emojiGroupItems, stickerGroupItems];
|
||||
};
|
||||
|
||||
const useItemRenderer = (tab: EmojiBoardTab) => {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const renderItem = (emoji: IEmoji | PackImageReader, index: number) => {
|
||||
if ('unicode' in emoji) {
|
||||
return <EmojiItem key={emoji.unicode + index} emoji={emoji} />;
|
||||
}
|
||||
const url = mxcToHttp(ct.session.baseUrl, emoji.url, useAuthentication).url();
|
||||
if (tab === EmojiBoardTab.Sticker) {
|
||||
return (
|
||||
<StickerItem
|
||||
key={emoji.shortcode + index}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
image={emoji}
|
||||
/>
|
||||
);
|
||||
return <StickerItem key={emoji.shortcode + index} url={url} image={emoji} />;
|
||||
}
|
||||
return (
|
||||
<CustomEmojiItem
|
||||
key={emoji.shortcode + index}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
image={emoji}
|
||||
/>
|
||||
);
|
||||
return <CustomEmojiItem key={emoji.shortcode + index} url={url} image={emoji} />;
|
||||
};
|
||||
|
||||
return renderItem;
|
||||
|
|
@ -168,9 +156,10 @@ type EmojiSidebarProps = {
|
|||
activeGroupAtom: PrimitiveAtom<string | undefined>;
|
||||
packs: ImagePack[];
|
||||
onScrollToGroup: (groupId: string) => void;
|
||||
getRoomName: GetRoomNameCallback;
|
||||
};
|
||||
function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarProps) {
|
||||
const mx = useMatrixClient();
|
||||
function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup, getRoomName }: EmojiSidebarProps) {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||
|
|
@ -199,11 +188,12 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||
<SidebarDivider />
|
||||
{packs.map((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id);
|
||||
|
||||
const url =
|
||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ||
|
||||
pack.meta.avatar;
|
||||
const avatarMxc = pack.getAvatarUrl(usage);
|
||||
const url = avatarMxc
|
||||
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url()
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ImageGroupIcon
|
||||
|
|
@ -245,9 +235,15 @@ type StickerSidebarProps = {
|
|||
activeGroupAtom: PrimitiveAtom<string | undefined>;
|
||||
packs: ImagePack[];
|
||||
onScrollToGroup: (groupId: string) => void;
|
||||
getRoomName: GetRoomNameCallback;
|
||||
};
|
||||
function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) {
|
||||
const mx = useMatrixClient();
|
||||
function StickerSidebar({
|
||||
activeGroupAtom,
|
||||
packs,
|
||||
onScrollToGroup,
|
||||
getRoomName,
|
||||
}: StickerSidebarProps) {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||
|
|
@ -263,10 +259,12 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||
<SidebarStack>
|
||||
{packs.map((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id);
|
||||
|
||||
const url =
|
||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || pack.meta.avatar;
|
||||
const avatarMxc = pack.getAvatarUrl(usage);
|
||||
const url = avatarMxc
|
||||
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url()
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ImageGroupIcon
|
||||
|
|
@ -355,7 +353,7 @@ const VIRTUAL_OVER_SCAN = 2;
|
|||
type EmojiBoardProps = {
|
||||
tab?: EmojiBoardTab;
|
||||
onTabChange?: (tab: EmojiBoardTab) => void;
|
||||
imagePackRooms: Room[];
|
||||
imagePackRooms: string[];
|
||||
requestClose: () => void;
|
||||
returnFocusOnDeactivate?: boolean;
|
||||
onEmojiSelect?: (unicode: string, shortcode: string) => void;
|
||||
|
|
@ -377,7 +375,9 @@ export function EmojiBoard({
|
|||
allowTextCustomEmoji,
|
||||
addToRecentEmoji = true,
|
||||
}: EmojiBoardProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(imagePackRooms);
|
||||
const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value);
|
||||
|
||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
|
||||
|
|
@ -388,8 +388,8 @@ export function EmojiBoard({
|
|||
);
|
||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
|
||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||
const [imagePacks, loading] = useRelevantImagePacks(usage, imagePackRooms);
|
||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks, getRoomName);
|
||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||
const renderItem = useItemRenderer(tab);
|
||||
|
||||
|
|
@ -438,7 +438,7 @@ export function EmojiBoard({
|
|||
if (emojiInfo.type === EmojiType.Emoji) {
|
||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
|
||||
addRecentEmoji(mx, emojiInfo.data);
|
||||
addRecentEmoji(ct, emojiInfo.data);
|
||||
}
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
||||
|
|
@ -522,57 +522,61 @@ export function EmojiBoard({
|
|||
activeGroupAtom={activeGroupIdAtom}
|
||||
packs={imagePacks}
|
||||
onScrollToGroup={handleScrollToGroup}
|
||||
getRoomName={getRoomName}
|
||||
/>
|
||||
) : (
|
||||
<StickerSidebar
|
||||
activeGroupAtom={activeGroupIdAtom}
|
||||
packs={imagePacks}
|
||||
onScrollToGroup={handleScrollToGroup}
|
||||
getRoomName={getRoomName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<EmojiGroupHolder
|
||||
key={tab}
|
||||
contentScrollRef={contentScrollRef}
|
||||
previewAtom={previewAtom}
|
||||
onGroupItemClick={handleGroupItemClick}
|
||||
>
|
||||
{searchedItems && (
|
||||
<EmojiGroup
|
||||
id={SEARCH_GROUP_ID}
|
||||
label={searchedItems.length ? 'Search Results' : 'No Results found'}
|
||||
>
|
||||
{searchedItems.map(renderItem)}
|
||||
</EmojiGroup>
|
||||
)}
|
||||
<div
|
||||
ref={virtualBaseRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
{!loading && (
|
||||
<EmojiGroupHolder
|
||||
key={tab}
|
||||
contentScrollRef={contentScrollRef}
|
||||
previewAtom={previewAtom}
|
||||
onGroupItemClick={handleGroupItemClick}
|
||||
>
|
||||
{vItems.map((vItem) => {
|
||||
const group = groups[vItem.index];
|
||||
{searchedItems && (
|
||||
<EmojiGroup
|
||||
id={SEARCH_GROUP_ID}
|
||||
label={searchedItems.length ? 'Search Results' : 'No Results found'}
|
||||
>
|
||||
{searchedItems.map(renderItem)}
|
||||
</EmojiGroup>
|
||||
)}
|
||||
<div
|
||||
ref={virtualBaseRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{vItems.map((vItem) => {
|
||||
const group = groups[vItem.index];
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingTop: config.space.S200 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<EmojiGroup key={group.id} id={group.id} label={group.name}>
|
||||
{group.items.map(renderItem)}
|
||||
</EmojiGroup>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
||||
</EmojiGroupHolder>
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingTop: config.space.S200 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<EmojiGroup key={group.id} id={group.id} label={group.name}>
|
||||
{group.items.map(renderItem)}
|
||||
</EmojiGroup>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
||||
</EmojiGroupHolder>
|
||||
)}
|
||||
</Box>
|
||||
<Preview previewAtom={previewAtom} />
|
||||
</EmojiBoardLayout>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import React from 'react';
|
||||
import { Box } from 'folds';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { EmojiItemInfo, EmojiType } from '../types';
|
||||
import * as css from './styles.css';
|
||||
import { PackImageReader } from '../../../plugins/custom-emoji';
|
||||
import { IEmoji } from '../../../plugins/emoji';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
|
||||
export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
|
||||
const label = element.getAttribute('title');
|
||||
|
|
@ -46,11 +44,10 @@ export function EmojiItem({ emoji }: EmojiItemProps) {
|
|||
}
|
||||
|
||||
type CustomEmojiItemProps = {
|
||||
mx: MatrixClient;
|
||||
useAuthentication?: boolean;
|
||||
url?: string;
|
||||
image: PackImageReader;
|
||||
};
|
||||
export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) {
|
||||
export function CustomEmojiItem({ url, image }: CustomEmojiItemProps) {
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
|
|
@ -68,19 +65,18 @@ export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiIte
|
|||
loading="lazy"
|
||||
className={css.CustomEmojiImg}
|
||||
alt={image.body || image.shortcode}
|
||||
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
|
||||
src={url ?? image.url}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type StickerItemProps = {
|
||||
mx: MatrixClient;
|
||||
useAuthentication?: boolean;
|
||||
url?: string;
|
||||
image: PackImageReader;
|
||||
};
|
||||
|
||||
export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) {
|
||||
export function StickerItem({ url, image }: StickerItemProps) {
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
|
|
@ -98,7 +94,7 @@ export function StickerItem({ mx, useAuthentication, image }: StickerItemProps)
|
|||
loading="lazy"
|
||||
className={css.StickerImg}
|
||||
alt={image.body || image.shortcode}
|
||||
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
|
||||
src={url ?? image.url}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { Box, Text } from 'folds';
|
|||
import React from 'react';
|
||||
import { Atom, atom, useAtomValue } from 'jotai';
|
||||
import * as css from './styles.css';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
|
||||
export type PreviewData = {
|
||||
key: string;
|
||||
|
|
@ -18,7 +18,7 @@ type PreviewProps = {
|
|||
previewAtom: Atom<PreviewData | undefined>;
|
||||
};
|
||||
export function Preview({ previewAtom }: PreviewProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const { key, shortcode } = useAtomValue(previewAtom) ?? {};
|
||||
|
|
@ -37,7 +37,7 @@ export function Preview({ previewAtom }: PreviewProps) {
|
|||
{key.startsWith('mxc://') ? (
|
||||
<img
|
||||
className={css.PreviewImg}
|
||||
src={mxcUrlToHttp(mx, key, useAuthentication) ?? key}
|
||||
src={mxcToHttp(ct.session.baseUrl, key, useAuthentication).url() ?? key}
|
||||
alt={shortcode}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
109
src/app/components/encrypted-event/EncryptedEvent.tsx
Normal file
109
src/app/components/encrypted-event/EncryptedEvent.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { as, Box, color, config, Icon, Icons, Text } from 'folds';
|
||||
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
|
||||
import {
|
||||
DecryptionManager,
|
||||
EventDecryptionIdle,
|
||||
EventDecryptionPending,
|
||||
EventDecryptionSuccess,
|
||||
} from '../../../client/decryption';
|
||||
import { useObservableValue } from '../../hooks/useObservableValue';
|
||||
|
||||
export const MessageDecryptionIdle = as<'div', { children?: never }>(({ ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.ShieldLock} />
|
||||
<i>Decrypt message event to view.</i>
|
||||
</Box>
|
||||
));
|
||||
|
||||
export const MessageDecryptionPending = as<'div', { children?: never }>(({ ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Clock} />
|
||||
<i>Waiting for message to be decrypted...</i>
|
||||
</Box>
|
||||
));
|
||||
|
||||
export const MessageDecryptionError = as<'div', { message: string; children?: never }>(
|
||||
({ message, ...props }, ref) => (
|
||||
<Box
|
||||
as="span"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{ color: color.Critical.Main, opacity: config.opacity.P300 }}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Icon size="50" src={Icons.Warning} />
|
||||
<i>{message}</i>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
type EncryptedMessageEventProps = {
|
||||
decryptionManager: DecryptionManager;
|
||||
roomId: string;
|
||||
event: MessageEventWithoutRoomId;
|
||||
children: (decryptedEvent: MessageEventWithoutRoomId) => ReactNode;
|
||||
};
|
||||
export function EncryptedMessageEvent({
|
||||
decryptionManager,
|
||||
roomId,
|
||||
event,
|
||||
children,
|
||||
}: EncryptedMessageEventProps) {
|
||||
const encryptedEvent = decryptionManager.decryptableMessageEvent(roomId, event);
|
||||
if (encryptedEvent.decryption.read instanceof EventDecryptionIdle) {
|
||||
encryptedEvent.decrypt();
|
||||
}
|
||||
const decryption = useObservableValue(encryptedEvent.decryption);
|
||||
|
||||
if (decryption instanceof EventDecryptionIdle) {
|
||||
return (
|
||||
<Text>
|
||||
<MessageDecryptionIdle />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (decryption instanceof EventDecryptionPending) {
|
||||
return (
|
||||
<Text>
|
||||
<MessageDecryptionPending />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (decryption instanceof EventDecryptionSuccess) {
|
||||
return children(decryption.event);
|
||||
}
|
||||
|
||||
const { cause, error } = decryption;
|
||||
|
||||
if (cause) {
|
||||
return (
|
||||
<Text>
|
||||
<MessageDecryptionError
|
||||
message={
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'Unknown error occurs while decrypting message.'
|
||||
}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
return (
|
||||
<Text>
|
||||
<MessageDecryptionError message="Unknown error occurs while decrypting message." />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<MessageDecryptionError message={error.description} />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
1
src/app/components/encrypted-event/index.ts
Normal file
1
src/app/components/encrypted-event/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './EncryptedEvent';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Avatar,
|
||||
|
|
@ -13,33 +13,46 @@ import {
|
|||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import * as css from './EventReaders.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { useObservableValuePromise } from '../../hooks/useObservableValue';
|
||||
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
|
||||
export type EventReadersProps = {
|
||||
room: Room;
|
||||
eventId: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
export const EventReaders = as<'div', EventReadersProps>(
|
||||
({ className, room, eventId, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const latestEventReaders = useRoomEventReaders(room, eventId);
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
({ className, eventId, requestClose, ...props }, ref) => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const membersAPI = useRoomMembersAPI();
|
||||
|
||||
const getName = (userId: string) =>
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const eventReceipt = useObservableValuePromise(
|
||||
useMemo(
|
||||
() => ct.storage.readReceipt.observableRead.queryIndex('eventId', eventId),
|
||||
[ct, eventId]
|
||||
)
|
||||
);
|
||||
|
||||
const users: string[] = useMemo(() => {
|
||||
const u = new Set<string>();
|
||||
eventReceipt.value?.forEach((r) => {
|
||||
u.add(r.userId);
|
||||
});
|
||||
return Array.from(u);
|
||||
}, [eventReceipt]);
|
||||
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const spaceId = useSpaceOptionally();
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -59,19 +72,15 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||
<Box grow="Yes">
|
||||
<Scroll visibility="Hover" hideTrack size="300">
|
||||
<Box className={css.Content} direction="Column">
|
||||
{latestEventReaders.map((readerId) => {
|
||||
const name = getName(readerId);
|
||||
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
||||
{users.map((readerId) => {
|
||||
const name = membersAPI.getName(readerId);
|
||||
const avatarMxcUrl = membersAPI.getAvatar(readerId);
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(
|
||||
avatarMxcUrl,
|
||||
100,
|
||||
100,
|
||||
'crop',
|
||||
undefined,
|
||||
false,
|
||||
useAuthentication
|
||||
)
|
||||
? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
|
||||
width: 96,
|
||||
height: 96,
|
||||
method: 'crop',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
|
@ -81,8 +90,8 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||
radii="400"
|
||||
onClick={(event) => {
|
||||
openProfile(
|
||||
room.roomId,
|
||||
space?.roomId,
|
||||
roomId,
|
||||
spaceId,
|
||||
readerId,
|
||||
getMouseEventCords(event.nativeEvent),
|
||||
'Bottom'
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import * as css from './style.css';
|
|||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { CompactUploadCardRenderer } from '../upload-card';
|
||||
import { UploadSuccess } from '../../state/upload';
|
||||
import { getImageInfo, TUploadContent } from '../../utils/matrix';
|
||||
import { getImageInfo, UploadContent } from '../../utils/matrix';
|
||||
import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom';
|
||||
import { replaceSpaceWithDash, suffixRename } from '../../utils/common';
|
||||
import { getFileNameWithoutExt } from '../../utils/mimeTypes';
|
||||
|
|
@ -110,7 +110,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
|
|||
[imagePack.meta]
|
||||
);
|
||||
|
||||
const handleUploadRemove = useCallback((file: TUploadContent) => {
|
||||
const handleUploadRemove = useCallback((file: UploadContent) => {
|
||||
setFiles((fs) => fs.filter((f) => f !== file));
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/cust
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { SettingTile } from '../setting-tile';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { createUploadAtom, TUploadAtom } from '../../state/upload';
|
||||
import { createUploadAtom, UploadAtom } from '../../state/upload';
|
||||
import { replaceSpaceWithDash } from '../../utils/common';
|
||||
|
||||
type ImageTileProps = {
|
||||
|
|
@ -91,7 +91,7 @@ export function ImageTile({
|
|||
|
||||
type ImageTileUploadProps = {
|
||||
file: File;
|
||||
children: (uploadAtom: TUploadAtom) => ReactNode;
|
||||
children: (uploadAtom: UploadAtom) => ReactNode;
|
||||
};
|
||||
export function ImageTileUpload({ file, children }: ImageTileUploadProps) {
|
||||
const url = useObjectURL(file);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import React, {
|
|||
FormEventHandler,
|
||||
KeyboardEventHandler,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
|
@ -29,19 +28,18 @@ import {
|
|||
Scroll,
|
||||
MenuItem,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { postInvite } from 'neurone/lib/api/matrix/client/v3/rooms';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useDirectUsers } from '../../hooks/useDirectUsers';
|
||||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
|
|
@ -52,27 +50,19 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
|||
const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
|
||||
|
||||
type InviteUserProps = {
|
||||
room: Room;
|
||||
roomId: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
const mx = useMatrixClient();
|
||||
export function InviteUserPrompt({ roomId, requestClose }: InviteUserProps) {
|
||||
const ct = useClient();
|
||||
const alive = useAlive();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const directUsers = useDirectUsers();
|
||||
const [validUserId, setValidUserId] = useState<string>();
|
||||
|
||||
const filteredUsers = useMemo(
|
||||
() =>
|
||||
directUsers.filter((userId) => {
|
||||
const membership = room.getMember(userId)?.membership;
|
||||
return membership !== Membership.Join;
|
||||
}),
|
||||
[directUsers, room]
|
||||
);
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
filteredUsers,
|
||||
directUsers,
|
||||
getUserIdString,
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
|
|
@ -83,9 +73,17 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||
const [inviteState, invite] = useAsyncCallback<void, Error, [string, string | undefined]>(
|
||||
useCallback(
|
||||
async (userId, reason) => {
|
||||
await mx.invite(room.roomId, userId, reason);
|
||||
await ct.requestAuthJSON(
|
||||
postInvite({
|
||||
pathParams: { roomId },
|
||||
body: {
|
||||
user_id: userId,
|
||||
reason,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[mx, room]
|
||||
[ct, roomId]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,16 @@ import {
|
|||
Button,
|
||||
Spinner,
|
||||
} from 'folds';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { postLeave } from 'neurone/lib/api/matrix/client/v3/rooms';
|
||||
import {
|
||||
MatrixResponseResult,
|
||||
parseMatrixResp,
|
||||
printMatrixError,
|
||||
} from 'neurone/lib/matrix-request';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { deleteJoinRooms } from '../../../client/syncWriter/utils';
|
||||
|
||||
type LeaveRoomPromptProps = {
|
||||
roomId: string;
|
||||
|
|
@ -27,23 +33,31 @@ type LeaveRoomPromptProps = {
|
|||
onCancel: () => void;
|
||||
};
|
||||
export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
|
||||
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
|
||||
const [leaveState, leaveRoom] = useAsyncCallback<MatrixResponseResult<unknown>, Error, []>(
|
||||
useCallback(async () => {
|
||||
mx.leave(roomId);
|
||||
}, [mx, roomId])
|
||||
const response = await ct.requestAuthJSON(postLeave({ pathParams: { roomId } }));
|
||||
deleteJoinRooms(ct, [roomId])
|
||||
.then(() => console.log('=====> deleted'))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
return parseMatrixResp(response);
|
||||
}, [ct, roomId])
|
||||
);
|
||||
const success = leaveState.status === AsyncStatus.Success;
|
||||
const [requestError] = success ? leaveState.data : [];
|
||||
|
||||
const handleLeave = () => {
|
||||
leaveRoom();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (leaveState.status === AsyncStatus.Success) {
|
||||
if (success) {
|
||||
onDone();
|
||||
}
|
||||
}, [leaveState, onDone]);
|
||||
}, [success, onDone]);
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
|
|
@ -80,6 +94,11 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
|
|||
Failed to leave room! {leaveState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
{requestError && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
Failed to leave room! {printMatrixError(requestError)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -3,15 +3,11 @@ import React, { ReactNode, useCallback } from 'react';
|
|||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import FileSaver from 'file-saver';
|
||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import {
|
||||
decryptFile,
|
||||
downloadEncryptedMedia,
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../utils/matrix';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
|
||||
const badgeStyles = { maxWidth: toRem(100) };
|
||||
|
||||
|
|
@ -19,23 +15,25 @@ type FileDownloadButtonProps = {
|
|||
filename: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
encInfo?: object;
|
||||
};
|
||||
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [downloadState, download] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url() ?? url;
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
|
||||
)
|
||||
: await downloadMedia(mediaUrl);
|
||||
|
||||
const fileURL = URL.createObjectURL(fileContent);
|
||||
FileSaver.saveAs(fileURL, filename);
|
||||
return fileURL;
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
||||
}, [ct, url, useAuthentication, mimeType, encInfo, filename])
|
||||
);
|
||||
|
||||
const downloading = downloadState.status === AsyncStatus.Loading;
|
||||
|
|
|
|||
|
|
@ -1,34 +1,29 @@
|
|||
import React, { CSSProperties, ReactNode } from 'react';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts } from 'linkifyjs';
|
||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
||||
import { trimReplyFromBody } from '../../utils/room';
|
||||
import { MessageTextBody } from './layout';
|
||||
import {
|
||||
AudioContent,
|
||||
FileContent,
|
||||
ImageContent,
|
||||
MessageBadEncryptedContent,
|
||||
MessageBrokenContent,
|
||||
MessageDeletedContent,
|
||||
MessageEditedContent,
|
||||
MessageUnsupportedContent,
|
||||
VideoContent,
|
||||
} from './content';
|
||||
import {
|
||||
IAudioContent,
|
||||
IAudioInfo,
|
||||
IEncryptedFile,
|
||||
IFileContent,
|
||||
IFileInfo,
|
||||
IImageContent,
|
||||
IImageInfo,
|
||||
IThumbnailContent,
|
||||
IVideoContent,
|
||||
IVideoInfo,
|
||||
MATRIX_SPOILER_PROPERTY_NAME,
|
||||
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
||||
} 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, FileDownloadButton } from './FileHeader';
|
||||
import { RenderBody } from './RenderBody';
|
||||
import { rContent, rEncryptedFile, rInfo } from '../../../client/reader';
|
||||
import { UrlPreviewCard, UrlPreviewHolder } from '../url-preview';
|
||||
import { testMatrixTo } from '../../plugins/matrix-to';
|
||||
|
||||
export function MBadEncrypted() {
|
||||
return (
|
||||
|
|
@ -65,24 +60,53 @@ export function BrokenContent() {
|
|||
);
|
||||
}
|
||||
|
||||
type RenderBodyProps = {
|
||||
type UrlPreviewProps = {
|
||||
body: string;
|
||||
customBody?: string;
|
||||
ts: number;
|
||||
};
|
||||
function UrlPreview({ body, ts }: UrlPreviewProps) {
|
||||
const urlsMatch = body.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
|
||||
if (!urls) return null;
|
||||
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
if (filteredUrls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<UrlPreviewHolder>
|
||||
{filteredUrls.map((url) => (
|
||||
<UrlPreviewCard key={url} url={url} ts={ts} />
|
||||
))}
|
||||
</UrlPreviewHolder>
|
||||
);
|
||||
}
|
||||
|
||||
type MTextProps = {
|
||||
edited?: boolean;
|
||||
content: Record<string, unknown>;
|
||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||
content: object;
|
||||
urlPreview?: boolean;
|
||||
ts: number;
|
||||
style?: CSSProperties;
|
||||
highlightRegex?: RegExp;
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
};
|
||||
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
|
||||
const { body, formatted_body: customBody } = content;
|
||||
export function MText({
|
||||
edited,
|
||||
content,
|
||||
urlPreview,
|
||||
ts,
|
||||
style,
|
||||
highlightRegex,
|
||||
linkifyOpts,
|
||||
htmlReactParserOptions,
|
||||
}: MTextProps) {
|
||||
const body = rContent.body(content);
|
||||
const customBody = rContent.formattedBody(content);
|
||||
const trimmedBody = trimReplyFromBody(body ?? '');
|
||||
|
||||
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 (
|
||||
<>
|
||||
|
|
@ -91,13 +115,16 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
style={style}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
<RenderBody
|
||||
body={trimmedBody}
|
||||
customBody={customBody}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
{edited && <MessageEditedContent />}
|
||||
</MessageTextBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
{urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -105,23 +132,28 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||
type MEmoteProps = {
|
||||
displayName: string;
|
||||
edited?: boolean;
|
||||
content: Record<string, unknown>;
|
||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||
content: object;
|
||||
urlPreview?: boolean;
|
||||
ts: number;
|
||||
highlightRegex?: RegExp;
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
};
|
||||
export function MEmote({
|
||||
displayName,
|
||||
edited,
|
||||
content,
|
||||
renderBody,
|
||||
renderUrlsPreview,
|
||||
urlPreview,
|
||||
ts,
|
||||
highlightRegex,
|
||||
linkifyOpts,
|
||||
htmlReactParserOptions,
|
||||
}: MEmoteProps) {
|
||||
const { body, formatted_body: customBody } = content;
|
||||
const body = rContent.body(content);
|
||||
const customBody = rContent.formattedBody(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 (
|
||||
<>
|
||||
|
|
@ -131,30 +163,43 @@ export function MEmote({
|
|||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
<b>{`${displayName} `}</b>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
<RenderBody
|
||||
body={trimmedBody}
|
||||
customBody={customBody}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
{edited && <MessageEditedContent />}
|
||||
</MessageTextBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
{urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MNoticeProps = {
|
||||
edited?: boolean;
|
||||
content: Record<string, unknown>;
|
||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||
content: object;
|
||||
urlPreview?: boolean;
|
||||
ts: number;
|
||||
highlightRegex?: RegExp;
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
};
|
||||
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
|
||||
const { body, formatted_body: customBody } = content;
|
||||
export function MNotice({
|
||||
edited,
|
||||
content,
|
||||
urlPreview,
|
||||
ts,
|
||||
highlightRegex,
|
||||
linkifyOpts,
|
||||
htmlReactParserOptions,
|
||||
}: MNoticeProps) {
|
||||
const body = rContent.body(content);
|
||||
const customBody = rContent.formattedBody(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 (
|
||||
<>
|
||||
|
|
@ -163,39 +208,34 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
|||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
<RenderBody
|
||||
body={trimmedBody}
|
||||
customBody={customBody}
|
||||
highlightRegex={highlightRegex}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
{edited && <MessageEditedContent />}
|
||||
</MessageTextBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
{urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type RenderImageContentProps = {
|
||||
body: string;
|
||||
filename?: string;
|
||||
info?: IImageInfo & IThumbnailContent;
|
||||
mimeType?: string;
|
||||
url: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
};
|
||||
type MImageProps = {
|
||||
content: IImageContent;
|
||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||
content: object;
|
||||
mediaAutoLoad?: boolean;
|
||||
outlined?: boolean;
|
||||
};
|
||||
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
const imgInfo = content?.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
export function MImage({ content, mediaAutoLoad, outlined }: MImageProps) {
|
||||
const info = rContent.info(content);
|
||||
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
|
||||
|
||||
if (typeof mxcUrl !== 'string') {
|
||||
return <BrokenContent />;
|
||||
}
|
||||
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
|
||||
|
||||
const height = scaleYDimension(rInfo.w(info) || 400, 400, rInfo.h(info) || 400);
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
|
|
@ -204,178 +244,154 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
{renderImageContent({
|
||||
body: content.body || 'Image',
|
||||
info: imgInfo,
|
||||
mimeType: imgInfo?.mimetype,
|
||||
url: mxcUrl,
|
||||
encInfo: content.file,
|
||||
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
|
||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||
})}
|
||||
<ImageContent
|
||||
body={rContent.body(content) ?? 'Image'}
|
||||
info={info}
|
||||
mimeType={rInfo.mimetype(info)}
|
||||
url={mxcUrl}
|
||||
encInfo={rContent.file(content)}
|
||||
autoPlay={mediaAutoLoad}
|
||||
markedAsSpoiler={rContent.spoiler(content)}
|
||||
spoilerReason={rContent.spoilerReason(content)}
|
||||
/>
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
|
||||
type RenderVideoContentProps = {
|
||||
body: string;
|
||||
info: IVideoInfo & IThumbnailContent;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
};
|
||||
type MVideoProps = {
|
||||
content: IVideoContent;
|
||||
renderAsFile: () => ReactNode;
|
||||
renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
|
||||
type MFileProps = {
|
||||
content: object;
|
||||
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 ?? '');
|
||||
export function MFile({ content, outlined }: MFileProps) {
|
||||
const fileInfo = rContent.info(content);
|
||||
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
|
||||
|
||||
if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
|
||||
if (mxcUrl) {
|
||||
return renderAsFile();
|
||||
}
|
||||
if (typeof mxcUrl !== 'string') {
|
||||
return <BrokenContent />;
|
||||
}
|
||||
|
||||
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
|
||||
|
||||
const filename = content.filename ?? content.body ?? 'Video';
|
||||
const body = rContent.filename(content) ?? rContent.body(content) ?? 'Unnamed File';
|
||||
const mimeType = rInfo.mimetype(fileInfo) ?? FALLBACK_MIMETYPE;
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentHeader>
|
||||
<FileHeader
|
||||
body={filename}
|
||||
mimeType={safeMimeType}
|
||||
after={
|
||||
<FileDownloadButton
|
||||
filename={filename}
|
||||
url={mxcUrl}
|
||||
mimeType={safeMimeType}
|
||||
encInfo={content.file}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
mimeType: safeMimeType,
|
||||
url: mxcUrl,
|
||||
encInfo: content.file,
|
||||
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
|
||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||
})}
|
||||
</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 />;
|
||||
}
|
||||
|
||||
const filename = content.filename ?? content.body ?? 'Audio';
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentHeader>
|
||||
<FileHeader
|
||||
body={filename}
|
||||
mimeType={safeMimeType}
|
||||
after={
|
||||
<FileDownloadButton
|
||||
filename={filename}
|
||||
url={mxcUrl}
|
||||
mimeType={safeMimeType}
|
||||
encInfo={content.file}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FileHeader body={body} mimeType={mimeType} />
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox>
|
||||
<AttachmentContent>
|
||||
{renderAudioContent({
|
||||
info: audioInfo,
|
||||
mimeType: safeMimeType,
|
||||
url: mxcUrl,
|
||||
encInfo: content.file,
|
||||
})}
|
||||
<FileContent
|
||||
body={body}
|
||||
mimeType={mimeType}
|
||||
info={fileInfo ?? {}}
|
||||
url={mxcUrl}
|
||||
encInfo={rContent.file(content)}
|
||||
/>
|
||||
</AttachmentContent>
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
|
||||
type RenderFileContentProps = {
|
||||
body: string;
|
||||
info: IFileInfo & IThumbnailContent;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
};
|
||||
type MFileProps = {
|
||||
content: IFileContent;
|
||||
renderFileContent: (props: RenderFileContentProps) => ReactNode;
|
||||
type MVideoProps = {
|
||||
content: object;
|
||||
mediaAutoLoad?: boolean;
|
||||
outlined?: boolean;
|
||||
};
|
||||
export function MFile({ content, renderFileContent, outlined }: MFileProps) {
|
||||
const fileInfo = content?.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
export function MVideo({ content, mediaAutoLoad, outlined }: MVideoProps) {
|
||||
const videoInfo = rContent.info(content);
|
||||
const mxcUrl = rEncryptedFile.url(rContent.file) ?? rContent.url(content);
|
||||
const safeMimeType = getBlobSafeMimeType(rInfo.mimetype(videoInfo) ?? '');
|
||||
|
||||
if (typeof mxcUrl !== 'string') {
|
||||
if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
|
||||
if (mxcUrl) {
|
||||
return <MFile content={content} outlined={outlined} />;
|
||||
}
|
||||
return <BrokenContent />;
|
||||
}
|
||||
|
||||
const height = scaleYDimension(rInfo.w(videoInfo) || 400, 400, rInfo.h(videoInfo) || 400);
|
||||
|
||||
const filename = rContent.filename(content) ?? rContent.body(content) ?? 'Video';
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentHeader>
|
||||
<FileHeader
|
||||
body={content.filename ?? content.body ?? 'Unnamed File'}
|
||||
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
|
||||
body={filename}
|
||||
mimeType={safeMimeType}
|
||||
after={
|
||||
<FileDownloadButton
|
||||
filename={filename}
|
||||
url={mxcUrl}
|
||||
mimeType={safeMimeType}
|
||||
encInfo={rContent.file(content)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<VideoContent
|
||||
body={rContent.body(content) || 'Video'}
|
||||
info={videoInfo}
|
||||
mimeType={safeMimeType}
|
||||
url={mxcUrl}
|
||||
encInfo={rContent.file(content)}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
markedAsSpoiler={rContent.spoiler(content)}
|
||||
spoilerReason={rContent.spoilerReason(content)}
|
||||
/>
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
|
||||
type MAudioProps = {
|
||||
content: object;
|
||||
outlined?: boolean;
|
||||
};
|
||||
export function MAudio({ content, outlined }: MAudioProps) {
|
||||
const audioInfo = rContent.info(content);
|
||||
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
|
||||
const safeMimeType = getBlobSafeMimeType(rInfo.mimetype(audioInfo) ?? '');
|
||||
|
||||
if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
|
||||
if (mxcUrl) {
|
||||
return <MFile content={content} outlined={outlined} />;
|
||||
}
|
||||
return <BrokenContent />;
|
||||
}
|
||||
|
||||
const filename = rContent.filename(content) ?? rContent.body(content) ?? 'Audio';
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentHeader>
|
||||
<FileHeader
|
||||
body={filename}
|
||||
mimeType={safeMimeType}
|
||||
after={
|
||||
<FileDownloadButton
|
||||
filename={filename}
|
||||
url={mxcUrl}
|
||||
mimeType={safeMimeType}
|
||||
encInfo={rContent.file(content)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox>
|
||||
<AttachmentContent>
|
||||
{renderFileContent({
|
||||
body: content.filename ?? content.body ?? 'File',
|
||||
info: fileInfo ?? {},
|
||||
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
|
||||
url: mxcUrl,
|
||||
encInfo: content.file,
|
||||
})}
|
||||
<AudioContent
|
||||
info={audioInfo}
|
||||
mimeType={safeMimeType}
|
||||
url={mxcUrl}
|
||||
encInfo={rContent.file(content)}
|
||||
/>
|
||||
</AttachmentContent>
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
|
|
@ -383,11 +399,11 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
|
|||
}
|
||||
|
||||
type MLocationProps = {
|
||||
content: IContent;
|
||||
content: object;
|
||||
};
|
||||
export function MLocation({ content }: MLocationProps) {
|
||||
const geoUri = content.geo_uri;
|
||||
if (typeof geoUri !== 'string') return <BrokenContent />;
|
||||
const geoUri = rContent.geoUri(content);
|
||||
if (!geoUri) return <BrokenContent />;
|
||||
const location = parseGeoUri(geoUri);
|
||||
return (
|
||||
<Box direction="Column" alignItems="Start" gap="100">
|
||||
|
|
@ -409,16 +425,18 @@ export function MLocation({ content }: MLocationProps) {
|
|||
}
|
||||
|
||||
type MStickerProps = {
|
||||
content: IImageContent;
|
||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||
content: object;
|
||||
mediaAutoLoad?: boolean;
|
||||
outlineAttachment?: boolean;
|
||||
};
|
||||
export function MSticker({ content, renderImageContent }: MStickerProps) {
|
||||
const imgInfo = content?.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
export function MSticker({ content, mediaAutoLoad, outlineAttachment }: MStickerProps) {
|
||||
const imgInfo = rContent.info(content);
|
||||
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
|
||||
|
||||
if (typeof mxcUrl !== 'string') {
|
||||
return <MessageBrokenContent />;
|
||||
}
|
||||
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
|
||||
const height = scaleYDimension(rInfo.w(imgInfo) || 152, 152, rInfo.h(imgInfo) || 152);
|
||||
|
||||
return (
|
||||
<AttachmentBox
|
||||
|
|
@ -427,13 +445,7 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
|
|||
width: toRem(152),
|
||||
}}
|
||||
>
|
||||
{renderImageContent({
|
||||
body: content.body || 'Sticker',
|
||||
info: imgInfo,
|
||||
mimeType: imgInfo?.mimetype,
|
||||
url: mxcUrl,
|
||||
encInfo: content.file,
|
||||
})}
|
||||
<MImage content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
|
||||
</AttachmentBox>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import React from 'react';
|
||||
import { Box, Text, as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import * as css from './Reaction.css';
|
||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
import { ClientContext } from '../../../client/context';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
|
||||
export const Reaction = as<
|
||||
'button',
|
||||
{
|
||||
mx: MatrixClient;
|
||||
ct: ClientContext;
|
||||
count: number;
|
||||
reaction: string;
|
||||
useAuthentication?: boolean;
|
||||
}
|
||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
|
||||
>(({ className, ct, count, reaction, useAuthentication, ...props }, ref) => (
|
||||
<Box
|
||||
as="button"
|
||||
className={classNames(css.Reaction, className)}
|
||||
|
|
@ -29,8 +29,7 @@ export const Reaction = as<
|
|||
{reaction.startsWith('mxc://') ? (
|
||||
<img
|
||||
className={css.ReactionImg}
|
||||
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||
}
|
||||
src={mxcToHttp(ct.session.baseUrl, reaction, useAuthentication).url() ?? reaction}
|
||||
alt={reaction}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -46,23 +45,15 @@ export const Reaction = as<
|
|||
));
|
||||
|
||||
type ReactionTooltipMsgProps = {
|
||||
room: Room;
|
||||
reaction: string;
|
||||
events: MatrixEvent[];
|
||||
shortcode?: string;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMsgProps) {
|
||||
const shortCodeEvt = events.find(eventWithShortcode);
|
||||
const shortcode =
|
||||
shortCodeEvt?.getContent().shortcode ??
|
||||
getShortcodeFor(getHexcodeForEmoji(reaction)) ??
|
||||
reaction;
|
||||
const names = events.map(
|
||||
(ev: MatrixEvent) =>
|
||||
getMemberDisplayName(room, ev.getSender() ?? 'Unknown') ??
|
||||
getMxIdLocalPart(ev.getSender() ?? 'Unknown') ??
|
||||
'Unknown'
|
||||
);
|
||||
export function ReactionTooltipMsg({ reaction, shortcode, users }: ReactionTooltipMsgProps) {
|
||||
const memberAPI = useRoomMembersAPI();
|
||||
const label = shortcode ?? getShortcodeFor(getHexcodeForEmoji(reaction)) ?? reaction;
|
||||
const names = users.map((userId) => memberAPI.getName(userId));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -109,7 +100,7 @@ export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMs
|
|||
<Text as="span" size="Inherit" priority="300">
|
||||
{' reacted with '}
|
||||
</Text>
|
||||
:<b>{shortcode}</b>:
|
||||
:<b>{label}</b>:
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,31 @@
|
|||
import React from 'react';
|
||||
import parse, { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import Linkify from 'linkify-react';
|
||||
import { Chip, Text } from 'folds';
|
||||
import { Opts } from 'linkifyjs';
|
||||
import { MessageEmptyContent } from './content';
|
||||
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
||||
import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||
|
||||
export function shouldTruncate(
|
||||
body: string,
|
||||
customBody?: string,
|
||||
maxLines = 15,
|
||||
charPerLine = 100
|
||||
) {
|
||||
// Count text characters (strip HTML tags)
|
||||
const text = customBody?.replace(/<[^>]+>/g, '') || body;
|
||||
|
||||
const newlines = (body.match(/\n/g) || []).length;
|
||||
|
||||
return text.length > maxLines * charPerLine || newlines > maxLines;
|
||||
}
|
||||
|
||||
const NEXT_LINE_ONLY_REGEX = /^\n+$/;
|
||||
|
||||
type RenderBodyProps = {
|
||||
body: string;
|
||||
customBody?: string;
|
||||
|
||||
highlightRegex?: RegExp;
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
|
|
@ -21,9 +37,9 @@ export function RenderBody({
|
|||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
}: RenderBodyProps) {
|
||||
if (body === '') <MessageEmptyContent />;
|
||||
if (customBody) {
|
||||
if (customBody === '') <MessageEmptyContent />;
|
||||
if (body === '' || NEXT_LINE_ONLY_REGEX.test(body)) return <MessageEmptyContent />;
|
||||
if (typeof customBody === 'string') {
|
||||
if (customBody === '') return <MessageEmptyContent />;
|
||||
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
|
||||
}
|
||||
return (
|
||||
|
|
@ -34,3 +50,30 @@ export function RenderBody({
|
|||
</Linkify>
|
||||
);
|
||||
}
|
||||
|
||||
export function ViewMoreLessButton({
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Chip
|
||||
type="button"
|
||||
variant="Primary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={onToggle}
|
||||
outlined
|
||||
style={{
|
||||
position: collapsed ? 'sticky' : 'initial',
|
||||
bottom: '0',
|
||||
borderBottomLeftRadius: collapsed ? 0 : undefined,
|
||||
borderBottomRightRadius: collapsed ? 0 : undefined,
|
||||
}}
|
||||
>
|
||||
<Text size="L400">{collapsed ? 'View More' : 'View Less'}</Text>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,15 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const ReplyBend = style({
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const ThreadIndicator = style({
|
||||
opacity: config.opacity.P300,
|
||||
|
||||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
':hover&': {
|
||||
opacity: config.opacity.P500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Reply = style({
|
||||
marginBottom: toRem(1),
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
minHeight: config.lineHeight.T300,
|
||||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ReplyContent = style({
|
||||
opacity: config.opacity.P300,
|
||||
|
||||
selectors: {
|
||||
[`${Reply}:hover &`]: {
|
||||
opacity: config.opacity.P500,
|
||||
},
|
||||
},
|
||||
export const ReplyBar = style({
|
||||
position: 'relative',
|
||||
top: toRem(8),
|
||||
marginLeft: 'auto',
|
||||
|
||||
width: toRem(19),
|
||||
height: `calc(100% - ${toRem(8)})`,
|
||||
|
||||
borderLeft: `${config.borderWidth.B500} solid ${color.Surface.ContainerLine}`,
|
||||
borderTop: `${config.borderWidth.B500} solid ${color.Surface.ContainerLine}`,
|
||||
borderTopLeftRadius: config.radii.R400,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,138 +1,175 @@
|
|||
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
||||
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
||||
import { Box, Text, color, toRem } from 'folds';
|
||||
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts } from 'linkifyjs';
|
||||
import { LinePlaceholder } from './placeholder';
|
||||
import { randomNumberBetween } from '../../utils/common';
|
||||
import * as css from './Reply.css';
|
||||
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
|
||||
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||
import { MessageBadEncryptedContent } from './content';
|
||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { GetRoomMemberNameCallback } from '../../hooks/useRoomMembers';
|
||||
import { rUnsigned } from '../../../client/reader';
|
||||
import { TimelineView } from '../../../client/room';
|
||||
import { RenderMessageContent } from '../RenderMessageContent';
|
||||
import { Time } from './Time';
|
||||
import { BubbleLayout, CompactLayout, MessageBase, ModernLayout } from './layout';
|
||||
import { MessageLayout, MessageSpacing } from '../../state/settings';
|
||||
|
||||
type ReplyLayoutProps = {
|
||||
userColor?: string;
|
||||
type ReplyMessageProps = {
|
||||
username?: ReactNode;
|
||||
time?: ReactNode;
|
||||
messageLayout: MessageLayout;
|
||||
children: 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>
|
||||
)
|
||||
);
|
||||
export function ReplyMessage({ username, time, messageLayout, children }: ReplyMessageProps) {
|
||||
const msgBarJSX = <div className={css.ReplyBar} />;
|
||||
|
||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||
<Box
|
||||
shrink="No"
|
||||
className={css.ThreadIndicator}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Icon size="50" src={Icons.Thread} />
|
||||
<Text size="L400">Thread</Text>
|
||||
</Box>
|
||||
));
|
||||
if (messageLayout === MessageLayout.Compact) {
|
||||
return (
|
||||
<CompactLayout before={msgBarJSX}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box alignItems="Baseline" shrink="No" gap="200">
|
||||
{username}
|
||||
{time}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
</CompactLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (messageLayout === MessageLayout.Bubble) {
|
||||
return (
|
||||
<BubbleLayout
|
||||
hideBubble
|
||||
before={msgBarJSX}
|
||||
header={
|
||||
<Box alignItems="Baseline" shrink="No" gap="200">
|
||||
{username}
|
||||
{time}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</BubbleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModernLayout before={msgBarJSX}>
|
||||
<Box alignItems="Baseline" shrink="No" gap="200">
|
||||
{username}
|
||||
{time}
|
||||
</Box>
|
||||
{children}
|
||||
</ModernLayout>
|
||||
);
|
||||
}
|
||||
|
||||
type ReplyProps = {
|
||||
room: Room;
|
||||
timelineSet?: EventTimelineSet | undefined;
|
||||
replyEventId: string;
|
||||
threadRootId?: string | undefined;
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
onClick?: MouseEventHandler | undefined;
|
||||
getMemberPowerTag?: GetMemberPowerTag;
|
||||
accessibleTagColors?: Map<string, string>;
|
||||
legacyUsernameColor?: boolean;
|
||||
getMemberName: GetRoomMemberNameCallback;
|
||||
redacted: boolean;
|
||||
timeline?: TimelineView;
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
hour24Clock: boolean;
|
||||
messageLayout: MessageLayout;
|
||||
dateFormatString: string;
|
||||
messageSpacing: MessageSpacing;
|
||||
mediaAutoLoad?: boolean;
|
||||
};
|
||||
|
||||
export const Reply = as<'div', ReplyProps>(
|
||||
(
|
||||
{
|
||||
room,
|
||||
timelineSet,
|
||||
replyEventId,
|
||||
threadRootId,
|
||||
onClick,
|
||||
getMemberPowerTag,
|
||||
accessibleTagColors,
|
||||
legacyUsernameColor,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
|
||||
const getFromLocalTimeline = useCallback(
|
||||
() => timelineSet?.findEventById(replyEventId),
|
||||
[timelineSet, replyEventId]
|
||||
);
|
||||
const replyEvent = useRoomEvent(room, replyEventId, getFromLocalTimeline);
|
||||
export function Reply({
|
||||
roomId,
|
||||
eventId,
|
||||
onClick,
|
||||
getMemberPowerTag,
|
||||
accessibleTagColors,
|
||||
legacyUsernameColor,
|
||||
getMemberName,
|
||||
redacted,
|
||||
timeline,
|
||||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
messageLayout,
|
||||
messageSpacing,
|
||||
mediaAutoLoad,
|
||||
}: ReplyProps) {
|
||||
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
|
||||
const replyEvent = useRoomEvent(
|
||||
roomId,
|
||||
eventId,
|
||||
useCallback(() => timeline?.findEvent(eventId), [timeline, eventId])
|
||||
);
|
||||
|
||||
const { body } = replyEvent?.getContent() ?? {};
|
||||
const sender = replyEvent?.getSender();
|
||||
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
||||
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
||||
const sender = replyEvent?.sender;
|
||||
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
||||
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
||||
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? eventId) : tagColor;
|
||||
|
||||
const fallbackBody = replyEvent?.isRedacted() ? (
|
||||
<MessageDeletedContent />
|
||||
) : (
|
||||
<MessageFailedContent />
|
||||
);
|
||||
|
||||
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
|
||||
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
||||
|
||||
return (
|
||||
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||
{threadRootId && (
|
||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||
)}
|
||||
<ReplyLayout
|
||||
as="button"
|
||||
userColor={usernameColor}
|
||||
username={
|
||||
sender && (
|
||||
<Text size="T300" truncate>
|
||||
<b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
data-event-id={replyEventId}
|
||||
onClick={onClick}
|
||||
>
|
||||
{replyEvent !== undefined ? (
|
||||
<Text size="T300" truncate>
|
||||
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
||||
return (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<ReplyMessage
|
||||
messageLayout={messageLayout}
|
||||
username={
|
||||
sender && (
|
||||
<Text style={{ color: usernameColor }} size="T300" truncate>
|
||||
<b>{getMemberName(sender)}</b>
|
||||
</Text>
|
||||
) : (
|
||||
<LinePlaceholder
|
||||
style={{
|
||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||
width: toRem(placeholderWidth),
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
)
|
||||
}
|
||||
time={
|
||||
replyEvent && (
|
||||
<Time
|
||||
ts={replyEvent.origin_server_ts}
|
||||
dateFormatString={dateFormatString}
|
||||
hour24Clock={hour24Clock}
|
||||
/>
|
||||
)}
|
||||
</ReplyLayout>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
}
|
||||
data-event-id={eventId}
|
||||
>
|
||||
{replyEvent !== undefined ? (
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }} as="div">
|
||||
{replyEvent === null ? (
|
||||
<Text>
|
||||
<MessageBadEncryptedContent />
|
||||
</Text>
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={getMemberName(replyEvent.sender)}
|
||||
eventType={replyEvent.type}
|
||||
ts={replyEvent.origin_server_ts}
|
||||
edited={false}
|
||||
content={replyEvent.content}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
redacted={redacted || !!rUnsigned.redactedBecause(replyEvent.unsigned)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<LinePlaceholder
|
||||
style={{
|
||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||
width: toRem(placeholderWidth),
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ReplyMessage>
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
/* eslint-disable jsx-a11y/media-has-caption */
|
||||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import React, { 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 { IAudioInfo } from '../../../../types/matrix/common';
|
||||
import {
|
||||
PlayTimeCallback,
|
||||
useMediaLoading,
|
||||
|
|
@ -16,57 +14,45 @@ import {
|
|||
} from '../../../hooks/media';
|
||||
import { useThrottle } from '../../../hooks/useThrottle';
|
||||
import { secondsToMinutesAndSeconds } from '../../../utils/common';
|
||||
import {
|
||||
decryptFile,
|
||||
downloadEncryptedMedia,
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
import { rInfo } from '../../../../client/reader';
|
||||
import { MediaControl } from '../../media';
|
||||
|
||||
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;
|
||||
info: object;
|
||||
encInfo?: object;
|
||||
};
|
||||
export function AudioContent({
|
||||
mimeType,
|
||||
url,
|
||||
info,
|
||||
encInfo,
|
||||
renderMediaControl,
|
||||
}: AudioContentProps) {
|
||||
const mx = useMatrixClient();
|
||||
export function AudioContent({ mimeType, url, info, encInfo }: AudioContentProps) {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url() ?? url;
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
|
||||
)
|
||||
: await downloadMedia(mediaUrl);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
}, [ct, url, useAuthentication, 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 infoDuration = rInfo.duration(info) ?? 0;
|
||||
const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
|
||||
|
||||
const getAudioRef = useCallback(() => audioRef.current, []);
|
||||
|
|
@ -74,9 +60,9 @@ export function AudioContent({
|
|||
const { playing, setPlaying } = useMediaPlay(getAudioRef);
|
||||
const { seek } = useMediaSeek(getAudioRef);
|
||||
const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
|
||||
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
|
||||
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, cTime) => {
|
||||
setDuration(d);
|
||||
setCurrentTime(ct);
|
||||
setCurrentTime(cTime);
|
||||
}, []);
|
||||
useMediaPlayTimeCallback(
|
||||
getAudioRef,
|
||||
|
|
@ -90,95 +76,25 @@ export function AudioContent({
|
|||
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>
|
||||
return (
|
||||
<MediaControl
|
||||
after={
|
||||
<Range
|
||||
step={0.1}
|
||||
step={1}
|
||||
min={0}
|
||||
max={1}
|
||||
values={[volume]}
|
||||
onChange={(values) => setVolume(values[0])}
|
||||
max={duration || 1}
|
||||
values={[currentTime]}
|
||||
onChange={(values) => seek(values[0])}
|
||||
renderTrack={(params) => (
|
||||
<div {...params.props}>
|
||||
{params.children}
|
||||
<ProgressBar
|
||||
style={{ width: toRem(48) }}
|
||||
as="div"
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
min={0}
|
||||
max={1}
|
||||
value={volume}
|
||||
max={duration}
|
||||
value={currentTime}
|
||||
radii="300"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -198,12 +114,82 @@ export function AudioContent({
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
children: (
|
||||
}
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<audio controls={false} autoPlay ref={audioRef}>
|
||||
{srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
|
||||
</audio>
|
||||
),
|
||||
});
|
||||
</MediaControl>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
|
|||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
|
||||
<Icon size="50" src={iconSrc} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { ReactNode, useCallback, useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -12,14 +12,12 @@ import {
|
|||
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 { bytesToSize } from '../../../utils/common';
|
||||
import {
|
||||
READABLE_EXT_TO_MIME_TYPE,
|
||||
|
|
@ -28,14 +26,13 @@ import {
|
|||
mimeTypeToExt,
|
||||
} from '../../../utils/mimeTypes';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import {
|
||||
decryptFile,
|
||||
downloadEncryptedMedia,
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
import { TextViewer } from '../../text-viewer';
|
||||
import { PdfViewer } from '../../Pdf-viewer';
|
||||
|
||||
const renderErrorButton = (retry: () => void, text: string) => (
|
||||
<TooltipProvider
|
||||
|
|
@ -66,35 +63,30 @@ const renderErrorButton = (retry: () => void, text: string) => (
|
|||
</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;
|
||||
encInfo?: object;
|
||||
};
|
||||
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
|
||||
const mx = useMatrixClient();
|
||||
export function ReadTextFile({ body, mimeType, url, encInfo }: ReadTextFileProps) {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [textViewer, setTextViewer] = useState(false);
|
||||
|
||||
const [textState, loadText] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url() ?? url;
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
|
||||
)
|
||||
: await downloadMedia(mediaUrl);
|
||||
|
||||
const text = fileContent.text();
|
||||
setTextViewer(true);
|
||||
return text;
|
||||
}, [mx, useAuthentication, mimeType, encInfo, url])
|
||||
}, [ct, useAuthentication, mimeType, encInfo, url])
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -115,14 +107,16 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
|||
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),
|
||||
})}
|
||||
<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>
|
||||
|
|
@ -157,32 +151,28 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
|||
);
|
||||
}
|
||||
|
||||
type RenderPdfViewerProps = {
|
||||
name: string;
|
||||
src: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
export type ReadPdfFileProps = {
|
||||
body: string;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
renderViewer: (props: RenderPdfViewerProps) => ReactNode;
|
||||
encInfo?: object;
|
||||
};
|
||||
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
|
||||
const mx = useMatrixClient();
|
||||
export function ReadPdfFile({ body, mimeType, url, encInfo }: ReadPdfFileProps) {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [pdfViewer, setPdfViewer] = useState(false);
|
||||
|
||||
const [pdfState, loadPdf] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url() ?? url;
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
|
||||
)
|
||||
: await downloadMedia(mediaUrl);
|
||||
setPdfViewer(true);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
}, [ct, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -203,11 +193,11 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
|||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
src: pdfState.data,
|
||||
requestClose: () => setPdfViewer(false),
|
||||
})}
|
||||
<PdfViewer
|
||||
name={body}
|
||||
src={pdfState.data}
|
||||
requestClose={() => setPdfViewer(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
|
|
@ -245,23 +235,25 @@ export type DownloadFileProps = {
|
|||
mimeType: string;
|
||||
url: string;
|
||||
info: IFileInfo;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
encInfo?: object;
|
||||
};
|
||||
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [downloadState, download] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url() ?? url;
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
|
||||
)
|
||||
: await downloadMedia(mediaUrl);
|
||||
|
||||
const fileURL = URL.createObjectURL(fileContent);
|
||||
FileSaver.saveAs(fileURL, body);
|
||||
return fileURL;
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
||||
}, [ct, url, useAuthentication, mimeType, encInfo, body])
|
||||
);
|
||||
|
||||
return downloadState.status === AsyncStatus.Error ? (
|
||||
|
|
@ -294,17 +286,21 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||
type FileContentProps = {
|
||||
body: string;
|
||||
mimeType: string;
|
||||
renderAsTextFile: () => ReactNode;
|
||||
renderAsPdfFile: () => ReactNode;
|
||||
info: object;
|
||||
url: string;
|
||||
encInfo?: object;
|
||||
};
|
||||
export const FileContent = as<'div', FileContentProps>(
|
||||
({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
|
||||
<Box direction="Column" gap="300" {...props} ref={ref}>
|
||||
export function FileContent({ body, mimeType, info, url, encInfo }: FileContentProps) {
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
|
||||
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
|
||||
renderAsTextFile()}
|
||||
{mimeType === 'application/pdf' && renderAsPdfFile()}
|
||||
{children}
|
||||
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} encInfo={encInfo} info={info} />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
|
|
@ -22,41 +22,28 @@ 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 * as css from './style.css';
|
||||
import { bytesToSize } from '../../../utils/common';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { decryptFile, downloadEncryptedMedia } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
import { Image } from '../../media';
|
||||
import { ImageViewer } from '../../image-viewer';
|
||||
|
||||
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;
|
||||
encInfo?: object;
|
||||
autoPlay?: boolean;
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
renderViewer: (props: RenderViewerProps) => ReactNode;
|
||||
renderImage: (props: RenderImageProps) => ReactNode;
|
||||
};
|
||||
export const ImageContent = as<'div', ImageContentProps>(
|
||||
(
|
||||
|
|
@ -70,13 +57,11 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
autoPlay,
|
||||
markedAsSpoiler,
|
||||
spoilerReason,
|
||||
renderViewer,
|
||||
renderImage,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
|
||||
|
|
@ -87,15 +72,15 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url() ?? url;
|
||||
if (encInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
|
||||
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo as EncryptedAttachmentInfo)
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
return mediaUrl;
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
}, [ct, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
const handleLoad = () => {
|
||||
|
|
@ -133,11 +118,11 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
alt: body,
|
||||
requestClose: () => setViewer(false),
|
||||
})}
|
||||
<ImageViewer
|
||||
src={srcState.data}
|
||||
alt={body}
|
||||
requestClose={() => setViewer(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
|
|
@ -168,15 +153,16 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderImage({
|
||||
alt: body,
|
||||
title: body,
|
||||
src: srcState.data,
|
||||
onLoad: handleLoad,
|
||||
onError: handleError,
|
||||
onClick: () => setViewer(true),
|
||||
tabIndex: 0,
|
||||
})}
|
||||
<Image
|
||||
alt={body}
|
||||
title={body}
|
||||
src={srcState.data}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
onClick={() => setViewer(true)}
|
||||
tabIndex={0}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{blurred && !error && srcState.status !== AsyncStatus.Error && (
|
||||
|
|
|
|||
|
|
@ -1,43 +1,50 @@
|
|||
import { ReactNode, useCallback, useEffect } from 'react';
|
||||
import { IThumbnailContent } from '../../../../types/matrix/common';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { decryptFile, downloadEncryptedMedia } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
import { rEncryptedFile, rInfo, rThumbnailInfo } from '../../../../client/reader';
|
||||
import { Image } from '../../media';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
|
||||
export type ThumbnailContentProps = {
|
||||
info: IThumbnailContent;
|
||||
renderImage: (src: string) => ReactNode;
|
||||
info: object;
|
||||
};
|
||||
export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
||||
const mx = useMatrixClient();
|
||||
export function ThumbnailContent({ info }: ThumbnailContentProps) {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const thumbInfo = info.thumbnail_info;
|
||||
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
|
||||
const encInfo = info.thumbnail_file;
|
||||
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
|
||||
const thumbInfo = rInfo.thumbnailInfo(info);
|
||||
const encInfo = rInfo.thumbnailFile(info);
|
||||
const thumbMxcUrl = rEncryptedFile.url(encInfo) ?? rInfo.thumbnailUrl(info);
|
||||
const mimeType = rThumbnailInfo.mimetype(thumbInfo);
|
||||
|
||||
if (typeof thumbMxcUrl !== 'string' || typeof mimeType !== 'string') {
|
||||
throw new Error('Failed to load thumbnail');
|
||||
}
|
||||
|
||||
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
|
||||
const mediaUrl =
|
||||
mxcToHttp(ct.session.baseUrl, thumbMxcUrl, useAuthentication).url() ?? thumbMxcUrl;
|
||||
if (encInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
|
||||
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo as EncryptedAttachmentInfo)
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
|
||||
return mediaUrl;
|
||||
}, [mx, info, useAuthentication])
|
||||
}, [ct, info, useAuthentication])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadThumbSrc();
|
||||
}, [loadThumbSrc]);
|
||||
|
||||
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
|
||||
return thumbSrcState.status === AsyncStatus.Success ? (
|
||||
<Image alt="Thumbnail" title="Thumbnail" src={thumbSrcState.data} loading="lazy" />
|
||||
) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
|
|
@ -15,43 +15,28 @@ import {
|
|||
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 { bytesToSize, millisecondsToMinutesAndSeconds } from '../../../utils/common';
|
||||
import {
|
||||
decryptFile,
|
||||
downloadEncryptedMedia,
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
import { Video } from '../../media';
|
||||
import { rInfo, rThumbnailInfo } from '../../../../client/reader';
|
||||
import { ThumbnailContent } from './ThumbnailContent';
|
||||
|
||||
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;
|
||||
info: object;
|
||||
encInfo?: object;
|
||||
autoPlay?: boolean;
|
||||
mediaAutoLoad?: boolean;
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
renderThumbnail?: () => ReactNode;
|
||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||
};
|
||||
export const VideoContent = as<'div', VideoContentProps>(
|
||||
(
|
||||
|
|
@ -63,17 +48,20 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
info,
|
||||
encInfo,
|
||||
autoPlay,
|
||||
mediaAutoLoad,
|
||||
markedAsSpoiler,
|
||||
spoilerReason,
|
||||
renderThumbnail,
|
||||
renderVideo,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
const thumbnailInfo = rInfo.thumbnailInfo(info);
|
||||
const blurHash = validBlurHash(rThumbnailInfo.blurhash(thumbnailInfo));
|
||||
|
||||
const size = rInfo.size(info);
|
||||
const duration = rInfo.duration(info);
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
|
@ -81,14 +69,14 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url() ?? url;
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo)
|
||||
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
|
||||
)
|
||||
: await downloadMedia(mediaUrl);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
}, [ct, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
const handleLoad = () => {
|
||||
|
|
@ -119,13 +107,13 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
punch={1}
|
||||
/>
|
||||
)}
|
||||
{renderThumbnail && !load && (
|
||||
{mediaAutoLoad && !load && (
|
||||
<Box
|
||||
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
{renderThumbnail()}
|
||||
<ThumbnailContent info={info} />
|
||||
</Box>
|
||||
)}
|
||||
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||
|
|
@ -144,14 +132,14 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderVideo({
|
||||
title: body,
|
||||
src: srcState.data,
|
||||
onLoadedMetadata: handleLoad,
|
||||
onError: handleError,
|
||||
autoPlay: true,
|
||||
controls: true,
|
||||
})}
|
||||
<Video
|
||||
title={body}
|
||||
src={srcState.data}
|
||||
onLoadedMetadata={handleLoad}
|
||||
onError={handleError}
|
||||
autoPlay
|
||||
controls
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{blurred && !error && srcState.status !== AsyncStatus.Error && (
|
||||
|
|
@ -219,7 +207,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
</TooltipProvider>
|
||||
</Box>
|
||||
)}
|
||||
{!load && typeof info.size === 'number' && (
|
||||
{!load && typeof size === 'number' && (
|
||||
<Box
|
||||
className={css.AbsoluteFooter}
|
||||
justifyContent="SpaceBetween"
|
||||
|
|
@ -227,10 +215,10 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
gap="200"
|
||||
>
|
||||
<Badge variant="Secondary" fill="Soft">
|
||||
<Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
|
||||
<Text size="L400">{millisecondsToMinutesAndSeconds(duration ?? 0)}</Text>
|
||||
</Badge>
|
||||
<Badge variant="Secondary" fill="Soft">
|
||||
<Text size="L400">{bytesToSize(info.size)}</Text>
|
||||
<Text size="L400">{bytesToSize(size)}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,18 @@ export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...p
|
|||
));
|
||||
|
||||
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
|
||||
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
|
||||
(
|
||||
{ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, truncate, ...props },
|
||||
ref
|
||||
) => (
|
||||
<Text
|
||||
as={asComp}
|
||||
size="T400"
|
||||
priority={notice ? '300' : '400'}
|
||||
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
||||
className={classNames(
|
||||
css.MessageTextBody({ preWrap, jumboEmoji, emote, truncate }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -112,8 +112,7 @@ export const CompactHeader = style([
|
|||
DefaultReset,
|
||||
StickySection,
|
||||
{
|
||||
maxWidth: toRem(170),
|
||||
width: '100%',
|
||||
width: toRem(170),
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -182,6 +181,7 @@ export const UsernameBold = style({
|
|||
export const MessageTextBody = recipe({
|
||||
base: {
|
||||
wordBreak: 'break-word',
|
||||
position: 'relative',
|
||||
},
|
||||
variants: {
|
||||
preWrap: {
|
||||
|
|
@ -201,6 +201,27 @@ export const MessageTextBody = recipe({
|
|||
fontStyle: 'italic',
|
||||
},
|
||||
},
|
||||
truncate: {
|
||||
true: {
|
||||
maxHeight: '15em',
|
||||
overflow: 'hidden',
|
||||
|
||||
selectors: {
|
||||
'&::before': {
|
||||
content: '',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
pointerEvents: 'none',
|
||||
|
||||
height: config.space.S400,
|
||||
background: `linear-gradient(to top, ${color.Primary.Container}, #00000000)`,
|
||||
borderBottom: `${config.borderWidth.B400} solid ${color.Primary.ContainerLine}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,3 +12,7 @@ export const RoomAvatar = style({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const RoomAvatarImg = style({
|
||||
fontSize: 1,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { JoinRule } from 'matrix-js-sdk';
|
||||
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
|
||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './RoomAvatar.css';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { JoinRule } from '../../../types/matrix/room';
|
||||
|
||||
type RoomAvatarProps = {
|
||||
roomId: string;
|
||||
|
|
@ -31,7 +32,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||
|
||||
return (
|
||||
<AvatarImage
|
||||
className={css.RoomAvatar}
|
||||
className={classNames(css.RoomAvatar, css.RoomAvatarImg)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
|
|
@ -44,12 +45,15 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||
export const RoomIcon = forwardRef<
|
||||
SVGSVGElement,
|
||||
Omit<ComponentProps<typeof Icon>, 'src'> & {
|
||||
joinRule: JoinRule;
|
||||
joinRule?: JoinRule;
|
||||
space?: boolean;
|
||||
}
|
||||
>(({ joinRule, space, ...props }, ref) => (
|
||||
<Icon
|
||||
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
|
||||
src={
|
||||
joinRuleToIconSrc(Icons, joinRule ?? 'invite', space || false) ??
|
||||
(space ? Icons.Space : Icons.Hash)
|
||||
}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
|
|
@ -19,20 +18,22 @@ import {
|
|||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { postJoin, PostJoinOK } from 'neurone/lib/api/matrix/client/v3/join';
|
||||
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import * as css from './style.css';
|
||||
import { RoomAvatar } from '../room-avatar';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
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, stopPropagation } from '../../utils/keyboard';
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { RoomType } 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';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useLocalRoomSummary } from '../../hooks/useLocalRoomSummary';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
|
||||
type GridColumnCount = '1' | '2' | '3';
|
||||
const getGridColumnCount = (gridWidth: number): GridColumnCount => {
|
||||
|
|
@ -161,44 +162,40 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||
},
|
||||
ref
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const { baseUrl } = ct.session;
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
|
||||
const joinedRoom = mx.getRoom(joinedRoomId);
|
||||
const [topicEvent, setTopicEvent] = useState(() =>
|
||||
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
|
||||
);
|
||||
const localSummary = useLocalRoomSummary(joinedRoomId ?? '');
|
||||
|
||||
const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
|
||||
const fallbackTopic = roomIdOrAlias;
|
||||
|
||||
const avatar = joinedRoom
|
||||
? getRoomAvatarUrl(mx, joinedRoom, 96, useAuthentication)
|
||||
: avatarUrl && mxcUrlToHttp(mx, avatarUrl, useAuthentication, 96, 96, 'crop');
|
||||
const avatarMxc = localSummary.avatarUrl ?? avatarUrl;
|
||||
const avatarHttpUrl =
|
||||
avatarMxc &&
|
||||
mxcToHttp(baseUrl, avatarMxc, useAuthentication).thumbnail({
|
||||
width: 96,
|
||||
height: 96,
|
||||
method: 'crop',
|
||||
});
|
||||
|
||||
const roomName = joinedRoom?.name || name || fallbackName;
|
||||
const roomTopic =
|
||||
(topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic;
|
||||
const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
|
||||
const roomName = localSummary?.name || name || fallbackName;
|
||||
const roomTopic = localSummary.topic || topic || fallbackTopic;
|
||||
const joinedMemberCount = localSummary.memberCount ?? 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, { viaServers }), [mx, roomIdOrAlias, viaServers])
|
||||
const [joinState, join] = useAsyncCallback<PostJoinOK, Error, []>(
|
||||
useCallback(async () => {
|
||||
const response = await ct.requestAuthJSON(
|
||||
postJoin({
|
||||
pathParams: { roomIdOrAlias },
|
||||
searchParams: { server_name: viaServers, via: viaServers },
|
||||
})
|
||||
);
|
||||
const [err, result] = await parseMatrixResp<PostJoinOK>(response);
|
||||
if (err) throw new Error(printMatrixError(err));
|
||||
return result;
|
||||
}, [ct, roomIdOrAlias, viaServers])
|
||||
);
|
||||
const joining =
|
||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||
|
|
@ -213,7 +210,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||
<Avatar size="500">
|
||||
<RoomAvatar
|
||||
roomId={roomIdOrAlias}
|
||||
src={avatar ?? undefined}
|
||||
src={avatarHttpUrl}
|
||||
alt={roomIdOrAlias}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H3">
|
||||
|
|
@ -222,7 +219,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
{(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
|
||||
{(roomType === RoomType.Space || localSummary.space) && (
|
||||
<Badge variant="Secondary" fill="Soft" outlined>
|
||||
<Text size="L400">Space</Text>
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -1,50 +1,55 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
|
||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, Box, Button, Text, as } from 'folds';
|
||||
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';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { InviteUserPrompt } from '../invite-user-prompt';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
import { useRoomTopic } from '../../hooks/storage/useRoomTopic';
|
||||
import { useRoomStateEvent } from '../../hooks/storage/useRoomStateEvent';
|
||||
import { StateEventType } from '../../../types/matrix/room';
|
||||
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
|
||||
export type RoomIntroProps = {
|
||||
room: Room;
|
||||
};
|
||||
|
||||
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
export const RoomIntro = as<
|
||||
'div',
|
||||
{
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
>(({ name, avatar, ...props }, ref) => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
const { getName } = useRoomMembersAPI();
|
||||
|
||||
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
|
||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||
const topic = useRoomTopic(roomId);
|
||||
const avatarHttpUrl = avatar
|
||||
? mxcToHttp(ct.session.baseUrl, avatar, useAuthentication).url()
|
||||
: 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 createResult = useRoomStateEvent(roomId, StateEventType.RoomCreate, '');
|
||||
const event = createResult.value?.event;
|
||||
const content = event?.content;
|
||||
|
||||
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
|
||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
||||
);
|
||||
const ts = event?.origin_server_ts;
|
||||
const creatorId = event?.sender;
|
||||
const creatorName = creatorId && getName(creatorId);
|
||||
|
||||
const prevRoomId =
|
||||
content &&
|
||||
'predecessor' in event.content &&
|
||||
typeof event.content.predecessor === 'object' &&
|
||||
event.content.predecessor &&
|
||||
'room_id' in event.content.predecessor &&
|
||||
event.content.predecessor.room_id;
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
|
||||
|
|
@ -53,7 +58,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||
<Box>
|
||||
<Avatar size="500">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
roomId={roomId}
|
||||
src={avatarHttpUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
|
||||
|
|
@ -82,36 +87,19 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||
</Button>
|
||||
|
||||
{invitePrompt && (
|
||||
<InviteUserPrompt room={room} requestClose={() => setInvitePrompt(false)} />
|
||||
<InviteUserPrompt roomId={roomId} requestClose={() => setInvitePrompt(false)} />
|
||||
)}
|
||||
{typeof prevRoomId === 'string' && (
|
||||
<Button
|
||||
onClick={() => navigateRoom(prevRoomId)}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="B300">Open Old Room</Text>
|
||||
</Button>
|
||||
)}
|
||||
{typeof prevRoomId === 'string' &&
|
||||
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
|
||||
<Button
|
||||
onClick={() => navigateRoom(prevRoomId, createContent?.predecessor?.event_id)}
|
||||
variant="Success"
|
||||
size="300"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="B300">Open Old Room</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => joinPrevRoom(prevRoomId)}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={prevRoomState.status === AsyncStatus.Loading}
|
||||
after={
|
||||
prevRoomState.status === AsyncStatus.Loading ? (
|
||||
<Spinner size="50" variant="Secondary" fill="Soft" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Text size="B300">Join Old Room</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config } from 'folds';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const SplashScreen = style({
|
||||
minHeight: '100%',
|
||||
|
|
@ -7,6 +7,11 @@ export const SplashScreen = style({
|
|||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
export const SplashLogo = style({
|
||||
width: toRem(42),
|
||||
height: toRem(42),
|
||||
});
|
||||
|
||||
export const SplashScreenFooter = style({
|
||||
padding: config.space.S400,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,26 +3,33 @@ import React, { ReactNode } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import * as patternsCSS from '../../styles/Patterns.css';
|
||||
import * as css from './SplashScreen.css';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
|
||||
type SplashScreenProps = {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
export function SplashScreen({ children }: SplashScreenProps) {
|
||||
export function SplashScreen({ footer, children }: SplashScreenProps) {
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.SplashScreen, patternsCSS.BackgroundDotPattern)}
|
||||
direction="Column"
|
||||
>
|
||||
{children}
|
||||
{children || (
|
||||
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center" gap="400">
|
||||
<img className={css.SplashLogo} src={CinnySVG} alt="Cinny Logo" />
|
||||
<Text size="H2" align="Center">
|
||||
Cinny
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
className={css.SplashScreenFooter}
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text size="H2" align="Center">
|
||||
Cinny
|
||||
</Text>
|
||||
{footer}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Dialog, Text, Box, Button, config } from 'folds';
|
||||
import { AuthType } from 'matrix-js-sdk';
|
||||
import { StageComponentProps } from './types';
|
||||
import { AuthType } from '../../utils/matrix-uia';
|
||||
|
||||
function DummyErrorDialog({
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useEffect, useCallback, FormEventHandler } from 'react';
|
||||
import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
|
||||
import { AuthType, MatrixError } from 'matrix-js-sdk';
|
||||
import { printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { StageComponentProps } from './types';
|
||||
import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types';
|
||||
import { RequestEmailTokenCallback, RequestEmailTokenResult } from '../../hooks/types';
|
||||
import { AuthType } from '../../utils/matrix-uia';
|
||||
|
||||
function EmailErrorDialog({
|
||||
title,
|
||||
|
|
@ -77,10 +78,14 @@ export function EmailStageDialog({
|
|||
}: StageComponentProps & {
|
||||
email?: string;
|
||||
clientSecret: string;
|
||||
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
|
||||
emailTokenState: AsyncState<RequestEmailTokenResult, Error>;
|
||||
requestEmailToken: RequestEmailTokenCallback;
|
||||
}) {
|
||||
const { errorCode, error, session } = stageData;
|
||||
const [matrixError, result] =
|
||||
emailTokenState.status === AsyncStatus.Success ? emailTokenState.data : [];
|
||||
const requestError =
|
||||
emailTokenState.status === AsyncStatus.Error ? emailTokenState.error : undefined;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(sessionId: string) => {
|
||||
|
|
@ -120,14 +125,23 @@ export function EmailStageDialog({
|
|||
);
|
||||
}
|
||||
|
||||
if (emailTokenState.status === AsyncStatus.Error) {
|
||||
if (requestError) {
|
||||
return (
|
||||
<EmailErrorDialog
|
||||
title={emailTokenState.error.errcode ?? 'Verify Email'}
|
||||
title={requestError.name ?? 'Verify Email'}
|
||||
message={requestError.message ?? 'Failed to send verification Email request.'}
|
||||
onRetry={handleEmailSubmit}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (matrixError) {
|
||||
return (
|
||||
<EmailErrorDialog
|
||||
title={matrixError?.data.errcode ?? 'Verify Email'}
|
||||
message={
|
||||
emailTokenState.error?.data?.error ??
|
||||
emailTokenState.error.message ??
|
||||
'Failed to send verification Email request.'
|
||||
matrixError ? printMatrixError(matrixError) : 'Failed to send verification Email request.'
|
||||
}
|
||||
onRetry={handleEmailSubmit}
|
||||
onCancel={onCancel}
|
||||
|
|
@ -135,19 +149,19 @@ export function EmailStageDialog({
|
|||
);
|
||||
}
|
||||
|
||||
if (emailTokenState.status === AsyncStatus.Success) {
|
||||
if (result) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="H4">Verification Request Sent</Text>
|
||||
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
|
||||
<Text>{`Please check your email "${result.email}" and validate before continuing further.`}</Text>
|
||||
|
||||
{errorCode && (
|
||||
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
|
||||
<Button variant="Primary" onClick={() => handleSubmit(result.result.sid)}>
|
||||
<Text as="span" size="B400">
|
||||
Continue
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
||||
import React, { FormEventHandler } from 'react';
|
||||
import { AuthType } from 'matrix-js-sdk';
|
||||
import { StageComponentProps } from './types';
|
||||
import { ErrorCode } from '../../cs-errorcode';
|
||||
import { PasswordInput } from '../password-input';
|
||||
import { AuthType } from '../../utils/matrix-uia';
|
||||
|
||||
export function PasswordStage({
|
||||
stageData,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Dialog, Text, Box, Button, config } from 'folds';
|
||||
import { AuthType } from 'matrix-js-sdk';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { StageComponentProps } from './types';
|
||||
import { AuthType } from '../../utils/matrix-uia';
|
||||
|
||||
function ReCaptchaErrorDialog({
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useCallback, FormEventHandler } from 'react';
|
||||
import { Dialog, Text, Box, Button, config, Input } from 'folds';
|
||||
import { AuthType } from 'matrix-js-sdk';
|
||||
import { StageComponentProps } from './types';
|
||||
import { AuthType } from '../../utils/matrix-uia';
|
||||
|
||||
function RegistrationTokenErrorDialog({
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Dialog, Text, Box, Button, config } from 'folds';
|
||||
import { AuthType } from 'matrix-js-sdk';
|
||||
import { StageComponentProps } from './types';
|
||||
import { AuthType } from '../../utils/matrix-uia';
|
||||
|
||||
function TermsErrorDialog({
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { AuthDict } from 'matrix-js-sdk';
|
||||
import { AuthStageData } from '../../hooks/useUIAFlows';
|
||||
|
||||
export type StageComponentProps = {
|
||||
stageData: AuthStageData;
|
||||
submitAuthDict: (authDict: AuthDict) => void;
|
||||
submitAuthDict: (authDict: object) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import classNames from 'classnames';
|
|||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import * as css from './UploadBoard.css';
|
||||
import { TUploadFamilyObserverAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload';
|
||||
import { UploadsAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload';
|
||||
import { UploadItem } from '../../state/room/roomInputDrafts';
|
||||
|
||||
type UploadBoardProps = {
|
||||
header: ReactNode;
|
||||
|
|
@ -29,22 +30,24 @@ export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
|
|||
type UploadBoardHeaderProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
|
||||
uploadsAtom: UploadsAtom;
|
||||
uploadItems: UploadItem[];
|
||||
onCancel: (uploads: Upload[]) => void;
|
||||
onSend: (uploads: UploadSuccess[]) => Promise<void>;
|
||||
onSend: (uploadItems: UploadItem[], uploads: UploadSuccess[]) => Promise<void>;
|
||||
imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
|
||||
};
|
||||
|
||||
export function UploadBoardHeader({
|
||||
open,
|
||||
onToggle,
|
||||
uploadFamilyObserverAtom,
|
||||
uploadsAtom,
|
||||
uploadItems,
|
||||
onCancel,
|
||||
onSend,
|
||||
imperativeHandlerRef,
|
||||
}: UploadBoardHeaderProps) {
|
||||
const sendingRef = useRef(false);
|
||||
const uploads = useAtomValue(uploadFamilyObserverAtom);
|
||||
const uploads = useAtomValue(uploadsAtom);
|
||||
|
||||
const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success);
|
||||
const isError = uploads.some((upload) => upload.status === UploadStatus.Error);
|
||||
|
|
@ -66,6 +69,7 @@ export function UploadBoardHeader({
|
|||
if (sendingRef.current) return;
|
||||
sendingRef.current = true;
|
||||
await onSend(
|
||||
uploadItems,
|
||||
uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[]
|
||||
);
|
||||
sendingRef.current = false;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
|
||||
import { printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { UploadCard, UploadCardError, CompactUploadCardProgress } from './UploadCard';
|
||||
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { TUploadContent } from '../../utils/matrix';
|
||||
import { UploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||
import { UploadContent } from '../../utils/matrix';
|
||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
|
||||
type CompactUploadCardRendererProps = {
|
||||
isEncrypted?: boolean;
|
||||
uploadAtom: TUploadAtom;
|
||||
onRemove: (file: TUploadContent) => void;
|
||||
uploadAtom: UploadAtom;
|
||||
onRemove: (file: UploadContent) => void;
|
||||
onComplete?: (upload: UploadSuccess) => void;
|
||||
};
|
||||
export function CompactUploadCardRenderer({
|
||||
|
|
@ -19,11 +19,10 @@ export function CompactUploadCardRenderer({
|
|||
onRemove,
|
||||
onComplete,
|
||||
}: CompactUploadCardRendererProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mediaConfig = useMediaConfig();
|
||||
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
||||
|
||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(uploadAtom, isEncrypted);
|
||||
const { file } = upload;
|
||||
const fileSizeExceeded = file.size >= allowSize;
|
||||
|
||||
|
|
@ -91,7 +90,11 @@ export function CompactUploadCardRenderer({
|
|||
)}
|
||||
{upload.status === UploadStatus.Error && (
|
||||
<UploadCardError>
|
||||
<Text size="T200">{upload.error.message}</Text>
|
||||
<Text size="T200">
|
||||
{upload.error instanceof Error
|
||||
? upload.error.message
|
||||
: printMatrixError(upload.error)}
|
||||
</Text>
|
||||
</UploadCardError>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
||||
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { TUploadContent } from '../../utils/matrix';
|
||||
import { UploadContent } from '../../utils/matrix';
|
||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||
import {
|
||||
roomUploadAtomFamily,
|
||||
TUploadItem,
|
||||
TUploadMetadata,
|
||||
roomUploadContentToUploadAtom,
|
||||
UploadItem,
|
||||
UploadMetadata,
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
|
||||
type PreviewImageProps = {
|
||||
fileItem: TUploadItem;
|
||||
fileItem: UploadItem;
|
||||
};
|
||||
function PreviewImage({ fileItem }: PreviewImageProps) {
|
||||
const { originalFile, metadata } = fileItem;
|
||||
|
|
@ -35,7 +35,7 @@ function PreviewImage({ fileItem }: PreviewImageProps) {
|
|||
}
|
||||
|
||||
type PreviewVideoProps = {
|
||||
fileItem: TUploadItem;
|
||||
fileItem: UploadItem;
|
||||
};
|
||||
function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
||||
const { originalFile, metadata } = fileItem;
|
||||
|
|
@ -56,7 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
|||
}
|
||||
|
||||
type MediaPreviewProps = {
|
||||
fileItem: TUploadItem;
|
||||
fileItem: UploadItem;
|
||||
onSpoiler: (marked: boolean) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
|
@ -100,9 +100,9 @@ function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
|
|||
|
||||
type UploadCardRendererProps = {
|
||||
isEncrypted?: boolean;
|
||||
fileItem: TUploadItem;
|
||||
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
||||
onRemove: (file: TUploadContent) => void;
|
||||
fileItem: UploadItem;
|
||||
setMetadata: (fileItem: UploadItem, metadata: UploadMetadata) => void;
|
||||
onRemove: (file: UploadContent) => void;
|
||||
onComplete?: (upload: UploadSuccess) => void;
|
||||
};
|
||||
export function UploadCardRenderer({
|
||||
|
|
@ -112,13 +112,12 @@ export function UploadCardRenderer({
|
|||
onRemove,
|
||||
onComplete,
|
||||
}: UploadCardRendererProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mediaConfig = useMediaConfig();
|
||||
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
||||
|
||||
const uploadAtom = roomUploadAtomFamily(fileItem.file);
|
||||
const uploadAtom = roomUploadContentToUploadAtom(fileItem.file);
|
||||
const { metadata } = fileItem;
|
||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(uploadAtom, isEncrypted);
|
||||
const { file } = upload;
|
||||
const fileSizeExceeded = file.size >= allowSize;
|
||||
|
||||
|
|
@ -190,7 +189,11 @@ export function UploadCardRenderer({
|
|||
)}
|
||||
{upload.status === UploadStatus.Error && (
|
||||
<UploadCardError>
|
||||
<Text size="T200">{upload.error.message}</Text>
|
||||
<Text size="T200">
|
||||
{upload.error instanceof Error
|
||||
? upload.error.message
|
||||
: printMatrixError(upload.error)}
|
||||
</Text>
|
||||
</UploadCardError>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 { getPreviewUrl, GetPreviewUrlOK } from 'neurone/lib/api/matrix/client/v1/media';
|
||||
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
||||
import {
|
||||
getIntersectionObserverEntry,
|
||||
|
|
@ -10,17 +10,32 @@ import {
|
|||
} from '../../hooks/useIntersectionObserver';
|
||||
import * as css from './UrlPreviewCard.css';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
({ url, ts, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [previewStatus, loadPreview] = useAsyncCallback(
|
||||
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
|
||||
useCallback(async () => {
|
||||
const resp = await ct.requestAuthJSON(
|
||||
getPreviewUrl({
|
||||
searchParams: {
|
||||
url,
|
||||
ts,
|
||||
},
|
||||
})
|
||||
);
|
||||
const [err, result] = await parseMatrixResp<GetPreviewUrlOK>(resp);
|
||||
if (err) {
|
||||
throw new Error(printMatrixError(err));
|
||||
}
|
||||
return result;
|
||||
}, [url, ts, ct])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -29,12 +44,29 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse) => {
|
||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
|
||||
const renderContent = (prev: GetPreviewUrlOK) => {
|
||||
const imgUrl =
|
||||
'og:image' in prev &&
|
||||
typeof prev['og:image'] === 'string' &&
|
||||
mxcToHttp(ct.session.baseUrl, prev['og:image'], useAuthentication).thumbnail({
|
||||
width: 256,
|
||||
height: 256,
|
||||
method: 'scale',
|
||||
});
|
||||
const title =
|
||||
'og:title' in prev && typeof prev['og:title'] === 'string' ? prev['og:title'] : undefined;
|
||||
const description =
|
||||
'og:description' in prev && typeof prev['og:description'] === 'string'
|
||||
? prev['og:description']
|
||||
: undefined;
|
||||
const siteName =
|
||||
'og:site_name' in prev && typeof prev['og:site_name'] === 'string'
|
||||
? prev['og:site_name']
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
|
||||
{imgUrl && <UrlPreviewImg src={imgUrl} alt={title} title={title} />}
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
|
|
@ -46,14 +78,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
{typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
|
||||
{siteName ? `${siteName} | ` : null}
|
||||
{tryDecodeURIComponent(url)}
|
||||
</Text>
|
||||
<Text truncate priority="400">
|
||||
<b>{prev['og:title']}</b>
|
||||
<b>{title}</b>
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
|
||||
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||
</Text>
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -12,3 +12,7 @@ export const UserAvatar = style({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const UserAvatarImg = style({
|
||||
fontSize: 1,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||
|
||||
return (
|
||||
<AvatarImage
|
||||
className={classNames(css.UserAvatar, className)}
|
||||
className={classNames(css.UserAvatar, css.UserAvatarImg, className)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,76 @@
|
|||
import { Box, Button, color, config, Icon, Icons, Input, Spinner, Switch, Text } from 'folds';
|
||||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||
import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
MatrixResponseResult,
|
||||
parseMatrixResp,
|
||||
printMatrixError,
|
||||
} from 'neurone/lib/matrix-request';
|
||||
import {
|
||||
CreateRoomStateEvent,
|
||||
postCreateRoom,
|
||||
PostCreateRoomOK,
|
||||
} from 'neurone/lib/api/matrix/client/v3/createRoom';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { addRoomIdToMDirect, isUserId } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { ErrorCode } from '../../cs-errorcode';
|
||||
import { millisecondsToMinutes } from '../../utils/common';
|
||||
import { createRoomEncryptionState } from '../../components/create-room';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { rError } from '../../../client/reader';
|
||||
|
||||
type CreateChatProps = {
|
||||
defaultUserId?: string;
|
||||
};
|
||||
export function CreateChat({ defaultUserId }: CreateChatProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const alive = useAlive();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [encryption, setEncryption] = useState(true);
|
||||
const [invalidUserId, setInvalidUserId] = useState(false);
|
||||
|
||||
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
|
||||
const [createState, create] = useAsyncCallback<
|
||||
MatrixResponseResult<string>,
|
||||
Error,
|
||||
[string, boolean]
|
||||
>(
|
||||
useCallback(
|
||||
async (userId, encrypted) => {
|
||||
const initialState: ICreateRoomStateEvent[] = [];
|
||||
const initialState: CreateRoomStateEvent[] = [];
|
||||
|
||||
if (encrypted) initialState.push(createRoomEncryptionState());
|
||||
|
||||
const result = await mx.createRoom({
|
||||
is_direct: true,
|
||||
invite: [userId],
|
||||
visibility: Visibility.Private,
|
||||
preset: Preset.TrustedPrivateChat,
|
||||
initial_state: initialState,
|
||||
});
|
||||
const response = await ct.requestAuthJSON(
|
||||
postCreateRoom({
|
||||
body: {
|
||||
is_direct: true,
|
||||
invite: [userId],
|
||||
visibility: 'private',
|
||||
preset: 'trusted_private_chat',
|
||||
initial_state: initialState,
|
||||
},
|
||||
})
|
||||
);
|
||||
const [err, result] = await parseMatrixResp<PostCreateRoomOK>(response);
|
||||
if (err) return [err, undefined];
|
||||
|
||||
addRoomIdToMDirect(mx, result.room_id, userId);
|
||||
const roomId = result.room_id;
|
||||
|
||||
return result.room_id;
|
||||
addRoomIdToMDirect(ct, roomId, userId);
|
||||
|
||||
return [undefined, roomId];
|
||||
},
|
||||
[mx]
|
||||
[ct]
|
||||
)
|
||||
);
|
||||
const loading = createState.status === AsyncStatus.Loading;
|
||||
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
|
||||
const [respError] = createState.status === AsyncStatus.Success ? createState.data : [];
|
||||
const disabled = createState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
|
|
@ -64,8 +87,8 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
create(userId, encryption).then((roomId) => {
|
||||
if (alive()) {
|
||||
create(userId, encryption).then(([, roomId]) => {
|
||||
if (alive() && roomId) {
|
||||
userIdInput.value = '';
|
||||
navigate(getDirectRoomPath(roomId));
|
||||
}
|
||||
|
|
@ -123,13 +146,16 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
|
|||
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Warning} filled size="100" />
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
<b>
|
||||
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
|
||||
? `Server rate-limited your request for ${millisecondsToMinutes(
|
||||
(error.data.retry_after_ms as number | undefined) ?? 0
|
||||
)} minutes!`
|
||||
: error.message}
|
||||
</b>
|
||||
{error && <b>error.message</b>}
|
||||
{respError &&
|
||||
(respError.data.errcode === ErrorCode.M_LIMIT_EXCEEDED ? (
|
||||
<b>
|
||||
Server rate-limited your request for{' '}
|
||||
{millisecondsToMinutes(rError.retryAfterMs(respError.data) ?? 0)} minutes!
|
||||
</b>
|
||||
) : (
|
||||
<b>{printMatrixError(respError)}</b>
|
||||
))}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
Text,
|
||||
TextArea,
|
||||
} from 'folds';
|
||||
import { MatrixResponseResult, printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import {
|
||||
|
|
@ -22,7 +22,6 @@ import {
|
|||
knockSupported,
|
||||
restrictedSupported,
|
||||
} from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCapabilities } from '../../hooks/useCapabilities';
|
||||
|
|
@ -38,6 +37,8 @@ import {
|
|||
RoomVersionSelector,
|
||||
useAdditionalCreators,
|
||||
} from '../../components/create-room';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { rError } from '../../../client/reader';
|
||||
|
||||
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
|
||||
if (kind === CreateRoomKind.Private) return Icons.HashLock;
|
||||
|
|
@ -47,11 +48,11 @@ const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
|
|||
|
||||
type CreateRoomFormProps = {
|
||||
defaultKind?: CreateRoomKind;
|
||||
space?: Room;
|
||||
spaceId?: string;
|
||||
onCreate?: (roomId: string) => void;
|
||||
};
|
||||
export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) {
|
||||
const mx = useMatrixClient();
|
||||
export function CreateRoomForm({ defaultKind, spaceId, onCreate }: CreateRoomFormProps) {
|
||||
const ct = useClient();
|
||||
const alive = useAlive();
|
||||
|
||||
const capabilities = useCapabilities();
|
||||
|
|
@ -62,7 +63,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
selectRoomVersion(roomVersions?.default ?? '1');
|
||||
}, [roomVersions?.default]);
|
||||
|
||||
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
||||
const allowRestricted = spaceId ? restrictedSupported(selectedRoomVersion) : false;
|
||||
|
||||
const [kind, setKind] = useState(
|
||||
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
|
||||
|
|
@ -86,11 +87,14 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
selectRoomVersion(version);
|
||||
};
|
||||
|
||||
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
|
||||
useCallback((data) => createRoom(mx, data), [mx])
|
||||
);
|
||||
const [createState, create] = useAsyncCallback<
|
||||
MatrixResponseResult<string>,
|
||||
Error,
|
||||
[CreateRoomData]
|
||||
>(useCallback((data) => createRoom(ct, data), [ct]));
|
||||
const loading = createState.status === AsyncStatus.Loading;
|
||||
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
|
||||
const [respError] = createState.status === AsyncStatus.Success ? createState.data : [];
|
||||
const disabled = createState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
|
|
@ -118,7 +122,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
|
||||
create({
|
||||
version: selectedRoomVersion,
|
||||
parent: space,
|
||||
parentId: spaceId,
|
||||
kind,
|
||||
name: roomName,
|
||||
topic: roomTopic || undefined,
|
||||
|
|
@ -127,8 +131,8 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
knock: roomKnock,
|
||||
allowFederation: federation,
|
||||
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
||||
}).then((roomId) => {
|
||||
if (alive()) {
|
||||
}).then(([, roomId]) => {
|
||||
if (roomId && alive()) {
|
||||
onCreate?.(roomId);
|
||||
}
|
||||
});
|
||||
|
|
@ -279,13 +283,16 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Warning} filled size="100" />
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
<b>
|
||||
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
|
||||
? `Server rate-limited your request for ${millisecondsToMinutes(
|
||||
(error.data.retry_after_ms as number | undefined) ?? 0
|
||||
)} minutes!`
|
||||
: error.message}
|
||||
</b>
|
||||
{error && <b>error.message</b>}
|
||||
{respError &&
|
||||
(respError.data.errcode === ErrorCode.M_LIMIT_EXCEEDED ? (
|
||||
<b>
|
||||
Server rate-limited your request for{' '}
|
||||
{millisecondsToMinutes(rError.retryAfterMs(respError.data) ?? 0)} minutes!
|
||||
</b>
|
||||
) : (
|
||||
<b>{printMatrixError(respError)}</b>
|
||||
))}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
Text,
|
||||
TextArea,
|
||||
} from 'folds';
|
||||
import { MatrixResponseResult, printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import {
|
||||
|
|
@ -22,7 +22,6 @@ import {
|
|||
knockSupported,
|
||||
restrictedSupported,
|
||||
} from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCapabilities } from '../../hooks/useCapabilities';
|
||||
|
|
@ -39,6 +38,8 @@ import {
|
|||
useAdditionalCreators,
|
||||
} from '../../components/create-room';
|
||||
import { RoomType } from '../../../types/matrix/room';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { rError } from '../../../client/reader';
|
||||
|
||||
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
|
||||
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
|
||||
|
|
@ -48,11 +49,11 @@ const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
|
|||
|
||||
type CreateSpaceFormProps = {
|
||||
defaultKind?: CreateRoomKind;
|
||||
space?: Room;
|
||||
spaceId?: string;
|
||||
onCreate?: (roomId: string) => void;
|
||||
};
|
||||
export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) {
|
||||
const mx = useMatrixClient();
|
||||
export function CreateSpaceForm({ defaultKind, spaceId, onCreate }: CreateSpaceFormProps) {
|
||||
const ct = useClient();
|
||||
const alive = useAlive();
|
||||
|
||||
const capabilities = useCapabilities();
|
||||
|
|
@ -63,7 +64,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||
selectRoomVersion(roomVersions?.default ?? '1');
|
||||
}, [roomVersions?.default]);
|
||||
|
||||
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
||||
const allowRestricted = spaceId ? restrictedSupported(selectedRoomVersion) : false;
|
||||
|
||||
const [kind, setKind] = useState(
|
||||
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
|
||||
|
|
@ -87,11 +88,14 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||
selectRoomVersion(version);
|
||||
};
|
||||
|
||||
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
|
||||
useCallback((data) => createRoom(mx, data), [mx])
|
||||
);
|
||||
const [createState, create] = useAsyncCallback<
|
||||
MatrixResponseResult<string>,
|
||||
Error,
|
||||
[CreateRoomData]
|
||||
>(useCallback((data) => createRoom(ct, data), [ct]));
|
||||
const loading = createState.status === AsyncStatus.Loading;
|
||||
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
|
||||
const [respError] = createState.status === AsyncStatus.Success ? createState.data : [];
|
||||
const disabled = createState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
|
|
@ -120,7 +124,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||
create({
|
||||
version: selectedRoomVersion,
|
||||
type: RoomType.Space,
|
||||
parent: space,
|
||||
parentId: spaceId,
|
||||
kind,
|
||||
name: roomName,
|
||||
topic: roomTopic || undefined,
|
||||
|
|
@ -128,8 +132,8 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||
knock: roomKnock,
|
||||
allowFederation: federation,
|
||||
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
||||
}).then((roomId) => {
|
||||
if (alive()) {
|
||||
}).then(([, roomId]) => {
|
||||
if (roomId && alive()) {
|
||||
onCreate?.(roomId);
|
||||
}
|
||||
});
|
||||
|
|
@ -248,17 +252,20 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||
)}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
{(error || respError) && (
|
||||
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Warning} filled size="100" />
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
<b>
|
||||
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
|
||||
? `Server rate-limited your request for ${millisecondsToMinutes(
|
||||
(error.data.retry_after_ms as number | undefined) ?? 0
|
||||
)} minutes!`
|
||||
: error.message}
|
||||
</b>
|
||||
{error && <b>error.message</b>}
|
||||
{respError &&
|
||||
(respError.data.errcode === ErrorCode.M_LIMIT_EXCEEDED ? (
|
||||
<b>
|
||||
Server rate-limited your request for{' '}
|
||||
{millisecondsToMinutes(rError.retryAfterMs(respError.data) ?? 0)} minutes!
|
||||
</b>
|
||||
) : (
|
||||
<b>{printMatrixError(respError)}</b>
|
||||
))}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ 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';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
|
||||
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
|
||||
export function JoinBeforeNavigate({
|
||||
|
|
@ -17,13 +17,13 @@ export function JoinBeforeNavigate({
|
|||
eventId,
|
||||
viaServers,
|
||||
}: JoinBeforeNavigateProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
const handleView = (roomId: string) => {
|
||||
if (mx.getRoom(roomId)?.isSpaceRoom()) {
|
||||
if (ct.spaceRooms.read.has(roomId)) {
|
||||
navigateSpace(roomId);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const DropTargetLine = style({
|
|||
|
||||
borderBottom: `${toRem(LineHeight)} solid currentColor`,
|
||||
},
|
||||
'&[data-hover=true]:after': {
|
||||
'&[data-hover=true]::after': {
|
||||
content: '',
|
||||
display: 'block',
|
||||
width: toRem(LineHeight * 3),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from 'folds';
|
||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
|
||||
import { SpaceChildContent, StateEvent } from '../../../types/matrix/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
||||
|
|
@ -47,7 +47,7 @@ function SuggestMenuItem({
|
|||
|
||||
const [toggleState, handleToggleSuggested] = useAsyncCallback(
|
||||
useCallback(() => {
|
||||
const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
|
||||
const newContent: SpaceChildContent = { ...content, suggested: !content.suggested };
|
||||
return mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, newContent, roomId);
|
||||
}, [mx, parentId, roomId, content])
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config
|
|||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
import produce from 'immer';
|
||||
import { useSpace } from '../../hooks/useSpace';
|
||||
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
|
||||
|
|
@ -33,7 +30,6 @@ import {
|
|||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
|
||||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
|
||||
import { getSpaceRoomPath } from '../../pages/pathUtils';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
|
|
@ -22,21 +21,15 @@ 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 } from '../../hooks/usePowerLevels';
|
||||
import { usePowerLevels, usePowerLevelsState } from '../../hooks/usePowerLevels';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
import { TypingIndicator } from '../../components/typing-indicator';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
|
|
@ -51,29 +44,35 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { JoinRule } from '../../../types/matrix/room';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
import { TotalNotifications } from '../../state/hooks/roomNotifications';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
roomId: string;
|
||||
requestClose: () => void;
|
||||
notificationMode?: RoomNotificationMode;
|
||||
notifications?: TotalNotifications;
|
||||
};
|
||||
const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
({ room, requestClose, notificationMode }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
({ roomId, requestClose, notificationMode, notifications }, ref) => {
|
||||
const ct = useClient();
|
||||
const { userId } = ct.session;
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const powerLevelState = usePowerLevelsState(roomId);
|
||||
const powerLevels = usePowerLevels(powerLevelState.value);
|
||||
const creators = useRoomCreators(roomId);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||
const canInvite = permissions.action('invite', userId);
|
||||
const openRoomSettings = useOpenRoomSettings();
|
||||
const space = useSpaceOptionally();
|
||||
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
// markAsRead(mx, roomId, hideActivity);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
|
|
@ -82,22 +81,22 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
||||
const alias = ct.roomToAlias.read.get(roomId);
|
||||
const viaServers = alias ? undefined : getViaServers(creators, powerLevels);
|
||||
copyToClipboard(getMatrixToRoom(alias ?? roomId, viaServers));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleRoomSettings = () => {
|
||||
openRoomSettings(room.roomId, space?.roomId);
|
||||
openRoomSettings(roomId, space?.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
{invitePrompt && room && (
|
||||
{invitePrompt && (
|
||||
<InviteUserPrompt
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
requestClose={() => {
|
||||
setInvitePrompt(false);
|
||||
requestClose();
|
||||
|
|
@ -110,13 +109,13 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
disabled={!unread}
|
||||
disabled={!notifications || notifications.count === 0}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
||||
<RoomNotificationModeSwitcher roomId={roomId} value={notificationMode}>
|
||||
{(handleOpen, opened, changing) => (
|
||||
<MenuItem
|
||||
size="300"
|
||||
|
|
@ -195,7 +194,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||
</MenuItem>
|
||||
{promptLeave && (
|
||||
<LeaveRoomPrompt
|
||||
roomId={room.roomId}
|
||||
roomId={roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
|
|
@ -210,31 +209,37 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||
);
|
||||
|
||||
type RoomNavItemProps = {
|
||||
room: Room;
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
avatarUrl?: string;
|
||||
selected: boolean;
|
||||
linkPath: string;
|
||||
joinRule?: JoinRule;
|
||||
notificationMode?: RoomNotificationMode;
|
||||
showAvatar?: boolean;
|
||||
direct?: boolean;
|
||||
notifications?: TotalNotifications;
|
||||
unread?: boolean;
|
||||
};
|
||||
export function RoomNavItem({
|
||||
room,
|
||||
roomId,
|
||||
roomName,
|
||||
avatarUrl,
|
||||
selected,
|
||||
showAvatar,
|
||||
direct,
|
||||
notificationMode,
|
||||
linkPath,
|
||||
joinRule,
|
||||
notifications,
|
||||
unread,
|
||||
}: RoomNavItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const { baseUrl, userId } = ct.session;
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
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).filter(
|
||||
(receipt) => receipt.userId !== mx.getUserId()
|
||||
);
|
||||
const typingMember = useRoomTypingMember(roomId).filter((uId) => uId !== userId);
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
|
|
@ -251,12 +256,13 @@ export function RoomNavItem({
|
|||
};
|
||||
|
||||
const optionsVisible = hover || !!menuAnchor;
|
||||
const unreadOrNotification = (notifications && notifications.count > 0) || unread;
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={unread !== undefined}
|
||||
highlight={unreadOrNotification}
|
||||
aria-selected={selected}
|
||||
data-hover={!!menuAnchor}
|
||||
onContextMenu={handleContextMenu}
|
||||
|
|
@ -269,41 +275,53 @@ export function RoomNavItem({
|
|||
<Avatar size="200" radii="400">
|
||||
{showAvatar ? (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
roomId={roomId}
|
||||
src={
|
||||
direct
|
||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
avatarUrl &&
|
||||
mxcToHttp(baseUrl, avatarUrl, useAuthentication).thumbnail({
|
||||
width: 96,
|
||||
height: 96,
|
||||
})
|
||||
}
|
||||
alt={room.name}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(room.name)}
|
||||
{nameInitials(roomName)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon
|
||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||
style={{
|
||||
opacity: unreadOrNotification ? config.opacity.P500 : config.opacity.P300,
|
||||
}}
|
||||
filled={selected}
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
joinRule={joinRule}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||
{room.name}
|
||||
<Text
|
||||
priority={unreadOrNotification ? '500' : '300'}
|
||||
as="span"
|
||||
size="Inherit"
|
||||
truncate
|
||||
>
|
||||
{roomName}
|
||||
</Text>
|
||||
</Box>
|
||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||
{!optionsVisible && !unreadOrNotification && !selected && typingMember.length > 0 && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" disableAnimation />
|
||||
</Badge>
|
||||
)}
|
||||
{!optionsVisible && unread && (
|
||||
{!optionsVisible && unreadOrNotification && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
<UnreadBadge
|
||||
highlight={notifications?.highlight}
|
||||
count={notifications?.count ?? 0}
|
||||
/>
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||
|
|
@ -333,9 +351,10 @@ export function RoomNavItem({
|
|||
}}
|
||||
>
|
||||
<RoomNavItemMenu
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
notificationMode={notificationMode}
|
||||
notifications={notifications}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { Box, config, MenuItem, Text } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Command, useCommands } from '../../hooks/useCommands';
|
||||
import { Command, CommandRecord } from '../../hooks/useCommands';
|
||||
import {
|
||||
AutocompleteMenu,
|
||||
AutocompleteQuery,
|
||||
|
|
@ -11,14 +10,13 @@ import {
|
|||
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;
|
||||
commands: CommandRecord;
|
||||
editor: Editor;
|
||||
query: AutocompleteQuery<string>;
|
||||
requestClose: () => void;
|
||||
|
|
@ -31,13 +29,11 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
|||
};
|
||||
|
||||
export function CommandAutocomplete({
|
||||
room,
|
||||
commands,
|
||||
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(
|
||||
|
|
|
|||
36
src/app/features/room/FileDropDialog.tsx
Normal file
36
src/app/features/room/FileDropDialog.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
Icon,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import React from 'react';
|
||||
|
||||
export function FileDropDialog({ visible }: { visible: boolean }) {
|
||||
return (
|
||||
<Overlay open={visible} 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"`}
|
||||
</Text>
|
||||
<Text align="Center">Drag and drop files here or click for selection dialog</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,22 +26,14 @@ import {
|
|||
TooltipProvider,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import * as css from './MembersDrawer.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
UseAsyncSearchOptions,
|
||||
useAsyncSearch,
|
||||
} from '../../hooks/useAsyncSearch';
|
||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { TypingIndicator } from '../../components/typing-indicator';
|
||||
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getMemberSearchStr } from '../../utils/room';
|
||||
import { useSetSetting, useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { millify } from '../../plugins/millify';
|
||||
|
|
@ -59,19 +51,30 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
|||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import {
|
||||
GetRoomMemberAvatarCallback,
|
||||
GetRoomMemberNameCallback,
|
||||
useRoomMembersAPI,
|
||||
} from '../../hooks/useRoomMembers';
|
||||
import { JoinRoomMemberObject } from '../../../client/storage/collections';
|
||||
import { ClientContext } from '../../../client/context';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
import { usePendingMembers } from '../../hooks/usePendingMembers';
|
||||
|
||||
type MemberDrawerHeaderProps = {
|
||||
room: Room;
|
||||
joinedMemberCount: number;
|
||||
};
|
||||
function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
|
||||
function MemberDrawerHeader({ joinedMemberCount }: MemberDrawerHeaderProps) {
|
||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
||||
|
||||
return (
|
||||
<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 title={`${joinedMemberCount} Members`} size="H5" truncate>
|
||||
{joinedMemberCount > 0 ? `${millify(joinedMemberCount)} Members` : 'Members'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
|
|
@ -102,28 +105,33 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
|
|||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
mx: MatrixClient;
|
||||
ct: ClientContext;
|
||||
useAuthentication: boolean;
|
||||
room: Room;
|
||||
member: RoomMember;
|
||||
member: JoinRoomMemberObject;
|
||||
getName: GetRoomMemberNameCallback;
|
||||
getAvatar: GetRoomMemberAvatarCallback;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
pressed?: boolean;
|
||||
typing?: boolean;
|
||||
};
|
||||
function MemberItem({
|
||||
mx,
|
||||
ct,
|
||||
useAuthentication,
|
||||
room,
|
||||
member,
|
||||
getName,
|
||||
getAvatar,
|
||||
onClick,
|
||||
pressed,
|
||||
typing,
|
||||
}: MemberItemProps) {
|
||||
const name =
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
const avatarMxcUrl = member.getMxcAvatarUrl();
|
||||
const name = getName(member.userId);
|
||||
const avatarMxcUrl = getAvatar(member.userId);
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
|
||||
? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
|
||||
width: 96,
|
||||
height: 96,
|
||||
method: 'crop',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
|
@ -168,32 +176,32 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
|||
},
|
||||
};
|
||||
|
||||
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
|
||||
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
|
||||
getMemberSearchStr(m, query, mxIdToName);
|
||||
|
||||
type MembersDrawerProps = {
|
||||
room: Room;
|
||||
members: RoomMember[];
|
||||
};
|
||||
export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const EMPTY_MEMBER_LIST: JoinRoomMemberObject[] = [];
|
||||
export function MembersDrawer() {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||
const creators = useRoomCreators(roomId);
|
||||
const getPowerTag = useGetMemberPowerTag(creators, powerLevels);
|
||||
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
|
||||
|
||||
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
||||
const membersAPI = useRoomMembersAPI();
|
||||
const members = membersAPI.memberEventsResult.value ?? EMPTY_MEMBER_LIST;
|
||||
const loadingFromMemory = membersAPI.memberEventsResult.pending;
|
||||
|
||||
const fetchingMembers = usePendingMembers(roomId);
|
||||
|
||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const spaceId = useSpaceOptionally();
|
||||
const openProfileUserId = useUserRoomProfileState()?.userId;
|
||||
|
||||
const membershipFilterMenu = useMembershipFilterMenu();
|
||||
const sortFilterMenu = useMemberSortMenu();
|
||||
const membershipFilterMenu = useMembershipFilterMenu(membersAPI.getMembership);
|
||||
const sortFilterMenu = useMemberSortMenu(membersAPI.getName);
|
||||
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
|
||||
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
|
||||
|
||||
|
|
@ -201,16 +209,20 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
|
||||
const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
|
||||
|
||||
const typingMembers = useRoomTypingMember(room.roomId);
|
||||
const typingMembers = useRoomTypingMember(roomId);
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
|
||||
[members, membershipFilter, memberSort, memberPowerSort]
|
||||
);
|
||||
const joinedMemberCount = filteredMembers.length ?? 0;
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
filteredMembers,
|
||||
getRoomMemberStr,
|
||||
useCallback(
|
||||
(m, query) => getMemberSearchStr(m, query, membersAPI.getName),
|
||||
[membersAPI.getName]
|
||||
),
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
|
||||
|
|
@ -241,7 +253,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
const btn = evt.currentTarget as HTMLButtonElement;
|
||||
const userId = btn.getAttribute('data-user-id');
|
||||
if (!userId) return;
|
||||
openUserRoomProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect(), 'Left');
|
||||
openUserRoomProfile(roomId, spaceId, userId, btn.getBoundingClientRect(), 'Left');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -250,7 +262,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
shrink="No"
|
||||
direction="Column"
|
||||
>
|
||||
<MemberDrawerHeader room={room} />
|
||||
<MemberDrawerHeader joinedMemberCount={joinedMemberCount} />
|
||||
<Box className={css.MemberDrawerContentBase} grow="Yes">
|
||||
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
|
||||
<Box className={css.MemberDrawerContent} direction="Column" gap="200">
|
||||
|
|
@ -370,7 +382,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
</IconButton>
|
||||
</ScrollTopContainer>
|
||||
|
||||
{!fetchingMembers && !result && processMembers.length === 0 && (
|
||||
{!loadingFromMemory && !fetchingMembers && !result && processMembers.length === 0 && (
|
||||
<Text style={{ padding: config.space.S300 }} align="Center">
|
||||
{`No "${membershipFilter.name}" Members`}
|
||||
</Text>
|
||||
|
|
@ -393,7 +405,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
}}
|
||||
data-index={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
key={`${room.roomId}-${vItem.index}`}
|
||||
key={`${roomId}-${vItem.index}`}
|
||||
className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
|
||||
size="L400"
|
||||
>
|
||||
|
|
@ -409,19 +421,18 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
}}
|
||||
className={css.DrawerVirtualItem}
|
||||
data-index={vItem.index}
|
||||
key={`${room.roomId}-${tagOrMember.userId}`}
|
||||
key={`${roomId}-${tagOrMember.userId}`}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<MemberItem
|
||||
mx={mx}
|
||||
ct={ct}
|
||||
useAuthentication={useAuthentication}
|
||||
room={room}
|
||||
member={tagOrMember}
|
||||
getName={membersAPI.getName}
|
||||
getAvatar={membersAPI.getAvatar}
|
||||
onClick={handleMemberClick}
|
||||
pressed={openProfileUserId === tagOrMember.userId}
|
||||
typing={typingMembers.some(
|
||||
(receipt) => receipt.userId === tagOrMember.userId
|
||||
)}
|
||||
typing={typingMembers.some((userId) => userId === tagOrMember.userId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -429,7 +440,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
</div>
|
||||
</Box>
|
||||
|
||||
{fetchingMembers && (
|
||||
{(fetchingMembers || loadingFromMemory) && (
|
||||
<Box justifyContent="Center">
|
||||
<Spinner />
|
||||
</Box>
|
||||
|
|
|
|||
339
src/app/features/room/OutboxView.tsx
Normal file
339
src/app/features/room/OutboxView.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import React, { Fragment, MouseEventHandler, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Box,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts } from 'linkifyjs';
|
||||
import { useTimelineView } from './hooks/useTimeline';
|
||||
import { useObservableValue } from '../../hooks/useObservableValue';
|
||||
import {
|
||||
EventSenderError,
|
||||
EventSenderPending,
|
||||
EventSenderSuccess,
|
||||
LocalEvent,
|
||||
LocalEventSender,
|
||||
LocalMessage,
|
||||
} from '../../../client/room/local-event';
|
||||
import { MessageBase, Reply } from '../../components/message';
|
||||
import { inSameDay, minuteDifference } from '../../utils/time';
|
||||
import { MessageLayout, MessageSpacing } from '../../state/settings';
|
||||
import { RoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { fallbackReply, getReplyId } from '../../utils/room';
|
||||
import * as css from './message/styles.css';
|
||||
import { MemberPowerTag } from '../../../types/matrix/room';
|
||||
import { MessageAvatar, MessageUsername, RenderMessageLayout } from './message';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { InfoCard } from '../../components/info-card';
|
||||
import { LocalEventOutbox } from '../../../client/room/local-event/LocalEventOutbox';
|
||||
import { MessageBatch } from '../../../client/room/messages';
|
||||
import { DayDivider } from './components';
|
||||
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
|
||||
function MessageSendingError({
|
||||
sender,
|
||||
senderError,
|
||||
outbox,
|
||||
}: {
|
||||
sender: LocalEventSender;
|
||||
outbox: LocalEventOutbox;
|
||||
senderError: EventSenderError;
|
||||
}) {
|
||||
return (
|
||||
<InfoCard
|
||||
variant="Critical"
|
||||
title="Failed to send message."
|
||||
description={
|
||||
senderError.error
|
||||
? senderError.error.data.error ?? printMatrixError(senderError.error)
|
||||
: (senderError.cause instanceof Error && senderError.cause.message) ?? 'Unknown Error'
|
||||
}
|
||||
after={
|
||||
<Box gap="200">
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T300">Retry</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Critical"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => senderError.resend()}
|
||||
>
|
||||
<Icon size="100" src={Icons.Reload} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T300">Delete</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Critical"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => outbox.remove(sender)}
|
||||
>
|
||||
<Icon size="100" src={Icons.Delete} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageSendingProps = {
|
||||
outbox: LocalEventOutbox;
|
||||
sender: LocalEventSender;
|
||||
collapse: boolean;
|
||||
messageLayout: MessageLayout;
|
||||
messageSpacing: MessageSpacing;
|
||||
membersAPI: RoomMembersAPI;
|
||||
|
||||
memberPowerTag?: MemberPowerTag;
|
||||
accessibleTagColors?: Map<string, string>;
|
||||
legacyUsernameColor?: boolean;
|
||||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function MessageSending({
|
||||
outbox,
|
||||
sender,
|
||||
collapse,
|
||||
messageLayout,
|
||||
messageSpacing,
|
||||
membersAPI,
|
||||
memberPowerTag,
|
||||
accessibleTagColors,
|
||||
legacyUsernameColor,
|
||||
onUserClick,
|
||||
onUsernameClick,
|
||||
children,
|
||||
}: MessageSendingProps) {
|
||||
const ct = useClient();
|
||||
const { userId } = ct.session;
|
||||
const senderState = useObservableValue(sender.state);
|
||||
const { original } = sender.localEvent;
|
||||
|
||||
const senderDisplayName = membersAPI.getName(userId);
|
||||
const senderAvatarMxc = membersAPI.getAvatar(userId);
|
||||
|
||||
return (
|
||||
<MessageBase
|
||||
className={classNames(css.MessageBase, {
|
||||
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
|
||||
})}
|
||||
tabIndex={0}
|
||||
space={messageSpacing}
|
||||
collapse={collapse || (!!getReplyId(original.content) && !fallbackReply(original.content))}
|
||||
>
|
||||
<RenderMessageLayout
|
||||
avatar={
|
||||
!collapse &&
|
||||
messageLayout !== MessageLayout.Compact && (
|
||||
<MessageAvatar
|
||||
userId={userId}
|
||||
displayName={senderDisplayName}
|
||||
avatarUrl={senderAvatarMxc}
|
||||
onUserClick={onUserClick}
|
||||
bubbleLayout={messageLayout === MessageLayout.Bubble}
|
||||
/>
|
||||
)
|
||||
}
|
||||
username={
|
||||
!collapse && (
|
||||
<MessageUsername
|
||||
userId={userId}
|
||||
displayName={senderDisplayName}
|
||||
onUserClick={onUserClick}
|
||||
onUsernameClick={onUsernameClick}
|
||||
messageLayout={messageLayout}
|
||||
memberPowerTag={memberPowerTag}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
time={
|
||||
<>
|
||||
{senderState instanceof EventSenderSuccess && <Icon size="50" src={Icons.Check} />}
|
||||
{senderState instanceof EventSenderError && (
|
||||
<Icon style={{ color: color.Critical.Main }} filled size="50" src={Icons.Warning} />
|
||||
)}
|
||||
{senderState instanceof EventSenderPending && <Icon size="50" src={Icons.Clock} />}
|
||||
</>
|
||||
}
|
||||
collapse={collapse}
|
||||
messageLayout={messageLayout}
|
||||
>
|
||||
{children}
|
||||
{senderState instanceof EventSenderError && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: config.space.S200,
|
||||
width: toRem(400),
|
||||
}}
|
||||
>
|
||||
<MessageSendingError sender={sender} outbox={outbox} senderError={senderState} />
|
||||
</div>
|
||||
)}
|
||||
</RenderMessageLayout>
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutboxView({
|
||||
messageBatch,
|
||||
senders,
|
||||
redacted,
|
||||
messageLayout,
|
||||
messageSpacing,
|
||||
membersAPI,
|
||||
getMemberPowerTag,
|
||||
accessibleTagColors,
|
||||
legacyUsernameColor,
|
||||
onUserClick,
|
||||
onUsernameClick,
|
||||
mediaAutoLoad,
|
||||
urlPreview,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
onReplyClick,
|
||||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
}: {
|
||||
messageBatch: MessageBatch;
|
||||
senders: readonly LocalEventSender[];
|
||||
redacted: Set<string>;
|
||||
messageLayout: MessageLayout;
|
||||
messageSpacing: MessageSpacing;
|
||||
membersAPI: RoomMembersAPI;
|
||||
getMemberPowerTag: GetMemberPowerTag;
|
||||
accessibleTagColors?: Map<string, string>;
|
||||
legacyUsernameColor?: boolean;
|
||||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||
mediaAutoLoad?: boolean;
|
||||
urlPreview?: boolean;
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
onReplyClick?: MouseEventHandler | undefined;
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
}) {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const timeline = useTimelineView();
|
||||
const direct = useIsDirectRoom();
|
||||
|
||||
const lastMessage = messageBatch.messages.events[messageBatch.size - 1];
|
||||
|
||||
let prevMsg: LocalEvent | undefined =
|
||||
lastMessage && lastMessage.sender === ct.session.userId
|
||||
? new LocalEvent(
|
||||
new LocalMessage(roomId, lastMessage.type, lastMessage.content),
|
||||
new LocalMessage(roomId, lastMessage.type, lastMessage.content),
|
||||
'',
|
||||
lastMessage.origin_server_ts
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{senders.map((sender) => {
|
||||
const dayDivider = prevMsg ? !inSameDay(prevMsg.ts, sender.localEvent.ts) : false;
|
||||
|
||||
const collapse =
|
||||
!!prevMsg &&
|
||||
!dayDivider &&
|
||||
prevMsg.original.type === sender.localEvent.original.type &&
|
||||
minuteDifference(prevMsg.ts, sender.localEvent.ts) < 2;
|
||||
prevMsg = sender.localEvent;
|
||||
|
||||
const replyEventId = getReplyId(sender.localEvent.original.content);
|
||||
|
||||
const replyJSX = replyEventId && (
|
||||
<Reply
|
||||
roomId={roomId}
|
||||
eventId={replyEventId}
|
||||
onClick={onReplyClick}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
redacted={redacted.has(replyEventId)}
|
||||
getMemberName={membersAPI.getName}
|
||||
timeline={timeline}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
messageLayout={messageLayout}
|
||||
messageSpacing={messageSpacing}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={sender.localEvent.txnId}>
|
||||
{dayDivider && <DayDivider ts={sender.localEvent.ts} messageSpacing={messageSpacing} />}
|
||||
{replyJSX}
|
||||
<MessageSending
|
||||
key={sender.localEvent.txnId}
|
||||
outbox={timeline.outbox}
|
||||
sender={sender}
|
||||
membersAPI={membersAPI}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
onUserClick={onUserClick}
|
||||
onUsernameClick={onUsernameClick}
|
||||
memberPowerTag={getMemberPowerTag(ct.session.userId)}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
>
|
||||
<RenderMessageContent
|
||||
displayName={membersAPI.getName(ct.session.userId)}
|
||||
eventType={sender.localEvent.original.type}
|
||||
ts={sender.localEvent.ts}
|
||||
content={sender.localEvent.original.content}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
/>
|
||||
</MessageSending>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,47 +7,91 @@ 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 {
|
||||
PowerLevelsContextProvider,
|
||||
usePowerLevels,
|
||||
usePowerLevelsState,
|
||||
} from '../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { RoomMembersAPIProvider, useCreateRoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
import {
|
||||
PowerLevelTagsContextProvider,
|
||||
usePowerLevelTags,
|
||||
usePowerLevelTagsState,
|
||||
} from '../../hooks/usePowerLevelTags';
|
||||
import { usePendingMemberLoader, usePendingMembers } from '../../hooks/usePendingMembers';
|
||||
import { useObservableValue, useObservableValueOptionally } from '../../hooks/useObservableValue';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { ThreadView } from './thread-view';
|
||||
import { TimelineViewProvider } from './hooks/useTimeline';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const roomToTimeline = useObservableValue(ct.roomToTimeline);
|
||||
|
||||
const timeline = roomToTimeline.get(roomId);
|
||||
const activeThreadId = useObservableValueOptionally(timeline?.activeThread);
|
||||
const threads = useObservableValueOptionally(timeline?.threads);
|
||||
const threadTimeline = activeThreadId ? threads?.get(activeThreadId) : undefined;
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const powerLevelStateResult = usePowerLevelsState(roomId);
|
||||
const powerLevels = usePowerLevels(powerLevelStateResult.value);
|
||||
const powerLevelTagsStateResult = usePowerLevelTagsState(roomId);
|
||||
const powerLevelTags = usePowerLevelTags(powerLevels, powerLevelTagsStateResult.value);
|
||||
const membersAPI = useCreateRoomMembersAPI(roomId);
|
||||
const membersPending = usePendingMembers(roomId);
|
||||
usePendingMemberLoader(roomId, membersPending);
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
// markAsRead(mx, roomId, hideActivity);
|
||||
}
|
||||
},
|
||||
[mx, room.roomId, hideActivity]
|
||||
[roomId, hideActivity]
|
||||
)
|
||||
);
|
||||
|
||||
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} members={members} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</PowerLevelsContextProvider>
|
||||
<RoomMembersAPIProvider value={membersAPI}>
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<PowerLevelTagsContextProvider value={powerLevelTags}>
|
||||
{timeline && (
|
||||
<Box grow="Yes">
|
||||
<TimelineViewProvider value={timeline}>
|
||||
<RoomView key={roomId} timeline={timeline} eventId={eventId} />
|
||||
</TimelineViewProvider>
|
||||
|
||||
{threadTimeline ? (
|
||||
<TimelineViewProvider value={threadTimeline}>
|
||||
<Line variant="Surface" direction="Vertical" size="300" />
|
||||
<ThreadView
|
||||
threadTimeline={threadTimeline}
|
||||
requestClose={() => timeline.closeThread()}
|
||||
/>
|
||||
</TimelineViewProvider>
|
||||
) : (
|
||||
screenSize === ScreenSize.Desktop &&
|
||||
isDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={roomId} />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</PowerLevelTagsContextProvider>
|
||||
</PowerLevelsContextProvider>
|
||||
</RoomMembersAPIProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,34 +7,15 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useAtom } from 'jotai';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { EventType, IContent, MsgType, RelationType, 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 { Box, Checkbox, Icon, IconButton, Icons, Line, PopOut, Scroll, Text, config } from 'folds';
|
||||
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
CustomEditor,
|
||||
Toolbar,
|
||||
toMatrixCustomHTML,
|
||||
toPlainText,
|
||||
AUTOCOMPLETE_PREFIXES,
|
||||
AutocompletePrefix,
|
||||
AutocompleteQuery,
|
||||
|
|
@ -47,33 +28,15 @@ import {
|
|||
createEmoticonElement,
|
||||
moveCursor,
|
||||
resetEditorHistory,
|
||||
customHtmlEqualsPlainText,
|
||||
trimCustomHtml,
|
||||
isEmptyEditor,
|
||||
getBeginCommand,
|
||||
trimCommand,
|
||||
getMentions,
|
||||
} from '../../components/editor';
|
||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import {
|
||||
TUploadContent,
|
||||
encryptFile,
|
||||
getImageInfo,
|
||||
getMxIdLocalPart,
|
||||
mxcUrlToHttp,
|
||||
} 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,
|
||||
TUploadMetadata,
|
||||
roomIdToMsgDraftAtomFamily,
|
||||
roomIdToReplyDraftAtomFamily,
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
roomUploadAtomFamily,
|
||||
composerToDraftAtom,
|
||||
getComposerId,
|
||||
composerToReplyDraftAtom,
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import { UploadCardRenderer } from '../../components/upload-card';
|
||||
import {
|
||||
|
|
@ -82,70 +45,62 @@ import {
|
|||
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 { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
||||
import { useCommands } from '../../hooks/useCommands';
|
||||
import { mobileOrTablet } from '../../utils/user-agent';
|
||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import { usePowerLevelTagsContext } from '../../hooks/usePowerLevelTags';
|
||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
import {
|
||||
useSendMessage,
|
||||
useSendStickerMessage,
|
||||
useSendUploadMessage,
|
||||
} from './hooks/useSendMessage';
|
||||
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
import { FileDropDialog } from './FileDropDialog';
|
||||
import { useUploadItemPicker } from './hooks/useUploadItemPicker';
|
||||
import { useUploadItems } from './hooks/useUploadItems';
|
||||
import { useUploadsAtom } from './hooks/useUploadsAtom';
|
||||
|
||||
interface RoomInputProps {
|
||||
editor: Editor;
|
||||
fileDropContainerRef: RefObject<HTMLElement>;
|
||||
roomId: string;
|
||||
room: Room;
|
||||
threadId?: string;
|
||||
}
|
||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
({ editor, fileDropContainerRef, roomId, room }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
({ editor, fileDropContainerRef, threadId }, ref) => {
|
||||
const roomId = useRoom();
|
||||
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
const direct = useIsDirectRoom();
|
||||
const commands = useCommands(mx, room);
|
||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
const [ping, setPing] = useState(true);
|
||||
|
||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
||||
const direct = useIsDirectRoom();
|
||||
const commands = useCommands();
|
||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(roomId);
|
||||
const memberAPI = useRoomMembersAPI();
|
||||
|
||||
const composerId = getComposerId(roomId, threadId);
|
||||
const [draft, setDraft] = useAtom(composerToDraftAtom(composerId));
|
||||
const [replyDraft, setReplyDraft] = useAtom(composerToReplyDraftAtom(composerId));
|
||||
const replyUserID = replyDraft?.userId;
|
||||
|
||||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||
const powerLevelTags = usePowerLevelTagsContext();
|
||||
const creatorsTag = useRoomCreatorsTag();
|
||||
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||
const getMemberPowerTag = useGetMemberPowerTag(creators, powerLevels);
|
||||
const theme = useTheme();
|
||||
const accessibleTagColors = useAccessiblePowerTagColors(
|
||||
theme.kind,
|
||||
|
|
@ -161,223 +116,74 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||||
|
||||
const [uploadBoard, setUploadBoard] = useState(true);
|
||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
||||
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
||||
roomUploadAtomFamily,
|
||||
selectedFiles.map((f) => f.file)
|
||||
const { uploadItems, addUploadItems, removeUploadItem, updateUploadMetadata, dropUploads } =
|
||||
useUploadItems();
|
||||
useEffect(() => setUploadBoard(true), [uploadItems]); // open upload board so items start uploading in UploadCard
|
||||
const { pickFile, handlePaste, dropZoneVisible } = useUploadItemPicker(
|
||||
fileDropContainerRef,
|
||||
addUploadItems
|
||||
);
|
||||
|
||||
const uploadsAtom = useUploadsAtom(uploadItems);
|
||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
||||
|
||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||
const imagePackRooms: string[] = useImagePackRooms(roomId);
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
|
||||
const sendTypingStatus = useTypingStatusUpdater(roomId);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setUploadBoard(true);
|
||||
const safeFiles = files.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
encryptFiles.forEach((ef) =>
|
||||
fileItems.push({
|
||||
...ef,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
});
|
||||
},
|
||||
[setSelectedFiles, room]
|
||||
);
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handlePaste = useFilePasteHandler(handleFiles);
|
||||
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
||||
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
|
||||
|
||||
const isComposing = useComposingCheck();
|
||||
|
||||
const sendMessage = useSendMessage(editor, commands, threadId);
|
||||
const sendSticker = useSendStickerMessage(threadId);
|
||||
const sendUploads = useSendUploadMessage(dropUploads, threadId);
|
||||
|
||||
useElementSizeObserver(
|
||||
useCallback(() => document.body, []),
|
||||
useCallback((width) => setHideStickerBtn(width < 500), [])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
Transforms.insertFragment(editor, msgDraft);
|
||||
}, [editor, msgDraft]);
|
||||
Transforms.insertFragment(editor, draft);
|
||||
}, [editor, draft]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
setDraft(parsedDraft);
|
||||
} else {
|
||||
setMsgDraft([]);
|
||||
setDraft([]);
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
},
|
||||
[roomId, editor, setMsgDraft]
|
||||
[roomId, editor, setDraft]
|
||||
);
|
||||
|
||||
const handleFileMetadata = useCallback(
|
||||
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
|
||||
setSelectedFiles({
|
||||
type: 'REPLACE',
|
||||
item: fileItem,
|
||||
replacement: { ...fileItem, metadata },
|
||||
});
|
||||
},
|
||||
[setSelectedFiles]
|
||||
);
|
||||
|
||||
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 as any));
|
||||
};
|
||||
|
||||
const submit = useCallback(() => {
|
||||
uploadBoardHandlers.current?.handleSend();
|
||||
|
||||
const commandName = getBeginCommand(editor);
|
||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||
let customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
})
|
||||
sendMessage(
|
||||
replyDraft
|
||||
? {
|
||||
userId: replyDraft.userId,
|
||||
eventId: replyDraft.eventId,
|
||||
ping,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
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 === Command.TableFlip) {
|
||||
plainText = `${TABLEFLIP} ${plainText}`;
|
||||
customHtml = `${TABLEFLIP} ${customHtml}`;
|
||||
} else if (commandName === Command.UnFlip) {
|
||||
plainText = `${UNFLIP} ${plainText}`;
|
||||
customHtml = `${UNFLIP} ${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;
|
||||
|
||||
const body = plainText;
|
||||
const formattedBody = customHtml;
|
||||
const mentionData = getMentions(mx, roomId, editor);
|
||||
|
||||
const content: IContent = {
|
||||
msgtype: msgType,
|
||||
body,
|
||||
};
|
||||
|
||||
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
|
||||
mentionData.users.add(replyDraft.userId);
|
||||
}
|
||||
|
||||
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
|
||||
content['m.mentions'] = mMentions;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
if (replyDraft.relation?.rel_type === RelationType.Thread) {
|
||||
content['m.relates_to'].event_id = replyDraft.relation.event_id;
|
||||
content['m.relates_to'].rel_type = RelationType.Thread;
|
||||
content['m.relates_to'].is_falling_back = false;
|
||||
}
|
||||
}
|
||||
mx.sendMessage(roomId, content as any);
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||
}, [editor, replyDraft, ping, sendTypingStatus, setReplyDraft, sendMessage]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
|
|
@ -430,41 +236,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
moveCursor(editor);
|
||||
};
|
||||
|
||||
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
||||
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
||||
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 && (
|
||||
{uploadItems.length > 0 && (
|
||||
<UploadBoard
|
||||
header={
|
||||
<UploadBoardHeader
|
||||
open={uploadBoard}
|
||||
onToggle={() => setUploadBoard(!uploadBoard)}
|
||||
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
|
||||
onSend={handleSendUpload}
|
||||
uploadItems={uploadItems}
|
||||
uploadsAtom={uploadsAtom}
|
||||
onSend={sendUploads}
|
||||
imperativeHandlerRef={uploadBoardHandlers}
|
||||
onCancel={handleCancelUpload}
|
||||
onCancel={dropUploads}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{uploadBoard && (
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<UploadBoardContent>
|
||||
{Array.from(selectedFiles)
|
||||
{Array.from(uploadItems)
|
||||
.reverse()
|
||||
.map((fileItem, index) => (
|
||||
<UploadCardRenderer
|
||||
|
|
@ -472,8 +263,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
key={index}
|
||||
isEncrypted={!!fileItem.encInfo}
|
||||
fileItem={fileItem}
|
||||
setMetadata={handleFileMetadata}
|
||||
onRemove={handleRemoveUpload}
|
||||
setMetadata={updateUploadMetadata}
|
||||
onRemove={removeUploadItem}
|
||||
/>
|
||||
))}
|
||||
</UploadBoardContent>
|
||||
|
|
@ -481,29 +272,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
)}
|
||||
</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>
|
||||
<FileDropDialog visible={dropZoneVisible} />
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
|
||||
<RoomMentionAutocomplete
|
||||
roomId={roomId}
|
||||
|
|
@ -514,7 +283,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
||||
<UserMentionAutocomplete
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
|
|
@ -530,7 +299,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
|
||||
<CommandAutocomplete
|
||||
room={room}
|
||||
commands={commands}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
|
|
@ -559,24 +328,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<Box direction="Row" gap="200" alignItems="Center">
|
||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||
<ReplyLayout
|
||||
userColor={replyUsernameColor}
|
||||
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 grow="Yes" direction="Row" gap="200" alignItems="Center">
|
||||
<Text size="T300" truncate>
|
||||
Quote{' '}
|
||||
<strong style={{ color: replyUsernameColor }}>
|
||||
@{memberAPI.getName(replyDraft.userId)}
|
||||
</strong>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box alignItems="Center" shrink="No" gap="100">
|
||||
<Text as="label" size="T200">
|
||||
Ping:
|
||||
</Text>
|
||||
<Checkbox
|
||||
checked={ping}
|
||||
onClick={() => setPing(!ping)}
|
||||
variant="Success"
|
||||
size="100"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
|
|
@ -622,7 +391,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
onStickerSelect={sendSticker}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab((t) => {
|
||||
if (t) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,33 +1,50 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Box, Button, Spinner, Text, color } from 'folds';
|
||||
|
||||
import { postJoin, PostJoinOK } from 'neurone/lib/api/matrix/client/v3/join';
|
||||
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
|
||||
import * as css from './RoomTombstone.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
import { StateEventType } from '../../../types/matrix/room';
|
||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { useRoomStateEvent } from '../../hooks/storage/useRoomStateEvent';
|
||||
import { useAllJoinedRoomsSet } from '../../hooks/useGetRoom';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
|
||||
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
|
||||
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
|
||||
const mx = useMatrixClient();
|
||||
type RoomTombstoneProps = { roomId: string; replacementRoomId: string };
|
||||
export function RoomTombstone({ roomId, replacementRoomId }: RoomTombstoneProps) {
|
||||
const ct = useClient();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const joinedRooms = useAllJoinedRoomsSet();
|
||||
const tombstoneEventResult = useRoomStateEvent(roomId, StateEventType.RoomTombstone, '');
|
||||
const tombstoneEvent = tombstoneEventResult.value;
|
||||
const { content } = tombstoneEvent?.event ?? {};
|
||||
const body =
|
||||
content && 'body' in content && typeof content.body === 'string'
|
||||
? content.body.trim()
|
||||
: undefined;
|
||||
|
||||
const [joinState, handleJoin] = useAsyncCallback(
|
||||
useCallback(() => {
|
||||
const currentRoom = mx.getRoom(roomId);
|
||||
const via = currentRoom ? getViaServers(currentRoom) : [];
|
||||
return mx.joinRoom(replacementRoomId, {
|
||||
viaServers: via,
|
||||
});
|
||||
}, [mx, roomId, replacementRoomId])
|
||||
const [joinState, handleJoin] = useAsyncCallback<string, Error, []>(
|
||||
useCallback(async () => {
|
||||
const sender = tombstoneEvent?.event.sender;
|
||||
const senderServer = sender ? getMxIdServer(sender) : undefined;
|
||||
const via = senderServer ? [senderServer] : undefined;
|
||||
const response = await ct.requestAuthJSON(
|
||||
postJoin({
|
||||
pathParams: { roomIdOrAlias: replacementRoomId },
|
||||
searchParams: { server_name: via, via },
|
||||
})
|
||||
);
|
||||
const [err, result] = await parseMatrixResp<PostJoinOK>(response);
|
||||
if (err) throw new Error(printMatrixError(err));
|
||||
return result.room_id;
|
||||
}, [ct, replacementRoomId, tombstoneEvent])
|
||||
);
|
||||
const replacementRoom = mx.getRoom(replacementRoomId);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (replacementRoom) navigateRoom(replacementRoom.roomId);
|
||||
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
|
||||
if (joinedRooms.has(replacementRoomId)) navigateRoom(replacementRoomId);
|
||||
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -36,13 +53,12 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
|
|||
<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!'}
|
||||
{joinState.error.message ?? 'Failed to join replacement room!'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
{replacementRoom?.getMyMembership() === Membership.Join ||
|
||||
joinState.status === AsyncStatus.Success ? (
|
||||
{joinedRooms.has(replacementRoomId) || joinState.status === AsyncStatus.Success ? (
|
||||
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
|
||||
<Text size="B300">Open New Room</Text>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { 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, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
|
|
@ -22,6 +17,12 @@ import { settingsAtom } from '../../state/settings';
|
|||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useObservableValue } from '../../hooks/useObservableValue';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { MessageEvent } from '../../../types/matrix/room';
|
||||
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
import { Timeline } from '../../../client/room';
|
||||
|
||||
const FN_KEYS_REGEX = /^F\d+$/;
|
||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
|
|
@ -56,23 +57,25 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|||
return true;
|
||||
};
|
||||
|
||||
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
export function RoomView({ eventId, timeline }: { eventId?: string; timeline: Timeline }) {
|
||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
|
||||
const { roomId } = room;
|
||||
const roomId = useRoom();
|
||||
const editor = useEditor();
|
||||
const ct = useClient();
|
||||
const { memberEventsResult } = useRoomMembersAPI();
|
||||
|
||||
const mx = useMatrixClient();
|
||||
const roomToReplacement = useObservableValue(ct.roomToReplacement);
|
||||
const replacementRoom = roomToReplacement.get(roomId);
|
||||
|
||||
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||
const creators = useRoomCreators(roomId);
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
||||
const canMessage = permissions.event(MessageEvent.RoomMessage, ct.session.userId);
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
|
|
@ -95,33 +98,24 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||
<Page ref={roomViewRef}>
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
{timeline && (
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
timeline={timeline}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
{replacementRoom ? (
|
||||
<RoomTombstone roomId={roomId} replacementRoomId={replacementRoom} />
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
<RoomInput editor={editor} fileDropContainerRef={roomViewRef} ref={roomInputRef} />
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
|
|
@ -135,7 +129,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing />}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Icon,
|
||||
|
|
@ -11,135 +11,142 @@ import {
|
|||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { ObservableValue } from 'neurone/lib/utils/observable';
|
||||
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';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { useObservableValue, useObservableValuePromise } from '../../hooks/useObservableValue';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
|
||||
export function RoomViewFollowingPlaceholder() {
|
||||
return <div className={css.RoomViewFollowingPlaceholder} />;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
export const RoomViewFollowing = as<'div'>(({ className, ...props }, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const membersAPI = useRoomMembersAPI();
|
||||
|
||||
const eventId = latestEvent?.getId();
|
||||
const roomToLatestEvent = useObservableValue(ct.roomToLatestEvent);
|
||||
const latestEvent = roomToLatestEvent.get(roomId);
|
||||
const eventId = latestEvent?.event_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
{eventId && (
|
||||
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
const receiptsResult = useObservableValuePromise(
|
||||
useMemo(() => {
|
||||
if (!eventId) return Promise.resolve(new ObservableValue(undefined, ''));
|
||||
return ct.storage.readReceipt.observableRead.queryIndex('eventId', eventId);
|
||||
}, [ct, eventId])
|
||||
);
|
||||
const receipts = receiptsResult.value;
|
||||
|
||||
const names = useMemo(
|
||||
() =>
|
||||
receipts
|
||||
?.map((receipt) => receipt.userId)
|
||||
.filter((readerId) => readerId !== ct.session.userId)
|
||||
.map((readerId) => membersAPI.getName(readerId)) ?? [],
|
||||
[ct, receipts, membersAPI]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{eventId && (
|
||||
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<EventReaders 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
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,32 +23,20 @@ import {
|
|||
Spinner,
|
||||
} from 'folds';
|
||||
import { 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 { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||
import * as css from './RoomViewHeader.css';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
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 { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
|
|
@ -69,28 +57,43 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { useObservableValue } from '../../hooks/useObservableValue';
|
||||
import { useRoomHeroes } from '../../hooks/storage/useRoomHeros';
|
||||
import { useGetRoomName } from '../../hooks/storage/useGetRoomName';
|
||||
import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
import { useRoomTopic } from '../../hooks/storage/useRoomTopic';
|
||||
import {
|
||||
useRoomNotifications,
|
||||
useRoomTotalNotifications,
|
||||
} from '../../state/hooks/roomNotifications';
|
||||
import { roomToNotificationsAtom } from '../../state/room/roomToNotifications';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
requestClose: () => void;
|
||||
};
|
||||
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ requestClose }, ref) => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
|
||||
const roomNotifications = useRoomNotifications(roomId, roomToNotificationsAtom);
|
||||
const notifications = useRoomTotalNotifications(roomNotifications);
|
||||
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
const creators = useRoomCreators(roomId);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||
const canInvite = permissions.action('invite', ct.session.userId);
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||
const notificationMode = getRoomNotificationMode(notificationPreferences, roomId);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
// markAsRead(mx, room.roomId, hideActivity);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
|
|
@ -99,16 +102,16 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
||||
const alias = ct.roomToAlias.read.get(roomId);
|
||||
const viaServers = alias ? undefined : getViaServers(creators, powerLevels);
|
||||
copyToClipboard(getMatrixToRoom(alias ?? roomId, viaServers));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const openSettings = useOpenRoomSettings();
|
||||
const parentSpace = useSpaceOptionally();
|
||||
const parentSpaceId = useSpaceOptionally();
|
||||
const handleOpenSettings = () => {
|
||||
openSettings(room.roomId, parentSpace?.roomId);
|
||||
openSettings(roomId, parentSpaceId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
|
|
@ -116,7 +119,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
{invitePrompt && (
|
||||
<InviteUserPrompt
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
requestClose={() => {
|
||||
setInvitePrompt(false);
|
||||
requestClose();
|
||||
|
|
@ -129,13 +132,13 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
disabled={!unread}
|
||||
disabled={!notifications || notifications.count === 0}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
||||
<RoomNotificationModeSwitcher roomId={roomId} value={notificationMode}>
|
||||
{(handleOpen, opened, changing) => (
|
||||
<MenuItem
|
||||
size="300"
|
||||
|
|
@ -211,7 +214,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||
<JumpToTime
|
||||
onSubmit={(eventId) => {
|
||||
setPromptJump(false);
|
||||
navigateRoom(room.roomId, eventId);
|
||||
navigateRoom(roomId, eventId);
|
||||
requestClose();
|
||||
}}
|
||||
onCancel={() => setPromptJump(false)}
|
||||
|
|
@ -241,7 +244,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||
</MenuItem>
|
||||
{promptLeave && (
|
||||
<LeaveRoomPrompt
|
||||
roomId={room.roomId}
|
||||
roomId={roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
|
|
@ -256,33 +259,40 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||
|
||||
export function RoomViewHeader() {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const room = useRoom();
|
||||
const space = useSpaceOptionally();
|
||||
const roomId = useRoom();
|
||||
const spaceId = useSpaceOptionally();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const encryptedRooms = useObservableValue(ct.encryptedRooms);
|
||||
const roomToJoinRule = useObservableValue(ct.roomToJoinRule);
|
||||
|
||||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
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 pinnedEvents = useRoomPinnedEvents(roomId);
|
||||
const [getRoomHeroes, roomToHeroMemberResult] = useRoomHeroes(roomId);
|
||||
const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMemberResult.value);
|
||||
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMemberResult.value);
|
||||
|
||||
const avatarMxc = getRoomAvatar(roomId);
|
||||
const name = getRoomName(roomId) ?? 'Unknown';
|
||||
const topic = useRoomTopic(roomId);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({
|
||||
width: 96,
|
||||
height: 96,
|
||||
method: 'crop',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
|
||||
const handleSearchClick = () => {
|
||||
const searchParams: _SearchPathSearchParams = {
|
||||
rooms: room.roomId,
|
||||
rooms: roomId,
|
||||
};
|
||||
const path = space
|
||||
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
||||
const path = spaceId
|
||||
? getSpaceSearchPath(ct.roomToAlias.read.get(spaceId) ?? spaceId)
|
||||
: getHomeSearchPath();
|
||||
navigate(withSearchParam(path, searchParams));
|
||||
};
|
||||
|
|
@ -313,15 +323,11 @@ export function RoomViewHeader() {
|
|||
{screenSize !== ScreenSize.Mobile && (
|
||||
<Avatar size="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
roomId={roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
size="200"
|
||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||
filled
|
||||
/>
|
||||
<RoomIcon size="200" joinRule={roomToJoinRule.get(roomId)} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
|
|
@ -370,7 +376,7 @@ export function RoomViewHeader() {
|
|||
</Box>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
{!ecryptedRoom && (
|
||||
{!encryptedRooms.has(roomId) && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
|
|
@ -439,7 +445,7 @@ export function RoomViewHeader() {
|
|||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
|
||||
<RoomPinMenu requestClose={() => setPinMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
|
@ -492,7 +498,7 @@ export function RoomViewHeader() {
|
|||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
|
||||
<RoomMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@ const SlideUpAnime = keyframes({
|
|||
export const RoomViewTyping = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: `0 ${config.space.S500}`,
|
||||
padding: `0 0 0 ${config.space.S500}`,
|
||||
width: '100%',
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
bottom: `calc(-1 * ${config.space.S600})`,
|
||||
animation: `${SlideUpAnime} 100ms ease-in-out`,
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,121 +1,115 @@
|
|||
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';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
|
||||
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);
|
||||
export const RoomViewTyping = as<'div'>(({ className, ...props }, ref) => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const membersAPI = useRoomMembersAPI();
|
||||
|
||||
const typingNames = typingMembers
|
||||
.filter((receipt) => receipt.userId !== mx.getUserId())
|
||||
.map(
|
||||
(receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
|
||||
)
|
||||
.reverse();
|
||||
const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
|
||||
const typingMembers = useRoomTypingMember(roomId);
|
||||
|
||||
if (typingNames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const typingNames = typingMembers
|
||||
.filter((userId) => userId !== ct.session.userId)
|
||||
.map((userId) => membersAPI.getName(userId))
|
||||
.reverse();
|
||||
|
||||
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>
|
||||
);
|
||||
if (typingNames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
const handleDropAll = () => {
|
||||
setTypingMembers(new Map());
|
||||
};
|
||||
|
||||
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 === 0 && <b>No one is typing...</b>} */}
|
||||
{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"
|
||||
fill="None"
|
||||
radii="Pill"
|
||||
onClick={handleDropAll}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
39
src/app/features/room/components/JumpToLatestButton.tsx
Normal file
39
src/app/features/room/components/JumpToLatestButton.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Chip, Icon, Icons, Spinner, Text } from 'folds';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { scrollToBottom } from '../../../utils/dom';
|
||||
import { useTimelineView } from '../hooks/useTimeline';
|
||||
|
||||
export function JumpToLatestButton({
|
||||
getScrollElement,
|
||||
}: {
|
||||
getScrollElement: () => HTMLDivElement | null;
|
||||
}) {
|
||||
const timeline = useTimelineView();
|
||||
|
||||
const [jumpState, jumpToBottom] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await timeline.jumpToLatest();
|
||||
const scrollEl = getScrollElement();
|
||||
if (scrollEl) scrollToBottom(scrollEl, 'instant');
|
||||
}, [timeline, getScrollElement])
|
||||
);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
onClick={jumpToBottom}
|
||||
before={
|
||||
jumpState.status === AsyncStatus.Loading ? (
|
||||
<Spinner size="50" variant="Secondary" />
|
||||
) : (
|
||||
<Icon size="50" src={Icons.ArrowBottom} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="L400">Jump to Latest</Text>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
45
src/app/features/room/components/TimelineDivider.tsx
Normal file
45
src/app/features/room/components/TimelineDivider.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import { as, Badge, Box, color, ContainerColor, Line, Text } from 'folds';
|
||||
import { MessageBase } from '../../../components/message';
|
||||
import { timeDayMonthYear, today, yesterday } from '../../../utils/time';
|
||||
import { MessageSpacing } from '../../../state/settings';
|
||||
|
||||
export 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 function DayDivider({ messageSpacing, ts }: { messageSpacing: MessageSpacing; ts: number }) {
|
||||
return (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<TimelineDivider variant="Surface">
|
||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||
<Text size="L400">
|
||||
{(() => {
|
||||
if (today(ts)) return 'Today';
|
||||
if (yesterday(ts)) return 'Yesterday';
|
||||
return timeDayMonthYear(ts);
|
||||
})()}
|
||||
</Text>
|
||||
</Badge>
|
||||
</TimelineDivider>
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewMessageDivider({ messageSpacing }: { messageSpacing: MessageSpacing }) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
17
src/app/features/room/components/TimelineFloat.tsx
Normal file
17
src/app/features/room/components/TimelineFloat.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import { as, Box } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
|
||||
export 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}
|
||||
/>
|
||||
)
|
||||
);
|
||||
3
src/app/features/room/components/index.ts
Normal file
3
src/app/features/room/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './TimelineDivider';
|
||||
export * from './TimelineFloat';
|
||||
export * from './JumpToLatestButton';
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { useCallback } from 'react';
|
||||
import {
|
||||
EncryptionSettings,
|
||||
HistoryVisibility,
|
||||
RoomEncryptionSettings,
|
||||
} from 'neurone/lib/crypto/machine';
|
||||
import { ObjectReader } from 'neurone/lib/utils';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { useRoomMembersAPI } from '../../../hooks/useRoomMembers';
|
||||
|
||||
export function buildEncryptionSettings(
|
||||
historyVisibility: HistoryVisibility,
|
||||
roomSettings: RoomEncryptionSettings | undefined,
|
||||
encryptionEventContent: object | undefined
|
||||
): EncryptionSettings {
|
||||
const rotationPeriodMs = ObjectReader.number(encryptionEventContent, 'rotation_period_ms');
|
||||
|
||||
const rotationPeriodMsgs = ObjectReader.number(encryptionEventContent, 'rotation_period_msgs');
|
||||
|
||||
const rotationMilliseconds = roomSettings?.sessionRotationPeriodMs ?? rotationPeriodMs;
|
||||
|
||||
return {
|
||||
algorithm: 'm.megolm.v1.aes-sha2',
|
||||
historyVisibility,
|
||||
encryptStateEvents: roomSettings?.encryptStateEvents,
|
||||
// EncryptionSettings expects microseconds.
|
||||
rotationPeriod: rotationMilliseconds === undefined ? undefined : rotationMilliseconds * 1_000,
|
||||
rotationPeriodMessages: roomSettings?.sessionRotationPeriodMessages ?? rotationPeriodMsgs,
|
||||
sharingStrategy: roomSettings?.onlyAllowTrustedDevices ? 'only-trusted-devices' : 'all-devices',
|
||||
};
|
||||
}
|
||||
|
||||
export const useEnsureRoomEncryptionSession = () => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
|
||||
const { memberEventsResult, getMembership } = useRoomMembersAPI();
|
||||
|
||||
const prepareEncryptionSession = useCallback(async () => {
|
||||
if (!ct.encryptedRooms.read.has(roomId)) {
|
||||
return;
|
||||
}
|
||||
const historyVisibility = ct.roomToHistoryVisibility.read.get(roomId) ?? 'shared';
|
||||
const encryptForInvited = historyVisibility !== 'joined';
|
||||
|
||||
// TODO: make sure all members are loaded.
|
||||
const targetMembers = memberEventsResult.value
|
||||
?.map((e) => e.userId)
|
||||
.filter((userId) => {
|
||||
const membership = getMembership(userId);
|
||||
return membership === 'join' || (membership === 'invite' && encryptForInvited);
|
||||
});
|
||||
|
||||
if (!targetMembers) return;
|
||||
|
||||
await ct.machine.userTracker.track(targetMembers);
|
||||
await ct.machine.outgoingRequests.processAll();
|
||||
await ct.machine.outgoingRequests.claimKeys(targetMembers);
|
||||
|
||||
const roomSettings = await ct.machine.roomEncryption.getSettings(roomId);
|
||||
const encryptionEventContent = ct.state.join.encryption.read.find((ev) => ev.roomId === roomId)
|
||||
?.event.content;
|
||||
|
||||
const encryptionSettings: EncryptionSettings = buildEncryptionSettings(
|
||||
historyVisibility,
|
||||
roomSettings,
|
||||
encryptionEventContent
|
||||
);
|
||||
|
||||
await ct.machine.roomKeys.shareRoomKey(roomId, targetMembers, encryptionSettings);
|
||||
}, [ct, roomId, memberEventsResult, getMembership]);
|
||||
|
||||
return prepareEncryptionSession;
|
||||
};
|
||||
276
src/app/features/room/hooks/useSendMessage.ts
Normal file
276
src/app/features/room/hooks/useSendMessage.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { useCallback } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import {
|
||||
customHtmlEqualsPlainText,
|
||||
getBeginCommand,
|
||||
getMentions,
|
||||
resetEditor,
|
||||
resetEditorHistory,
|
||||
toMatrixCustomHTML,
|
||||
toPlainText,
|
||||
trimCommand,
|
||||
trimCustomHtml,
|
||||
} from '../../../components/editor';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { MessageEvent } from '../../../../types/matrix/room';
|
||||
import { Command, CommandRecord, SHRUG, TABLEFLIP, UNFLIP } from '../../../hooks/useCommands';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { getMentionContent } from '../../../utils/room';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { mxcToHttp } from '../../../../client/mxcToHttp';
|
||||
import { getImageInfo } from '../../../utils/matrix';
|
||||
import { getImageUrlBlob, loadImageElement } from '../../../utils/dom';
|
||||
import { UploadItem } from '../../../state/room/roomInputDrafts';
|
||||
import { Upload, UploadSuccess } from '../../../state/upload';
|
||||
import { fulfilledPromiseSettledResult } from '../../../utils/common';
|
||||
import {
|
||||
getAudioMsgContent,
|
||||
getFileMsgContent,
|
||||
getImageMsgContent,
|
||||
getVideoMsgContent,
|
||||
} from '../msgContent';
|
||||
import {
|
||||
MsgType,
|
||||
MessageContent,
|
||||
RelationType,
|
||||
StickerContent,
|
||||
} from '../../../../types/matrix/common';
|
||||
import { useEnsureRoomEncryptionSession } from './useEnsureRoomEncryptionSession';
|
||||
import { LocalMessage } from '../../../../client/room/local-event';
|
||||
import { useTimelineView } from './useTimeline';
|
||||
|
||||
const useSendLocalMessage = () => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const prepareEncryptionSession = useEnsureRoomEncryptionSession();
|
||||
const timeline = useTimelineView();
|
||||
|
||||
const sendLocalMessage = useCallback(
|
||||
async (localMessage: LocalMessage) => {
|
||||
let wireMessage = localMessage.clone();
|
||||
|
||||
if (ct.encryptedRooms.read.has(roomId)) {
|
||||
await prepareEncryptionSession();
|
||||
const encStr = await ct.machine.roomEncryption.encryptMessageEvent(
|
||||
roomId,
|
||||
localMessage.type,
|
||||
localMessage.content
|
||||
);
|
||||
const encContent = JSON.parse(encStr);
|
||||
|
||||
wireMessage = new LocalMessage(roomId, MessageEvent.RoomMessageEncrypted, encContent);
|
||||
}
|
||||
|
||||
const txnId = ct.getTxnId();
|
||||
|
||||
await timeline.sendMessage(localMessage, wireMessage, txnId);
|
||||
},
|
||||
[ct, roomId, prepareEncryptionSession, timeline]
|
||||
);
|
||||
|
||||
return sendLocalMessage;
|
||||
};
|
||||
|
||||
export type ReplyToData = {
|
||||
userId: string;
|
||||
eventId: string;
|
||||
ping: boolean;
|
||||
};
|
||||
|
||||
export type SendMessageCallback = (replyTo?: ReplyToData) => void;
|
||||
|
||||
export const useSendMessage = (
|
||||
editor: Editor,
|
||||
commands: CommandRecord,
|
||||
threadId?: string
|
||||
): SendMessageCallback => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
|
||||
const sendLocalMessage = useSendLocalMessage();
|
||||
|
||||
const sendMessage: SendMessageCallback = useCallback(
|
||||
async (replyTo) => {
|
||||
const commandName = getBeginCommand(editor);
|
||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||
let customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
})
|
||||
);
|
||||
let msgType: 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 === Command.TableFlip) {
|
||||
plainText = `${TABLEFLIP} ${plainText}`;
|
||||
customHtml = `${TABLEFLIP} ${customHtml}`;
|
||||
} else if (commandName === Command.UnFlip) {
|
||||
plainText = `${UNFLIP} ${plainText}`;
|
||||
customHtml = `${UNFLIP} ${customHtml}`;
|
||||
} else if (commandName) {
|
||||
const commandContent = commands[commandName as Command];
|
||||
if (commandContent) {
|
||||
commandContent.exe(plainText);
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plainText === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = plainText;
|
||||
const formattedBody = customHtml;
|
||||
const mentionData = getMentions(ct, editor);
|
||||
|
||||
const content: MessageContent = {
|
||||
msgtype: msgType,
|
||||
body,
|
||||
};
|
||||
|
||||
if (replyTo && replyTo.userId !== ct.session.userId && replyTo.ping) {
|
||||
mentionData.users.add(replyTo.userId);
|
||||
}
|
||||
|
||||
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
|
||||
content['m.mentions'] = mMentions;
|
||||
|
||||
if (replyTo || !customHtmlEqualsPlainText(formattedBody, body)) {
|
||||
content.format = 'org.matrix.custom.html';
|
||||
content.formatted_body = formattedBody;
|
||||
}
|
||||
if (replyTo) {
|
||||
content['m.relates_to'] = {
|
||||
'm.in_reply_to': {
|
||||
event_id: replyTo.eventId,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (threadId) {
|
||||
content['m.relates_to'] = {
|
||||
...content['m.relates_to'],
|
||||
event_id: threadId,
|
||||
rel_type: RelationType.Thread,
|
||||
};
|
||||
if (!replyTo) {
|
||||
// this should be the latest threaded message event_id
|
||||
// but we will always keep it to root. The intention is
|
||||
// so user always see this message in response to original
|
||||
// thread root message as they are not in response to the latest
|
||||
// message from thread
|
||||
content['m.relates_to']['m.in_reply_to'] = { event_id: threadId };
|
||||
content['m.relates_to'].is_falling_back = true;
|
||||
}
|
||||
}
|
||||
|
||||
const localMessage = new LocalMessage(roomId, MessageEvent.RoomMessage, content);
|
||||
sendLocalMessage(localMessage);
|
||||
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
},
|
||||
[ct, roomId, editor, isMarkdown, commands, threadId, sendLocalMessage]
|
||||
);
|
||||
|
||||
return sendMessage;
|
||||
};
|
||||
|
||||
export const useSendStickerMessage = (threadId?: string) => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const sendLocalMessage = useSendLocalMessage();
|
||||
|
||||
const sendSticker = async (mxc: string, shortcode: string, label: string) => {
|
||||
const stickerUrl = mxcToHttp(ct.session.baseUrl, mxc, useAuthentication).url();
|
||||
if (!stickerUrl) return;
|
||||
|
||||
const info = await getImageInfo(
|
||||
await loadImageElement(stickerUrl),
|
||||
await getImageUrlBlob(stickerUrl)
|
||||
);
|
||||
const content: StickerContent = {
|
||||
body: label,
|
||||
url: mxc,
|
||||
info,
|
||||
};
|
||||
if (threadId) {
|
||||
content['m.relates_to'] = {
|
||||
event_id: threadId,
|
||||
rel_type: RelationType.Thread,
|
||||
'm.in_reply_to': { event_id: threadId },
|
||||
is_falling_back: true,
|
||||
};
|
||||
}
|
||||
|
||||
const localMessage = new LocalMessage(roomId, MessageEvent.Sticker, content);
|
||||
sendLocalMessage(localMessage);
|
||||
};
|
||||
|
||||
return sendSticker;
|
||||
};
|
||||
|
||||
export const useSendUploadMessage = (
|
||||
dropUploads: (uploads: Upload[]) => void,
|
||||
threadId?: string
|
||||
) => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
|
||||
const sendLocalMessage = useSendLocalMessage();
|
||||
|
||||
const sendUpload = async (uploadItems: UploadItem[], uploads: UploadSuccess[]) => {
|
||||
const contentsPromises = uploads.map(async (upload) => {
|
||||
const fileItem = uploadItems.find((f) => f.file === upload.file);
|
||||
if (!fileItem) throw new Error('Broken upload');
|
||||
|
||||
if (fileItem.file.type.startsWith('image')) {
|
||||
return getImageMsgContent(ct, fileItem, upload.mxc);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('video')) {
|
||||
return getVideoMsgContent(ct, fileItem, upload.mxc);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('audio')) {
|
||||
return getAudioMsgContent(fileItem, upload.mxc);
|
||||
}
|
||||
return getFileMsgContent(fileItem, upload.mxc);
|
||||
});
|
||||
dropUploads(uploads);
|
||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||
|
||||
contents.forEach((c) => {
|
||||
const content = { ...c };
|
||||
if (threadId) {
|
||||
content['m.relates_to'] = {
|
||||
event_id: threadId,
|
||||
rel_type: RelationType.Thread,
|
||||
'm.in_reply_to': { event_id: threadId },
|
||||
is_falling_back: true,
|
||||
};
|
||||
}
|
||||
|
||||
const localMessage = new LocalMessage(roomId, MessageEvent.RoomMessage, content);
|
||||
sendLocalMessage(localMessage);
|
||||
});
|
||||
};
|
||||
|
||||
return sendUpload;
|
||||
};
|
||||
284
src/app/features/room/hooks/useTimeline.ts
Normal file
284
src/app/features/room/hooks/useTimeline.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import {
|
||||
createContext,
|
||||
MouseEventHandler,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Editor } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { scrollToBottom } from '../../../utils/dom';
|
||||
import { MessageBatch } from '../../../../client/room/messages';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../../hooks/useResizeObserver';
|
||||
import {
|
||||
getIntersectionObserverEntry,
|
||||
useIntersectionObserver,
|
||||
} from '../../../hooks/useIntersectionObserver';
|
||||
import { useDebounce } from '../../../hooks/useDebounce';
|
||||
import { TimelineView } from '../../../../client/room';
|
||||
import {
|
||||
composerToReplyDraftAtom,
|
||||
getComposerId,
|
||||
ReplyDraft,
|
||||
} from '../../../state/room/roomInputDrafts';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||
import { useRoomMembersAPI } from '../../../hooks/useRoomMembers';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { createMentionElement, moveCursor } from '../../../components/editor';
|
||||
|
||||
const TimelineViewContext = createContext<TimelineView | null>(null);
|
||||
export const TimelineViewProvider = TimelineViewContext.Provider;
|
||||
|
||||
export const useTimelineView = (): TimelineView => {
|
||||
const timelineView = useContext(TimelineViewContext);
|
||||
if (!timelineView) {
|
||||
throw new Error('TimelineView is not provided!');
|
||||
}
|
||||
|
||||
return timelineView;
|
||||
};
|
||||
|
||||
export const useTimelinePrefetch = (timeline: TimelineView, limit: number) => {
|
||||
useEffect(
|
||||
() =>
|
||||
timeline.range.info.subscribe((info) => {
|
||||
if (info.start <= limit) {
|
||||
requestAnimationFrame(() => timeline.paginateBackward(limit));
|
||||
}
|
||||
if (info.end >= Math.max(timeline.batch.read.size - limit, 0)) {
|
||||
requestAnimationFrame(() => timeline.paginateForward(limit));
|
||||
}
|
||||
}),
|
||||
[timeline, limit]
|
||||
);
|
||||
};
|
||||
|
||||
export const useTimelinePaginate = (timeline: TimelineView, limit: number) =>
|
||||
useCallback(
|
||||
(back: boolean) => {
|
||||
if (back) {
|
||||
timeline.paginateBackward(limit);
|
||||
return;
|
||||
}
|
||||
timeline.paginateForward(limit);
|
||||
},
|
||||
[timeline, limit]
|
||||
);
|
||||
|
||||
export const useTimelineScrollRestore = (
|
||||
timeline: TimelineView,
|
||||
scrollRef: RefObject<HTMLDivElement>
|
||||
) => {
|
||||
useLayoutEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl) {
|
||||
const scrollTop = timeline.range.scrollTopCache;
|
||||
if (scrollTop) {
|
||||
scrollEl.scrollTo({ top: scrollTop, behavior: 'instant' });
|
||||
} else {
|
||||
scrollToBottom(scrollEl);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (!scrollEl) return;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
timeline.range.scrollTopCache = scrollEl.scrollTop;
|
||||
};
|
||||
}, [timeline, scrollRef]);
|
||||
};
|
||||
|
||||
/**
|
||||
* When room loads and timeline does not hav any message
|
||||
* scrolls to bottom after message appears.
|
||||
*/
|
||||
export const useRestoreScrollToBottom = (
|
||||
scrollRef: RefObject<HTMLDivElement>,
|
||||
msgBatch: MessageBatch
|
||||
) => {
|
||||
const prevSize = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
const pSize = prevSize.current;
|
||||
prevSize.current = msgBatch.size;
|
||||
|
||||
const scrollElement = scrollRef.current;
|
||||
if (pSize === 0 && msgBatch.next.end && scrollElement) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom(scrollElement, 'instant');
|
||||
});
|
||||
}
|
||||
}, [msgBatch, scrollRef]);
|
||||
};
|
||||
|
||||
export const useEditorResizeStickBottom = (
|
||||
getScrollElement: () => HTMLElement | null,
|
||||
getEditorElement: () => HTMLElement | null,
|
||||
atBottom: boolean
|
||||
) => {
|
||||
const atBottomRef = useRef(atBottom);
|
||||
atBottomRef.current = atBottom;
|
||||
|
||||
useResizeObserver(
|
||||
useMemo(() => {
|
||||
let mounted = false;
|
||||
return (entries) => {
|
||||
if (!mounted) {
|
||||
// skip initial mounting call
|
||||
mounted = true;
|
||||
return;
|
||||
}
|
||||
const editorEl = getEditorElement();
|
||||
if (!editorEl) return;
|
||||
const editorBaseEntry = getResizeObserverEntry(editorEl, entries);
|
||||
const scrollElement = getScrollElement();
|
||||
if (!editorBaseEntry || !scrollElement) return;
|
||||
|
||||
if (atBottomRef.current) {
|
||||
scrollToBottom(scrollElement);
|
||||
}
|
||||
};
|
||||
}, [getScrollElement, getEditorElement, atBottomRef]),
|
||||
getEditorElement
|
||||
);
|
||||
};
|
||||
|
||||
export const useAtBottomToggle = (
|
||||
getScrollElement: () => HTMLElement | null,
|
||||
atLiveEnd: boolean,
|
||||
onBottom?: (bottom: boolean) => void
|
||||
): [boolean, RefObject<HTMLElement>] => {
|
||||
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
||||
const [atBottom, setAtBottom] = useState<boolean>(true);
|
||||
|
||||
const debounceSetAtBottom = useDebounce(
|
||||
useCallback(
|
||||
(entry: IntersectionObserverEntry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
setAtBottom(false);
|
||||
onBottom?.(false);
|
||||
}
|
||||
},
|
||||
[onBottom]
|
||||
),
|
||||
{ wait: 1000 }
|
||||
);
|
||||
useIntersectionObserver(
|
||||
useCallback(
|
||||
(entries) => {
|
||||
const target = atBottomAnchorRef.current;
|
||||
if (!target) return;
|
||||
const targetEntry = getIntersectionObserverEntry(target, entries);
|
||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
||||
if (targetEntry?.isIntersecting && atLiveEnd) {
|
||||
setAtBottom(true);
|
||||
onBottom?.(true);
|
||||
}
|
||||
},
|
||||
[debounceSetAtBottom, atLiveEnd, onBottom]
|
||||
),
|
||||
useCallback(
|
||||
() => ({
|
||||
root: getScrollElement(),
|
||||
rootMargin: '100px',
|
||||
}),
|
||||
[getScrollElement]
|
||||
),
|
||||
useCallback(() => atBottomAnchorRef.current, [])
|
||||
);
|
||||
|
||||
return [atBottom, atBottomAnchorRef];
|
||||
};
|
||||
|
||||
export const useReplyClickHandler = (
|
||||
timeline: TimelineView,
|
||||
editor: Editor,
|
||||
threadId?: string
|
||||
): [ReplyDraft | undefined, MouseEventHandler<HTMLButtonElement>] => {
|
||||
const roomId = useRoom();
|
||||
const [replyDraft, setReplyDraft] = useAtom(
|
||||
composerToReplyDraftAtom(getComposerId(roomId, threadId))
|
||||
);
|
||||
|
||||
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 senderId = timeline.batch.read.messages.get(replyId)?.sender;
|
||||
if (!senderId) return;
|
||||
|
||||
if (senderId) {
|
||||
setReplyDraft({
|
||||
userId: senderId,
|
||||
eventId: replyId,
|
||||
});
|
||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||
}
|
||||
},
|
||||
[setReplyDraft, editor, timeline]
|
||||
);
|
||||
|
||||
return [replyDraft, handleReplyClick];
|
||||
};
|
||||
|
||||
export const useUserClickHandler = () => {
|
||||
const roomId = useRoom();
|
||||
const spaceId = useSpaceOptionally();
|
||||
|
||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||
|
||||
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;
|
||||
}
|
||||
openUserRoomProfile(roomId, spaceId, userId, evt.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[roomId, spaceId, openUserRoomProfile]
|
||||
);
|
||||
|
||||
return handleUserClick;
|
||||
};
|
||||
|
||||
export const useUsernameClickHandler = (editor: Editor) => {
|
||||
const ct = useClient();
|
||||
const membersAPI = useRoomMembersAPI();
|
||||
|
||||
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 = membersAPI.getName(userId);
|
||||
editor.insertNode(
|
||||
createMentionElement(
|
||||
userId,
|
||||
name.startsWith('@') ? name : `@${name}`,
|
||||
userId === ct.session.userId
|
||||
)
|
||||
);
|
||||
ReactEditor.focus(editor);
|
||||
moveCursor(editor);
|
||||
},
|
||||
[ct, membersAPI, editor]
|
||||
);
|
||||
|
||||
return handleUsernameClick;
|
||||
};
|
||||
62
src/app/features/room/hooks/useUploadItemPicker.ts
Normal file
62
src/app/features/room/hooks/useUploadItemPicker.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { RefObject, useCallback } from 'react';
|
||||
import { useFileDropZone } from '../../../hooks/useFileDrop';
|
||||
import { useFilePasteHandler } from '../../../hooks/useFilePasteHandler';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { safeFile } from '../../../utils/mimeTypes';
|
||||
import { UploadItem } from '../../../state/room/roomInputDrafts';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { fulfilledPromiseSettledResult } from '../../../utils/common';
|
||||
import { encryptFile } from '../../../utils/matrix';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
|
||||
export const useUploadItemPicker = (
|
||||
dropAreaRef: RefObject<HTMLElement>,
|
||||
onPick: (uploadItems: UploadItem[]) => void
|
||||
) => {
|
||||
const ct = useClient();
|
||||
const roomId = useRoom();
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
const safeFiles = files.map(safeFile);
|
||||
const uploadItems: UploadItem[] = [];
|
||||
|
||||
if (ct.encryptedRooms.read.has(roomId)) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
encryptFiles.forEach((ef) =>
|
||||
uploadItems.push({
|
||||
...ef,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
uploadItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
onPick(uploadItems);
|
||||
},
|
||||
[roomId, ct, onPick]
|
||||
);
|
||||
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handlePaste = useFilePasteHandler(handleFiles);
|
||||
const dropZoneVisible = useFileDropZone(dropAreaRef, handleFiles);
|
||||
|
||||
return {
|
||||
pickFile,
|
||||
handlePaste,
|
||||
dropZoneVisible,
|
||||
};
|
||||
};
|
||||
69
src/app/features/room/hooks/useUploadItems.ts
Normal file
69
src/app/features/room/hooks/useUploadItems.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useAtom } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
composerToUploadItemsAtom,
|
||||
getComposerId,
|
||||
roomUploadContentToUploadAtom,
|
||||
UploadItem,
|
||||
UploadMetadata,
|
||||
} from '../../../state/room/roomInputDrafts';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { UploadContent } from '../../../utils/matrix';
|
||||
import { Upload, UploadStatus } from '../../../state/upload';
|
||||
|
||||
export const useUploadItems = (threadId?: string) => {
|
||||
const roomId = useRoom();
|
||||
const [uploadItems, setUploadItems] = useAtom(
|
||||
composerToUploadItemsAtom(getComposerId(roomId, threadId))
|
||||
);
|
||||
|
||||
const updateUploadMetadata = useCallback(
|
||||
(item: UploadItem, metadata: UploadMetadata) => {
|
||||
setUploadItems({
|
||||
type: 'REPLACE',
|
||||
item,
|
||||
replacement: { ...item, metadata },
|
||||
});
|
||||
},
|
||||
[setUploadItems]
|
||||
);
|
||||
|
||||
const removeUploadItem = useCallback(
|
||||
(fileOrFiles: UploadContent | UploadContent[]) => {
|
||||
const files = Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles];
|
||||
setUploadItems({
|
||||
type: 'DELETE',
|
||||
item: uploadItems.filter((i) => files.find((f) => f === i.file)),
|
||||
});
|
||||
files.forEach((f) => roomUploadContentToUploadAtom.remove(f));
|
||||
},
|
||||
[uploadItems, setUploadItems]
|
||||
);
|
||||
|
||||
const addUploadItems = useCallback(
|
||||
(items: UploadItem[]) => {
|
||||
setUploadItems({
|
||||
type: 'PUT',
|
||||
item: items,
|
||||
});
|
||||
},
|
||||
[setUploadItems]
|
||||
);
|
||||
|
||||
const dropUploads = (uploads: Upload[]) => {
|
||||
uploads.forEach((upload) => {
|
||||
if (upload.status === UploadStatus.Loading) {
|
||||
upload.abortController.abort();
|
||||
}
|
||||
});
|
||||
removeUploadItem(uploads.map((upload) => upload.file));
|
||||
};
|
||||
|
||||
return {
|
||||
uploadItems,
|
||||
addUploadItems,
|
||||
removeUploadItem,
|
||||
updateUploadMetadata,
|
||||
dropUploads,
|
||||
};
|
||||
};
|
||||
16
src/app/features/room/hooks/useUploadsAtom.ts
Normal file
16
src/app/features/room/hooks/useUploadsAtom.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useMemo } from 'react';
|
||||
import { roomUploadContentToUploadAtom, UploadItem } from '../../../state/room/roomInputDrafts';
|
||||
import { createUploadsAtom } from '../../../state/upload';
|
||||
|
||||
export const useUploadsAtom = (uploadItems: UploadItem[]) => {
|
||||
const uploadsAtom = useMemo(
|
||||
() =>
|
||||
createUploadsAtom(
|
||||
roomUploadContentToUploadAtom,
|
||||
uploadItems.map((f) => f.file)
|
||||
),
|
||||
[uploadItems]
|
||||
);
|
||||
|
||||
return uploadsAtom;
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from 'folds';
|
||||
import { Editor, Transforms } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import {
|
||||
AUTOCOMPLETE_PREFIXES,
|
||||
|
|
@ -50,21 +50,21 @@ 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, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { MentionsContent, MessageContent, RelationType } from '../../../../types/matrix/common';
|
||||
|
||||
type MessageEditorProps = {
|
||||
roomId: string;
|
||||
room: Room;
|
||||
mEvent: MatrixEvent;
|
||||
imagePackRooms?: Room[];
|
||||
event: MessageEventWithoutRoomId;
|
||||
imagePackRooms?: string[];
|
||||
onCancel: () => void;
|
||||
};
|
||||
export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
({ roomId, event, imagePackRooms, onCancel, ...props }, ref) => {
|
||||
const ct = useClient();
|
||||
const editor = useEditor();
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
|
|
@ -78,9 +78,9 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||
const getPrevBodyAndFormattedBody = useCallback((): [
|
||||
string | undefined,
|
||||
string | undefined,
|
||||
IMentions | undefined
|
||||
MentionsContent | undefined
|
||||
] => {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtId = event.event_id;
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const editedEvent =
|
||||
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
|
||||
|
|
@ -95,7 +95,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||
typeof customHtml === 'string' ? customHtml : undefined,
|
||||
mMentions,
|
||||
];
|
||||
}, [room, mEvent]);
|
||||
}, [roomId, event]);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
|
|
@ -124,12 +124,13 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||
}
|
||||
}
|
||||
|
||||
const newContent: IContent = {
|
||||
msgtype: mEvent.getContent().msgtype,
|
||||
const prevContent = event.content as MessageContent;
|
||||
const newContent: MessageContent = {
|
||||
msgtype: prevContent.msgtype,
|
||||
body: plainText,
|
||||
};
|
||||
|
||||
const mentionData = getMentions(mx, roomId, editor);
|
||||
const mentionData = getMentions(ct, editor);
|
||||
|
||||
prevMentions?.user_ids?.forEach((prevMentionId) => {
|
||||
mentionData.users.add(prevMentionId);
|
||||
|
|
@ -143,18 +144,20 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||
newContent.formatted_body = customHtml;
|
||||
}
|
||||
|
||||
const content: IContent = {
|
||||
const content: MessageContent = {
|
||||
...newContent,
|
||||
body: `* ${plainText}`,
|
||||
'm.new_content': newContent,
|
||||
'm.relates_to': {
|
||||
event_id: mEvent.getId(),
|
||||
event_id: event.event_id,
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
};
|
||||
|
||||
return mx.sendMessage(roomId, content);
|
||||
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
|
||||
// TODO: send message
|
||||
// return mx.sendMessage(roomId, content);
|
||||
return Promise.resolve();
|
||||
}, [ct, editor, event, isMarkdown, getPrevBodyAndFormattedBody])
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
|
|
@ -241,7 +244,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
||||
<UserMentionAutocomplete
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||
import React, { MouseEventHandler, useMemo, useState } from 'react';
|
||||
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
|
|
@ -12,35 +13,27 @@ import {
|
|||
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';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ReactionContent } from '../../../../types/matrix/common';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { getReactionGroups } from '../../../utils/room';
|
||||
|
||||
export type ReactionsProps = {
|
||||
room: Room;
|
||||
mEventId: string;
|
||||
eventId: string;
|
||||
canSendReaction?: boolean;
|
||||
relations: Relations;
|
||||
reactions: MessageEventWithoutRoomId<ReactionContent>[];
|
||||
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();
|
||||
({ className, reactions, eventId, canSendReaction, onReactionToggle, ...props }, ref) => {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
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();
|
||||
|
|
@ -50,6 +43,8 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||
else setViewer(key);
|
||||
};
|
||||
|
||||
const reactionGroups = useMemo(() => getReactionGroups(reactions), [reactions]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.ReactionsContainer, className)}
|
||||
|
|
@ -58,20 +53,21 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||
{...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();
|
||||
{reactionGroups.map((reaction) => {
|
||||
const isPressed = reaction.users.has(ct.session.userId);
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
key={key}
|
||||
key={reaction.key}
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
<ReactionTooltipMsg
|
||||
shortcode={reaction.shortcode}
|
||||
reaction={reaction.key}
|
||||
users={Array.from(reaction.users)}
|
||||
/>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
|
|
@ -79,13 +75,15 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||
{(targetRef) => (
|
||||
<Reaction
|
||||
ref={targetRef}
|
||||
data-reaction-key={key}
|
||||
data-reaction-key={reaction.key}
|
||||
aria-pressed={isPressed}
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={events.size}
|
||||
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
|
||||
key={reaction.key}
|
||||
ct={ct}
|
||||
reaction={reaction.key}
|
||||
count={reaction.users.size}
|
||||
onClick={
|
||||
canSendReaction ? () => onReactionToggle(eventId, reaction.key) : undefined
|
||||
}
|
||||
onContextMenu={handleViewReaction}
|
||||
aria-disabled={!canSendReaction}
|
||||
useAuthentication={useAuthentication}
|
||||
|
|
@ -114,9 +112,8 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<ReactionViewer
|
||||
room={room}
|
||||
initialKey={typeof viewer === 'string' ? viewer : undefined}
|
||||
relations={relations}
|
||||
reactions={reactionGroups}
|
||||
requestClose={() => setViewer(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue