/* Copyright 2024 New Vector Ltd. Copyright 2019-2021 The Matrix.org Foundation C.I.C. Copyright 2015, 2016 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React, { FC, useState, useMemo, useCallback } from "react"; import classNames from "classnames"; import { throttle } from "lodash"; import { RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { IconButton, Tooltip } from "@vector-im/compound-web"; import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import type { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { UserTab } from "../dialogs/UserTab"; import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from "../right_panel/LegacyRoomHeaderButtons"; import E2EIcon from "./E2EIcon"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; import { E2EStatus } from "../../../utils/ShieldUtils"; import { IOOBData } from "../../../stores/ThreepidInviteStore"; import { RoomKnocksBar } from "./RoomKnocksBar"; import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; import RoomContextMenu from "../context_menus/RoomContextMenu"; import { contextMenuBelow } from "./RoomTile"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import RoomContext from "../../../contexts/RoomContext"; import RoomLiveShareWarning from "../beacon/RoomLiveShareWarning"; import { BetaPill } from "../beta/BetaCard"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler"; import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings"; import SdkConfig from "../../../SdkConfig"; import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import { useWidgets } from "../../../utils/WidgetUtils"; import { WidgetType } from "../../../widgets/WidgetType"; import { useCall, useLayout } from "../../../hooks/useCall"; import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; import { Call, ElementCall, Layout } from "../../../models/Call"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList, IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { SessionDuration } from "../voip/CallDuration"; import RoomCallBanner from "../beacon/RoomCallBanner"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; import { SearchInfo } from "../../../Searching"; class DisabledWithReason { public constructor(public readonly reason: string) {} } interface VoiceCallButtonProps { room: Room; busy: boolean; setBusy: (value: boolean) => void; behavior: DisabledWithReason | "legacy_or_jitsi"; } /** * Button for starting voice calls, supporting only legacy 1:1 calls and Jitsi * widgets. */ const VoiceCallButton: FC = ({ room, busy, setBusy, behavior }) => { const { onClick, tooltip, disabled } = useMemo(() => { if (behavior instanceof DisabledWithReason) { return { onClick: () => {}, tooltip: behavior.reason, disabled: true, }; } else { // behavior === "legacy_or_jitsi" return { onClick: async (ev: ButtonEvent): Promise => { ev.preventDefault(); setBusy(true); await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice); setBusy(false); }, disabled: false, }; } }, [behavior, room, setBusy]); return ( ); }; interface VideoCallButtonProps { room: Room; busy: boolean; setBusy: (value: boolean) => void; behavior: DisabledWithReason | "legacy_or_jitsi" | "element" | "jitsi_or_element" | "legacy_or_element"; } /** * Button for starting video calls, supporting both legacy 1:1 calls, Jitsi * widgets, and native group calls. If multiple calling options are available, * this shows a menu to pick between them. */ const VideoCallButton: FC = ({ room, busy, setBusy, behavior }) => { const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); const startLegacyCall = useCallback(async (): Promise => { setBusy(true); await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video); setBusy(false); }, [setBusy, room]); const startElementCall = useCallback( (skipLobby: boolean) => { setBusy(true); defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, view_call: true, skipLobby: skipLobby, metricsTrigger: undefined, }); setBusy(false); }, [setBusy, room], ); const { onClick, tooltip, disabled } = useMemo(() => { if (behavior instanceof DisabledWithReason) { return { onClick: () => {}, tooltip: behavior.reason, disabled: true, }; } else if (behavior === "legacy_or_jitsi") { return { onClick: async (ev: ButtonEvent): Promise => { ev.preventDefault(); await startLegacyCall(); }, disabled: false, }; } else if (behavior === "element") { return { onClick: async (ev: ButtonEvent): Promise => { ev.preventDefault(); startElementCall("shiftKey" in ev ? ev.shiftKey : false); }, disabled: false, }; } else { // behavior === "jitsi_or_element" | "legacy_or_element" return { onClick: async (ev: ButtonEvent): Promise => { ev.preventDefault(); openMenu(); }, disabled: false, }; } }, [behavior, startLegacyCall, startElementCall, openMenu]); const onJitsiClick = useCallback( async (ev: ButtonEvent): Promise => { ev.preventDefault(); closeMenu(); await startLegacyCall(); }, [closeMenu, startLegacyCall], ); const onElementClick = useCallback( (ev: ButtonEvent) => { ev.preventDefault(); closeMenu(); startElementCall("shiftKey" in ev ? ev.shiftKey : false); }, [closeMenu, startElementCall], ); let menu: JSX.Element | null = null; if (menuOpen) { const buttonRect = buttonRef.current!.getBoundingClientRect(); const brand = SdkConfig.get("element_call").brand; menu = ( ); } return ( <> {menu} ); }; interface CallButtonsProps { room: Room; } // The header buttons for placing calls have become stupidly complex, so here // they are as a separate component const CallButtons: FC = ({ room }) => { const [busy, setBusy] = useState(false); const showButtons = useSettingValue("showCallButtonsInComposer"); const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); const isVideoRoom = useMemo(() => calcIsVideoRoom(room), [room]); const useElementCallExclusively = useMemo(() => { return SdkConfig.get("element_call").use_exclusively; }, []); const hasLegacyCall = useEventEmitterState( LegacyCallHandler.instance, LegacyCallHandlerEvent.CallsChanged, useCallback(() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, [room]), ); const widgets = useWidgets(room); const hasJitsiWidget = useMemo(() => widgets.some((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]); const hasGroupCall = useCall(room.roomId) !== null; const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState( room, RoomStateEvent.Update, useCallback( () => [ getJoinedNonFunctionalMembers(room), room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), ], [room], ), ); const makeVoiceCallButton = (behavior: VoiceCallButtonProps["behavior"]): JSX.Element => ( ); const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => ( ); if (isVideoRoom || !showButtons) { return null; } else if (groupCallsEnabled && useElementCallExclusively) { if (hasGroupCall) { return makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call"))); } else if (mayCreateElementCalls) { return makeVideoCallButton("element"); } else { return makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_no_perms_start_video_call"))); } } else if (hasLegacyCall || hasJitsiWidget) { return ( <> {makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")))} {makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")))} ); } else if (functionalMembers.length <= 1) { return ( <> {makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_no_one_here")))} {makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_no_one_here")))} ); } else if (functionalMembers.length === 2) { return ( <> {makeVoiceCallButton("legacy_or_jitsi")} {makeVideoCallButton(groupCallsEnabled ? "legacy_or_element" : "legacy_or_jitsi")} ); } else if (mayEditWidgets) { return ( <> {makeVoiceCallButton("legacy_or_jitsi")} {makeVideoCallButton( groupCallsEnabled && mayCreateElementCalls ? "jitsi_or_element" : "legacy_or_jitsi", )} ); } else { const videoCallBehavior = groupCallsEnabled && mayCreateElementCalls ? "element" : new DisabledWithReason(_t("voip|disabled_no_perms_start_video_call")); return ( <> {makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_no_perms_start_voice_call")))} {makeVideoCallButton(videoCallBehavior)} ); } }; interface CallLayoutSelectorProps { call: ElementCall; } const CallLayoutSelector: FC = ({ call }) => { const layout = useLayout(call); const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); const onClick = useCallback( (ev: ButtonEvent) => { ev.preventDefault(); openMenu(); }, [openMenu], ); const onFreedomClick = useCallback( (ev: ButtonEvent) => { ev.preventDefault(); closeMenu(); call.setLayout(Layout.Tile); }, [closeMenu, call], ); const onSpotlightClick = useCallback( (ev: ButtonEvent) => { ev.preventDefault(); closeMenu(); call.setLayout(Layout.Spotlight); }, [closeMenu, call], ); let menu: JSX.Element | null = null; if (menuOpen) { const buttonRect = buttonRef.current!.getBoundingClientRect(); menu = ( ); } return ( <> {menu} ); }; export interface IProps { room: Room; oobData?: IOOBData; inRoom: boolean; onSearchClick: (() => void) | null; onInviteClick: (() => void) | null; onForgetClick: (() => void) | null; onAppsClick: (() => void) | null; e2eStatus: E2EStatus; appsShown: boolean; searchInfo?: SearchInfo; excludedRightPanelPhaseButtons?: Array; showButtons?: boolean; enableRoomOptionsMenu?: boolean; viewingCall: boolean; activeCall: Call | null; additionalButtons?: ViewRoomOpts["buttons"]; } interface IState { contextMenuPosition?: DOMRect; rightPanelOpen: boolean; featureAskToJoin: boolean; } /** * @deprecated use `src/components/views/rooms/RoomHeader.tsx` instead */ export default class RoomHeader extends React.Component { public static defaultProps: Partial = { inRoom: false, excludedRightPanelPhaseButtons: [], showButtons: true, enableRoomOptionsMenu: true, }; public static contextType = RoomContext; public declare context: React.ContextType; private readonly client = this.props.room.client; private readonly featureAskToJoinWatcher: string; public constructor(props: IProps, context: React.ContextType) { super(props, context); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); this.state = { rightPanelOpen: RightPanelStore.instance.isOpen, featureAskToJoin: SettingsStore.getValue("feature_ask_to_join"), }; this.featureAskToJoinWatcher = SettingsStore.watchSetting( "feature_ask_to_join", null, (_settingName, _roomId, _atLevel, _newValAtLevel, featureAskToJoin) => { this.setState({ featureAskToJoin }); }, ); } public componentDidMount(): void { this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); } public componentWillUnmount(): void { this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room); notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate); RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); SettingsStore.unwatchSetting(this.featureAskToJoinWatcher); } private onRightPanelStoreUpdate = (): void => { this.setState({ rightPanelOpen: RightPanelStore.instance.isOpen }); }; private onRoomStateEvents = (event: MatrixEvent): void => { if (!this.props.room || event.getRoomId() !== this.props.room.roomId) { return; } // redisplay the room name, topic, etc. this.rateLimitedUpdate(); }; private onNotificationUpdate = (): void => { this.forceUpdate(); }; private rateLimitedUpdate = throttle( () => { this.forceUpdate(); }, 500, { leading: true, trailing: true }, ); private onContextMenuOpenClick = (ev: ButtonEvent): void => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; this.setState({ contextMenuPosition: target.getBoundingClientRect() }); }; private onContextMenuCloseClick = (): void => { this.setState({ contextMenuPosition: undefined }); }; private onHideCallClick = (ev: ButtonEvent): void => { ev.preventDefault(); defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: this.props.room.roomId, view_call: false, metricsTrigger: undefined, }); }; private renderButtons(isVideoRoom: boolean): React.ReactNode { const startButtons: JSX.Element[] = []; if (!this.props.viewingCall && this.props.inRoom && !this.context.tombstone) { startButtons.push(); } if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) { startButtons.push(); } if (!this.props.viewingCall && this.props.onForgetClick) { startButtons.push( , ); } if (!this.props.viewingCall && this.props.onAppsClick) { startButtons.push( , ); } if (!this.props.viewingCall && this.props.onSearchClick && this.props.inRoom) { startButtons.push( , ); } if (this.props.onInviteClick && (!this.props.viewingCall || isVideoRoom) && this.props.inRoom) { startButtons.push( , ); } const endButtons: JSX.Element[] = []; if (this.props.viewingCall && !isVideoRoom) { if (this.props.activeCall === null) { endButtons.push( , ); } else { endButtons.push( , ); } } return ( <> {this.props.additionalButtons?.map((props) => { const label = props.label(); return ( { props.onClick(); this.forceUpdate(); }} title={label} > {typeof props.icon === "function" ? props.icon() : props.icon} ); })} {startButtons} {endButtons} ); } private renderName(oobName: string): JSX.Element { let contextMenu: JSX.Element | null = null; if (this.state.contextMenuPosition && this.props.room) { contextMenu = ( ); } // XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'... let settingsHint = false; const members = this.props.room ? this.props.room.getJoinedMembers() : undefined; if (members) { if (members.length === 1 && members[0].userId === this.client.credentials.userId) { const nameEvent = this.props.room.currentState.getStateEvents("m.room.name", ""); if (!nameEvent || !nameEvent.getContent().name) { settingsHint = true; } } } const textClasses = classNames("mx_LegacyRoomHeader_nametext", { mx_LegacyRoomHeader_settingsHint: settingsHint, }); const roomName = ( {(name) => { const roomName = name || oobName; return (
{roomName}
); }}
); if (this.props.enableRoomOptionsMenu && shouldShowComponent(UIComponent.RoomOptionsMenu)) { return ( {roomName} {this.props.room &&
} {contextMenu} ); } return
{roomName}
; } public render(): React.ReactNode { const isVideoRoom = calcIsVideoRoom(this.props.room); let roomAvatar: JSX.Element | null = null; if (this.props.room) { roomAvatar = ( ); } const icon = this.props.viewingCall ? (
) : this.props.e2eStatus ? ( ) : // If we're expecting an E2EE status to come in, but it hasn't // yet been loaded, insert a blank div to reserve space this.client.isRoomEncrypted(this.props.room.roomId) && this.client.isCryptoEnabled() ? (
) : null; const buttons = this.props.showButtons ? this.renderButtons(isVideoRoom) : null; let oobName = _t("common|unnamed_room"); if (this.props.oobData && this.props.oobData.name) { oobName = this.props.oobData.name; } const name = this.renderName(oobName); if (this.props.viewingCall && !isVideoRoom) { return (
{roomAvatar}
{icon} {name} {this.props.activeCall instanceof ElementCall && ( )} {/* Empty topic element to fill out space */}
{buttons}
); } let searchStatus: JSX.Element | null = null; // don't display the search count until the search completes and // gives us a valid (possibly zero) searchCount. if (typeof this.props.searchInfo?.count === "number") { searchStatus = (
  {_t("room|search|result_count", { count: this.props.searchInfo.count })}
); } const topicElement = ; const viewLabs = (): void => defaultDispatcher.dispatch({ action: Action.ViewUserSettings, initialTabId: UserTab.Labs, }); const betaPill = isVideoRoom ? ( ) : null; return (
{roomAvatar}
{icon} {name} {searchStatus} {topicElement} {betaPill} {buttons}
{!isVideoRoom && } {this.state.featureAskToJoin && }
); } }