Compare commits

..

13 commits

Author SHA1 Message Date
8d2a7e02f6 version stuff
Some checks failed
Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
2026-01-30 20:14:33 +02:00
482e93e3fb Merge commit 'refs/pull/2512/head' of https://github.com/cinnyapp/cinny into dev
Some checks are pending
Deploy to Netlify (dev) / Deploy to Netlify (push) Waiting to run
Merged the element call pr from upstream
2026-01-30 19:54:33 +02:00
f027696676 Merge commit 'refs/pull/2487/head' of https://github.com/cinnyapp/cinny into dev
Merging the pronouns pr from upstream
2026-01-30 19:52:37 +02:00
Timo K
c1b2902bc5 add netlify redirect to widgets/
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-21 13:51:59 +02:00
Timo K
0f70e6f706 fix widget loading vite config
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-21 11:58:47 +02:00
Timo K
94ff2d1d7d Add call button to header
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:33:45 +02:00
Timo K
c9effa0860 use call view in room view
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:32:57 +02:00
Timo K
4985de841c call view impl
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:32:11 +02:00
Timo K
65085e84f7 Call View template
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:31:32 +02:00
Timo K
e18769f9c6 copy over StopGapWidget (ElementCallMessaging class)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:29:30 +02:00
Timo K
8df27ac688 copy over widgetDriver from EW
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:24:02 +02:00
Timo K
bbe53d6d6f copy over utils form EW
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:23:48 +02:00
Timo K
98c90b23af dependencies
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:22:58 +02:00
46 changed files with 1152 additions and 2460 deletions

View file

@ -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": {

View file

@ -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;
}
}

View file

@ -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
View file

@ -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"

View file

@ -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"
}
}
}

View file

@ -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;
};

View file

@ -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>
}

View 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>
);
}

View file

@ -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;
}
}

View 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);
});
}
}
}
}

View 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]);
}

View file

@ -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}
/>
));

View file

@ -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',
},
});

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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);
}
}

View file

@ -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
});

View file

@ -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

View file

@ -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} />}

View file

@ -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>
)}

View file

@ -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={

View file

@ -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} />}
>

View file

@ -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,
});

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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} />

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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>
)}

View file

@ -373,7 +373,7 @@ export function Search({ requestClose }: SearchProps) {
<RoomIcon
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
space={room.isSpaceRoom()}
/>
)}
</Avatar>

View file

@ -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

View file

@ -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;
};

View 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;
};

View file

@ -9,7 +9,6 @@ export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
allowCustomHomeservers?: boolean;
elementCallUrl?: string;
featuredCommunities?: {
openAsDefault?: boolean;

View file

@ -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;
};

View file

@ -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 />

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 = (

View file

@ -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 = {

View file

@ -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/',
},
],
};