diff --git a/config.json b/config.json index 644f0eb6..aed232e6 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,6 @@ { "defaultHomeserver": 1, - "homeserverList": [ - "converser.eu", - "matrix.org", - "mozilla.org", - "unredacted.org", - "xmr.se" - ], + "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], "allowCustomHomeservers": true, "featuredCommunities": { @@ -27,7 +21,7 @@ "#PrivSec.dev:arcticfoxes.net", "#disroot:aria-net.org" ], - "servers": [ "matrix.org", "mozilla.org", "unredacted.org" ] + "servers": ["matrix.org", "mozilla.org", "unredacted.org"] }, "hashRouter": { diff --git a/package-lock.json b/package-lock.json index 05466866..4648a9fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.17.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", @@ -8666,9 +8674,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.17.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz", + "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==", "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", @@ -10909,6 +10917,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 de8ee72d..4eeead2d 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 .", @@ -55,6 +56,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.17.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/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx new file mode 100644 index 00000000..9f4b3f2b --- /dev/null +++ b/src/app/components/CallEmbedProvider.tsx @@ -0,0 +1,63 @@ +import React, { ReactNode, useCallback, useRef } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + CallEmbedContextProvider, + CallEmbedRefContextProvider, + useCallHangupEvent, + useCallJoined, + useCallThemeSync, +} from '../hooks/useCallEmbed'; +import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; +import { CallEmbed } from '../plugins/call'; +import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; +import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; + +function CallUtils({ embed }: { embed: CallEmbed }) { + const setCallEmbed = useSetAtom(callEmbedAtom); + + useCallThemeSync(embed); + useCallHangupEvent( + embed, + useCallback(() => { + setCallEmbed(undefined); + }, [setCallEmbed]) + ); + + return null; +} + +type CallEmbedProviderProps = { + children?: ReactNode; +}; +export function CallEmbedProvider({ children }: CallEmbedProviderProps) { + const callEmbed = useAtomValue(callEmbedAtom); + const callEmbedRef = useRef(null); + const joined = useCallJoined(callEmbed); + + const selectedRoom = useSelectedRoom(); + const chat = useAtomValue(callChatAtom); + const screenSize = useScreenSizeContext(); + + const chatOnlyView = chat && screenSize !== ScreenSize.Desktop; + + const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView; + + return ( + + {callEmbed && } + {children} +
+ + ); +} diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx index 9507317a..bbc0a65d 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 => + +export const useJoinRuleIcons = (roomType?: string): 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, + [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), }), - [] - ); -export const useSpaceJoinRuleIcon = (): 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, - }), - [] + [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 096954fb..35f39af8 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 00000000..b1f69c39 --- /dev/null +++ b/src/app/components/create-room/CreateRoomTypeSelector.tsx @@ -0,0 +1,75 @@ +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'; +import { BetaNoticeBadge } from '../BetaNoticeBadge'; + +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 && } + > + + + Chat Room + + + - Messages, photos, and videos. + + + + + onSelect(CreateRoomType.VoiceRoom)} + disabled={disabled} + > + } + after={value === CreateRoomType.VoiceRoom && } + > + + + Voice Room + + + - Live audio and video conversations. + + + + + + + ); +} diff --git a/src/app/components/create-room/index.ts b/src/app/components/create-room/index.ts index ffd9cb3d..cce6e037 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 00000000..8b54587d --- /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 d0a45c3d..f3e699aa 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: [ @@ -86,11 +86,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; @@ -110,7 +122,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, @@ -122,6 +138,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 b0c64f60..377cecab 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -169,12 +169,13 @@ export function RoomMentionAutocomplete({ )} /> ) : ( - + )} } diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 23f3998d..cbcd626a 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/components/sequence-card/SequenceCard.tsx b/src/app/components/sequence-card/SequenceCard.tsx index d0e77ae6..46d5ea0f 100644 --- a/src/app/components/sequence-card/SequenceCard.tsx +++ b/src/app/components/sequence-card/SequenceCard.tsx @@ -17,6 +17,7 @@ export const SequenceCard = as< firstChild, lastChild, outlined, + mergeBorder, ...props }, ref @@ -24,7 +25,7 @@ export const SequenceCard = as< ( + ({ size, variant, className, ...props }, ref) => ( + + ) +); diff --git a/src/app/components/stacked-avatar/index.ts b/src/app/components/stacked-avatar/index.ts new file mode 100644 index 00000000..63d9f56d --- /dev/null +++ b/src/app/components/stacked-avatar/index.ts @@ -0,0 +1 @@ +export * from './StackedAvatar'; diff --git a/src/app/components/stacked-avatar/styles.css.ts b/src/app/components/stacked-avatar/styles.css.ts new file mode 100644 index 00000000..3bbf7271 --- /dev/null +++ b/src/app/components/stacked-avatar/styles.css.ts @@ -0,0 +1,59 @@ +import { ComplexStyleRule } from '@vanilla-extract/css'; +import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; +import { color, config, ContainerColor, toRem } from 'folds'; + +const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ + outlineColor: color[variant].Container, +}); + +export const StackedAvatar = recipe({ + base: { + backgroundColor: color.Surface.Container, + outlineStyle: 'solid', + selectors: { + '&:first-child': { + marginLeft: 0, + }, + 'button&': { + cursor: 'pointer', + }, + }, + }, + + variants: { + size: { + '200': { + marginLeft: toRem(-6), + outlineWidth: config.borderWidth.B300, + }, + '300': { + marginLeft: toRem(-9), + outlineWidth: config.borderWidth.B400, + }, + '400': { + marginLeft: toRem(-10.5), + outlineWidth: config.borderWidth.B500, + }, + '500': { + marginLeft: toRem(-13), + outlineWidth: config.borderWidth.B600, + }, + }, + variant: { + Background: getVariant('Background'), + Surface: getVariant('Surface'), + SurfaceVariant: getVariant('SurfaceVariant'), + Primary: getVariant('Primary'), + Secondary: getVariant('Secondary'), + Success: getVariant('Success'), + Warning: getVariant('Warning'), + Critical: getVariant('Critical'), + }, + }, + defaultVariants: { + size: '400', + variant: 'Surface', + }, +}); + +export type StackedAvatarVariants = RecipeVariants; diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 53e6618b..82880634 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -323,7 +323,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) { )} /> ) : ( - + )} } diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index cbae018f..d100096b 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -291,7 +291,11 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM )} /> ) : ( - + )} } diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx new file mode 100644 index 00000000..39cd975b --- /dev/null +++ b/src/app/features/call-status/CallControl.tsx @@ -0,0 +1,168 @@ +import { Box, Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds'; +import React, { useState } from 'react'; +import { StatusDivider } from './components'; +import { CallEmbed, useCallControlState } from '../../plugins/call'; + +type MicrophoneButtonProps = { + enabled: boolean; + onToggle: () => Promise; +}; +function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { + return ( + + {enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined={!enabled} + > + + + )} + + ); +} + +type SoundButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +function SoundButton({ enabled, onToggle }: SoundButtonProps) { + return ( + + {enabled ? 'Turn Off Sound' : 'Turn On Sound'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined={!enabled} + > + + + )} + + ); +} + +type VideoButtonProps = { + enabled: boolean; + onToggle: () => Promise; +}; +function VideoButton({ enabled, onToggle }: VideoButtonProps) { + return ( + + {enabled ? 'Stop Camera' : 'Start Camera'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined={enabled} + > + + + )} + + ); +} + +function ScreenShareButton() { + const [enabled, setEnabled] = useState(false); + + return ( + + {enabled ? 'Stop Screenshare' : 'Start Screenshare'} + + } + > + {(anchorRef) => ( + setEnabled(!enabled)} + outlined={enabled} + > + + + )} + + ); +} + +export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) { + const { microphone, video, sound } = useCallControlState(callEmbed.control); + + return ( + + + callEmbed.control.toggleMicrophone()} + /> + callEmbed.control.toggleSound()} /> + + + + callEmbed.control.toggleVideo()} /> + {false && } + + + } + outlined + onClick={() => callEmbed.hangup()} + > + + End + + + + ); +} diff --git a/src/app/features/call-status/CallRoomName.tsx b/src/app/features/call-status/CallRoomName.tsx new file mode 100644 index 00000000..39f0e914 --- /dev/null +++ b/src/app/features/call-status/CallRoomName.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Room } from 'matrix-js-sdk'; +import { Chip, Text } from 'folds'; +import { useAtomValue } from 'jotai'; +import { useRoomName } from '../../hooks/useRoomMeta'; +import { RoomIcon } from '../../components/room-avatar'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { getAllParents, guessPerfectParent } from '../../utils/room'; +import { useOrphanSpaces } from '../../state/hooks/roomList'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { mDirectAtom } from '../../state/mDirectList'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; + +type CallRoomNameProps = { + room: Room; +}; +export function CallRoomName({ room }: CallRoomNameProps) { + const mx = useMatrixClient(); + const name = useRoomName(room); + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const mDirects = useAtomValue(mDirectAtom); + const dm = mDirects.has(room.roomId); + + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const allParents = getAllParents(roomToParents, room.roomId); + const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o)); + const perfectOrphanParent = orphanParents && guessPerfectParent(mx, room.roomId, orphanParents); + + const { navigateRoom } = useRoomNavigate(); + + return ( + + } + onClick={() => navigateRoom(room.roomId)} + > + + {name} + {!dm && perfectOrphanParent && ( + + {' •'} {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} + + )} + + + ); +} diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx new file mode 100644 index 00000000..e2a17222 --- /dev/null +++ b/src/app/features/call-status/CallStatus.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Box, Spinner } from 'folds'; +import classNames from 'classnames'; +import { LiveChip } from './LiveChip'; +import * as css from './styles.css'; +import { CallRoomName } from './CallRoomName'; +import { CallControl } from './CallControl'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import { useCallMembers, useCallSession } from '../../hooks/useCall'; +import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize'; +import { MemberGlance } from './MemberGlance'; +import { StatusDivider } from './components'; +import { CallEmbed } from '../../plugins/call/CallEmbed'; +import { useCallJoined } from '../../hooks/useCallEmbed'; + +type CallStatusProps = { + callEmbed: CallEmbed; +}; +export function CallStatus({ callEmbed }: CallStatusProps) { + const { room } = callEmbed; + + const callSession = useCallSession(room); + const callMembers = useCallMembers(room, callSession); + const screenSize = useScreenSize(); + const callJoined = useCallJoined(callEmbed); + + return ( + + + {callJoined && callMembers.length > 0 ? ( + + + + + ) : ( + + )} + + + + + + + + ); +} diff --git a/src/app/features/call-status/LiveChip.tsx b/src/app/features/call-status/LiveChip.tsx new file mode 100644 index 00000000..34167fb6 --- /dev/null +++ b/src/app/features/call-status/LiveChip.tsx @@ -0,0 +1,137 @@ +import React, { MouseEventHandler, useState } from 'react'; +import { + Avatar, + Badge, + Box, + Chip, + config, + Icon, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Scroll, + Text, + toRem, +} from 'folds'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import FocusTrap from 'focus-trap-react'; +import { Room } from 'matrix-js-sdk'; +import * as css from './styles.css'; +import { stopPropagation } from '../../utils/keyboard'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { UserAvatar } from '../../components/user-avatar'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { getMouseEventCords } from '../../utils/dom'; + +type LiveChipProps = { + room: Room; + members: CallMembership[]; + count: number; +}; +export function LiveChip({ count, room, members }: LiveChipProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openUserProfile = useOpenUserRoomProfile(); + + const [cords, setCords] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + + {members.map((callMember) => { + const userId = callMember.sender; + if (!userId) return null; + const name = + getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined + : undefined; + + return ( + + openUserProfile( + room.roomId, + undefined, + userId, + getMouseEventCords(evt.nativeEvent), + 'Right' + ) + } + before={ + + } + /> + + } + > + + {name} + + + ); + })} + + + + + + } + > + } + after={} + radii="Pill" + onClick={handleOpenMenu} + > + + {count} Live + + + + ); +} diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx new file mode 100644 index 00000000..f4991f93 --- /dev/null +++ b/src/app/features/call-status/MemberGlance.tsx @@ -0,0 +1,72 @@ +import { Box, config, Icon, Icons, Text } from 'folds'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import React from 'react'; +import { Room } from 'matrix-js-sdk'; +import { UserAvatar } from '../../components/user-avatar'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { StackedAvatar } from '../../components/stacked-avatar'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { getMouseEventCords } from '../../utils/dom'; + +type MemberGlanceProps = { + room: Room; + members: CallMembership[]; + max?: number; +}; +export function MemberGlance({ room, members, max = 6 }: MemberGlanceProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openUserProfile = useOpenUserRoomProfile(); + + const visibleMembers = members.slice(0, max); + const remainingCount = max && members.length > max ? members.length - max : 0; + + return ( + + {visibleMembers.map((callMember) => { + const userId = callMember.sender; + if (!userId) return null; + const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined + : undefined; + + return ( + + openUserProfile( + room.roomId, + undefined, + userId, + getMouseEventCords(evt.nativeEvent), + 'Top' + ) + } + > + } + /> + + ); + })} + {remainingCount > 0 && ( + + +{remainingCount} + + )} + + ); +} diff --git a/src/app/features/call-status/components.tsx b/src/app/features/call-status/components.tsx new file mode 100644 index 00000000..cd0c0daf --- /dev/null +++ b/src/app/features/call-status/components.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { Line } from 'folds'; +import * as css from './styles.css'; + +export function StatusDivider() { + return ( + + ); +} diff --git a/src/app/features/call-status/index.ts b/src/app/features/call-status/index.ts new file mode 100644 index 00000000..99accf8b --- /dev/null +++ b/src/app/features/call-status/index.ts @@ -0,0 +1 @@ +export * from './CallStatus'; diff --git a/src/app/features/call-status/styles.css.ts b/src/app/features/call-status/styles.css.ts new file mode 100644 index 00000000..26b08f0b --- /dev/null +++ b/src/app/features/call-status/styles.css.ts @@ -0,0 +1,17 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const LiveChipText = style({ + color: color.Critical.Main, +}); + +export const CallStatus = style([ + { + padding: `${toRem(6)} ${config.space.S200}`, + borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, + }, +]); + +export const ControlDivider = style({ + height: toRem(16), +}); diff --git a/src/app/features/call/CallMemberCard.tsx b/src/app/features/call/CallMemberCard.tsx new file mode 100644 index 00000000..f96b9a06 --- /dev/null +++ b/src/app/features/call/CallMemberCard.tsx @@ -0,0 +1,121 @@ +import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import React, { useState } from 'react'; +import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { SequenceCard } from '../../components/sequence-card'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useRoom } from '../../hooks/useRoom'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { UserAvatar } from '../../components/user-avatar'; +import { getMouseEventCords } from '../../utils/dom'; +import * as css from './styles.css'; + +interface MemberWithMembershipData { + membershipData?: SessionMembershipData & { + 'm.call.intent': 'video' | 'audio'; + }; +} + +type CallMemberCardProps = { + member: CallMembership; +}; +export function CallMemberCard({ member }: CallMemberCardProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const room = useRoom(); + + const openUserProfile = useOpenUserRoomProfile(); + + const userId = member.sender; + if (!userId) return null; + + const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined + : undefined; + + const audioOnly = + (member as unknown as MemberWithMembershipData).membershipData?.['m.call.intent'] === 'audio'; + + return ( + + openUserProfile( + room.roomId, + undefined, + userId, + getMouseEventCords(evt.nativeEvent), + 'Right' + ) + } + > + + + } + /> + + + + {name} + + + {audioOnly && } + + + ); +} + +export function CallMemberRenderer({ + members, + max = 4, +}: { + members: CallMembership[]; + max?: number; +}) { + const [viewMore, setViewMore] = useState(false); + + const truncatedMembers = viewMore ? members : members.slice(0, 4); + const remaining = members.length - truncatedMembers.length; + + return ( + <> + {truncatedMembers.map((member) => ( + + ))} + {members.length > max && ( + setViewMore(!viewMore)} + > + + {viewMore ? ( + + Collapse + + ) : ( + + {remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`} + + )} + + + + )} + + ); +} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx new file mode 100644 index 00000000..b2a200fb --- /dev/null +++ b/src/app/features/call/CallView.tsx @@ -0,0 +1,105 @@ +import React, { useRef } from 'react'; +import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { useCallEmbed, useCallJoined, useSyncCallEmbedPlacement } from '../../hooks/useCallEmbed'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import { PrescreenControls } from './PrescreenControls'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { useRoom } from '../../hooks/useRoom'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { StateEvent } from '../../../types/matrix/room'; +import { useCallMembers, useCallSession } from '../../hooks/useCall'; +import { CallMemberRenderer } from './CallMemberCard'; +import * as css from './styles.css'; + +function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) { + if (hasParticipant) return null; + + return ( + + Voice chat’s empty — Be the first to hop in! + + ); +} + +function NoPermissionMessage() { + return ( + + You don't have permission to join! + + ); +} + +function AlreadyInCallMessage() { + return ( + + Already in another call — End the current call to join! + + ); +} + +export function CallView() { + const mx = useMatrixClient(); + const room = useRoom(); + + const callViewRef = useRef(null); + useSyncCallEmbedPlacement(callViewRef); + + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canJoin = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId()); + + const callSession = useCallSession(room); + const callMembers = useCallMembers(room, callSession); + const hasParticipant = callMembers.length > 0; + + const callEmbed = useCallEmbed(); + const callJoined = useCallJoined(callEmbed); + const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; + + const currentJoined = callEmbed?.roomId === room.roomId && callJoined; + + return ( + + {!currentJoined && ( + + + + {hasParticipant && ( +
+ + Participant + + + + {callMembers.length} Live + + +
+ )} + + +
+ {!inOtherCall && + (canJoin ? ( + + ) : ( + + ))} + {inOtherCall && } +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx new file mode 100644 index 00000000..39c5d87d --- /dev/null +++ b/src/app/features/call/Controls.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds'; +import { useAtom } from 'jotai'; +import * as css from './styles.css'; +import { callChatAtom } from '../../state/callEmbed'; + +export function ControlDivider() { + return ( + + ); +} + +type MicrophoneButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { + return ( + + {enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined + > + + + )} + + ); +} + +type SoundButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +export function SoundButton({ enabled, onToggle }: SoundButtonProps) { + return ( + + {enabled ? 'Turn Off Sound' : 'Turn On Sound'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined + > + + + )} + + ); +} + +type VideoButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +export function VideoButton({ enabled, onToggle }: VideoButtonProps) { + return ( + + {enabled ? 'Stop Camera' : 'Start Camera'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined + > + + + )} + + ); +} + +export function ChatButton() { + const [chat, setChat] = useAtom(callChatAtom); + + return ( + + {chat ? 'Close Chat' : 'Open Chat'} + + } + > + {(anchorRef) => ( + setChat(!chat)} + outlined + > + + + )} + + ); +} diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx new file mode 100644 index 00000000..4782c9b6 --- /dev/null +++ b/src/app/features/call/PrescreenControls.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Box, Button, Icon, Icons, Spinner, Text } from 'folds'; +import { SequenceCard } from '../../components/sequence-card'; +import * as css from './styles.css'; +import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls'; +import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; +import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed'; +import { useCallPreferences } from '../../state/hooks/callPreferences'; +import { CallControlState } from '../../plugins/call/CallControlState'; + +type PrescreenControlsProps = { + canJoin?: boolean; +}; +export function PrescreenControls({ canJoin }: PrescreenControlsProps) { + const room = useRoom(); + const callEmbed = useCallEmbed(); + const callJoined = useCallJoined(callEmbed); + const direct = useIsDirectRoom(); + + const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; + + const startCall = useCallStart(direct); + const joining = callEmbed?.roomId === room.roomId && !callJoined; + + const disabled = inOtherCall || !canJoin; + + const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } = + useCallPreferences(); + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/call/styles.css.ts b/src/app/features/call/styles.css.ts new file mode 100644 index 00000000..1ee2892a --- /dev/null +++ b/src/app/features/call/styles.css.ts @@ -0,0 +1,20 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const CallViewContent = style({ + padding: config.space.S400, + paddingRight: 0, + minHeight: '100%', +}); + +export const ControlCard = style({ + padding: config.space.S300, +}); + +export const ControlDivider = style({ + height: toRem(24), +}); + +export const CallMemberCard = style({ + padding: config.space.S300, +}); diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index b9e75499..0d352167 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 + + {type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'} + @@ -74,7 +77,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 530145af..b0c12f56 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 994cda05..7de59acd 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,9 +283,19 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { radii="300" variant="Primary" fill="None" - onClick={handleCreateRoom} + onClick={() => handleCreateRoom(CreateRoomType.TextRoom)} > - New Room + Chat Room + + handleCreateRoom(CreateRoomType.VoiceRoom)} + after={} + > + Voice Room Existing Room diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 929dd1e9..6883e363 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/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index 62ef9c4b..14817cc4 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -203,7 +203,12 @@ export function SearchResultGroup({ src={getRoomAvatarUrl(mx, room, 96, useAuthentication)} alt={room.name} renderFallback={() => ( - + )} /> diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 33b21bff..cfb52d44 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -19,6 +19,7 @@ import { } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; +import { useAtom, useAtomValue } from 'jotai'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; @@ -51,6 +52,12 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { useRoomName } from '../../hooks/useRoomMeta'; +import { useCallMembers, useCallSession } from '../../hooks/useCall'; +import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; +import { callChatAtom } from '../../state/callEmbed'; +import { useCallPreferencesAtom } from '../../state/hooks/callPreferences'; +import { CallControlState } from '../../plugins/call/CallControlState'; type RoomNavItemMenuProps = { room: Room; @@ -209,6 +216,24 @@ const RoomNavItemMenu = forwardRef( } ); +function CallChatToggle() { + const [chat, setChat] = useAtom(callChatAtom); + + return ( + setChat(!chat)} + aria-pressed={chat} + aria-label="Toggle Chat" + variant="Background" + fill="None" + size="300" + radii="300" + > + + + ); +} + type RoomNavItemProps = { room: Room; selected: boolean; @@ -236,6 +261,8 @@ export function RoomNavItem({ (receipt) => receipt.userId !== mx.getUserId() ); + const roomName = useRoomName(room); + const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); setMenuAnchor({ @@ -251,6 +278,23 @@ export function RoomNavItem({ }; const optionsVisible = hover || !!menuAnchor; + const callSession = useCallSession(room); + const callMembers = useCallMembers(room, callSession); + const startCall = useCallStart(direct); + const callEmbed = useCallEmbed(); + const callPref = useAtomValue(useCallPreferencesAtom()); + + const handleStartCall: MouseEventHandler = (evt) => { + // Do not join if already in call + if (callEmbed) { + return; + } + // Start call in second click + if (selected) { + evt.preventDefault(); + startCall(room, new CallControlState(callPref.microphone, callPref.video, callPref.sound)); + } + }; return ( - + @@ -275,25 +319,28 @@ export function RoomNavItem({ ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication) } - alt={room.name} + alt={roomName} renderFallback={() => ( - {nameInitials(room.name)} + {nameInitials(roomName)} )} /> ) : ( )} - {room.name} + {roomName} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( @@ -307,14 +354,30 @@ export function RoomNavItem({ )} {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( - + + )} + {room.isCallRoom() && callMembers.length > 0 && ( + + + {callMembers.length} Live + + )} {optionsVisible && ( + {selected && (callEmbed?.roomId === room.roomId || room.isCallRoom()) && ( + + )} ( diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index 7572a71b..fe6b098b 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 f3b45424..d4f5f562 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: [ @@ -203,12 +216,13 @@ export const usePermissionGroups = (): PermissionGroup[] => { return [ messagesGroup, + ...(isCallRoom ? [callSettingsGroup] : []), moderationGroup, roomOverviewGroup, roomSettingsGroup, otherSettingsGroup, ]; - }, []); + }, [isCallRoom]); return groups; }; diff --git a/src/app/features/room/CallChatView.tsx b/src/app/features/room/CallChatView.tsx new file mode 100644 index 00000000..6c32f4a0 --- /dev/null +++ b/src/app/features/room/CallChatView.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { useSetAtom } from 'jotai'; +import { useParams } from 'react-router-dom'; +import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds'; +import { Page, PageHeader } from '../../components/page'; +import { callChatAtom } from '../../state/callEmbed'; +import { RoomView } from './RoomView'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; + +export function CallChatView() { + const { eventId } = useParams(); + const setChat = useSetAtom(callChatAtom); + const screenSize = useScreenSizeContext(); + + const handleClose = () => setChat(false); + + return ( + + + + + + Chat + + + + + Close + + } + > + {(triggerRef) => ( + + + + )} + + + + + + + + + ); +} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 24878d5e..5e5d7d78 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { Box, Line } from 'folds'; import { useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; +import { useAtomValue } from 'jotai'; import { RoomView } from './RoomView'; import { MembersDrawer } from './MembersDrawer'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; @@ -13,6 +14,10 @@ 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 { callChatAtom } from '../../state/callEmbed'; +import { CallChatView } from './CallChatView'; export function Room() { const { eventId } = useParams(); @@ -24,6 +29,7 @@ export function Room() { const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); const members = useRoomMembers(mx, room.roomId); + const chat = useAtomValue(callChatAtom); useKeyDown( window, @@ -37,11 +43,37 @@ export function Room() { ) ); + const callView = room.isCallRoom(); + return ( - - {screenSize === ScreenSize.Desktop && isDrawer && ( + {callView && (screenSize === ScreenSize.Desktop || !chat) && ( + + + + + + + )} + {!callView && ( + + + + + + + )} + + {callView && chat && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + + + )} + {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( <> diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d1678b65..64043054 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -27,6 +27,7 @@ import { HTMLReactParserOptions } from 'html-react-parser'; import classNames from 'classnames'; import { ReactEditor } from 'slate-react'; import { Editor } from 'slate'; +import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import to from 'await-to-js'; import { useAtomValue, useSetAtom } from 'jotai'; import { @@ -1469,6 +1470,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); }, + [StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => { + const highlighted = focusItem?.index === item && focusItem.highlight; + const senderId = mEvent.getSender() ?? ''; + const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); + + const callJoined = mEvent.getContent().application; + + const timeJSX = ( + + } + /> + + ); + }, }, (mEventId, mEvent, item) => { if (!showHiddenEvents) return null; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 0f837594..a4f0d7af 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useRef } from 'react'; import { Box, Text, config } from 'folds'; -import { EventType, Room } from 'matrix-js-sdk'; +import { EventType } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; import { useStateEvent } from '../../hooks/useStateEvent'; @@ -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 { useRoom } from '../../hooks/useRoom'; 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') || @@ -56,12 +54,13 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { return true; }; -export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { +export function RoomView({ eventId }: { eventId?: string }) { const roomInputRef = useRef(null); const roomViewRef = useRef(null); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const room = useRoom(); const { roomId } = room; const editor = useEditor(); @@ -93,7 +92,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { return ( - (({ room, requestClose ); }); -export function RoomViewHeader() { +export function RoomViewHeader({ callView }: { callView?: boolean }) { const navigate = useNavigate(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -263,12 +264,14 @@ export function RoomViewHeader() { const space = useSpaceOptionally(); const [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); - const mDirects = useAtomValue(mDirectAtom); + const direct = useIsDirectRoom(); + + const [chat, setChat] = useAtom(callChatAtom); 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 @@ -295,14 +298,27 @@ export function RoomViewHeader() { setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const openSettings = useOpenRoomSettings(); + const parentSpace = useSpaceOptionally(); + const handleMemberToggle = () => { + if (callView) { + openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage); + return; + } + setPeopleDrawer(!peopleDrawer); + }; + return ( - + {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + @@ -317,11 +333,7 @@ export function RoomViewHeader() { src={avatarUrl} alt={name} renderFallback={() => ( - + )} /> @@ -369,8 +381,9 @@ export function RoomViewHeader() { )} + - {!ecryptedRoom && ( + {!encryptedRoom && ( {(triggerRef) => ( - + )} @@ -398,6 +411,7 @@ export function RoomViewHeader() { > {(triggerRef) => ( } /> + {screenSize === ScreenSize.Desktop && ( {(triggerRef) => ( - setPeopleDrawer((drawer) => !drawer)}> + )} )} + + {callView && ( + + Chat + + } + > + {(triggerRef) => ( + setChat(!chat)}> + + + )} + + )} + {(triggerRef) => ( - + )} diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index fcd6233a..6027f322 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 e565fb92..b5fefc93 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 mx = useMatrixClient(); + + const [session, setSession] = useState(mx.matrixRTC.getRoomSession(room)); + + useEffect(() => { + const start = (roomId: string) => { + if (roomId !== room.roomId) return; + setSession(mx.matrixRTC.getRoomSession(room)); + }; + const end = (roomId: string) => { + if (roomId !== room.roomId) return; + setSession(mx.matrixRTC.getRoomSession(room)); + }; + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, start); + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, end); + return () => { + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, start); + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, end); + }; + }, [mx, room]); + + return session; +}; + +export const useCallMembers = (room: Room, session: MatrixRTCSession): CallMembership[] => { + const [memberships, setMemberships] = useState( + MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription) + ); + + useEffect(() => { + const updateMemberships = () => { + setMemberships(MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription)); + }; + + updateMemberships(); + + session.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + return () => { + session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + }; + }, [session, room]); + + return memberships; +}; diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts new file mode 100644 index 00000000..e04182dd --- /dev/null +++ b/src/app/hooks/useCallEmbed.ts @@ -0,0 +1,130 @@ +import { createContext, RefObject, useCallback, useContext, useEffect, useState } from 'react'; +import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; +import { MatrixClient, Room } from 'matrix-js-sdk'; +import { useSetAtom } from 'jotai'; +import { + CallEmbed, + ElementCallThemeKind, + ElementWidgetActions, + useClientWidgetApiEvent, +} from '../plugins/call'; +import { useMatrixClient } from './useMatrixClient'; +import { ThemeKind, useTheme } from './useTheme'; +import { callEmbedAtom } from '../state/callEmbed'; +import { useResizeObserver } from './useResizeObserver'; +import { CallControlState } from '../plugins/call/CallControlState'; + +const CallEmbedContext = createContext(undefined); + +export const CallEmbedContextProvider = CallEmbedContext.Provider; + +export const useCallEmbed = (): CallEmbed | undefined => { + const callEmbed = useContext(CallEmbedContext); + + return callEmbed; +}; + +const CallEmbedRefContext = createContext | undefined>(undefined); +export const CallEmbedRefContextProvider = CallEmbedRefContext.Provider; +export const useCallEmbedRef = (): RefObject => { + const ref = useContext(CallEmbedRefContext); + if (!ref) { + throw new Error('CallEmbedRef is not provided!'); + } + return ref; +}; + +export const createCallEmbed = ( + mx: MatrixClient, + room: Room, + dm: boolean, + themeKind: ElementCallThemeKind, + container: HTMLElement, + controlState?: CallControlState +): CallEmbed => { + const rtcSession = mx.matrixRTC.getRoomSession(room); + const ongoing = + MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0; + + const intent = CallEmbed.getIntent(dm, ongoing); + const widget = CallEmbed.getWidget(mx, room, intent, themeKind); + const embed = new CallEmbed(mx, room, widget, container, controlState); + + return embed; +}; + +export const useCallStart = (dm = false) => { + const mx = useMatrixClient(); + const theme = useTheme(); + const setCallEmbed = useSetAtom(callEmbedAtom); + const callEmbedRef = useCallEmbedRef(); + + const startCall = useCallback( + (room: Room, controlState?: CallControlState) => { + const container = callEmbedRef.current; + if (!container) { + throw new Error('Failed to start call, No embed container element found!'); + } + const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, controlState); + + setCallEmbed(callEmbed); + }, + [mx, dm, theme, setCallEmbed, callEmbedRef] + ); + + return startCall; +}; + +export const useCallJoined = (embed?: CallEmbed): boolean => { + const [joined, setJoined] = useState(embed?.joined ?? false); + + useClientWidgetApiEvent( + embed?.call, + ElementWidgetActions.JoinCall, + useCallback(() => { + setJoined(true); + }, []) + ); + + useEffect(() => { + if (!embed) { + setJoined(false); + } + }, [embed]); + + return joined; +}; + +export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => { + useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback); +}; + +export const useCallThemeSync = (embed: CallEmbed) => { + const theme = useTheme(); + + useEffect(() => { + const name: ElementCallThemeKind = theme.kind === ThemeKind.Dark ? 'dark' : 'light'; + + embed.setTheme(name); + }, [theme.kind, embed]); +}; + +export const useSyncCallEmbedPlacement = (containerViewRef: RefObject): void => { + const callEmbedRef = useCallEmbedRef(); + + const syncCallEmbedPlacement = useCallback(() => { + const embedEl = callEmbedRef.current; + const container = containerViewRef.current; + if (!embedEl || !container) return; + + embedEl.style.top = `${container.offsetTop}px`; + embedEl.style.left = `${container.offsetLeft}px`; + embedEl.style.width = `${container.clientWidth}px`; + embedEl.style.height = `${container.clientHeight}px`; + }, [callEmbedRef, containerViewRef]); + + useResizeObserver( + syncCallEmbedPlacement, + useCallback(() => containerViewRef.current, [containerViewRef]) + ); +}; diff --git a/src/app/hooks/useRoomMeta.ts b/src/app/hooks/useRoomMeta.ts index 8b0ae8cc..086c3a56 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 dd085693..00000000 --- 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/CallStatusRenderer.tsx b/src/app/pages/CallStatusRenderer.tsx new file mode 100644 index 00000000..f8e38054 --- /dev/null +++ b/src/app/pages/CallStatusRenderer.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { useCallEmbed } from '../hooks/useCallEmbed'; +import { CallStatus } from '../features/call-status'; + +export function CallStatusRenderer() { + const callEmbed = useCallEmbed(); + + if (!callEmbed) return null; + + return ; +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index f8647d18..4de42081 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -68,6 +68,8 @@ import { Create } from './client/create'; import { CreateSpaceModalRenderer } from '../features/create-space'; import { SearchModalRenderer } from '../features/search'; import { getFallbackSession } from '../state/sessions'; +import { CallStatusRenderer } from './CallStatusRenderer'; +import { CallEmbedProvider } from '../components/CallEmbedProvider'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -124,15 +126,18 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - } - > - - + + + + + } + > + + + + diff --git a/src/app/pages/client/ClientInitStorageAtom.tsx b/src/app/pages/client/ClientInitStorageAtom.tsx index 1abee707..5793bb6b 100644 --- a/src/app/pages/client/ClientInitStorageAtom.tsx +++ b/src/app/pages/client/ClientInitStorageAtom.tsx @@ -8,6 +8,8 @@ import { makeNavToActivePathAtom } from '../../state/navToActivePath'; import { NavToActivePathProvider } from '../../state/hooks/navToActivePath'; import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder'; import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder'; +import { makeCallPreferencesAtom } from '../../state/callPreferences'; +import { CallPreferencesProvider } from '../../state/hooks/callPreferences'; type ClientInitStorageAtomProps = { children: ReactNode; @@ -24,12 +26,16 @@ export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps) const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]); + const callPreferencesAtom = useMemo(() => makeCallPreferencesAtom(userId), [userId]); + return ( - {children} + + {children} + diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 4cc94d91..9592224f 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -417,7 +417,12 @@ function RoomNotificationsGroupComp({ src={getRoomAvatarUrl(mx, room, 96, useAuthentication)} alt={room.name} renderFallback={() => ( - + )} /> diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 3f60d2a9..2a7c6199 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -84,6 +84,7 @@ import { ContainerColor } from '../../../styles/ContainerColor.css'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { BreakWord } from '../../../styles/Text.css'; import { InviteUserPrompt } from '../../../components/invite-user-prompt'; +import { useCallEmbed } from '../../../hooks/useCallEmbed'; type SpaceMenuProps = { room: Room; @@ -387,15 +388,15 @@ export function Space() { const notificationPreferences = useRoomsNotificationPreferencesContext(); const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); - const selectedRoomId = useSelectedRoom(); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); + const callEmbed = useCallEmbed(); const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); const getRoom = useCallback( - (rId: string) => { + (rId: string): Room | undefined => { if (allJoinedRooms.has(rId)) { return mx.getRoom(rId) ?? undefined; } @@ -412,11 +413,11 @@ export function Space() { if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { return false; } - const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId; - if (showRoom) return false; - return true; + const showRoomAnyway = + roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; + return !showRoomAnyway; }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId] + [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] ), useCallback( (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts new file mode 100644 index 00000000..c2087fe0 --- /dev/null +++ b/src/app/plugins/call/CallControl.ts @@ -0,0 +1,117 @@ +import { ClientWidgetApi } from 'matrix-widget-api'; +import EventEmitter from 'events'; +import { CallControlState } from './CallControlState'; +import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types'; + +export enum CallControlEvent { + StateUpdate = 'state_update', +} + +export class CallControl extends EventEmitter implements CallControlState { + private state: CallControlState; + + private call: ClientWidgetApi; + + private iframe: HTMLIFrameElement; + + constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { + super(); + + this.state = state; + this.call = call; + this.iframe = iframe; + } + + public getState(): CallControlState { + return this.state; + } + + public get microphone(): boolean { + return this.state.microphone; + } + + public get video(): boolean { + return this.state.video; + } + + public get sound(): boolean { + return this.state.sound; + } + + public async applyState() { + await this.setMediaState({ + audio_enabled: this.microphone, + video_enabled: this.video, + }); + this.setSound(this.sound); + this.emitStateUpdate(); + } + + private setMediaState(state: ElementMediaStatePayload) { + return this.call.transport.send(ElementWidgetActions.DeviceMute, state); + } + + private setSound(sound: boolean): void { + const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; + if (callDocument) { + callDocument.querySelectorAll('audio').forEach((el) => { + if (el instanceof HTMLAudioElement) { + // eslint-disable-next-line no-param-reassign + el.muted = !sound; + } + }); + } + } + + public onMediaState(evt: CustomEvent) { + const { data } = evt.detail; + if (!data) return; + + const state = new CallControlState( + data.audio_enabled ?? this.microphone, + data.video_enabled ?? this.video, + this.sound + ); + + this.state = state; + this.emitStateUpdate(); + + if (this.microphone && !this.sound) { + this.toggleSound(); + } + } + + public toggleMicrophone() { + const payload: ElementMediaStatePayload = { + audio_enabled: !this.microphone, + video_enabled: this.video, + }; + return this.setMediaState(payload); + } + + public toggleVideo() { + const payload: ElementMediaStatePayload = { + audio_enabled: this.microphone, + video_enabled: !this.video, + }; + return this.setMediaState(payload); + } + + public toggleSound() { + const sound = !this.sound; + + this.setSound(sound); + + const state = new CallControlState(this.microphone, this.video, sound); + this.state = state; + this.emitStateUpdate(); + + if (!this.sound && this.microphone) { + this.toggleMicrophone(); + } + } + + private emitStateUpdate() { + this.emit(CallControlEvent.StateUpdate); + } +} diff --git a/src/app/plugins/call/CallControlState.ts b/src/app/plugins/call/CallControlState.ts new file mode 100644 index 00000000..42f0b196 --- /dev/null +++ b/src/app/plugins/call/CallControlState.ts @@ -0,0 +1,13 @@ +export class CallControlState { + public readonly microphone: boolean; + + public readonly video: boolean; + + public readonly sound: boolean; + + constructor(microphone: boolean, video: boolean, sound: boolean) { + this.microphone = microphone; + this.video = video; + this.sound = sound; + } +} diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts new file mode 100644 index 00000000..b852e3ef --- /dev/null +++ b/src/app/plugins/call/CallEmbed.ts @@ -0,0 +1,358 @@ +import { + ClientEvent, + KnownMembership, + MatrixClient, + MatrixEvent, + MatrixEventEvent, + Room, + RoomStateEvent, +} from 'matrix-js-sdk'; +import { + ClientWidgetApi, + IRoomEvent, + IWidget, + Widget, + WidgetApiToWidgetAction, + WidgetDriver, +} from 'matrix-widget-api'; +import { CallWidgetDriver } from './CallWidgetDriver'; +import { trimTrailingSlash } from '../../utils/common'; +import { + ElementCallIntent, + ElementCallThemeKind, + ElementMediaStateDetail, + ElementWidgetActions, +} from './types'; +import { CallControl } from './CallControl'; +import { CallControlState } from './CallControlState'; + +export class CallEmbed { + private mx: MatrixClient; + + public readonly call: ClientWidgetApi; + + public readonly iframe: HTMLIFrameElement; + + public readonly room: Room; + + public joined = false; + + public readonly control: CallControl; + + private readonly container: HTMLElement; + + private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID + + private eventsToFeed = new WeakSet(); + + private readonly disposables: Array<() => void> = []; + + static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent { + if (ongoing) { + return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting; + } + + return dm ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCall; + } + + static getWidget( + mx: MatrixClient, + room: Room, + intent: ElementCallIntent, + themeKind: ElementCallThemeKind + ): Widget { + const userId = mx.getSafeUserId(); + const deviceId = mx.getDeviceId() ?? ''; + const clientOrigin = window.location.origin; + const widgetId = 'call-embed'; + + const params = new URLSearchParams({ + widgetId, + parentUrl: clientOrigin, + baseUrl: mx.baseUrl, + roomId: room.roomId, + userId, + deviceId, + intent, + + skipLobby: 'true', + confineToRoom: 'true', + appPrompt: 'false', + perParticipantE2EE: room.hasEncryptionStateEvent().toString(), + lang: 'en-EN', + theme: themeKind, + }); + + const widgetUrl = new URL( + `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, + window.location.origin + ); + widgetUrl.search = params.toString(); + + const options: IWidget = { + id: widgetId, + creatorUserId: userId, + name: 'Call', + type: 'm.call', + url: widgetUrl.href, + waitForIframeLoad: false, + data: {}, + }; + + const widget: Widget = new Widget(options); + + return widget; + } + + static getIframe(url: string): HTMLIFrameElement { + const iframe = document.createElement('iframe'); + + iframe.title = 'Call Embed'; + iframe.sandbox = + 'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads'; + iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;'; + iframe.src = url; + + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + + return iframe; + } + + constructor( + mx: MatrixClient, + room: Room, + widget: Widget, + container: HTMLElement, + initialControlState?: CallControlState + ) { + const iframe = CallEmbed.getIframe( + widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() }) + ); + container.append(iframe); + + const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId); + const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver); + + this.mx = mx; + this.call = call; + this.room = room; + this.iframe = iframe; + this.container = container; + + const controlState = initialControlState ?? new CallControlState(true, false, true); + this.control = new CallControl(controlState, call, iframe); + + let initialMediaEvent = true; + this.disposables.push( + this.listenEvent(ElementWidgetActions.DeviceMute, (evt) => { + if (initialMediaEvent) { + initialMediaEvent = false; + this.control.applyState(); + return; + } + this.control.onMediaState(evt); + }) + ); + + this.start(); + } + + get roomId(): string { + return this.room.roomId; + } + + public setTheme(theme: ElementCallThemeKind) { + return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, { + name: theme, + }); + } + + public hangup() { + return this.call.transport.send(ElementWidgetActions.HangupCall, {}); + } + + public listenEvent(type: string, callback: (event: CustomEvent) => void) { + this.call.on(`action:${type}`, callback); + return () => { + this.call.off(`action:${type}`, callback); + }; + } + + private start() { + // Room widgets get locked to the room they were added in + this.call.setViewedRoomId(this.roomId); + this.disposables.push( + this.listenEvent(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)) + ); + + // Populate the map of "read up to" events for this widget with the current event in every room. + // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget + // requests timeline capabilities in other rooms down the road. It's just easier to manage here. + this.mx.getRooms().forEach((room) => { + // Timelines are most recent last + const events = room.getLiveTimeline()?.getEvents() || []; + const roomEvent = events[events.length - 1]; + if (!roomEvent) return; // force later code to think the room is fresh + this.readUpToMap[room.roomId] = roomEvent.getId()!; + }); + + // Attach listeners for feeding events - the underlying widget classes handle permissions for us + this.mx.on(ClientEvent.Event, this.onEvent.bind(this)); + this.mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); + this.mx.on(RoomStateEvent.Events, this.onStateUpdate.bind(this)); + this.mx.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + } + + /** + * Stops the widget messaging for if it is started. Skips stopping if it is an active + * widget. + * @param opts + */ + public dispose(): void { + this.disposables.forEach((disposable) => { + disposable(); + }); + this.call.stop(); + this.container.removeChild(this.iframe); + + this.mx.off(ClientEvent.Event, this.onEvent.bind(this)); + this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); + this.mx.off(RoomStateEvent.Events, this.onStateUpdate.bind(this)); + this.mx.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + + // Clear internal state + this.readUpToMap = {}; + this.eventsToFeed = new WeakSet(); + } + + private onCallJoined(): void { + this.joined = true; + } + + private onEvent(ev: MatrixEvent): void { + this.mx.decryptEventIfNeeded(ev); + this.feedEvent(ev); + } + + private onEventDecrypted(ev: MatrixEvent): void { + this.feedEvent(ev); + } + + private onStateUpdate(ev: MatrixEvent): void { + if (this.call === null) return; + const raw = ev.getEffectiveEvent(); + this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => { + console.error('Error sending state update to widget: ', e); + }); + } + + private async onToDeviceEvent(ev: MatrixEvent): Promise { + await this.mx.decryptEventIfNeeded(ev); + if (ev.isDecryptionFailure()) return; + await this.call?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted()); + } + + /** + * Determines whether the event has a relation to an unknown parent. + */ + private relatesToUnknown(ev: MatrixEvent): boolean { + // Replies to unknown events don't count + if (!ev.relationEventId || ev.replyEventId) return false; + const room = this.mx.getRoom(ev.getRoomId()); + return room === null || !room.findEventById(ev.relationEventId); + } + + /** + * Advances the "read up to" marker for a room to a certain event. No-ops if + * the event is before the marker. + * @returns Whether the "read up to" marker was advanced. + */ + private advanceReadUpToMarker(ev: MatrixEvent): boolean { + const evId = ev.getId(); + if (evId === undefined) return false; + const roomId = ev.getRoomId(); + if (roomId === undefined) return false; + const room = this.mx.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 = [...timeline.getEvents()].reverse().slice(0, 100); + function isRelevantTimelineEvent(timelineEvent: MatrixEvent): boolean { + return timelineEvent.getId() === upToEventId || timelineEvent.getId() === ev.getId(); + } + const possibleMarkerEv = events.find(isRelevantTimelineEvent); + if (possibleMarkerEv?.getId() === upToEventId) { + // The event must be somewhere before the "read up to" marker + return false; + } + if (possibleMarkerEv?.getId() === ev.getId()) { + // The event is after the marker; advance it + this.readUpToMap[roomId] = evId; + return true; + } + + // We can't say for sure whether the widget has seen the event; let's + // just assume that it has + return false; + } + + /** + * Determines whether the event comes from a room that we've been invited to + * (in which case we likely don't have the full timeline). + */ + private isFromInvite(ev: MatrixEvent): boolean { + const room = this.mx.getRoom(ev.getRoomId()); + return room?.getMyMembership() === KnownMembership.Invite; + } + + private feedEvent(ev: MatrixEvent): void { + if (this.call === 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.call.feedEvent(raw as IRoomEvent).catch((e) => { + console.error('Error sending event to widget: ', e); + }); + } + } + } +} diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts new file mode 100644 index 00000000..babe90ef --- /dev/null +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -0,0 +1,340 @@ +import { + type Capability, + type ISendDelayedEventDetails, + type ISendEventDetails, + type IReadEventRelationsResult, + type IRoomEvent, + WidgetDriver, + 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'; +import { getCallCapabilities } from './utils'; +import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; + +export class CallWidgetDriver extends WidgetDriver { + private allowedCapabilities: Set; + + private readonly mx: MatrixClient; + + public constructor(mx: MatrixClient, private inRoomId: string) { + super(); + this.mx = mx; + + const deviceId = mx.getDeviceId(); + if (!deviceId) throw new Error('Failed to initialize CallWidgetDriver! Device ID not found.'); + + this.allowedCapabilities = getCallCapabilities(inRoomId, mx.getSafeUserId(), deviceId); + } + + public async validateCapabilities(requested: Set): Promise> { + const allow = Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap)); + return new Set(allow); + } + + public async sendEvent( + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mx; + 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 (typeof stateKey === 'string') { + 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 { + r = await client.sendEvent( + roomId, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + + return { roomId, eventId: r.event_id }; + } + + 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.mx; + 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, + }; + } + + public async updateDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction + ): Promise { + const client = this.mx; + + if (!client) throw new Error('Not in a room or not attached to a client'); + + await client._unstable_updateDelayedEvent(delayId, action); + } + + public async sendToDevice( + eventType: string, + encrypted: boolean, + contentMap: { [userId: string]: { [deviceId: string]: object } } + ): Promise { + const client = this.mx; + + 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, + })) + ), + }); + } + } + + public async readRoomTimeline( + roomId: string, + eventType: string, + msgtype: string | undefined, + stateKey: string | undefined, + limit: number, + since: string | undefined + ): Promise { + const safeLimit = + limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + + const room = this.mx.getRoom(roomId); + if (room === null) return []; + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); + + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i]; + if (results.length >= safeLimit) break; + if (since !== undefined && ev.getId() === since) break; + + if ( + ev.getType() === eventType && + !ev.isState() && + (eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) && + (ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey) + ) { + results.push(ev); + } + } + + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); + } + + public async askOpenID(observer: SimpleObservable): Promise { + return observer.update({ + state: OpenIDRequestState.Allowed, + token: await this.mx.getOpenIdToken(), + }); + } + + public async readRoomState( + roomId: string, + eventType: string, + stateKey: string | undefined + ): Promise { + const room = this.mx.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 readEventRelations( + eventId: string, + roomId?: string, + relationType?: string, + eventType?: string, + from?: string, + to?: string, + limit?: number, + direction?: 'f' | 'b' + ): Promise { + const client = this.mx; + const dir = direction as Direction; + const targetRoomId = roomId ?? this.inRoomId ?? undefined; + + if (typeof targetRoomId !== 'string') { + throw new Error('Error while reading the current room'); + } + + const { events, nextBatch, prevBatch } = await client.relations( + targetRoomId, + 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.mx; + + 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.mx; + + return client.getMediaConfig(); + } + + public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> { + const client = this.mx; + + const uploadResult = await client.uploadContent(file); + + return { contentUri: uploadResult.content_uri }; + } + + public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> { + const httpUrl = mxcUrlToHttp(this.mx, contentUri, true); + if (!httpUrl) { + throw new Error('Call widget failed to download file! No http url!'); + } + const blob = await downloadMedia(httpUrl); + return { file: blob }; + } + + public getKnownRooms(): string[] { + return this.mx.getVisibleRooms().map((r) => r.roomId); + } + + // eslint-disable-next-line class-methods-use-this + public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined { + return error instanceof MatrixError + ? { matrix_api_error: error.asWidgetApiErrorData() } + : undefined; + } +} diff --git a/src/app/plugins/call/hooks.ts b/src/app/plugins/call/hooks.ts new file mode 100644 index 00000000..f5b3ff93 --- /dev/null +++ b/src/app/plugins/call/hooks.ts @@ -0,0 +1,49 @@ +import { + ClientWidgetApi, + IWidgetApiAcknowledgeResponseData, + IWidgetApiRequestData, +} from 'matrix-widget-api'; +import { useCallback, useEffect, useState } from 'react'; +import { CallControl, CallControlEvent } from './CallControl'; +import { CallControlState } from './CallControlState'; + +export const useClientWidgetApiEvent = ( + api: ClientWidgetApi | undefined, + type: string, + callback: (event: CustomEvent) => void +) => { + useEffect(() => { + api?.on(`action:${type}`, callback); + return () => { + api?.off(`action:${type}`, callback); + }; + }, [api, type, callback]); +}; + +export const useSendClientWidgetApiAction = (api: ClientWidgetApi) => { + const sendWidgetAction = useCallback( + async ( + action: string, + data: T + ): Promise => api.transport.send(action, data), + [api] + ); + + return sendWidgetAction; +}; + +export const useCallControlState = (control: CallControl): CallControlState => { + const [state, setState] = useState(control.getState()); + + useEffect(() => { + const handleUpdate = () => { + setState(control.getState()); + }; + control.on(CallControlEvent.StateUpdate, handleUpdate); + return () => { + control.off(CallControlEvent.StateUpdate, handleUpdate); + }; + }, [control]); + + return state; +}; diff --git a/src/app/plugins/call/index.ts b/src/app/plugins/call/index.ts new file mode 100644 index 00000000..a4c6bb36 --- /dev/null +++ b/src/app/plugins/call/index.ts @@ -0,0 +1,3 @@ +export * from './CallEmbed'; +export * from './hooks'; +export * from './types'; diff --git a/src/app/plugins/call/types.ts b/src/app/plugins/call/types.ts new file mode 100644 index 00000000..4f4fc381 --- /dev/null +++ b/src/app/plugins/call/types.ts @@ -0,0 +1,25 @@ +export enum ElementCallIntent { + StartCall = 'start_call', + JoinExisting = 'join_existing', + StartCallDM = 'start_call_dm', + JoinExistingDM = 'join_existing_dm', + StartCallDMVoice = 'start_call_dm_voice', + JoinExistingDMVoice = 'join_existing_dm_voice', +} + +export type ElementCallThemeKind = 'light' | 'dark'; + +export type ElementMediaStatePayload = { + audio_enabled?: boolean; + video_enabled?: boolean; +}; +export type ElementMediaStateDetail = { + data?: ElementMediaStatePayload; +}; + +export enum ElementWidgetActions { + JoinCall = 'io.element.join', + HangupCall = 'im.vector.hangup', + Close = 'io.element.close', + DeviceMute = 'io.element.device_mute', +} diff --git a/src/app/plugins/call/utils.ts b/src/app/plugins/call/utils.ts new file mode 100644 index 00000000..0ea72b3c --- /dev/null +++ b/src/app/plugins/call/utils.ts @@ -0,0 +1,118 @@ +import { + type Capability, + EventDirection, + MatrixCapabilities, + WidgetEventCapability, +} from 'matrix-widget-api'; +import { EventType } from 'matrix-js-sdk'; + +export function getCallCapabilities( + roomId: string, + userId: string, + deviceId: string +): Set { + const capabilities: Set = new Set(); + + capabilities.add(MatrixCapabilities.Screenshots); + capabilities.add(MatrixCapabilities.AlwaysOnScreen); + capabilities.add(MatrixCapabilities.MSC3846TurnServers); + capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent); + capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); + capabilities.add(`org.matrix.msc2762.timeline:${roomId}`); + capabilities.add(`org.matrix.msc2762.state:${roomId}`); + + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call').raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomEncryption).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomName).raw + ); + + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + userId + ).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `_${userId}_${deviceId}_m.call` + ).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `${userId}_${deviceId}_m.call` + ).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `_${userId}_${deviceId}` + ).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `${userId}_${deviceId}` + ).raw + ); + + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member') + .raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw + ); + + capabilities.add( + WidgetEventCapability.forRoomEvent( + EventDirection.Receive, + 'org.matrix.msc4075.rtc.notification' + ).raw + ); + + [ + 'io.element.call.encryption_keys', + 'org.matrix.rageshake_request', + EventType.Reaction, + EventType.RoomRedaction, + 'io.element.call.reaction', + 'org.matrix.msc4310.rtc.decline', + ].forEach((type) => { + capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, type).raw); + capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Receive, type).raw); + }); + + [ + EventType.CallInvite, + EventType.CallCandidates, + EventType.CallAnswer, + EventType.CallHangup, + EventType.CallReject, + EventType.CallSelectAnswer, + EventType.CallNegotiate, + EventType.CallSDPStreamMetadataChanged, + EventType.CallSDPStreamMetadataChangedPrefix, + EventType.CallReplaces, + EventType.CallEncryptionKeysPrefix, + ].forEach((type) => { + capabilities.add(WidgetEventCapability.forToDeviceEvent(EventDirection.Send, type).raw); + capabilities.add(WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, type).raw); + }); + + return capabilities; +} diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts new file mode 100644 index 00000000..a9f89300 --- /dev/null +++ b/src/app/state/callEmbed.ts @@ -0,0 +1,20 @@ +import { atom } from 'jotai'; +import { CallEmbed } from '../plugins/call'; + +const baseCallEmbedAtom = atom(undefined); + +export const callEmbedAtom = atom( + (get) => get(baseCallEmbedAtom), + (get, set, callEmbed) => { + const prevCallEmbed = get(baseCallEmbedAtom); + if (callEmbed === prevCallEmbed) return; + + if (prevCallEmbed) { + prevCallEmbed.dispose(); + } + + set(baseCallEmbedAtom, callEmbed); + } +); + +export const callChatAtom = atom(false); diff --git a/src/app/state/callPreferences.ts b/src/app/state/callPreferences.ts new file mode 100644 index 00000000..a8e82067 --- /dev/null +++ b/src/app/state/callPreferences.ts @@ -0,0 +1,39 @@ +import { WritableAtom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from './utils/atomWithLocalStorage'; + +export type CallPreferences = { + microphone: boolean; + video: boolean; + sound: boolean; +}; + +const CALL_PREFERENCES = 'callPreferences'; + +const DEFAULT_PREFERENCES: CallPreferences = { + microphone: true, + video: false, + sound: true, +}; + +export type CallPreferencesAtom = WritableAtom; + +export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom => { + const storeKey = `${CALL_PREFERENCES}${userId}`; + + const callPreferencesAtom = atomWithLocalStorage( + storeKey, + (key) => { + const v = getLocalStorageItem(key, DEFAULT_PREFERENCES); + return v; + }, + (key, value) => { + setLocalStorageItem(key, value); + } + ); + + return callPreferencesAtom; +}; diff --git a/src/app/state/createRoomModal.ts b/src/app/state/createRoomModal.ts index 81af5d5b..f32e7665 100644 --- a/src/app/state/createRoomModal.ts +++ b/src/app/state/createRoomModal.ts @@ -1,7 +1,9 @@ import { atom } from 'jotai'; +import { CreateRoomType } from '../components/create-room/types'; export type CreateRoomModalState = { spaceId?: string; + type?: CreateRoomType; }; export const createRoomModalAtom = atom(undefined); diff --git a/src/app/state/hooks/callPreferences.ts b/src/app/state/hooks/callPreferences.ts new file mode 100644 index 00000000..829ed4b4 --- /dev/null +++ b/src/app/state/hooks/callPreferences.ts @@ -0,0 +1,61 @@ +import { createContext, useCallback, useContext } from 'react'; +import { useAtom } from 'jotai'; +import { CallPreferences, CallPreferencesAtom } from '../callPreferences'; + +const CallPreferencesAtomContext = createContext(null); +export const CallPreferencesProvider = CallPreferencesAtomContext.Provider; + +export const useCallPreferencesAtom = (): CallPreferencesAtom => { + const atom = useContext(CallPreferencesAtomContext); + if (!atom) { + throw new Error('CallPreferencesAtom not provided!'); + } + + return atom; +}; + +export const useCallPreferences = (): CallPreferences & { + toggleMicrophone: () => void; + toggleVideo: () => void; + toggleSound: () => void; +} => { + const callPrefAtom = useCallPreferencesAtom(); + const [pref, setPref] = useAtom(callPrefAtom); + + const toggleMicrophone = useCallback(() => { + const microphone = !pref.microphone; + + setPref({ + microphone, + video: pref.video, + sound: !pref.sound && microphone ? true : pref.sound, + }); + }, [setPref, pref]); + + const toggleVideo = useCallback(() => { + const video = !pref.video; + + setPref({ + microphone: pref.microphone, + video, + sound: pref.sound, + }); + }, [setPref, pref]); + + const toggleSound = useCallback(() => { + const sound = !pref.sound; + + setPref({ + microphone: !sound ? false : pref.microphone, + video: pref.video, + sound, + }); + }, [setPref, pref]); + + return { + ...pref, + toggleMicrophone, + toggleVideo, + toggleSound, + }; +}; diff --git a/src/app/state/hooks/createRoomModal.ts b/src/app/state/hooks/createRoomModal.ts index 15db7289..2663e5dc 100644 --- a/src/app/state/hooks/createRoomModal.ts +++ b/src/app/state/hooks/createRoomModal.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { createRoomModalAtom, CreateRoomModalState } from '../createRoomModal'; +import { CreateRoomType } from '../../components/create-room/types'; export const useCreateRoomModalState = (): CreateRoomModalState | undefined => { const data = useAtomValue(createRoomModalAtom); @@ -19,13 +20,13 @@ export const useCloseCreateRoomModal = (): CloseCallback => { return close; }; -type OpenCallback = (space?: string) => void; +type OpenCallback = (space?: string, type?: CreateRoomType) => void; export const useOpenCreateRoomModal = (): OpenCallback => { const setSettings = useSetAtom(createRoomModalAtom); const open: OpenCallback = useCallback( - (spaceId) => { - setSettings({ spaceId }); + (spaceId, type) => { + setSettings({ spaceId, type }); }, [setSettings] ); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index c282a0a2..05dfeb29 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -161,7 +161,8 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string): export const isMutedRule = (rule: IPushRule) => // Check for empty actions (new spec) or dont_notify (deprecated) - (rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match'; + (rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && + rule.conditions?.[0]?.kind === 'event_match'; export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); @@ -257,24 +258,44 @@ export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => { return unreadInfos; }; -export const joinRuleToIconSrc = ( +export const getRoomIconSrc = ( icons: Record, - joinRule: JoinRule, - space: boolean -): IconSrc | undefined => { - if (joinRule === JoinRule.Restricted) { - return space ? icons.Space : icons.Hash; + roomType?: string, + joinRule?: JoinRule +): IconSrc => { + if (roomType === RoomType.Space) { + if (joinRule === JoinRule.Public) return icons.SpaceGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.SpaceLock; + } + return icons.Space; } - if (joinRule === JoinRule.Knock) { - return space ? icons.SpaceLock : icons.HashLock; + + if (roomType === RoomType.Call) { + if (joinRule === JoinRule.Public) return icons.VolumeHighGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.VolumeHighLock; + } + return icons.VolumeHigh; } - if (joinRule === JoinRule.Invite) { - return space ? icons.SpaceLock : icons.HashLock; + + if (joinRule === JoinRule.Public) return icons.HashGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.HashLock; } - if (joinRule === JoinRule.Public) { - return space ? icons.SpaceGlobe : icons.HashGlobe; - } - return undefined; + return icons.Hash; }; export const getRoomAvatarUrl = ( diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index b866fd77..c73c0f0b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -32,6 +32,8 @@ export enum StateEvent { RoomGuestAccess = 'm.room.guest_access', RoomServerAcl = 'm.room.server_acl', RoomTombstone = 'm.room.tombstone', + GroupCallPrefix = 'org.matrix.msc3401.call', + GroupCallMemberPrefix = 'org.matrix.msc3401.call.member', SpaceChild = 'm.space.child', SpaceParent = 'm.space.parent', @@ -50,6 +52,7 @@ export enum MessageEvent { export enum RoomType { Space = 'm.space', + Call = 'org.matrix.msc3417.call', } export type MSpaceChildContent = { diff --git a/vite.config.js b/vite.config.js index dfa02fc4..a5af94d8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,6 +13,10 @@ import buildConfig from './build.config'; const copyFiles = { targets: [ + { + src: 'node_modules/@element-hq/element-call-embedded/dist/*', + dest: 'public/element-call', + }, { src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs', dest: '', @@ -47,7 +51,10 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) { configureServer(server) { server.middlewares.use((req, res, next) => { if (req.url === wasmFilePath) { - const resolvedPath = path.join(path.resolve(), "/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm"); + const resolvedPath = path.join( + path.resolve(), + '/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm' + ); if (fs.existsSync(resolvedPath)) { res.setHeader('Content-Type', 'application/wasm'); @@ -102,8 +109,8 @@ export default defineConfig({ }, devOptions: { enabled: true, - type: 'module' - } + type: 'module', + }, }), ], optimizeDeps: {