diff --git a/config.json b/config.json index 644f0eb611..9b95c3552f 100644 --- a/config.json +++ b/config.json @@ -8,6 +8,7 @@ "xmr.se" ], "allowCustomHomeservers": true, + "elementCallUrl": null, "featuredCommunities": { "openAsDefault": false, diff --git a/docker-nginx.conf b/docker-nginx.conf index a2dbeba033..aea395aead 100644 --- a/docker-nginx.conf +++ b/docker-nginx.conf @@ -14,6 +14,8 @@ server { rewrite ^/public/(.*)$ /public/$1 break; rewrite ^/assets/(.*)$ /assets/$1 break; + rewrite ^/element-call/dist/(.*)$ /element-call/dist/$1 break; + rewrite ^(.+)$ /index.html break; } } diff --git a/package-lock.json b/package-lock.json index 582f02f40d..f272a24fe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.5.0", + "folds": "2.6.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -44,6 +44,7 @@ "linkify-react": "4.1.3", "linkifyjs": "4.1.3", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.13.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -65,6 +66,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { + "@element-hq/element-call-embedded": "0.16.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -1649,6 +1651,12 @@ "node": ">=6.9.0" } }, + "node_modules/@element-hq/element-call-embedded": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz", + "integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==", + "dev": true + }, "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", @@ -7158,9 +7166,9 @@ } }, "node_modules/folds": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz", - "integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.6.0.tgz", + "integrity": "sha512-9353l0KFBptqUXYBJhoZ0ZEs7ofLUeSmcZEtzn23IbAM1SG7R3tTQTk24lcLKJu95qMGxuC3no8MQTQExNHUNw==", "license": "Apache-2.0", "peerDependencies": { "@vanilla-extract/css": "1.9.2", @@ -8664,9 +8672,9 @@ } }, "node_modules/matrix-widget-api": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz", - "integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz", + "integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==", "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", @@ -10907,6 +10915,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 601323adf7..a3d4c74cd9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "start": "vite", "build": "vite build", + "preview": "vite preview", "lint": "yarn check:eslint && yarn check:prettier", "check:eslint": "eslint src/*", "check:prettier": "prettier --check .", @@ -43,7 +44,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.5.0", + "folds": "2.6.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -55,6 +56,7 @@ "linkify-react": "4.1.3", "linkifyjs": "4.1.3", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.13.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -76,6 +78,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { + "@element-hq/element-call-embedded": "0.16.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx index 9507317a0c..bbc0a65dcf 100644 --- a/src/app/components/JoinRulesSwitcher.tsx +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -16,34 +16,24 @@ import { import { JoinRule } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../utils/keyboard'; +import { getRoomIconSrc } from '../utils/room'; export type ExtraJoinRules = 'knock_restricted'; export type ExtendedJoinRules = JoinRule | ExtraJoinRules; type JoinRuleIcons = Record; -export const useRoomJoinRuleIcon = (): JoinRuleIcons => - useMemo( - () => ({ - [JoinRule.Invite]: Icons.HashLock, - [JoinRule.Knock]: Icons.HashLock, - knock_restricted: Icons.Hash, - [JoinRule.Restricted]: Icons.Hash, - [JoinRule.Public]: Icons.HashGlobe, - [JoinRule.Private]: Icons.HashLock, - }), - [] - ); -export const useSpaceJoinRuleIcon = (): JoinRuleIcons => + +export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons => useMemo( () => ({ - [JoinRule.Invite]: Icons.SpaceLock, - [JoinRule.Knock]: Icons.SpaceLock, - knock_restricted: Icons.Space, - [JoinRule.Restricted]: Icons.Space, - [JoinRule.Public]: Icons.SpaceGlobe, - [JoinRule.Private]: Icons.SpaceLock, + [JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite), + [JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock), + knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), + [JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), + [JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public), + [JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private), }), - [] + [roomType] ); type JoinRuleLabels = Record; diff --git a/src/app/components/create-room/CreateRoomKindSelector.tsx b/src/app/components/create-room/CreateRoomAccessSelector.tsx similarity index 54% rename from src/app/components/create-room/CreateRoomKindSelector.tsx rename to src/app/components/create-room/CreateRoomAccessSelector.tsx index 096954fbcb..35f39af888 100644 --- a/src/app/components/create-room/CreateRoomKindSelector.tsx +++ b/src/app/components/create-room/CreateRoomAccessSelector.tsx @@ -2,43 +2,39 @@ import React from 'react'; import { Box, Text, Icon, Icons, config, IconSrc } from 'folds'; import { SequenceCard } from '../sequence-card'; import { SettingTile } from '../setting-tile'; +import { CreateRoomAccess } from './types'; -export enum CreateRoomKind { - Private = 'private', - Restricted = 'restricted', - Public = 'public', -} -type CreateRoomKindSelectorProps = { - value?: CreateRoomKind; - onSelect: (value: CreateRoomKind) => void; +type CreateRoomAccessSelectorProps = { + value?: CreateRoomAccess; + onSelect: (value: CreateRoomAccess) => void; canRestrict?: boolean; disabled?: boolean; - getIcon: (kind: CreateRoomKind) => IconSrc; + getIcon: (access: CreateRoomAccess) => IconSrc; }; -export function CreateRoomKindSelector({ +export function CreateRoomAccessSelector({ value, onSelect, canRestrict, disabled, getIcon, -}: CreateRoomKindSelectorProps) { +}: CreateRoomAccessSelectorProps) { return ( {canRestrict && ( onSelect(CreateRoomKind.Restricted)} + aria-pressed={value === CreateRoomAccess.Restricted} + onClick={() => onSelect(CreateRoomAccess.Restricted)} disabled={disabled} > } - after={value === CreateRoomKind.Restricted && } + before={} + after={value === CreateRoomAccess.Restricted && } > Restricted @@ -49,18 +45,18 @@ export function CreateRoomKindSelector({ )} onSelect(CreateRoomKind.Private)} + aria-pressed={value === CreateRoomAccess.Private} + onClick={() => onSelect(CreateRoomAccess.Private)} disabled={disabled} > } - after={value === CreateRoomKind.Private && } + before={} + after={value === CreateRoomAccess.Private && } > Private @@ -70,18 +66,18 @@ export function CreateRoomKindSelector({ onSelect(CreateRoomKind.Public)} + aria-pressed={value === CreateRoomAccess.Public} + onClick={() => onSelect(CreateRoomAccess.Public)} disabled={disabled} > } - after={value === CreateRoomKind.Public && } + before={} + after={value === CreateRoomAccess.Public && } > Public diff --git a/src/app/components/create-room/CreateRoomTypeSelector.tsx b/src/app/components/create-room/CreateRoomTypeSelector.tsx new file mode 100644 index 0000000000..06227cb5fa --- /dev/null +++ b/src/app/components/create-room/CreateRoomTypeSelector.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Box, Text, Icon, Icons, config, IconSrc } from 'folds'; +import { SequenceCard } from '../sequence-card'; +import { SettingTile } from '../setting-tile'; +import { CreateRoomType } from './types'; + +type CreateRoomTypeSelectorProps = { + value?: CreateRoomType; + onSelect: (value: CreateRoomType) => void; + disabled?: boolean; + getIcon: (type: CreateRoomType) => IconSrc; +}; +export function CreateRoomTypeSelector({ + value, + onSelect, + disabled, + getIcon, +}: CreateRoomTypeSelectorProps) { + return ( + + onSelect(CreateRoomType.TextRoom)} + disabled={disabled} + > + } + after={value === CreateRoomType.TextRoom && } + > + Text + + Send text messages, videos and GIFs. + + + + onSelect(CreateRoomType.VoiceRoom)} + disabled={disabled} + > + } + after={value === CreateRoomType.VoiceRoom && } + > + Voice + + A room optimized for voice calls. + + + + + ); +} diff --git a/src/app/components/create-room/index.ts b/src/app/components/create-room/index.ts index ffd9cb3d33..cce6e03736 100644 --- a/src/app/components/create-room/index.ts +++ b/src/app/components/create-room/index.ts @@ -1,5 +1,6 @@ -export * from './CreateRoomKindSelector'; +export * from './CreateRoomAccessSelector'; export * from './CreateRoomAliasInput'; export * from './RoomVersionSelector'; export * from './utils'; export * from './AdditionalCreatorInput'; +export * from './types'; diff --git a/src/app/components/create-room/types.ts b/src/app/components/create-room/types.ts new file mode 100644 index 0000000000..8b54587dd5 --- /dev/null +++ b/src/app/components/create-room/types.ts @@ -0,0 +1,10 @@ +export enum CreateRoomType { + TextRoom = 'text', + VoiceRoom = 'voice', +} + +export enum CreateRoomAccess { + Private = 'private', + Restricted = 'restricted', + Public = 'public', +} diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index a0ca7488ae..b8cca03aab 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -7,10 +7,10 @@ import { Room, } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; -import { CreateRoomKind } from './CreateRoomKindSelector'; import { RoomType, StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; import { getMxIdServer } from '../../utils/matrix'; +import { CreateRoomAccess } from './types'; export const createRoomCreationContent = ( type: RoomType | undefined, @@ -32,7 +32,7 @@ export const createRoomCreationContent = ( }; export const createRoomJoinRulesState = ( - kind: CreateRoomKind, + access: CreateRoomAccess, parent: Room | undefined, knock: boolean ) => { @@ -40,13 +40,13 @@ export const createRoomJoinRulesState = ( join_rule: knock ? JoinRule.Knock : JoinRule.Invite, }; - if (kind === CreateRoomKind.Public) { + if (access === CreateRoomAccess.Public) { content = { join_rule: JoinRule.Public, }; } - if (kind === CreateRoomKind.Restricted && parent) { + if (access === CreateRoomAccess.Restricted && parent) { content = { join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted, allow: [ @@ -82,11 +82,23 @@ export const createRoomEncryptionState = () => ({ }, }); +export const createRoomCallState = () => ({ + type: 'org.matrix.msc3401.call', + state_key: '', + content: {}, +}); + +export const createVoiceRoomPowerLevelsOverride = () => ({ + events: { + [StateEvent.GroupCallMemberPrefix]: 0, + }, +}); + export type CreateRoomData = { version: string; type?: RoomType; parent?: Room; - kind: CreateRoomKind; + access: CreateRoomAccess; name: string; topic?: string; aliasLocalPart?: string; @@ -106,7 +118,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis initialState.push(createRoomParentState(data.parent)); } - initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock)); + if (data.type === RoomType.Call) { + initialState.push(createRoomCallState()); + } + + initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock)); const options: ICreateRoomOpts = { room_version: data.version, @@ -118,6 +134,8 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis data.allowFederation, data.additionalCreators ), + power_level_content_override: + data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined, initial_state: initialState, }; diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index b0c64f60f1..a9e7f51faa 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -174,7 +174,7 @@ export function RoomMentionAutocomplete({ )} /> ) : ( - + )} } diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 23f3998d88..cbcd626a27 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk'; import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import * as css from './RoomAvatar.css'; -import { joinRuleToIconSrc } from '../../utils/room'; +import { getRoomIconSrc } from '../../utils/room'; import colorMXID from '../../../util/colorMXID'; type RoomAvatarProps = { @@ -44,13 +44,9 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps export const RoomIcon = forwardRef< SVGSVGElement, Omit, 'src'> & { - joinRule: JoinRule; - space?: boolean; + joinRule?: JoinRule; + roomType?: string; } ->(({ joinRule, space, ...props }, ref) => ( - +>(({ joinRule, roomType, ...props }, ref) => ( + )); diff --git a/src/app/features/call/CallView.css.ts b/src/app/features/call/CallView.css.ts new file mode 100644 index 0000000000..ef470ca518 --- /dev/null +++ b/src/app/features/call/CallView.css.ts @@ -0,0 +1,37 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, config } from 'folds'; +import { ContainerColor } from '../../styles/ContainerColor.css'; + +export const CallViewUserGrid = style({ + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'center', + alignItems: 'center', + marginInline: '20px', + gap: config.space.S400, +}); + +export const CallViewUser = style([ + DefaultReset, + ContainerColor({ variant: 'SurfaceVariant' }), + { + height: '90px', + width: '150px', + borderRadius: config.radii.R500, + }, +]); + +export const UserLink = style({ + color: 'inherit', + minWidth: 0, + cursor: 'pointer', + flexGrow: 0, + transition: 'all ease-out 200ms', + ':hover': { + transform: 'translateY(-3px)', + textDecoration: 'unset', + }, + ':focus': { + outline: 'none', + }, +}); diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx new file mode 100644 index 0000000000..300e18d87a --- /dev/null +++ b/src/app/features/call/CallView.tsx @@ -0,0 +1,267 @@ +import { EventType, Room } from 'matrix-js-sdk'; +import React, { + useContext, + useCallback, + useEffect, + useRef, + MouseEventHandler, + useState, + ReactNode, +} from 'react'; +import { Box, Button, config, Spinner, Text } from 'folds'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { useCallMembers } from '../../hooks/useCallMemberships'; + +import { CallRefContext } from '../../pages/client/call/PersistentCallContainer'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { useDebounce } from '../../hooks/useDebounce'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { CallViewUser } from './CallViewUser'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { getMemberDisplayName } from '../../utils/room'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import * as css from './CallView.css'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { useRoomName } from '../../hooks/useRoomMeta'; + +type OriginalStyles = { + position?: string; + top?: string; + left?: string; + width?: string; + height?: string; + zIndex?: string; + display?: string; + visibility?: string; + pointerEvents?: string; + border?: string; +}; + +export function CallViewUserGrid({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function CallView({ room }: { room: Room }) { + const callIframeRef = useContext(CallRefContext); + const iframeHostRef = useRef(null); + + const originalIframeStylesRef = useRef(null); + const mx = useMatrixClient(); + + const [visibleCallNames, setVisibleCallNames] = useState(''); + + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + + const roomName = useRoomName(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canJoin = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId()); + + const { + isActiveCallReady, + activeCallRoomId, + isChatOpen, + setActiveCallRoomId, + hangUp, + setViewedCallRoomId, + } = useCallState(); + + const isActiveCallRoom = activeCallRoomId === room.roomId; + const callIsCurrentAndReady = isActiveCallRoom && isActiveCallReady; + const callMembers = useCallMembers(mx, room.roomId); + + const getName = (userId: string) => + getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId); + + const memberDisplayNames = callMembers.map((callMembership) => + getName(callMembership.sender ?? '') + ); + + const { navigateRoom } = useRoomNavigate(); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + + const activeIframeDisplayRef = callIframeRef; + + const applyFixedPositioningToIframe = useCallback(() => { + const iframeElement = activeIframeDisplayRef?.current; + const hostElement = iframeHostRef?.current; + + if (iframeElement && hostElement) { + if (!originalIframeStylesRef.current) { + const computed = window.getComputedStyle(iframeElement); + originalIframeStylesRef.current = { + position: iframeElement.style.position || computed.position, + top: iframeElement.style.top || computed.top, + left: iframeElement.style.left || computed.left, + width: iframeElement.style.width || computed.width, + height: iframeElement.style.height || computed.height, + zIndex: iframeElement.style.zIndex || computed.zIndex, + display: iframeElement.style.display || computed.display, + visibility: iframeElement.style.visibility || computed.visibility, + pointerEvents: iframeElement.style.pointerEvents || computed.pointerEvents, + border: iframeElement.style.border || computed.border, + }; + } + + const hostRect = hostElement.getBoundingClientRect(); + + iframeElement.style.position = 'fixed'; + iframeElement.style.top = `${hostRect.top}px`; + iframeElement.style.left = `${hostRect.left}px`; + iframeElement.style.width = `${hostRect.width}px`; + iframeElement.style.height = `${hostRect.height}px`; + iframeElement.style.border = 'none'; + iframeElement.style.zIndex = '1000'; + iframeElement.style.display = room.isCallRoom() ? 'block' : 'none'; + iframeElement.style.visibility = 'visible'; + iframeElement.style.pointerEvents = 'auto'; + } + }, [activeIframeDisplayRef, room]); + + const debouncedApplyFixedPositioning = useDebounce(applyFixedPositioningToIframe, { + wait: 50, + immediate: false, + }); + useEffect(() => { + const iframeElement = activeIframeDisplayRef?.current; + const hostElement = iframeHostRef?.current; + + if (room.isCallRoom() || (callIsCurrentAndReady && iframeElement && hostElement)) { + applyFixedPositioningToIframe(); + + const resizeObserver = new ResizeObserver(debouncedApplyFixedPositioning); + if (hostElement) resizeObserver.observe(hostElement); + window.addEventListener('scroll', debouncedApplyFixedPositioning, true); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('scroll', debouncedApplyFixedPositioning, true); + + if (iframeElement && originalIframeStylesRef.current) { + const originalStyles = originalIframeStylesRef.current; + (Object.keys(originalStyles) as Array).forEach((key) => { + if (key in iframeElement.style) { + iframeElement.style[key as any] = originalStyles[key] || ''; + } + }); + } + originalIframeStylesRef.current = null; + }; + } + + return undefined; + }, [ + activeIframeDisplayRef, + applyFixedPositioningToIframe, + debouncedApplyFixedPositioning, + callIsCurrentAndReady, + room, + ]); + + const handleJoinVCClick: MouseEventHandler = (evt) => { + if (!canJoin) return; + + if (isMobile) { + evt.stopPropagation(); + setViewedCallRoomId(room.roomId); + navigateRoom(room.roomId); + } + if (!callIsCurrentAndReady) { + hangUp(); + setActiveCallRoomId(room.roomId); + } + }; + + const isCallViewVisible = room.isCallRoom() && (screenSize === ScreenSize.Desktop || !isChatOpen); + + useEffect(() => { + if (memberDisplayNames.length <= 2) { + setVisibleCallNames(memberDisplayNames.join(' and ')); + } else { + const visible = memberDisplayNames.slice(0, 2); + const remaining = memberDisplayNames.length - 2; + + setVisibleCallNames( + `${visible.join(', ')}, and ${remaining} other${remaining > 1 ? 's' : ''}` + ); + } + }, [memberDisplayNames]); + + return ( + +
+ + + {callMembers.slice(0, 6).map((callMember) => ( + + ))} + + + + + {roomName} + + + {visibleCallNames !== '' ? visibleCallNames : 'No one'}{' '} + {memberDisplayNames.length > 1 ? 'are' : 'is'} currently in voice + + + + + + ); +} diff --git a/src/app/features/call/CallViewUser.tsx b/src/app/features/call/CallViewUser.tsx new file mode 100644 index 0000000000..776fead601 --- /dev/null +++ b/src/app/features/call/CallViewUser.tsx @@ -0,0 +1,71 @@ +import { as, Avatar, Box, Icon, Icons, Text } from 'folds'; +import React from 'react'; +import classNames from 'classnames'; +import { Room } from 'matrix-js-sdk'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import { UserAvatar } from '../../components/user-avatar'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; +import * as css from './CallView.css'; + +type CallViewUserProps = { + room: Room; + callMembership: CallMembership; +}; + +export const UserProfileButton = as<'button'>( + ({ as: AsUserProfileButton = 'button', className, ...props }, ref) => ( + + ) +); + +export const CallViewUserBase = as<'div'>(({ className, ...props }, ref) => ( + +)); + +export function CallViewUser({ room, callMembership }: CallViewUserProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); + const userId = callMembership.sender ?? ''; + const avatarMxcUrl = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication) + : undefined; + const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId); + + const handleUserClick: React.MouseEventHandler = (evt) => { + openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + + + + } + /> + + + {getName} + + + + + ); +} diff --git a/src/app/features/call/CinnyWidget.ts b/src/app/features/call/CinnyWidget.ts new file mode 100644 index 0000000000..3894af8793 --- /dev/null +++ b/src/app/features/call/CinnyWidget.ts @@ -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); + } +} diff --git a/src/app/features/call/SmallWidget.ts b/src/app/features/call/SmallWidget.ts new file mode 100644 index 0000000000..a16c416c45 --- /dev/null +++ b/src/app/features/call/SmallWidget.ts @@ -0,0 +1,412 @@ +/* + * 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, + RoomStateEvent, +} from 'matrix-js-sdk'; +import { + ClientWidgetApi, + IRoomEvent, + IStickyActionRequest, + IWidget, + IWidgetData, + MatrixCapabilities, + WidgetApiFromWidgetAction, + WidgetKind, +} from 'matrix-widget-api'; +import { CinnyWidget } from './CinnyWidget'; +import { SmallWidgetDriver } from './SmallWidgetDriver'; + +/** + * Generates the URL for the Element Call widget. + * @param mx - The MatrixClient instance. + * @param roomId - The ID of the room. + * @returns The generated URL object. + */ +export const getWidgetUrl = ( + mx: MatrixClient, + roomId: string, + elementCallUrl: string, + widgetId: string, + setParams: any, +): URL => { + const baseUrl = window.location.origin; + const url = elementCallUrl + ? new URL(`${elementCallUrl}/room`) + : new URL('/public/element-call/index.html#', baseUrl); + + const params = new URLSearchParams({ + embed: 'true', + widgetId, + appPrompt: 'false', + skipLobby: setParams.skipLobby ?? 'true', // TODO: skipLobby is deprecated, use intent instead (intent doesn't produce the same effect?) + returnToLobby: setParams.returnToLobby ?? 'true', + perParticipantE2EE: setParams.perParticipantE2EE ?? 'true', + callIntent: setParams.callIntent ?? 'video', + header: 'none', + confineToRoom: 'true', + theme: setParams.theme ?? 'dark', + userId: mx.getUserId()!, + deviceId: mx.getDeviceId()!, + roomId, + baseUrl: mx.baseUrl!, + parentUrl: window.location.origin, + }); + + const replacedParams = params.toString().replace(/%24/g, '$'); + url.search = `?${replacedParams}`; + + return url; +}; + +export interface IApp extends IWidget { + client: MatrixClient; + roomId: string; + eventId?: string; + avatar_url?: string; + sender: string; + 'io.element.managed_hybrid'?: boolean; +} + +export class SmallWidget extends EventEmitter { + private client: MatrixClient; + + private messaging: ClientWidgetApi | null = null; + + private mockWidget: CinnyWidget; + + public roomId?: string; + + public url?: string; + + public iframe: HTMLIFrameElement | null = null; + + private type: string; // Type of the widget (e.g., 'm.call') + + private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID + + private readonly eventsToFeed = new WeakSet(); + + private stickyPromise?: () => Promise; + + 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); + this.messaging.setViewedRoomId(this.roomId ?? null); + + // Emit events during the widget lifecycle + this.messaging.on('preparing', () => this.emit('preparing')); + this.messaging.on('error:preparing', (err: unknown) => this.emit('error:preparing', err)); + this.messaging.once('ready', () => this.emit('ready')); + // this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); // Uncomment if needed + + // Populate the map of "read up to" events for this widget with the current event in every room. + // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget + // requests timeline capabilities in other rooms down the road. It's just easier to manage here. + // eslint-disable-next-line no-restricted-syntax + for (const room of this.client.getRooms()) { + // Timelines are most recent last + const events = room.getLiveTimeline()?.getEvents() || []; + const roomEvent = events[events.length - 1]; + // force later code to think the room is fresh + if (roomEvent) { + const eventId = roomEvent.getId(); + if (eventId) this.readUpToMap[room.roomId] = eventId; + } + } + + this.messaging.on('action:org.matrix.msc2876.read_events', (ev: CustomEvent) => { + const room = this.client.getRoom(this.roomId); + const events: Partial[] = []; + const { type } = ev.detail.data; + + ev.preventDefault(); + if (room === null) { + return this.messaging?.transport.reply(ev.detail, { events }); + } + const state = room.getLiveTimeline().getState(Direction.Forward); + if (state === undefined) { + return this.messaging?.transport.reply(ev.detail, { events }); + } + + const stateEvents = state.events?.get(type); + + Array.from(stateEvents?.values() ?? []).forEach((eventObject) => { + events.push(eventObject.event); + }); + + return this.messaging?.transport.reply(ev.detail, { events }); + }); + + this.client.on(ClientEvent.Event, this.onEvent); + this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.on(RoomStateEvent.Events, this.onStateUpdate); + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + this.messaging.on( + `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, + async (ev: CustomEvent) => { + if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + ev.preventDefault(); + if (ev.detail.data.value) { + // If the widget wants to become sticky we wait for the stickyPromise to resolve + if (this.stickyPromise) await this.stickyPromise(); + this.messaging.transport.reply(ev.detail, {}); + } + // Stop being persistent can be done instantly + // MAKE PERSISTENT HERE + // Send the ack after the widget actually has become sticky. + } + }, + ); + + return this.messaging; + } + + private onEvent = (ev: MatrixEvent): void => { + this.client.decryptEventIfNeeded(ev); + if (!ev.isState()) this.feedEvent(ev); + }; + + private onEventDecrypted = (ev: MatrixEvent): void => { + if (!ev.isState()) this.feedEvent(ev); + }; + + private onReadEvent = (ev: MatrixEvent): void => { + this.feedEvent(ev); + }; + + private onStateUpdate = (ev: MatrixEvent): void => { + if (this.messaging === null || !ev.isState()) return; + const raw = ev.getEffectiveEvent(); + this.messaging.feedStateUpdate(raw as IRoomEvent).catch(() => null); + }; + + private onToDeviceEvent = async (ev: MatrixEvent): Promise => { + 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(a: T[]): T[] { + return a.slice(0, a.length); + } + + private advanceReadUpToMarker(ev: MatrixEvent): boolean { + const evId = ev.getId(); + if (evId === undefined) return false; + const roomId = ev.getRoomId(); + if (roomId === undefined) return false; + const room = this.client.getRoom(roomId); + if (room === null) return false; + + const upToEventId = this.readUpToMap[ev.getRoomId()!]; + if (!upToEventId) { + // There's no marker yet; start it at this event + this.readUpToMap[roomId] = evId; + return true; + } + + // Small optimization for exact match (skip the search) + if (upToEventId === evId) return false; + + // Timelines are most recent last, so reverse the order and limit ourselves to 100 events + // to avoid overusing the CPU. + const timeline = room.getLiveTimeline(); + const events = this.arrayFastClone(timeline.getEvents()).reverse().slice(0, 100); + + let advanced = false; + + events.some((timelineEvent) => { + const id = timelineEvent.getId(); + + if (id === upToEventId) { + // The event must be somewhere before the "read up to" marker + return true; + } + + if (id === evId) { + // The event is after the marker; advance it + this.readUpToMap[roomId] = evId; + advanced = true; + return true; + } + // We can't say for sure whether the widget has seen the event; let's + // just assume that it has + return false; + }); + + return advanced; + } + + private feedEvent(ev: MatrixEvent): void { + if (this.messaging === null) return; + + if ( + // If we had decided earlier to feed this event to the widget, but + // it just wasn't ready, give it another try + this.eventsToFeed.delete(ev) || + // Skip marker timeline check for events with relations to unknown parent because these + // events are not added to the timeline here and will be ignored otherwise: + // https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213 + this.relatesToUnknown(ev) || + // Skip marker timeline check for rooms where membership is + // 'invite', otherwise the membership event from the invitation room + // will advance the marker and new state events will not be + // forwarded to the widget. + this.isFromInvite(ev) || + // Check whether this event would be before or after our "read up to" marker. If it's + // before, or we can't decide, then we assume the widget will have already seen the event. + // If the event is after, or we don't have a marker for the room, then the marker will advance and we'll + // send it through. + // This approach of "read up to" prevents widgets receiving decryption spam from startup or + // receiving ancient events from backfill and such. + this.advanceReadUpToMarker(ev) + ) { + // If the event is still being decrypted, remember that we want to + // feed it to the widget (even if not strictly in the order given by + // the timeline) and get back to it later + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { + this.eventsToFeed.add(ev); + } else { + const raw = ev.getEffectiveEvent(); + this.messaging.feedEvent(raw as IRoomEvent).catch(() => null); + } + } + } + + /** + * Stops the widget messaging and cleans up resources. + */ + stopMessaging() { + if (this.messaging) { + this.messaging.stop(); // Example if a stop method exists + this.messaging.removeAllListeners(); // Remove listeners attached by SmallWidget + this.messaging = null; + } + + this.client.off(ClientEvent.Event, this.onEvent); + this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.off(RoomStateEvent.Events, this.onStateUpdate); + this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + } +} + +/** + * 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 +}); diff --git a/src/app/features/call/SmallWidgetDriver.ts b/src/app/features/call/SmallWidgetDriver.ts new file mode 100644 index 0000000000..6764bb407a --- /dev/null +++ b/src/app/features/call/SmallWidgetDriver.ts @@ -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; + + 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): Promise> { + // Stubbed under the assumption voice calls will be valid thru element-call + return requested; + } + + public async sendEvent( + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null + ): Promise; + + public async sendEvent( + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null + ): Promise; + + public async sendEvent( + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + 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( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null + ): Promise; + + /** + * @experimental Part of MSC4140 & MSC4157 + */ + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null + ): Promise; + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + 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 { + 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 { + 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} 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 { + 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): Promise { + 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} Resolves to the events representing the + * current values of the room state entries. + */ + public async readRoomState( + roomId: string, + eventType: string, + stateKey: string | undefined + ): Promise { + 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 { + navigateToPermalink(uri); + } + */ + + public async readEventRelations( + eventId: string, + roomId?: string, + relationType?: string, + eventType?: string, + from?: string, + to?: string, + limit?: number, + direction?: 'f' | 'b' + ): Promise { + 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 { + 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 { + 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; + } +} diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index b9e754991f..0d3521672e 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -6,9 +6,8 @@ import { useAtomValue } from 'jotai'; import { ExtendedJoinRules, JoinRulesSwitcher, - useRoomJoinRuleIcon, + useJoinRuleIcons, useRoomJoinRuleLabel, - useSpaceJoinRuleIcon, } from '../../../components/JoinRulesSwitcher'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; @@ -75,8 +74,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { return r; }, [allowKnockRestricted, allowRestricted, allowKnock, space]); - const icons = useRoomJoinRuleIcon(); - const spaceIcons = useSpaceJoinRuleIcon(); + const icons = useJoinRuleIcons(room.getType()); const labels = useRoomJoinRuleLabel(); const [submitState, submit] = useAsyncCallback( @@ -137,7 +135,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { } after={ ( ( { - if (kind === CreateRoomKind.Private) return Icons.HashLock; - if (kind === CreateRoomKind.Restricted) return Icons.Hash; - return Icons.HashGlobe; +const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => { + const isVoiceRoom = type === CreateRoomType.VoiceRoom; + + let joinRule: JoinRule = JoinRule.Public; + if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted; + if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock; + + return getRoomIconSrc(Icons, isVoiceRoom ? RoomType.Call : undefined, joinRule); +}; + +const getCreateRoomTypeToIcon = (type: CreateRoomType) => { + if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh; + return Icons.Hash; }; type CreateRoomFormProps = { - defaultKind?: CreateRoomKind; + defaultAccess?: CreateRoomAccess; + defaultType?: CreateRoomType; space?: Room; onCreate?: (roomId: string) => void; }; -export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) { +export function CreateRoomForm({ + defaultAccess, + defaultType, + space, + onCreate, +}: CreateRoomFormProps) { const mx = useMatrixClient(); const alive = useAlive(); @@ -64,8 +83,9 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP const allowRestricted = space && restrictedSupported(selectedRoomVersion); - const [kind, setKind] = useState( - defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private + const [type, setType] = useState(defaultType ?? CreateRoomType.TextRoom); + const [access, setAccess] = useState( + defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private) ); const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } = @@ -75,13 +95,13 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP const [knock, setKnock] = useState(false); const [advance, setAdvance] = useState(false); - const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion); + const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion); const allowKnockRestricted = - kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion); + access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion); const handleRoomVersionChange = (version: string) => { if (!restrictedSupported(version)) { - setKind(CreateRoomKind.Private); + setAccess(CreateRoomAccess.Private); } selectRoomVersion(version); }; @@ -107,19 +127,23 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined; if (!roomName) return; - const publicRoom = kind === CreateRoomKind.Public; + const publicRoom = access === CreateRoomAccess.Public; let roomKnock = false; - if (allowKnock && kind === CreateRoomKind.Private) { + if (allowKnock && access === CreateRoomAccess.Private) { roomKnock = knock; } - if (allowKnockRestricted && kind === CreateRoomKind.Restricted) { + if (allowKnockRestricted && access === CreateRoomAccess.Restricted) { roomKnock = knock; } + let roomType: RoomType | undefined; + if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call; + create({ version: selectedRoomVersion, + type: roomType, parent: space, - kind, + access, name: roomName, topic: roomTopic || undefined, aliasLocalPart: publicRoom ? aliasLocalPart : undefined, @@ -136,21 +160,32 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP return ( + {!space && ( + + Type + + + )} Access - getCreateRoomAccessToIcon(roomAccess, type)} /> Name } + before={} name="nameInput" autoFocus size="500" @@ -171,7 +206,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP /> - {kind === CreateRoomKind.Public && } + {access === CreateRoomAccess.Public && } @@ -201,7 +236,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP /> )} - {kind !== CreateRoomKind.Public && ( + {access !== CreateRoomAccess.Public && ( <> - New Room + New {type === CreateRoomType.VoiceRoom && 'Voice '}Room @@ -74,7 +75,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { direction="Column" gap="500" > - + diff --git a/src/app/features/create-space/CreateSpace.tsx b/src/app/features/create-space/CreateSpace.tsx index 530145af47..b0c12f565a 100644 --- a/src/app/features/create-space/CreateSpace.tsx +++ b/src/app/features/create-space/CreateSpace.tsx @@ -33,25 +33,25 @@ import { createRoom, CreateRoomAliasInput, CreateRoomData, - CreateRoomKind, - CreateRoomKindSelector, + CreateRoomAccess, + CreateRoomAccessSelector, RoomVersionSelector, useAdditionalCreators, } from '../../components/create-room'; import { RoomType } from '../../../types/matrix/room'; -const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => { - if (kind === CreateRoomKind.Private) return Icons.SpaceLock; - if (kind === CreateRoomKind.Restricted) return Icons.Space; +const getCreateSpaceAccessToIcon = (access: CreateRoomAccess) => { + if (access === CreateRoomAccess.Private) return Icons.SpaceLock; + if (access === CreateRoomAccess.Restricted) return Icons.Space; return Icons.SpaceGlobe; }; type CreateSpaceFormProps = { - defaultKind?: CreateRoomKind; + defaultAccess?: CreateRoomAccess; space?: Room; onCreate?: (roomId: string) => void; }; -export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) { +export function CreateSpaceForm({ defaultAccess, space, onCreate }: CreateSpaceFormProps) { const mx = useMatrixClient(); const alive = useAlive(); @@ -65,8 +65,8 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor const allowRestricted = space && restrictedSupported(selectedRoomVersion); - const [kind, setKind] = useState( - defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private + const [access, setAccess] = useState( + defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private) ); const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); @@ -76,13 +76,13 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor const [knock, setKnock] = useState(false); const [advance, setAdvance] = useState(false); - const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion); + const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion); const allowKnockRestricted = - kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion); + access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion); const handleRoomVersionChange = (version: string) => { if (!restrictedSupported(version)) { - setKind(CreateRoomKind.Private); + setAccess(CreateRoomAccess.Private); } selectRoomVersion(version); }; @@ -108,12 +108,12 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined; if (!roomName) return; - const publicRoom = kind === CreateRoomKind.Public; + const publicRoom = access === CreateRoomAccess.Public; let roomKnock = false; - if (allowKnock && kind === CreateRoomKind.Private) { + if (allowKnock && access === CreateRoomAccess.Private) { roomKnock = knock; } - if (allowKnockRestricted && kind === CreateRoomKind.Restricted) { + if (allowKnockRestricted && access === CreateRoomAccess.Restricted) { roomKnock = knock; } @@ -121,7 +121,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor version: selectedRoomVersion, type: RoomType.Space, parent: space, - kind, + access, name: roomName, topic: roomTopic || undefined, aliasLocalPart: publicRoom ? aliasLocalPart : undefined, @@ -139,19 +139,19 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor Access - Name } + before={} name="nameInput" autoFocus size="500" @@ -172,7 +172,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor /> - {kind === CreateRoomKind.Public && } + {access === CreateRoomAccess.Public && } @@ -202,7 +202,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor /> )} - {kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && ( + {access !== CreateRoomAccess.Public && advance && (allowKnock || allowKnockRestricted) && ( {(onBack) => ( - + )} @@ -218,7 +218,11 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { } > {(triggerRef) => ( - setPeopleDrawer((drawer) => !drawer)}> + setPeopleDrawer((drawer) => !drawer)} + > )} @@ -235,7 +239,12 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { } > {(triggerRef) => ( - + )} diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 994cda0590..7de59acd91 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -175,6 +175,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf type RoomProfileProps = { roomId: string; + roomType?: string; name: string; topic?: string; avatarUrl?: string; @@ -185,6 +186,7 @@ type RoomProfileProps = { }; function RoomProfile({ roomId, + roomType, name, topic, avatarUrl, @@ -200,9 +202,7 @@ function RoomProfile({ roomId={roomId} src={avatarUrl} alt={name} - renderFallback={() => ( - - )} + renderFallback={() => } /> @@ -338,6 +338,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( {(localSummary) => ( ( {summary && ( { - openCreateRoomModal(item.roomId); + const handleCreateRoom = (type?: CreateRoomType) => { + openCreateRoomModal(item.roomId, type); setCords(undefined); }; @@ -281,10 +282,19 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { radii="300" variant="Primary" fill="None" - onClick={handleCreateRoom} + onClick={() => handleCreateRoom(CreateRoomType.TextRoom)} > New Room + handleCreateRoom(CreateRoomType.VoiceRoom)} + > + New Voice Room + Existing Room diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 929dd1e919..6883e3631f 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -29,7 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { joinRuleToIconSrc } from '../../utils/room'; +import { getRoomIconSrc } from '../../utils/room'; import { factoryRoomIdByAtoZ } from '../../utils/sort'; import { SearchItemStrGetter, @@ -274,9 +274,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto before={ } > @@ -392,10 +390,7 @@ export function SearchFilters({ onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))} radii="Pill" before={ - + } after={} > diff --git a/src/app/features/room-nav/RoomCallNavStatus.css.ts b/src/app/features/room-nav/RoomCallNavStatus.css.ts new file mode 100644 index 0000000000..059afd5add --- /dev/null +++ b/src/app/features/room-nav/RoomCallNavStatus.css.ts @@ -0,0 +1,21 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +export const Actions = style({ + padding: config.space.S200, +}); + +export const RoomButtonWrap = style({ + minWidth: 0, +}); + +export const RoomButton = style({ + width: '100%', + minWidth: 0, + padding: `0 ${config.space.S200}`, +}); + +export const RoomName = style({ + flexGrow: 1, + minWidth: 0, +}); diff --git a/src/app/features/room-nav/RoomCallNavStatus.tsx b/src/app/features/room-nav/RoomCallNavStatus.tsx new file mode 100644 index 0000000000..cd05171cdf --- /dev/null +++ b/src/app/features/room-nav/RoomCallNavStatus.tsx @@ -0,0 +1,129 @@ +import { + Box, + Chip, + Icon, + IconButton, + Icons, + Line, + Spinner, + Text, + Tooltip, + TooltipProvider, + color, +} from 'folds'; +import React from 'react'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import * as css from './RoomCallNavStatus.css'; + +export function CallNavStatus() { + const { + activeCallRoomId, + isActiveCallReady, + isAudioEnabled, + isVideoEnabled, + toggleAudio, + toggleVideo, + hangUp, + } = useCallState(); + const { navigateRoom } = useRoomNavigate(); + const hasActiveCall = Boolean(activeCallRoomId); + const isConnected = hasActiveCall && isActiveCallReady; + const handleGoToCallRoom = () => { + if (activeCallRoomId) { + navigateRoom(activeCallRoomId); + } + }; + + return ( + + + + + {hasActiveCall && ( + + Go to Room + + } + > + {(triggerRef) => ( + + {isConnected ? ( + + ) : ( + + )} + + {isConnected ? 'Connected' : 'Connecting'} + + + )} + + )} + + {hasActiveCall && ( + + Hang Up + + } + > + {(triggerRef) => ( + + + + )} + + )} + + {!isAudioEnabled ? 'Unmute' : 'Mute'} + + } + > + {(triggerRef) => ( + + + + )} + + + {!isVideoEnabled ? 'Video On' : 'Video Off'} + + } + > + {(triggerRef) => ( + + + + )} + + + + ); +} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 33b21bff24..52303fc693 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -1,5 +1,5 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { Room } from 'matrix-js-sdk'; +import React, { MouseEventHandler, forwardRef, useState, MouseEvent } from 'react'; +import { EventType, Room } from 'matrix-js-sdk'; import { Avatar, Box, @@ -16,10 +16,13 @@ import { RectCords, Badge, Spinner, + Tooltip, + TooltipProvider, } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; -import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; +import { useNavigate } from 'react-router-dom'; +import { NavButton, NavItem, NavItemContent, NavItemOptions } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; @@ -51,6 +54,12 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { useCallMembers } from '../../hooks/useCallMemberships'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { RoomNavUser } from './RoomNavUser'; +import { useRoomName } from '../../hooks/useRoomMeta'; type RoomNavItemMenuProps = { room: Room; @@ -236,6 +245,32 @@ export function RoomNavItem({ (receipt) => receipt.userId !== mx.getUserId() ); + const { + isActiveCallReady, + activeCallRoomId, + setActiveCallRoomId, + setViewedCallRoomId, + isChatOpen, + toggleChat, + hangUp, + } = useCallState(); + + const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId; + const callMemberships = useCallMembers(mx, room.roomId); + + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const roomName = useRoomName(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canJoinCall = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId()); + + const { navigateRoom } = useRoomNavigate(); + const navigate = useNavigate(); + + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); setMenuAnchor({ @@ -250,109 +285,206 @@ export function RoomNavItem({ setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleNavItemClick: MouseEventHandler = (evt) => { + if (room.isCallRoom()) { + if (!isMobile) { + if (!isActiveCall && canJoinCall) { + hangUp(); + setActiveCallRoomId(room.roomId); + } else { + navigateRoom(room.roomId); + } + } else { + evt.stopPropagation(); + if (isChatOpen) toggleChat(); + setViewedCallRoomId(room.roomId); + navigateRoom(room.roomId); + } + } else { + navigate(linkPath); + } + }; + + const handleChatButtonClick = (evt: MouseEvent) => { + evt.stopPropagation(); + if (!isChatOpen) toggleChat(); + setViewedCallRoomId(room.roomId); + navigate(linkPath); + }; + const optionsVisible = hover || !!menuAnchor; + const ariaLabel = [ + roomName, + room.isCallRoom() + ? [ + 'Call Room', + isActiveCall && 'Currently in Call', + callMemberships.length && `${callMemberships.length} in Call`, + ] + : 'Text Room', + unread?.total && `${unread.total} Messages`, + ] + .flat() + .filter(Boolean) + .join(', '); return ( - - - - - - {showAvatar ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - + + + + + + {showAvatar ? ( + ( + + {nameInitials(roomName)} + + )} + /> + ) : ( + + )} + + + + {roomName} + + + {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( + + + + )} + {!optionsVisible && unread && ( + + 0} count={unread.total} /> + + )} + {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( + )} - - - - {room.name} - - {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( - - - - )} - {!optionsVisible && unread && ( - - 0} count={unread.total} /> - - )} - {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( - - )} - - - - {optionsVisible && ( - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - notificationMode={notificationMode} - /> - - } - > - + + {optionsVisible && ( + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + notificationMode={notificationMode} + /> + + } > - - - - + {room.isCallRoom() && ( + + Open Chat + + } + > + {(triggerRef) => ( + + + + )} + + )} + + + + + + )} + + {room.isCallRoom() && ( + + {callMemberships.map((callMembership) => ( + + ))} + )} - + ); } diff --git a/src/app/features/room-nav/RoomNavUser.tsx b/src/app/features/room-nav/RoomNavUser.tsx new file mode 100644 index 0000000000..1d10218e53 --- /dev/null +++ b/src/app/features/room-nav/RoomNavUser.tsx @@ -0,0 +1,63 @@ +import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import React from 'react'; +import { Room } from 'matrix-js-sdk'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import { NavButton, NavItem, NavItemContent } from '../../components/nav'; +import { UserAvatar } from '../../components/user-avatar'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; + +type RoomNavUserProps = { + room: Room; + callMembership: CallMembership; +}; +export function RoomNavUser({ room, callMembership }: RoomNavUserProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); + const { isActiveCallReady, activeCallRoomId } = useCallState(); + const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId; + const userId = callMembership.sender ?? ''; + const avatarMxcUrl = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication) + : undefined; + const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId); + const isCallParticipant = isActiveCall && userId !== mx.getUserId(); + + const handleNavUserClick: React.MouseEventHandler = (evt) => { + openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect()); + }; + + const ariaLabel = isCallParticipant ? `Call Participant: ${name}` : name; + + return ( + + + + + + + } + /> + + + {name} + + + + + + + ); +} diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index 7572a71bf4..fe6b098ba7 100644 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ b/src/app/features/room-settings/permissions/Permissions.tsx @@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) { const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId()); const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId()); - const permissionGroups = usePermissionGroups(); + const permissionGroups = usePermissionGroups(room.isCallRoom()); const [powerEditor, setPowerEditor] = useState(false); diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts index 513f82b455..615f9410dc 100644 --- a/src/app/features/room-settings/permissions/usePermissionItems.ts +++ b/src/app/features/room-settings/permissions/usePermissionItems.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { MessageEvent, StateEvent } from '../../../../types/matrix/room'; import { PermissionGroup } from '../../common-settings/permissions'; -export const usePermissionGroups = (): PermissionGroup[] => { +export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => { const groups: PermissionGroup[] = useMemo(() => { const messagesGroup: PermissionGroup = { name: 'Messages', @@ -46,6 +46,19 @@ export const usePermissionGroups = (): PermissionGroup[] => { ], }; + const callSettingsGroup: PermissionGroup = { + name: 'Calls', + items: [ + { + location: { + state: true, + key: StateEvent.GroupCallMemberPrefix, + }, + name: 'Join Call', + }, + ], + }; + const moderationGroup: PermissionGroup = { name: 'Moderation', items: [ @@ -196,12 +209,13 @@ export const usePermissionGroups = (): PermissionGroup[] => { return [ messagesGroup, + ...(isCallRoom ? [callSettingsGroup] : []), moderationGroup, roomOverviewGroup, roomSettingsGroup, otherSettingsGroup, ]; - }, []); + }, [isCallRoom]); return groups; }; diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 24878d5e6e..caad789e5f 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -13,6 +13,9 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { markAsRead } from '../../utils/notifications'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { CallView } from '../call/CallView'; +import { RoomViewHeader } from './RoomViewHeader'; +import { useCallState } from '../../pages/client/call/CallProvider'; export function Room() { const { eventId } = useParams(); @@ -24,6 +27,7 @@ export function Room() { const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); const members = useRoomMembers(mx, room.roomId); + const { isChatOpen } = useCallState(); useKeyDown( window, @@ -40,7 +44,16 @@ export function Room() { return ( - + + + + + {room.isCallRoom() && screenSize === ScreenSize.Desktop && isChatOpen && ( + + )} + {(!room.isCallRoom() || isChatOpen) && } + + {screenSize === ScreenSize.Desktop && isDrawer && ( <> diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 0f8375943c..9aed6b8e2d 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { Box, Text, config } from 'folds'; +import { Box, Text, config, toRem } from 'folds'; import { EventType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -15,13 +15,13 @@ import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { Page } from '../../components/page'; -import { RoomViewHeader } from './RoomViewHeader'; import { useKeyDown } from '../../hooks/useKeyDown'; import { editableActiveElement } from '../../utils/dom'; import { settingsAtom } from '../../state/settings'; import { useSetting } from '../../state/hooks/settings'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { @@ -30,10 +30,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { return false; } - // do not focus on F keys if (FN_KEYS_REGEX.test(code)) return false; - // do not focus on numlock/scroll lock if ( code.startsWith('OS') || code.startsWith('Meta') || @@ -61,6 +59,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { const roomViewRef = useRef(null); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const screenSize = useScreenSizeContext(); const { roomId } = room; const editor = useEditor(); @@ -92,8 +91,14 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { ); return ( - - + (); const [pinMenuAnchor, setPinMenuAnchor] = useState(); - const mDirects = useAtomValue(mDirectAtom); + const direct = useIsDirectRoom(); + const { isChatOpen, toggleChat } = useCallState(); const pinnedEvents = useRoomPinnedEvents(room); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); - const ecryptedRoom = !!encryptionEvent; - const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); + const encryptedRoom = !!encryptionEvent; + const avatarMxc = useRoomAvatar(room, direct); const name = useRoomName(room); const topic = useRoomTopic(room); const avatarUrl = avatarMxc @@ -296,13 +297,16 @@ export function RoomViewHeader() { }; return ( - + {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + @@ -317,11 +321,7 @@ export function RoomViewHeader() { src={avatarUrl} alt={name} renderFallback={() => ( - + )} /> @@ -369,97 +369,127 @@ export function RoomViewHeader() { )} + - {!ecryptedRoom && ( + {(!room.isCallRoom() || isChatOpen) && ( + <> + {!encryptedRoom && ( + + Search + + } + > + {(triggerRef) => ( + + + + )} + + )} + + Pinned Messages + + } + > + {(triggerRef) => ( + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + + + )} + + setPinMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(undefined)} /> + + } + /> + + )} + + {screenSize === ScreenSize.Desktop && ( - Search + {peopleDrawer ? 'Hide Members' : 'Show Members'} } > {(triggerRef) => ( - - + setPeopleDrawer((drawer) => !drawer)} + > + )} )} - - Pinned Messages - - } - > - {(triggerRef) => ( - - {pinnedEvents.length > 0 && ( - - - {pinnedEvents.length} - - - )} - - - )} - - setPinMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setPinMenuAnchor(undefined)} /> - - } - /> - {screenSize === ScreenSize.Desktop && ( + + {room.isCallRoom() && !direct && ( - {peopleDrawer ? 'Hide Members' : 'Show Members'} + Chat } > {(triggerRef) => ( - setPeopleDrawer((drawer) => !drawer)}> - + + )} )} + {(triggerRef) => ( - + )} diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index fcd6233a28..6027f322e7 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -373,7 +373,7 @@ export function Search({ requestClose }: SearchProps) { )} diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index e565fb92cf..b5fefc9373 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -103,7 +103,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) alt={roomName} renderFallback={() => ( { + const [memberships, setMemberships] = useState([]); + const room = mx.getRoom(roomId); + useEffect(() => { + if (!room) { + setMemberships([]); + return undefined; + } + + const mxr = mx.matrixRTC.getRoomSession(room); + + const updateMemberships = () => { + if (!room.isCallRoom()) return; + setMemberships(MatrixRTCSession.callMembershipsForRoom(room)); + }; + + updateMemberships(); + + mxr.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + return () => { + mxr.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + }; + }, [mx, room, roomId]); + + return memberships; +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e5fc6cc617..70ba26bb30 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -9,6 +9,7 @@ export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; allowCustomHomeservers?: boolean; + elementCallUrl?: string; featuredCommunities?: { openAsDefault?: boolean; diff --git a/src/app/hooks/useRoomMeta.ts b/src/app/hooks/useRoomMeta.ts index 8b0ae8ccc3..086c3a56f9 100644 --- a/src/app/hooks/useRoomMeta.ts +++ b/src/app/hooks/useRoomMeta.ts @@ -20,6 +20,8 @@ export const useRoomName = (room: Room): string => { const [name, setName] = useState(room.name); useEffect(() => { + setName(room.name); + const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => { setName(room.name); }; diff --git a/src/app/hooks/useStateEvents.ts b/src/app/hooks/useStateEvents.ts deleted file mode 100644 index dd08569307..0000000000 --- a/src/app/hooks/useStateEvents.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { Room } from 'matrix-js-sdk'; -import { StateEvent } from '../../types/matrix/room'; -import { useForceUpdate } from './useForceUpdate'; -import { useStateEventCallback } from './useStateEventCallback'; -import { getStateEvents } from '../utils/room'; - -export const useStateEvents = (room: Room, eventType: StateEvent) => { - const [updateCount, forceUpdate] = useForceUpdate(); - - useStateEventCallback( - room.client, - useCallback( - (event) => { - if (event.getRoomId() === room.roomId && event.getType() === eventType) { - forceUpdate(); - } - }, - [room, eventType, forceUpdate] - ) - ); - - return useMemo( - () => getStateEvents(room, eventType), - // eslint-disable-next-line react-hooks/exhaustive-deps - [room, eventType, updateCount] - ); -}; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 04d14a07f1..2a15a7acc2 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -69,6 +69,8 @@ import { CreateSpaceModalRenderer } from '../features/create-space'; import { SearchModalRenderer } from '../features/search'; import { getFallbackSession } from '../state/sessions'; import { pushSessionToSW } from '../../sw-session'; +import { CallProvider } from './client/call/CallProvider'; +import { PersistentCallContainer } from './client/call/PersistentCallContainer'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -126,15 +128,19 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - } - > - - + + + + + } + > + + + + + diff --git a/src/app/pages/client/call/CallProvider.tsx b/src/app/pages/client/call/CallProvider.tsx new file mode 100644 index 0000000000..ffa15e9468 --- /dev/null +++ b/src/app/pages/client/call/CallProvider.tsx @@ -0,0 +1,343 @@ +import React, { + createContext, + useState, + useContext, + useMemo, + useCallback, + ReactNode, + useEffect, +} from 'react'; +import { + WidgetApiToWidgetAction, + WidgetApiAction, + ClientWidgetApi, + IWidgetApiRequestData, +} from 'matrix-widget-api'; +import { useParams } from 'react-router-dom'; +import { SmallWidget } from '../../../features/call/SmallWidget'; + +interface MediaStatePayload { + data?: { + audio_enabled?: boolean; + video_enabled?: boolean; + }; +} + +const WIDGET_MEDIA_STATE_UPDATE_ACTION = 'io.element.device_mute'; +const WIDGET_HANGUP_ACTION = 'im.vector.hangup'; +const WIDGET_ON_SCREEN_ACTION = 'set_always_on_screen'; +const WIDGET_JOIN_ACTION = 'io.element.join'; +const WIDGET_TILE_UPDATE = 'io.element.tile_layout'; + +interface CallContextState { + activeCallRoomId: string | null; + setActiveCallRoomId: (roomId: string | null) => void; + viewedCallRoomId: string | null; + setViewedCallRoomId: (roomId: string | null) => void; + hangUp: () => void; + activeClientWidgetApi: ClientWidgetApi | null; + activeClientWidget: SmallWidget | null; + registerActiveClientWidgetApi: ( + roomId: string | null, + clientWidgetApi: ClientWidgetApi | null, + clientWidget: SmallWidget, + activeClientIframeRef: HTMLIFrameElement + ) => void; + sendWidgetAction: ( + action: WidgetApiToWidgetAction | string, + data: T + ) => Promise; + isAudioEnabled: boolean; + isVideoEnabled: boolean; + isChatOpen: boolean; + isActiveCallReady: boolean; + toggleAudio: () => Promise; + toggleVideo: () => Promise; + toggleChat: () => Promise; +} + +const CallContext = createContext(undefined); + +interface CallProviderProps { + children: ReactNode; +} + +const DEFAULT_AUDIO_ENABLED = true; +const DEFAULT_VIDEO_ENABLED = false; +const DEFAULT_CHAT_OPENED = false; + +export function CallProvider({ children }: CallProviderProps) { + const [activeCallRoomId, setActiveCallRoomIdState] = useState(null); + const [viewedCallRoomId, setViewedCallRoomIdState] = useState(null); + + const [activeClientWidgetApi, setActiveClientWidgetApiState] = useState( + null + ); + const [activeClientWidget, setActiveClientWidget] = useState(null); + const [activeClientWidgetApiRoomId, setActiveClientWidgetApiRoomId] = useState( + null + ); + const [activeClientWidgetIframeRef, setActiveClientWidgetIframeRef] = + useState(null); + + const [isAudioEnabled, setIsAudioEnabledState] = useState(DEFAULT_AUDIO_ENABLED); + const [isVideoEnabled, setIsVideoEnabledState] = useState(DEFAULT_VIDEO_ENABLED); + const [isChatOpen, setIsChatOpenState] = useState(DEFAULT_CHAT_OPENED); + const [isActiveCallReady, setIsActiveCallReady] = useState(false); + + const { roomIdOrAlias: viewedRoomId } = useParams<{ roomIdOrAlias: string }>(); + + const setActiveCallRoomId = useCallback((roomId: string | null) => { + setActiveCallRoomIdState(roomId); + }, []); + + const setViewedCallRoomId = useCallback( + (roomId: string | null) => { + setViewedCallRoomIdState(roomId); + }, + [setViewedCallRoomIdState] + ); + + const setActiveClientWidgetApi = useCallback( + ( + clientWidgetApi: ClientWidgetApi | null, + clientWidget: SmallWidget | null, + roomId: string | null, + clientWidgetIframeRef: HTMLIFrameElement | null + ) => { + setActiveClientWidgetApiState(clientWidgetApi); + setActiveClientWidget(clientWidget); + setActiveClientWidgetApiRoomId(roomId); + setActiveClientWidgetIframeRef(clientWidgetIframeRef); + }, + [] + ); + + const registerActiveClientWidgetApi = useCallback( + ( + roomId: string | null, + clientWidgetApi: ClientWidgetApi | null, + clientWidget: SmallWidget | null, + clientWidgetIframeRef: HTMLIFrameElement | null + ) => { + if (roomId && clientWidgetApi) { + setActiveClientWidgetApi(clientWidgetApi, clientWidget, roomId, clientWidgetIframeRef); + } else if (roomId === activeClientWidgetApiRoomId || roomId === null) { + setActiveClientWidgetApi(null, null, null, null); + } + }, + [activeClientWidgetApiRoomId, setActiveClientWidgetApi] + ); + + const hangUp = useCallback(() => { + setActiveClientWidgetApi(null, null, null, null); + setActiveCallRoomIdState(null); + activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {}); + setIsActiveCallReady(false); + }, [activeClientWidgetApi?.transport, setActiveClientWidgetApi]); + + const sendWidgetAction = useCallback( + async ( + action: WidgetApiToWidgetAction | string, + data: T + ): Promise => { + if (!activeClientWidgetApi) { + return Promise.reject(new Error('No active call clientWidgetApi')); + } + if (!activeClientWidgetApiRoomId || activeClientWidgetApiRoomId !== activeCallRoomId) { + return Promise.reject(new Error('Mismatched active call clientWidgetApi')); + } + + await activeClientWidgetApi.transport.send(action as WidgetApiAction, data); + + return Promise.resolve(); + }, + [activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId] + ); + + const toggleAudio = useCallback(async () => { + const newState = !isAudioEnabled; + setIsAudioEnabledState(newState); + + if (isActiveCallReady) { + try { + await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { + audio_enabled: newState, + video_enabled: isVideoEnabled, + }); + } catch (error) { + setIsAudioEnabledState(!newState); + throw error; + } + } + }, [isAudioEnabled, isVideoEnabled, sendWidgetAction, isActiveCallReady]); + + const toggleVideo = useCallback(async () => { + const newState = !isVideoEnabled; + setIsVideoEnabledState(newState); + + if (isActiveCallReady) { + try { + await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { + audio_enabled: isAudioEnabled, + video_enabled: newState, + }); + } catch (error) { + setIsVideoEnabledState(!newState); + throw error; + } + } + }, [isVideoEnabled, isAudioEnabled, sendWidgetAction, isActiveCallReady]); + + useEffect(() => { + if (!activeCallRoomId && !viewedCallRoomId) { + return; + } + + if (!activeClientWidgetApi) { + return; + } + + const handleHangup = (ev: CustomEvent) => { + ev.preventDefault(); + if (isActiveCallReady && ev.detail.widgetId === activeClientWidgetApi.widget.id) { + activeClientWidgetApi.transport.reply(ev.detail, {}); + } + }; + + const handleMediaStateUpdate = (ev: CustomEvent) => { + if (!isActiveCallReady) return; + ev.preventDefault(); + + /* eslint-disable camelcase */ + const { audio_enabled, video_enabled } = ev.detail.data ?? {}; + + if (typeof audio_enabled === 'boolean' && audio_enabled !== isAudioEnabled) { + setIsAudioEnabledState(audio_enabled); + } + if (typeof video_enabled === 'boolean' && video_enabled !== isVideoEnabled) { + setIsVideoEnabledState(video_enabled); + } + /* eslint-enable camelcase */ + }; + + const handleOnScreenStateUpdate = (ev: CustomEvent) => { + ev.preventDefault(); + activeClientWidgetApi.transport.reply(ev.detail, {}); + }; + + const handleOnTileLayout = (ev: CustomEvent) => { + ev.preventDefault(); + + activeClientWidgetApi.transport.reply(ev.detail, {}); + }; + + const handleJoin = (ev: CustomEvent) => { + ev.preventDefault(); + + activeClientWidgetApi.transport.reply(ev.detail, {}); + + const iframeDoc = + activeClientWidgetIframeRef?.contentWindow?.document || + activeClientWidgetIframeRef?.contentDocument; + + if (iframeDoc) { + const observer = new MutationObserver(() => { + const button = iframeDoc.querySelector('[data-testid="incall_leave"]'); + if (button) { + button.addEventListener('click', () => { + hangUp(); + }); + } + observer.disconnect(); + }); + observer.observe(iframeDoc, { childList: true, subtree: true }); + } + + setIsActiveCallReady(true); + }; + + void sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { + audio_enabled: isAudioEnabled, + video_enabled: isVideoEnabled, + }).catch(() => { + // Widget transport may reject while call/session setup is still in progress. + }); + + activeClientWidgetApi.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup); + activeClientWidgetApi.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate); + activeClientWidgetApi.on(`action:${WIDGET_TILE_UPDATE}`, handleOnTileLayout); + activeClientWidgetApi.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate); + activeClientWidgetApi.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin); + }, [ + activeClientWidgetIframeRef, + activeClientWidgetApi, + activeCallRoomId, + activeClientWidgetApiRoomId, + hangUp, + isChatOpen, + isAudioEnabled, + isVideoEnabled, + isActiveCallReady, + viewedRoomId, + viewedCallRoomId, + setViewedCallRoomId, + activeClientWidget?.iframe?.contentDocument, + activeClientWidget?.iframe?.contentWindow?.document, + sendWidgetAction, + ]); + + const toggleChat = useCallback(async () => { + const newState = !isChatOpen; + setIsChatOpenState(newState); + }, [isChatOpen]); + + const contextValue = useMemo( + () => ({ + activeCallRoomId, + setActiveCallRoomId, + viewedCallRoomId, + setViewedCallRoomId, + hangUp, + activeClientWidgetApi, + registerActiveClientWidgetApi, + activeClientWidget, + sendWidgetAction, + isChatOpen, + isAudioEnabled, + isVideoEnabled, + isActiveCallReady, + toggleAudio, + toggleVideo, + toggleChat, + }), + [ + activeCallRoomId, + setActiveCallRoomId, + viewedCallRoomId, + setViewedCallRoomId, + hangUp, + activeClientWidgetApi, + registerActiveClientWidgetApi, + activeClientWidget, + sendWidgetAction, + isChatOpen, + isAudioEnabled, + isVideoEnabled, + isActiveCallReady, + toggleAudio, + toggleVideo, + toggleChat, + ] + ); + + return {children}; +} + +export function useCallState(): CallContextState { + const context = useContext(CallContext); + if (context === undefined) { + throw new Error('useCallState must be used within a CallProvider'); + } + return context; +} diff --git a/src/app/pages/client/call/PersistentCallContainer.tsx b/src/app/pages/client/call/PersistentCallContainer.tsx new file mode 100644 index 0000000000..fabb048305 --- /dev/null +++ b/src/app/pages/client/call/PersistentCallContainer.tsx @@ -0,0 +1,187 @@ +import React, { createContext, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; +import { ClientWidgetApi } from 'matrix-widget-api'; +import { Box } from 'folds'; +import { useCallState } from './CallProvider'; +import { + createVirtualWidget, + SmallWidget, + getWidgetData, + getWidgetUrl, +} from '../../../features/call/SmallWidget'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useClientConfig } from '../../../hooks/useClientConfig'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { ThemeKind, useTheme } from '../../../hooks/useTheme'; + +interface PersistentCallContainerProps { + children: ReactNode; +} + +export const CallRefContext = + createContext | null>(null); + +export function PersistentCallContainer({ children }: PersistentCallContainerProps) { + const callIframeRef = useRef(null); + const callWidgetApiRef = useRef(null); + const callSmallWidgetRef = useRef(null); + + const { + activeCallRoomId, + viewedCallRoomId, + isChatOpen, + isActiveCallReady, + registerActiveClientWidgetApi, + activeClientWidget, + } = useCallState(); + const mx = useMatrixClient(); + const clientConfig = useClientConfig(); + const screenSize = useScreenSizeContext(); + const theme = useTheme(); + const isMobile = screenSize === ScreenSize.Mobile; + + /* eslint-disable no-param-reassign */ + + const setupWidget = useCallback( + ( + widgetApiRef: React.MutableRefObject, + smallWidgetRef: React.MutableRefObject, + iframeRef: React.MutableRefObject, + skipLobby: boolean, + themeKind: ThemeKind | null, + ) => { + if (mx?.getUserId()) { + if (activeCallRoomId && !isActiveCallReady) { + const roomIdToSet = activeCallRoomId; + + const widgetId = `element-call-${roomIdToSet}-${Date.now()}`; + const newUrl = getWidgetUrl( + mx, + roomIdToSet, + clientConfig.elementCallUrl ?? '', + widgetId, + { + skipLobby: skipLobby.toString(), + returnToLobby: 'true', + perParticipantE2EE: 'true', + theme: themeKind, + callIntent: 'audio', + }, + ); + + if ( + callSmallWidgetRef.current?.roomId && + activeClientWidget?.roomId && + activeClientWidget.roomId === callSmallWidgetRef.current?.roomId + ) { + return; + } + + if ( + iframeRef.current && + (!iframeRef.current.src || iframeRef.current.src !== newUrl.toString()) + ) { + iframeRef.current.src = newUrl.toString(); + } + + const iframeElement = iframeRef.current; + if (!iframeElement) { + return; + } + + const userId = mx.getUserId() ?? ''; + const app = createVirtualWidget( + mx, + widgetId, + userId, + 'Element Call', + 'm.call', + newUrl, + false, + getWidgetData(mx, roomIdToSet, {}, { skipLobby: true, callIntent: 'audio' }), + roomIdToSet, + ); + + const smallWidget = new SmallWidget(app); + smallWidgetRef.current = smallWidget; + + const widgetApiInstance = smallWidget.startMessaging(iframeElement); + widgetApiRef.current = widgetApiInstance; + registerActiveClientWidgetApi( + roomIdToSet, + widgetApiRef.current, + smallWidget, + iframeElement, + ); + } + } + }, + [ + mx, + activeCallRoomId, + isActiveCallReady, + clientConfig.elementCallUrl, + activeClientWidget, + registerActiveClientWidgetApi, + ], + ); + + useEffect(() => { + if (activeCallRoomId) { + setupWidget(callWidgetApiRef, callSmallWidgetRef, callIframeRef, true, theme.kind); + } + }, [ + theme, + setupWidget, + callWidgetApiRef, + callSmallWidgetRef, + callIframeRef, + registerActiveClientWidgetApi, + activeCallRoomId, + viewedCallRoomId, + isActiveCallReady, + ]); + + const memoizedIframeRef = useMemo(() => callIframeRef, [callIframeRef]); + + return ( + + + + +