Compare commits
13 commits
903eefb475
...
8d2a7e02f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d2a7e02f6 | |||
| 482e93e3fb | |||
| f027696676 | |||
|
|
c1b2902bc5 | ||
|
|
0f70e6f706 | ||
|
|
94ff2d1d7d | ||
|
|
c9effa0860 | ||
|
|
4985de841c | ||
|
|
65085e84f7 | ||
|
|
e18769f9c6 | ||
|
|
8df27ac688 | ||
|
|
bbe53d6d6f | ||
|
|
98c90b23af |
46 changed files with 1152 additions and 2460 deletions
10
config.json
10
config.json
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"defaultHomeserver": 1,
|
||||
"defaultHomeserver": 2,
|
||||
"homeserverList": [
|
||||
"converser.eu",
|
||||
"envs.net",
|
||||
"matrix.org",
|
||||
"monero.social",
|
||||
"mozilla.org",
|
||||
"unredacted.org",
|
||||
"xmr.se"
|
||||
],
|
||||
"allowCustomHomeservers": true,
|
||||
"elementCallUrl": null,
|
||||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny-space:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:unredacted.org",
|
||||
"#space:envs.net",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"#PrivSec.dev:arcticfoxes.net",
|
||||
"#disroot:aria-net.org"
|
||||
],
|
||||
"servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
|
||||
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ server {
|
|||
rewrite ^/public/(.*)$ /public/$1 break;
|
||||
rewrite ^/assets/(.*)$ /assets/$1 break;
|
||||
|
||||
rewrite ^/element-call/dist/(.*)$ /element-call/dist/$1 break;
|
||||
|
||||
rewrite ^(.+)$ /index.html break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@
|
|||
to = "/assets/:splat"
|
||||
status = 200
|
||||
|
||||
[[redirects]]
|
||||
from = "/widgets/*"
|
||||
to = "/widgets/:splat"
|
||||
status = 200
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
|
|
|
|||
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -12,8 +12,8 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@element-hq/element-call-embedded": "0.16.0",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@matrix-org/react-sdk-module-api": "2.5.0",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.5.0",
|
||||
"folds": "2.4.0",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
"linkify-react": "4.1.3",
|
||||
"linkifyjs": "4.1.3",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.11.0",
|
||||
"matrix-widget-api": "1.13.1",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
|
|
@ -68,7 +68,6 @@
|
|||
"zod": "4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
|
|
@ -1654,10 +1653,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@element-hq/element-call-embedded": {
|
||||
"version": "0.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz",
|
||||
"integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==",
|
||||
"dev": true
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.0.tgz",
|
||||
"integrity": "sha512-cTJwW9cmQHrTUvcJm0xv9uSTMYiToDEaw5AuxXQS9jVjHQT8B3W3DWtKYAzq1PRRH13JZkcwXbHjdFpkxzhzCQ=="
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
|
|
@ -2274,18 +2272,6 @@
|
|||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@matrix-org/react-sdk-module-api": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.5.0.tgz",
|
||||
"integrity": "sha512-l/SmiO47gPIRd6YJJGj+B6qbxyypJF6SEsfYr7j9rSW6E85ZYCqf+TpMM2LmfwZRADyKfCVkaJbbBZYpoD02VA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -7179,9 +7165,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/folds": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz",
|
||||
"integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.4.0.tgz",
|
||||
"integrity": "sha512-Q5xCmvU3SIM8etQ9qLF6Y5Jtv01c9JpG3QcnF+Z3nlbMvtktfE13Pj7p0XgSPBcA3OuoU0zXiRwiTlMcbU7KhA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@vanilla-extract/css": "1.9.2",
|
||||
|
|
@ -8685,9 +8671,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/matrix-widget-api": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.11.0.tgz",
|
||||
"integrity": "sha512-ED/9hrJqDWVLeED0g1uJnYRhINh3ZTquwurdM+Hc8wLVJIQ8G/r7A7z74NC+8bBIHQ1Jo7i1Uq5CoJp/TzFYrA==",
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
|
||||
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
|
|
@ -10926,7 +10912,6 @@
|
|||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
|
||||
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"version": "4.10.2",
|
||||
"version": "4.10.2-minty",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "yarn check:eslint && yarn check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
"check:prettier": "prettier --check .",
|
||||
|
|
@ -24,8 +23,8 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@element-hq/element-call-embedded": "0.16.0",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@matrix-org/react-sdk-module-api": "2.5.0",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -45,7 +44,7 @@
|
|||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.5.0",
|
||||
"folds": "2.4.0",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
|
|
@ -56,8 +55,8 @@
|
|||
"jotai": "2.6.0",
|
||||
"linkify-react": "4.1.3",
|
||||
"linkifyjs": "4.1.3",
|
||||
"matrix-widget-api": "1.11.0",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.13.1",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
|
|
@ -80,7 +79,6 @@
|
|||
"zod": "4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
|
|
@ -112,4 +110,4 @@
|
|||
"vite-plugin-static-copy": "1.0.4",
|
||||
"vite-plugin-top-level-await": "1.4.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { CreateRoomKind } from './CreateRoomKindSelector';
|
|||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||
|
||||
export const createRoomCreationContent = (
|
||||
type: RoomType | undefined,
|
||||
|
|
@ -83,44 +82,6 @@ export const createRoomEncryptionState = () => ({
|
|||
},
|
||||
});
|
||||
|
||||
export const createRoomCallState = () => ({
|
||||
type: 'org.matrix.msc3401.call',
|
||||
state_key: '',
|
||||
content: {},
|
||||
});
|
||||
|
||||
export const createPowerLevelContentOverrides = (
|
||||
base: IPowerLevels,
|
||||
overrides: Partial<IPowerLevels>
|
||||
): IPowerLevels => ({
|
||||
...base,
|
||||
...overrides,
|
||||
...(base.events || overrides.events
|
||||
? {
|
||||
events: {
|
||||
...base.events,
|
||||
...overrides.events,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(base.users || overrides.users
|
||||
? {
|
||||
users: {
|
||||
...base.users,
|
||||
...overrides.users,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(base.notifications || overrides.notifications
|
||||
? {
|
||||
notifications: {
|
||||
...base.notifications,
|
||||
...overrides.notifications,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
export type CreateRoomData = {
|
||||
version: string;
|
||||
type?: RoomType;
|
||||
|
|
@ -133,7 +94,6 @@ export type CreateRoomData = {
|
|||
knock: boolean;
|
||||
allowFederation: boolean;
|
||||
additionalCreators?: string[];
|
||||
powerLevelContentOverrides?: IPowerLevels;
|
||||
};
|
||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||
const initialState: ICreateRoomStateEvent[] = [];
|
||||
|
|
@ -146,10 +106,6 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||
initialState.push(createRoomParentState(data.parent));
|
||||
}
|
||||
|
||||
if (data.type === RoomType.Call) {
|
||||
initialState.push(createRoomCallState());
|
||||
}
|
||||
|
||||
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
|
||||
|
||||
const options: ICreateRoomOpts = {
|
||||
|
|
@ -180,15 +136,5 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||
);
|
||||
}
|
||||
|
||||
if (data.powerLevelContentOverrides) {
|
||||
const roomPowers = await mx.getStateEvent(result.room_id, StateEvent.RoomPowerLevels, '');
|
||||
const updatedPowers = createPowerLevelContentOverrides(
|
||||
roomPowers,
|
||||
data.powerLevelContentOverrides
|
||||
);
|
||||
|
||||
await mx.sendStateEvent(result.room_id, StateEvent.RoomPowerLevels as any, updatedPowers, '');
|
||||
}
|
||||
|
||||
return result.room_id;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ export function RoomMentionAutocomplete({
|
|||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
|
|
|
|||
151
src/app/components/element-call/CallView.tsx
Normal file
151
src/app/components/element-call/CallView.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ClientWidgetApi } from 'matrix-widget-api';
|
||||
import { Button, Text } from 'folds';
|
||||
import ElementCall from './ElementCall';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useEventEmitter } from './utils';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useCallOngoing } from '../../hooks/useCallOngoing';
|
||||
|
||||
export enum CallWidgetActions {
|
||||
// All of these actions are currently specific to Jitsi and Element Call
|
||||
JoinCall = 'io.element.join',
|
||||
HangupCall = 'im.vector.hangup',
|
||||
Close = 'io.element.close',
|
||||
}
|
||||
export function action(type: CallWidgetActions): string {
|
||||
return `action:${type}`;
|
||||
}
|
||||
|
||||
const iframeFeatures =
|
||||
'microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; ' +
|
||||
'clipboard-read;';
|
||||
const sandboxFlags =
|
||||
'allow-forms allow-popups allow-popups-to-escape-sandbox ' +
|
||||
'allow-same-origin allow-scripts allow-presentation allow-downloads';
|
||||
const iframeStyles = { flex: '1 1', border: 'none' };
|
||||
const containerStyle = (hidden: boolean): React.CSSProperties => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
...(hidden
|
||||
? {
|
||||
overflow: 'hidden',
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const closeButtonStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 100,
|
||||
maxWidth: 'fit-content',
|
||||
};
|
||||
|
||||
enum State {
|
||||
Preparing = 'preparing',
|
||||
Lobby = 'lobby',
|
||||
Joined = 'joined',
|
||||
HungUp = 'hung_up',
|
||||
CanClose = 'can_close',
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a call for this room. Rendering this component will
|
||||
* automatically create a call widget and join the call in the room.
|
||||
* @returns
|
||||
*/
|
||||
export interface IRoomCallViewProps {
|
||||
onClose?: () => void;
|
||||
onJoin?: () => void;
|
||||
onHangup?: (errorMessage?: string) => void;
|
||||
}
|
||||
|
||||
export function CallView({
|
||||
onJoin = undefined,
|
||||
onClose = undefined,
|
||||
onHangup = undefined,
|
||||
}: IRoomCallViewProps): JSX.Element {
|
||||
// Construct required variables
|
||||
const room = useRoom();
|
||||
const client = useMatrixClient();
|
||||
const iframe = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Model state
|
||||
const [elementCall, setElementCall] = useState<ElementCall | null>();
|
||||
const [widgetApi, setWidgetApi] = useState<ClientWidgetApi | null>(null);
|
||||
const [state, setState] = useState(State.Preparing);
|
||||
|
||||
// Initialization parameters
|
||||
const isDirect = useIsDirectRoom();
|
||||
const callOngoing = useCallOngoing(room);
|
||||
const initialCallOngoing = React.useRef(callOngoing);
|
||||
const initialIsDirect = React.useRef(isDirect);
|
||||
useEffect(() => {
|
||||
if (client && room && !elementCall) {
|
||||
const e = new ElementCall(client, room, initialIsDirect.current, initialCallOngoing.current);
|
||||
setElementCall(e);
|
||||
}
|
||||
}, [client, room, setElementCall, elementCall]);
|
||||
|
||||
// Start the messaging over the widget api.
|
||||
useEffect(() => {
|
||||
if (iframe.current && elementCall) {
|
||||
elementCall.startMessaging(iframe.current);
|
||||
}
|
||||
return () => {
|
||||
elementCall?.stopMessaging();
|
||||
};
|
||||
}, [iframe, elementCall]);
|
||||
|
||||
// Widget api ready
|
||||
useEventEmitter(elementCall, 'ready', () => {
|
||||
setWidgetApi(elementCall?.widgetApi ?? null);
|
||||
setState(State.Lobby);
|
||||
});
|
||||
|
||||
// Use widget api to listen for hangup/join/close actions
|
||||
useEventEmitter(widgetApi, action(CallWidgetActions.HangupCall), () => {
|
||||
setState(State.HungUp);
|
||||
onHangup?.();
|
||||
});
|
||||
useEventEmitter(widgetApi, action(CallWidgetActions.JoinCall), () => {
|
||||
setState(State.Joined);
|
||||
onJoin?.();
|
||||
});
|
||||
useEventEmitter(widgetApi, action(CallWidgetActions.Close), () => {
|
||||
setState(State.CanClose);
|
||||
onClose?.();
|
||||
});
|
||||
|
||||
// render component
|
||||
return (
|
||||
<div style={containerStyle(state === State.HungUp)}>
|
||||
{/* Exit button for lobby state */}
|
||||
{state === State.Lobby && (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
onClick={() => {
|
||||
setState(State.CanClose);
|
||||
onClose?.();
|
||||
}}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<Text size="B400">Close</Text>
|
||||
</Button>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframe}
|
||||
allow={iframeFeatures}
|
||||
sandbox={sandboxFlags}
|
||||
style={iframeStyles}
|
||||
src={elementCall?.embedUrl}
|
||||
title="room call"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +1,27 @@
|
|||
/* eslint-disable no-return-await */
|
||||
/* eslint-disable no-param-reassign */
|
||||
/* eslint-disable no-continue */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
/* eslint-disable no-plusplus */
|
||||
/* eslint-disable no-dupe-class-members */
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
* Copyright 2020-2023 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
/* eslint-disable lines-between-class-members */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
import {
|
||||
type Capability,
|
||||
EventDirection,
|
||||
type IOpenIDCredentials,
|
||||
type IOpenIDUpdate,
|
||||
type ISendDelayedEventDetails,
|
||||
type ISendEventDetails,
|
||||
type ITurnServer,
|
||||
type IReadEventRelationsResult,
|
||||
type IRoomEvent,
|
||||
MatrixCapabilities,
|
||||
type Widget,
|
||||
OpenIDRequestState,
|
||||
type SimpleObservable,
|
||||
WidgetDriver,
|
||||
WidgetEventCapability,
|
||||
WidgetKind,
|
||||
type IWidgetApiErrorResponseDataDetails,
|
||||
type ISearchUserDirectoryResult,
|
||||
type IGetMediaConfigResult,
|
||||
type UpdateDelayedEventAction,
|
||||
OpenIDRequestState,
|
||||
SimpleObservable,
|
||||
IOpenIDUpdate,
|
||||
} from 'matrix-widget-api';
|
||||
import {
|
||||
ClientEvent,
|
||||
type ITurnServer as IClientTurnServer,
|
||||
EventType,
|
||||
type IContent,
|
||||
MatrixError,
|
||||
|
|
@ -40,130 +31,36 @@ import {
|
|||
type StateEvents,
|
||||
type TimelineEvents,
|
||||
MatrixClient,
|
||||
getHttpUriForMxc,
|
||||
} from 'matrix-js-sdk';
|
||||
import { logger } from 'matrix-js-sdk/lib/logger';
|
||||
import iterableDiff, { downloadFromUrlToFile } from './utils';
|
||||
|
||||
export class SmallWidgetDriver extends WidgetDriver {
|
||||
private allowedCapabilities: Set<Capability>;
|
||||
// TODO: Purge this from the universe
|
||||
|
||||
private readonly mxClient: MatrixClient; // Store the client instance
|
||||
const normalizeTurnServer = ({ urls, username, credential }: IClientTurnServer): ITurnServer => ({
|
||||
uris: urls,
|
||||
username,
|
||||
password: credential,
|
||||
});
|
||||
|
||||
export default class CallWidgetDriver extends WidgetDriver {
|
||||
// TODO: Refactor widgetKind into the Widget class
|
||||
public constructor(
|
||||
mx: MatrixClient,
|
||||
allowedCapabilities: Capability[],
|
||||
private forWidget: Widget,
|
||||
private forWidgetKind: WidgetKind,
|
||||
virtual: boolean, // Assuming 'virtual' might be needed later, kept for consistency
|
||||
private inRoomId?: string
|
||||
private client: MatrixClient,
|
||||
private allowedCapabilities = new Set<Capability>(),
|
||||
private inRoomId: string
|
||||
) {
|
||||
super();
|
||||
this.mxClient = mx; // Store the passed instance
|
||||
|
||||
this.allowedCapabilities = new Set([
|
||||
...allowedCapabilities,
|
||||
MatrixCapabilities.Screenshots,
|
||||
// Add other base capabilities as needed, e.g., ElementWidgetCapabilities.RequiresClient
|
||||
]);
|
||||
|
||||
// --- Capabilities specific to Element Call (or similar trusted widgets) ---
|
||||
// This is a trusted Element Call widget that we control (adjust if not Element Call)
|
||||
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||
this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
|
||||
this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
|
||||
this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||
// Capability to access the room timeline (MSC2762)
|
||||
this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
|
||||
// Capability to read room state (MSC2762)
|
||||
this.allowedCapabilities.add(`org.matrix.msc2762.state:${inRoomId}`);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw
|
||||
);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call').raw
|
||||
);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomEncryption).raw
|
||||
);
|
||||
const clientUserId = this.mxClient.getSafeUserId();
|
||||
// For the legacy membership type
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(
|
||||
EventDirection.Send,
|
||||
'org.matrix.msc3401.call.member',
|
||||
clientUserId
|
||||
).raw
|
||||
);
|
||||
const clientDeviceId = this.mxClient.getDeviceId();
|
||||
if (clientDeviceId !== null) {
|
||||
// For the session membership type compliant with MSC4143
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(
|
||||
EventDirection.Send,
|
||||
'org.matrix.msc3401.call.member',
|
||||
`_${clientUserId}_${clientDeviceId}`
|
||||
).raw
|
||||
);
|
||||
// Version with no leading underscore, for room versions whose auth rules allow it
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(
|
||||
EventDirection.Send,
|
||||
'org.matrix.msc3401.call.member',
|
||||
`${clientUserId}_${clientDeviceId}`
|
||||
).raw
|
||||
);
|
||||
}
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member')
|
||||
.raw
|
||||
);
|
||||
// for determining auth rules specific to the room version
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
|
||||
);
|
||||
|
||||
const sendRecvRoomEvents = [
|
||||
'io.element.call.encryption_keys',
|
||||
'org.matrix.rageshake_request',
|
||||
EventType.Reaction,
|
||||
EventType.RoomRedaction,
|
||||
'io.element.call.reaction',
|
||||
];
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const eventType of sendRecvRoomEvents) {
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forRoomEvent(EventDirection.Send, eventType).raw
|
||||
);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forRoomEvent(EventDirection.Receive, eventType).raw
|
||||
);
|
||||
}
|
||||
|
||||
const sendRecvToDevice = [
|
||||
EventType.CallInvite,
|
||||
EventType.CallCandidates,
|
||||
EventType.CallAnswer,
|
||||
EventType.CallHangup,
|
||||
EventType.CallReject,
|
||||
EventType.CallSelectAnswer,
|
||||
EventType.CallNegotiate,
|
||||
EventType.CallSDPStreamMetadataChanged,
|
||||
EventType.CallSDPStreamMetadataChangedPrefix,
|
||||
EventType.CallReplaces,
|
||||
EventType.CallEncryptionKeysPrefix,
|
||||
];
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const eventType of sendRecvToDevice) {
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw
|
||||
);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
||||
// Stubbed under the assumption voice calls will be valid thru element-call
|
||||
return requested;
|
||||
// Check to see if any capabilities aren't automatically accepted (such as sticker pickers
|
||||
// allowing stickers to be sent). If there are excess capabilities to be approved, the user
|
||||
// will be prompted to accept them.
|
||||
const missing = new Set(iterableDiff(requested, this.allowedCapabilities).removed); // "removed" is "in A (requested) but not in B (allowed)"
|
||||
if (missing.size > 0) logger.error('missing widget capabilities', missing);
|
||||
return this.allowedCapabilities;
|
||||
}
|
||||
|
||||
public async sendEvent<K extends keyof StateEvents>(
|
||||
|
|
@ -172,21 +69,19 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
stateKey: string | null,
|
||||
targetRoomId: string | null
|
||||
): Promise<ISendEventDetails>;
|
||||
|
||||
public async sendEvent<K extends keyof TimelineEvents>(
|
||||
eventType: K,
|
||||
content: TimelineEvents[K],
|
||||
stateKey: null,
|
||||
targetRoomId: string | null
|
||||
): Promise<ISendEventDetails>;
|
||||
|
||||
public async sendEvent(
|
||||
eventType: string,
|
||||
content: IContent,
|
||||
stateKey: string | null = null,
|
||||
targetRoomId: string | null = null
|
||||
): Promise<ISendEventDetails> {
|
||||
const client = this.mxClient;
|
||||
const { client } = this;
|
||||
const roomId = targetRoomId || this.inRoomId;
|
||||
|
||||
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
|
||||
|
|
@ -227,7 +122,6 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
stateKey: string | null,
|
||||
targetRoomId: string | null
|
||||
): Promise<ISendDelayedEventDetails>;
|
||||
|
||||
/**
|
||||
* @experimental Part of MSC4140 & MSC4157
|
||||
*/
|
||||
|
|
@ -239,7 +133,6 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
stateKey: null,
|
||||
targetRoomId: string | null
|
||||
): Promise<ISendDelayedEventDetails>;
|
||||
|
||||
public async sendDelayedEvent(
|
||||
delay: number | null,
|
||||
parentDelayId: string | null,
|
||||
|
|
@ -248,7 +141,7 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
stateKey: string | null = null,
|
||||
targetRoomId: string | null = null
|
||||
): Promise<ISendDelayedEventDetails> {
|
||||
const client = this.mxClient;
|
||||
const { client } = this;
|
||||
const roomId = targetRoomId || this.inRoomId;
|
||||
|
||||
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
|
||||
|
|
@ -301,7 +194,7 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
delayId: string,
|
||||
action: UpdateDelayedEventAction
|
||||
): Promise<void> {
|
||||
const client = this.mxClient;
|
||||
const { client } = this;
|
||||
|
||||
if (!client) throw new Error('Not in a room or not attached to a client');
|
||||
|
||||
|
|
@ -316,7 +209,7 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
encrypted: boolean,
|
||||
contentMap: { [userId: string]: { [deviceId: string]: object } }
|
||||
): Promise<void> {
|
||||
const client = this.mxClient;
|
||||
const { client } = this;
|
||||
|
||||
if (encrypted) {
|
||||
const crypto = client.getCrypto();
|
||||
|
|
@ -324,18 +217,15 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
|
||||
// attempt to re-batch these up into a single request
|
||||
const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {};
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const userId of Object.keys(contentMap)) {
|
||||
Object.keys(contentMap).forEach((userId) => {
|
||||
const userContentMap = contentMap[userId];
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const deviceId of Object.keys(userContentMap)) {
|
||||
Object.keys(userContentMap).forEach((deviceId) => {
|
||||
const content = userContentMap[deviceId];
|
||||
const stringifiedContent = JSON.stringify(content);
|
||||
invertedContentMap[stringifiedContent] = invertedContentMap[stringifiedContent] || [];
|
||||
invertedContentMap[stringifiedContent].push({ userId, deviceId });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(invertedContentMap).map(async ([stringifiedContent, recipients]) => {
|
||||
|
|
@ -386,35 +276,32 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
limit: number,
|
||||
since: string | undefined
|
||||
): Promise<IRoomEvent[]> {
|
||||
limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
|
||||
// relatively arbitrary
|
||||
const timelineLimit =
|
||||
limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const room = this.mxClient.getRoom(roomId);
|
||||
const room = this.client.getRoom(roomId);
|
||||
if (room === null) return [];
|
||||
const results: MatrixEvent[] = [];
|
||||
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i];
|
||||
if (results.length >= limit) break;
|
||||
if (results.length >= timelineLimit) break;
|
||||
if (since !== undefined && ev.getId() === since) break;
|
||||
|
||||
if (ev.getType() !== eventType || ev.isState()) continue;
|
||||
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent().msgtype)
|
||||
continue;
|
||||
if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey)
|
||||
continue;
|
||||
results.push(ev);
|
||||
if (
|
||||
ev.getType() === eventType &&
|
||||
!ev.isState() &&
|
||||
(eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) &&
|
||||
(ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey)
|
||||
) {
|
||||
results.push(ev);
|
||||
}
|
||||
}
|
||||
|
||||
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
|
||||
}
|
||||
|
||||
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
|
||||
return observer.update({
|
||||
state: OpenIDRequestState.Allowed,
|
||||
token: await this.mxClient.getOpenIdToken(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current values of all matching room state entries.
|
||||
* @param roomId The ID of the room.
|
||||
|
|
@ -429,22 +316,69 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
eventType: string,
|
||||
stateKey: string | undefined
|
||||
): Promise<IRoomEvent[]> {
|
||||
const room = this.mxClient.getRoom(roomId);
|
||||
const room = this.client.getRoom(roomId);
|
||||
if (room === null) return [];
|
||||
const state = room.getLiveTimeline().getState(Direction.Forward);
|
||||
if (state === undefined) return [];
|
||||
|
||||
if (stateKey === undefined)
|
||||
if (stateKey === undefined) {
|
||||
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
|
||||
}
|
||||
const event = state.getStateEvents(eventType, stateKey);
|
||||
return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent];
|
||||
}
|
||||
|
||||
/*
|
||||
public async navigate(uri: string): Promise<void> {
|
||||
navigateToPermalink(uri);
|
||||
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
|
||||
// TODO: Fully functional widget driver a user prompt is required here, see element web
|
||||
const getToken = (): Promise<IOpenIDCredentials> => this.client.getOpenIdToken();
|
||||
return observer.update({ state: OpenIDRequestState.Allowed, token: await getToken() });
|
||||
}
|
||||
|
||||
public async navigate(uri: string): Promise<void> {
|
||||
// navigateToPermalink(uri);
|
||||
// TODO: Dummy code until we figured out navigateToPermalink implementation
|
||||
if (uri) return Promise.resolve();
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
public async *getTurnServers(): AsyncGenerator<ITurnServer> {
|
||||
const { client } = this;
|
||||
if (!client.pollingTurnServers || !client.getTurnServers().length) return;
|
||||
|
||||
let setTurnServer: (server: ITurnServer) => void;
|
||||
let setError: (error: Error) => void;
|
||||
|
||||
const onTurnServers = ([server]: IClientTurnServer[]): void =>
|
||||
setTurnServer(normalizeTurnServer(server));
|
||||
const onTurnServersError = (error: Error, fatal: boolean): void => {
|
||||
if (fatal) setError(error);
|
||||
};
|
||||
|
||||
client.on(ClientEvent.TurnServers, onTurnServers);
|
||||
client.on(ClientEvent.TurnServersError, onTurnServersError);
|
||||
|
||||
try {
|
||||
const initialTurnServer = client.getTurnServers()[0];
|
||||
yield normalizeTurnServer(initialTurnServer);
|
||||
|
||||
const waitForTurnServer = (): Promise<ITurnServer> =>
|
||||
new Promise<ITurnServer>((resolve, reject) => {
|
||||
setTurnServer = resolve;
|
||||
setError = reject;
|
||||
});
|
||||
|
||||
// Repeatedly listen for new TURN servers until an error occurs or
|
||||
// the caller stops this generator
|
||||
while (true) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
yield await waitForTurnServer();
|
||||
}
|
||||
} finally {
|
||||
// The loop was broken - clean up
|
||||
client.off(ClientEvent.TurnServers, onTurnServers);
|
||||
client.off(ClientEvent.TurnServersError, onTurnServersError);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
public async readEventRelations(
|
||||
eventId: string,
|
||||
|
|
@ -456,20 +390,25 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
limit?: number,
|
||||
direction?: 'f' | 'b'
|
||||
): Promise<IReadEventRelationsResult> {
|
||||
const client = this.mxClient;
|
||||
const { client } = this;
|
||||
const dir = direction as Direction;
|
||||
roomId = roomId ?? this.inRoomId ?? undefined;
|
||||
const rId = roomId || this.inRoomId;
|
||||
|
||||
if (typeof roomId !== 'string') {
|
||||
if (typeof rId !== 'string') {
|
||||
throw new Error('Error while reading the current room');
|
||||
}
|
||||
|
||||
const { events, nextBatch, prevBatch } = await client.relations(
|
||||
roomId,
|
||||
rId,
|
||||
eventId,
|
||||
relationType ?? null,
|
||||
eventType ?? null,
|
||||
{ from, to, limit, dir }
|
||||
{
|
||||
from,
|
||||
to,
|
||||
limit,
|
||||
dir,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -483,7 +422,7 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
searchTerm: string,
|
||||
limit?: number
|
||||
): Promise<ISearchUserDirectoryResult> {
|
||||
const client = this.mxClient;
|
||||
const { client } = this;
|
||||
|
||||
const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit });
|
||||
|
||||
|
|
@ -498,13 +437,13 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
}
|
||||
|
||||
public async getMediaConfig(): Promise<IGetMediaConfigResult> {
|
||||
const client = this.mxClient;
|
||||
const { client } = this;
|
||||
|
||||
return await client.getMediaConfig();
|
||||
return client.getMediaConfig();
|
||||
}
|
||||
|
||||
public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
|
||||
const client = this.mxClient;
|
||||
const { client } = this;
|
||||
|
||||
const uploadResult = await client.uploadContent(file);
|
||||
|
||||
|
|
@ -517,15 +456,20 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
* @param contentUri - the MXC URI of the file to download
|
||||
* @returns an object with: file - response contents as Blob
|
||||
*/
|
||||
/*
|
||||
public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
|
||||
const client = this.mxClient;
|
||||
const media = mediaFromMxc(contentUri, client);
|
||||
const response = await media.downloadSource();
|
||||
const blob = await response.blob();
|
||||
return { file: blob };
|
||||
}
|
||||
*/
|
||||
public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
|
||||
const httpUrl = getHttpUriForMxc(
|
||||
this.client.baseUrl,
|
||||
contentUri,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
const file = await downloadFromUrlToFile(httpUrl);
|
||||
return { file };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IDs of all joined or invited rooms currently known to the
|
||||
|
|
@ -533,7 +477,7 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
* @returns The room IDs.
|
||||
*/
|
||||
public getKnownRooms(): string[] {
|
||||
return this.mxClient.getVisibleRooms().map((r) => r.roomId);
|
||||
return this.client.getVisibleRooms(false).map((r) => r.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -545,7 +489,9 @@ export class SmallWidgetDriver extends WidgetDriver {
|
|||
*/
|
||||
public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined {
|
||||
return error instanceof MatrixError
|
||||
? { matrix_api_error: error.asWidgetApiErrorData() }
|
||||
? {
|
||||
matrix_api_error: error.asWidgetApiErrorData(),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
290
src/app/components/element-call/ElementCall.ts
Normal file
290
src/app/components/element-call/ElementCall.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import {
|
||||
MatrixEvent,
|
||||
MatrixClient,
|
||||
MatrixEventEvent,
|
||||
ClientEvent,
|
||||
RoomStateEvent,
|
||||
KnownMembership,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
|
||||
import { ClientWidgetApi, type IRoomEvent, Widget } from 'matrix-widget-api';
|
||||
import { EventEmitter } from 'events';
|
||||
import { logger } from 'matrix-js-sdk/src/logger';
|
||||
|
||||
import { arrayFastClone, elementCallCapabilities } from './utils';
|
||||
import CallWidgetDriver from './CallWidgetDriver';
|
||||
|
||||
export enum ElementCallIntent {
|
||||
StartCall = 'start_call',
|
||||
JoinExisting = 'join_existing',
|
||||
StartCallDM = 'start_call_dm',
|
||||
JoinExistingDM = 'join_existing_dm',
|
||||
}
|
||||
|
||||
function createCallWidget(room: Room, client: MatrixClient, intent: string): Widget {
|
||||
const perParticipantE2EE = room?.hasEncryptionStateEvent() ?? false;
|
||||
|
||||
const baseUrl = new URL(window.location.href).origin;
|
||||
const url = new URL('./widgets/element-call/index.html#', baseUrl); // this strips hash fragment from baseUrl
|
||||
const widgetId = 'io-element-call-widget-id';
|
||||
// Splice together the Element Call URL for this call
|
||||
const paramsHash = new URLSearchParams({
|
||||
perParticipantE2EE: perParticipantE2EE ? 'true' : 'false',
|
||||
intent,
|
||||
userId: client.getSafeUserId(),
|
||||
deviceId: client.getDeviceId() ?? '',
|
||||
roomId: room.roomId,
|
||||
baseUrl: client.baseUrl,
|
||||
lang: 'en-EN',
|
||||
theme: 'light',
|
||||
});
|
||||
const paramsSearch = new URLSearchParams({
|
||||
widgetId,
|
||||
parentUrl: window.location.href.split('#', 2)[0],
|
||||
});
|
||||
|
||||
url.search = paramsSearch.toString();
|
||||
const replacedUrl = paramsHash.toString().replace(/%24/g, '$');
|
||||
url.hash = `#?${replacedUrl}`;
|
||||
|
||||
return new Widget({
|
||||
id: widgetId,
|
||||
creatorUserId: client.getSafeUserId(),
|
||||
name: 'Element Call',
|
||||
type: 'm.call',
|
||||
url: url.toString(),
|
||||
waitForIframeLoad: false,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
export default class ElementCall extends EventEmitter {
|
||||
private messaging: ClientWidgetApi | null = null;
|
||||
|
||||
public widget: Widget;
|
||||
|
||||
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
||||
|
||||
// Holds events that should be fed to the widget once they finish decrypting
|
||||
private eventsToFeed = new WeakSet<MatrixEvent>();
|
||||
|
||||
public constructor(
|
||||
private client: MatrixClient,
|
||||
private room: Room,
|
||||
isDirect: boolean,
|
||||
callOngoing: boolean
|
||||
) {
|
||||
super();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const intent = new Map([
|
||||
['start_call_dm', ElementCallIntent.StartCallDM],
|
||||
['join_existing_dm', ElementCallIntent.JoinExistingDM],
|
||||
['start_call', ElementCallIntent.StartCall],
|
||||
['join_existing', ElementCallIntent.JoinExisting],
|
||||
]).get((callOngoing ? 'join_existing' : 'start_call') + (isDirect ? '_dm' : ''))!;
|
||||
|
||||
this.widget = createCallWidget(this.room, this.client, intent);
|
||||
}
|
||||
|
||||
public get widgetApi(): ClientWidgetApi | null {
|
||||
return this.messaging;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to use in the iframe
|
||||
*/
|
||||
public get embedUrl(): string {
|
||||
return this.widget.templateUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* This starts the messaging for the widget if it is not in the state `started` yet.
|
||||
* @param iframe the iframe the widget should use
|
||||
*/
|
||||
public startMessaging(iframe: HTMLIFrameElement) {
|
||||
if (this.messaging) return;
|
||||
|
||||
const userId = this.client.getSafeUserId();
|
||||
const deviceId = this.client.getDeviceId() ?? undefined;
|
||||
const driver = new CallWidgetDriver(
|
||||
this.client,
|
||||
elementCallCapabilities(this.room.roomId, userId, deviceId),
|
||||
this.room.roomId
|
||||
);
|
||||
|
||||
this.messaging = new ClientWidgetApi(this.widget, iframe, driver);
|
||||
this.messaging.on('preparing', () => this.emit('preparing'));
|
||||
this.messaging.on('error:preparing', (err: unknown) => this.emit('error:preparing', err));
|
||||
this.messaging.once('ready', () => {
|
||||
this.emit('ready');
|
||||
});
|
||||
this.messaging.on('capabilitiesNotified', () => this.emit('capabilitiesNotified'));
|
||||
|
||||
// Room widgets get locked to the room they were added in
|
||||
this.messaging.setViewedRoomId(this.room.roomId);
|
||||
|
||||
// Populate the map of "read up to" events for this widget with the current event in every room.
|
||||
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
|
||||
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
|
||||
this.client.getRooms().forEach((room) => {
|
||||
// Timelines are most recent last
|
||||
const events = room.getLiveTimeline()?.getEvents() || [];
|
||||
const roomEvent = events[events.length - 1];
|
||||
if (!roomEvent) return; // force later code to think the room is fresh
|
||||
this.readUpToMap[room.roomId] = roomEvent.getId()!;
|
||||
});
|
||||
|
||||
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
|
||||
this.client.on(ClientEvent.Event, this.onEvent);
|
||||
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
this.client.on(RoomStateEvent.Events, this.onStateUpdate);
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the widget messaging for if it is started. Skips stopping if it is an active
|
||||
* widget.
|
||||
* @param opts
|
||||
*/
|
||||
public stopMessaging(): void {
|
||||
if (this.messaging) {
|
||||
this.messaging.stop();
|
||||
}
|
||||
this.messaging = null;
|
||||
|
||||
this.client.off(ClientEvent.Event, this.onEvent);
|
||||
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
this.client.off(RoomStateEvent.Events, this.onStateUpdate);
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
|
||||
// Clear internal state
|
||||
this.readUpToMap = {};
|
||||
this.eventsToFeed = new WeakSet<MatrixEvent>();
|
||||
}
|
||||
|
||||
private onEvent = (ev: MatrixEvent): void => {
|
||||
this.client.decryptEventIfNeeded(ev);
|
||||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onEventDecrypted = (ev: MatrixEvent): void => {
|
||||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onStateUpdate = (ev: MatrixEvent): void => {
|
||||
if (this.messaging === null) return;
|
||||
const raw = ev.getEffectiveEvent();
|
||||
this.messaging.feedStateUpdate(raw as IRoomEvent).catch((e) => {
|
||||
logger.error('Error sending state update to widget: ', e);
|
||||
});
|
||||
};
|
||||
|
||||
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
|
||||
await this.client.decryptEventIfNeeded(ev);
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the event has a relation to an unknown parent.
|
||||
*/
|
||||
private relatesToUnknown(ev: MatrixEvent): boolean {
|
||||
// Replies to unknown events don't count
|
||||
if (!ev.relationEventId || ev.replyEventId) return false;
|
||||
const room = this.client.getRoom(ev.getRoomId());
|
||||
return room === null || !room.findEventById(ev.relationEventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the "read up to" marker for a room to a certain event. No-ops if
|
||||
* the event is before the marker.
|
||||
* @returns Whether the "read up to" marker was advanced.
|
||||
*/
|
||||
private advanceReadUpToMarker(ev: MatrixEvent): boolean {
|
||||
const evId = ev.getId();
|
||||
if (evId === undefined) return false;
|
||||
const roomId = ev.getRoomId();
|
||||
if (roomId === undefined) return false;
|
||||
const room = this.client.getRoom(roomId);
|
||||
if (room === null) return false;
|
||||
|
||||
const upToEventId = this.readUpToMap[ev.getRoomId()!];
|
||||
if (!upToEventId) {
|
||||
// There's no marker yet; start it at this event
|
||||
this.readUpToMap[roomId] = evId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Small optimization for exact match (skip the search)
|
||||
if (upToEventId === evId) return false;
|
||||
|
||||
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
|
||||
// to avoid overusing the CPU.
|
||||
const timeline = room.getLiveTimeline();
|
||||
const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
|
||||
function isRelevantTimelineEvent(timelineEvent: MatrixEvent): boolean {
|
||||
return timelineEvent.getId() === upToEventId || timelineEvent.getId() === ev.getId();
|
||||
}
|
||||
const possibleMarkerEv = events.find(isRelevantTimelineEvent);
|
||||
if (possibleMarkerEv?.getId() === upToEventId) {
|
||||
// The event must be somewhere before the "read up to" marker
|
||||
return false;
|
||||
}
|
||||
if (possibleMarkerEv?.getId() === ev.getId()) {
|
||||
// The event is after the marker; advance it
|
||||
this.readUpToMap[roomId] = evId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// We can't say for sure whether the widget has seen the event; let's
|
||||
// just assume that it has
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the event comes from a room that we've been invited to
|
||||
* (in which case we likely don't have the full timeline).
|
||||
*/
|
||||
private isFromInvite(ev: MatrixEvent): boolean {
|
||||
const room = this.client.getRoom(ev.getRoomId());
|
||||
return room?.getMyMembership() === KnownMembership.Invite;
|
||||
}
|
||||
|
||||
private feedEvent(ev: MatrixEvent): void {
|
||||
if (this.messaging === null) return;
|
||||
if (
|
||||
// If we had decided earlier to feed this event to the widget, but
|
||||
// it just wasn't ready, give it another try
|
||||
this.eventsToFeed.delete(ev) ||
|
||||
// Skip marker timeline check for events with relations to unknown parent because these
|
||||
// events are not added to the timeline here and will be ignored otherwise:
|
||||
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
|
||||
this.relatesToUnknown(ev) ||
|
||||
// Skip marker timeline check for rooms where membership is
|
||||
// 'invite', otherwise the membership event from the invitation room
|
||||
// will advance the marker and new state events will not be
|
||||
// forwarded to the widget.
|
||||
this.isFromInvite(ev) ||
|
||||
// Check whether this event would be before or after our "read up to" marker. If it's
|
||||
// before, or we can't decide, then we assume the widget will have already seen the event.
|
||||
// If the event is after, or we don't have a marker for the room,
|
||||
// then the marker will advance and we'll send it through.
|
||||
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
|
||||
// receiving ancient events from backfill and such.
|
||||
this.advanceReadUpToMarker(ev)
|
||||
) {
|
||||
// If the event is still being decrypted, remember that we want to
|
||||
// feed it to the widget (even if not strictly in the order given by
|
||||
// the timeline) and get back to it later
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
|
||||
this.eventsToFeed.add(ev);
|
||||
} else {
|
||||
const raw = ev.getEffectiveEvent();
|
||||
this.messaging.feedEvent(raw as IRoomEvent).catch((e) => {
|
||||
logger.error('Error sending event to widget: ', e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
157
src/app/components/element-call/utils.ts
Normal file
157
src/app/components/element-call/utils.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { logger } from 'matrix-js-sdk/lib/logger';
|
||||
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { EventDirection, MatrixCapabilities, WidgetEventCapability } from 'matrix-widget-api';
|
||||
import EventEmitter from 'events';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
function getCredentials() {
|
||||
const accessToken = localStorage.getItem('mx_access_token');
|
||||
const userId = localStorage.getItem('mx_user_id');
|
||||
const deviceId = localStorage.getItem('mx_device_id');
|
||||
return [accessToken, userId, deviceId];
|
||||
}
|
||||
|
||||
export async function downloadFromUrlToFile(url: string, filename?: string): Promise<File> {
|
||||
const [accessToken] = getCredentials();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
body: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const file = new File([await response.blob()], filename || 'file', {
|
||||
type: response.type,
|
||||
});
|
||||
return file;
|
||||
} catch (error) {
|
||||
logger.error('Error downloading file', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export type Diff<T> = { added: T[]; removed: T[] };
|
||||
|
||||
/**
|
||||
* Performs a diff on two arrays. The result is what is different with the
|
||||
* first array (`added` in the returned object means objects in B that aren't
|
||||
* in A). Shallow comparisons are used to perform the diff.
|
||||
* @param a The first array. Must be defined.
|
||||
* @param b The second array. Must be defined.
|
||||
* @returns The diff between the arrays.
|
||||
*/
|
||||
export function arrayDiff<T>(a: T[], b: T[]): Diff<T> {
|
||||
return {
|
||||
added: b.filter((i) => !a.includes(i)),
|
||||
removed: a.filter((i) => !b.includes(i)),
|
||||
};
|
||||
}
|
||||
|
||||
export default function iterableDiff<T>(
|
||||
a: Iterable<T>,
|
||||
b: Iterable<T>
|
||||
): { added: Iterable<T>; removed: Iterable<T> } {
|
||||
return arrayDiff(Array.from(a), Array.from(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones an array as fast as possible, retaining references of the array's values.
|
||||
* @param a The array to clone. Must be defined.
|
||||
* @returns A copy of the array.
|
||||
*/
|
||||
export function arrayFastClone<T>(a: T[]): T[] {
|
||||
return a.slice(0, a.length);
|
||||
}
|
||||
|
||||
export function elementCallCapabilities(
|
||||
inRoomId: string,
|
||||
clientUserId: string,
|
||||
clientDeviceId?: string
|
||||
): Set<string> {
|
||||
const allowedCapabilities = new Set<string>();
|
||||
|
||||
// This is a trusted Element Call widget that we control
|
||||
const addCapability = (type: string, state: boolean, dir: EventDirection, stateKey?: string) =>
|
||||
allowedCapabilities.add(
|
||||
state
|
||||
? WidgetEventCapability.forStateEvent(dir, type, stateKey).raw
|
||||
: WidgetEventCapability.forRoomEvent(dir, type).raw
|
||||
);
|
||||
const addToDeviceCapability = (eventType: string, dir: EventDirection) =>
|
||||
allowedCapabilities.add(WidgetEventCapability.forToDeviceEvent(dir, eventType).raw);
|
||||
const recvState = (eventType: string, stateKey?: string) =>
|
||||
addCapability(eventType, true, EventDirection.Receive, stateKey);
|
||||
const sendState = (eventType: string, stateKey?: string) =>
|
||||
addCapability(eventType, true, EventDirection.Send, stateKey);
|
||||
const sendRecvToDevice = (eventType: string) => {
|
||||
addToDeviceCapability(eventType, EventDirection.Receive);
|
||||
addToDeviceCapability(eventType, EventDirection.Send);
|
||||
};
|
||||
const recvRoom = (eventType: string) => addCapability(eventType, false, EventDirection.Receive);
|
||||
const sendRoom = (eventType: string) => addCapability(eventType, false, EventDirection.Send);
|
||||
const sendRecvRoom = (eventType: string) => {
|
||||
recvRoom(eventType);
|
||||
sendRoom(eventType);
|
||||
};
|
||||
|
||||
allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||
allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
|
||||
allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
|
||||
allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
|
||||
allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||
recvState(EventType.RoomMember);
|
||||
recvState('org.matrix.msc3401.call');
|
||||
recvState(EventType.RoomEncryption);
|
||||
recvState(EventType.RoomName);
|
||||
// For the legacy membership type
|
||||
sendState('org.matrix.msc3401.call.member', clientUserId);
|
||||
// For the session membership type compliant with MSC4143
|
||||
sendState('org.matrix.msc3401.call.member', `_${clientUserId}_${clientDeviceId}_m.call`);
|
||||
sendState('org.matrix.msc3401.call.member', `${clientUserId}_${clientDeviceId}_m.call`);
|
||||
sendState('org.matrix.msc3401.call.member', `_${clientUserId}_${clientDeviceId}`);
|
||||
sendState('org.matrix.msc3401.call.member', `${clientUserId}_${clientDeviceId}`);
|
||||
recvState('org.matrix.msc3401.call.member');
|
||||
// for determining auth rules specific to the room version
|
||||
recvState(EventType.RoomCreate);
|
||||
|
||||
sendRoom('org.matrix.msc4075.rtc.notification');
|
||||
sendRecvRoom('io.element.call.encryption_keys');
|
||||
sendRecvRoom('org.matrix.rageshake_request');
|
||||
sendRecvRoom(EventType.Reaction);
|
||||
sendRecvRoom(EventType.RoomRedaction);
|
||||
sendRecvRoom('io.element.call.reaction');
|
||||
sendRecvRoom('org.matrix.msc4310.rtc.decline');
|
||||
|
||||
sendRecvToDevice(EventType.CallInvite);
|
||||
sendRecvToDevice(EventType.CallCandidates);
|
||||
sendRecvToDevice(EventType.CallAnswer);
|
||||
sendRecvToDevice(EventType.CallHangup);
|
||||
sendRecvToDevice(EventType.CallReject);
|
||||
sendRecvToDevice(EventType.CallSelectAnswer);
|
||||
sendRecvToDevice(EventType.CallNegotiate);
|
||||
sendRecvToDevice(EventType.CallSDPStreamMetadataChanged);
|
||||
sendRecvToDevice(EventType.CallSDPStreamMetadataChangedPrefix);
|
||||
sendRecvToDevice(EventType.CallReplaces);
|
||||
sendRecvToDevice(EventType.CallEncryptionKeysPrefix);
|
||||
|
||||
return allowedCapabilities;
|
||||
}
|
||||
|
||||
// Shortcut for registering a listener on an EventTarget
|
||||
// Copied from element-web
|
||||
export function useEventEmitter<T>(
|
||||
emitter: EventEmitter | null | undefined,
|
||||
eventType: string,
|
||||
listener: (event: T) => void
|
||||
): void {
|
||||
useEffect((): (() => void) => {
|
||||
if (emitter) {
|
||||
emitter.on(eventType, listener);
|
||||
return (): void => {
|
||||
emitter.off(eventType, listener);
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
}, [emitter, eventType, listener]);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk';
|
|||
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
|
||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
||||
import * as css from './RoomAvatar.css';
|
||||
import { getRoomIconSrc } from '../../utils/room';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
type RoomAvatarProps = {
|
||||
|
|
@ -44,10 +44,13 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||
export const RoomIcon = forwardRef<
|
||||
SVGSVGElement,
|
||||
Omit<ComponentProps<typeof Icon>, 'src'> & {
|
||||
joinRule?: JoinRule;
|
||||
roomType?: string;
|
||||
locked?: boolean;
|
||||
joinRule: JoinRule;
|
||||
space?: boolean;
|
||||
}
|
||||
>(({ joinRule, roomType, locked, ...props }, ref) => (
|
||||
<Icon src={getRoomIconSrc(Icons, roomType, joinRule, locked)} {...props} ref={ref} />
|
||||
>(({ joinRule, space, ...props }, ref) => (
|
||||
<Icon
|
||||
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
|
||||
export const CallViewUserGrid = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginInline: '20px',
|
||||
gap: config.space.S400,
|
||||
});
|
||||
|
||||
export const CallViewUser = style([
|
||||
DefaultReset,
|
||||
ContainerColor({ variant: 'SurfaceVariant' }),
|
||||
{
|
||||
height: '90px',
|
||||
width: '150px',
|
||||
borderRadius: config.radii.R500,
|
||||
},
|
||||
]);
|
||||
|
||||
export const UserLink = style({
|
||||
color: 'inherit',
|
||||
minWidth: 0,
|
||||
cursor: 'pointer',
|
||||
flexGrow: 0,
|
||||
transition: 'all ease-out 200ms',
|
||||
':hover': {
|
||||
transform: 'translateY(-3px)',
|
||||
textDecoration: 'unset',
|
||||
},
|
||||
':focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import React, {
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
MouseEventHandler,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { Box, Button, config, Spinner, Text } from 'folds';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { useCallMembers } from '../../hooks/useCallMemberships';
|
||||
|
||||
import { CallRefContext } from '../../pages/client/call/PersistentCallContainer';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { CallViewUser } from './CallViewUser';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import * as css from './CallView.css';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
|
||||
type OriginalStyles = {
|
||||
position?: string;
|
||||
top?: string;
|
||||
left?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
zIndex?: string;
|
||||
display?: string;
|
||||
visibility?: string;
|
||||
pointerEvents?: string;
|
||||
border?: string;
|
||||
};
|
||||
|
||||
export function CallViewUserGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
className={css.CallViewUserGrid}
|
||||
style={{
|
||||
maxWidth: React.Children.count(children) === 4 ? '336px' : '503px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallView({ room }: { room: Room }) {
|
||||
const callIframeRef = useContext(CallRefContext);
|
||||
const iframeHostRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const originalIframeStylesRef = useRef<OriginalStyles | null>(null);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [visibleCallNames, setVisibleCallNames] = useState('');
|
||||
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canJoin = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId());
|
||||
|
||||
const {
|
||||
isActiveCallReady,
|
||||
activeCallRoomId,
|
||||
isChatOpen,
|
||||
setActiveCallRoomId,
|
||||
hangUp,
|
||||
setViewedCallRoomId,
|
||||
} = useCallState();
|
||||
|
||||
const isActiveCallRoom = activeCallRoomId === room.roomId;
|
||||
const callIsCurrentAndReady = isActiveCallRoom && isActiveCallReady;
|
||||
const callMembers = useCallMembers(mx, room.roomId);
|
||||
|
||||
const getName = (userId: string) =>
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
|
||||
|
||||
const memberDisplayNames = callMembers.map((callMembership) =>
|
||||
getName(callMembership.sender ?? '')
|
||||
);
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
|
||||
const activeIframeDisplayRef = callIframeRef;
|
||||
|
||||
const applyFixedPositioningToIframe = useCallback(() => {
|
||||
const iframeElement = activeIframeDisplayRef?.current;
|
||||
const hostElement = iframeHostRef?.current;
|
||||
|
||||
if (iframeElement && hostElement) {
|
||||
if (!originalIframeStylesRef.current) {
|
||||
const computed = window.getComputedStyle(iframeElement);
|
||||
originalIframeStylesRef.current = {
|
||||
position: iframeElement.style.position || computed.position,
|
||||
top: iframeElement.style.top || computed.top,
|
||||
left: iframeElement.style.left || computed.left,
|
||||
width: iframeElement.style.width || computed.width,
|
||||
height: iframeElement.style.height || computed.height,
|
||||
zIndex: iframeElement.style.zIndex || computed.zIndex,
|
||||
display: iframeElement.style.display || computed.display,
|
||||
visibility: iframeElement.style.visibility || computed.visibility,
|
||||
pointerEvents: iframeElement.style.pointerEvents || computed.pointerEvents,
|
||||
border: iframeElement.style.border || computed.border,
|
||||
};
|
||||
}
|
||||
|
||||
const hostRect = hostElement.getBoundingClientRect();
|
||||
|
||||
iframeElement.style.position = 'fixed';
|
||||
iframeElement.style.top = `${hostRect.top}px`;
|
||||
iframeElement.style.left = `${hostRect.left}px`;
|
||||
iframeElement.style.width = `${hostRect.width}px`;
|
||||
iframeElement.style.height = `${hostRect.height}px`;
|
||||
iframeElement.style.border = 'none';
|
||||
iframeElement.style.zIndex = '1000';
|
||||
iframeElement.style.display = room.isCallRoom() ? 'block' : 'none';
|
||||
iframeElement.style.visibility = 'visible';
|
||||
iframeElement.style.pointerEvents = 'auto';
|
||||
}
|
||||
}, [activeIframeDisplayRef, room]);
|
||||
|
||||
const debouncedApplyFixedPositioning = useDebounce(applyFixedPositioningToIframe, {
|
||||
wait: 50,
|
||||
immediate: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
const iframeElement = activeIframeDisplayRef?.current;
|
||||
const hostElement = iframeHostRef?.current;
|
||||
|
||||
if (room.isCallRoom() || (callIsCurrentAndReady && iframeElement && hostElement)) {
|
||||
applyFixedPositioningToIframe();
|
||||
|
||||
const resizeObserver = new ResizeObserver(debouncedApplyFixedPositioning);
|
||||
if (hostElement) resizeObserver.observe(hostElement);
|
||||
window.addEventListener('scroll', debouncedApplyFixedPositioning, true);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('scroll', debouncedApplyFixedPositioning, true);
|
||||
|
||||
if (iframeElement && originalIframeStylesRef.current) {
|
||||
const originalStyles = originalIframeStylesRef.current;
|
||||
(Object.keys(originalStyles) as Array<keyof OriginalStyles>).forEach((key) => {
|
||||
if (key in iframeElement.style) {
|
||||
iframeElement.style[key as any] = originalStyles[key] || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
originalIframeStylesRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
activeIframeDisplayRef,
|
||||
applyFixedPositioningToIframe,
|
||||
debouncedApplyFixedPositioning,
|
||||
callIsCurrentAndReady,
|
||||
room,
|
||||
]);
|
||||
|
||||
const handleJoinVCClick: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
if (!canJoin) return;
|
||||
|
||||
if (isMobile) {
|
||||
evt.stopPropagation();
|
||||
setViewedCallRoomId(room.roomId);
|
||||
navigateRoom(room.roomId);
|
||||
}
|
||||
if (!callIsCurrentAndReady) {
|
||||
hangUp();
|
||||
setActiveCallRoomId(room.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
const isCallViewVisible = room.isCallRoom() && (screenSize === ScreenSize.Desktop || !isChatOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (memberDisplayNames.length <= 2) {
|
||||
setVisibleCallNames(memberDisplayNames.join(' and '));
|
||||
} else {
|
||||
const visible = memberDisplayNames.slice(0, 2);
|
||||
const remaining = memberDisplayNames.length - 2;
|
||||
|
||||
setVisibleCallNames(
|
||||
`${visible.join(', ')}, and ${remaining} other${remaining > 1 ? 's' : ''}`
|
||||
);
|
||||
}
|
||||
}, [memberDisplayNames]);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" style={{ display: isCallViewVisible ? 'flex' : 'none' }}>
|
||||
<div
|
||||
ref={iframeHostRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
display: callIsCurrentAndReady ? 'flex' : 'none',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
grow="Yes"
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
style={{
|
||||
display: callIsCurrentAndReady ? 'none' : 'flex',
|
||||
}}
|
||||
>
|
||||
<CallViewUserGrid>
|
||||
{callMembers.slice(0, 6).map((callMember) => (
|
||||
<CallViewUser key={callMember.membershipID} room={room} callMembership={callMember} />
|
||||
))}
|
||||
</CallViewUserGrid>
|
||||
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
paddingBlock: config.space.S200,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="H1"
|
||||
style={{
|
||||
paddingBottom: config.space.S300,
|
||||
}}
|
||||
>
|
||||
{roomName}
|
||||
</Text>
|
||||
<Text size="T200">
|
||||
{visibleCallNames !== '' ? visibleCallNames : 'No one'}{' '}
|
||||
{memberDisplayNames.length > 1 ? 'are' : 'is'} currently in voice
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
disabled={!canJoin || isActiveCallRoom}
|
||||
onClick={handleJoinVCClick}
|
||||
>
|
||||
{isActiveCallRoom ? (
|
||||
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Spinner />
|
||||
<Text size="B500">{activeCallRoomId === room.roomId ? `Joining` : 'Join Voice'}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="B500">{canJoin ? 'Join Voice' : 'Channel Locked'}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import * as css from './CallView.css';
|
||||
|
||||
type CallViewUserProps = {
|
||||
room: Room;
|
||||
callMembership: CallMembership;
|
||||
};
|
||||
|
||||
export const UserProfileButton = as<'button'>(
|
||||
({ as: AsUserProfileButton = 'button', className, ...props }, ref) => (
|
||||
<AsUserProfileButton className={classNames(css.UserLink, className)} {...props} ref={ref} />
|
||||
)
|
||||
);
|
||||
|
||||
export const CallViewUserBase = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="300"
|
||||
className={classNames(css.CallViewUser, className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
export function CallViewUser({ room, callMembership }: CallViewUserProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const userId = callMembership.sender ?? '';
|
||||
const avatarMxcUrl = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
|
||||
: undefined;
|
||||
const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
|
||||
|
||||
const handleUserClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<UserProfileButton onClick={handleUserClick} aria-label={getName}>
|
||||
<CallViewUserBase>
|
||||
<Box direction="Column" grow="Yes" alignItems="Center" gap="200" justifyContent="Center">
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="B400" priority="300" truncate>
|
||||
{getName}
|
||||
</Text>
|
||||
</Box>
|
||||
</CallViewUserBase>
|
||||
</UserProfileButton>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Widget } from 'matrix-widget-api';
|
||||
import { IApp } from './SmallWidget';
|
||||
|
||||
// Wrapper class for the widget definition
|
||||
export class CinnyWidget extends Widget {
|
||||
public constructor(private rawDefinition: IApp) {
|
||||
super(rawDefinition);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
* Copyright 2020-2023 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EventEmitter from 'events';
|
||||
import {
|
||||
ClientEvent,
|
||||
Direction,
|
||||
IEvent,
|
||||
KnownMembership,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import {
|
||||
ClientWidgetApi,
|
||||
IRoomEvent,
|
||||
IStickyActionRequest,
|
||||
IWidget,
|
||||
IWidgetData,
|
||||
MatrixCapabilities,
|
||||
WidgetApiFromWidgetAction,
|
||||
WidgetKind,
|
||||
} from 'matrix-widget-api';
|
||||
import { CinnyWidget } from './CinnyWidget';
|
||||
import { SmallWidgetDriver } from './SmallWidgetDriver';
|
||||
|
||||
/**
|
||||
* Generates the URL for the Element Call widget.
|
||||
* @param mx - The MatrixClient instance.
|
||||
* @param roomId - The ID of the room.
|
||||
* @returns The generated URL object.
|
||||
*/
|
||||
export const getWidgetUrl = (
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
elementCallUrl: string,
|
||||
widgetId: string,
|
||||
setParams: any
|
||||
): URL => {
|
||||
const baseUrl = window.location.origin;
|
||||
const url = elementCallUrl
|
||||
? new URL(`${elementCallUrl}/room`)
|
||||
: new URL('/public/element-call/index.html#', baseUrl);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
embed: 'true',
|
||||
widgetId,
|
||||
appPrompt: 'false',
|
||||
skipLobby: setParams.skipLobby ?? 'true', // TODO: skipLobby is deprecated, use intent instead (intent doesn't produce the same effect?)
|
||||
returnToLobby: setParams.returnToLobby ?? 'true',
|
||||
perParticipantE2EE: setParams.perParticipantE2EE ?? 'true',
|
||||
header: 'none',
|
||||
confineToRoom: 'true',
|
||||
theme: setParams.theme ?? 'dark',
|
||||
userId: mx.getUserId()!,
|
||||
deviceId: mx.getDeviceId()!,
|
||||
roomId,
|
||||
baseUrl: mx.baseUrl!,
|
||||
parentUrl: window.location.origin,
|
||||
});
|
||||
|
||||
const replacedParams = params.toString().replace(/%24/g, '$');
|
||||
url.search = `?${replacedParams}`;
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export interface IApp extends IWidget {
|
||||
client: MatrixClient;
|
||||
roomId: string;
|
||||
eventId?: string;
|
||||
avatar_url?: string;
|
||||
sender: string;
|
||||
'io.element.managed_hybrid'?: boolean;
|
||||
}
|
||||
|
||||
export class SmallWidget extends EventEmitter {
|
||||
private client: MatrixClient;
|
||||
|
||||
private messaging: ClientWidgetApi | null = null;
|
||||
|
||||
private mockWidget: CinnyWidget;
|
||||
|
||||
public roomId?: string;
|
||||
|
||||
public url?: string;
|
||||
|
||||
public iframe: HTMLIFrameElement | null = null;
|
||||
|
||||
private type: string; // Type of the widget (e.g., 'm.call')
|
||||
|
||||
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
||||
|
||||
private readonly eventsToFeed = new WeakSet<MatrixEvent>();
|
||||
|
||||
private stickyPromise?: () => Promise<void>;
|
||||
|
||||
constructor(private iapp: IApp) {
|
||||
super();
|
||||
this.client = iapp.client;
|
||||
this.roomId = iapp.roomId;
|
||||
this.url = iapp.url;
|
||||
this.type = iapp.type;
|
||||
this.mockWidget = new CinnyWidget(iapp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the widget messaging API.
|
||||
* @param iframe - The HTMLIFrameElement to bind to.
|
||||
* @returns The initialized ClientWidgetApi instance.
|
||||
*/
|
||||
startMessaging(iframe: HTMLIFrameElement): ClientWidgetApi {
|
||||
// Ensure the driver is correctly instantiated
|
||||
// The capabilities array might need adjustment based on required permissions
|
||||
const driver = new SmallWidgetDriver(
|
||||
this.client,
|
||||
[],
|
||||
this.mockWidget,
|
||||
WidgetKind.Room,
|
||||
true,
|
||||
this.roomId
|
||||
);
|
||||
this.iframe = iframe;
|
||||
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
|
||||
|
||||
// Emit events during the widget lifecycle
|
||||
this.messaging.on('preparing', () => this.emit('preparing'));
|
||||
this.messaging.on('error:preparing', (err: unknown) => this.emit('error:preparing', err));
|
||||
this.messaging.once('ready', () => this.emit('ready'));
|
||||
// this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); // Uncomment if needed
|
||||
|
||||
// Populate the map of "read up to" events for this widget with the current event in every room.
|
||||
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
|
||||
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const room of this.client.getRooms()) {
|
||||
// Timelines are most recent last
|
||||
const events = room.getLiveTimeline()?.getEvents() || [];
|
||||
const roomEvent = events[events.length - 1];
|
||||
// force later code to think the room is fresh
|
||||
if (roomEvent) {
|
||||
const eventId = roomEvent.getId();
|
||||
if (eventId) this.readUpToMap[room.roomId] = eventId;
|
||||
}
|
||||
}
|
||||
|
||||
this.messaging.on('action:org.matrix.msc2876.read_events', (ev: CustomEvent) => {
|
||||
const room = this.client.getRoom(this.roomId);
|
||||
const events: Partial<IEvent>[] = [];
|
||||
const { type } = ev.detail.data;
|
||||
|
||||
ev.preventDefault();
|
||||
if (room === null) {
|
||||
return this.messaging?.transport.reply(ev.detail, { events });
|
||||
}
|
||||
const state = room.getLiveTimeline().getState(Direction.Forward);
|
||||
if (state === undefined) {
|
||||
return this.messaging?.transport.reply(ev.detail, { events });
|
||||
}
|
||||
|
||||
const stateEvents = state.events?.get(type);
|
||||
|
||||
Array.from(stateEvents?.values() ?? []).forEach((eventObject) => {
|
||||
events.push(eventObject.event);
|
||||
});
|
||||
|
||||
return this.messaging?.transport.reply(ev.detail, { events });
|
||||
});
|
||||
|
||||
this.client.on(ClientEvent.Event, this.onEvent);
|
||||
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
this.messaging.on(
|
||||
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
|
||||
async (ev: CustomEvent<IStickyActionRequest>) => {
|
||||
if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
||||
ev.preventDefault();
|
||||
if (ev.detail.data.value) {
|
||||
// If the widget wants to become sticky we wait for the stickyPromise to resolve
|
||||
if (this.stickyPromise) await this.stickyPromise();
|
||||
this.messaging.transport.reply(ev.detail, {});
|
||||
}
|
||||
// Stop being persistent can be done instantly
|
||||
// MAKE PERSISTENT HERE
|
||||
// Send the ack after the widget actually has become sticky.
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return this.messaging;
|
||||
}
|
||||
|
||||
private onEvent = (ev: MatrixEvent): void => {
|
||||
this.client.decryptEventIfNeeded(ev);
|
||||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onEventDecrypted = (ev: MatrixEvent): void => {
|
||||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onReadEvent = (ev: MatrixEvent): void => {
|
||||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
|
||||
await this.client.decryptEventIfNeeded(ev);
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the event comes from a room that we've been invited to
|
||||
* (in which case we likely don't have the full timeline).
|
||||
*/
|
||||
private isFromInvite(ev: MatrixEvent): boolean {
|
||||
const room = this.client.getRoom(ev.getRoomId());
|
||||
return room?.getMyMembership() === KnownMembership.Invite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the event has a relation to an unknown parent.
|
||||
*/
|
||||
private relatesToUnknown(ev: MatrixEvent): boolean {
|
||||
// Replies to unknown events don't count
|
||||
if (!ev.relationEventId || ev.replyEventId) return false;
|
||||
const room = this.client.getRoom(ev.getRoomId());
|
||||
return room === null || !room.findEventById(ev.relationEventId);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
private arrayFastClone<T>(a: T[]): T[] {
|
||||
return a.slice(0, a.length);
|
||||
}
|
||||
|
||||
private advanceReadUpToMarker(ev: MatrixEvent): boolean {
|
||||
const evId = ev.getId();
|
||||
if (evId === undefined) return false;
|
||||
const roomId = ev.getRoomId();
|
||||
if (roomId === undefined) return false;
|
||||
const room = this.client.getRoom(roomId);
|
||||
if (room === null) return false;
|
||||
|
||||
const upToEventId = this.readUpToMap[ev.getRoomId()!];
|
||||
if (!upToEventId) {
|
||||
// There's no marker yet; start it at this event
|
||||
this.readUpToMap[roomId] = evId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Small optimization for exact match (skip the search)
|
||||
if (upToEventId === evId) return false;
|
||||
|
||||
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
|
||||
// to avoid overusing the CPU.
|
||||
const timeline = room.getLiveTimeline();
|
||||
const events = this.arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
|
||||
|
||||
let advanced = false;
|
||||
|
||||
events.some((timelineEvent) => {
|
||||
const id = timelineEvent.getId();
|
||||
|
||||
if (id === upToEventId) {
|
||||
// The event must be somewhere before the "read up to" marker
|
||||
return true;
|
||||
}
|
||||
|
||||
if (id === evId) {
|
||||
// The event is after the marker; advance it
|
||||
this.readUpToMap[roomId] = evId;
|
||||
advanced = true;
|
||||
return true;
|
||||
}
|
||||
// We can't say for sure whether the widget has seen the event; let's
|
||||
// just assume that it has
|
||||
return false;
|
||||
});
|
||||
|
||||
return advanced;
|
||||
}
|
||||
|
||||
private feedEvent(ev: MatrixEvent): void {
|
||||
if (this.messaging === null) return;
|
||||
|
||||
if (
|
||||
// If we had decided earlier to feed this event to the widget, but
|
||||
// it just wasn't ready, give it another try
|
||||
this.eventsToFeed.delete(ev) ||
|
||||
// Skip marker timeline check for events with relations to unknown parent because these
|
||||
// events are not added to the timeline here and will be ignored otherwise:
|
||||
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
|
||||
this.relatesToUnknown(ev) ||
|
||||
// Skip marker timeline check for rooms where membership is
|
||||
// 'invite', otherwise the membership event from the invitation room
|
||||
// will advance the marker and new state events will not be
|
||||
// forwarded to the widget.
|
||||
this.isFromInvite(ev) ||
|
||||
// Check whether this event would be before or after our "read up to" marker. If it's
|
||||
// before, or we can't decide, then we assume the widget will have already seen the event.
|
||||
// If the event is after, or we don't have a marker for the room, then the marker will advance and we'll
|
||||
// send it through.
|
||||
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
|
||||
// receiving ancient events from backfill and such.
|
||||
this.advanceReadUpToMarker(ev)
|
||||
) {
|
||||
// If the event is still being decrypted, remember that we want to
|
||||
// feed it to the widget (even if not strictly in the order given by
|
||||
// the timeline) and get back to it later
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
|
||||
this.eventsToFeed.add(ev);
|
||||
} else {
|
||||
const raw = ev.getEffectiveEvent();
|
||||
this.messaging.feedEvent(raw as IRoomEvent, this.roomId ?? '').catch(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the widget messaging and cleans up resources.
|
||||
*/
|
||||
stopMessaging() {
|
||||
if (this.messaging) {
|
||||
this.messaging.stop(); // Example if a stop method exists
|
||||
this.messaging.removeAllListeners(); // Remove listeners attached by SmallWidget
|
||||
this.messaging = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the data object for the widget.
|
||||
* @param client - The MatrixClient instance.
|
||||
* @param roomId - The ID of the room.
|
||||
* @param currentData - Existing widget data.
|
||||
* @param overwriteData - Data to merge or overwrite.
|
||||
* @returns The final widget data object.
|
||||
*/
|
||||
export const getWidgetData = (
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
currentData: object,
|
||||
overwriteData: object
|
||||
): IWidgetData => {
|
||||
// Example: Determine E2EE based on room state if needed
|
||||
const perParticipantE2EE = true; // Default or based on logic
|
||||
// const roomEncryption = client.getRoom(roomId)?.currentState.getStateEvents(EventType.RoomEncryption, "");
|
||||
// if (roomEncryption) perParticipantE2EE = true; // Simplified example
|
||||
|
||||
return {
|
||||
...currentData,
|
||||
...overwriteData,
|
||||
perParticipantE2EE,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a virtual widget definition (IApp).
|
||||
* @param client - MatrixClient instance.
|
||||
* @param id - Widget ID.
|
||||
* @param creatorUserId - User ID of the creator.
|
||||
* @param name - Widget display name.
|
||||
* @param type - Widget type (e.g., 'm.call').
|
||||
* @param url - Widget URL.
|
||||
* @param waitForIframeLoad - Whether to wait for iframe load signal.
|
||||
* @param data - Widget data.
|
||||
* @param roomId - Room ID.
|
||||
* @returns The IApp widget definition.
|
||||
*/
|
||||
export const createVirtualWidget = (
|
||||
client: MatrixClient,
|
||||
id: string,
|
||||
creatorUserId: string,
|
||||
name: string,
|
||||
type: string,
|
||||
url: URL,
|
||||
waitForIframeLoad: boolean,
|
||||
data: IWidgetData,
|
||||
roomId: string
|
||||
): IApp => ({
|
||||
client,
|
||||
id,
|
||||
creatorUserId,
|
||||
name,
|
||||
type,
|
||||
url: url.toString(), // Store URL as string in the definition
|
||||
waitForIframeLoad,
|
||||
data,
|
||||
roomId,
|
||||
// Add other required fields from IWidget if necessary
|
||||
sender: creatorUserId, // Example: Assuming sender is the creator
|
||||
});
|
||||
|
|
@ -199,7 +199,7 @@ export function RoomProfileEdit({
|
|||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
space={room.isSpaceRoom()}
|
||||
size="400"
|
||||
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
||||
filled
|
||||
|
|
@ -342,7 +342,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
|||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
space={room.isSpaceRoom()}
|
||||
size="400"
|
||||
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
||||
filled
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ import {
|
|||
RoomVersionSelector,
|
||||
useAdditionalCreators,
|
||||
} from '../../components/create-room';
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||
|
||||
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
|
||||
if (kind === CreateRoomKind.Private) return Icons.HashLock;
|
||||
|
|
@ -74,7 +72,6 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
useAdditionalCreators();
|
||||
const [federation, setFederation] = useState(true);
|
||||
const [encryption, setEncryption] = useState(false);
|
||||
const [callRoom, setCallRoom] = useState(false);
|
||||
const [knock, setKnock] = useState(false);
|
||||
const [advance, setAdvance] = useState(false);
|
||||
|
||||
|
|
@ -119,18 +116,8 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
roomKnock = knock;
|
||||
}
|
||||
|
||||
let roomType;
|
||||
const powerOverrides: IPowerLevels = {
|
||||
events: {},
|
||||
};
|
||||
if (callRoom) {
|
||||
roomType = RoomType.Call;
|
||||
powerOverrides.events![StateEvent.GroupCallMemberPrefix] = 0;
|
||||
}
|
||||
|
||||
create({
|
||||
version: selectedRoomVersion,
|
||||
type: roomType,
|
||||
parent: space,
|
||||
kind,
|
||||
name: roomName,
|
||||
|
|
@ -140,7 +127,6 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
knock: roomKnock,
|
||||
allowFederation: federation,
|
||||
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
||||
powerLevelContentOverrides: powerOverrides,
|
||||
}).then((roomId) => {
|
||||
if (alive()) {
|
||||
onCreate?.(roomId);
|
||||
|
|
@ -184,20 +170,6 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
disabled={disabled}
|
||||
/>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="500"
|
||||
>
|
||||
<SettingTile
|
||||
title="Call Room"
|
||||
description="Enable this to create a room optimized for voice calls."
|
||||
after={
|
||||
<Switch variant="Primary" value={callRoom} onChange={setCallRoom} disabled={disabled} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
<Box shrink="No">
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton fill="None" onClick={onBack}>
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -218,11 +218,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={() => setPeopleDrawer((drawer) => !drawer)}
|
||||
>
|
||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -239,12 +235,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,6 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
|
|||
|
||||
type RoomProfileProps = {
|
||||
roomId: string;
|
||||
roomType?: string;
|
||||
name: string;
|
||||
topic?: string;
|
||||
avatarUrl?: string;
|
||||
|
|
@ -186,7 +185,6 @@ type RoomProfileProps = {
|
|||
};
|
||||
function RoomProfile({
|
||||
roomId,
|
||||
roomType,
|
||||
name,
|
||||
topic,
|
||||
avatarUrl,
|
||||
|
|
@ -202,7 +200,9 @@ function RoomProfile({
|
|||
roomId={roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <RoomIcon size="300" joinRule={joinRule} roomType={roomType} />}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -338,7 +338,6 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
|||
{(localSummary) => (
|
||||
<RoomProfile
|
||||
roomId={roomId}
|
||||
roomType={localSummary.roomType}
|
||||
name={localSummary.name}
|
||||
topic={localSummary.topic}
|
||||
avatarUrl={
|
||||
|
|
@ -397,7 +396,6 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
|||
{summary && (
|
||||
<RoomProfile
|
||||
roomId={roomId}
|
||||
roomType={summary.room_type}
|
||||
name={summary.name || summary.canonical_alias || roomId}
|
||||
topic={summary.topic}
|
||||
avatarUrl={
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk';
|
|||
import FocusTrap from 'focus-trap-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getRoomIconSrc } from '../../utils/room';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
|
|
@ -274,7 +274,9 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())}
|
||||
src={
|
||||
joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
@ -390,7 +392,10 @@ export function SearchFilters({
|
|||
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
|
||||
radii="Pill"
|
||||
before={
|
||||
<Icon size="50" src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} />
|
||||
<Icon
|
||||
size="50"
|
||||
src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
|
||||
/>
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const Actions = style({
|
||||
padding: config.space.S200,
|
||||
});
|
||||
|
||||
export const RoomButtonWrap = style({
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const RoomButton = style({
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
padding: `0 ${config.space.S200}`,
|
||||
});
|
||||
|
||||
export const RoomName = style({
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
});
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
color,
|
||||
} from 'folds';
|
||||
import React from 'react';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import * as css from './RoomCallNavStatus.css';
|
||||
|
||||
export function CallNavStatus() {
|
||||
const {
|
||||
activeCallRoomId,
|
||||
isActiveCallReady,
|
||||
isAudioEnabled,
|
||||
isVideoEnabled,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
hangUp,
|
||||
} = useCallState();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const hasActiveCall = Boolean(activeCallRoomId);
|
||||
const isConnected = hasActiveCall && isActiveCallReady;
|
||||
const handleGoToCallRoom = () => {
|
||||
if (activeCallRoomId) {
|
||||
navigateRoom(activeCallRoomId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box className={css.Actions} direction="Row" alignItems="Center" gap="100">
|
||||
<Box className={css.RoomButtonWrap} grow="Yes">
|
||||
{hasActiveCall && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Go to Room</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Chip
|
||||
size="500"
|
||||
fill="Soft"
|
||||
as="button"
|
||||
onClick={handleGoToCallRoom}
|
||||
ref={triggerRef}
|
||||
className={css.RoomButton}
|
||||
>
|
||||
{isConnected ? (
|
||||
<Icon size="300" src={Icons.VolumeHigh} style={{ color: color.Success.Main }} />
|
||||
) : (
|
||||
<Spinner size="300" variant="Secondary" />
|
||||
)}
|
||||
<Text
|
||||
as="span"
|
||||
size="L400"
|
||||
style={{ color: isConnected ? color.Success.Main : color.Warning.Main }}
|
||||
>
|
||||
{isConnected ? 'Connected' : 'Connecting'}
|
||||
</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Box>
|
||||
{hasActiveCall && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Hang Up</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" size="300" ref={triggerRef} onClick={hangUp}>
|
||||
<Icon src={Icons.Phone} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{!isAudioEnabled ? 'Unmute' : 'Mute'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" size="300" ref={triggerRef} onClick={toggleAudio}>
|
||||
<Icon src={!isAudioEnabled ? Icons.MicMute : Icons.Mic} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{!isVideoEnabled ? 'Video On' : 'Video Off'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" size="300" ref={triggerRef} onClick={toggleVideo}>
|
||||
<Icon src={!isVideoEnabled ? Icons.VideoCameraMute : Icons.VideoCamera} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { MouseEventHandler, forwardRef, useState, MouseEvent } from 'react';
|
||||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
|
|
@ -16,13 +16,10 @@ import {
|
|||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NavButton, NavItem, NavItemContent, NavItemOptions } from '../../components/nav';
|
||||
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';
|
||||
|
|
@ -54,12 +51,6 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { useCallMembers } from '../../hooks/useCallMemberships';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { RoomNavUser } from './RoomNavUser';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -217,7 +208,6 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||
);
|
||||
}
|
||||
);
|
||||
RoomNavItemMenu.displayName = 'RoomNavItemMenu';
|
||||
|
||||
type RoomNavItemProps = {
|
||||
room: Room;
|
||||
|
|
@ -246,32 +236,6 @@ export function RoomNavItem({
|
|||
(receipt) => receipt.userId !== mx.getUserId()
|
||||
);
|
||||
|
||||
const {
|
||||
isActiveCallReady,
|
||||
activeCallRoomId,
|
||||
setActiveCallRoomId,
|
||||
setViewedCallRoomId,
|
||||
isChatOpen,
|
||||
toggleChat,
|
||||
hangUp,
|
||||
} = useCallState();
|
||||
|
||||
const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId;
|
||||
const callMemberships = useCallMembers(mx, room.roomId);
|
||||
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const roomName = useRoomName(room);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canJoinCall = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId());
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
setMenuAnchor({
|
||||
|
|
@ -286,207 +250,109 @@ export function RoomNavItem({
|
|||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleNavItemClick: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
if (room.isCallRoom()) {
|
||||
if (!isMobile) {
|
||||
if (!isActiveCall && canJoinCall) {
|
||||
hangUp();
|
||||
setActiveCallRoomId(room.roomId);
|
||||
} else {
|
||||
navigateRoom(room.roomId);
|
||||
}
|
||||
} else {
|
||||
evt.stopPropagation();
|
||||
if (isChatOpen) toggleChat();
|
||||
setViewedCallRoomId(room.roomId);
|
||||
navigateRoom(room.roomId);
|
||||
}
|
||||
} else {
|
||||
navigateRoom(room.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatButtonClick = (evt: MouseEvent<HTMLButtonElement>) => {
|
||||
evt.stopPropagation();
|
||||
if (!isChatOpen) toggleChat();
|
||||
setViewedCallRoomId(room.roomId);
|
||||
navigate(linkPath);
|
||||
};
|
||||
|
||||
const optionsVisible = hover || !!menuAnchor;
|
||||
const ariaLabel = [
|
||||
roomName,
|
||||
room.isCallRoom()
|
||||
? [
|
||||
'Call Room',
|
||||
isActiveCall && 'Currently in Call',
|
||||
callMemberships.length && `${callMemberships.length} in Call`,
|
||||
]
|
||||
: 'Text Room',
|
||||
unread?.total && `${unread.total} Messages`,
|
||||
]
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<Box direction="Column" grow="Yes">
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={unread !== undefined}
|
||||
aria-selected={selected}
|
||||
data-hover={!!menuAnchor}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
>
|
||||
<NavButton onClick={handleNavItemClick} aria-label={ariaLabel}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
{showAvatar ? (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={
|
||||
direct
|
||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(roomName)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon
|
||||
style={{
|
||||
opacity: unread || isActiveCall ? config.opacity.P500 : config.opacity.P300,
|
||||
}}
|
||||
filled={selected || isActiveCall}
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
roomType={room.getType()}
|
||||
locked={room.isCallRoom() && !canJoinCall}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text
|
||||
priority={unread || isActiveCall ? '500' : '300'}
|
||||
as="span"
|
||||
size="Inherit"
|
||||
truncate
|
||||
>
|
||||
{roomName}
|
||||
</Text>
|
||||
</Box>
|
||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" disableAnimation />
|
||||
</Badge>
|
||||
)}
|
||||
{!optionsVisible && unread && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||
<Icon
|
||||
size="50"
|
||||
src={getRoomNotificationModeIcon(notificationMode)}
|
||||
aria-label={notificationMode}
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={unread !== undefined}
|
||||
aria-selected={selected}
|
||||
data-hover={!!menuAnchor}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
>
|
||||
<NavLink to={linkPath}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
{showAvatar ? (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={
|
||||
direct
|
||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(room.name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon
|
||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||
filled={selected}
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
{optionsVisible && (
|
||||
<NavItemOptions>
|
||||
<PopOut
|
||||
id={`menu-${room.roomId}`}
|
||||
aria-expanded={!!menuAnchor}
|
||||
anchor={menuAnchor}
|
||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
||||
position="Bottom"
|
||||
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomNavItemMenu
|
||||
room={room}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
notificationMode={notificationMode}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
{room.isCallRoom() && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Open Chat</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
data-testid="chat-button"
|
||||
onClick={handleChatButtonClick}
|
||||
aria-pressed={isChatOpen && selected}
|
||||
aria-label="Open Chat"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="50" src={Icons.Message} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
aria-controls={`menu-${room.roomId}`}
|
||||
aria-label="More Options"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" disableAnimation />
|
||||
</Badge>
|
||||
)}
|
||||
{!optionsVisible && unread && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||
<Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} />
|
||||
)}
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
{optionsVisible && (
|
||||
<NavItemOptions>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
||||
position="Bottom"
|
||||
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Icon size="50" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
</NavItemOptions>
|
||||
)}
|
||||
</NavItem>
|
||||
{room.isCallRoom() && (
|
||||
<Box direction="Column" style={{ paddingLeft: config.space.S200 }}>
|
||||
{callMemberships.map((callMembership) => (
|
||||
<RoomNavUser
|
||||
key={callMembership.membershipID}
|
||||
room={room}
|
||||
callMembership={callMembership}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<RoomNavItemMenu
|
||||
room={room}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
notificationMode={notificationMode}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="50" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
</NavItemOptions>
|
||||
)}
|
||||
</Box>
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import React from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { NavButton, NavItem, NavItemContent } from '../../components/nav';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
|
||||
type RoomNavUserProps = {
|
||||
room: Room;
|
||||
callMembership: CallMembership;
|
||||
};
|
||||
export function RoomNavUser({ room, callMembership }: RoomNavUserProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const { isActiveCallReady, activeCallRoomId } = useCallState();
|
||||
const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId;
|
||||
const userId = callMembership.sender ?? '';
|
||||
const avatarMxcUrl = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
|
||||
: undefined;
|
||||
const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
|
||||
const isCallParticipant = isActiveCall && userId !== mx.getUserId();
|
||||
|
||||
const handleNavUserClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const ariaLabel = isCallParticipant ? `Call Participant: ${getName}` : getName;
|
||||
|
||||
return (
|
||||
<NavItem variant="Background" radii="400">
|
||||
<NavButton onClick={handleNavUserClick} aria-label={ariaLabel}>
|
||||
<NavItemContent as="div">
|
||||
<Box direction="Column" grow="Yes" gap="200" justifyContent="Stretch">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text as="span" size="B400" priority="300" truncate>
|
||||
{getName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,19 +46,6 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||
],
|
||||
};
|
||||
|
||||
const callSettingsGroup: PermissionGroup = {
|
||||
name: 'Calls',
|
||||
items: [
|
||||
{
|
||||
location: {
|
||||
state: true,
|
||||
key: StateEvent.GroupCallMemberPrefix,
|
||||
},
|
||||
name: 'Join Call',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const moderationGroup: PermissionGroup = {
|
||||
name: 'Moderation',
|
||||
items: [
|
||||
|
|
@ -209,7 +196,6 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||
|
||||
return [
|
||||
messagesGroup,
|
||||
callSettingsGroup,
|
||||
moderationGroup,
|
||||
roomOverviewGroup,
|
||||
roomSettingsGroup,
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import { useKeyDown } from '../../hooks/useKeyDown';
|
|||
import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { CallView } from '../call/CallView';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
|
|
@ -25,7 +23,7 @@ export function Room() {
|
|||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room?.roomId);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
|
|
@ -42,17 +40,8 @@ export function Room() {
|
|||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<Box grow="Yes">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
<CallView room={room} />
|
||||
{room.isCallRoom() && screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
)}
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
</Box>
|
||||
</Box>
|
||||
{!room.isCallRoom() && screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
|
|
|
|||
|
|
@ -471,7 +471,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
||||
const canRedact = permissions.action('redact', mx.getSafeUserId());
|
||||
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||
const [editId, setEditId] = useState<string>();
|
||||
|
|
@ -1048,7 +1047,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
edit={editId === mEventId}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
|
|
@ -1130,7 +1129,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
edit={editId === mEventId}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
|
|
@ -1248,7 +1247,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import { Box, Text, config, toRem } from 'folds';
|
||||
import React, { useCallback, useRef, useState } 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';
|
||||
|
|
@ -15,14 +15,14 @@ import { RoomTombstone } from './RoomTombstone';
|
|||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
import { Page } from '../../components/page';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { editableActiveElement } from '../../utils/dom';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { CallView } from '../../components/element-call/CallView';
|
||||
|
||||
const FN_KEYS_REGEX = /^F\d+$/;
|
||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
|
|
@ -31,8 +31,10 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|||
return false;
|
||||
}
|
||||
|
||||
// do not focus on F keys
|
||||
if (FN_KEYS_REGEX.test(code)) return false;
|
||||
|
||||
// do not focus on numlock/scroll lock
|
||||
if (
|
||||
code.startsWith('OS') ||
|
||||
code.startsWith('Meta') ||
|
||||
|
|
@ -60,8 +62,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const { isChatOpen } = useCallState();
|
||||
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
|
|
@ -72,6 +72,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const [showCall, setShowCall] = useState(false);
|
||||
const [callJoined, setCallJoined] = useState(false);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
||||
|
||||
|
|
@ -93,59 +95,62 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||
);
|
||||
|
||||
return (
|
||||
(!room.isCallRoom() || isChatOpen) && (
|
||||
<Page
|
||||
ref={roomViewRef}
|
||||
style={
|
||||
room.isCallRoom() && screenSize === ScreenSize.Desktop
|
||||
? { maxWidth: toRem(399), minWidth: toRem(399) }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
<Page ref={roomViewRef}>
|
||||
<RoomViewHeader onCallClick={() => setShowCall(true)} callJoined={callJoined} />
|
||||
<Box grow="Yes" direction="Row">
|
||||
{showCall && (
|
||||
<CallView
|
||||
onClose={() => setShowCall(false)}
|
||||
onJoin={() => setCallJoined(true)}
|
||||
onHangup={() => setCallJoined(false)}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
)}
|
||||
<Box grow="Yes" direction="Column" style={{ width: 350 }}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
</Box>
|
||||
</Page>
|
||||
)
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import {
|
|||
Spinner,
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { PageHeader } from '../../components/page';
|
||||
|
|
@ -32,7 +33,7 @@ import { UseStateProvider } from '../../components/UseStateProvider';
|
|||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
|
|
@ -47,6 +48,7 @@ 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';
|
||||
|
|
@ -67,8 +69,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useCallOngoing } from '../../hooks/useCallOngoing';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -253,8 +254,11 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
export function RoomViewHeader() {
|
||||
interface RoomViewHeaderProps {
|
||||
onCallClick?: () => void;
|
||||
callJoined?: boolean;
|
||||
}
|
||||
export function RoomViewHeader({ onCallClick, callJoined }: RoomViewHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
|
@ -263,18 +267,18 @@ export function RoomViewHeader() {
|
|||
const space = useSpaceOptionally();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const direct = useIsDirectRoom();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
|
||||
const { isChatOpen, toggleChat } = useCallState();
|
||||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
const ecryptedRoom = !!encryptionEvent;
|
||||
const avatarMxc = useRoomAvatar(room, direct);
|
||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
const callOngoing = useCallOngoing(room);
|
||||
|
||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
|
||||
|
|
@ -296,17 +300,17 @@ export function RoomViewHeader() {
|
|||
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const notParticipatingState = callOngoing ? 'join' : 'start';
|
||||
const buttonState = callJoined ? 'participating' : notParticipatingState;
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
balance={screenSize === ScreenSize.Mobile}
|
||||
>
|
||||
<PageHeader balance={screenSize === ScreenSize.Mobile}>
|
||||
<Box grow="Yes" gap="300">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<IconButton fill="None" onClick={onBack}>
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
@ -321,7 +325,11 @@ export function RoomViewHeader() {
|
|||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||
<RoomIcon
|
||||
size="200"
|
||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
|
|
@ -369,9 +377,8 @@ export function RoomViewHeader() {
|
|||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box shrink="No">
|
||||
{!ecryptedRoom && (!room.isCallRoom() || isChatOpen) && (
|
||||
{!ecryptedRoom && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
|
|
@ -382,75 +389,96 @@ export function RoomViewHeader() {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="400" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{(!room.isCallRoom() || isChatOpen) && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Pinned Messages</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleOpenPinMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!pinMenuAnchor}
|
||||
>
|
||||
{pinnedEvents.length > 0 && (
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: toRem(3),
|
||||
top: toRem(3),
|
||||
}}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
{pinnedEvents.length}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{(!room.isCallRoom() || isChatOpen) && (
|
||||
<PopOut
|
||||
anchor={pinMenuAnchor}
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setPinMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!room.isCallRoom() && screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
{buttonState === 'start' && <Text>Start Call</Text>}
|
||||
{buttonState === 'join' && <Text>Join Call</Text>}
|
||||
{buttonState === 'participating' && <Text>Call Ongoing</Text>}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
variant={buttonState === 'join' ? 'Primary' : undefined}
|
||||
style={{ position: 'relative' }}
|
||||
onClick={onCallClick}
|
||||
ref={triggerRef}
|
||||
disabled={buttonState === 'participating'}
|
||||
aria-pressed={!!pinMenuAnchor}
|
||||
>
|
||||
{buttonState === 'join' && <Text size="B400">Join</Text>}
|
||||
<Icon size="400" src={Icons.Phone} filled={buttonState === 'participating'} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Pinned Messages</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleOpenPinMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!pinMenuAnchor}
|
||||
>
|
||||
{pinnedEvents.length > 0 && (
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: toRem(3),
|
||||
top: toRem(3),
|
||||
}}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
{pinnedEvents.length}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={pinMenuAnchor}
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setPinMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
|
|
@ -461,35 +489,12 @@ export function RoomViewHeader() {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={() => setPeopleDrawer((drawer) => !drawer)}
|
||||
>
|
||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{room.isCallRoom() && !direct && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Chat</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" ref={triggerRef} onClick={toggleChat}>
|
||||
<Icon size="400" src={Icons.Message} filled={isChatOpen} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
|
|
@ -501,12 +506,7 @@ export function RoomViewHeader() {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ export function Search({ requestClose }: SearchProps) {
|
|||
<RoomIcon
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
roomType={room.getType()}
|
||||
space={room.isSpaceRoom()}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
|||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
space
|
||||
size="50"
|
||||
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
|
||||
filled
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import {
|
||||
MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useCallMembers = (mx: MatrixClient, roomId: string): CallMembership[] => {
|
||||
const [memberships, setMemberships] = useState<CallMembership[]>([]);
|
||||
const room = mx.getRoom(roomId);
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setMemberships([]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mxr = mx.matrixRTC.getRoomSession(room);
|
||||
|
||||
const updateMemberships = () => {
|
||||
if (!room.isCallRoom()) return;
|
||||
setMemberships(MatrixRTCSession.callMembershipsForRoom(room));
|
||||
};
|
||||
|
||||
updateMemberships();
|
||||
|
||||
mxr.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
|
||||
return () => {
|
||||
mxr.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
|
||||
};
|
||||
}, [mx, room, roomId]);
|
||||
|
||||
return memberships;
|
||||
};
|
||||
29
src/app/hooks/useCallOngoing.ts
Normal file
29
src/app/hooks/useCallOngoing.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Room } from 'matrix-js-sdk';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
|
||||
|
||||
export const useCallOngoing = (room: Room) => {
|
||||
const [callOngoing, setCallOngoing] = useState(
|
||||
room.client.matrixRTC.getRoomSession(room).memberships.length > 0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const start = (roomId: string) => {
|
||||
if (roomId !== room.roomId) return;
|
||||
setCallOngoing(true);
|
||||
};
|
||||
const end = (roomId: string) => {
|
||||
if (roomId !== room.roomId) return;
|
||||
setCallOngoing(false);
|
||||
};
|
||||
room.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, start);
|
||||
room.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, end);
|
||||
return () => {
|
||||
room.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, start);
|
||||
room.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, end);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
return callOngoing;
|
||||
};
|
||||
|
|
@ -9,7 +9,6 @@ export type ClientConfig = {
|
|||
defaultHomeserver?: number;
|
||||
homeserverList?: string[];
|
||||
allowCustomHomeservers?: boolean;
|
||||
elementCallUrl?: string;
|
||||
|
||||
featuredCommunities?: {
|
||||
openAsDefault?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,35 +1,28 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
import { useStateEventCallback } from './useStateEventCallback';
|
||||
import { getStateEvents } from '../utils/room';
|
||||
|
||||
export const useStateEvents = (rooms: Room[], eventType: StateEvent): number => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
export const useStateEvents = (room: Room, eventType: StateEvent) => {
|
||||
const [updateCount, forceUpdate] = useForceUpdate();
|
||||
|
||||
const relevantRoomIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
if (rooms && Array.isArray(rooms)) {
|
||||
rooms.forEach((room) => {
|
||||
if (room?.roomId) {
|
||||
ids.add(room.roomId);
|
||||
useStateEventCallback(
|
||||
room.client,
|
||||
useCallback(
|
||||
(event) => {
|
||||
if (event.getRoomId() === room.roomId && event.getType() === eventType) {
|
||||
forceUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
}, [rooms]);
|
||||
const handleEventCallback = useCallback(
|
||||
(event: MatrixEvent) => {
|
||||
const eventRoomId = event.getRoomId();
|
||||
if (eventRoomId && event.getType() === eventType && relevantRoomIds.has(eventRoomId)) {
|
||||
forceUpdate();
|
||||
}
|
||||
},
|
||||
[eventType, relevantRoomIds, forceUpdate]
|
||||
},
|
||||
[room, eventType, forceUpdate]
|
||||
)
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => getStateEvents(room, eventType),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[room, eventType, updateCount]
|
||||
);
|
||||
useStateEventCallback(mx, handleEventCallback);
|
||||
return updateCount;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,8 +68,6 @@ import { Create } from './client/create';
|
|||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||
import { SearchModalRenderer } from '../features/search';
|
||||
import { getFallbackSession } from '../state/sessions';
|
||||
import { CallProvider } from './client/call/CallProvider';
|
||||
import { PersistentCallContainer } from './client/call/PersistentCallContainer';
|
||||
|
||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||
const { hashRouter } = clientConfig;
|
||||
|
|
@ -125,19 +123,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<ClientRoomsNotificationPreferences>
|
||||
<ClientBindAtoms>
|
||||
<ClientNonUIFeatures>
|
||||
<CallProvider>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<PersistentCallContainer>
|
||||
<Outlet />
|
||||
</PersistentCallContainer>
|
||||
</ClientLayout>
|
||||
</CallProvider>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</ClientLayout>
|
||||
<SearchModalRenderer />
|
||||
<UserRoomProfileRenderer />
|
||||
<CreateRoomModalRenderer />
|
||||
|
|
|
|||
|
|
@ -1,343 +0,0 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useContext,
|
||||
useMemo,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import {
|
||||
WidgetApiToWidgetAction,
|
||||
WidgetApiAction,
|
||||
ClientWidgetApi,
|
||||
IWidgetApiRequestData,
|
||||
} from 'matrix-widget-api';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SmallWidget } from '../../../features/call/SmallWidget';
|
||||
|
||||
interface MediaStatePayload {
|
||||
data?: {
|
||||
audio_enabled?: boolean;
|
||||
video_enabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const WIDGET_MEDIA_STATE_UPDATE_ACTION = 'io.element.device_mute';
|
||||
const WIDGET_HANGUP_ACTION = 'im.vector.hangup';
|
||||
const WIDGET_ON_SCREEN_ACTION = 'set_always_on_screen';
|
||||
const WIDGET_JOIN_ACTION = 'io.element.join';
|
||||
const WIDGET_TILE_UPDATE = 'io.element.tile_layout';
|
||||
|
||||
interface CallContextState {
|
||||
activeCallRoomId: string | null;
|
||||
setActiveCallRoomId: (roomId: string | null) => void;
|
||||
viewedCallRoomId: string | null;
|
||||
setViewedCallRoomId: (roomId: string | null) => void;
|
||||
hangUp: () => void;
|
||||
activeClientWidgetApi: ClientWidgetApi | null;
|
||||
activeClientWidget: SmallWidget | null;
|
||||
registerActiveClientWidgetApi: (
|
||||
roomId: string | null,
|
||||
clientWidgetApi: ClientWidgetApi | null,
|
||||
clientWidget: SmallWidget,
|
||||
activeClientIframeRef: HTMLIFrameElement
|
||||
) => void;
|
||||
sendWidgetAction: <T extends IWidgetApiRequestData = IWidgetApiRequestData>(
|
||||
action: WidgetApiToWidgetAction | string,
|
||||
data: T
|
||||
) => Promise<void>;
|
||||
isAudioEnabled: boolean;
|
||||
isVideoEnabled: boolean;
|
||||
isChatOpen: boolean;
|
||||
isActiveCallReady: boolean;
|
||||
toggleAudio: () => Promise<void>;
|
||||
toggleVideo: () => Promise<void>;
|
||||
toggleChat: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CallContext = createContext<CallContextState | undefined>(undefined);
|
||||
|
||||
interface CallProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_ENABLED = true;
|
||||
const DEFAULT_VIDEO_ENABLED = false;
|
||||
const DEFAULT_CHAT_OPENED = false;
|
||||
|
||||
export function CallProvider({ children }: CallProviderProps) {
|
||||
const [activeCallRoomId, setActiveCallRoomIdState] = useState<string | null>(null);
|
||||
const [viewedCallRoomId, setViewedCallRoomIdState] = useState<string | null>(null);
|
||||
|
||||
const [activeClientWidgetApi, setActiveClientWidgetApiState] = useState<ClientWidgetApi | null>(
|
||||
null
|
||||
);
|
||||
const [activeClientWidget, setActiveClientWidget] = useState<SmallWidget | null>(null);
|
||||
const [activeClientWidgetApiRoomId, setActiveClientWidgetApiRoomId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [activeClientWidgetIframeRef, setActiveClientWidgetIframeRef] =
|
||||
useState<HTMLIFrameElement | null>(null);
|
||||
|
||||
const [isAudioEnabled, setIsAudioEnabledState] = useState<boolean>(DEFAULT_AUDIO_ENABLED);
|
||||
const [isVideoEnabled, setIsVideoEnabledState] = useState<boolean>(DEFAULT_VIDEO_ENABLED);
|
||||
const [isChatOpen, setIsChatOpenState] = useState<boolean>(DEFAULT_CHAT_OPENED);
|
||||
const [isActiveCallReady, setIsActiveCallReady] = useState<boolean>(false);
|
||||
|
||||
const { roomIdOrAlias: viewedRoomId } = useParams<{ roomIdOrAlias: string }>();
|
||||
|
||||
const setActiveCallRoomId = useCallback((roomId: string | null) => {
|
||||
setActiveCallRoomIdState(roomId);
|
||||
}, []);
|
||||
|
||||
const setViewedCallRoomId = useCallback(
|
||||
(roomId: string | null) => {
|
||||
setViewedCallRoomIdState(roomId);
|
||||
},
|
||||
[setViewedCallRoomIdState]
|
||||
);
|
||||
|
||||
const setActiveClientWidgetApi = useCallback(
|
||||
(
|
||||
clientWidgetApi: ClientWidgetApi | null,
|
||||
clientWidget: SmallWidget | null,
|
||||
roomId: string | null,
|
||||
clientWidgetIframeRef: HTMLIFrameElement | null
|
||||
) => {
|
||||
setActiveClientWidgetApiState(clientWidgetApi);
|
||||
setActiveClientWidget(clientWidget);
|
||||
setActiveClientWidgetApiRoomId(roomId);
|
||||
setActiveClientWidgetIframeRef(clientWidgetIframeRef);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const registerActiveClientWidgetApi = useCallback(
|
||||
(
|
||||
roomId: string | null,
|
||||
clientWidgetApi: ClientWidgetApi | null,
|
||||
clientWidget: SmallWidget | null,
|
||||
clientWidgetIframeRef: HTMLIFrameElement | null
|
||||
) => {
|
||||
if (roomId && clientWidgetApi) {
|
||||
setActiveClientWidgetApi(clientWidgetApi, clientWidget, roomId, clientWidgetIframeRef);
|
||||
} else if (roomId === activeClientWidgetApiRoomId || roomId === null) {
|
||||
setActiveClientWidgetApi(null, null, null, null);
|
||||
}
|
||||
},
|
||||
[activeClientWidgetApiRoomId, setActiveClientWidgetApi]
|
||||
);
|
||||
|
||||
const hangUp = useCallback(() => {
|
||||
setActiveClientWidgetApi(null, null, null, null);
|
||||
setActiveCallRoomIdState(null);
|
||||
activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {});
|
||||
setIsActiveCallReady(false);
|
||||
}, [activeClientWidgetApi?.transport, setActiveClientWidgetApi]);
|
||||
|
||||
const sendWidgetAction = useCallback(
|
||||
async <T extends IWidgetApiRequestData = IWidgetApiRequestData>(
|
||||
action: WidgetApiToWidgetAction | string,
|
||||
data: T
|
||||
): Promise<void> => {
|
||||
if (!activeClientWidgetApi) {
|
||||
return Promise.reject(new Error('No active call clientWidgetApi'));
|
||||
}
|
||||
if (!activeClientWidgetApiRoomId || activeClientWidgetApiRoomId !== activeCallRoomId) {
|
||||
return Promise.reject(new Error('Mismatched active call clientWidgetApi'));
|
||||
}
|
||||
|
||||
await activeClientWidgetApi.transport.send(action as WidgetApiAction, data);
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
[activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId]
|
||||
);
|
||||
|
||||
const toggleAudio = useCallback(async () => {
|
||||
const newState = !isAudioEnabled;
|
||||
setIsAudioEnabledState(newState);
|
||||
|
||||
if (isActiveCallReady) {
|
||||
try {
|
||||
await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
|
||||
audio_enabled: newState,
|
||||
video_enabled: isVideoEnabled,
|
||||
});
|
||||
} catch (error) {
|
||||
setIsAudioEnabledState(!newState);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}, [isAudioEnabled, isVideoEnabled, sendWidgetAction, isActiveCallReady]);
|
||||
|
||||
const toggleVideo = useCallback(async () => {
|
||||
const newState = !isVideoEnabled;
|
||||
setIsVideoEnabledState(newState);
|
||||
|
||||
if (isActiveCallReady) {
|
||||
try {
|
||||
await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
|
||||
audio_enabled: isAudioEnabled,
|
||||
video_enabled: newState,
|
||||
});
|
||||
} catch (error) {
|
||||
setIsVideoEnabledState(!newState);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}, [isVideoEnabled, isAudioEnabled, sendWidgetAction, isActiveCallReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCallRoomId && !viewedCallRoomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeClientWidgetApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleHangup = (ev: CustomEvent) => {
|
||||
ev.preventDefault();
|
||||
if (isActiveCallReady && ev.detail.widgetId === activeClientWidgetApi.widget.id) {
|
||||
activeClientWidgetApi.transport.reply(ev.detail, {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaStateUpdate = (ev: CustomEvent<MediaStatePayload>) => {
|
||||
if (!isActiveCallReady) return;
|
||||
ev.preventDefault();
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
const { audio_enabled, video_enabled } = ev.detail.data ?? {};
|
||||
|
||||
if (typeof audio_enabled === 'boolean' && audio_enabled !== isAudioEnabled) {
|
||||
setIsAudioEnabledState(audio_enabled);
|
||||
}
|
||||
if (typeof video_enabled === 'boolean' && video_enabled !== isVideoEnabled) {
|
||||
setIsVideoEnabledState(video_enabled);
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
};
|
||||
|
||||
const handleOnScreenStateUpdate = (ev: CustomEvent) => {
|
||||
ev.preventDefault();
|
||||
activeClientWidgetApi.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
const handleOnTileLayout = (ev: CustomEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
activeClientWidgetApi.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
const handleJoin = (ev: CustomEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
activeClientWidgetApi.transport.reply(ev.detail, {});
|
||||
|
||||
const iframeDoc =
|
||||
activeClientWidgetIframeRef?.contentWindow?.document ||
|
||||
activeClientWidgetIframeRef?.contentDocument;
|
||||
|
||||
if (iframeDoc) {
|
||||
const observer = new MutationObserver(() => {
|
||||
const button = iframeDoc.querySelector('[data-testid="incall_leave"]');
|
||||
if (button) {
|
||||
button.addEventListener('click', () => {
|
||||
hangUp();
|
||||
});
|
||||
}
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(iframeDoc, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
setIsActiveCallReady(true);
|
||||
};
|
||||
|
||||
void sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
|
||||
audio_enabled: isAudioEnabled,
|
||||
video_enabled: isVideoEnabled,
|
||||
}).catch(() => {
|
||||
// Widget transport may reject while call/session setup is still in progress.
|
||||
});
|
||||
|
||||
activeClientWidgetApi.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup);
|
||||
activeClientWidgetApi.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate);
|
||||
activeClientWidgetApi.on(`action:${WIDGET_TILE_UPDATE}`, handleOnTileLayout);
|
||||
activeClientWidgetApi.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate);
|
||||
activeClientWidgetApi.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin);
|
||||
}, [
|
||||
activeClientWidgetIframeRef,
|
||||
activeClientWidgetApi,
|
||||
activeCallRoomId,
|
||||
activeClientWidgetApiRoomId,
|
||||
hangUp,
|
||||
isChatOpen,
|
||||
isAudioEnabled,
|
||||
isVideoEnabled,
|
||||
isActiveCallReady,
|
||||
viewedRoomId,
|
||||
viewedCallRoomId,
|
||||
setViewedCallRoomId,
|
||||
activeClientWidget?.iframe?.contentDocument,
|
||||
activeClientWidget?.iframe?.contentWindow?.document,
|
||||
sendWidgetAction,
|
||||
]);
|
||||
|
||||
const toggleChat = useCallback(async () => {
|
||||
const newState = !isChatOpen;
|
||||
setIsChatOpenState(newState);
|
||||
}, [isChatOpen]);
|
||||
|
||||
const contextValue = useMemo<CallContextState>(
|
||||
() => ({
|
||||
activeCallRoomId,
|
||||
setActiveCallRoomId,
|
||||
viewedCallRoomId,
|
||||
setViewedCallRoomId,
|
||||
hangUp,
|
||||
activeClientWidgetApi,
|
||||
registerActiveClientWidgetApi,
|
||||
activeClientWidget,
|
||||
sendWidgetAction,
|
||||
isChatOpen,
|
||||
isAudioEnabled,
|
||||
isVideoEnabled,
|
||||
isActiveCallReady,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
toggleChat,
|
||||
}),
|
||||
[
|
||||
activeCallRoomId,
|
||||
setActiveCallRoomId,
|
||||
viewedCallRoomId,
|
||||
setViewedCallRoomId,
|
||||
hangUp,
|
||||
activeClientWidgetApi,
|
||||
registerActiveClientWidgetApi,
|
||||
activeClientWidget,
|
||||
sendWidgetAction,
|
||||
isChatOpen,
|
||||
isAudioEnabled,
|
||||
isVideoEnabled,
|
||||
isActiveCallReady,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
toggleChat,
|
||||
]
|
||||
);
|
||||
|
||||
return <CallContext.Provider value={contextValue}>{children}</CallContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCallState(): CallContextState {
|
||||
const context = useContext(CallContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCallState must be used within a CallProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import React, { createContext, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ClientWidgetApi } from 'matrix-widget-api';
|
||||
import { Box } from 'folds';
|
||||
import { useCallState } from './CallProvider';
|
||||
import {
|
||||
createVirtualWidget,
|
||||
SmallWidget,
|
||||
getWidgetData,
|
||||
getWidgetUrl,
|
||||
} from '../../../features/call/SmallWidget';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { ThemeKind, useTheme } from '../../../hooks/useTheme';
|
||||
|
||||
interface PersistentCallContainerProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const CallRefContext =
|
||||
createContext<React.MutableRefObject<HTMLIFrameElement | null> | null>(null);
|
||||
|
||||
export function PersistentCallContainer({ children }: PersistentCallContainerProps) {
|
||||
const callIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const callWidgetApiRef = useRef<ClientWidgetApi | null>(null);
|
||||
const callSmallWidgetRef = useRef<SmallWidget | null>(null);
|
||||
|
||||
const {
|
||||
activeCallRoomId,
|
||||
viewedCallRoomId,
|
||||
isChatOpen,
|
||||
isActiveCallReady,
|
||||
registerActiveClientWidgetApi,
|
||||
activeClientWidget,
|
||||
} = useCallState();
|
||||
const mx = useMatrixClient();
|
||||
const clientConfig = useClientConfig();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const theme = useTheme();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
const setupWidget = useCallback(
|
||||
(
|
||||
widgetApiRef: React.MutableRefObject<ClientWidgetApi | null>,
|
||||
smallWidgetRef: React.MutableRefObject<SmallWidget | null>,
|
||||
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
||||
skipLobby: boolean,
|
||||
themeKind: ThemeKind | null
|
||||
) => {
|
||||
if (mx?.getUserId()) {
|
||||
if (activeCallRoomId && !isActiveCallReady) {
|
||||
const roomIdToSet = activeCallRoomId;
|
||||
|
||||
const widgetId = `element-call-${roomIdToSet}-${Date.now()}`;
|
||||
const newUrl = getWidgetUrl(
|
||||
mx,
|
||||
roomIdToSet,
|
||||
clientConfig.elementCallUrl ?? '',
|
||||
widgetId,
|
||||
{
|
||||
skipLobby: skipLobby.toString(),
|
||||
returnToLobby: 'true',
|
||||
perParticipantE2EE: 'true',
|
||||
theme: themeKind,
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
callSmallWidgetRef.current?.roomId &&
|
||||
activeClientWidget?.roomId &&
|
||||
activeClientWidget.roomId === callSmallWidgetRef.current?.roomId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
iframeRef.current &&
|
||||
(!iframeRef.current.src || iframeRef.current.src !== newUrl.toString())
|
||||
) {
|
||||
iframeRef.current.src = newUrl.toString();
|
||||
}
|
||||
|
||||
const iframeElement = iframeRef.current;
|
||||
if (!iframeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = mx.getUserId() ?? '';
|
||||
const app = createVirtualWidget(
|
||||
mx,
|
||||
widgetId,
|
||||
userId,
|
||||
'Element Call',
|
||||
'm.call',
|
||||
newUrl,
|
||||
true,
|
||||
getWidgetData(mx, roomIdToSet, {}, { skipLobby: true }),
|
||||
roomIdToSet
|
||||
);
|
||||
|
||||
const smallWidget = new SmallWidget(app);
|
||||
smallWidgetRef.current = smallWidget;
|
||||
|
||||
const widgetApiInstance = smallWidget.startMessaging(iframeElement);
|
||||
widgetApiRef.current = widgetApiInstance;
|
||||
registerActiveClientWidgetApi(
|
||||
roomIdToSet,
|
||||
widgetApiRef.current,
|
||||
smallWidget,
|
||||
iframeElement
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
mx,
|
||||
activeCallRoomId,
|
||||
isActiveCallReady,
|
||||
clientConfig.elementCallUrl,
|
||||
activeClientWidget,
|
||||
registerActiveClientWidgetApi,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCallRoomId) {
|
||||
setupWidget(callWidgetApiRef, callSmallWidgetRef, callIframeRef, true, theme.kind);
|
||||
}
|
||||
}, [
|
||||
theme,
|
||||
setupWidget,
|
||||
callWidgetApiRef,
|
||||
callSmallWidgetRef,
|
||||
callIframeRef,
|
||||
registerActiveClientWidgetApi,
|
||||
activeCallRoomId,
|
||||
viewedCallRoomId,
|
||||
isActiveCallReady,
|
||||
]);
|
||||
|
||||
const memoizedIframeRef = useMemo(() => callIframeRef, [callIframeRef]);
|
||||
|
||||
return (
|
||||
<CallRefContext.Provider value={memoizedIframeRef}>
|
||||
<Box grow="No">
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
display: isMobile && isChatOpen ? 'none' : 'flex',
|
||||
width: isMobile && isChatOpen ? '0%' : '100%',
|
||||
height: isMobile && isChatOpen ? '0%' : '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={{
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={callIframeRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
title="Persistent Element Call"
|
||||
sandbox="allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads"
|
||||
allow="microphone; camera; display-capture; autoplay; clipboard-write;"
|
||||
src="about:blank"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{children}
|
||||
</CallRefContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -51,7 +51,6 @@ import {
|
|||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
||||
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||
|
||||
type DirectMenuProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -276,7 +275,6 @@ export function Direct() {
|
|||
</Box>
|
||||
</PageNavContent>
|
||||
)}
|
||||
<CallNavStatus />
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ import {
|
|||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||
import { _RoomSearchParams } from '../../paths';
|
||||
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||
|
||||
type HomeMenuProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -358,7 +357,6 @@ export function Home() {
|
|||
</Box>
|
||||
</PageNavContent>
|
||||
)}
|
||||
<CallNavStatus />
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,8 +84,6 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
|
|||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
||||
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||
import { useCallState } from '../call/CallProvider';
|
||||
|
||||
type SpaceMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -298,7 +296,7 @@ function SpaceHeader() {
|
|||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
{space && <SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />}
|
||||
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
|
@ -389,15 +387,15 @@ export function Space() {
|
|||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
|
||||
const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone);
|
||||
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
|
||||
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
|
||||
const { isActiveCallReady, activeCallRoomId } = useCallState();
|
||||
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||
|
||||
const getRoom = useCallback(
|
||||
(rId: string): Room | undefined => {
|
||||
(rId: string) => {
|
||||
if (allJoinedRooms.has(rId)) {
|
||||
return mx.getRoom(rId) ?? undefined;
|
||||
}
|
||||
|
|
@ -414,20 +412,11 @@ export function Space() {
|
|||
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
|
||||
return false;
|
||||
}
|
||||
const showRoomAnyway =
|
||||
roomToUnread.has(roomId) ||
|
||||
roomId === selectedRoomId ||
|
||||
(isActiveCallReady && activeCallRoomId === roomId);
|
||||
return !showRoomAnyway;
|
||||
const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId;
|
||||
if (showRoom) return false;
|
||||
return true;
|
||||
},
|
||||
[
|
||||
space.roomId,
|
||||
closedCategories,
|
||||
roomToUnread,
|
||||
selectedRoomId,
|
||||
activeCallRoomId,
|
||||
isActiveCallReady,
|
||||
]
|
||||
[space.roomId, closedCategories, roomToUnread, selectedRoomId]
|
||||
),
|
||||
useCallback(
|
||||
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
|
||||
|
|
@ -438,7 +427,7 @@ export function Space() {
|
|||
const virtualizer = useVirtualizer({
|
||||
count: hierarchy.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 32,
|
||||
estimateSize: () => 0,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
|
|
@ -545,7 +534,6 @@ export function Space() {
|
|||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
<CallNavStatus />
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,8 +160,7 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string):
|
|||
};
|
||||
|
||||
export const isMutedRule = (rule: IPushRule) =>
|
||||
// Check for empty actions (new spec) or dont_notify (deprecated)
|
||||
(rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match';
|
||||
rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match';
|
||||
|
||||
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
|
||||
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));
|
||||
|
|
@ -257,60 +256,24 @@ export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
|
|||
return unreadInfos;
|
||||
};
|
||||
|
||||
export const getRoomIconSrc = (
|
||||
export const joinRuleToIconSrc = (
|
||||
icons: Record<IconName, IconSrc>,
|
||||
roomType?: string,
|
||||
joinRule?: JoinRule,
|
||||
locked?: boolean
|
||||
): IconSrc => {
|
||||
type RoomIcons = {
|
||||
base: IconSrc;
|
||||
locked: IconSrc;
|
||||
public: IconSrc;
|
||||
};
|
||||
|
||||
const roomTypeIcons: Record<string, RoomIcons> = {
|
||||
[RoomType.Call]: {
|
||||
base: icons.VolumeHigh,
|
||||
locked: icons.Lock,
|
||||
public: icons.VolumeHigh,
|
||||
},
|
||||
[RoomType.Space]: {
|
||||
base: icons.Space,
|
||||
locked: icons.SpaceLock,
|
||||
public: icons.SpaceGlobe,
|
||||
},
|
||||
default: {
|
||||
base: icons.Hash,
|
||||
locked: icons.HashLock,
|
||||
public: icons.HashGlobe,
|
||||
},
|
||||
};
|
||||
|
||||
const roomIcons = roomTypeIcons[roomType ?? 'default'] ?? roomTypeIcons.default;
|
||||
|
||||
let roomIcon = roomIcons.base;
|
||||
|
||||
if (locked) {
|
||||
roomIcon = roomIcons.locked;
|
||||
} else {
|
||||
switch (joinRule) {
|
||||
case JoinRule.Invite:
|
||||
case JoinRule.Knock:
|
||||
roomIcon = roomIcons.locked;
|
||||
break;
|
||||
case JoinRule.Restricted:
|
||||
roomIcon = roomIcons.base;
|
||||
break;
|
||||
case JoinRule.Public:
|
||||
roomIcon = roomIcons.public;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
joinRule: JoinRule,
|
||||
space: boolean
|
||||
): IconSrc | undefined => {
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
return space ? icons.Space : icons.Hash;
|
||||
}
|
||||
|
||||
return roomIcon;
|
||||
if (joinRule === JoinRule.Knock) {
|
||||
return space ? icons.SpaceLock : icons.HashLock;
|
||||
}
|
||||
if (joinRule === JoinRule.Invite) {
|
||||
return space ? icons.SpaceLock : icons.HashLock;
|
||||
}
|
||||
if (joinRule === JoinRule.Public) {
|
||||
return space ? icons.SpaceGlobe : icons.HashGlobe;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getRoomAvatarUrl = (
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ export enum StateEvent {
|
|||
RoomGuestAccess = 'm.room.guest_access',
|
||||
RoomServerAcl = 'm.room.server_acl',
|
||||
RoomTombstone = 'm.room.tombstone',
|
||||
GroupCallPrefix = 'org.matrix.msc3401.call',
|
||||
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
|
||||
|
||||
SpaceChild = 'm.space.child',
|
||||
SpaceParent = 'm.space.parent',
|
||||
|
|
@ -52,7 +50,6 @@ export enum MessageEvent {
|
|||
|
||||
export enum RoomType {
|
||||
Space = 'm.space',
|
||||
Call = 'org.matrix.msc3417.call',
|
||||
}
|
||||
|
||||
export type MSpaceChildContent = {
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ import buildConfig from './build.config';
|
|||
|
||||
const copyFiles = {
|
||||
targets: [
|
||||
{
|
||||
src: 'node_modules/@element-hq/element-call-embedded/dist/*',
|
||||
dest: 'public/element-call',
|
||||
},
|
||||
{
|
||||
src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
dest: '',
|
||||
|
|
@ -42,6 +38,10 @@ const copyFiles = {
|
|||
src: 'public/locales',
|
||||
dest: 'public/',
|
||||
},
|
||||
{
|
||||
src: 'node_modules/@element-hq/element-call-embedded/dist/*',
|
||||
dest: 'widgets/element-call/',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue