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 => {
if (this.state.roomId === undefined) return;
const activeCall = CallStore.instance.getActiveCall(this.state.roomId);
if (activeCall === null) {
// We disconnected from the call, so stop viewing it
dis.dispatch<ViewRoomPayload>(

View File

@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useMemo, useState } from "react";
import { Body as BodyText, IconButton, Tooltip } from "@vector-im/compound-web";
import React, { useCallback, useEffect, useMemo, useState } from "react";
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 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 NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications-solid.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 { Flex } from "../../utils/Flex";
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 { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
import SdkConfig from "../../../SdkConfig";
@ -51,6 +52,7 @@ import { Linkify, topicToHtml } from "../../../HtmlUtils";
import PosthogTrackers from "../../../PosthogTrackers";
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
import { RoomKnocksBar } from "./RoomKnocksBar";
import { isVideoRoom } from "../../../utils/video-rooms";
import { notificationLevelToIndicator } from "../../../utils/notifications";
export default function RoomHeader({
@ -69,7 +71,17 @@ export default function RoomHeader({
const members = useRoomMembers(room, 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");
/**
@ -104,6 +116,97 @@ export default function RoomHeader({
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 (
<>
<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 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 */}
<VideoRoomChatButton room={room} />
{((isConnectedToCall && isViewingCall) || isVideoRoom(room)) && <VideoRoomChatButton room={room} />}
{hasActiveCallSession && !isConnectedToCall ? (
joinCallButton
) : (
<>
{!isVideoRoom(room) && videoCallButton}
{!useElementCallExclusively && !isVideoRoom(room) && voiceCallButton}
</>
)}
<Tooltip label={_t("common|threads")}>
<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 { IconButton, Tooltip } from "@vector-im/compound-web";
import { isVideoRoom as calcIsVideoRoom } from "../../../../utils/video-rooms";
import { _t } from "../../../../languageHandler";
import { useEventEmitterState } from "../../../../hooks/useEventEmitter";
import { NotificationStateEvents } from "../../../../stores/notifications/NotificationState";
@ -31,25 +30,18 @@ import { ButtonEvent } from "../../elements/AccessibleButton";
/**
* Display a button to toggle timeline for video rooms
* @param room
* @returns for a video room: a button to toggle timeline in the right panel
* otherwise null
* @returns A button to toggle timeline in the right panel.
*/
export const VideoRoomChatButton: React.FC<{ room: Room }> = ({ room }) => {
const sdkContext = useContext(SDKContext);
const isVideoRoom = calcIsVideoRoom(room);
const notificationState = isVideoRoom ? sdkContext.roomNotificationStateStore.getRoomState(room) : undefined;
const notificationState = sdkContext.roomNotificationStateStore.getRoomState(room);
const notificationColor = useEventEmitterState(
notificationState,
NotificationStateEvents.Update,
() => notificationState?.level,
);
if (!isVideoRoom) {
return null;
}
const displayUnreadIndicator =
!!notificationColor &&
[NotificationLevel.Activity, NotificationLevel.Notification, NotificationLevel.Highlight].includes(

View File

@ -24,18 +24,37 @@ import { useEventEmitter, useEventEmitterState } from "../useEventEmitter";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard";
import { WidgetType } from "../../widgets/WidgetType";
import { useCall } from "../useCall";
import { useCall, useConnectionState, useParticipantCount } from "../useCall";
import { useRoomMemberCount } from "../useRoomMembers";
import { ElementCall } from "../../models/Call";
import { Call, ConnectionState, ElementCall } from "../../models/Call";
import { placeCall } from "../../utils/room/placeCall";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { useRoomState } from "../useRoomState";
import { _t } from "../../languageHandler";
import { isManagedHybridWidget } from "../../widgets/ManagedHybrid";
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 {
NoCall,
NoOneHere,
@ -53,9 +72,14 @@ export const useRoomCall = (
room: Room,
): {
voiceCallDisabledReason: string | null;
voiceCallClick(evt: React.MouseEvent): void;
voiceCallClick(evt: React.MouseEvent | undefined, selectedType: PlatformCallType): void;
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 useElementCallExclusively = useMemo(() => {
@ -75,69 +99,83 @@ export const useRoomCall = (
const hasManagedHybridWidget = !!managedHybridWidget;
const groupCall = useCall(room.roomId);
const isConnectedToCall = useConnectionState(groupCall) === ConnectionState.Connected;
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 [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [
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 (hasGroupCall) {
return "jitsi_or_element_call";
if (hasGroupCall || mayCreateElementCalls) {
options.push(PlatformCallType.ElementCall);
}
if (mayCreateElementCalls && hasJitsiWidget) {
return "jitsi_or_element_call";
if (useElementCallExclusively && !hasJitsiWidget) {
return [PlatformCallType.ElementCall];
}
if (useElementCallExclusively) {
return "element_call";
}
if (memberCount <= 2) {
return "legacy_or_jitsi";
}
if (mayCreateElementCalls) {
return "element_call";
if (hasGroupCall && WidgetType.CALL.matches(groupCall.widget.type)) {
// only allow joining joining the ongoing Element call if there is one.
return [PlatformCallType.ElementCall];
}
}
return "legacy_or_jitsi";
return options;
}, [
memberCount,
mayEditWidgets,
hasJitsiWidget,
groupCallsEnabled,
hasGroupCall,
mayCreateElementCalls,
hasJitsiWidget,
useElementCallExclusively,
memberCount,
groupCall?.widget.type,
]);
let widget: IApp | undefined;
if (callType === "legacy_or_jitsi") {
if (callOptions.includes(PlatformCallType.JitsiCall) || callOptions.includes(PlatformCallType.LegacyCall)) {
widget = jitsiWidget ?? managedHybridWidget;
} else if (callType === "element_call") {
}
if (callOptions.includes(PlatformCallType.ElementCall)) {
widget = groupCall?.widget;
} else {
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 [widgetPinned, setWidgetPinned] = useState(false);
// We only want to prompt to pin the widget if it's not element call based.
const isECWidget = WidgetType.CALL.matches(widget?.type ?? "");
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 => {
if (activeCalls.find((call) => call.roomId != room.roomId)) {
return State.Ongoing;
}
if (hasGroupCall || hasJitsiWidget || hasManagedHybridWidget) {
return promptPinWidget ? State.Unpinned : State.Ongoing;
}
@ -152,9 +190,9 @@ export const useRoomCall = (
if (!mayCreateElementCalls && !mayEditWidgets) {
return State.NoPermission;
}
return State.NoCall;
}, [
activeCalls,
hasGroupCall,
hasJitsiWidget,
hasLegacyCall,
@ -163,29 +201,30 @@ export const useRoomCall = (
mayEditWidgets,
memberCount,
promptPinWidget,
room.roomId,
]);
const voiceCallClick = useCallback(
(evt: React.MouseEvent): void => {
evt.stopPropagation();
(evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
evt?.stopPropagation();
if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
} 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(
(evt: React.MouseEvent): void => {
evt.stopPropagation();
(evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
evt?.stopPropagation();
if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
} 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;
@ -208,6 +247,14 @@ export const useRoomCall = (
voiceCallDisabledReason = 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
@ -217,5 +264,10 @@ export const useRoomCall = (
voiceCallClick,
videoCallDisabledReason,
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;
};
export const useConnectionState = (call: Call): ConnectionState =>
export const useConnectionState = (call: Call | null): ConnectionState =>
useTypedEventEmitterState(
call,
call ?? undefined,
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>> =>
useTypedEventEmitterState(
call,
export const useParticipants = (call: Call | null): Map<RoomMember, Set<string>> => {
return useTypedEventEmitterState(
call ?? undefined,
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);
return useMemo(() => {

View File

@ -3807,6 +3807,7 @@
"camera_enabled": "Your camera is still enabled",
"cannot_call_yourself_description": "You cannot place a call with yourself.",
"change_input_device": "Change input device",
"close_lobby": "Close lobby",
"connecting": "Connecting",
"connection_lost": "Connectivity to the server has been lost",
"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_voice_call": "You do not have permission to start voice calls",
"disabled_ongoing_call": "Ongoing call",
"element_call": "Element Call",
"enable_camera": "Turn on camera",
"enable_microphone": "Unmute microphone",
"expand": "Return to call",
@ -3828,9 +3830,13 @@
"hangup": "Hangup",
"hide_sidebar_button": "Hide sidebar",
"input_devices": "Input devices",
"jitsi_call": "Jitsi Conference",
"join_button_tooltip_call_full": "Sorry — this call is currently full",
"join_button_tooltip_connecting": "Connecting",
"legacy_call": "Legacy Call",
"maximise": "Fill screen",
"maximise_call": "Maximise call",
"minimise_call": "Minimise call",
"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_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",
"video_call": "Video call",
"video_call_started": "Video call started",
"video_call_using": "Video call using:",
"voice_call": "Voice call",
"you_are_presenting": "You are presenting"
},

View File

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

View File

@ -345,8 +345,11 @@ export class StopGapWidget extends EventEmitter {
if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
if (this.stickyPromise) await this.stickyPromise();
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();
}
// Stop being persistent can be done instantly
ActiveWidgetStore.instance.setWidgetPersistence(
this.mockWidget.id,
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";
// eslint-disable-next-line no-restricted-imports
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 RoomAvatar from "../components/views/avatars/RoomAvatar";
@ -28,7 +30,6 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../dispatcher/actions";
import ToastStore from "../stores/ToastStore";
import AccessibleTooltipButton from "../components/views/elements/AccessibleTooltipButton";
import {
LiveContentSummary,
LiveContentSummaryWithCall,
@ -41,6 +42,7 @@ import { ActionPayload } from "../dispatcher/payloads";
import { Call } from "../models/Call";
import { AudioID } from "../LegacyCallHandler";
import { useTypedEventEmitter } from "../hooks/useEventEmitter";
import AccessibleTooltipButton from "../components/views/elements/AccessibleTooltipButton";
export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`;
const MAX_RING_TIME_MS = 10 * 1000;
@ -54,15 +56,15 @@ function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps):
const disabledTooltip = useJoinCallButtonDisabledTooltip(call);
return (
<AccessibleTooltipButton
<Button
className="mx_IncomingCallToast_joinButton"
onClick={onClick}
disabled={disabledTooltip !== null}
tooltip={disabledTooltip ?? undefined}
kind="primary"
size="sm"
>
{_t("action|join")}
</AccessibleTooltipButton>
</Button>
);
}
@ -179,13 +181,15 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
{call ? (
<JoinCallButtonWithCall onClick={onJoinClick} call={call} />
) : (
<AccessibleTooltipButton
<Button
className="mx_IncomingCallToast_joinButton"
onClick={onJoinClick}
kind="primary"
size="sm"
Icon={VideoCallIcon}
>
{_t("action|join")}
</AccessibleTooltipButton>
</Button>
)}
</div>
<AccessibleTooltipButton

View File

@ -35,27 +35,15 @@ export const placeCall = async (
platformCallType: PlatformCallType,
skipLobby: boolean,
): Promise<void> => {
switch (platformCallType) {
case "legacy_or_jitsi":
await LegacyCallHandler.instance.placeCall(room.roomId, callType);
break;
// TODO: Remove the jitsi_or_element_call case and
// use the commented code below
case "element_call":
case "jitsi_or_element_call":
defaultDispatcher.dispatch<ViewRoomPayload>({
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;
if (platformCallType == PlatformCallType.LegacyCall || platformCallType == PlatformCallType.JitsiCall) {
await LegacyCallHandler.instance.placeCall(room.roomId, callType);
} else if (platformCallType == PlatformCallType.ElementCall) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
skipLobby,
metricsTrigger: undefined,
});
}
};

View File

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

View File

@ -80,20 +80,6 @@ describe("<VideoRoomChatButton />", () => {
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", () => {
const room = makeRoom();
getComponent(room);

View File

@ -1,23 +1,5 @@
// 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`] = `
<button
aria-label="Chat"

View File

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