diff --git a/config.json b/config.json index de6015a1..9b95c355 100644 --- a/config.json +++ b/config.json @@ -1,21 +1,21 @@ { - "defaultHomeserver": 2, + "defaultHomeserver": 1, "homeserverList": [ "converser.eu", - "envs.net", "matrix.org", - "monero.social", "mozilla.org", + "unredacted.org", "xmr.se" ], "allowCustomHomeservers": true, + "elementCallUrl": null, "featuredCommunities": { "openAsDefault": false, "spaces": [ "#cinny-space:matrix.org", "#community:matrix.org", - "#space:envs.net", + "#space:unredacted.org", "#science-space:matrix.org", "#libregaming-games:tchncs.de", "#mathematics-on:matrix.org" @@ -28,7 +28,7 @@ "#PrivSec.dev:arcticfoxes.net", "#disroot:aria-net.org" ], - "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"] + "servers": [ "matrix.org", "mozilla.org", "unredacted.org" ] }, "hashRouter": { diff --git a/docker-nginx.conf b/docker-nginx.conf index a2dbeba0..aea395ae 100644 --- a/docker-nginx.conf +++ b/docker-nginx.conf @@ -14,6 +14,8 @@ server { rewrite ^/public/(.*)$ /public/$1 break; rewrite ^/assets/(.*)$ /assets/$1 break; + rewrite ^/element-call/dist/(.*)$ /element-call/dist/$1 break; + rewrite ^(.+)$ /index.html break; } } diff --git a/package-lock.json b/package-lock.json index 158a6112..48508a28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@fontsource/inter": "4.5.14", + "@matrix-org/react-sdk-module-api": "2.5.0", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -32,7 +33,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.4.0", + "folds": "2.5.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -44,6 +45,7 @@ "linkify-react": "4.1.3", "linkifyjs": "4.1.3", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.11.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -62,9 +64,11 @@ "slate-dom": "0.112.2", "slate-history": "0.110.3", "slate-react": "0.112.1", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "zod": "4.1.8" }, "devDependencies": { + "@element-hq/element-call-embedded": "0.16.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -1649,6 +1653,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", @@ -2264,6 +2274,18 @@ "node": ">= 18" } }, + "node_modules/@matrix-org/react-sdk-module-api": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.5.0.tgz", + "integrity": "sha512-l/SmiO47gPIRd6YJJGj+B6qbxyypJF6SEsfYr7j9rSW6E85ZYCqf+TpMM2LmfwZRADyKfCVkaJbbBZYpoD02VA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.17.9" + }, + "peerDependencies": { + "react": "^18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7157,9 +7179,9 @@ } }, "node_modules/folds": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.4.0.tgz", - "integrity": "sha512-Q5xCmvU3SIM8etQ9qLF6Y5Jtv01c9JpG3QcnF+Z3nlbMvtktfE13Pj7p0XgSPBcA3OuoU0zXiRwiTlMcbU7KhA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz", + "integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==", "license": "Apache-2.0", "peerDependencies": { "@vanilla-extract/css": "1.9.2", @@ -8663,9 +8685,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.11.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.11.0.tgz", + "integrity": "sha512-ED/9hrJqDWVLeED0g1uJnYRhINh3ZTquwurdM+Hc8wLVJIQ8G/r7A7z74NC+8bBIHQ1Jo7i1Uq5CoJp/TzFYrA==", "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", @@ -10904,6 +10926,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" @@ -12112,6 +12135,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 7316dcce..bcf30c93 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 .", @@ -24,6 +25,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@fontsource/inter": "4.5.14", + "@matrix-org/react-sdk-module-api": "2.5.0", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -43,7 +45,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.4.0", + "folds": "2.5.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -54,6 +56,7 @@ "jotai": "2.6.0", "linkify-react": "4.1.3", "linkifyjs": "4.1.3", + "matrix-widget-api": "1.11.0", "matrix-js-sdk": "38.2.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", @@ -73,9 +76,11 @@ "slate-dom": "0.112.2", "slate-history": "0.110.3", "slate-react": "0.112.1", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "zod": "4.1.8" }, "devDependencies": { + "@element-hq/element-call-embedded": "0.16.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -107,4 +112,4 @@ "vite-plugin-static-copy": "1.0.4", "vite-plugin-top-level-await": "1.4.4" } -} +} \ No newline at end of file diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..ef8d01a1 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor'; const EDITOR_INTENT_SPACE_COUNT = 2; export type AccountDataSubmitCallback = (type: string, content: object) => Promise; +export type AccountDataDeleteCallback = (type: string) => Promise; type AccountDataInfo = { type: string; @@ -83,8 +84,7 @@ function AccountDataEdit({ if ( !typeStr || - parsedContent === null || - defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT) + parsedContent === null ) { return; } @@ -121,7 +121,7 @@ function AccountDataEdit({ aria-disabled={submitting} > - Account Data + Field Name void; + requestClose: () => void; + onEdit?: () => void; + submitDelete?: AccountDataDeleteCallback; }; -function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) { +function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) { + const [deleteState, deleteCallback] = useAsyncCallback(useCallback( + async () => { + if (submitDelete !== undefined) { + await submitDelete(type); + requestClose(); + } + }, + [type, submitDelete, requestClose], + )); + const deleting = deleteState.status === AsyncStatus.Loading; + return ( - Account Data + Field Name - + {onEdit && ( + + )} + {submitDelete && ( + + )} JSON Content @@ -243,8 +270,9 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) export type AccountDataEditorProps = { type?: string; - content?: object; - submitChange: AccountDataSubmitCallback; + content?: unknown; + submitChange?: AccountDataSubmitCallback; + submitDelete?: AccountDataDeleteCallback; requestClose: () => void; }; @@ -252,6 +280,7 @@ export function AccountDataEditor({ type, content, submitChange, + submitDelete, requestClose, }: AccountDataEditorProps) { const [data, setData] = useState({ @@ -301,7 +330,7 @@ export function AccountDataEditor({ - {edit ? ( + {(edit && submitChange) ? ( setEdit(true)} + requestClose={requestClose} + onEdit={submitChange ? () => setEdit(true) : undefined} + submitDelete={submitDelete} /> )} diff --git a/src/app/components/CollapsibleCard.tsx b/src/app/components/CollapsibleCard.tsx new file mode 100644 index 00000000..95c02964 --- /dev/null +++ b/src/app/components/CollapsibleCard.tsx @@ -0,0 +1,54 @@ +import React, { ReactNode } from 'react'; +import { Button, Icon, Icons, Text } from 'folds'; +import { SequenceCard } from './sequence-card'; +import { SequenceCardStyle } from '../features/settings/styles.css'; +import { SettingTile } from './setting-tile'; + +type CollapsibleCardProps = { + expand: boolean; + setExpand: (expand: boolean) => void; + title?: ReactNode; + description?: ReactNode; + before?: ReactNode; + children?: ReactNode; +}; + +export function CollapsibleCard({ + expand, + setExpand, + title, + description, + before, + children, +}: CollapsibleCardProps) { + return ( + + setExpand(!expand)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expand ? 'Collapse' : 'Expand'} + + } + /> + {expand && children} + + ); +} diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index a0ca7488..f10adf21 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -11,6 +11,7 @@ import { CreateRoomKind } from './CreateRoomKindSelector'; import { RoomType, StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; import { getMxIdServer } from '../../utils/matrix'; +import { IPowerLevels } from '../../hooks/usePowerLevels'; export const createRoomCreationContent = ( type: RoomType | undefined, @@ -82,6 +83,44 @@ export const createRoomEncryptionState = () => ({ }, }); +export const createRoomCallState = () => ({ + type: 'org.matrix.msc3401.call', + state_key: '', + content: {}, +}); + +export const createPowerLevelContentOverrides = ( + base: IPowerLevels, + overrides: Partial +): IPowerLevels => ({ + ...base, + ...overrides, + ...(base.events || overrides.events + ? { + events: { + ...base.events, + ...overrides.events, + }, + } + : {}), + ...(base.users || overrides.users + ? { + users: { + ...base.users, + ...overrides.users, + }, + } + : {}), + ...(base.notifications || overrides.notifications + ? { + notifications: { + ...base.notifications, + ...overrides.notifications, + }, + } + : {}), +}); + export type CreateRoomData = { version: string; type?: RoomType; @@ -94,6 +133,7 @@ export type CreateRoomData = { knock: boolean; allowFederation: boolean; additionalCreators?: string[]; + powerLevelContentOverrides?: IPowerLevels; }; export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise => { const initialState: ICreateRoomStateEvent[] = []; @@ -106,6 +146,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis initialState.push(createRoomParentState(data.parent)); } + if (data.type === RoomType.Call) { + initialState.push(createRoomCallState()); + } + initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock)); const options: ICreateRoomOpts = { @@ -136,5 +180,15 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis ); } + if (data.powerLevelContentOverrides) { + const roomPowers = await mx.getStateEvent(result.room_id, StateEvent.RoomPowerLevels, ''); + const updatedPowers = createPowerLevelContentOverrides( + roomPowers, + data.powerLevelContentOverrides + ); + + await mx.sendStateEvent(result.room_id, StateEvent.RoomPowerLevels as any, updatedPowers, ''); + } + return result.room_id; }; diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index b0c64f60..a9e7f51f 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -174,7 +174,7 @@ export function RoomMentionAutocomplete({ )} /> ) : ( - + )} } diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 23f3998d..1ee72d4a 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,10 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps export const RoomIcon = forwardRef< SVGSVGElement, Omit, 'src'> & { - joinRule: JoinRule; - space?: boolean; + joinRule?: JoinRule; + roomType?: string; + locked?: boolean; } ->(({ joinRule, space, ...props }, ref) => ( - +>(({ joinRule, roomType, locked, ...props }, ref) => ( + )); diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 53e6618b..d65006bd 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -19,6 +19,13 @@ import { Box, Scroll, Avatar, + TooltipProvider, + Tooltip, + Badge, + Overlay, + OverlayBackdrop, + OverlayCenter, + Modal, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getMxIdServer } from '../../utils/matrix'; @@ -41,6 +48,11 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; +import { useInterval } from '../../hooks/useInterval'; +import { TextViewer } from '../text-viewer'; +import { ExtendedProfile } from '../../hooks/useExtendedProfile'; +import { settingsAtom } from '../../state/settings'; +import { useSetting } from '../../state/hooks/settings'; export function ServerChip({ server }: { server: string }) { const mx = useMatrixClient(); @@ -436,15 +448,24 @@ export function IgnoredUserAlert() { ); } -export function OptionsChip({ userId }: { userId: string }) { +export function OptionsChip({ + userId, + extendedProfile, +}: { + userId: string; + extendedProfile: ExtendedProfile | null; +}) { const mx = useMatrixClient(); - const [cords, setCords] = useState(); + const [developerToolsEnabled] = useSetting(settingsAtom, 'developerTools'); - const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); + const [profileFieldsOpen, setProfileFieldsOpen] = useState(false); + const [menuCoords, setMenuCoords] = useState(); + + const openMenu: MouseEventHandler = (evt) => { + setMenuCoords(evt.currentTarget.getBoundingClientRect()); }; - const close = () => setCords(undefined); + const closeMenu = () => setMenuCoords(undefined); const ignoredUsers = useIgnoredUsers(); const ignored = ignoredUsers.includes(userId); @@ -459,56 +480,163 @@ export function OptionsChip({ userId }: { userId: string }) { const ignoring = ignoreState.status === AsyncStatus.Loading; return ( - isKeyHotkey('arrowdown', evt), - isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), - }} - > - -
- { - toggleIgnore(); - close(); - }} - before={ - ignoring ? ( - - ) : ( - - ) - } - disabled={ignoring} - > - {ignored ? 'Unblock User' : 'Block User'} - -
-
- - } - > - - {ignoring ? ( - - ) : ( - - )} - -
+ <> + {extendedProfile && ( + }> + + setProfileFieldsOpen(false), + escapeDeactivates: stopPropagation, + }} + > + + setProfileFieldsOpen(false)} + /> + + + + + )} + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + toggleIgnore(); + closeMenu(); + }} + before={ + ignoring ? ( + + ) : ( + + ) + } + disabled={ignoring} + > + {ignored ? 'Unblock User' : 'Block User'} + + {extendedProfile && developerToolsEnabled && ( + { + setProfileFieldsOpen(true); + closeMenu(); + }} + before={} + > + View Profile Fields + + )} +
+
+ + } + > + + {ignoring ? ( + + ) : ( + + )} + +
+ + ); +} + +export function TimezoneChip({ timezone }: { timezone: string }) { + const shortFormat = useMemo( + () => + new Intl.DateTimeFormat(undefined, { + dateStyle: undefined, + timeStyle: 'short', + timeZone: timezone, + }), + [timezone] + ); + const longFormat = useMemo( + () => + new Intl.DateTimeFormat(undefined, { + dateStyle: 'long', + timeStyle: 'short', + timeZone: timezone, + }), + [timezone] + ); + const [shortTime, setShortTime] = useState(shortFormat.format()); + const [longTime, setLongTime] = useState(longFormat.format()); + const updateTime = useCallback(() => { + setShortTime(shortFormat.format()); + setLongTime(longFormat.format()); + }, [setShortTime, setLongTime, shortFormat, longFormat]); + + useEffect(() => { + updateTime(); + }, [timezone, updateTime]); + + useInterval(updateTime, 1000); + + return ( + + + + Timezone: + + {timezone} + + + {longTime} + + + } + > + {(triggerRef) => ( + } + > + + {shortTime} + + + )} + ); } diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0e7fb748..54e40402 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -21,6 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { ImageViewer } from '../image-viewer'; import { stopPropagation } from '../../utils/keyboard'; +import { ExtendedProfile } from '../../hooks/useExtendedProfile'; type UserHeroProps = { userId: string; @@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { type UserHeroNameProps = { displayName?: string; userId: string; + extendedProfile?: ExtendedProfile; }; -export function UserHeroName({ displayName, userId }: UserHeroNameProps) { +export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) { const username = getMxIdLocalPart(userId); + const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"]; return ( @@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) { {displayName ?? username ?? userId} - + @{username} + {pronouns && ยท {pronouns.map(({ summary }) => summary).join(", ")}} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 78d201ec..cb54083e 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -1,5 +1,5 @@ import { Box, Button, config, Icon, Icons, Text } from 'folds'; -import React from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { UserHero, UserHeroName } from './UserHero'; import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; @@ -9,7 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePowerLevels } from '../../hooks/usePowerLevels'; import { useRoom } from '../../hooks/useRoom'; import { useUserPresence } from '../../hooks/useUserPresence'; -import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; +import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip, TimezoneChip } from './UserChips'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { PowerChip } from './PowerChip'; import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; @@ -22,6 +22,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; import { CreatorChip } from './CreatorChip'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { DirectCreateSearchParams } from '../../pages/paths'; +import { useExtendedProfile } from '../../hooks/useExtendedProfile'; type UserRoomProfileProps = { userId: string; @@ -56,9 +57,24 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const displayName = getMemberDisplayName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); + const timezone = useMemo(() => { + // @ts-expect-error Intl.supportedValuesOf isn't in the types yet + const supportedTimezones = Intl.supportedValuesOf('timeZone') as string[]; + const profileTimezone = extendedProfile?.['us.cloke.msc4175.tz']; + if (profileTimezone && supportedTimezones.includes(profileTimezone)) { + return profileTimezone; + } + return undefined; + + }, [extendedProfile]); const presence = useUserPresence(userId); + useEffect(() => { + refreshExtendedProfile(); + }, [refreshExtendedProfile]); + const handleMessage = () => { closeUserRoomProfile(); const directSearchParam: DirectCreateSearchParams = { @@ -77,7 +93,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { - + {userId !== myUserId && ( + + + ); +} diff --git a/src/app/features/call/CallViewUser.tsx b/src/app/features/call/CallViewUser.tsx new file mode 100644 index 00000000..776fead6 --- /dev/null +++ b/src/app/features/call/CallViewUser.tsx @@ -0,0 +1,71 @@ +import { as, Avatar, Box, Icon, Icons, Text } from 'folds'; +import React from 'react'; +import classNames from 'classnames'; +import { Room } from 'matrix-js-sdk'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import { UserAvatar } from '../../components/user-avatar'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; +import * as css from './CallView.css'; + +type CallViewUserProps = { + room: Room; + callMembership: CallMembership; +}; + +export const UserProfileButton = as<'button'>( + ({ as: AsUserProfileButton = 'button', className, ...props }, ref) => ( + + ) +); + +export const CallViewUserBase = as<'div'>(({ className, ...props }, ref) => ( + +)); + +export function CallViewUser({ room, callMembership }: CallViewUserProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); + const userId = callMembership.sender ?? ''; + const avatarMxcUrl = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication) + : undefined; + const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId); + + const handleUserClick: React.MouseEventHandler = (evt) => { + openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + + + + } + /> + + + {getName} + + + + + ); +} diff --git a/src/app/features/call/CinnyWidget.ts b/src/app/features/call/CinnyWidget.ts new file mode 100644 index 00000000..3894af87 --- /dev/null +++ b/src/app/features/call/CinnyWidget.ts @@ -0,0 +1,9 @@ +import { Widget } from 'matrix-widget-api'; +import { IApp } from './SmallWidget'; + +// Wrapper class for the widget definition +export class CinnyWidget extends Widget { + public constructor(private rawDefinition: IApp) { + super(rawDefinition); + } +} diff --git a/src/app/features/call/SmallWidget.ts b/src/app/features/call/SmallWidget.ts new file mode 100644 index 00000000..0f9e4064 --- /dev/null +++ b/src/app/features/call/SmallWidget.ts @@ -0,0 +1,397 @@ +/* + * Copyright 2024 New Vector Ltd. + * Copyright 2020-2023 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import EventEmitter from 'events'; +import { + ClientEvent, + Direction, + IEvent, + KnownMembership, + MatrixClient, + MatrixEvent, + MatrixEventEvent, +} from 'matrix-js-sdk'; +import { + ClientWidgetApi, + IRoomEvent, + IStickyActionRequest, + IWidget, + IWidgetData, + MatrixCapabilities, + WidgetApiFromWidgetAction, + WidgetKind, +} from 'matrix-widget-api'; +import { CinnyWidget } from './CinnyWidget'; +import { SmallWidgetDriver } from './SmallWidgetDriver'; + +/** + * Generates the URL for the Element Call widget. + * @param mx - The MatrixClient instance. + * @param roomId - The ID of the room. + * @returns The generated URL object. + */ +export const getWidgetUrl = ( + mx: MatrixClient, + roomId: string, + elementCallUrl: string, + widgetId: string, + setParams: any +): URL => { + const baseUrl = window.location.origin; + const url = elementCallUrl + ? new URL(`${elementCallUrl}/room`) + : new URL('/public/element-call/index.html#', baseUrl); + + const params = new URLSearchParams({ + embed: 'true', + widgetId, + appPrompt: 'false', + skipLobby: setParams.skipLobby ?? 'true', // TODO: skipLobby is deprecated, use intent instead (intent doesn't produce the same effect?) + returnToLobby: setParams.returnToLobby ?? 'true', + perParticipantE2EE: setParams.perParticipantE2EE ?? 'true', + header: 'none', + confineToRoom: 'true', + theme: setParams.theme ?? 'dark', + userId: mx.getUserId()!, + deviceId: mx.getDeviceId()!, + roomId, + baseUrl: mx.baseUrl!, + parentUrl: window.location.origin, + }); + + const replacedParams = params.toString().replace(/%24/g, '$'); + url.search = `?${replacedParams}`; + + return url; +}; + +export interface IApp extends IWidget { + client: MatrixClient; + roomId: string; + eventId?: string; + avatar_url?: string; + sender: string; + 'io.element.managed_hybrid'?: boolean; +} + +export class SmallWidget extends EventEmitter { + private client: MatrixClient; + + private messaging: ClientWidgetApi | null = null; + + private mockWidget: CinnyWidget; + + public roomId?: string; + + public url?: string; + + public iframe: HTMLIFrameElement | null = null; + + private type: string; // Type of the widget (e.g., 'm.call') + + private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID + + private readonly eventsToFeed = new WeakSet(); + + private stickyPromise?: () => Promise; + + constructor(private iapp: IApp) { + super(); + this.client = iapp.client; + this.roomId = iapp.roomId; + this.url = iapp.url; + this.type = iapp.type; + this.mockWidget = new CinnyWidget(iapp); + } + + /** + * Initializes the widget messaging API. + * @param iframe - The HTMLIFrameElement to bind to. + * @returns The initialized ClientWidgetApi instance. + */ + startMessaging(iframe: HTMLIFrameElement): ClientWidgetApi { + // Ensure the driver is correctly instantiated + // The capabilities array might need adjustment based on required permissions + const driver = new SmallWidgetDriver( + this.client, + [], + this.mockWidget, + WidgetKind.Room, + true, + this.roomId + ); + this.iframe = iframe; + this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); + + // Emit events during the widget lifecycle + this.messaging.on('preparing', () => this.emit('preparing')); + this.messaging.on('error:preparing', (err: unknown) => this.emit('error:preparing', err)); + this.messaging.once('ready', () => this.emit('ready')); + // this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); // Uncomment if needed + + // Populate the map of "read up to" events for this widget with the current event in every room. + // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget + // requests timeline capabilities in other rooms down the road. It's just easier to manage here. + // eslint-disable-next-line no-restricted-syntax + for (const room of this.client.getRooms()) { + // Timelines are most recent last + const events = room.getLiveTimeline()?.getEvents() || []; + const roomEvent = events[events.length - 1]; + // force later code to think the room is fresh + if (roomEvent) { + const eventId = roomEvent.getId(); + if (eventId) this.readUpToMap[room.roomId] = eventId; + } + } + + this.messaging.on('action:org.matrix.msc2876.read_events', (ev: CustomEvent) => { + const room = this.client.getRoom(this.roomId); + const events: Partial[] = []; + const { type } = ev.detail.data; + + ev.preventDefault(); + if (room === null) { + return this.messaging?.transport.reply(ev.detail, { events }); + } + const state = room.getLiveTimeline().getState(Direction.Forward); + if (state === undefined) { + return this.messaging?.transport.reply(ev.detail, { events }); + } + + const stateEvents = state.events?.get(type); + + Array.from(stateEvents?.values() ?? []).forEach((eventObject) => { + events.push(eventObject.event); + }); + + return this.messaging?.transport.reply(ev.detail, { events }); + }); + + this.client.on(ClientEvent.Event, this.onEvent); + this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + this.messaging.on( + `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, + async (ev: CustomEvent) => { + if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + ev.preventDefault(); + if (ev.detail.data.value) { + // If the widget wants to become sticky we wait for the stickyPromise to resolve + if (this.stickyPromise) await this.stickyPromise(); + this.messaging.transport.reply(ev.detail, {}); + } + // Stop being persistent can be done instantly + // MAKE PERSISTENT HERE + // Send the ack after the widget actually has become sticky. + } + } + ); + + return this.messaging; + } + + private onEvent = (ev: MatrixEvent): void => { + this.client.decryptEventIfNeeded(ev); + this.feedEvent(ev); + }; + + private onEventDecrypted = (ev: MatrixEvent): void => { + this.feedEvent(ev); + }; + + private onReadEvent = (ev: MatrixEvent): void => { + this.feedEvent(ev); + }; + + private onToDeviceEvent = async (ev: MatrixEvent): Promise => { + await this.client.decryptEventIfNeeded(ev); + if (ev.isDecryptionFailure()) return; + await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted()); + }; + + /** + * Determines whether the event comes from a room that we've been invited to + * (in which case we likely don't have the full timeline). + */ + private isFromInvite(ev: MatrixEvent): boolean { + const room = this.client.getRoom(ev.getRoomId()); + return room?.getMyMembership() === KnownMembership.Invite; + } + + /** + * Determines whether the event has a relation to an unknown parent. + */ + private relatesToUnknown(ev: MatrixEvent): boolean { + // Replies to unknown events don't count + if (!ev.relationEventId || ev.replyEventId) return false; + const room = this.client.getRoom(ev.getRoomId()); + return room === null || !room.findEventById(ev.relationEventId); + } + + // eslint-disable-next-line class-methods-use-this + private arrayFastClone(a: T[]): T[] { + return a.slice(0, a.length); + } + + private advanceReadUpToMarker(ev: MatrixEvent): boolean { + const evId = ev.getId(); + if (evId === undefined) return false; + const roomId = ev.getRoomId(); + if (roomId === undefined) return false; + const room = this.client.getRoom(roomId); + if (room === null) return false; + + const upToEventId = this.readUpToMap[ev.getRoomId()!]; + if (!upToEventId) { + // There's no marker yet; start it at this event + this.readUpToMap[roomId] = evId; + return true; + } + + // Small optimization for exact match (skip the search) + if (upToEventId === evId) return false; + + // Timelines are most recent last, so reverse the order and limit ourselves to 100 events + // to avoid overusing the CPU. + const timeline = room.getLiveTimeline(); + const events = this.arrayFastClone(timeline.getEvents()).reverse().slice(0, 100); + + let advanced = false; + + events.some((timelineEvent) => { + const id = timelineEvent.getId(); + + if (id === upToEventId) { + // The event must be somewhere before the "read up to" marker + return true; + } + + if (id === evId) { + // The event is after the marker; advance it + this.readUpToMap[roomId] = evId; + advanced = true; + return true; + } + // We can't say for sure whether the widget has seen the event; let's + // just assume that it has + return false; + }); + + return advanced; + } + + private feedEvent(ev: MatrixEvent): void { + if (this.messaging === null) return; + + if ( + // If we had decided earlier to feed this event to the widget, but + // it just wasn't ready, give it another try + this.eventsToFeed.delete(ev) || + // Skip marker timeline check for events with relations to unknown parent because these + // events are not added to the timeline here and will be ignored otherwise: + // https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213 + this.relatesToUnknown(ev) || + // Skip marker timeline check for rooms where membership is + // 'invite', otherwise the membership event from the invitation room + // will advance the marker and new state events will not be + // forwarded to the widget. + this.isFromInvite(ev) || + // Check whether this event would be before or after our "read up to" marker. If it's + // before, or we can't decide, then we assume the widget will have already seen the event. + // If the event is after, or we don't have a marker for the room, then the marker will advance and we'll + // send it through. + // This approach of "read up to" prevents widgets receiving decryption spam from startup or + // receiving ancient events from backfill and such. + this.advanceReadUpToMarker(ev) + ) { + // If the event is still being decrypted, remember that we want to + // feed it to the widget (even if not strictly in the order given by + // the timeline) and get back to it later + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { + this.eventsToFeed.add(ev); + } else { + const raw = ev.getEffectiveEvent(); + this.messaging.feedEvent(raw as IRoomEvent, this.roomId ?? '').catch(() => null); + } + } + } + + /** + * Stops the widget messaging and cleans up resources. + */ + stopMessaging() { + if (this.messaging) { + this.messaging.stop(); // Example if a stop method exists + this.messaging.removeAllListeners(); // Remove listeners attached by SmallWidget + this.messaging = null; + } + } +} + +/** + * Creates the data object for the widget. + * @param client - The MatrixClient instance. + * @param roomId - The ID of the room. + * @param currentData - Existing widget data. + * @param overwriteData - Data to merge or overwrite. + * @returns The final widget data object. + */ +export const getWidgetData = ( + client: MatrixClient, + roomId: string, + currentData: object, + overwriteData: object +): IWidgetData => { + // Example: Determine E2EE based on room state if needed + const perParticipantE2EE = true; // Default or based on logic + // const roomEncryption = client.getRoom(roomId)?.currentState.getStateEvents(EventType.RoomEncryption, ""); + // if (roomEncryption) perParticipantE2EE = true; // Simplified example + + return { + ...currentData, + ...overwriteData, + perParticipantE2EE, + }; +}; + +/** + * Creates a virtual widget definition (IApp). + * @param client - MatrixClient instance. + * @param id - Widget ID. + * @param creatorUserId - User ID of the creator. + * @param name - Widget display name. + * @param type - Widget type (e.g., 'm.call'). + * @param url - Widget URL. + * @param waitForIframeLoad - Whether to wait for iframe load signal. + * @param data - Widget data. + * @param roomId - Room ID. + * @returns The IApp widget definition. + */ +export const createVirtualWidget = ( + client: MatrixClient, + id: string, + creatorUserId: string, + name: string, + type: string, + url: URL, + waitForIframeLoad: boolean, + data: IWidgetData, + roomId: string +): IApp => ({ + client, + id, + creatorUserId, + name, + type, + url: url.toString(), // Store URL as string in the definition + waitForIframeLoad, + data, + roomId, + // Add other required fields from IWidget if necessary + sender: creatorUserId, // Example: Assuming sender is the creator +}); diff --git a/src/app/features/call/SmallWidgetDriver.ts b/src/app/features/call/SmallWidgetDriver.ts new file mode 100644 index 00000000..6764bb40 --- /dev/null +++ b/src/app/features/call/SmallWidgetDriver.ts @@ -0,0 +1,551 @@ +/* eslint-disable no-return-await */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-continue */ +/* eslint-disable class-methods-use-this */ +/* eslint-disable no-dupe-class-members */ +/* + * Copyright 2024 New Vector Ltd. + * Copyright 2020-2023 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +import { + type Capability, + EventDirection, + type ISendDelayedEventDetails, + type ISendEventDetails, + type IReadEventRelationsResult, + type IRoomEvent, + MatrixCapabilities, + type Widget, + WidgetDriver, + WidgetEventCapability, + WidgetKind, + type IWidgetApiErrorResponseDataDetails, + type ISearchUserDirectoryResult, + type IGetMediaConfigResult, + type UpdateDelayedEventAction, + OpenIDRequestState, + SimpleObservable, + IOpenIDUpdate, +} from 'matrix-widget-api'; +import { + EventType, + type IContent, + MatrixError, + type MatrixEvent, + Direction, + type SendDelayedEventResponse, + type StateEvents, + type TimelineEvents, + MatrixClient, +} from 'matrix-js-sdk'; + +export class SmallWidgetDriver extends WidgetDriver { + private allowedCapabilities: Set; + + private readonly mxClient: MatrixClient; // Store the client instance + + public constructor( + mx: MatrixClient, + allowedCapabilities: Capability[], + private forWidget: Widget, + private forWidgetKind: WidgetKind, + virtual: boolean, // Assuming 'virtual' might be needed later, kept for consistency + private inRoomId?: string + ) { + super(); + this.mxClient = mx; // Store the passed instance + + this.allowedCapabilities = new Set([ + ...allowedCapabilities, + MatrixCapabilities.Screenshots, + // Add other base capabilities as needed, e.g., ElementWidgetCapabilities.RequiresClient + ]); + + // --- Capabilities specific to Element Call (or similar trusted widgets) --- + // This is a trusted Element Call widget that we control (adjust if not Element Call) + this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen); + this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers); + this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent); + this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); + // Capability to access the room timeline (MSC2762) + this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`); + // Capability to read room state (MSC2762) + this.allowedCapabilities.add(`org.matrix.msc2762.state:${inRoomId}`); + this.allowedCapabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw + ); + this.allowedCapabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call').raw + ); + this.allowedCapabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomEncryption).raw + ); + const clientUserId = this.mxClient.getSafeUserId(); + // For the legacy membership type + this.allowedCapabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + clientUserId + ).raw + ); + const clientDeviceId = this.mxClient.getDeviceId(); + if (clientDeviceId !== null) { + // For the session membership type compliant with MSC4143 + this.allowedCapabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `_${clientUserId}_${clientDeviceId}` + ).raw + ); + // Version with no leading underscore, for room versions whose auth rules allow it + this.allowedCapabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `${clientUserId}_${clientDeviceId}` + ).raw + ); + } + this.allowedCapabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member') + .raw + ); + // for determining auth rules specific to the room version + this.allowedCapabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw + ); + + const sendRecvRoomEvents = [ + 'io.element.call.encryption_keys', + 'org.matrix.rageshake_request', + EventType.Reaction, + EventType.RoomRedaction, + 'io.element.call.reaction', + ]; + // eslint-disable-next-line no-restricted-syntax + for (const eventType of sendRecvRoomEvents) { + this.allowedCapabilities.add( + WidgetEventCapability.forRoomEvent(EventDirection.Send, eventType).raw + ); + this.allowedCapabilities.add( + WidgetEventCapability.forRoomEvent(EventDirection.Receive, eventType).raw + ); + } + + const sendRecvToDevice = [ + EventType.CallInvite, + EventType.CallCandidates, + EventType.CallAnswer, + EventType.CallHangup, + EventType.CallReject, + EventType.CallSelectAnswer, + EventType.CallNegotiate, + EventType.CallSDPStreamMetadataChanged, + EventType.CallSDPStreamMetadataChangedPrefix, + EventType.CallReplaces, + EventType.CallEncryptionKeysPrefix, + ]; + // eslint-disable-next-line no-restricted-syntax + for (const eventType of sendRecvToDevice) { + this.allowedCapabilities.add( + WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw + ); + this.allowedCapabilities.add( + WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw + ); + } + } + + public async validateCapabilities(requested: Set): Promise> { + // Stubbed under the assumption voice calls will be valid thru element-call + return requested; + } + + public async sendEvent( + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null + ): Promise; + + public async sendEvent( + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null + ): Promise; + + public async sendEvent( + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mxClient; + const roomId = targetRoomId || this.inRoomId; + + if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + + let r: { event_id: string } | null; + if (stateKey !== null) { + // state event + r = await client.sendStateEvent( + roomId, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); + } else if (eventType === EventType.RoomRedaction) { + // special case: extract the `redacts` property and call redact + r = await client.redactEvent(roomId, content.redacts); + } else { + // message event + r = await client.sendEvent( + roomId, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + + return { roomId, eventId: r.event_id }; + } + + /** + * @experimental Part of MSC4140 & MSC4157 + * @see {@link WidgetDriver#sendDelayedEvent} + */ + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null + ): Promise; + + /** + * @experimental Part of MSC4140 & MSC4157 + */ + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null + ): Promise; + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mxClient; + const roomId = targetRoomId || this.inRoomId; + + if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + + let delayOpts; + if (delay !== null) { + delayOpts = { + delay, + ...(parentDelayId !== null && { parent_delay_id: parentDelayId }), + }; + } else if (parentDelayId !== null) { + delayOpts = { + parent_delay_id: parentDelayId, + }; + } else { + throw new Error('Must provide at least one of delay or parentDelayId'); + } + + let r: SendDelayedEventResponse | null; + if (stateKey !== null) { + // state event + r = await client._unstable_sendDelayedStateEvent( + roomId, + delayOpts, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); + } else { + // message event + r = await client._unstable_sendDelayedEvent( + roomId, + delayOpts, + null, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + + return { + roomId, + delayId: r.delay_id, + }; + } + + /** + * @experimental Part of MSC4140 & MSC4157 + */ + public async updateDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction + ): Promise { + const client = this.mxClient; + + if (!client) throw new Error('Not in a room or not attached to a client'); + + await client._unstable_updateDelayedEvent(delayId, action); + } + + /** + * Implements {@link WidgetDriver#sendToDevice} + */ + public async sendToDevice( + eventType: string, + encrypted: boolean, + contentMap: { [userId: string]: { [deviceId: string]: object } } + ): Promise { + const client = this.mxClient; + + if (encrypted) { + const crypto = client.getCrypto(); + if (!crypto) throw new Error('E2EE not enabled'); + + // attempt to re-batch these up into a single request + const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {}; + + // eslint-disable-next-line no-restricted-syntax + for (const userId of Object.keys(contentMap)) { + const userContentMap = contentMap[userId]; + // eslint-disable-next-line no-restricted-syntax + for (const deviceId of Object.keys(userContentMap)) { + const content = userContentMap[deviceId]; + const stringifiedContent = JSON.stringify(content); + invertedContentMap[stringifiedContent] = invertedContentMap[stringifiedContent] || []; + invertedContentMap[stringifiedContent].push({ userId, deviceId }); + } + } + + await Promise.all( + Object.entries(invertedContentMap).map(async ([stringifiedContent, recipients]) => { + const batch = await crypto.encryptToDeviceMessages( + eventType, + recipients, + JSON.parse(stringifiedContent) + ); + + await client.queueToDevice(batch); + }) + ); + } else { + await client.queueToDevice({ + eventType, + batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) => + Object.entries(userContentMap).map(([deviceId, content]) => ({ + userId, + deviceId, + payload: content, + })) + ), + }); + } + } + + /** + * Reads all events of the given type, and optionally `msgtype` (if applicable/defined), + * the user has access to. The widget API will have already verified that the widget is + * capable of receiving the events. Less events than the limit are allowed to be returned, + * but not more. + * @param roomId The ID of the room to look within. + * @param eventType The event type to be read. + * @param msgtype The msgtype of the events to be read, if applicable/defined. + * @param stateKey The state key of the events to be read, if applicable/defined. + * @param limit The maximum number of events to retrieve. Will be zero to denote "as many as + * possible". + * @param since When null, retrieves the number of events specified by the "limit" parameter. + * Otherwise, the event ID at which only subsequent events will be returned, as many as specified + * in "limit". + * @returns {Promise} Resolves to the room events, or an empty array. + */ + public async readRoomTimeline( + roomId: string, + eventType: string, + msgtype: string | undefined, + stateKey: string | undefined, + limit: number, + since: string | undefined + ): Promise { + limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + + const room = this.mxClient.getRoom(roomId); + if (room === null) return []; + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); // timelines are most recent last + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]; + if (results.length >= limit) break; + if (since !== undefined && ev.getId() === since) break; + + if (ev.getType() !== eventType || ev.isState()) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent().msgtype) + continue; + if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey) + continue; + results.push(ev); + } + + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); + } + + public async askOpenID(observer: SimpleObservable): Promise { + return observer.update({ + state: OpenIDRequestState.Allowed, + token: await this.mxClient.getOpenIdToken(), + }); + } + + /** + * Reads the current values of all matching room state entries. + * @param roomId The ID of the room. + * @param eventType The event type of the entries to be read. + * @param stateKey The state key of the entry to be read. If undefined, + * all room state entries with a matching event type should be returned. + * @returns {Promise} Resolves to the events representing the + * current values of the room state entries. + */ + public async readRoomState( + roomId: string, + eventType: string, + stateKey: string | undefined + ): Promise { + const room = this.mxClient.getRoom(roomId); + if (room === null) return []; + const state = room.getLiveTimeline().getState(Direction.Forward); + if (state === undefined) return []; + + if (stateKey === undefined) + return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent); + const event = state.getStateEvents(eventType, stateKey); + return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent]; + } + + /* + public async navigate(uri: string): Promise { + navigateToPermalink(uri); + } + */ + + public async readEventRelations( + eventId: string, + roomId?: string, + relationType?: string, + eventType?: string, + from?: string, + to?: string, + limit?: number, + direction?: 'f' | 'b' + ): Promise { + const client = this.mxClient; + const dir = direction as Direction; + roomId = roomId ?? this.inRoomId ?? undefined; + + if (typeof roomId !== 'string') { + throw new Error('Error while reading the current room'); + } + + const { events, nextBatch, prevBatch } = await client.relations( + roomId, + eventId, + relationType ?? null, + eventType ?? null, + { from, to, limit, dir } + ); + + return { + chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent), + nextBatch: nextBatch ?? undefined, + prevBatch: prevBatch ?? undefined, + }; + } + + public async searchUserDirectory( + searchTerm: string, + limit?: number + ): Promise { + const client = this.mxClient; + + const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit }); + + return { + limited, + results: results.map((r) => ({ + userId: r.user_id, + displayName: r.display_name, + avatarUrl: r.avatar_url, + })), + }; + } + + public async getMediaConfig(): Promise { + const client = this.mxClient; + + return await client.getMediaConfig(); + } + + public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> { + const client = this.mxClient; + + const uploadResult = await client.uploadContent(file); + + return { contentUri: uploadResult.content_uri }; + } + + /** + * Download a file from the media repository on the homeserver. + * + * @param contentUri - the MXC URI of the file to download + * @returns an object with: file - response contents as Blob + */ + /* + public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> { + const client = this.mxClient; + const media = mediaFromMxc(contentUri, client); + const response = await media.downloadSource(); + const blob = await response.blob(); + return { file: blob }; + } + */ + + /** + * Gets the IDs of all joined or invited rooms currently known to the + * client. + * @returns The room IDs. + */ + public getKnownRooms(): string[] { + return this.mxClient.getVisibleRooms().map((r) => r.roomId); + } + + /** + * Expresses a {@link MatrixError} as a JSON payload + * for use by Widget API error responses. + * @param error The error to handle. + * @returns The error expressed as a JSON payload, + * or undefined if it is not a {@link MatrixError}. + */ + public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined { + return error instanceof MatrixError + ? { matrix_api_error: error.asWidgetApiErrorData() } + : undefined; + } +} diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 29b6aa51..4125f6d2 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -30,6 +30,7 @@ import { AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; type DeveloperToolsProps = { requestClose: () => void; @@ -175,216 +176,166 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { } /> - - setExpandState(!expandState)} - variant="Secondary" - fill="Soft" + + + Events + Total: {roomState.size} + + + setComposeEvent({ stateKey: '' })} + variant="Surface" + fill="None" size="300" - radii="300" - outlined - before={ - - } + radii="0" + before={} > - {expandState ? 'Collapse' : 'Expand'} - - } - /> - {expandState && ( - - - Events - Total: {roomState.size} - - - setComposeEvent({ stateKey: '' })} - variant="Surface" - fill="None" - size="300" - radii="0" - before={} - > - - - Add New - - - - {Array.from(roomState.keys()) - .sort() - .map((eventType) => { - const expanded = eventType === expandStateType; - const stateKeyToEvents = roomState.get(eventType); - if (!stateKeyToEvents) return null; + + + Add New + + + + {Array.from(roomState.keys()) + .sort() + .map((eventType) => { + const expanded = eventType === expandStateType; + const stateKeyToEvents = roomState.get(eventType); + if (!stateKeyToEvents) return null; - return ( - - - setExpandStateType(expanded ? undefined : eventType) - } - variant="Surface" - fill="None" - size="300" - radii="0" - before={ - - } - after={{stateKeyToEvents.size}} + return ( + + + setExpandStateType(expanded ? undefined : eventType) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={ + + } + after={{stateKeyToEvents.size}} + > + + + {eventType} + + + + {expanded && ( +
- - - {eventType} - - - - {expanded && ( -
+ setComposeEvent({ type: eventType, stateKey: '' }) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={} > - - setComposeEvent({ type: eventType, stateKey: '' }) - } - variant="Surface" - fill="None" - size="300" - radii="0" - before={} - > - - - Add New - - - - {Array.from(stateKeyToEvents.keys()) - .sort() - .map((stateKey) => ( - { - setOpenStateEvent({ - type: eventType, - stateKey, - }); - }} - key={stateKey} - variant="Surface" - fill="None" - size="300" - radii="0" - after={} - > - - - {stateKey ? `"${stateKey}"` : 'Default'} - - - - ))} -
- )} - - ); - })} - - - )} - - + + Add New + + + + {Array.from(stateKeyToEvents.keys()) + .sort() + .map((stateKey) => ( + { + setOpenStateEvent({ + type: eventType, + stateKey, + }); + }} + key={stateKey} + variant="Surface" + fill="None" + size="300" + radii="0" + after={} + > + + + {stateKey ? `"${stateKey}"` : 'Default'} + + + + ))} +
+ )} +
+ ); + })} +
+
+ + - setExpandAccountData(!expandAccountData)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {expandAccountData ? 'Collapse' : 'Expand'} - - } - /> - {expandAccountData && ( - - - Events - Total: {accountData.size} - - - } - onClick={() => setAccountDataType(null)} - > - - - Add New - - - - {Array.from(accountData.keys()) - .sort() - .map((type) => ( - } - onClick={() => setAccountDataType(type)} - > - - - {type} - - - - ))} - + + + Events + Total: {accountData.size} - )} -
+ + } + onClick={() => setAccountDataType(null)} + > + + + Add New + + + + {Array.from(accountData.keys()) + .sort() + .map((type) => ( + } + onClick={() => setAccountDataType(type)} + > + + + {type} + + + + ))} + +
+
)}
diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx index 767a967e..1a618a68 100644 --- a/src/app/features/common-settings/general/RoomAddress.tsx +++ b/src/app/features/common-settings/general/RoomAddress.tsx @@ -7,8 +7,6 @@ import { Chip, color, config, - Icon, - Icons, Input, Spinner, Text, @@ -33,6 +31,7 @@ import { useAlive } from '../../../hooks/useAlive'; import { StateEvent } from '../../../../types/matrix/room'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; import { getMxIdServer } from '../../../utils/matrix'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; type RoomPublishedAddressesProps = { permissions: RoomPermissionsAPI; @@ -373,64 +372,40 @@ export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissio const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId); return ( - - setExpand(!expand)} - size="300" - variant="Secondary" - fill="Soft" - outlined - radii="300" - before={ - - } - > - - {expand ? 'Collapse' : 'Expand'} + + {localAliasesState.status === AsyncStatus.Loading && ( + + + Loading... + + )} + {localAliasesState.status === AsyncStatus.Success && + (localAliasesState.data.length === 0 ? ( + + No Addresses + + ) : ( + + ))} + {localAliasesState.status === AsyncStatus.Error && ( + + + {localAliasesState.error.message} - - } - /> - {expand && ( - - {localAliasesState.status === AsyncStatus.Loading && ( - - - Loading... - - )} - {localAliasesState.status === AsyncStatus.Success && - (localAliasesState.data.length === 0 ? ( - - No Addresses - - ) : ( - - ))} - {localAliasesState.status === AsyncStatus.Error && ( - - - {localAliasesState.error.message} - - - )} - - )} + + )} + {expand && } - + ); } diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index 0f515c39..0a67f9e0 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -199,7 +199,7 @@ export function RoomProfileEdit({ alt={name} renderFallback={() => ( ( { if (kind === CreateRoomKind.Private) return Icons.HashLock; @@ -72,6 +74,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP useAdditionalCreators(); const [federation, setFederation] = useState(true); const [encryption, setEncryption] = useState(false); + const [callRoom, setCallRoom] = useState(false); const [knock, setKnock] = useState(false); const [advance, setAdvance] = useState(false); @@ -116,8 +119,18 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP roomKnock = knock; } + let roomType; + const powerOverrides: IPowerLevels = { + events: {}, + }; + if (callRoom) { + roomType = RoomType.Call; + powerOverrides.events![StateEvent.GroupCallMemberPrefix] = 0; + } + create({ version: selectedRoomVersion, + type: roomType, parent: space, kind, name: roomName, @@ -127,6 +140,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP knock: roomKnock, allowFederation: federation, additionalCreators: allowAdditionalCreators ? additionalCreators : undefined, + powerLevelContentOverrides: powerOverrides, }).then((roomId) => { if (alive()) { onCreate?.(roomId); @@ -170,6 +184,20 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP disabled={disabled} />
+ + + } + /> + {kind === CreateRoomKind.Public && } diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index a0c4d3ab..ed526c68 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -165,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { {(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 && ( } > @@ -392,10 +390,7 @@ export function SearchFilters({ onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))} radii="Pill" before={ - + } after={} > diff --git a/src/app/features/room-nav/RoomCallNavStatus.css.ts b/src/app/features/room-nav/RoomCallNavStatus.css.ts new file mode 100644 index 00000000..059afd5a --- /dev/null +++ b/src/app/features/room-nav/RoomCallNavStatus.css.ts @@ -0,0 +1,21 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +export const Actions = style({ + padding: config.space.S200, +}); + +export const RoomButtonWrap = style({ + minWidth: 0, +}); + +export const RoomButton = style({ + width: '100%', + minWidth: 0, + padding: `0 ${config.space.S200}`, +}); + +export const RoomName = style({ + flexGrow: 1, + minWidth: 0, +}); diff --git a/src/app/features/room-nav/RoomCallNavStatus.tsx b/src/app/features/room-nav/RoomCallNavStatus.tsx new file mode 100644 index 00000000..8c132c9d --- /dev/null +++ b/src/app/features/room-nav/RoomCallNavStatus.tsx @@ -0,0 +1,129 @@ +import { + Box, + Chip, + Icon, + IconButton, + Icons, + Line, + Spinner, + Text, + Tooltip, + TooltipProvider, + color, +} from 'folds'; +import React from 'react'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import * as css from './RoomCallNavStatus.css'; + +export function CallNavStatus() { + const { + activeCallRoomId, + isActiveCallReady, + isAudioEnabled, + isVideoEnabled, + toggleAudio, + toggleVideo, + hangUp, + } = useCallState(); + const { navigateRoom } = useRoomNavigate(); + const hasActiveCall = Boolean(activeCallRoomId); + const isConnected = hasActiveCall && isActiveCallReady; + const handleGoToCallRoom = () => { + if (activeCallRoomId) { + navigateRoom(activeCallRoomId); + } + }; + + return ( + + + + + {hasActiveCall && ( + + Go to Room + + } + > + {(triggerRef) => ( + + {isConnected ? ( + + ) : ( + + )} + + {isConnected ? 'Connected' : 'Connecting'} + + + )} + + )} + + {hasActiveCall && ( + + Hang Up + + } + > + {(triggerRef) => ( + + + + )} + + )} + + {!isAudioEnabled ? 'Unmute' : 'Mute'} + + } + > + {(triggerRef) => ( + + + + )} + + + {!isVideoEnabled ? 'Video On' : 'Video Off'} + + } + > + {(triggerRef) => ( + + + + )} + + + + ); +} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 33b21bff..03c2d169 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -1,5 +1,5 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { Room } from 'matrix-js-sdk'; +import React, { MouseEventHandler, forwardRef, useState, MouseEvent } from 'react'; +import { EventType, Room } from 'matrix-js-sdk'; import { Avatar, Box, @@ -16,10 +16,13 @@ import { RectCords, Badge, Spinner, + Tooltip, + TooltipProvider, } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; -import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; +import { useNavigate } from 'react-router-dom'; +import { NavButton, NavItem, NavItemContent, NavItemOptions } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; @@ -51,6 +54,12 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { useCallMembers } from '../../hooks/useCallMemberships'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { RoomNavUser } from './RoomNavUser'; +import { useRoomName } from '../../hooks/useRoomMeta'; type RoomNavItemMenuProps = { room: Room; @@ -208,6 +217,7 @@ const RoomNavItemMenu = forwardRef( ); } ); +RoomNavItemMenu.displayName = 'RoomNavItemMenu'; type RoomNavItemProps = { room: Room; @@ -236,6 +246,32 @@ export function RoomNavItem({ (receipt) => receipt.userId !== mx.getUserId() ); + const { + isActiveCallReady, + activeCallRoomId, + setActiveCallRoomId, + setViewedCallRoomId, + isChatOpen, + toggleChat, + hangUp, + } = useCallState(); + + const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId; + const callMemberships = useCallMembers(mx, room.roomId); + + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const roomName = useRoomName(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canJoinCall = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId()); + + const { navigateRoom } = useRoomNavigate(); + const navigate = useNavigate(); + + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); setMenuAnchor({ @@ -250,109 +286,207 @@ export function RoomNavItem({ setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleNavItemClick: MouseEventHandler = (evt) => { + if (room.isCallRoom()) { + if (!isMobile) { + if (!isActiveCall && canJoinCall) { + hangUp(); + setActiveCallRoomId(room.roomId); + } else { + navigateRoom(room.roomId); + } + } else { + evt.stopPropagation(); + if (isChatOpen) toggleChat(); + setViewedCallRoomId(room.roomId); + navigateRoom(room.roomId); + } + } else { + navigateRoom(room.roomId); + } + }; + + const handleChatButtonClick = (evt: MouseEvent) => { + evt.stopPropagation(); + if (!isChatOpen) toggleChat(); + setViewedCallRoomId(room.roomId); + navigate(linkPath); + }; + const optionsVisible = hover || !!menuAnchor; + const ariaLabel = [ + roomName, + room.isCallRoom() + ? [ + 'Call Room', + isActiveCall && 'Currently in Call', + callMemberships.length && `${callMemberships.length} in Call`, + ] + : 'Text Room', + unread?.total && `${unread.total} Messages`, + ] + .flat() + .filter(Boolean) + .join(', '); return ( - - - - - - {showAvatar ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - + + + + + + {showAvatar ? ( + ( + + {nameInitials(roomName)} + + )} + /> + ) : ( + + )} + + + + {roomName} + + + {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( + + + + )} + {!optionsVisible && unread && ( + + 0} count={unread.total} /> + + )} + {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( + )} - - - - {room.name} - - {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( - - - - )} - {!optionsVisible && unread && ( - - 0} count={unread.total} /> - - )} - {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( - - )} - - - - {optionsVisible && ( - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - notificationMode={notificationMode} - /> - - } - > - + + {optionsVisible && ( + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + notificationMode={notificationMode} + /> + + } > - - - - + {room.isCallRoom() && ( + + Open Chat + + } + > + {(triggerRef) => ( + + + + )} + + )} + + + + + + )} + + {room.isCallRoom() && ( + + {callMemberships.map((callMembership) => ( + + ))} + )} - + ); } diff --git a/src/app/features/room-nav/RoomNavUser.tsx b/src/app/features/room-nav/RoomNavUser.tsx new file mode 100644 index 00000000..96795647 --- /dev/null +++ b/src/app/features/room-nav/RoomNavUser.tsx @@ -0,0 +1,63 @@ +import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import React from 'react'; +import { Room } from 'matrix-js-sdk'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import { NavButton, NavItem, NavItemContent } from '../../components/nav'; +import { UserAvatar } from '../../components/user-avatar'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; + +type RoomNavUserProps = { + room: Room; + callMembership: CallMembership; +}; +export function RoomNavUser({ room, callMembership }: RoomNavUserProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); + const { isActiveCallReady, activeCallRoomId } = useCallState(); + const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId; + const userId = callMembership.sender ?? ''; + const avatarMxcUrl = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication) + : undefined; + const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId); + const isCallParticipant = isActiveCall && userId !== mx.getUserId(); + + const handleNavUserClick: React.MouseEventHandler = (evt) => { + openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect()); + }; + + const ariaLabel = isCallParticipant ? `Call Participant: ${getName}` : getName; + + return ( + + + + + + + } + /> + + + {getName} + + + + + + + ); +} diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts index 513f82b4..cf77a277 100644 --- a/src/app/features/room-settings/permissions/usePermissionItems.ts +++ b/src/app/features/room-settings/permissions/usePermissionItems.ts @@ -46,6 +46,19 @@ export const usePermissionGroups = (): PermissionGroup[] => { ], }; + const callSettingsGroup: PermissionGroup = { + name: 'Calls', + items: [ + { + location: { + state: true, + key: StateEvent.GroupCallMemberPrefix, + }, + name: 'Join Call', + }, + ], + }; + const moderationGroup: PermissionGroup = { name: 'Moderation', items: [ @@ -196,6 +209,7 @@ export const usePermissionGroups = (): PermissionGroup[] => { return [ messagesGroup, + callSettingsGroup, moderationGroup, roomOverviewGroup, roomSettingsGroup, diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 24878d5e..d86c0d98 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -13,6 +13,8 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { markAsRead } from '../../utils/notifications'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { CallView } from '../call/CallView'; +import { RoomViewHeader } from './RoomViewHeader'; export function Room() { const { eventId } = useParams(); @@ -23,7 +25,7 @@ export function Room() { const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); - const members = useRoomMembers(mx, room.roomId); + const members = useRoomMembers(mx, room?.roomId); useKeyDown( window, @@ -40,8 +42,17 @@ export function Room() { return ( - - {screenSize === ScreenSize.Desktop && isDrawer && ( + + + + + {room.isCallRoom() && screenSize === ScreenSize.Desktop && ( + + )} + + + + {!room.isCallRoom() && screenSize === ScreenSize.Desktop && isDrawer && ( <> diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2281b59d..d1678b65 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -471,6 +471,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const permissions = useRoomPermissions(creators, powerLevels); const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); const [editId, setEditId] = useState(); @@ -1047,7 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli collapse={collapse} highlight={highlighted} edit={editId === mEventId} - canDelete={canRedact || mEvent.getSender() === mx.getUserId()} + canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} @@ -1129,7 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli collapse={collapse} highlight={highlighted} edit={editId === mEventId} - canDelete={canRedact || mEvent.getSender() === mx.getUserId()} + canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} @@ -1247,7 +1248,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli messageLayout={messageLayout} collapse={collapse} highlight={highlighted} - canDelete={canRedact || mEvent.getSender() === mx.getUserId()} + canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 0f837594..16bfe891 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { Box, Text, config } from 'folds'; +import { Box, Text, config, toRem } from 'folds'; import { EventType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -15,13 +15,14 @@ import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { Page } from '../../components/page'; -import { RoomViewHeader } from './RoomViewHeader'; import { useKeyDown } from '../../hooks/useKeyDown'; import { editableActiveElement } from '../../utils/dom'; import { settingsAtom } from '../../state/settings'; import { useSetting } from '../../state/hooks/settings'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { @@ -30,10 +31,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { return false; } - // do not focus on F keys if (FN_KEYS_REGEX.test(code)) return false; - // do not focus on numlock/scroll lock if ( code.startsWith('OS') || code.startsWith('Meta') || @@ -61,6 +60,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { const roomViewRef = useRef(null); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const screenSize = useScreenSizeContext(); + const { isChatOpen } = useCallState(); const { roomId } = room; const editor = useEditor(); @@ -92,51 +93,59 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { ); return ( - - - - - - - -
- {tombstoneEvent ? ( - - ) : ( - <> - {canMessage && ( - - )} - {!canMessage && ( - - You do not have permission to post in this room - - )} - - )} -
- {hideActivity ? : } -
-
+ (!room.isCallRoom() || isChatOpen) && ( + + + + + + +
+ {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + You do not have permission to post in this room + + )} + + )} +
+ {hideActivity ? : } +
+
+ ) ); } diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 9b4bfd23..5c430268 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -23,8 +23,7 @@ import { Spinner, } from 'folds'; import { useNavigate } from 'react-router-dom'; -import { JoinRule, Room } from 'matrix-js-sdk'; -import { useAtomValue } from 'jotai'; +import { Room } from 'matrix-js-sdk'; import { useStateEvent } from '../../hooks/useStateEvent'; import { PageHeader } from '../../components/page'; @@ -33,7 +32,7 @@ import { UseStateProvider } from '../../components/UseStateProvider'; import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { StateEvent } from '../../../types/matrix/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useRoom } from '../../hooks/useRoom'; +import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useSpaceOptionally } from '../../hooks/useSpace'; @@ -48,7 +47,6 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { copyToClipboard } from '../../utils/dom'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; -import { mDirectAtom } from '../../state/mDirectList'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { stopPropagation } from '../../utils/keyboard'; import { getMatrixToRoom } from '../../plugins/matrix-to'; @@ -69,6 +67,8 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { useCallState } from '../../pages/client/call/CallProvider'; +import { ContainerColor } from '../../styles/ContainerColor.css'; type RoomMenuProps = { room: Room; @@ -263,12 +263,13 @@ export function RoomViewHeader() { const space = useSpaceOptionally(); const [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); - const mDirects = useAtomValue(mDirectAtom); + const direct = useIsDirectRoom(); + const { isChatOpen, toggleChat } = useCallState(); const pinnedEvents = useRoomPinnedEvents(room); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const ecryptedRoom = !!encryptionEvent; - const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); + const avatarMxc = useRoomAvatar(room, direct); const name = useRoomName(room); const topic = useRoomTopic(room); const avatarUrl = avatarMxc @@ -296,13 +297,16 @@ export function RoomViewHeader() { }; return ( - + {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + @@ -317,11 +321,7 @@ export function RoomViewHeader() { src={avatarUrl} alt={name} renderFallback={() => ( - + )} /> @@ -369,8 +369,9 @@ export function RoomViewHeader() { )}
+ - {!ecryptedRoom && ( + {!ecryptedRoom && (!room.isCallRoom() || isChatOpen) && ( {(triggerRef) => ( - + )} )} - - Pinned Messages - - } - > - {(triggerRef) => ( - - {pinnedEvents.length > 0 && ( - - - {pinnedEvents.length} - - - )} - - - )} - - setPinMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setPinMenuAnchor(undefined)} /> - - } - /> - {screenSize === ScreenSize.Desktop && ( + {(!room.isCallRoom() || isChatOpen) && ( + + Pinned Messages + + } + > + {(triggerRef) => ( + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + + + )} + + )} + {(!room.isCallRoom() || isChatOpen) && ( + setPinMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(undefined)} /> + + } + /> + )} + + {!room.isCallRoom() && screenSize === ScreenSize.Desktop && ( {(triggerRef) => ( - setPeopleDrawer((drawer) => !drawer)}> + setPeopleDrawer((drawer) => !drawer)} + > )} )} + + {room.isCallRoom() && !direct && ( + + Chat + + } + > + {(triggerRef) => ( + + + + )} + + )} + {(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/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 5e1a20f4..fc8e1a7f 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -21,10 +21,9 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { Account } from './account'; import { useUserProfile } from '../../hooks/useUserProfile'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { UserAvatar } from '../../components/user-avatar'; -import { nameInitials } from '../../utils/common'; import { Notifications } from './notifications'; import { Devices } from './devices'; import { EmojisStickers } from './emojis-stickers'; @@ -99,9 +98,8 @@ type SettingsProps = { export function Settings({ initialPage, requestClose }: SettingsProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const userId = mx.getUserId()!; + const userId = mx.getUserId() as string; const profile = useUserProfile(userId); - const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; @@ -132,7 +130,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {nameInitials(displayName)}} + renderFallback={() => } /> diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index e982a799..71f5773f 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -1,324 +1,283 @@ -import React, { - ChangeEventHandler, - FormEventHandler, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { - Box, - Text, - IconButton, - Icon, - Icons, - Input, - Avatar, - Button, - Overlay, - OverlayBackdrop, - OverlayCenter, - Modal, - Dialog, - Header, - config, - Spinner, -} from 'folds'; -import FocusTrap from 'focus-trap-react'; +import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { Box, Text, Button, config, Spinner, Line } from 'folds'; +import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; -import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; -import { UserAvatar } from '../../../components/user-avatar'; +import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { nameInitials } from '../../../utils/common'; +import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero'; +import { + ExtendedProfile, + profileEditsAllowed, + useExtendedProfile, +} from '../../../hooks/useExtendedProfile'; +import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { useFilePicker } from '../../../hooks/useFilePicker'; -import { useObjectURL } from '../../../hooks/useObjectURL'; -import { stopPropagation } from '../../../utils/keyboard'; -import { ImageEditor } from '../../../components/image-editor'; -import { ModalWide } from '../../../styles/Modal.css'; -import { createUploadAtom, UploadSuccess } from '../../../state/upload'; -import { CompactUploadCardRenderer } from '../../../components/upload-card'; +import { CutoutCard } from '../../../components/cutout-card'; +import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips'; +import { SequenceCardStyle } from '../styles.css'; +import { useUserProfile } from '../../../hooks/useUserProfile'; +import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; +import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; +import { withSearchParam } from '../../../pages/pathUtils'; import { useCapabilities } from '../../../hooks/useCapabilities'; +import { ProfileAvatar } from './fields/ProfileAvatar'; +import { ProfileTextField } from './fields/ProfileTextField'; +import { ProfilePronouns } from './fields/ProfilePronouns'; +import { ProfileTimezone } from './fields/ProfileTimezone'; -type ProfileProps = { - profile: UserProfile; - userId: string; -}; -function ProfileAvatar({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const capabilities = useCapabilities(); - const [alertRemove, setAlertRemove] = useState(false); - const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false; +function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) { + const accountManagementActions = useAccountManagementActions(); - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const avatarUrl = profile.avatarUrl - ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined - : undefined; + const openProviderProfileSettings = useCallback(() => { + const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer; + if (!authUrl) return; - const [imageFile, setImageFile] = useState(); - const imageFileURL = useObjectURL(imageFile); - const uploadAtom = useMemo(() => { - if (imageFile) return createUploadAtom(imageFile); - return undefined; - }, [imageFile]); - - const pickFile = useFilePicker(setImageFile, false); - - const handleRemoveUpload = useCallback(() => { - setImageFile(undefined); - }, []); - - const handleUploaded = useCallback( - (upload: UploadSuccess) => { - const { mxc } = upload; - mx.setAvatarUrl(mxc); - handleRemoveUpload(); - }, - [mx, handleRemoveUpload] - ); - - const handleRemoveAvatar = () => { - mx.setAvatarUrl(''); - setAlertRemove(false); - }; + window.open( + withSearchParam(authUrl, { + action: accountManagementActions.profile, + }), + '_blank' + ); + }, [authMetadata, accountManagementActions]); return ( - - Avatar - - } - after={ - - {nameInitials(defaultDisplayName)}} - /> - - } - > - {uploadAtom ? ( - - - - ) : ( - + + pickFile('image/*')} size="300" variant="Secondary" fill="Soft" - outlined radii="300" - disabled={disableSetAvatar} + outlined + onClick={openProviderProfileSettings} > - Upload + Open - {avatarUrl && ( - - )} - - )} - - {imageFileURL && ( - }> - - - - - - - - - )} - - }> - - setAlertRemove(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - Remove Avatar - - setAlertRemove(false)} radii="300"> - - -
- - - Are you sure you want to remove profile avatar? - - - -
-
-
-
- + } + > + Change profile settings in your homeserver's account dashboard. + + ); } -function ProfileDisplayName({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); - const capabilities = useCapabilities(); - const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; +/// Context props which are passed to every field element. +/// Right now this is only a flag for if the profile is being saved. +export type FieldContext = { busy: boolean }; - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const [displayName, setDisplayName] = useState(defaultDisplayName); +/// Field editor elements for the pre-MSC4133 profile fields. This should only +/// ever contain keys for `displayname` and `avatar_url`. +const LEGACY_FIELD_ELEMENTS = { + avatar_url: ProfileAvatar, + displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => ( + + ), +}; - const [changeState, changeDisplayName] = useAsyncCallback( - useCallback((name: string) => mx.setDisplayName(name), [mx]) - ); - const changingDisplayName = changeState.status === AsyncStatus.Loading; - - useEffect(() => { - setDisplayName(defaultDisplayName); - }, [defaultDisplayName]); - - const handleChange: ChangeEventHandler = (evt) => { - const name = evt.currentTarget.value; - setDisplayName(name); - }; - - const handleReset = () => { - setDisplayName(defaultDisplayName); - }; - - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (changingDisplayName) return; - - const target = evt.target as HTMLFormElement | undefined; - const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined; - const name = displayNameInput?.value; - if (!name) return; - - changeDisplayName(name); - }; - - const hasChanges = displayName !== defaultDisplayName; - return ( - - Display Name - - } - > - - - - - -
- ) - } - /> -
- -
-
- - ); -} +/// Field editor elements for MSC4133 extended profile fields. +/// These will appear in the UI in the order they are defined in this map. +const EXTENDED_FIELD_ELEMENTS = { + 'io.fsky.nyx.pronouns': ProfilePronouns, + 'us.cloke.msc4175.tz': ProfileTimezone, +}; export function Profile() { const mx = useMatrixClient(); - const userId = mx.getUserId()!; - const profile = useUserProfile(userId); + const userId = mx.getUserId() as string; + const server = getMxIdServer(userId); + const authMetadata = useAuthMetadata(); + const accountManagementActions = useAccountManagementActions(); + const useAuthentication = useMediaAuthentication(); + const capabilities = useCapabilities(); + + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); + const extendedProfileSupported = extendedProfile !== null; + const legacyProfile = useUserProfile(userId); + + // next-gen auth identity providers may provide profile settings if they want + const profileEditableThroughIDP = + authMetadata !== undefined && + authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile); + + const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => { + const entries = Object.entries({ + ...LEGACY_FIELD_ELEMENTS, + // don't show the MSC4133 elements if the HS doesn't support them + ...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}), + }).filter(([key]) => + // don't show fields if the HS blocks them with capabilities + profileEditsAllowed(key, capabilities, extendedProfileSupported) + ); + return [Object.fromEntries(entries), entries.length > 0]; + }, [capabilities, extendedProfileSupported]); + + const [fieldDefaults, setFieldDefaults] = useState({ + displayname: legacyProfile.displayName, + avatar_url: legacyProfile.avatarUrl, + }); + + // this updates the field defaults when the extended profile data is (re)loaded. + // it has to be a layout effect to prevent flickering on saves. + // if MSC4133 isn't supported by the HS this does nothing + useLayoutEffect(() => { + // `extendedProfile` includes the old dn/av fields, so + // we don't have to add those here + if (extendedProfile) { + setFieldDefaults(extendedProfile); + } + }, [setFieldDefaults, extendedProfile]); + + const [saveState, handleSave] = useAsyncCallback( + useCallback( + async (fields: ExtendedProfile) => { + if (extendedProfileSupported) { + await Promise.all( + Object.entries(fields).map(async ([key, value]) => { + if (value === undefined) { + await mx.deleteExtendedProfileProperty(key); + } else { + await mx.setExtendedProfileProperty(key, value); + } + }) + ); + + // calling this will trigger the layout effect to update the defaults + // once the profile request completes + await refreshExtendedProfile(); + + // synthesize a profile update for ourselves to update our name and avatar in the rest + // of the UI. code copied from matrix-js-sdk + const user = mx.getUser(userId); + if (user) { + user.displayName = fields.displayname; + user.avatarUrl = fields.avatar_url; + user.emit(UserEvent.DisplayName, user.events.presence, user); + user.emit(UserEvent.AvatarUrl, user.events.presence, user); + } + } else { + await mx.setDisplayName(fields.displayname ?? ''); + await mx.setAvatarUrl(fields.avatar_url ?? ''); + // layout effect does nothing because `extendedProfile` is undefined + // so we have to update the defaults explicitly here + setFieldDefaults(fields); + } + }, + [mx, userId, refreshExtendedProfile, extendedProfileSupported, setFieldDefaults] + ) + ); + + const saving = saveState.status === AsyncStatus.Loading; + const loadingExtendedProfile = extendedProfile === undefined; + const busy = saving || loadingExtendedProfile; return ( Profile - - + + {(reset, hasChanges, fields, fieldElements) => { + const heroAvatarUrl = + (fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ?? + undefined; + return ( + <> + + + + + + + {server && } + + {fields['us.cloke.msc4175.tz'] && ( + + )} + + + + + {profileEditableThroughIDP && ( + + )} + {profileEditableThroughClient && ( + <> + + {fieldElements} + + + + + {saving && } + + + )} + {!(profileEditableThroughClient || profileEditableThroughIDP) && ( + + + + + Profile Editing Disabled + + + + Your homeserver does not allow you to edit your profile. + + + + + + )} + + + ); + }} + ); diff --git a/src/app/features/settings/account/fields/ProfileAvatar.tsx b/src/app/features/settings/account/fields/ProfileAvatar.tsx new file mode 100644 index 00000000..5c668c56 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileAvatar.tsx @@ -0,0 +1,118 @@ +import FocusTrap from 'focus-trap-react'; +import { Text, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds'; +import React, { useState, useMemo, useCallback } from 'react'; +import { ImageEditor } from '../../../../components/image-editor'; +import { SettingTile } from '../../../../components/setting-tile'; +import { CompactUploadCardRenderer } from '../../../../components/upload-card'; +import { useFilePicker } from '../../../../hooks/useFilePicker'; +import { useMatrixClient } from '../../../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication'; +import { useObjectURL } from '../../../../hooks/useObjectURL'; +import { createUploadAtom, UploadSuccess } from '../../../../state/upload'; +import { stopPropagation } from '../../../../utils/keyboard'; +import { mxcUrlToHttp } from '../../../../utils/matrix'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; +import { ModalWide } from '../../../../styles/Modal.css'; + +export function ProfileAvatar({ + busy, value, setValue, +}: ProfileFieldElementProps<'avatar_url', FieldContext>) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const avatarUrl = value + ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; + const disabled = busy; + + const [imageFile, setImageFile] = useState(); + const imageFileURL = useObjectURL(imageFile); + const uploadAtom = useMemo(() => { + if (imageFile) return createUploadAtom(imageFile); + return undefined; + }, [imageFile]); + + const pickFile = useFilePicker(setImageFile, false); + + const handleRemoveUpload = useCallback(() => { + setImageFile(undefined); + }, []); + + const handleUploaded = useCallback( + (upload: UploadSuccess) => { + const { mxc } = upload; + setValue(mxc); + handleRemoveUpload(); + }, + [setValue, handleRemoveUpload] + ); + + const handleRemoveAvatar = () => { + setValue(''); + }; + + return ( + + Avatar + } + > + {uploadAtom ? ( + + + + ) : ( + + + {avatarUrl && ( + + )} + + )} + + {imageFileURL && ( + }> + + + + + + + + + )} + + ); +} diff --git a/src/app/features/settings/account/fields/ProfileFieldContext.tsx b/src/app/features/settings/account/fields/ProfileFieldContext.tsx new file mode 100644 index 00000000..18f78627 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileFieldContext.tsx @@ -0,0 +1,127 @@ +import React, { + FunctionComponent, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { deepCompare } from 'matrix-js-sdk/lib/utils'; +import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; + +/// These types ensure the element functions are actually able to manipulate +/// the profile fields they're mapped to. The generic parameter represents +/// extra "context" props which are passed to every element. + +// strip the index signature from ExtendedProfile using mapped type magic. +// keeping the index signature causes weird typechecking issues further down the line +// plus there should never be field elements passed with keys which don't exist in ExtendedProfile. +type ExtendedProfileKeys = keyof { + [Property in keyof ExtendedProfile as string extends Property + ? never + : Property]: ExtendedProfile[Property]; +}; + +// these are the props which all field elements must accept. +// this is split into `RawProps` and `Props` so we can type `V` instead of +// spraying `ExtendedProfile[K]` all over the place. +// don't use this directly, use the `ProfileFieldElementProps` type instead +type ProfileFieldElementRawProps = { + defaultValue: V; + value: V; + setValue: (value: V) => void; +} & C; + +export type ProfileFieldElementProps< + K extends ExtendedProfileKeys, + C +> = ProfileFieldElementRawProps; + +// the map of extended profile keys to field element functions +type ProfileFieldElements = { + [Property in ExtendedProfileKeys]?: FunctionComponent>; +}; + +type ProfileFieldContextProps = { + fieldDefaults: ExtendedProfile; + fieldElements: ProfileFieldElements; + children: ( + reset: () => void, + hasChanges: boolean, + fields: ExtendedProfile, + fieldElements: ReactNode + ) => ReactNode; + context: C; +}; + +/// This element manages the pending state of the profile field widgets. +/// It takes the default values of each field, as well as a map associating a profile field key +/// with an element _function_ (not a rendered element!) that will be used to edit that field. +/// It renders the editor elements internally using React.createElement and passes the rendered +/// elements into the child UI. This allows it to handle the pending state entirely by itself, +/// and provides strong typechecking. +export function ProfileFieldContext({ + fieldDefaults, + fieldElements: fieldElementConstructors, + children, + context, +}: ProfileFieldContextProps): ReactNode { + const [fields, setFields] = useState(fieldDefaults); + + // this callback also runs when fieldDefaults changes, + // which happens when the profile is saved and the pending fields become the new defaults + const reset = useCallback(() => { + setFields(fieldDefaults); + }, [fieldDefaults]); + + // set the pending values to the defaults on the first render + useEffect(() => { + reset(); + }, [reset]); + + const setField = useCallback( + (key: string, value: unknown) => { + setFields({ + ...fields, + [key]: value, + }); + }, + [fields] + ); + + const hasChanges = useMemo( + () => + Object.entries(fields).find( + ([key, value]) => + // deep comparison is necessary here because field values can be any JSON type + !deepCompare(fieldDefaults[key as keyof ExtendedProfile], value) + ) !== undefined, + [fields, fieldDefaults] + ); + + const createElement = useCallback( + (key: K, element: ProfileFieldElements[K]) => { + const props: ProfileFieldElementRawProps = { + ...context, + defaultValue: fieldDefaults[key], + value: fields[key], + setValue: (value) => setField(key, value), + key, + }; + // element can be undefined if the field defaults didn't include its key, + // which means the HS doesn't support setting that field + if (element !== undefined) { + return React.createElement(element, props); + } + return undefined; + }, + [context, fieldDefaults, fields, setField] + ); + + const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) => + // @ts-expect-error TypeScript doesn't quite understand the magic going on here + createElement(key, element) + ); + + return children(reset, hasChanges, fields, fieldElements); +} diff --git a/src/app/features/settings/account/fields/ProfilePronouns.tsx b/src/app/features/settings/account/fields/ProfilePronouns.tsx new file mode 100644 index 00000000..963a7a94 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfilePronouns.tsx @@ -0,0 +1,125 @@ +import FocusTrap from 'focus-trap-react'; +import { RectCords, Text, Box, Chip, Icon, Icons, PopOut, Menu, config, Input, Button } from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import React, { useState, FormEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react'; +import { SettingTile } from '../../../../components/setting-tile'; +import { stopPropagation } from '../../../../utils/keyboard'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfilePronouns({ + value, setValue, busy, +}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) { + const disabled = busy; + + const [menuCords, setMenuCords] = useState(); + const [pendingPronoun, setPendingPronoun] = useState(''); + + const handleRemovePronoun = (index: number) => { + const newPronouns = [...(value ?? [])]; + newPronouns.splice(index, 1); + if (newPronouns.length > 0) { + setValue(newPronouns); + } else { + setValue(undefined); + } + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + setMenuCords(undefined); + if (pendingPronoun.length > 0) { + setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setMenuCords(undefined); + } + }; + + const handleOpenMenu: MouseEventHandler = (evt) => { + setPendingPronoun(''); + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + Pronouns + } + > + + {value?.map(({ summary }, index) => ( + } + onClick={() => handleRemovePronoun(index)} + disabled={disabled} + > + + {summary} + + + ))} + } + onClick={handleOpenMenu} + > + Add + + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + setPendingPronoun(evt.currentTarget.value)} + onKeyDown={handleKeyDown} /> + + + + } /> + + ); +} diff --git a/src/app/features/settings/account/fields/ProfileTextField.tsx b/src/app/features/settings/account/fields/ProfileTextField.tsx new file mode 100644 index 00000000..fc96cb69 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileTextField.tsx @@ -0,0 +1,63 @@ +import { Text, Box, Input, IconButton, Icon, Icons } from 'folds'; +import React, { ChangeEventHandler } from 'react'; +import { FilterByValues } from '../../../../../types/utils'; +import { SettingTile } from '../../../../components/setting-tile'; +import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfileTextField>({ + label, defaultValue, value, setValue, busy, +}: ProfileFieldElementProps & { label: string; }) { + const disabled = busy; + const hasChanges = defaultValue !== value; + + const handleChange: ChangeEventHandler = (evt) => { + const content = evt.currentTarget.value; + if (content.length > 0) { + setValue(evt.currentTarget.value); + } else { + setValue(undefined); + } + }; + + const handleReset = () => { + setValue(defaultValue); + }; + + return ( + + {label} + } + > + + + + + + + )} /> + + + + + ); +} diff --git a/src/app/features/settings/account/fields/ProfileTimezone.tsx b/src/app/features/settings/account/fields/ProfileTimezone.tsx new file mode 100644 index 00000000..6ebea2ac --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileTimezone.tsx @@ -0,0 +1,160 @@ +import FocusTrap from 'focus-trap-react'; +import { Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton, Icon, Icons, Input, toRem, MenuItem, Button } from 'folds'; +import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react'; +import { CutoutCard } from '../../../../components/cutout-card'; +import { SettingTile } from '../../../../components/setting-tile'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfileTimezone({ + value, setValue, busy, +}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) { + const disabled = busy; + + const inputRef = useRef(null); + const scrollRef = useRef(null); + const [overlayOpen, setOverlayOpen] = useState(false); + const [query, setQuery] = useState(''); + + // @ts-expect-error Intl.supportedValuesOf isn't in the types yet + const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []); + const filteredTimezones = timezones.filter( + (timezone) => query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase()) + ); + + const handleSelect = useCallback( + (timezone: string) => { + setOverlayOpen(false); + setValue(timezone); + }, + [setOverlayOpen, setValue] + ); + + useEffect(() => { + if (overlayOpen) { + const scrollView = scrollRef.current; + const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`); + + if (value && focusedItem && scrollView) { + focusedItem.scrollIntoView({ + block: 'center', + }); + } + } + }, [scrollRef, value, overlayOpen]); + + return ( + + Timezone + } + > + }> + + inputRef.current, + allowOutsideClick: true, + clickOutsideDeactivates: true, + onDeactivate: () => setOverlayOpen(false), + escapeDeactivates: (evt) => { + evt.stopPropagation(); + return true; + }, + }} + > + +
+ + Choose a Timezone + + setOverlayOpen(false)} radii="300"> + + +
+ + } + value={query} + onChange={(evt) => setQuery(evt.currentTarget.value)} /> + + {filteredTimezones.length === 0 && ( + + + No Results + + + )} + {filteredTimezones.map((timezone) => ( + } + onClick={() => handleSelect(timezone)} + > + + + {timezone} + + + + ))} + + +
+
+
+
+ + + {value && ( + + )} + +
+ ); +} diff --git a/src/app/features/settings/developer-tools/AccountData.tsx b/src/app/features/settings/developer-tools/AccountData.tsx deleted file mode 100644 index 8bccb62e..00000000 --- a/src/app/features/settings/developer-tools/AccountData.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SequenceCardStyle } from '../styles.css'; -import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; -import { CutoutCard } from '../../../components/cutout-card'; - -type AccountDataProps = { - expand: boolean; - onExpandToggle: (expand: boolean) => void; - onSelect: (type: string | null) => void; -}; -export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) { - const mx = useMatrixClient(); - const [accountDataTypes, setAccountDataKeys] = useState(() => - Array.from(mx.store.accountData.keys()) - ); - - useAccountDataCallback( - mx, - useCallback(() => { - setAccountDataKeys(Array.from(mx.store.accountData.keys())); - }, [mx]) - ); - - return ( - - Account Data - - onExpandToggle(!expand)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {expand ? 'Collapse' : 'Expand'} - - } - /> - {expand && ( - - - Events - Total: {accountDataTypes.length} - - - } - onClick={() => onSelect(null)} - > - - - Add New - - - - {accountDataTypes.sort().map((type) => ( - } - onClick={() => onSelect(type)} - > - - - {type} - - - - ))} - - - )} - - - ); -} diff --git a/src/app/features/settings/developer-tools/AccountDataList.tsx b/src/app/features/settings/developer-tools/AccountDataList.tsx new file mode 100644 index 00000000..9c9cbe6a --- /dev/null +++ b/src/app/features/settings/developer-tools/AccountDataList.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Box, Text, Icon, Icons, MenuItem } from 'folds'; +import { CutoutCard } from '../../../components/cutout-card'; + +type AccountDataListProps = { + types: string[]; + onSelect: (type: string | null) => void; +}; +export function AccountDataList({ + types, + onSelect, +}: AccountDataListProps) { + return ( + + + Fields + Total: {types.length} + + + } + onClick={() => onSelect(null)} + > + + + Add New + + + + {types.sort().map((type) => ( + } + onClick={() => onSelect(type)} + > + + + {type} + + + + ))} + + + ); +} diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index a3f04567..01e6e6a4 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useState } from 'react'; import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; +import { AccountDataEvents } from 'matrix-js-sdk'; +import { Feature, ServerSupport } from 'matrix-js-sdk/lib/feature'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -8,117 +10,209 @@ import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { + AccountDataDeleteCallback, AccountDataEditor, AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { copyToClipboard } from '../../../utils/dom'; -import { AccountData } from './AccountData'; +import { AccountDataList } from './AccountDataList'; +import { useExtendedProfile } from '../../../hooks/useExtendedProfile'; +import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; + +type DeveloperToolsPage = + | { name: 'index' } + | { name: 'account-data'; type: string | null } + | { name: 'profile-field'; type: string | null }; type DeveloperToolsProps = { requestClose: () => void; }; export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const mx = useMatrixClient(); + const userId = mx.getUserId() as string; + + const [accountDataTypes, setAccountDataKeys] = useState(() => + Array.from(mx.store.accountData.keys()) + ); + const accountDataDeletionSupported = + (mx.canSupport.get(Feature.AccountDataDeletion) ?? ServerSupport.Unsupported) !== + ServerSupport.Unsupported; + useAccountDataCallback( + mx, + useCallback(() => { + setAccountDataKeys(Array.from(mx.store.accountData.keys())); + }, [mx]) + ); + + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); + const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); - const [expand, setExpend] = useState(false); - const [accountDataType, setAccountDataType] = useState(); + const [page, setPage] = useState({ name: 'index' }); + const [globalExpand, setGlobalExpand] = useState(false); + const [profileExpand, setProfileExpand] = useState(false); const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { - await mx.setAccountData(type, content); + await mx.setAccountData(type as keyof AccountDataEvents, content); }, [mx] ); - if (accountDataType !== undefined) { - return ( - setAccountDataType(undefined)} - /> - ); - } - - return ( - - - - - - Developer Tools - - - - - - - - - - - - - - - Options - - - } - /> - - {developerTools && ( - - - copyToClipboard(mx.getAccessToken() ?? '') - } - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - > - Copy - - } - /> - - )} - - {developerTools && ( - - )} - - - - - + const deleteAccountData: AccountDataDeleteCallback = useCallback( + async (type) => { + await mx.deleteAccountData(type as keyof AccountDataEvents); + }, + [mx] ); + + const submitProfileField: AccountDataSubmitCallback = useCallback( + async (type, content) => { + await mx.setExtendedProfileProperty(type, content); + await refreshExtendedProfile(); + }, + [mx, refreshExtendedProfile] + ); + + const deleteProfileField: AccountDataDeleteCallback = useCallback( + async (type) => { + await mx.deleteExtendedProfileProperty(type); + await refreshExtendedProfile(); + }, + [mx, refreshExtendedProfile] + ); + + const handleClose = useCallback(() => setPage({ name: 'index' }), [setPage]); + + switch (page.name) { + case 'account-data': + return ( + + ); + + case 'profile-field': + return ( + + ); + + default: + return ( + + + + + + Developer Tools + + + + + + + + + + + + + + + Options + + + } + /> + + {developerTools && ( + + + copyToClipboard(mx.getAccessToken() ?? '') + } + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + > + Copy + + } + /> + + )} + + {developerTools && ( + + Account Data + + setPage({ name: 'account-data', type })} + /> + + {extendedProfile && ( + + setPage({ name: 'profile-field', type })} + /> + + )} + + )} + + + + + + ); + } } diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 00128c8f..35baa9cb 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -11,6 +11,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys'; import { useAlive } from '../../../hooks/useAlive'; import { useFilePicker } from '../../../hooks/useFilePicker'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; function ExportKeys() { const mx = useMatrixClient(); @@ -121,37 +122,18 @@ function ExportKeys() { ); } -function ExportKeysTile() { +function ExportKeysCard() { const [expand, setExpand] = useState(false); return ( - <> - - -
- } - /> - {expand && } - + + + ); } @@ -304,14 +286,7 @@ export function LocalBackup() { return ( Local Backup - - - + ( { + const [memberships, setMemberships] = useState([]); + const room = mx.getRoom(roomId); + useEffect(() => { + if (!room) { + setMemberships([]); + return undefined; + } + + const mxr = mx.matrixRTC.getRoomSession(room); + + const updateMemberships = () => { + if (!room.isCallRoom()) return; + setMemberships(MatrixRTCSession.callMembershipsForRoom(room)); + }; + + updateMemberships(); + + mxr.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + return () => { + mxr.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + }; + }, [mx, room, roomId]); + + return memberships; +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e5fc6cc6..70ba26bb 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -9,6 +9,7 @@ export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; allowCustomHomeservers?: boolean; + elementCallUrl?: string; featuredCommunities?: { openAsDefault?: boolean; diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts new file mode 100644 index 00000000..9ef05fa3 --- /dev/null +++ b/src/app/hooks/useExtendedProfile.ts @@ -0,0 +1,112 @@ +import { useCallback } from 'react'; +import z from 'zod'; +import { useQuery } from '@tanstack/react-query'; +import { Capabilities } from 'matrix-js-sdk'; +import { useMatrixClient } from './useMatrixClient'; +import { useSpecVersions } from './useSpecVersions'; +import { IProfileFieldsCapability } from '../../types/matrix/common'; + +const extendedProfile = z.looseObject({ + displayname: z.string().optional(), + avatar_url: z.string().optional(), + 'io.fsky.nyx.pronouns': z + .object({ + language: z.string(), + summary: z.string(), + }) + .array() + .optional() + .catch(undefined), + 'us.cloke.msc4175.tz': z.string().optional().catch(undefined), +}); + +export type ExtendedProfile = z.infer; + +export function useExtendedProfileSupported(): boolean { + const { versions, unstable_features: unstableFeatures } = useSpecVersions(); + + return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15'); +} + +/// Returns the user's MSC4133 extended profile, if our homeserver supports it. +/// This will return `undefined` while the request is in flight and `null` if the HS lacks support. +export function useExtendedProfile( + userId: string +): [ExtendedProfile | undefined | null, () => Promise] { + const mx = useMatrixClient(); + const extendedProfileSupported = useExtendedProfileSupported(); + const { data, refetch } = useQuery({ + queryKey: ['extended-profile', userId], + queryFn: useCallback(async () => { + if (extendedProfileSupported) { + return extendedProfile.parse(await mx.getExtendedProfile(userId)); + } + return null; + }, [mx, userId, extendedProfileSupported]), + refetchOnMount: false, + }); + + return [ + data, + async () => { + await refetch(); + }, + ]; +} + +const LEGACY_FIELDS = ['displayname', 'avatar_url']; + +/// Returns whether the given profile field may be edited by the user. +export function profileEditsAllowed( + field: string, + capabilities: Capabilities, + extendedProfileSupported: boolean +): boolean { + if (LEGACY_FIELDS.includes(field)) { + // this field might have a pre-msc4133 capability. check that first + if (capabilities[`m.set_${field}`]?.enabled === false) { + return false; + } + + if (!extendedProfileSupported) { + // the homeserver only supports legacy fields + return true; + } + } + + if (extendedProfileSupported) { + // the homeserver has msc4133 support + const extendedProfileCapability = capabilities[ + 'uk.tcpip.msc4133.profile_fields' + ] as IProfileFieldsCapability; + + if (extendedProfileCapability === undefined) { + // the capability is missing, assume modification is allowed + return true; + } + + if (!extendedProfileCapability.enabled) { + // the capability is set to disable profile modifications + return false; + } + + if ( + extendedProfileCapability.allowed !== undefined && + !extendedProfileCapability.allowed.includes(field) + ) { + // the capability includes an allowlist and `field` isn't in it + return false; + } + + if (extendedProfileCapability.disallowed?.includes(field)) { + // the capability includes an blocklist and `field` is in it + return false; + } + + // the capability is enabled and `field` isn't blocked + return true; + } + + // `field` is an extended profile key and the homeserver lacks msc4133 support + return false; +} diff --git a/src/app/hooks/useStateEvents.ts b/src/app/hooks/useStateEvents.ts index dd085693..6d1c80bc 100644 --- a/src/app/hooks/useStateEvents.ts +++ b/src/app/hooks/useStateEvents.ts @@ -1,28 +1,35 @@ import { useCallback, useMemo } from 'react'; -import { Room } from 'matrix-js-sdk'; +import { MatrixEvent, Room } from 'matrix-js-sdk'; import { StateEvent } from '../../types/matrix/room'; +import { useMatrixClient } from './useMatrixClient'; import { useForceUpdate } from './useForceUpdate'; import { useStateEventCallback } from './useStateEventCallback'; -import { getStateEvents } from '../utils/room'; -export const useStateEvents = (room: Room, eventType: StateEvent) => { +export const useStateEvents = (rooms: Room[], eventType: StateEvent): number => { + const mx = useMatrixClient(); + const [updateCount, forceUpdate] = useForceUpdate(); - useStateEventCallback( - room.client, - useCallback( - (event) => { - if (event.getRoomId() === room.roomId && event.getType() === eventType) { - forceUpdate(); + const relevantRoomIds = useMemo(() => { + const ids = new Set(); + if (rooms && Array.isArray(rooms)) { + rooms.forEach((room) => { + if (room?.roomId) { + ids.add(room.roomId); } - }, - [room, eventType, forceUpdate] - ) - ); - - return useMemo( - () => getStateEvents(room, eventType), - // eslint-disable-next-line react-hooks/exhaustive-deps - [room, eventType, updateCount] + }); + } + return ids; + }, [rooms]); + const handleEventCallback = useCallback( + (event: MatrixEvent) => { + const eventRoomId = event.getRoomId(); + if (eventRoomId && event.getType() === eventType && relevantRoomIds.has(eventRoomId)) { + forceUpdate(); + } + }, + [eventType, relevantRoomIds, forceUpdate] ); + useStateEventCallback(mx, handleEventCallback); + return updateCount; }; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 18591f29..d5e8ee22 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 { CallProvider } from './client/call/CallProvider'; +import { PersistentCallContainer } from './client/call/PersistentCallContainer'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -123,15 +125,19 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - } - > - - + + + + + } + > + + + + + diff --git a/src/app/pages/client/call/CallProvider.tsx b/src/app/pages/client/call/CallProvider.tsx new file mode 100644 index 00000000..ffa15e94 --- /dev/null +++ b/src/app/pages/client/call/CallProvider.tsx @@ -0,0 +1,343 @@ +import React, { + createContext, + useState, + useContext, + useMemo, + useCallback, + ReactNode, + useEffect, +} from 'react'; +import { + WidgetApiToWidgetAction, + WidgetApiAction, + ClientWidgetApi, + IWidgetApiRequestData, +} from 'matrix-widget-api'; +import { useParams } from 'react-router-dom'; +import { SmallWidget } from '../../../features/call/SmallWidget'; + +interface MediaStatePayload { + data?: { + audio_enabled?: boolean; + video_enabled?: boolean; + }; +} + +const WIDGET_MEDIA_STATE_UPDATE_ACTION = 'io.element.device_mute'; +const WIDGET_HANGUP_ACTION = 'im.vector.hangup'; +const WIDGET_ON_SCREEN_ACTION = 'set_always_on_screen'; +const WIDGET_JOIN_ACTION = 'io.element.join'; +const WIDGET_TILE_UPDATE = 'io.element.tile_layout'; + +interface CallContextState { + activeCallRoomId: string | null; + setActiveCallRoomId: (roomId: string | null) => void; + viewedCallRoomId: string | null; + setViewedCallRoomId: (roomId: string | null) => void; + hangUp: () => void; + activeClientWidgetApi: ClientWidgetApi | null; + activeClientWidget: SmallWidget | null; + registerActiveClientWidgetApi: ( + roomId: string | null, + clientWidgetApi: ClientWidgetApi | null, + clientWidget: SmallWidget, + activeClientIframeRef: HTMLIFrameElement + ) => void; + sendWidgetAction: ( + action: WidgetApiToWidgetAction | string, + data: T + ) => Promise; + isAudioEnabled: boolean; + isVideoEnabled: boolean; + isChatOpen: boolean; + isActiveCallReady: boolean; + toggleAudio: () => Promise; + toggleVideo: () => Promise; + toggleChat: () => Promise; +} + +const CallContext = createContext(undefined); + +interface CallProviderProps { + children: ReactNode; +} + +const DEFAULT_AUDIO_ENABLED = true; +const DEFAULT_VIDEO_ENABLED = false; +const DEFAULT_CHAT_OPENED = false; + +export function CallProvider({ children }: CallProviderProps) { + const [activeCallRoomId, setActiveCallRoomIdState] = useState(null); + const [viewedCallRoomId, setViewedCallRoomIdState] = useState(null); + + const [activeClientWidgetApi, setActiveClientWidgetApiState] = useState( + null + ); + const [activeClientWidget, setActiveClientWidget] = useState(null); + const [activeClientWidgetApiRoomId, setActiveClientWidgetApiRoomId] = useState( + null + ); + const [activeClientWidgetIframeRef, setActiveClientWidgetIframeRef] = + useState(null); + + const [isAudioEnabled, setIsAudioEnabledState] = useState(DEFAULT_AUDIO_ENABLED); + const [isVideoEnabled, setIsVideoEnabledState] = useState(DEFAULT_VIDEO_ENABLED); + const [isChatOpen, setIsChatOpenState] = useState(DEFAULT_CHAT_OPENED); + const [isActiveCallReady, setIsActiveCallReady] = useState(false); + + const { roomIdOrAlias: viewedRoomId } = useParams<{ roomIdOrAlias: string }>(); + + const setActiveCallRoomId = useCallback((roomId: string | null) => { + setActiveCallRoomIdState(roomId); + }, []); + + const setViewedCallRoomId = useCallback( + (roomId: string | null) => { + setViewedCallRoomIdState(roomId); + }, + [setViewedCallRoomIdState] + ); + + const setActiveClientWidgetApi = useCallback( + ( + clientWidgetApi: ClientWidgetApi | null, + clientWidget: SmallWidget | null, + roomId: string | null, + clientWidgetIframeRef: HTMLIFrameElement | null + ) => { + setActiveClientWidgetApiState(clientWidgetApi); + setActiveClientWidget(clientWidget); + setActiveClientWidgetApiRoomId(roomId); + setActiveClientWidgetIframeRef(clientWidgetIframeRef); + }, + [] + ); + + const registerActiveClientWidgetApi = useCallback( + ( + roomId: string | null, + clientWidgetApi: ClientWidgetApi | null, + clientWidget: SmallWidget | null, + clientWidgetIframeRef: HTMLIFrameElement | null + ) => { + if (roomId && clientWidgetApi) { + setActiveClientWidgetApi(clientWidgetApi, clientWidget, roomId, clientWidgetIframeRef); + } else if (roomId === activeClientWidgetApiRoomId || roomId === null) { + setActiveClientWidgetApi(null, null, null, null); + } + }, + [activeClientWidgetApiRoomId, setActiveClientWidgetApi] + ); + + const hangUp = useCallback(() => { + setActiveClientWidgetApi(null, null, null, null); + setActiveCallRoomIdState(null); + activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {}); + setIsActiveCallReady(false); + }, [activeClientWidgetApi?.transport, setActiveClientWidgetApi]); + + const sendWidgetAction = useCallback( + async ( + action: WidgetApiToWidgetAction | string, + data: T + ): Promise => { + if (!activeClientWidgetApi) { + return Promise.reject(new Error('No active call clientWidgetApi')); + } + if (!activeClientWidgetApiRoomId || activeClientWidgetApiRoomId !== activeCallRoomId) { + return Promise.reject(new Error('Mismatched active call clientWidgetApi')); + } + + await activeClientWidgetApi.transport.send(action as WidgetApiAction, data); + + return Promise.resolve(); + }, + [activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId] + ); + + const toggleAudio = useCallback(async () => { + const newState = !isAudioEnabled; + setIsAudioEnabledState(newState); + + if (isActiveCallReady) { + try { + await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { + audio_enabled: newState, + video_enabled: isVideoEnabled, + }); + } catch (error) { + setIsAudioEnabledState(!newState); + throw error; + } + } + }, [isAudioEnabled, isVideoEnabled, sendWidgetAction, isActiveCallReady]); + + const toggleVideo = useCallback(async () => { + const newState = !isVideoEnabled; + setIsVideoEnabledState(newState); + + if (isActiveCallReady) { + try { + await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { + audio_enabled: isAudioEnabled, + video_enabled: newState, + }); + } catch (error) { + setIsVideoEnabledState(!newState); + throw error; + } + } + }, [isVideoEnabled, isAudioEnabled, sendWidgetAction, isActiveCallReady]); + + useEffect(() => { + if (!activeCallRoomId && !viewedCallRoomId) { + return; + } + + if (!activeClientWidgetApi) { + return; + } + + const handleHangup = (ev: CustomEvent) => { + ev.preventDefault(); + if (isActiveCallReady && ev.detail.widgetId === activeClientWidgetApi.widget.id) { + activeClientWidgetApi.transport.reply(ev.detail, {}); + } + }; + + const handleMediaStateUpdate = (ev: CustomEvent) => { + if (!isActiveCallReady) return; + ev.preventDefault(); + + /* eslint-disable camelcase */ + const { audio_enabled, video_enabled } = ev.detail.data ?? {}; + + if (typeof audio_enabled === 'boolean' && audio_enabled !== isAudioEnabled) { + setIsAudioEnabledState(audio_enabled); + } + if (typeof video_enabled === 'boolean' && video_enabled !== isVideoEnabled) { + setIsVideoEnabledState(video_enabled); + } + /* eslint-enable camelcase */ + }; + + const handleOnScreenStateUpdate = (ev: CustomEvent) => { + ev.preventDefault(); + activeClientWidgetApi.transport.reply(ev.detail, {}); + }; + + const handleOnTileLayout = (ev: CustomEvent) => { + ev.preventDefault(); + + activeClientWidgetApi.transport.reply(ev.detail, {}); + }; + + const handleJoin = (ev: CustomEvent) => { + ev.preventDefault(); + + activeClientWidgetApi.transport.reply(ev.detail, {}); + + const iframeDoc = + activeClientWidgetIframeRef?.contentWindow?.document || + activeClientWidgetIframeRef?.contentDocument; + + if (iframeDoc) { + const observer = new MutationObserver(() => { + const button = iframeDoc.querySelector('[data-testid="incall_leave"]'); + if (button) { + button.addEventListener('click', () => { + hangUp(); + }); + } + observer.disconnect(); + }); + observer.observe(iframeDoc, { childList: true, subtree: true }); + } + + setIsActiveCallReady(true); + }; + + void sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { + audio_enabled: isAudioEnabled, + video_enabled: isVideoEnabled, + }).catch(() => { + // Widget transport may reject while call/session setup is still in progress. + }); + + activeClientWidgetApi.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup); + activeClientWidgetApi.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate); + activeClientWidgetApi.on(`action:${WIDGET_TILE_UPDATE}`, handleOnTileLayout); + activeClientWidgetApi.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate); + activeClientWidgetApi.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin); + }, [ + activeClientWidgetIframeRef, + activeClientWidgetApi, + activeCallRoomId, + activeClientWidgetApiRoomId, + hangUp, + isChatOpen, + isAudioEnabled, + isVideoEnabled, + isActiveCallReady, + viewedRoomId, + viewedCallRoomId, + setViewedCallRoomId, + activeClientWidget?.iframe?.contentDocument, + activeClientWidget?.iframe?.contentWindow?.document, + sendWidgetAction, + ]); + + const toggleChat = useCallback(async () => { + const newState = !isChatOpen; + setIsChatOpenState(newState); + }, [isChatOpen]); + + const contextValue = useMemo( + () => ({ + activeCallRoomId, + setActiveCallRoomId, + viewedCallRoomId, + setViewedCallRoomId, + hangUp, + activeClientWidgetApi, + registerActiveClientWidgetApi, + activeClientWidget, + sendWidgetAction, + isChatOpen, + isAudioEnabled, + isVideoEnabled, + isActiveCallReady, + toggleAudio, + toggleVideo, + toggleChat, + }), + [ + activeCallRoomId, + setActiveCallRoomId, + viewedCallRoomId, + setViewedCallRoomId, + hangUp, + activeClientWidgetApi, + registerActiveClientWidgetApi, + activeClientWidget, + sendWidgetAction, + isChatOpen, + isAudioEnabled, + isVideoEnabled, + isActiveCallReady, + toggleAudio, + toggleVideo, + toggleChat, + ] + ); + + return {children}; +} + +export function useCallState(): CallContextState { + const context = useContext(CallContext); + if (context === undefined) { + throw new Error('useCallState must be used within a CallProvider'); + } + return context; +} diff --git a/src/app/pages/client/call/PersistentCallContainer.tsx b/src/app/pages/client/call/PersistentCallContainer.tsx new file mode 100644 index 00000000..ea435a4c --- /dev/null +++ b/src/app/pages/client/call/PersistentCallContainer.tsx @@ -0,0 +1,186 @@ +import React, { createContext, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; +import { ClientWidgetApi } from 'matrix-widget-api'; +import { Box } from 'folds'; +import { useCallState } from './CallProvider'; +import { + createVirtualWidget, + SmallWidget, + getWidgetData, + getWidgetUrl, +} from '../../../features/call/SmallWidget'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useClientConfig } from '../../../hooks/useClientConfig'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { ThemeKind, useTheme } from '../../../hooks/useTheme'; + +interface PersistentCallContainerProps { + children: ReactNode; +} + +export const CallRefContext = + createContext | null>(null); + +export function PersistentCallContainer({ children }: PersistentCallContainerProps) { + const callIframeRef = useRef(null); + const callWidgetApiRef = useRef(null); + const callSmallWidgetRef = useRef(null); + + const { + activeCallRoomId, + viewedCallRoomId, + isChatOpen, + isActiveCallReady, + registerActiveClientWidgetApi, + activeClientWidget, + } = useCallState(); + const mx = useMatrixClient(); + const clientConfig = useClientConfig(); + const screenSize = useScreenSizeContext(); + const theme = useTheme(); + const isMobile = screenSize === ScreenSize.Mobile; + + /* eslint-disable no-param-reassign */ + + const setupWidget = useCallback( + ( + widgetApiRef: React.MutableRefObject, + smallWidgetRef: React.MutableRefObject, + iframeRef: React.MutableRefObject, + skipLobby: boolean, + themeKind: ThemeKind | null + ) => { + if (mx?.getUserId()) { + if (activeCallRoomId && !isActiveCallReady) { + const roomIdToSet = activeCallRoomId; + + const widgetId = `element-call-${roomIdToSet}-${Date.now()}`; + const newUrl = getWidgetUrl( + mx, + roomIdToSet, + clientConfig.elementCallUrl ?? '', + widgetId, + { + skipLobby: skipLobby.toString(), + returnToLobby: 'true', + perParticipantE2EE: 'true', + theme: themeKind, + } + ); + + if ( + callSmallWidgetRef.current?.roomId && + activeClientWidget?.roomId && + activeClientWidget.roomId === callSmallWidgetRef.current?.roomId + ) { + return; + } + + if ( + iframeRef.current && + (!iframeRef.current.src || iframeRef.current.src !== newUrl.toString()) + ) { + iframeRef.current.src = newUrl.toString(); + } + + const iframeElement = iframeRef.current; + if (!iframeElement) { + return; + } + + const userId = mx.getUserId() ?? ''; + const app = createVirtualWidget( + mx, + widgetId, + userId, + 'Element Call', + 'm.call', + newUrl, + true, + getWidgetData(mx, roomIdToSet, {}, { skipLobby: true }), + roomIdToSet + ); + + const smallWidget = new SmallWidget(app); + smallWidgetRef.current = smallWidget; + + const widgetApiInstance = smallWidget.startMessaging(iframeElement); + widgetApiRef.current = widgetApiInstance; + registerActiveClientWidgetApi( + roomIdToSet, + widgetApiRef.current, + smallWidget, + iframeElement + ); + } + } + }, + [ + mx, + activeCallRoomId, + isActiveCallReady, + clientConfig.elementCallUrl, + activeClientWidget, + registerActiveClientWidgetApi, + ] + ); + + useEffect(() => { + if (activeCallRoomId) { + setupWidget(callWidgetApiRef, callSmallWidgetRef, callIframeRef, true, theme.kind); + } + }, [ + theme, + setupWidget, + callWidgetApiRef, + callSmallWidgetRef, + callIframeRef, + registerActiveClientWidgetApi, + activeCallRoomId, + viewedCallRoomId, + isActiveCallReady, + ]); + + const memoizedIframeRef = useMemo(() => callIframeRef, [callIframeRef]); + + return ( + + + + +