Add Element call related functionality to new room header (#12091)

* New room header
 - add chat button during call
 - close lobby button in lobby
 - join button if session exists
 - allow to toggle call <-> timeline during call with call button

Compound style for join button in call notify toast.

Signed-off-by: Timo K <toger5@hotmail.de>

* dont show start call, join button in video rooms.

Signed-off-by: Timo K <toger5@hotmail.de>

* Make active call check based on participant count
Not based on available call object

Signed-off-by: Timo K <toger5@hotmail.de>

* fix room header tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix room header tests

Signed-off-by: Timo K <toger5@hotmail.de>

* remove chat button test for displaying.
Chat button display logic is now part of the RoomHeader.

Signed-off-by: Timo K <toger5@hotmail.de>

* remove duplicate notification Tread icon

Signed-off-by: Timo K <toger5@hotmail.de>

* remove obsolete jest snapshot

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/components/views/rooms/RoomHeader.tsx

Co-authored-by: Robin <robin@robin.town>

* update isECWidget logic

Signed-off-by: Timo K <toger5@hotmail.de>

* remove dead code

Signed-off-by: Timo K <toger5@hotmail.de>

* refactor call options
Add menu to choose if there are multiple options

Signed-off-by: Timo K <toger5@hotmail.de>

* join ec when clicking join button (dont start jitsi)
Use icon buttons
don't show call icon when join button is visible

Signed-off-by: Timo K <toger5@hotmail.de>

* refactor isViewingCall

Signed-off-by: Timo K <toger5@hotmail.de>

* fix room header tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix header snapshot

Signed-off-by: Timo K <toger5@hotmail.de>

* sonar proposals

Signed-off-by: Timo K <toger5@hotmail.de>

* fix event shiftKey may be undefined

Signed-off-by: Timo K <toger5@hotmail.de>

* more lobby time before timeout
only await sticky promise on becoming sticky.

Signed-off-by: Timo K <toger5@hotmail.de>

* don't allow starting new calls if there is an ongoing other call.

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* fix translation typo

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
pull/28788/head^2
Timo 2024-01-31 16:18:52 +01:00 committed by GitHub
parent 31449d6f80
commit 73b16239a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 286 additions and 164 deletions

View File

@ -821,7 +821,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private onActiveCalls = (): void => { private onActiveCalls = (): void => {
if (this.state.roomId === undefined) return; if (this.state.roomId === undefined) return;
const activeCall = CallStore.instance.getActiveCall(this.state.roomId); const activeCall = CallStore.instance.getActiveCall(this.state.roomId);
if (activeCall === null) { if (activeCall === null) {
// We disconnected from the call, so stop viewing it // We disconnected from the call, so stop viewing it
dis.dispatch<ViewRoomPayload>( dis.dispatch<ViewRoomPayload>(

View File

@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Body as BodyText, IconButton, Tooltip } from "@vector-im/compound-web"; import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg"; import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg";
import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg"; import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg";
import { Icon as CloseCallIcon } from "@vector-im/compound-design-tokens/icons/close.svg";
import { Icon as ThreadsIcon } from "@vector-im/compound-design-tokens/icons/threads-solid.svg"; import { Icon as ThreadsIcon } from "@vector-im/compound-design-tokens/icons/threads-solid.svg";
import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications-solid.svg"; import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications-solid.svg";
import { Icon as VerifiedIcon } from "@vector-im/compound-design-tokens/icons/verified.svg"; import { Icon as VerifiedIcon } from "@vector-im/compound-design-tokens/icons/verified.svg";
@ -35,7 +36,7 @@ import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMember
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { Flex } from "../../utils/Flex"; import { Flex } from "../../utils/Flex";
import { Box } from "../../utils/Box"; import { Box } from "../../utils/Box";
import { useRoomCall } from "../../../hooks/room/useRoomCall"; import { getPlatformCallTypeLabel, useRoomCall } from "../../../hooks/room/useRoomCall";
import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications"; import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications";
import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState"; import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
@ -51,6 +52,7 @@ import { Linkify, topicToHtml } from "../../../HtmlUtils";
import PosthogTrackers from "../../../PosthogTrackers"; import PosthogTrackers from "../../../PosthogTrackers";
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton"; import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
import { RoomKnocksBar } from "./RoomKnocksBar"; import { RoomKnocksBar } from "./RoomKnocksBar";
import { isVideoRoom } from "../../../utils/video-rooms";
import { notificationLevelToIndicator } from "../../../utils/notifications"; import { notificationLevelToIndicator } from "../../../utils/notifications";
export default function RoomHeader({ export default function RoomHeader({
@ -69,7 +71,17 @@ export default function RoomHeader({
const members = useRoomMembers(room, 2500); const members = useRoomMembers(room, 2500);
const memberCount = useRoomMemberCount(room, { throttleWait: 2500 }); const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
const { voiceCallDisabledReason, voiceCallClick, videoCallDisabledReason, videoCallClick } = useRoomCall(room); const {
voiceCallDisabledReason,
voiceCallClick,
videoCallDisabledReason,
videoCallClick,
toggleCallMaximized: toggleCall,
isViewingCall,
isConnectedToCall,
hasActiveCallSession,
callOptions,
} = useRoomCall(room);
const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
/** /**
@ -104,6 +116,97 @@ export default function RoomHeader({
const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join"); const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join");
const videoClick = useCallback((ev) => videoCallClick(ev, callOptions[0]), [callOptions, videoCallClick]);
const toggleCallButton = (
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
<IconButton onClick={toggleCall}>
<VideoCallIcon />
</IconButton>
</Tooltip>
);
const joinCallButton = (
<Button
size="sm"
onClick={videoClick}
Icon={VideoCallIcon}
className="mx_RoomHeader_join_button"
color="primary"
>
{_t("action|join")}
</Button>
);
const [menuOpen, setMenuOpen] = useState(false);
const callIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<VideoCallIcon />
</Tooltip>
);
const startVideoCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={_t("voip|video_call_using")}
trigger={
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
>
{callIconWithTooltip}
</IconButton>
}
side="left"
align="start"
>
{callOptions.map((option) => (
<MenuItem
key={option}
label={getPlatformCallTypeLabel(option)}
onClick={(ev) => videoCallClick(ev, option)}
Icon={VideoCallIcon}
onSelect={() => {} /* Dummy handler since we want the click event.*/}
/>
))}
</Menu>
) : (
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
onClick={videoClick}
>
{callIconWithTooltip}
</IconButton>
)}
</>
);
const voiceCallButton = (
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
<IconButton
disabled={!!voiceCallDisabledReason}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
>
<VoiceCallIcon />
</IconButton>
</Tooltip>
);
const closeLobbyButton = (
<Tooltip label={_t("voip|close_lobby")}>
<IconButton onClick={toggleCall}>
<CloseCallIcon />
</IconButton>
</Tooltip>
);
let videoCallButton = startVideoCallButton;
if (isConnectedToCall) {
videoCallButton = toggleCallButton;
} else if (isViewingCall) {
videoCallButton = closeLobbyButton;
}
return ( return (
<> <>
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel"> <Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
@ -190,29 +293,17 @@ export default function RoomHeader({
</Tooltip> </Tooltip>
); );
})} })}
<Tooltip label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}>
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}
onClick={videoCallClick}
>
<VideoCallIcon />
</IconButton>
</Tooltip>
{!useElementCallExclusively && (
<Tooltip label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}>
<IconButton
disabled={!!voiceCallDisabledReason}
aria-label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}
onClick={voiceCallClick}
>
<VoiceCallIcon />
</IconButton>
</Tooltip>
)}
{/* Renders nothing when room is not a video room */} {((isConnectedToCall && isViewingCall) || isVideoRoom(room)) && <VideoRoomChatButton room={room} />}
<VideoRoomChatButton room={room} />
{hasActiveCallSession && !isConnectedToCall ? (
joinCallButton
) : (
<>
{!isVideoRoom(room) && videoCallButton}
{!useElementCallExclusively && !isVideoRoom(room) && voiceCallButton}
</>
)}
<Tooltip label={_t("common|threads")}> <Tooltip label={_t("common|threads")}>
<IconButton <IconButton

View File

@ -19,7 +19,6 @@ import { Icon as ChatIcon } from "@vector-im/compound-design-tokens/icons/chat-s
import { Room } from "matrix-js-sdk/src/matrix"; import { Room } from "matrix-js-sdk/src/matrix";
import { IconButton, Tooltip } from "@vector-im/compound-web"; import { IconButton, Tooltip } from "@vector-im/compound-web";
import { isVideoRoom as calcIsVideoRoom } from "../../../../utils/video-rooms";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import { useEventEmitterState } from "../../../../hooks/useEventEmitter"; import { useEventEmitterState } from "../../../../hooks/useEventEmitter";
import { NotificationStateEvents } from "../../../../stores/notifications/NotificationState"; import { NotificationStateEvents } from "../../../../stores/notifications/NotificationState";
@ -31,25 +30,18 @@ import { ButtonEvent } from "../../elements/AccessibleButton";
/** /**
* Display a button to toggle timeline for video rooms * Display a button to toggle timeline for video rooms
* @param room * @param room
* @returns for a video room: a button to toggle timeline in the right panel * @returns A button to toggle timeline in the right panel.
* otherwise null
*/ */
export const VideoRoomChatButton: React.FC<{ room: Room }> = ({ room }) => { export const VideoRoomChatButton: React.FC<{ room: Room }> = ({ room }) => {
const sdkContext = useContext(SDKContext); const sdkContext = useContext(SDKContext);
const isVideoRoom = calcIsVideoRoom(room); const notificationState = sdkContext.roomNotificationStateStore.getRoomState(room);
const notificationState = isVideoRoom ? sdkContext.roomNotificationStateStore.getRoomState(room) : undefined;
const notificationColor = useEventEmitterState( const notificationColor = useEventEmitterState(
notificationState, notificationState,
NotificationStateEvents.Update, NotificationStateEvents.Update,
() => notificationState?.level, () => notificationState?.level,
); );
if (!isVideoRoom) {
return null;
}
const displayUnreadIndicator = const displayUnreadIndicator =
!!notificationColor && !!notificationColor &&
[NotificationLevel.Activity, NotificationLevel.Notification, NotificationLevel.Highlight].includes( [NotificationLevel.Activity, NotificationLevel.Notification, NotificationLevel.Highlight].includes(

View File

@ -24,18 +24,37 @@ import { useEventEmitter, useEventEmitterState } from "../useEventEmitter";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard"; import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard";
import { WidgetType } from "../../widgets/WidgetType"; import { WidgetType } from "../../widgets/WidgetType";
import { useCall } from "../useCall"; import { useCall, useConnectionState, useParticipantCount } from "../useCall";
import { useRoomMemberCount } from "../useRoomMembers"; import { useRoomMemberCount } from "../useRoomMembers";
import { ElementCall } from "../../models/Call"; import { Call, ConnectionState, ElementCall } from "../../models/Call";
import { placeCall } from "../../utils/room/placeCall"; import { placeCall } from "../../utils/room/placeCall";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { useRoomState } from "../useRoomState"; import { useRoomState } from "../useRoomState";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import { isManagedHybridWidget } from "../../widgets/ManagedHybrid"; import { isManagedHybridWidget } from "../../widgets/ManagedHybrid";
import { IApp } from "../../stores/WidgetStore"; import { IApp } from "../../stores/WidgetStore";
import { SdkContextClass } from "../../contexts/SDKContext";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../dispatcher/actions";
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
export type PlatformCallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi"; export enum PlatformCallType {
ElementCall,
JitsiCall,
LegacyCall,
}
export const getPlatformCallTypeLabel = (platformCallType: PlatformCallType): string => {
switch (platformCallType) {
case PlatformCallType.ElementCall:
return _t("voip|element_call");
case PlatformCallType.JitsiCall:
return _t("voip|jitsi_call");
case PlatformCallType.LegacyCall:
return _t("voip|legacy_call");
}
};
const enum State { const enum State {
NoCall, NoCall,
NoOneHere, NoOneHere,
@ -53,9 +72,14 @@ export const useRoomCall = (
room: Room, room: Room,
): { ): {
voiceCallDisabledReason: string | null; voiceCallDisabledReason: string | null;
voiceCallClick(evt: React.MouseEvent): void; voiceCallClick(evt: React.MouseEvent | undefined, selectedType: PlatformCallType): void;
videoCallDisabledReason: string | null; videoCallDisabledReason: string | null;
videoCallClick(evt: React.MouseEvent): void; videoCallClick(evt: React.MouseEvent | undefined, selectedType: PlatformCallType): void;
toggleCallMaximized: () => void;
isViewingCall: boolean;
isConnectedToCall: boolean;
hasActiveCallSession: boolean;
callOptions: PlatformCallType[];
} => { } => {
const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
const useElementCallExclusively = useMemo(() => { const useElementCallExclusively = useMemo(() => {
@ -75,69 +99,83 @@ export const useRoomCall = (
const hasManagedHybridWidget = !!managedHybridWidget; const hasManagedHybridWidget = !!managedHybridWidget;
const groupCall = useCall(room.roomId); const groupCall = useCall(room.roomId);
const isConnectedToCall = useConnectionState(groupCall) === ConnectionState.Connected;
const hasGroupCall = groupCall !== null; const hasGroupCall = groupCall !== null;
const hasActiveCallSession = useParticipantCount(groupCall) > 0;
const isViewingCall = useEventEmitterState(SdkContextClass.instance.roomViewStore, UPDATE_EVENT, () =>
SdkContextClass.instance.roomViewStore.isViewingCall(),
);
const memberCount = useRoomMemberCount(room); const memberCount = useRoomMemberCount(room);
const [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [ const [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [
room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), room.currentState.mayClientSendStateEvent(ElementCall.MEMBER_EVENT_TYPE.name, room.client),
]); ]);
const callType = useMemo((): PlatformCallType => { // The options provided to the RoomHeader.
// If there are multiple options, the user will be prompted to choose.
const callOptions = useMemo((): PlatformCallType[] => {
const options = [];
if (memberCount <= 2) {
options.push(PlatformCallType.LegacyCall);
} else if (mayEditWidgets || hasJitsiWidget) {
options.push(PlatformCallType.JitsiCall);
}
if (groupCallsEnabled) { if (groupCallsEnabled) {
if (hasGroupCall) { if (hasGroupCall || mayCreateElementCalls) {
return "jitsi_or_element_call"; options.push(PlatformCallType.ElementCall);
} }
if (mayCreateElementCalls && hasJitsiWidget) { if (useElementCallExclusively && !hasJitsiWidget) {
return "jitsi_or_element_call"; return [PlatformCallType.ElementCall];
} }
if (useElementCallExclusively) { if (hasGroupCall && WidgetType.CALL.matches(groupCall.widget.type)) {
return "element_call"; // only allow joining joining the ongoing Element call if there is one.
} return [PlatformCallType.ElementCall];
if (memberCount <= 2) {
return "legacy_or_jitsi";
}
if (mayCreateElementCalls) {
return "element_call";
} }
} }
return "legacy_or_jitsi"; return options;
}, [ }, [
memberCount,
mayEditWidgets,
hasJitsiWidget,
groupCallsEnabled, groupCallsEnabled,
hasGroupCall, hasGroupCall,
mayCreateElementCalls, mayCreateElementCalls,
hasJitsiWidget,
useElementCallExclusively, useElementCallExclusively,
memberCount, groupCall?.widget.type,
]); ]);
let widget: IApp | undefined; let widget: IApp | undefined;
if (callType === "legacy_or_jitsi") { if (callOptions.includes(PlatformCallType.JitsiCall) || callOptions.includes(PlatformCallType.LegacyCall)) {
widget = jitsiWidget ?? managedHybridWidget; widget = jitsiWidget ?? managedHybridWidget;
} else if (callType === "element_call") { }
if (callOptions.includes(PlatformCallType.ElementCall)) {
widget = groupCall?.widget; widget = groupCall?.widget;
} else { } else {
widget = groupCall?.widget ?? jitsiWidget; widget = groupCall?.widget ?? jitsiWidget;
} }
const updateWidgetState = useCallback((): void => {
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top));
setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Top));
}, [room, widget]);
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateWidgetState);
useEffect(() => {
updateWidgetState();
}, [room, jitsiWidget, groupCall, updateWidgetState]);
const [activeCalls, setActiveCalls] = useState<Call[]>(Array.from(CallStore.instance.activeCalls));
useEventEmitter(CallStore.instance, CallStoreEvent.ActiveCalls, () => {
setActiveCalls(Array.from(CallStore.instance.activeCalls));
});
const [canPinWidget, setCanPinWidget] = useState(false); const [canPinWidget, setCanPinWidget] = useState(false);
const [widgetPinned, setWidgetPinned] = useState(false); const [widgetPinned, setWidgetPinned] = useState(false);
// We only want to prompt to pin the widget if it's not element call based. // We only want to prompt to pin the widget if it's not element call based.
const isECWidget = WidgetType.CALL.matches(widget?.type ?? ""); const isECWidget = WidgetType.CALL.matches(widget?.type ?? "");
const promptPinWidget = !isECWidget && canPinWidget && !widgetPinned; const promptPinWidget = !isECWidget && canPinWidget && !widgetPinned;
const updateWidgetState = useCallback((): void => {
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top));
setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Top));
}, [room, widget]);
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateWidgetState);
useEffect(() => {
updateWidgetState();
}, [room, jitsiWidget, groupCall, updateWidgetState]);
const state = useMemo((): State => { const state = useMemo((): State => {
if (activeCalls.find((call) => call.roomId != room.roomId)) {
return State.Ongoing;
}
if (hasGroupCall || hasJitsiWidget || hasManagedHybridWidget) { if (hasGroupCall || hasJitsiWidget || hasManagedHybridWidget) {
return promptPinWidget ? State.Unpinned : State.Ongoing; return promptPinWidget ? State.Unpinned : State.Ongoing;
} }
@ -152,9 +190,9 @@ export const useRoomCall = (
if (!mayCreateElementCalls && !mayEditWidgets) { if (!mayCreateElementCalls && !mayEditWidgets) {
return State.NoPermission; return State.NoPermission;
} }
return State.NoCall; return State.NoCall;
}, [ }, [
activeCalls,
hasGroupCall, hasGroupCall,
hasJitsiWidget, hasJitsiWidget,
hasLegacyCall, hasLegacyCall,
@ -163,29 +201,30 @@ export const useRoomCall = (
mayEditWidgets, mayEditWidgets,
memberCount, memberCount,
promptPinWidget, promptPinWidget,
room.roomId,
]); ]);
const voiceCallClick = useCallback( const voiceCallClick = useCallback(
(evt: React.MouseEvent): void => { (evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
evt.stopPropagation(); evt?.stopPropagation();
if (widget && promptPinWidget) { if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
} else { } else {
placeCall(room, CallType.Voice, callType, evt.shiftKey); placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey ?? false);
} }
}, },
[promptPinWidget, room, widget, callType], [promptPinWidget, room, widget],
); );
const videoCallClick = useCallback( const videoCallClick = useCallback(
(evt: React.MouseEvent): void => { (evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
evt.stopPropagation(); evt?.stopPropagation();
if (widget && promptPinWidget) { if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
} else { } else {
placeCall(room, CallType.Video, callType, evt.shiftKey); placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey ?? false);
} }
}, },
[widget, promptPinWidget, room, callType], [widget, promptPinWidget, room],
); );
let voiceCallDisabledReason: string | null; let voiceCallDisabledReason: string | null;
@ -208,6 +247,14 @@ export const useRoomCall = (
voiceCallDisabledReason = null; voiceCallDisabledReason = null;
videoCallDisabledReason = null; videoCallDisabledReason = null;
} }
const toggleCallMaximized = useCallback(() => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
view_call: !isViewingCall,
});
}, [isViewingCall, room.roomId]);
/** /**
* We've gone through all the steps * We've gone through all the steps
@ -217,5 +264,10 @@ export const useRoomCall = (
voiceCallClick, voiceCallClick,
videoCallDisabledReason, videoCallDisabledReason,
videoCallClick, videoCallClick,
toggleCallMaximized: toggleCallMaximized,
isViewingCall: isViewingCall,
isConnectedToCall: isConnectedToCall,
hasActiveCallSession: hasActiveCallSession,
callOptions,
}; };
}; };

View File

@ -36,21 +36,22 @@ export const useCallForWidget = (widgetId: string, roomId: string): Call | null
return call?.widget.id === widgetId ? call : null; return call?.widget.id === widgetId ? call : null;
}; };
export const useConnectionState = (call: Call): ConnectionState => export const useConnectionState = (call: Call | null): ConnectionState =>
useTypedEventEmitterState( useTypedEventEmitterState(
call, call ?? undefined,
CallEvent.ConnectionState, CallEvent.ConnectionState,
useCallback((state) => state ?? call.connectionState, [call]), useCallback((state) => state ?? call?.connectionState ?? ConnectionState.Disconnected, [call]),
); );
export const useParticipants = (call: Call): Map<RoomMember, Set<string>> => export const useParticipants = (call: Call | null): Map<RoomMember, Set<string>> => {
useTypedEventEmitterState( return useTypedEventEmitterState(
call, call ?? undefined,
CallEvent.Participants, CallEvent.Participants,
useCallback((state) => state ?? call.participants, [call]), useCallback((state) => state ?? call?.participants ?? [], [call]),
); );
};
export const useParticipantCount = (call: Call): number => { export const useParticipantCount = (call: Call | null): number => {
const participants = useParticipants(call); const participants = useParticipants(call);
return useMemo(() => { return useMemo(() => {

View File

@ -3807,6 +3807,7 @@
"camera_enabled": "Your camera is still enabled", "camera_enabled": "Your camera is still enabled",
"cannot_call_yourself_description": "You cannot place a call with yourself.", "cannot_call_yourself_description": "You cannot place a call with yourself.",
"change_input_device": "Change input device", "change_input_device": "Change input device",
"close_lobby": "Close lobby",
"connecting": "Connecting", "connecting": "Connecting",
"connection_lost": "Connectivity to the server has been lost", "connection_lost": "Connectivity to the server has been lost",
"connection_lost_description": "You cannot place calls without a connection to the server.", "connection_lost_description": "You cannot place calls without a connection to the server.",
@ -3820,6 +3821,7 @@
"disabled_no_perms_start_video_call": "You do not have permission to start video calls", "disabled_no_perms_start_video_call": "You do not have permission to start video calls",
"disabled_no_perms_start_voice_call": "You do not have permission to start voice calls", "disabled_no_perms_start_voice_call": "You do not have permission to start voice calls",
"disabled_ongoing_call": "Ongoing call", "disabled_ongoing_call": "Ongoing call",
"element_call": "Element Call",
"enable_camera": "Turn on camera", "enable_camera": "Turn on camera",
"enable_microphone": "Unmute microphone", "enable_microphone": "Unmute microphone",
"expand": "Return to call", "expand": "Return to call",
@ -3828,9 +3830,13 @@
"hangup": "Hangup", "hangup": "Hangup",
"hide_sidebar_button": "Hide sidebar", "hide_sidebar_button": "Hide sidebar",
"input_devices": "Input devices", "input_devices": "Input devices",
"jitsi_call": "Jitsi Conference",
"join_button_tooltip_call_full": "Sorry — this call is currently full", "join_button_tooltip_call_full": "Sorry — this call is currently full",
"join_button_tooltip_connecting": "Connecting", "join_button_tooltip_connecting": "Connecting",
"legacy_call": "Legacy Call",
"maximise": "Fill screen", "maximise": "Fill screen",
"maximise_call": "Maximise call",
"minimise_call": "Minimise call",
"misconfigured_server": "Call failed due to misconfigured server", "misconfigured_server": "Call failed due to misconfigured server",
"misconfigured_server_description": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.", "misconfigured_server_description": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.",
"misconfigured_server_fallback": "Alternatively, you can try to use the public server at <server/>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.", "misconfigured_server_fallback": "Alternatively, you can try to use the public server at <server/>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.",
@ -3878,6 +3884,7 @@
"user_is_presenting": "%(sharerName)s is presenting", "user_is_presenting": "%(sharerName)s is presenting",
"video_call": "Video call", "video_call": "Video call",
"video_call_started": "Video call started", "video_call_started": "Video call started",
"video_call_using": "Video call using:",
"voice_call": "Voice call", "voice_call": "Voice call",
"you_are_presenting": "You are presenting" "you_are_presenting": "You are presenting"
}, },

View File

@ -65,18 +65,26 @@ const waitForEvent = async (
emitter: EventEmitter, emitter: EventEmitter,
event: string, event: string,
pred: (...args: any[]) => boolean = () => true, pred: (...args: any[]) => boolean = () => true,
customTimeout?: number | false,
): Promise<void> => { ): Promise<void> => {
let listener: (...args: any[]) => void; let listener: (...args: any[]) => void;
const wait = new Promise<void>((resolve) => { const wait = new Promise<void>((resolve) => {
listener = (...args) => { listener = (...args) => {
if (pred(...args)) resolve(); if (pred(...args)) {
resolve();
if (customTimeout === false) {
emitter.off(event, listener!);
}
}
}; };
emitter.on(event, listener); emitter.on(event, listener);
}); });
const timedOut = (await timeout(wait, false, TIMEOUT_MS)) === false; if (customTimeout !== false) {
emitter.off(event, listener!); const timedOut = (await timeout(wait, false, customTimeout ?? TIMEOUT_MS)) === false;
if (timedOut) throw new Error("Timed out"); emitter.off(event, listener!);
if (timedOut) throw new Error("Timed out");
}
}; };
export enum ConnectionState { export enum ConnectionState {
@ -899,6 +907,7 @@ export class ElementCall extends Call {
MatrixRTCSessionEvent.MembershipsChanged, MatrixRTCSessionEvent.MembershipsChanged,
(_, newMemberships: CallMembership[]) => (_, newMemberships: CallMembership[]) =>
newMemberships.some((m) => m.sender === this.client.getUserId()), newMemberships.some((m) => m.sender === this.client.getUserId()),
false, // allow user to wait as long as they want (no timeout)
); );
} else { } else {
await waitForEvent( await waitForEvent(
@ -906,6 +915,7 @@ export class ElementCall extends Call {
MatrixRTCSessionManagerEvents.SessionStarted, MatrixRTCSessionManagerEvents.SessionStarted,
(roomId: string, session: MatrixRTCSession) => (roomId: string, session: MatrixRTCSession) =>
this.session.callId === session.callId && roomId === this.roomId, this.session.callId === session.callId && roomId === this.roomId,
false, // allow user to wait as long as they want (no timeout)
); );
} }
this.sendCallNotify(); this.sendCallNotify();

View File

@ -345,8 +345,11 @@ export class StopGapWidget extends EventEmitter {
if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
ev.preventDefault(); ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
if (ev.detail.data.value) {
if (this.stickyPromise) await this.stickyPromise(); // If the widget wants to become sticky we wait for the stickyPromise to resolve
if (this.stickyPromise) await this.stickyPromise();
}
// Stop being persistent can be done instantly
ActiveWidgetStore.instance.setWidgetPersistence( ActiveWidgetStore.instance.setWidgetPersistence(
this.mockWidget.id, this.mockWidget.id,
this.roomId ?? null, this.roomId ?? null,

View File

@ -20,6 +20,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { Button } from "@vector-im/compound-web";
import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
import RoomAvatar from "../components/views/avatars/RoomAvatar"; import RoomAvatar from "../components/views/avatars/RoomAvatar";
@ -28,7 +30,6 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import AccessibleTooltipButton from "../components/views/elements/AccessibleTooltipButton";
import { import {
LiveContentSummary, LiveContentSummary,
LiveContentSummaryWithCall, LiveContentSummaryWithCall,
@ -41,6 +42,7 @@ import { ActionPayload } from "../dispatcher/payloads";
import { Call } from "../models/Call"; import { Call } from "../models/Call";
import { AudioID } from "../LegacyCallHandler"; import { AudioID } from "../LegacyCallHandler";
import { useTypedEventEmitter } from "../hooks/useEventEmitter"; import { useTypedEventEmitter } from "../hooks/useEventEmitter";
import AccessibleTooltipButton from "../components/views/elements/AccessibleTooltipButton";
export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`; export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`;
const MAX_RING_TIME_MS = 10 * 1000; const MAX_RING_TIME_MS = 10 * 1000;
@ -54,15 +56,15 @@ function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps):
const disabledTooltip = useJoinCallButtonDisabledTooltip(call); const disabledTooltip = useJoinCallButtonDisabledTooltip(call);
return ( return (
<AccessibleTooltipButton <Button
className="mx_IncomingCallToast_joinButton" className="mx_IncomingCallToast_joinButton"
onClick={onClick} onClick={onClick}
disabled={disabledTooltip !== null} disabled={disabledTooltip !== null}
tooltip={disabledTooltip ?? undefined}
kind="primary" kind="primary"
size="sm"
> >
{_t("action|join")} {_t("action|join")}
</AccessibleTooltipButton> </Button>
); );
} }
@ -179,13 +181,15 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
{call ? ( {call ? (
<JoinCallButtonWithCall onClick={onJoinClick} call={call} /> <JoinCallButtonWithCall onClick={onJoinClick} call={call} />
) : ( ) : (
<AccessibleTooltipButton <Button
className="mx_IncomingCallToast_joinButton" className="mx_IncomingCallToast_joinButton"
onClick={onJoinClick} onClick={onJoinClick}
kind="primary" kind="primary"
size="sm"
Icon={VideoCallIcon}
> >
{_t("action|join")} {_t("action|join")}
</AccessibleTooltipButton> </Button>
)} )}
</div> </div>
<AccessibleTooltipButton <AccessibleTooltipButton

View File

@ -35,27 +35,15 @@ export const placeCall = async (
platformCallType: PlatformCallType, platformCallType: PlatformCallType,
skipLobby: boolean, skipLobby: boolean,
): Promise<void> => { ): Promise<void> => {
switch (platformCallType) { if (platformCallType == PlatformCallType.LegacyCall || platformCallType == PlatformCallType.JitsiCall) {
case "legacy_or_jitsi": await LegacyCallHandler.instance.placeCall(room.roomId, callType);
await LegacyCallHandler.instance.placeCall(room.roomId, callType); } else if (platformCallType == PlatformCallType.ElementCall) {
break; defaultDispatcher.dispatch<ViewRoomPayload>({
// TODO: Remove the jitsi_or_element_call case and action: Action.ViewRoom,
// use the commented code below room_id: room.roomId,
case "element_call": view_call: true,
case "jitsi_or_element_call": skipLobby,
defaultDispatcher.dispatch<ViewRoomPayload>({ metricsTrigger: undefined,
action: Action.ViewRoom, });
room_id: room.roomId,
view_call: true,
metricsTrigger: undefined,
skipLobby,
});
break;
// case "jitsi_or_element_call":
// TODO: Open dropdown menu to choice between
// EC and Jitsi. Waiting on Compound's dropdown
// component
// break;
} }
}; };

View File

@ -323,7 +323,7 @@ describe("RoomHeader", () => {
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true); jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget: {} } as Call); jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget: {}, on: () => {} } as unknown as Call);
const { container } = render(<RoomHeader room={room} />, getWrapper()); const { container } = render(<RoomHeader room={room} />, getWrapper());
expect(getByLabelText(container, "Ongoing call")).toHaveAttribute("aria-disabled", "true"); expect(getByLabelText(container, "Ongoing call")).toHaveAttribute("aria-disabled", "true");
@ -336,8 +336,11 @@ describe("RoomHeader", () => {
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false); jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
const widget = { eventId: "some_id_so_it_is_interpreted_as_non_virtual_widget" }; const widget = {};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget } as Call); jest.spyOn(CallStore.instance, "getCall").mockReturnValue({
widget,
on: () => {},
} as unknown as Call);
const { container } = render(<RoomHeader room={room} />, getWrapper()); const { container } = render(<RoomHeader room={room} />, getWrapper());
expect(getByLabelText(container, "Video call")).not.toHaveAttribute("aria-disabled", "true"); expect(getByLabelText(container, "Video call")).not.toHaveAttribute("aria-disabled", "true");
@ -367,6 +370,10 @@ describe("RoomHeader", () => {
it("calls using legacy or jitsi", async () => { it("calls using legacy or jitsi", async () => {
mockRoomMembers(room, 2); mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === "im.vector.modular.widgets") return true;
return false;
});
const { container } = render(<RoomHeader room={room} />, getWrapper()); const { container } = render(<RoomHeader room={room} />, getWrapper());
const voiceButton = getByLabelText(container, "Voice call"); const voiceButton = getByLabelText(container, "Voice call");
@ -409,8 +416,7 @@ describe("RoomHeader", () => {
mockRoomMembers(room, 3); mockRoomMembers(room, 3);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === "im.vector.modular.widgets") return true; if (key === ElementCall.MEMBER_EVENT_TYPE.name) return true;
if (key === ElementCall.CALL_EVENT_TYPE.name) return true;
return false; return false;
}); });

View File

@ -80,20 +80,6 @@ describe("<VideoRoomChatButton />", () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
it("does not render button when room is not a video room", () => {
const room = makeRoom(false);
getComponent(room);
expect(screen.queryByLabelText("Chat")).not.toBeInTheDocument();
});
it("renders button when room is a video room", () => {
const room = makeRoom();
getComponent(room);
expect(screen.getByLabelText("Chat")).toMatchSnapshot();
});
it("toggles timeline in right panel on click", () => { it("toggles timeline in right panel on click", () => {
const room = makeRoom(); const room = makeRoom();
getComponent(room); getComponent(room);

View File

@ -1,23 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<VideoRoomChatButton /> renders button when room is a video room 1`] = `
<button
aria-label="Chat"
class="_icon-button_16nk7_17"
data-state="closed"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_jtb4d_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
`;
exports[`<VideoRoomChatButton /> renders button with an unread marker when room is unread 1`] = ` exports[`<VideoRoomChatButton /> renders button with an unread marker when room is unread 1`] = `
<button <button
aria-label="Chat" aria-label="Chat"

View File

@ -47,7 +47,6 @@ exports[`RoomHeader does not show the face pile for DMs 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="There's no one here to call" aria-label="There's no one here to call"
class="_icon-button_16nk7_17" class="_icon-button_16nk7_17"
data-state="closed"
role="button" role="button"
style="--cpd-icon-button-size: 32px;" style="--cpd-icon-button-size: 32px;"
tabindex="0" tabindex="0"
@ -56,7 +55,9 @@ exports[`RoomHeader does not show the face pile for DMs 1`] = `
class="_indicator-icon_jtb4d_26" class="_indicator-icon_jtb4d_26"
style="--cpd-icon-button-size: 100%; --cpd-color-icon-tertiary: var(--cpd-color-icon-disabled);" style="--cpd-icon-button-size: 100%; --cpd-color-icon-tertiary: var(--cpd-color-icon-disabled);"
> >
<div /> <div
data-state="closed"
/>
</div> </div>
</button> </button>
<button <button