mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 22:40:29 +03:00
Merge 77f8a0409a into fbd7e0a14b
This commit is contained in:
commit
ae77cc1033
29 changed files with 2823 additions and 265 deletions
|
|
@ -9,6 +9,7 @@
|
||||||
"xmr.se"
|
"xmr.se"
|
||||||
],
|
],
|
||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
|
"elementCallUrl": null,
|
||||||
|
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ server {
|
||||||
rewrite ^/public/(.*)$ /public/$1 break;
|
rewrite ^/public/(.*)$ /public/$1 break;
|
||||||
rewrite ^/assets/(.*)$ /assets/$1 break;
|
rewrite ^/assets/(.*)$ /assets/$1 break;
|
||||||
|
|
||||||
|
rewrite ^/element-call/dist/(.*)$ /element-call/dist/$1 break;
|
||||||
|
|
||||||
rewrite ^(.+)$ /index.html break;
|
rewrite ^(.+)$ /index.html break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
package-lock.json
generated
27
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
|
"@matrix-org/react-sdk-module-api": "2.5.0",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
|
|
@ -47,6 +48,7 @@
|
||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.1.3",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.1.3",
|
||||||
"matrix-js-sdk": "37.5.0",
|
"matrix-js-sdk": "37.5.0",
|
||||||
|
"matrix-widget-api": "1.11.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
|
@ -72,6 +74,7 @@
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@element-hq/element-call-embedded": "0.12.2",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
"@rollup/plugin-inject": "5.0.3",
|
"@rollup/plugin-inject": "5.0.3",
|
||||||
"@rollup/plugin-wasm": "6.1.1",
|
"@rollup/plugin-wasm": "6.1.1",
|
||||||
|
|
@ -1657,6 +1660,12 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@element-hq/element-call-embedded": {
|
||||||
|
"version": "0.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.12.2.tgz",
|
||||||
|
"integrity": "sha512-2u5/bOARcjc5TFq4929x1R0tvsNbeVA58FBtiW05GlIJCapxzPSOeeGhbqEcJ1TW3/hLGpiKMcw0QwRBQVNzQA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@emotion/hash": {
|
"node_modules/@emotion/hash": {
|
||||||
"version": "0.9.2",
|
"version": "0.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||||
|
|
@ -2278,6 +2287,18 @@
|
||||||
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
|
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|
@ -8826,9 +8847,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matrix-widget-api": {
|
"node_modules/matrix-widget-api": {
|
||||||
"version": "1.13.1",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.11.0.tgz",
|
||||||
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
|
"integrity": "sha512-ED/9hrJqDWVLeED0g1uJnYRhINh3ZTquwurdM+Hc8wLVJIQ8G/r7A7z74NC+8bBIHQ1Jo7i1Uq5CoJp/TzFYrA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/events": "^3.0.0",
|
"@types/events": "^3.0.0",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
"lint": "yarn check:eslint && yarn check:prettier",
|
"lint": "yarn check:eslint && yarn check:prettier",
|
||||||
"check:eslint": "eslint src/*",
|
"check:eslint": "eslint src/*",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
|
"@matrix-org/react-sdk-module-api": "2.5.0",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
|
|
@ -57,6 +59,7 @@
|
||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.1.3",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.1.3",
|
||||||
|
"matrix-widget-api": "1.11.0",
|
||||||
"matrix-js-sdk": "37.5.0",
|
"matrix-js-sdk": "37.5.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
|
|
@ -83,6 +86,7 @@
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@element-hq/element-call-embedded": "0.12.2",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
"@rollup/plugin-inject": "5.0.3",
|
"@rollup/plugin-inject": "5.0.3",
|
||||||
"@rollup/plugin-wasm": "6.1.1",
|
"@rollup/plugin-wasm": "6.1.1",
|
||||||
|
|
@ -115,4 +119,5 @@
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"vite-plugin-static-copy": "1.0.4",
|
||||||
"vite-plugin-top-level-await": "1.4.4"
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,11 @@ export const RoomIcon = forwardRef<
|
||||||
Omit<ComponentProps<typeof Icon>, 'src'> & {
|
Omit<ComponentProps<typeof Icon>, 'src'> & {
|
||||||
joinRule: JoinRule;
|
joinRule: JoinRule;
|
||||||
space?: boolean;
|
space?: boolean;
|
||||||
|
call?: boolean;
|
||||||
}
|
}
|
||||||
>(({ joinRule, space, ...props }, ref) => (
|
>(({ joinRule, space, call, ...props }, ref) => (
|
||||||
<Icon
|
<Icon
|
||||||
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
|
src={joinRuleToIconSrc(Icons, joinRule, space || false, call || false) ?? Icons.Hash}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
155
src/app/features/call/CallView.tsx
Normal file
155
src/app/features/call/CallView.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/* eslint-disable no-nested-ternary */
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import React, { useContext, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Box } from 'folds';
|
||||||
|
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||||
|
import {
|
||||||
|
PrimaryRefContext,
|
||||||
|
BackupRefContext,
|
||||||
|
} from '../../pages/client/call/PersistentCallContainer';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
|
||||||
|
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
return (...args: Parameters<F>): void => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(() => func(...args), waitFor);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type OriginalStyles = {
|
||||||
|
position?: string;
|
||||||
|
top?: string;
|
||||||
|
left?: string;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
zIndex?: string;
|
||||||
|
display?: string;
|
||||||
|
visibility?: string;
|
||||||
|
pointerEvents?: string;
|
||||||
|
border?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CallView({ room }: { room: Room }) {
|
||||||
|
const primaryIframeRef = useContext(PrimaryRefContext);
|
||||||
|
const backupIframeRef = useContext(BackupRefContext);
|
||||||
|
const iframeHostRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const originalIframeStylesRef = useRef<OriginalStyles | null>(null);
|
||||||
|
const { activeCallRoomId, isPrimaryIframe, isChatOpen } = useCallState();
|
||||||
|
const isViewingActiveCall = useMemo(
|
||||||
|
() => activeCallRoomId !== null && activeCallRoomId === room.roomId,
|
||||||
|
[activeCallRoomId, room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
const activeIframeDisplayRef = isPrimaryIframe
|
||||||
|
? isViewingActiveCall
|
||||||
|
? primaryIframeRef
|
||||||
|
: backupIframeRef
|
||||||
|
: isViewingActiveCall
|
||||||
|
? backupIframeRef
|
||||||
|
: primaryIframeRef;
|
||||||
|
|
||||||
|
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 = useCallback(debounce(applyFixedPositioningToIframe, 50), [
|
||||||
|
applyFixedPositioningToIframe,
|
||||||
|
primaryIframeRef,
|
||||||
|
backupIframeRef,
|
||||||
|
]);
|
||||||
|
useEffect(() => {
|
||||||
|
const iframeElement = activeIframeDisplayRef?.current;
|
||||||
|
const hostElement = iframeHostRef?.current;
|
||||||
|
|
||||||
|
if (room.isCallRoom() || (isViewingActiveCall && iframeElement && hostElement)) {
|
||||||
|
applyFixedPositioningToIframe();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(debouncedApplyFixedPositioning);
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeIframeDisplayRef,
|
||||||
|
applyFixedPositioningToIframe,
|
||||||
|
debouncedApplyFixedPositioning,
|
||||||
|
isPrimaryIframe,
|
||||||
|
isViewingActiveCall,
|
||||||
|
room,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isCallViewVisible = room.isCallRoom();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
width: isChatOpen ? (isMobile ? '50%' : '100%') : '100%',
|
||||||
|
display: isCallViewVisible ? (isMobile && isChatOpen ? 'none' : 'flex') : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={iframeHostRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: isCallViewVisible ? 'flex' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/features/call/CinnyWidget.ts
Normal file
9
src/app/features/call/CinnyWidget.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
410
src/app/features/call/SmallWidget.ts
Normal file
410
src/app/features/call/SmallWidget.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
/*
|
||||||
|
* 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 { logger } from 'matrix-js-sdk/lib/logger';
|
||||||
|
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',
|
||||||
|
preload: 'false',
|
||||||
|
skipLobby: setParams.skipLobby ?? 'true',
|
||||||
|
returnToLobby: setParams.returnToLobby ?? 'true',
|
||||||
|
perParticipantE2EE: setParams.perParticipantE2EE ?? 'true',
|
||||||
|
hideHeader: 'true',
|
||||||
|
userId: mx.getUserId()!,
|
||||||
|
deviceId: mx.getDeviceId()!,
|
||||||
|
roomId,
|
||||||
|
baseUrl: mx.baseUrl!,
|
||||||
|
parentUrl: window.location.origin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacedParams = params.toString().replace(/%24/g, '$');
|
||||||
|
url.search = `?${replacedParams}`;
|
||||||
|
|
||||||
|
logger.info('Generated Element Call Widget URL:', url.toString());
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IApp extends IWidget {
|
||||||
|
client: MatrixClient;
|
||||||
|
roomId: string;
|
||||||
|
eventId?: string;
|
||||||
|
avatar_url?: 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: HTMLElement | 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.
|
||||||
|
for (const room of this.client.getRooms()) {
|
||||||
|
// Timelines are most recent last
|
||||||
|
const events = room.getLiveTimeline()?.getEvents() || [];
|
||||||
|
const roomEvent = events[events.length - 1];
|
||||||
|
if (!roomEvent) continue; // force later code to think the room is fresh
|
||||||
|
this.readUpToMap[room.roomId] = roomEvent.getId()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
for (const [key, eventObject] of stateEvents?.entries() ?? []) {
|
||||||
|
events.push(eventObject.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.messaging?.transport.reply(ev.detail, { events });
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
this.messaging?.on('action:content_loaded', () => {
|
||||||
|
this.messaging?.transport?.send('io.element.join', {
|
||||||
|
audioInput: 'true',
|
||||||
|
videoInput: 'true',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
//this.client.on(RoomStateEvent.Events, this.onReadEvent);
|
||||||
|
// this.messaging.setViewedRoomId(this.roomId ?? null);
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Widget messaging started for widgetId: ${this.mockWidget.id}`);
|
||||||
|
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);
|
||||||
|
|
||||||
|
for (const timelineEvent of events) {
|
||||||
|
if (timelineEvent.getId() === upToEventId) {
|
||||||
|
// The event must be somewhere before the "read up to" marker
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (timelineEvent.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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((e) => {
|
||||||
|
logger.error('Error sending event to widget: ', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
logger.info(`Widget messaging stopped for widgetId: ${this.mockWidget.id}`);
|
||||||
|
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
|
||||||
|
content: {
|
||||||
|
// Example content structure
|
||||||
|
type,
|
||||||
|
url: url.toString(),
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
creatorUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
551
src/app/features/call/SmallWidgetDriver.ts
Normal file
551
src/app/features/call/SmallWidgetDriver.ts
Normal file
|
|
@ -0,0 +1,551 @@
|
||||||
|
/* eslint-disable no-return-await */
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
/* eslint-disable no-continue */
|
||||||
|
/* eslint-disable class-methods-use-this */
|
||||||
|
/* 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.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type Capability,
|
||||||
|
EventDirection,
|
||||||
|
type ISendDelayedEventDetails,
|
||||||
|
type ISendEventDetails,
|
||||||
|
type IReadEventRelationsResult,
|
||||||
|
type IRoomEvent,
|
||||||
|
MatrixCapabilities,
|
||||||
|
type Widget,
|
||||||
|
WidgetDriver,
|
||||||
|
WidgetEventCapability,
|
||||||
|
WidgetKind,
|
||||||
|
type IWidgetApiErrorResponseDataDetails,
|
||||||
|
type ISearchUserDirectoryResult,
|
||||||
|
type IGetMediaConfigResult,
|
||||||
|
type UpdateDelayedEventAction,
|
||||||
|
OpenIDRequestState,
|
||||||
|
SimpleObservable,
|
||||||
|
IOpenIDUpdate,
|
||||||
|
} from 'matrix-widget-api';
|
||||||
|
import {
|
||||||
|
EventType,
|
||||||
|
type IContent,
|
||||||
|
MatrixError,
|
||||||
|
type MatrixEvent,
|
||||||
|
Direction,
|
||||||
|
type SendDelayedEventResponse,
|
||||||
|
type StateEvents,
|
||||||
|
type TimelineEvents,
|
||||||
|
MatrixClient,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export class SmallWidgetDriver extends WidgetDriver {
|
||||||
|
private allowedCapabilities: Set<Capability>;
|
||||||
|
|
||||||
|
private readonly mxClient: MatrixClient; // Store the client instance
|
||||||
|
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendEvent<K extends keyof StateEvents>(
|
||||||
|
eventType: K,
|
||||||
|
content: StateEvents[K],
|
||||||
|
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 roomId = targetRoomId || this.inRoomId;
|
||||||
|
|
||||||
|
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
|
||||||
|
|
||||||
|
let r: { event_id: string } | null;
|
||||||
|
if (stateKey !== null) {
|
||||||
|
// state event
|
||||||
|
r = await client.sendStateEvent(
|
||||||
|
roomId,
|
||||||
|
eventType as keyof StateEvents,
|
||||||
|
content as StateEvents[keyof StateEvents],
|
||||||
|
stateKey
|
||||||
|
);
|
||||||
|
} else if (eventType === EventType.RoomRedaction) {
|
||||||
|
// special case: extract the `redacts` property and call redact
|
||||||
|
r = await client.redactEvent(roomId, content.redacts);
|
||||||
|
} else {
|
||||||
|
// message event
|
||||||
|
r = await client.sendEvent(
|
||||||
|
roomId,
|
||||||
|
eventType as keyof TimelineEvents,
|
||||||
|
content as TimelineEvents[keyof TimelineEvents]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roomId, eventId: r.event_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental Part of MSC4140 & MSC4157
|
||||||
|
* @see {@link WidgetDriver#sendDelayedEvent}
|
||||||
|
*/
|
||||||
|
public async sendDelayedEvent<K extends keyof StateEvents>(
|
||||||
|
delay: number | null,
|
||||||
|
parentDelayId: string | null,
|
||||||
|
eventType: K,
|
||||||
|
content: StateEvents[K],
|
||||||
|
stateKey: string | null,
|
||||||
|
targetRoomId: string | null
|
||||||
|
): Promise<ISendDelayedEventDetails>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental Part of MSC4140 & MSC4157
|
||||||
|
*/
|
||||||
|
public async sendDelayedEvent<K extends keyof TimelineEvents>(
|
||||||
|
delay: number | null,
|
||||||
|
parentDelayId: string | null,
|
||||||
|
eventType: K,
|
||||||
|
content: TimelineEvents[K],
|
||||||
|
stateKey: null,
|
||||||
|
targetRoomId: string | null
|
||||||
|
): Promise<ISendDelayedEventDetails>;
|
||||||
|
|
||||||
|
public async sendDelayedEvent(
|
||||||
|
delay: number | null,
|
||||||
|
parentDelayId: string | null,
|
||||||
|
eventType: string,
|
||||||
|
content: IContent,
|
||||||
|
stateKey: string | null = null,
|
||||||
|
targetRoomId: string | null = null
|
||||||
|
): Promise<ISendDelayedEventDetails> {
|
||||||
|
const client = this.mxClient;
|
||||||
|
const roomId = targetRoomId || this.inRoomId;
|
||||||
|
|
||||||
|
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
|
||||||
|
|
||||||
|
let delayOpts;
|
||||||
|
if (delay !== null) {
|
||||||
|
delayOpts = {
|
||||||
|
delay,
|
||||||
|
...(parentDelayId !== null && { parent_delay_id: parentDelayId }),
|
||||||
|
};
|
||||||
|
} else if (parentDelayId !== null) {
|
||||||
|
delayOpts = {
|
||||||
|
parent_delay_id: parentDelayId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Must provide at least one of delay or parentDelayId');
|
||||||
|
}
|
||||||
|
|
||||||
|
let r: SendDelayedEventResponse | null;
|
||||||
|
if (stateKey !== null) {
|
||||||
|
// state event
|
||||||
|
r = await client._unstable_sendDelayedStateEvent(
|
||||||
|
roomId,
|
||||||
|
delayOpts,
|
||||||
|
eventType as keyof StateEvents,
|
||||||
|
content as StateEvents[keyof StateEvents],
|
||||||
|
stateKey
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// message event
|
||||||
|
r = await client._unstable_sendDelayedEvent(
|
||||||
|
roomId,
|
||||||
|
delayOpts,
|
||||||
|
null,
|
||||||
|
eventType as keyof TimelineEvents,
|
||||||
|
content as TimelineEvents[keyof TimelineEvents]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomId,
|
||||||
|
delayId: r.delay_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental Part of MSC4140 & MSC4157
|
||||||
|
*/
|
||||||
|
public async updateDelayedEvent(
|
||||||
|
delayId: string,
|
||||||
|
action: UpdateDelayedEventAction
|
||||||
|
): Promise<void> {
|
||||||
|
const client = this.mxClient;
|
||||||
|
|
||||||
|
if (!client) throw new Error('Not in a room or not attached to a client');
|
||||||
|
|
||||||
|
await client._unstable_updateDelayedEvent(delayId, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements {@link WidgetDriver#sendToDevice}
|
||||||
|
*/
|
||||||
|
public async sendToDevice(
|
||||||
|
eventType: string,
|
||||||
|
encrypted: boolean,
|
||||||
|
contentMap: { [userId: string]: { [deviceId: string]: object } }
|
||||||
|
): Promise<void> {
|
||||||
|
const client = this.mxClient;
|
||||||
|
|
||||||
|
if (encrypted) {
|
||||||
|
const crypto = client.getCrypto();
|
||||||
|
if (!crypto) throw new Error('E2EE not enabled');
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
const userContentMap = contentMap[userId];
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const deviceId of Object.keys(userContentMap)) {
|
||||||
|
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]) => {
|
||||||
|
const batch = await crypto.encryptToDeviceMessages(
|
||||||
|
eventType,
|
||||||
|
recipients,
|
||||||
|
JSON.parse(stringifiedContent)
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.queueToDevice(batch);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await client.queueToDevice({
|
||||||
|
eventType,
|
||||||
|
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
|
||||||
|
Object.entries(userContentMap).map(([deviceId, content]) => ({
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
payload: content,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads all events of the given type, and optionally `msgtype` (if applicable/defined),
|
||||||
|
* the user has access to. The widget API will have already verified that the widget is
|
||||||
|
* capable of receiving the events. Less events than the limit are allowed to be returned,
|
||||||
|
* but not more.
|
||||||
|
* @param roomId The ID of the room to look within.
|
||||||
|
* @param eventType The event type to be read.
|
||||||
|
* @param msgtype The msgtype of the events to be read, if applicable/defined.
|
||||||
|
* @param stateKey The state key of the events to be read, if applicable/defined.
|
||||||
|
* @param limit The maximum number of events to retrieve. Will be zero to denote "as many as
|
||||||
|
* possible".
|
||||||
|
* @param since When null, retrieves the number of events specified by the "limit" parameter.
|
||||||
|
* Otherwise, the event ID at which only subsequent events will be returned, as many as specified
|
||||||
|
* in "limit".
|
||||||
|
* @returns {Promise<IRoomEvent[]>} Resolves to the room events, or an empty array.
|
||||||
|
*/
|
||||||
|
public async readRoomTimeline(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
msgtype: string | undefined,
|
||||||
|
stateKey: string | undefined,
|
||||||
|
limit: number,
|
||||||
|
since: string | undefined
|
||||||
|
): Promise<IRoomEvent[]> {
|
||||||
|
limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
|
||||||
|
|
||||||
|
const room = this.mxClient.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 (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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
* @param eventType The event type of the entries to be read.
|
||||||
|
* @param stateKey The state key of the entry to be read. If undefined,
|
||||||
|
* all room state entries with a matching event type should be returned.
|
||||||
|
* @returns {Promise<IRoomEvent[]>} Resolves to the events representing the
|
||||||
|
* current values of the room state entries.
|
||||||
|
*/
|
||||||
|
public async readRoomState(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string | undefined
|
||||||
|
): Promise<IRoomEvent[]> {
|
||||||
|
const room = this.mxClient.getRoom(roomId);
|
||||||
|
if (room === null) return [];
|
||||||
|
const state = room.getLiveTimeline().getState(Direction.Forward);
|
||||||
|
if (state === undefined) return [];
|
||||||
|
|
||||||
|
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 readEventRelations(
|
||||||
|
eventId: string,
|
||||||
|
roomId?: string,
|
||||||
|
relationType?: string,
|
||||||
|
eventType?: string,
|
||||||
|
from?: string,
|
||||||
|
to?: string,
|
||||||
|
limit?: number,
|
||||||
|
direction?: 'f' | 'b'
|
||||||
|
): Promise<IReadEventRelationsResult> {
|
||||||
|
const client = this.mxClient;
|
||||||
|
const dir = direction as Direction;
|
||||||
|
roomId = roomId ?? this.inRoomId ?? undefined;
|
||||||
|
|
||||||
|
if (typeof roomId !== 'string') {
|
||||||
|
throw new Error('Error while reading the current room');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { events, nextBatch, prevBatch } = await client.relations(
|
||||||
|
roomId,
|
||||||
|
eventId,
|
||||||
|
relationType ?? null,
|
||||||
|
eventType ?? null,
|
||||||
|
{ from, to, limit, dir }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
|
||||||
|
nextBatch: nextBatch ?? undefined,
|
||||||
|
prevBatch: prevBatch ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchUserDirectory(
|
||||||
|
searchTerm: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<ISearchUserDirectoryResult> {
|
||||||
|
const client = this.mxClient;
|
||||||
|
|
||||||
|
const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit });
|
||||||
|
|
||||||
|
return {
|
||||||
|
limited,
|
||||||
|
results: results.map((r) => ({
|
||||||
|
userId: r.user_id,
|
||||||
|
displayName: r.display_name,
|
||||||
|
avatarUrl: r.avatar_url,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMediaConfig(): Promise<IGetMediaConfigResult> {
|
||||||
|
const client = this.mxClient;
|
||||||
|
|
||||||
|
return await client.getMediaConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
|
||||||
|
const client = this.mxClient;
|
||||||
|
|
||||||
|
const uploadResult = await client.uploadContent(file);
|
||||||
|
|
||||||
|
return { contentUri: uploadResult.content_uri };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from the media repository on the homeserver.
|
||||||
|
*
|
||||||
|
* @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 };
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the IDs of all joined or invited rooms currently known to the
|
||||||
|
* client.
|
||||||
|
* @returns The room IDs.
|
||||||
|
*/
|
||||||
|
public getKnownRooms(): string[] {
|
||||||
|
return this.mxClient.getVisibleRooms().map((r) => r.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expresses a {@link MatrixError} as a JSON payload
|
||||||
|
* for use by Widget API error responses.
|
||||||
|
* @param error The error to handle.
|
||||||
|
* @returns The error expressed as a JSON payload,
|
||||||
|
* or undefined if it is not a {@link MatrixError}.
|
||||||
|
*/
|
||||||
|
public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined {
|
||||||
|
return error instanceof MatrixError
|
||||||
|
? { matrix_api_error: error.asWidgetApiErrorData() }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/app/features/room-nav/RoomCallNavStatus.tsx
Normal file
115
src/app/features/room-nav/RoomCallNavStatus.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { Box, Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
|
import React from 'react';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
|
export function CallNavStatus() {
|
||||||
|
const {
|
||||||
|
activeCallRoomId,
|
||||||
|
isAudioEnabled,
|
||||||
|
isVideoEnabled,
|
||||||
|
isCallActive,
|
||||||
|
toggleAudio,
|
||||||
|
toggleVideo,
|
||||||
|
hangUp,
|
||||||
|
} = useCallState();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const handleGoToCallRoom = () => {
|
||||||
|
if (activeCallRoomId) {
|
||||||
|
navigateRoom(activeCallRoomId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!isCallActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderTop: `1px solid #e0e0e0`,
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box direction="Row" style={{ justifyContent: 'center' }}>
|
||||||
|
{/* Going to need better icons for this */}
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{!isAudioEnabled ? 'Unmute' : 'Mute'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton variant="Background" 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 variant="Background" ref={triggerRef} onClick={toggleVideo}>
|
||||||
|
<Icon src={!isVideoEnabled ? Icons.VideoCameraMute : Icons.VideoCamera} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Hang Up</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton variant="Background" ref={triggerRef} onClick={hangUp}>
|
||||||
|
<Icon src={Icons.Phone} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<Box grow="Yes" justifyContent="Center" alignItems="Center">
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Go to Room</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Chip
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
fill="Soft"
|
||||||
|
as="button"
|
||||||
|
onClick={handleGoToCallRoom}
|
||||||
|
ref={triggerRef}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
{mx.getRoom(activeCallRoomId)?.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
import React, { MouseEventHandler, forwardRef, useState, MouseEvent } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|
@ -16,10 +16,13 @@ import {
|
||||||
RectCords,
|
RectCords,
|
||||||
Badge,
|
Badge,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useFocusWithin, useHover } from 'react-aria';
|
import { useFocusWithin, useHover } from 'react-aria';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { NavButton, NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||||
|
|
@ -49,6 +52,11 @@ import {
|
||||||
RoomNotificationMode,
|
RoomNotificationMode,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
|
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';
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -193,6 +201,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
RoomNavItemMenu.displayName = 'RoomNavItemMenu';
|
||||||
|
|
||||||
type RoomNavItemProps = {
|
type RoomNavItemProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -217,9 +226,24 @@ export function RoomNavItem({
|
||||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
|
const {
|
||||||
|
isCallActive,
|
||||||
|
activeCallRoomId,
|
||||||
|
setActiveCallRoomId,
|
||||||
|
setViewedCallRoomId,
|
||||||
|
isChatOpen,
|
||||||
|
toggleChat,
|
||||||
|
hangUp,
|
||||||
|
} = useCallState();
|
||||||
const typingMember = useRoomTypingMember(room.roomId).filter(
|
const typingMember = useRoomTypingMember(room.roomId).filter(
|
||||||
(receipt) => receipt.userId !== mx.getUserId()
|
(receipt) => receipt.userId !== mx.getUserId()
|
||||||
);
|
);
|
||||||
|
const isActiveCall = isCallActive && activeCallRoomId === room.roomId;
|
||||||
|
const callMemberships = useCallMembers(mx, room.roomId);
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const { roomIdOrAlias: viewedRoomId } = useParams();
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
@ -235,9 +259,60 @@ export function RoomNavItem({
|
||||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavItemClick: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
const target = evt.target as HTMLElement;
|
||||||
|
const chatButton = (evt.currentTarget as HTMLElement).querySelector(
|
||||||
|
'[data-testid="chat-button"]'
|
||||||
|
);
|
||||||
|
if (chatButton && chatButton.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (room.isCallRoom()) {
|
||||||
|
if (!isMobile) {
|
||||||
|
if (activeCallRoomId !== room.roomId) {
|
||||||
|
if (mx.getRoom(viewedRoomId)?.isCallRoom()) {
|
||||||
|
navigateRoom(room.roomId);
|
||||||
|
}
|
||||||
|
hangUp(room.roomId);
|
||||||
|
setActiveCallRoomId(room.roomId);
|
||||||
|
} else {
|
||||||
|
navigateRoom(room.roomId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
evt.stopPropagation();
|
||||||
|
if (isChatOpen) toggleChat();
|
||||||
|
setViewedCallRoomId(room.roomId);
|
||||||
|
navigateRoom(room.roomId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigateRoom(room.roomId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChatButtonClick = (evt: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
if (!isChatOpen) toggleChat();
|
||||||
|
setViewedCallRoomId(room.roomId);
|
||||||
|
};
|
||||||
|
|
||||||
const optionsVisible = hover || !!menuAnchor;
|
const optionsVisible = hover || !!menuAnchor;
|
||||||
|
const ariaLabel = [
|
||||||
|
room.name,
|
||||||
|
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 (
|
return (
|
||||||
|
<Box direction="Column" grow="Yes">
|
||||||
<NavItem
|
<NavItem
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="400"
|
radii="400"
|
||||||
|
|
@ -248,7 +323,7 @@ export function RoomNavItem({
|
||||||
{...hoverProps}
|
{...hoverProps}
|
||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
>
|
>
|
||||||
<NavLink to={linkPath}>
|
<NavButton onClick={handleNavItemClick} aria-label={ariaLabel}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<Avatar size="200" radii="400">
|
<Avatar size="200" radii="400">
|
||||||
|
|
@ -269,15 +344,23 @@ export function RoomNavItem({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
style={{
|
||||||
filled={selected}
|
opacity: unread || isActiveCall ? config.opacity.P500 : config.opacity.P300,
|
||||||
|
}}
|
||||||
|
filled={selected || isActiveCall}
|
||||||
size="100"
|
size="100"
|
||||||
joinRule={room.getJoinRule()}
|
joinRule={room.getJoinRule()}
|
||||||
|
call={room.isCallRoom()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" grow="Yes">
|
<Box as="span" grow="Yes">
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text
|
||||||
|
priority={unread || isActiveCall ? '500' : '300'}
|
||||||
|
as="span"
|
||||||
|
size="Inherit"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
{room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -292,14 +375,19 @@ export function RoomNavItem({
|
||||||
</UnreadBadgeCenter>
|
</UnreadBadgeCenter>
|
||||||
)}
|
)}
|
||||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||||
<Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} />
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={getRoomNotificationModeIcon(notificationMode)}
|
||||||
|
aria-label={notificationMode}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</NavItemContent>
|
</NavItemContent>
|
||||||
</NavLink>
|
|
||||||
{optionsVisible && (
|
{optionsVisible && (
|
||||||
<NavItemOptions>
|
<NavItemOptions>
|
||||||
<PopOut
|
<PopOut
|
||||||
|
id={`menu-${room.roomId}`}
|
||||||
|
aria-expanded={!!menuAnchor}
|
||||||
anchor={menuAnchor}
|
anchor={menuAnchor}
|
||||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||||
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
||||||
|
|
@ -325,9 +413,40 @@ export function RoomNavItem({
|
||||||
</FocusTrap>
|
</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"
|
||||||
|
>
|
||||||
|
<NavLink to={linkPath}>
|
||||||
|
<Icon size="50" src={Icons.Message} />
|
||||||
|
</NavLink>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleOpenMenu}
|
onClick={handleOpenMenu}
|
||||||
aria-pressed={!!menuAnchor}
|
aria-pressed={!!menuAnchor}
|
||||||
|
aria-controls={`menu-${room.roomId}`}
|
||||||
|
aria-label="More Options"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
fill="None"
|
fill="None"
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -338,6 +457,15 @@ export function RoomNavItem({
|
||||||
</PopOut>
|
</PopOut>
|
||||||
</NavItemOptions>
|
</NavItemOptions>
|
||||||
)}
|
)}
|
||||||
|
</NavButton>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
{room.isCallRoom() && (
|
||||||
|
<Box direction="Column" style={{ paddingLeft: config.space.S200 }}>
|
||||||
|
{callMemberships.map((callMembership) => (
|
||||||
|
<RoomNavUser room={room} callMembership={callMembership} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
165
src/app/features/room-nav/RoomNavUser.tsx
Normal file
165
src/app/features/room-nav/RoomNavUser.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useFocusWithin, useHover } from 'react-aria';
|
||||||
|
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
|
import { NavItem, NavItemContent, NavItemOptions } 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 { openProfileViewer } from '../../../client/action/navigation';
|
||||||
|
|
||||||
|
type RoomNavUserProps = {
|
||||||
|
room: Room;
|
||||||
|
callMembership: CallMembership;
|
||||||
|
};
|
||||||
|
export function RoomNavUser({ room, callMembership }: RoomNavUserProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const [navUserExpanded, setNavUserExpanded] = useState(false);
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||||
|
const { focusWithinProps } = useFocusWithin({
|
||||||
|
onFocusWithinChange: (isFocused) => {
|
||||||
|
setHover(isFocused);
|
||||||
|
if (!isFocused) setNavUserExpanded(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { isCallActive, activeCallRoomId } = useCallState();
|
||||||
|
const isActiveCall = isCallActive && 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 = () => {
|
||||||
|
if (isCallParticipant) {
|
||||||
|
setNavUserExpanded((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickUser = () => {
|
||||||
|
openProfileViewer(userId, room.roomId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// PLACEHOLDER
|
||||||
|
const [userMuted, setUserMuted] = useState(false);
|
||||||
|
const handleToggleMute = () => {
|
||||||
|
setUserMuted(!userMuted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionsVisible = (hover || userMuted || navUserExpanded) && isCallParticipant && false; // Disable until individual volume control and mute have been added
|
||||||
|
const ariaLabel = isCallParticipant
|
||||||
|
? `Call Participant: ${getName}${userMuted ? ', Muted' : ''}`
|
||||||
|
: getName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavItem
|
||||||
|
tabIndex={0}
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
style={{ paddingTop: config.space.S200, paddingBottom: config.space.S200 }}
|
||||||
|
{...hoverProps}
|
||||||
|
{...focusWithinProps}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<NavItemContent onClick={handleNavUserClick}>
|
||||||
|
<Box direction="Column" grow="Yes" gap="200" justifyContent="Stretch">
|
||||||
|
<Box as="span" 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
|
||||||
|
size="B400"
|
||||||
|
priority="300"
|
||||||
|
// Set priority based on if talking
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
{getName}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{navUserExpanded && (
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
{/* Slider here, when implemented into folds */}
|
||||||
|
<Text>---- THIS IS A SLIDER ---</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
{optionsVisible && (
|
||||||
|
<NavItemOptions direction="Column" justifyContent="SpaceBetween">
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{userMuted ? 'Unmute' : 'Mute'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={handleToggleMute}
|
||||||
|
aria-pressed={userMuted}
|
||||||
|
aria-label={userMuted ? `Unmute ${getName}` : `Mute ${getName}`}
|
||||||
|
variant={userMuted ? 'Critical' : 'Background'}
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={userMuted ? Icons.VolumeMute : Icons.VolumeHigh} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
{navUserExpanded && (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>View Profile</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={handleClickUser}
|
||||||
|
aria-label="View Profile"
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.User} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</NavItemOptions>
|
||||||
|
)}
|
||||||
|
</NavItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable no-nested-ternary */
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Box, Line } from 'folds';
|
import { Box, Line } from 'folds';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
@ -13,6 +14,9 @@ import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { markAsRead } from '../../../client/action/notifications';
|
import { markAsRead } from '../../../client/action/notifications';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
|
import { CallView } from '../call/CallView';
|
||||||
|
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||||
|
import { RoomViewHeader } from './RoomViewHeader';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
|
|
@ -21,9 +25,10 @@ export function Room() {
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const { isChatOpen } = useCallState();
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room?.roomId);
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
|
|
@ -39,15 +44,49 @@ export function Room() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{room.isCallRoom() && <RoomViewHeader />}
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CallView room={room} />
|
||||||
|
{(!room.isCallRoom() || isChatOpen) && (
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
style={{
|
||||||
|
width: room.isCallRoom() ? (isChatOpen ? '40%' : '0%') : '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: '#fff' }}>
|
||||||
<RoomView room={room} eventId={eventId} />
|
<RoomView room={room} eventId={eventId} />
|
||||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{screenSize === ScreenSize.Desktop && !room.isCallRoom() && isDrawer && (
|
||||||
<>
|
<>
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
</PowerLevelsContextProvider>
|
</PowerLevelsContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback, useRef } from 'react';
|
||||||
import { Box, Text, config } from 'folds';
|
import { Box, Text, config } from 'folds'; // Assuming 'folds' is a UI library
|
||||||
import { EventType, Room } from 'matrix-js-sdk';
|
import { EventType, Room } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
|
@ -25,16 +25,20 @@ import { useAccessibleTagColors, usePowerLevelTags } from '../../hooks/usePowerL
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const FN_KEYS_REGEX = /^F\d+$/;
|
const FN_KEYS_REGEX = /^F\d+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a keyboard event should trigger focusing the message input field.
|
||||||
|
* @param evt - The KeyboardEvent.
|
||||||
|
* @returns True if the input should be focused, false otherwise.
|
||||||
|
*/
|
||||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
const { code } = evt;
|
const { code } = evt;
|
||||||
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// do not focus on F keys
|
|
||||||
if (FN_KEYS_REGEX.test(code)) return false;
|
if (FN_KEYS_REGEX.test(code)) return false;
|
||||||
|
|
||||||
// do not focus on numlock/scroll lock
|
|
||||||
if (
|
if (
|
||||||
code.startsWith('OS') ||
|
code.startsWith('OS') ||
|
||||||
code.startsWith('Meta') ||
|
code.startsWith('Meta') ||
|
||||||
|
|
@ -53,21 +57,16 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
|
||||||
const { roomId } = room;
|
const { roomId } = room;
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const { getPowerLevel, canSendEvent } = usePowerLevelsAPI(powerLevels);
|
const { getPowerLevel, canSendEvent } = usePowerLevelsAPI(powerLevels);
|
||||||
|
|
@ -75,7 +74,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||||
const canMessage = myUserId
|
const canMessage = myUserId
|
||||||
? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
|
? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
|
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
|
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
|
||||||
|
|
@ -85,15 +83,15 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
if (editableActiveElement()) return;
|
if (editableActiveElement()) return;
|
||||||
if (
|
if (document.querySelector('.ReactModalPortal > *') || navigation.isRawModalVisible) {
|
||||||
document.body.lastElementChild?.className !== 'ReactModalPortal' ||
|
|
||||||
navigation.isRawModalVisible
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {
|
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {
|
||||||
|
if (editor) {
|
||||||
ReactEditor.focus(editor);
|
ReactEditor.focus(editor);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
@ -101,8 +99,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef}>
|
<Page ref={roomViewRef}>
|
||||||
<RoomViewHeader />
|
{!room.isCallRoom() && <RoomViewHeader />}
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
<RoomTimeline
|
<RoomTimeline
|
||||||
key={roomId}
|
key={roomId}
|
||||||
room={room}
|
room={room}
|
||||||
|
|
@ -116,6 +114,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" direction="Column">
|
<Box shrink="No" direction="Column">
|
||||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||||
|
{' '}
|
||||||
{tombstoneEvent ? (
|
{tombstoneEvent ? (
|
||||||
<RoomTombstone
|
<RoomTombstone
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
|
|
@ -124,7 +123,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{canMessage && (
|
{canMessage ? (
|
||||||
<RoomInput
|
<RoomInput
|
||||||
room={room}
|
room={room}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
|
|
@ -134,8 +133,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||||
getPowerLevelTag={getPowerLevelTag}
|
getPowerLevelTag={getPowerLevelTag}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
{!canMessage && (
|
|
||||||
<RoomInputPlaceholder
|
<RoomInputPlaceholder
|
||||||
style={{ padding: config.space.S200 }}
|
style={{ padding: config.space.S200 }}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ import {
|
||||||
getRoomNotificationModeIcon,
|
getRoomNotificationModeIcon,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -220,6 +221,7 @@ export function RoomViewHeader() {
|
||||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
|
||||||
|
const { isChatOpen, toggleChat } = useCallState();
|
||||||
const pinnedEvents = useRoomPinnedEvents(room);
|
const pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||||
const ecryptedRoom = !!encryptionEvent;
|
const ecryptedRoom = !!encryptionEvent;
|
||||||
|
|
@ -232,6 +234,24 @@ export function RoomViewHeader() {
|
||||||
|
|
||||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
|
||||||
|
// I assume there is a global state so I don't have to run this check every time but for now we'll stub this in
|
||||||
|
const isDirectMessage = () => {
|
||||||
|
const mDirectsEvent = mx.getAccountData('m.direct');
|
||||||
|
if (mDirectsEvent?.event?.content === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { roomId } = room;
|
||||||
|
return (
|
||||||
|
Object.values(mDirectsEvent?.event?.content).filter((e) => {
|
||||||
|
if (e.indexOf(roomId) === 0) return true;
|
||||||
|
}).length !== 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCall: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
const searchParams: _SearchPathSearchParams = {
|
const searchParams: _SearchPathSearchParams = {
|
||||||
rooms: room.roomId,
|
rooms: room.roomId,
|
||||||
|
|
@ -324,8 +344,28 @@ export function RoomViewHeader() {
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
{!ecryptedRoom && (
|
{false && isDirectMessage() && (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Start a Call</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton onClick={handleCall} ref={triggerRef}>
|
||||||
|
<Icon size="400" src={Icons.Phone} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!ecryptedRoom && (!room.isCallRoom() || isChatOpen) && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
|
|
@ -342,6 +382,7 @@ export function RoomViewHeader() {
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
{(!room.isCallRoom() || isChatOpen) && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
|
|
@ -379,6 +420,8 @@ export function RoomViewHeader() {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{(!room.isCallRoom() || isChatOpen) && (
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={pinMenuAnchor}
|
anchor={pinMenuAnchor}
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
|
|
@ -398,7 +441,9 @@ export function RoomViewHeader() {
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
)}
|
||||||
|
|
||||||
|
{!room.isCallRoom() && screenSize === ScreenSize.Desktop && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
|
|
@ -415,6 +460,25 @@ export function RoomViewHeader() {
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{room.isCallRoom() && !isDirectMessage() && (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Chat</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton ref={triggerRef} onClick={toggleChat}>
|
||||||
|
<Icon size="400" src={Icons.Message} filled={isChatOpen} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="End"
|
align="End"
|
||||||
|
|
|
||||||
28
src/app/hooks/useCallMemberships.ts
Normal file
28
src/app/hooks/useCallMemberships.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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);
|
||||||
|
const mxr = mx.matrixRTC.getRoomSession(room);
|
||||||
|
useEffect(() => {
|
||||||
|
const updateMemberships = () => {
|
||||||
|
if (!room?.isCallRoom()) return;
|
||||||
|
setMemberships(MatrixRTCSession.callMembershipsForRoom(room));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMemberships();
|
||||||
|
|
||||||
|
mxr.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
|
||||||
|
return () => {
|
||||||
|
mxr.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
|
||||||
|
};
|
||||||
|
}, [mx, mxr, room, roomId]);
|
||||||
|
|
||||||
|
return memberships;
|
||||||
|
};
|
||||||
|
|
@ -9,6 +9,7 @@ export type ClientConfig = {
|
||||||
defaultHomeserver?: number;
|
defaultHomeserver?: number;
|
||||||
homeserverList?: string[];
|
homeserverList?: string[];
|
||||||
allowCustomHomeservers?: boolean;
|
allowCustomHomeservers?: boolean;
|
||||||
|
elementCallUrl?: string;
|
||||||
|
|
||||||
featuredCommunities?: {
|
featuredCommunities?: {
|
||||||
openAsDefault?: boolean;
|
openAsDefault?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,35 @@
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useForceUpdate } from './useForceUpdate';
|
import { useForceUpdate } from './useForceUpdate';
|
||||||
import { useStateEventCallback } from './useStateEventCallback';
|
import { useStateEventCallback } from './useStateEventCallback';
|
||||||
import { getStateEvents } from '../utils/room';
|
|
||||||
|
|
||||||
export const useStateEvents = (room: Room, eventType: StateEvent) => {
|
export const useStateEvents = (rooms: Room[], eventType: StateEvent): number => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const [updateCount, forceUpdate] = useForceUpdate();
|
const [updateCount, forceUpdate] = useForceUpdate();
|
||||||
|
|
||||||
useStateEventCallback(
|
const relevantRoomIds = useMemo(() => {
|
||||||
room.client,
|
const ids = new Set<string>();
|
||||||
useCallback(
|
if (rooms && Array.isArray(rooms)) {
|
||||||
(event) => {
|
rooms.forEach((room) => {
|
||||||
if (event.getRoomId() === room.roomId && event.getType() === eventType) {
|
if (room?.roomId) {
|
||||||
|
ids.add(room.roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [rooms]);
|
||||||
|
const handleEventCallback = useCallback(
|
||||||
|
(event: MatrixEvent) => {
|
||||||
|
const eventRoomId = event.getRoomId();
|
||||||
|
if (eventRoomId && event.getType() === eventType && relevantRoomIds.has(eventRoomId)) {
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[room, eventType, forceUpdate]
|
[eventType, relevantRoomIds, forceUpdate]
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => getStateEvents(room, eventType),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[room, eventType, updateCount]
|
|
||||||
);
|
);
|
||||||
|
useStateEventCallback(mx, handleEventCallback);
|
||||||
|
return updateCount;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
const [isValidAddress, setIsValidAddress] = useState(null);
|
const [isValidAddress, setIsValidAddress] = useState(null);
|
||||||
const [addressValue, setAddressValue] = useState(undefined);
|
const [addressValue, setAddressValue] = useState(undefined);
|
||||||
const [roleIndex, setRoleIndex] = useState(0);
|
const [roleIndex, setRoleIndex] = useState(0);
|
||||||
|
const [roomType, setRoomType] = useState(0);
|
||||||
|
|
||||||
const addressRef = useRef(null);
|
const addressRef = useRef(null);
|
||||||
|
|
||||||
|
|
@ -75,6 +76,7 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
joinRule,
|
joinRule,
|
||||||
alias: roomAlias,
|
alias: roomAlias,
|
||||||
isEncrypted: isSpace || joinRule === 'public' ? false : isEncrypted,
|
isEncrypted: isSpace || joinRule === 'public' ? false : isEncrypted,
|
||||||
|
roomType,
|
||||||
powerLevel,
|
powerLevel,
|
||||||
isSpace,
|
isSpace,
|
||||||
parentId,
|
parentId,
|
||||||
|
|
@ -218,6 +220,19 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||||
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
|
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{!isSpace && (
|
||||||
|
<SettingTile
|
||||||
|
title="Room type"
|
||||||
|
options={
|
||||||
|
<SegmentControl
|
||||||
|
selected={roomType}
|
||||||
|
segments={[{ text: 'Text' }, { text: 'Call' }]}
|
||||||
|
onSelect={setRoomType}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
content={<Text variant="b3">Select the type of room.</Text>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
||||||
<div className="create-room__name-wrapper">
|
<div className="create-room__name-wrapper">
|
||||||
<Input name="name" label={`${isSpace ? 'Space' : 'Room'} name`} required />
|
<Input name="name" label={`${isSpace ? 'Space' : 'Room'} name`} required />
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
|
||||||
import { RoomSettingsRenderer } from '../features/room-settings';
|
import { RoomSettingsRenderer } from '../features/room-settings';
|
||||||
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
|
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
|
||||||
import { SpaceSettingsRenderer } from '../features/space-settings';
|
import { SpaceSettingsRenderer } from '../features/space-settings';
|
||||||
|
import { CallProvider } from './client/call/CallProvider';
|
||||||
|
import { PersistentCallContainer } from './client/call/PersistentCallContainer';
|
||||||
|
|
||||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||||
const { hashRouter } = clientConfig;
|
const { hashRouter } = clientConfig;
|
||||||
|
|
@ -116,6 +118,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
<ClientRoomsNotificationPreferences>
|
<ClientRoomsNotificationPreferences>
|
||||||
<ClientBindAtoms>
|
<ClientBindAtoms>
|
||||||
<ClientNonUIFeatures>
|
<ClientNonUIFeatures>
|
||||||
|
<CallProvider>
|
||||||
<ClientLayout
|
<ClientLayout
|
||||||
nav={
|
nav={
|
||||||
<MobileFriendlyClientNav>
|
<MobileFriendlyClientNav>
|
||||||
|
|
@ -123,8 +126,11 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
</MobileFriendlyClientNav>
|
</MobileFriendlyClientNav>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<PersistentCallContainer>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</PersistentCallContainer>
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
|
</CallProvider>
|
||||||
<RoomSettingsRenderer />
|
<RoomSettingsRenderer />
|
||||||
<SpaceSettingsRenderer />
|
<SpaceSettingsRenderer />
|
||||||
<ReceiveSelfDeviceVerification />
|
<ReceiveSelfDeviceVerification />
|
||||||
|
|
|
||||||
536
src/app/pages/client/call/CallProvider.tsx
Normal file
536
src/app/pages/client/call/CallProvider.tsx
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useState,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
} from 'react';
|
||||||
|
import { logger } from 'matrix-js-sdk/lib/logger';
|
||||||
|
import { WidgetApiToWidgetAction, WidgetApiAction, ClientWidgetApi } 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: (room: string) => void;
|
||||||
|
activeClientWidgetApi: ClientWidgetApi | null;
|
||||||
|
activeClientWidget: SmallWidget | null;
|
||||||
|
registerActiveClientWidgetApi: (
|
||||||
|
roomId: string | null,
|
||||||
|
clientWidgetApi: ClientWidgetApi | null,
|
||||||
|
clientWidget: SmallWidget
|
||||||
|
) => void;
|
||||||
|
viewedClientWidgetApi: ClientWidgetApi | null;
|
||||||
|
viewedClientWidget: SmallWidget | null;
|
||||||
|
registerViewedClientWidgetApi: (
|
||||||
|
roomId: string | null,
|
||||||
|
clientWidgetApi: ClientWidgetApi | null,
|
||||||
|
clientWidget: SmallWidget
|
||||||
|
) => void;
|
||||||
|
sendWidgetAction: <T = unknown>(
|
||||||
|
action: WidgetApiToWidgetAction | string,
|
||||||
|
data: T
|
||||||
|
) => Promise<void>;
|
||||||
|
isAudioEnabled: boolean;
|
||||||
|
isVideoEnabled: boolean;
|
||||||
|
isChatOpen: boolean;
|
||||||
|
isCallActive: boolean;
|
||||||
|
isPrimaryIframe: boolean;
|
||||||
|
toggleAudio: () => Promise<void>;
|
||||||
|
toggleVideo: () => Promise<void>;
|
||||||
|
toggleChat: () => Promise<void>;
|
||||||
|
toggleIframe: () => 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;
|
||||||
|
const DEFAULT_CALL_ACTIVE = false;
|
||||||
|
const DEFAULT_PRIMARY_IFRAME = true;
|
||||||
|
|
||||||
|
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 [viewedClientWidgetApi, setViewedClientWidgetApiState] = useState<ClientWidgetApi | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [viewedClientWidget, setViewedClientWidget] = useState<SmallWidget | null>(null);
|
||||||
|
const [viewedClientWidgetApiRoomId, setViewedClientWidgetApiRoomId] = useState<string | 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 [isCallActive, setIsCallActive] = useState<boolean>(DEFAULT_CALL_ACTIVE);
|
||||||
|
const [isPrimaryIframe, setIsPrimaryIframe] = useState<boolean>(DEFAULT_PRIMARY_IFRAME);
|
||||||
|
|
||||||
|
const { roomIdOrAlias: viewedRoomId } = useParams<{ roomIdOrAlias: string }>();
|
||||||
|
const [lastViewedRoomDuringCall, setLastViewedRoomDuringCall] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resetMediaState = useCallback(() => {
|
||||||
|
logger.debug('CallContext: Resetting media state to defaults.');
|
||||||
|
setIsAudioEnabledState(DEFAULT_AUDIO_ENABLED);
|
||||||
|
setIsVideoEnabledState(DEFAULT_VIDEO_ENABLED);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActiveCallRoomId = useCallback(
|
||||||
|
(roomId: string | null) => {
|
||||||
|
logger.warn(`CallContext: Setting activeCallRoomId to ${roomId}`);
|
||||||
|
const previousRoomId = activeCallRoomId;
|
||||||
|
setActiveCallRoomIdState(roomId);
|
||||||
|
|
||||||
|
if (roomId !== previousRoomId) {
|
||||||
|
logger.debug(`CallContext: Active call room changed, resetting media state.`);
|
||||||
|
resetMediaState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomId === null || roomId !== activeClientWidgetApiRoomId) {
|
||||||
|
logger.warn(
|
||||||
|
`CallContext: Clearing active clientWidgetApi because active room changed to ${roomId} or was cleared.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeClientWidgetApiRoomId, resetMediaState, activeCallRoomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setViewedCallRoomId = useCallback(
|
||||||
|
(roomId: string | null) => {
|
||||||
|
logger.warn(`CallContext: Setting activeCallRoomId to ${roomId}`);
|
||||||
|
setViewedCallRoomIdState(roomId);
|
||||||
|
},
|
||||||
|
[setViewedCallRoomIdState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setActiveClientWidgetApi = useCallback(
|
||||||
|
(
|
||||||
|
clientWidgetApi: ClientWidgetApi | null,
|
||||||
|
clientWidget: SmallWidget | null,
|
||||||
|
roomId: string | null
|
||||||
|
) => {
|
||||||
|
setActiveClientWidgetApiState(clientWidgetApi);
|
||||||
|
setActiveClientWidget(clientWidget);
|
||||||
|
setActiveClientWidgetApiRoomId(roomId);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const registerActiveClientWidgetApi = useCallback(
|
||||||
|
(
|
||||||
|
roomId: string | null,
|
||||||
|
clientWidgetApi: ClientWidgetApi | null,
|
||||||
|
clientWidget: SmallWidget | null
|
||||||
|
) => {
|
||||||
|
if (activeClientWidgetApi && activeClientWidgetApi !== clientWidgetApi) {
|
||||||
|
logger.debug(`CallContext: Cleaning up listeners for previous clientWidgetApi instance.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomId && clientWidgetApi) {
|
||||||
|
logger.debug(`CallContext: Registering active clientWidgetApi for room ${roomId}.`);
|
||||||
|
setActiveClientWidgetApi(clientWidgetApi, clientWidget, roomId);
|
||||||
|
} else if (roomId === activeClientWidgetApiRoomId || roomId === null) {
|
||||||
|
setActiveClientWidgetApi(null, null, null);
|
||||||
|
resetMediaState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeClientWidgetApi, activeClientWidgetApiRoomId, setActiveClientWidgetApi, resetMediaState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setViewedClientWidgetApi = useCallback(
|
||||||
|
(
|
||||||
|
clientWidgetApi: ClientWidgetApi | null,
|
||||||
|
clientWidget: SmallWidget | null,
|
||||||
|
roomId: string | null
|
||||||
|
) => {
|
||||||
|
setViewedClientWidgetApiState(clientWidgetApi);
|
||||||
|
setViewedClientWidget(clientWidget);
|
||||||
|
setViewedClientWidgetApiRoomId(roomId);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const registerViewedClientWidgetApi = useCallback(
|
||||||
|
(
|
||||||
|
roomId: string | null,
|
||||||
|
clientWidgetApi: ClientWidgetApi | null,
|
||||||
|
clientWidget: SmallWidget | null
|
||||||
|
) => {
|
||||||
|
if (viewedClientWidgetApi && viewedClientWidgetApi !== clientWidgetApi) {
|
||||||
|
logger.debug(`CallContext: Cleaning up listeners for previous clientWidgetApi instance.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomId && clientWidgetApi) {
|
||||||
|
logger.debug(`CallContext: Registering viewed clientWidgetApi for room ${roomId}.`);
|
||||||
|
setViewedClientWidgetApi(clientWidgetApi, clientWidget, roomId);
|
||||||
|
} else if (roomId === viewedClientWidgetApiRoomId || roomId === null) {
|
||||||
|
logger.debug(
|
||||||
|
`CallContext: Clearing viewed clientWidgetApi for room ${viewedClientWidgetApiRoomId}.`
|
||||||
|
);
|
||||||
|
setViewedClientWidgetApi(null, null, null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[viewedClientWidgetApi, viewedClientWidgetApiRoomId, setViewedClientWidgetApi]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hangUp = useCallback(
|
||||||
|
(nextRoom: string) => {
|
||||||
|
if (typeof nextRoom === 'string') {
|
||||||
|
logger.debug('1 Hangup');
|
||||||
|
setActiveClientWidgetApi(null, null, null);
|
||||||
|
setActiveCallRoomIdState(null);
|
||||||
|
activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {});
|
||||||
|
} else if (viewedRoomId !== activeCallRoomId) {
|
||||||
|
logger.debug('2 Hangup');
|
||||||
|
setActiveClientWidgetApi(null, null, null);
|
||||||
|
setActiveCallRoomIdState(null);
|
||||||
|
activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {});
|
||||||
|
} else if (activeClientWidget) {
|
||||||
|
logger.debug('3 Hangup');
|
||||||
|
const iframeDoc =
|
||||||
|
activeClientWidget?.iframe?.contentDocument ||
|
||||||
|
activeClientWidget?.iframe?.contentWindow.document;
|
||||||
|
const button = iframeDoc.querySelector('[data-testid="incall_leave"]');
|
||||||
|
button.click();
|
||||||
|
}
|
||||||
|
setIsCallActive(false);
|
||||||
|
|
||||||
|
logger.debug(`CallContext: Hang up called.`);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeCallRoomId,
|
||||||
|
activeClientWidget,
|
||||||
|
activeClientWidgetApi?.transport,
|
||||||
|
setActiveClientWidgetApi,
|
||||||
|
viewedRoomId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeCallRoomId && !viewedCallRoomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lastViewedRoomDuringCall) {
|
||||||
|
if (activeCallRoomId)
|
||||||
|
setLastViewedRoomDuringCall((prevLastRoom) => prevLastRoom || activeCallRoomId);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lastViewedRoomDuringCall &&
|
||||||
|
lastViewedRoomDuringCall !== viewedRoomId &&
|
||||||
|
activeCallRoomId &&
|
||||||
|
isCallActive
|
||||||
|
) {
|
||||||
|
setLastViewedRoomDuringCall(activeCallRoomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHangup = (ev: CustomEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (isCallActive && ev.detail.widgetId === activeClientWidgetApi?.widget.id) {
|
||||||
|
activeClientWidgetApi?.transport.reply(ev.detail, {});
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`CallContext: Received hangup action from widget in room ${activeCallRoomId}.`,
|
||||||
|
ev
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMediaStateUpdate = (ev: CustomEvent<MediaStatePayload>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
logger.debug(
|
||||||
|
`CallContext: Received media state update from widget in room ${activeCallRoomId}:`,
|
||||||
|
ev.detail
|
||||||
|
);
|
||||||
|
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
const { audio_enabled, video_enabled } = ev.detail.data ?? {};
|
||||||
|
|
||||||
|
if (typeof audio_enabled === 'boolean' && audio_enabled !== isAudioEnabled) {
|
||||||
|
logger.debug(`CallContext: Updating audio enabled state from widget: ${audio_enabled}`);
|
||||||
|
setIsAudioEnabledState(audio_enabled);
|
||||||
|
}
|
||||||
|
if (typeof video_enabled === 'boolean' && video_enabled !== isVideoEnabled) {
|
||||||
|
logger.debug(`CallContext: Updating video enabled state from widget: ${video_enabled}`);
|
||||||
|
setIsVideoEnabledState(video_enabled);
|
||||||
|
}
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnScreenStateUpdate = (ev: CustomEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (isPrimaryIframe) {
|
||||||
|
activeClientWidgetApi?.transport.reply(ev.detail, {});
|
||||||
|
} else {
|
||||||
|
viewedClientWidgetApi?.transport.reply(ev.detail, {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnTileLayout = (ev: CustomEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (isPrimaryIframe) {
|
||||||
|
activeClientWidgetApi?.transport.reply(ev.detail, {});
|
||||||
|
} else {
|
||||||
|
viewedClientWidgetApi?.transport.reply(ev.detail, {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = (ev: CustomEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const setViewedAsActive = () => {
|
||||||
|
if (viewedCallRoomId !== activeCallRoomId) setIsPrimaryIframe(!isPrimaryIframe);
|
||||||
|
setActiveClientWidgetApi(viewedClientWidgetApi, viewedClientWidget, viewedCallRoomId);
|
||||||
|
setActiveCallRoomIdState(viewedCallRoomId);
|
||||||
|
setIsCallActive(true);
|
||||||
|
const iframeDoc =
|
||||||
|
viewedClientWidget?.iframe?.contentDocument ||
|
||||||
|
viewedClientWidget?.iframe?.contentWindow.document;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const button = iframeDoc.querySelector('[data-testid="incall_leave"]');
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
setIsCallActive(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
observer.disconnect();
|
||||||
|
});
|
||||||
|
observer.observe(iframeDoc, { childList: true, subtree: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ev.detail.widgetId === activeClientWidgetApi?.widget.id) {
|
||||||
|
activeClientWidgetApi?.transport.reply(ev.detail, {});
|
||||||
|
const iframeDoc =
|
||||||
|
activeClientWidget?.iframe?.contentDocument ||
|
||||||
|
activeClientWidget?.iframe?.contentWindow.document;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const button = iframeDoc.querySelector('[data-testid="incall_leave"]');
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
setIsCallActive(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
observer.disconnect();
|
||||||
|
});
|
||||||
|
logger.debug('1 Join');
|
||||||
|
observer.observe(iframeDoc, { childList: true, subtree: true });
|
||||||
|
setIsCallActive(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lastViewedRoomDuringCall &&
|
||||||
|
viewedRoomId === activeCallRoomId &&
|
||||||
|
lastViewedRoomDuringCall === activeCallRoomId
|
||||||
|
) {
|
||||||
|
logger.debug('2 Join');
|
||||||
|
setIsCallActive(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeClientWidgetApi) {
|
||||||
|
if (isCallActive && viewedClientWidgetApi && viewedCallRoomId) {
|
||||||
|
activeClientWidgetApi?.removeAllListeners();
|
||||||
|
activeClientWidgetApi?.transport.send(WIDGET_HANGUP_ACTION, {}).then(() => {
|
||||||
|
logger.debug('3 Join');
|
||||||
|
setViewedAsActive();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.debug('4 Join');
|
||||||
|
setViewedAsActive();
|
||||||
|
setIsCallActive(true);
|
||||||
|
}
|
||||||
|
} else if (viewedCallRoomId !== viewedRoomId) {
|
||||||
|
logger.debug('5 Join');
|
||||||
|
setIsCallActive(true);
|
||||||
|
} else {
|
||||||
|
logger.debug('6 Join');
|
||||||
|
setViewedAsActive();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`CallContext: Setting up listeners for clientWidgetApi in room ${activeCallRoomId}`
|
||||||
|
);
|
||||||
|
activeClientWidgetApi?.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup);
|
||||||
|
activeClientWidgetApi?.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate);
|
||||||
|
viewedClientWidgetApi?.on(`action:${WIDGET_TILE_UPDATE}`, handleOnTileLayout);
|
||||||
|
activeClientWidgetApi?.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate);
|
||||||
|
activeClientWidgetApi?.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin);
|
||||||
|
|
||||||
|
viewedClientWidgetApi?.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin);
|
||||||
|
viewedClientWidgetApi?.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate);
|
||||||
|
viewedClientWidgetApi?.on(`action:${WIDGET_TILE_UPDATE}`, handleOnTileLayout);
|
||||||
|
viewedClientWidgetApi?.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate);
|
||||||
|
viewedClientWidgetApi?.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup);
|
||||||
|
}, [
|
||||||
|
activeClientWidgetApi,
|
||||||
|
activeCallRoomId,
|
||||||
|
activeClientWidgetApiRoomId,
|
||||||
|
hangUp,
|
||||||
|
isChatOpen,
|
||||||
|
isAudioEnabled,
|
||||||
|
isVideoEnabled,
|
||||||
|
isCallActive,
|
||||||
|
viewedRoomId,
|
||||||
|
viewedClientWidgetApi,
|
||||||
|
isPrimaryIframe,
|
||||||
|
viewedCallRoomId,
|
||||||
|
setViewedClientWidgetApi,
|
||||||
|
setActiveClientWidgetApi,
|
||||||
|
viewedClientWidget,
|
||||||
|
setViewedCallRoomId,
|
||||||
|
lastViewedRoomDuringCall,
|
||||||
|
activeClientWidget?.iframe?.contentDocument,
|
||||||
|
activeClientWidget?.iframe?.contentWindow?.document,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sendWidgetAction = useCallback(
|
||||||
|
async <T = unknown,>(action: WidgetApiToWidgetAction | string, data: T): Promise<void> => {
|
||||||
|
if (!activeClientWidgetApi) {
|
||||||
|
logger.warn(
|
||||||
|
`CallContext: Cannot send action '${action}', no active API clientWidgetApi registered.`
|
||||||
|
);
|
||||||
|
return Promise.reject(new Error('No active call clientWidgetApi'));
|
||||||
|
}
|
||||||
|
if (!activeClientWidgetApiRoomId || activeClientWidgetApiRoomId !== activeCallRoomId) {
|
||||||
|
logger.debug(
|
||||||
|
`CallContext: Cannot send action '${action}', clientWidgetApi room (${activeClientWidgetApiRoomId}) does not match active call room (${activeCallRoomId}). Stale clientWidgetApi?`
|
||||||
|
);
|
||||||
|
return Promise.reject(new Error('Mismatched active call clientWidgetApi'));
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`CallContext: Sending action '${action}' via active clientWidgetApi (room: ${activeClientWidgetApiRoomId}) with data:`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
await activeClientWidgetApi.transport.send(action as WidgetApiAction, data);
|
||||||
|
},
|
||||||
|
[activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAudio = useCallback(async () => {
|
||||||
|
const newState = !isAudioEnabled;
|
||||||
|
logger.debug(`CallContext: Toggling audio. New state: enabled=${newState}`);
|
||||||
|
setIsAudioEnabledState(newState);
|
||||||
|
try {
|
||||||
|
await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
|
||||||
|
audio_enabled: newState,
|
||||||
|
video_enabled: isVideoEnabled,
|
||||||
|
});
|
||||||
|
logger.debug(`CallContext: Successfully sent audio toggle action.`);
|
||||||
|
} catch (error) {
|
||||||
|
setIsAudioEnabledState(!newState);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [isAudioEnabled, isVideoEnabled, sendWidgetAction]);
|
||||||
|
|
||||||
|
const toggleVideo = useCallback(async () => {
|
||||||
|
const newState = !isVideoEnabled;
|
||||||
|
logger.debug(`CallContext: Toggling video. New state: enabled=${newState}`);
|
||||||
|
setIsVideoEnabledState(newState);
|
||||||
|
try {
|
||||||
|
await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
|
||||||
|
audio_enabled: isAudioEnabled,
|
||||||
|
video_enabled: newState,
|
||||||
|
});
|
||||||
|
logger.debug(`CallContext: Successfully sent video toggle action.`);
|
||||||
|
} catch (error) {
|
||||||
|
setIsVideoEnabledState(!newState);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [isVideoEnabled, isAudioEnabled, sendWidgetAction]);
|
||||||
|
|
||||||
|
const toggleChat = useCallback(async () => {
|
||||||
|
const newState = !isChatOpen;
|
||||||
|
setIsChatOpenState(newState);
|
||||||
|
}, [isChatOpen]);
|
||||||
|
|
||||||
|
const toggleIframe = useCallback(async () => {
|
||||||
|
const newState = !isPrimaryIframe;
|
||||||
|
setIsPrimaryIframe(newState);
|
||||||
|
}, [isPrimaryIframe]);
|
||||||
|
|
||||||
|
const contextValue = useMemo<CallContextState>(
|
||||||
|
() => ({
|
||||||
|
activeCallRoomId,
|
||||||
|
setActiveCallRoomId,
|
||||||
|
viewedCallRoomId,
|
||||||
|
setViewedCallRoomId,
|
||||||
|
hangUp,
|
||||||
|
activeClientWidgetApi,
|
||||||
|
registerActiveClientWidgetApi,
|
||||||
|
activeClientWidget,
|
||||||
|
viewedClientWidgetApi,
|
||||||
|
registerViewedClientWidgetApi,
|
||||||
|
viewedClientWidget,
|
||||||
|
sendWidgetAction,
|
||||||
|
isChatOpen,
|
||||||
|
isAudioEnabled,
|
||||||
|
isVideoEnabled,
|
||||||
|
isCallActive,
|
||||||
|
isPrimaryIframe,
|
||||||
|
toggleAudio,
|
||||||
|
toggleVideo,
|
||||||
|
toggleChat,
|
||||||
|
toggleIframe,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeCallRoomId,
|
||||||
|
setActiveCallRoomId,
|
||||||
|
viewedCallRoomId,
|
||||||
|
setViewedCallRoomId,
|
||||||
|
hangUp,
|
||||||
|
activeClientWidgetApi,
|
||||||
|
registerActiveClientWidgetApi,
|
||||||
|
activeClientWidget,
|
||||||
|
viewedClientWidgetApi,
|
||||||
|
registerViewedClientWidgetApi,
|
||||||
|
viewedClientWidget,
|
||||||
|
sendWidgetAction,
|
||||||
|
isChatOpen,
|
||||||
|
isAudioEnabled,
|
||||||
|
isVideoEnabled,
|
||||||
|
isCallActive,
|
||||||
|
isPrimaryIframe,
|
||||||
|
toggleAudio,
|
||||||
|
toggleVideo,
|
||||||
|
toggleChat,
|
||||||
|
toggleIframe,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
237
src/app/pages/client/call/PersistentCallContainer.tsx
Normal file
237
src/app/pages/client/call/PersistentCallContainer.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import React, { createContext, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { logger } from 'matrix-js-sdk/lib/logger';
|
||||||
|
import { ClientWidgetApi } from 'matrix-widget-api';
|
||||||
|
import { Box } from 'folds';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface PersistentCallContainerProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrimaryRefContext = createContext(null);
|
||||||
|
export const BackupRefContext = createContext(null);
|
||||||
|
|
||||||
|
export function PersistentCallContainer({ children }: PersistentCallContainerProps) {
|
||||||
|
const primaryIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const primaryWidgetApiRef = useRef<ClientWidgetApi | null>(null);
|
||||||
|
const primarySmallWidgetRef = useRef<SmallWidget | null>(null);
|
||||||
|
|
||||||
|
const backupIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const backupWidgetApiRef = useRef<ClientWidgetApi | null>(null);
|
||||||
|
const backupSmallWidgetRef = useRef<SmallWidget | null>(null);
|
||||||
|
const {
|
||||||
|
activeCallRoomId,
|
||||||
|
viewedCallRoomId,
|
||||||
|
isChatOpen,
|
||||||
|
isCallActive,
|
||||||
|
isPrimaryIframe,
|
||||||
|
registerActiveClientWidgetApi,
|
||||||
|
activeClientWidget,
|
||||||
|
registerViewedClientWidgetApi,
|
||||||
|
viewedClientWidget,
|
||||||
|
} = useCallState();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const clientConfig = useClientConfig();
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
const { roomIdOrAlias: viewedRoomId } = useParams();
|
||||||
|
const isViewingActiveCall = useMemo(
|
||||||
|
() => activeCallRoomId !== null && activeCallRoomId === viewedRoomId,
|
||||||
|
[activeCallRoomId, viewedRoomId]
|
||||||
|
);
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
|
||||||
|
const setupWidget = useCallback(
|
||||||
|
(
|
||||||
|
widgetApiRef: { current: ClientWidgetApi },
|
||||||
|
smallWidgetRef: { current: SmallWidget },
|
||||||
|
iframeRef: { current: { src: string } },
|
||||||
|
skipLobby: { toString: () => any }
|
||||||
|
) => {
|
||||||
|
if (mx?.getUserId()) {
|
||||||
|
if (
|
||||||
|
(activeCallRoomId !== viewedCallRoomId && isCallActive) ||
|
||||||
|
(activeCallRoomId && !isCallActive) ||
|
||||||
|
(!activeCallRoomId && viewedCallRoomId && !isCallActive)
|
||||||
|
) {
|
||||||
|
const roomIdToSet = (skipLobby ? activeCallRoomId : viewedCallRoomId) ?? '';
|
||||||
|
if (roomIdToSet === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const widgetId = `element-call-${roomIdToSet}-${Date.now()}`;
|
||||||
|
const newUrl = getWidgetUrl(
|
||||||
|
mx,
|
||||||
|
roomIdToSet,
|
||||||
|
clientConfig.elementCallUrl ?? '',
|
||||||
|
widgetId,
|
||||||
|
{
|
||||||
|
skipLobby: skipLobby.toString(),
|
||||||
|
returnToLobby: 'true',
|
||||||
|
perParticipantE2EE: 'true',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(primarySmallWidgetRef.current?.roomId || backupSmallWidgetRef.current?.roomId) &&
|
||||||
|
(skipLobby
|
||||||
|
? activeClientWidget?.roomId &&
|
||||||
|
//activeCallRoomId === activeClientWidget.roomId &&
|
||||||
|
(activeClientWidget.roomId === primarySmallWidgetRef.current?.roomId ||
|
||||||
|
activeClientWidget.roomId === backupSmallWidgetRef.current?.roomId)
|
||||||
|
: viewedClientWidget?.roomId &&
|
||||||
|
viewedCallRoomId === viewedClientWidget.roomId &&
|
||||||
|
(viewedClientWidget.roomId === primarySmallWidgetRef.current?.roomId ||
|
||||||
|
viewedClientWidget.roomId === backupSmallWidgetRef.current?.roomId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iframeRef.current && iframeRef.current.src !== newUrl.toString()) {
|
||||||
|
iframeRef.current.src = newUrl.toString();
|
||||||
|
} else if (iframeRef.current && !iframeRef.current.src) {
|
||||||
|
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;
|
||||||
|
if (skipLobby) {
|
||||||
|
registerActiveClientWidgetApi(activeCallRoomId, widgetApiRef.current, smallWidget);
|
||||||
|
} else {
|
||||||
|
registerViewedClientWidgetApi(viewedCallRoomId, widgetApiRef.current, smallWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
widgetApiInstance.once('ready', () => {
|
||||||
|
logger.info(`PersistentCallContainer: Widget for ${roomIdToSet} is ready.`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
mx,
|
||||||
|
activeCallRoomId,
|
||||||
|
viewedCallRoomId,
|
||||||
|
isCallActive,
|
||||||
|
clientConfig.elementCallUrl,
|
||||||
|
viewedClientWidget,
|
||||||
|
activeClientWidget,
|
||||||
|
viewedRoomId,
|
||||||
|
registerActiveClientWidgetApi,
|
||||||
|
registerViewedClientWidgetApi,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((activeCallRoomId && !viewedCallRoomId) || (activeCallRoomId && viewedCallRoomId))
|
||||||
|
setupWidget(primaryWidgetApiRef, primarySmallWidgetRef, primaryIframeRef, isPrimaryIframe);
|
||||||
|
if ((!activeCallRoomId && viewedCallRoomId) || (viewedCallRoomId && activeCallRoomId))
|
||||||
|
setupWidget(backupWidgetApiRef, backupSmallWidgetRef, backupIframeRef, !isPrimaryIframe);
|
||||||
|
}, [
|
||||||
|
setupWidget,
|
||||||
|
primaryWidgetApiRef,
|
||||||
|
primarySmallWidgetRef,
|
||||||
|
primaryIframeRef,
|
||||||
|
backupWidgetApiRef,
|
||||||
|
backupSmallWidgetRef,
|
||||||
|
backupIframeRef,
|
||||||
|
registerActiveClientWidgetApi,
|
||||||
|
registerViewedClientWidgetApi,
|
||||||
|
activeCallRoomId,
|
||||||
|
viewedCallRoomId,
|
||||||
|
isCallActive,
|
||||||
|
isPrimaryIframe,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const memoizedIframeRef = useMemo(() => primaryIframeRef, [primaryIframeRef]);
|
||||||
|
const memoizedBackupIframeRef = useMemo(() => backupIframeRef, [backupIframeRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimaryRefContext.Provider value={memoizedIframeRef}>
|
||||||
|
<BackupRefContext.Provider value={memoizedBackupIframeRef}>
|
||||||
|
<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={primaryIframeRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
display: isPrimaryIframe || isViewingActiveCall ? 'flex' : 'none',
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<iframe
|
||||||
|
ref={backupIframeRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
display: !isPrimaryIframe || isViewingActiveCall ? 'flex' : '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}
|
||||||
|
</BackupRefContext.Provider>
|
||||||
|
</PrimaryRefContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,7 @@ import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||||
|
|
||||||
type DirectMenuProps = {
|
type DirectMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -269,6 +270,7 @@ export function Direct() {
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
)}
|
)}
|
||||||
|
<CallNavStatus />
|
||||||
</PageNav>
|
</PageNav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||||
|
|
||||||
type HomeMenuProps = {
|
type HomeMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -336,6 +337,7 @@ export function Home() {
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
)}
|
)}
|
||||||
|
<CallNavStatus />
|
||||||
</PageNav>
|
</PageNav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ import {
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
|
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
|
||||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||||
|
import { useCallState } from '../call/CallProvider';
|
||||||
|
|
||||||
type SpaceMenuProps = {
|
type SpaceMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -275,7 +277,7 @@ function SpaceHeader() {
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
|
{space && <SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />}
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -295,15 +297,15 @@ export function Space() {
|
||||||
const allRooms = useAtomValue(allRoomsAtom);
|
const allRooms = useAtomValue(allRoomsAtom);
|
||||||
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
|
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
|
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
|
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
|
||||||
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
|
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
|
||||||
|
const { isCallActive, activeCallRoomId } = useCallState();
|
||||||
|
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
const getRoom = useCallback(
|
const getRoom = useCallback(
|
||||||
(rId: string) => {
|
(rId: string): Room | undefined => {
|
||||||
if (allJoinedRooms.has(rId)) {
|
if (allJoinedRooms.has(rId)) {
|
||||||
return mx.getRoom(rId) ?? undefined;
|
return mx.getRoom(rId) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -320,11 +322,13 @@ export function Space() {
|
||||||
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
|
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId;
|
const showRoomAnyway =
|
||||||
if (showRoom) return false;
|
roomToUnread.has(roomId) ||
|
||||||
return true;
|
roomId === selectedRoomId ||
|
||||||
|
(isCallActive && activeCallRoomId === roomId);
|
||||||
|
return !showRoomAnyway;
|
||||||
},
|
},
|
||||||
[space.roomId, closedCategories, roomToUnread, selectedRoomId]
|
[space.roomId, closedCategories, roomToUnread, selectedRoomId, activeCallRoomId, isCallActive]
|
||||||
),
|
),
|
||||||
useCallback(
|
useCallback(
|
||||||
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
|
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
|
||||||
|
|
@ -335,7 +339,7 @@ export function Space() {
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: hierarchy.length,
|
count: hierarchy.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 0,
|
estimateSize: () => 32,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -436,6 +440,7 @@ export function Space() {
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
|
<CallNavStatus />
|
||||||
</PageNav>
|
</PageNav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,19 +258,20 @@ export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
|
||||||
export const joinRuleToIconSrc = (
|
export const joinRuleToIconSrc = (
|
||||||
icons: Record<IconName, IconSrc>,
|
icons: Record<IconName, IconSrc>,
|
||||||
joinRule: JoinRule,
|
joinRule: JoinRule,
|
||||||
space: boolean
|
space: boolean,
|
||||||
|
call: boolean
|
||||||
): IconSrc | undefined => {
|
): IconSrc | undefined => {
|
||||||
if (joinRule === JoinRule.Restricted) {
|
if (joinRule === JoinRule.Restricted) {
|
||||||
return space ? icons.Space : icons.Hash;
|
return space ? icons.Space : call ? icons.VolumeHigh : icons.Hash;
|
||||||
}
|
}
|
||||||
if (joinRule === JoinRule.Knock) {
|
if (joinRule === JoinRule.Knock) {
|
||||||
return space ? icons.SpaceLock : icons.HashLock;
|
return space ? icons.SpaceLock : call ? icons.VolumeHigh : icons.HashLock;
|
||||||
}
|
}
|
||||||
if (joinRule === JoinRule.Invite) {
|
if (joinRule === JoinRule.Invite) {
|
||||||
return space ? icons.SpaceLock : icons.HashLock;
|
return space ? icons.SpaceLock : call ? icons.VolumeHigh : icons.HashLock;
|
||||||
}
|
}
|
||||||
if (joinRule === JoinRule.Public) {
|
if (joinRule === JoinRule.Public) {
|
||||||
return space ? icons.SpaceGlobe : icons.HashGlobe;
|
return space ? icons.SpaceGlobe : call ? icons.VolumeHigh : icons.HashGlobe;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,10 @@ function guessDMRoomTargetId(room, myUserId) {
|
||||||
room.getJoinedMembers().forEach((member) => {
|
room.getJoinedMembers().forEach((member) => {
|
||||||
if (member.userId === myUserId) return;
|
if (member.userId === myUserId) return;
|
||||||
|
|
||||||
if (typeof oldestMemberTs === 'undefined' || (member.events.member && member.events.member.getTs() < oldestMemberTs)) {
|
if (
|
||||||
|
typeof oldestMemberTs === 'undefined' ||
|
||||||
|
(member.events.member && member.events.member.getTs() < oldestMemberTs)
|
||||||
|
) {
|
||||||
oldestMember = member;
|
oldestMember = member;
|
||||||
oldestMemberTs = member.events.member.getTs();
|
oldestMemberTs = member.events.member.getTs();
|
||||||
}
|
}
|
||||||
|
|
@ -64,10 +67,17 @@ function guessDMRoomTargetId(room, myUserId) {
|
||||||
if (oldestMember) return oldestMember.userId;
|
if (oldestMember) return oldestMember.userId;
|
||||||
|
|
||||||
// if there are no joined members other than us, use the oldest member
|
// if there are no joined members other than us, use the oldest member
|
||||||
room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getMembers().forEach((member) => {
|
room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
?.getMembers()
|
||||||
|
.forEach((member) => {
|
||||||
if (member.userId === myUserId) return;
|
if (member.userId === myUserId) return;
|
||||||
|
|
||||||
if (typeof oldestMemberTs === 'undefined' || (member.events.member && member.events.member.getTs() < oldestMemberTs)) {
|
if (
|
||||||
|
typeof oldestMemberTs === 'undefined' ||
|
||||||
|
(member.events.member && member.events.member.getTs() < oldestMemberTs)
|
||||||
|
) {
|
||||||
oldestMember = member;
|
oldestMember = member;
|
||||||
oldestMemberTs = member.events.member.getTs();
|
oldestMemberTs = member.events.member.getTs();
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +127,13 @@ async function create(mx, options, isDM = false) {
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errcodes = ['M_UNKNOWN', 'M_BAD_JSON', 'M_ROOM_IN_USE', 'M_INVALID_ROOM_STATE', 'M_UNSUPPORTED_ROOM_VERSION'];
|
const errcodes = [
|
||||||
|
'M_UNKNOWN',
|
||||||
|
'M_BAD_JSON',
|
||||||
|
'M_ROOM_IN_USE',
|
||||||
|
'M_INVALID_ROOM_STATE',
|
||||||
|
'M_UNSUPPORTED_ROOM_VERSION',
|
||||||
|
];
|
||||||
if (errcodes.includes(e.errcode)) {
|
if (errcodes.includes(e.errcode)) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +165,7 @@ async function createDM(mx, userIdOrIds, isEncrypted = true) {
|
||||||
|
|
||||||
async function createRoom(mx, opts) {
|
async function createRoom(mx, opts) {
|
||||||
// joinRule: 'public' | 'invite' | 'restricted'
|
// joinRule: 'public' | 'invite' | 'restricted'
|
||||||
const { name, topic, joinRule } = opts;
|
const { name, roomType, topic, joinRule } = opts;
|
||||||
const alias = opts.alias ?? undefined;
|
const alias = opts.alias ?? undefined;
|
||||||
const parentId = opts.parentId ?? undefined;
|
const parentId = opts.parentId ?? undefined;
|
||||||
const isSpace = opts.isSpace ?? false;
|
const isSpace = opts.isSpace ?? false;
|
||||||
|
|
@ -161,12 +177,16 @@ async function createRoom(mx, opts) {
|
||||||
const options = {
|
const options = {
|
||||||
creation_content: undefined,
|
creation_content: undefined,
|
||||||
name,
|
name,
|
||||||
|
roomType,
|
||||||
topic,
|
topic,
|
||||||
visibility,
|
visibility,
|
||||||
room_alias_name: alias,
|
room_alias_name: alias,
|
||||||
initial_state: [],
|
initial_state: [],
|
||||||
power_level_content_override: undefined,
|
power_level_content_override: undefined,
|
||||||
};
|
};
|
||||||
|
if (roomType) {
|
||||||
|
options.creation_content = { type: 'org.matrix.msc3417.call' };
|
||||||
|
}
|
||||||
if (isSpace) {
|
if (isSpace) {
|
||||||
options.creation_content = { type: 'm.space' };
|
options.creation_content = { type: 'm.space' };
|
||||||
}
|
}
|
||||||
|
|
@ -211,10 +231,12 @@ async function createRoom(mx, opts) {
|
||||||
type: 'm.room.join_rules',
|
type: 'm.room.join_rules',
|
||||||
content: {
|
content: {
|
||||||
join_rule: 'restricted',
|
join_rule: 'restricted',
|
||||||
allow: [{
|
allow: [
|
||||||
|
{
|
||||||
type: 'm.room_membership',
|
type: 'm.room_membership',
|
||||||
room_id: parentId,
|
room_id: parentId,
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -222,18 +244,22 @@ async function createRoom(mx, opts) {
|
||||||
const result = await create(mx, options);
|
const result = await create(mx, options);
|
||||||
|
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
await mx.sendStateEvent(parentId, 'm.space.child', {
|
await mx.sendStateEvent(
|
||||||
|
parentId,
|
||||||
|
'm.space.child',
|
||||||
|
{
|
||||||
auto_join: false,
|
auto_join: false,
|
||||||
suggested: false,
|
suggested: false,
|
||||||
via: [getIdServer(mx.getUserId())],
|
via: [getIdServer(mx.getUserId())],
|
||||||
}, result.room_id);
|
},
|
||||||
|
result.room_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ignore(mx, userIds) {
|
async function ignore(mx, userIds) {
|
||||||
|
|
||||||
let ignoredUsers = mx.getIgnoredUsers().concat(userIds);
|
let ignoredUsers = mx.getIgnoredUsers().concat(userIds);
|
||||||
ignoredUsers = [...new Set(ignoredUsers)];
|
ignoredUsers = [...new Set(ignoredUsers)];
|
||||||
await mx.setIgnoredUsers(ignoredUsers);
|
await mx.setIgnoredUsers(ignoredUsers);
|
||||||
|
|
@ -251,32 +277,51 @@ async function setPowerLevel(mx, roomId, userId, powerLevel) {
|
||||||
|
|
||||||
async function setMyRoomNick(mx, roomId, nick) {
|
async function setMyRoomNick(mx, roomId, nick) {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const mEvent = room.getLiveTimeline().getState(EventTimeline.FORWARDS).getStateEvents('m.room.member', mx.getUserId());
|
const mEvent = room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
.getStateEvents('m.room.member', mx.getUserId());
|
||||||
const content = mEvent?.getContent();
|
const content = mEvent?.getContent();
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
await mx.sendStateEvent(roomId, 'm.room.member', {
|
await mx.sendStateEvent(
|
||||||
|
roomId,
|
||||||
|
'm.room.member',
|
||||||
|
{
|
||||||
...content,
|
...content,
|
||||||
displayname: nick,
|
displayname: nick,
|
||||||
}, mx.getUserId());
|
},
|
||||||
|
mx.getUserId()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setMyRoomAvatar(mx, roomId, mxc) {
|
async function setMyRoomAvatar(mx, roomId, mxc) {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const mEvent = room.getLiveTimeline().getState(EventTimeline.FORWARDS).getStateEvents('m.room.member', mx.getUserId());
|
const mEvent = room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
.getStateEvents('m.room.member', mx.getUserId());
|
||||||
const content = mEvent?.getContent();
|
const content = mEvent?.getContent();
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
await mx.sendStateEvent(roomId, 'm.room.member', {
|
await mx.sendStateEvent(
|
||||||
|
roomId,
|
||||||
|
'm.room.member',
|
||||||
|
{
|
||||||
...content,
|
...content,
|
||||||
avatar_url: mxc,
|
avatar_url: mxc,
|
||||||
}, mx.getUserId());
|
},
|
||||||
|
mx.getUserId()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
convertToDm,
|
convertToDm,
|
||||||
convertToRoom,
|
convertToRoom,
|
||||||
join,
|
join,
|
||||||
createDM, createRoom,
|
createDM,
|
||||||
ignore, unignore,
|
createRoom,
|
||||||
|
ignore,
|
||||||
|
unignore,
|
||||||
setPowerLevel,
|
setPowerLevel,
|
||||||
setMyRoomNick, setMyRoomAvatar,
|
setMyRoomNick,
|
||||||
|
setMyRoomAvatar,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export enum StateEvent {
|
||||||
RoomGuestAccess = 'm.room.guest_access',
|
RoomGuestAccess = 'm.room.guest_access',
|
||||||
RoomServerAcl = 'm.room.server_acl',
|
RoomServerAcl = 'm.room.server_acl',
|
||||||
RoomTombstone = 'm.room.tombstone',
|
RoomTombstone = 'm.room.tombstone',
|
||||||
|
GroupCallPrefix = "org.matrix.msc3401.call",
|
||||||
|
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
|
||||||
|
|
||||||
SpaceChild = 'm.space.child',
|
SpaceChild = 'm.space.child',
|
||||||
SpaceParent = 'm.space.parent',
|
SpaceParent = 'm.space.parent',
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ import buildConfig from './build.config';
|
||||||
|
|
||||||
const copyFiles = {
|
const copyFiles = {
|
||||||
targets: [
|
targets: [
|
||||||
|
{
|
||||||
|
src: 'node_modules/@element-hq/element-call-embedded/dist/*',
|
||||||
|
dest: 'public/element-call',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs',
|
src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
dest: '',
|
dest: '',
|
||||||
|
|
@ -47,7 +51,10 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) {
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, res, next) => {
|
server.middlewares.use((req, res, next) => {
|
||||||
if (req.url === wasmFilePath) {
|
if (req.url === wasmFilePath) {
|
||||||
const resolvedPath = path.join(path.resolve(), "/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm");
|
const resolvedPath = path.join(
|
||||||
|
path.resolve(),
|
||||||
|
'/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm'
|
||||||
|
);
|
||||||
|
|
||||||
if (fs.existsSync(resolvedPath)) {
|
if (fs.existsSync(resolvedPath)) {
|
||||||
res.setHeader('Content-Type', 'application/wasm');
|
res.setHeader('Content-Type', 'application/wasm');
|
||||||
|
|
@ -102,8 +109,8 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
type: 'module'
|
type: 'module',
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue