Add Voice and Video call in room header (#11444)
* Add Voice and Video call in room header * Add thread icon in room header * Add room notification icon to room header * Fix linting * Add tests for buttons in room header * Add JSDoc * micro optimisations * Fix call disabled when hanging up * Fix disabled state change on members count update * Exclude functional members from members count optionally * i18n
This commit is contained in:
parent
c2e814ce95
commit
3acc9059ab
13 changed files with 709 additions and 48 deletions
|
@ -68,7 +68,7 @@
|
|||
"@sentry/browser": "^7.0.0",
|
||||
"@sentry/tracing": "^7.0.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@vector-im/compound-design-tokens": "^0.0.3",
|
||||
"@vector-im/compound-design-tokens": "^0.0.4",
|
||||
"@vector-im/compound-web": "^0.2.3",
|
||||
"await-lock": "^2.1.0",
|
||||
"blurhash": "^1.1.3",
|
||||
|
|
|
@ -14,8 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_Box {
|
||||
flex: var(--mx-box-flex, initial);
|
||||
flex-shrink: var(--mx-box-shrink, initial);
|
||||
flex-grow: var(--mx-box-grow, initial);
|
||||
.mx_Box--flex {
|
||||
flex: var(--mx-box-flex, unset);
|
||||
}
|
||||
|
||||
.mx_Box--shrink {
|
||||
flex-shrink: var(--mx-box-shrink, unset);
|
||||
}
|
||||
|
||||
.mx_Box--grow {
|
||||
flex-grow: var(--mx-box-grow, unset);
|
||||
}
|
||||
|
|
|
@ -15,9 +15,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
.mx_Flex {
|
||||
display: var(--mx-flex-display, initial);
|
||||
flex-direction: var(--mx-flex-direction, initial);
|
||||
align-items: var(--mx-flex-align, initial);
|
||||
justify-content: var(--mx-flex-justify, initial);
|
||||
gap: var(--mx-flex-gap, initial);
|
||||
display: var(--mx-flex-display, unset);
|
||||
flex-direction: var(--mx-flex-direction, unset);
|
||||
align-items: var(--mx-flex-align, unset);
|
||||
justify-content: var(--mx-flex-justify, unset);
|
||||
gap: var(--mx-flex-gap, unset);
|
||||
}
|
||||
|
|
|
@ -19,10 +19,10 @@ limitations under the License.
|
|||
padding: 0 var(--cpd-space-3x);
|
||||
border-bottom: 1px solid $separator;
|
||||
background-color: $background;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.mx_RoomHeader_info {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mx_RoomHeader_topic {
|
||||
|
@ -36,7 +36,7 @@ limitations under the License.
|
|||
word-break: break-all;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
transition: all var(--transition-standard) ease;
|
||||
transition: all var(--transition-standard) ease 0.1s;
|
||||
}
|
||||
|
||||
.mx_RoomHeader:hover .mx_RoomHeader_topic {
|
||||
|
|
|
@ -87,5 +87,17 @@ export function Box({
|
|||
addOrRemoveProperty(ref, `--mx-box-grow`, grow);
|
||||
}, [flex, grow, shrink]);
|
||||
|
||||
return React.createElement(as, { ...props, className: classNames("mx_Box", className), ref }, children);
|
||||
return React.createElement(
|
||||
as,
|
||||
{
|
||||
...props,
|
||||
className: classNames("mx_Box", className, {
|
||||
"mx_Box--flex": !!flex,
|
||||
"mx_Box--shrink": !!shrink,
|
||||
"mx_Box--grow": !!grow,
|
||||
}),
|
||||
ref,
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,10 +14,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Body as BodyText } from "@vector-im/compound-web";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Body as BodyText, IconButton } from "@vector-im/compound-web";
|
||||
import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call.svg";
|
||||
import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.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 { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useRoomName } from "../../../hooks/useRoomName";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
|
@ -25,26 +31,97 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
|||
import { useTopic } from "../../../hooks/room/useTopic";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { Box } from "../../utils/Box";
|
||||
import { useRoomCallStatus } from "../../../hooks/room/useRoomCallStatus";
|
||||
import LegacyCallHandler from "../../../LegacyCallHandler";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||
|
||||
/**
|
||||
* A helper to transform a notification color to the what the Compound Icon Button
|
||||
* expects
|
||||
*/
|
||||
function notificationColorToIndicator(color: NotificationColor): React.ComponentProps<typeof IconButton>["indicator"] {
|
||||
if (color <= NotificationColor.None) {
|
||||
return undefined;
|
||||
} else if (color <= NotificationColor.Grey) {
|
||||
return "default";
|
||||
} else {
|
||||
return "highlight";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper to show or hide the right panel
|
||||
*/
|
||||
function showOrHidePanel(phase: RightPanelPhases): void {
|
||||
const rightPanel = RightPanelStore.instance;
|
||||
rightPanel.isOpen && rightPanel.currentCard.phase === phase
|
||||
? rightPanel.togglePanel(null)
|
||||
: rightPanel.setCard({ phase });
|
||||
}
|
||||
|
||||
export default function RoomHeader({ room }: { room: Room }): JSX.Element {
|
||||
const roomName = useRoomName(room);
|
||||
const roomTopic = useTopic(room);
|
||||
|
||||
const { voiceCallDisabledReason, voiceCallType, videoCallDisabledReason, videoCallType } = useRoomCallStatus(room);
|
||||
|
||||
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
|
||||
/**
|
||||
* A special mode where only Element Call is used. In this case we want to
|
||||
* hide the voice call button
|
||||
*/
|
||||
const useElementCallExclusively = useMemo(() => {
|
||||
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
|
||||
}, [groupCallsEnabled]);
|
||||
|
||||
const placeCall = useCallback(
|
||||
async (callType: CallType, platformCallType: typeof voiceCallType) => {
|
||||
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,
|
||||
});
|
||||
break;
|
||||
|
||||
// case "jitsi_or_element_call":
|
||||
// TODO: Open dropdown menu to choice between
|
||||
// EC and Jitsi. Waiting on Compound's dropdown
|
||||
// component
|
||||
// break;
|
||||
}
|
||||
},
|
||||
[room.roomId],
|
||||
);
|
||||
|
||||
const threadNotifications = useRoomThreadNotifications(room);
|
||||
const globalNotificationState = useGlobalNotificationState();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="header"
|
||||
align="center"
|
||||
gap="var(--cpd-space-3x)"
|
||||
className="mx_RoomHeader light-panel"
|
||||
onClick={() => {
|
||||
const rightPanel = RightPanelStore.instance;
|
||||
rightPanel.isOpen
|
||||
? rightPanel.togglePanel(null)
|
||||
: rightPanel.setCard({ phase: RightPanelPhases.RoomSummary });
|
||||
}}
|
||||
>
|
||||
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
|
||||
<DecoratedRoomAvatar room={room} avatarSize={40} displayBadge={false} />
|
||||
<Box flex="1" className="mx_RoomHeader_info">
|
||||
<Box
|
||||
flex="1"
|
||||
className="mx_RoomHeader_info"
|
||||
onClick={() => {
|
||||
showOrHidePanel(RightPanelPhases.RoomSummary);
|
||||
}}
|
||||
>
|
||||
<BodyText
|
||||
as="div"
|
||||
size="lg"
|
||||
|
@ -62,6 +139,46 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
|
|||
</BodyText>
|
||||
)}
|
||||
</Box>
|
||||
<Flex as="nav" align="center" gap="var(--cpd-space-2x)">
|
||||
{!useElementCallExclusively && (
|
||||
<IconButton
|
||||
disabled={!!voiceCallDisabledReason}
|
||||
title={!voiceCallDisabledReason ? _t("Voice call") : voiceCallDisabledReason!}
|
||||
onClick={async () => {
|
||||
placeCall(CallType.Voice, voiceCallType);
|
||||
}}
|
||||
>
|
||||
<VoiceCallIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
disabled={!!videoCallDisabledReason}
|
||||
title={!videoCallDisabledReason ? _t("Video call") : videoCallDisabledReason!}
|
||||
onClick={() => {
|
||||
placeCall(CallType.Video, videoCallType);
|
||||
}}
|
||||
>
|
||||
<VideoCallIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
indicator={notificationColorToIndicator(threadNotifications)}
|
||||
onClick={() => {
|
||||
showOrHidePanel(RightPanelPhases.ThreadPanel);
|
||||
}}
|
||||
title={_t("Threads")}
|
||||
>
|
||||
<ThreadsIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
indicator={notificationColorToIndicator(globalNotificationState.color)}
|
||||
onClick={() => {
|
||||
showOrHidePanel(RightPanelPhases.NotificationPanel);
|
||||
}}
|
||||
title={_t("Notifications")}
|
||||
>
|
||||
<NotificationsIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
|
161
src/hooks/room/useRoomCallStatus.ts
Normal file
161
src/hooks/room/useRoomCallStatus.ts
Normal file
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useFeatureEnabled } from "../useSettings";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import { useEventEmitterState, useTypedEventEmitterState } 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 { _t } from "../../languageHandler";
|
||||
import { useRoomMemberCount } from "../useRoomMembers";
|
||||
import { ElementCall } from "../../models/Call";
|
||||
|
||||
type CallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi";
|
||||
|
||||
const DEFAULT_DISABLED_REASON = null;
|
||||
const DEFAULT_CALL_TYPE = "jitsi_or_element_call";
|
||||
|
||||
/**
|
||||
* Reports the call capabilities for the current room
|
||||
* @param room the room to track
|
||||
* @returns the call status for a room
|
||||
*/
|
||||
export const useRoomCallStatus = (
|
||||
room: Room,
|
||||
): {
|
||||
voiceCallDisabledReason: string | null;
|
||||
voiceCallType: CallType;
|
||||
videoCallDisabledReason: string | null;
|
||||
videoCallType: CallType;
|
||||
} => {
|
||||
const [voiceCallDisabledReason, setVoiceCallDisabledReason] = useState<string | null>(DEFAULT_DISABLED_REASON);
|
||||
const [videoCallDisabledReason, setVideoCallDisabledReason] = useState<string | null>(DEFAULT_DISABLED_REASON);
|
||||
const [voiceCallType, setVoiceCallType] = useState<CallType>(DEFAULT_CALL_TYPE);
|
||||
const [videoCallType, setVideoCallType] = useState<CallType>(DEFAULT_CALL_TYPE);
|
||||
|
||||
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
|
||||
const useElementCallExclusively = useMemo(() => {
|
||||
return SdkConfig.get("element_call").use_exclusively;
|
||||
}, []);
|
||||
|
||||
const hasLegacyCall = useEventEmitterState(
|
||||
LegacyCallHandler.instance,
|
||||
LegacyCallHandlerEvent.CallsChanged,
|
||||
() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null,
|
||||
);
|
||||
|
||||
const widgets = useWidgets(room);
|
||||
const hasJitsiWidget = useMemo(() => widgets.some((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]);
|
||||
|
||||
const hasGroupCall = useCall(room.roomId) !== null;
|
||||
|
||||
const memberCount = useRoomMemberCount(room, { includeFunctional: false });
|
||||
|
||||
const [mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState(
|
||||
room,
|
||||
RoomStateEvent.Update,
|
||||
useCallback(
|
||||
() => [
|
||||
room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
|
||||
room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client),
|
||||
],
|
||||
[room],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// First reset all state to their default value
|
||||
setVoiceCallDisabledReason(DEFAULT_DISABLED_REASON);
|
||||
setVideoCallDisabledReason(DEFAULT_DISABLED_REASON);
|
||||
setVoiceCallType(DEFAULT_CALL_TYPE);
|
||||
setVideoCallType(DEFAULT_CALL_TYPE);
|
||||
|
||||
// And then run the logic to figure out their correct state
|
||||
if (groupCallsEnabled) {
|
||||
if (useElementCallExclusively) {
|
||||
if (hasGroupCall) {
|
||||
setVideoCallDisabledReason(_t("Ongoing call"));
|
||||
} else if (mayCreateElementCalls) {
|
||||
setVideoCallType("element_call");
|
||||
} else {
|
||||
setVideoCallDisabledReason(_t("You do not have permission to start video calls"));
|
||||
}
|
||||
} else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) {
|
||||
setVoiceCallDisabledReason(_t("Ongoing call"));
|
||||
setVideoCallDisabledReason(_t("Ongoing call"));
|
||||
} else if (memberCount <= 1) {
|
||||
setVoiceCallDisabledReason(_t("There's no one here to call"));
|
||||
setVideoCallDisabledReason(_t("There's no one here to call"));
|
||||
} else if (memberCount === 2) {
|
||||
setVoiceCallType("legacy_or_jitsi");
|
||||
setVideoCallType("legacy_or_jitsi");
|
||||
} else if (mayEditWidgets) {
|
||||
setVoiceCallType("legacy_or_jitsi");
|
||||
setVideoCallType(mayCreateElementCalls ? "jitsi_or_element_call" : "legacy_or_jitsi");
|
||||
} else {
|
||||
setVoiceCallDisabledReason(_t("You do not have permission to start voice calls"));
|
||||
if (mayCreateElementCalls) {
|
||||
setVideoCallType("element_call");
|
||||
} else {
|
||||
setVideoCallDisabledReason(_t("You do not have permission to start video calls"));
|
||||
}
|
||||
}
|
||||
} else if (hasLegacyCall || hasJitsiWidget) {
|
||||
setVoiceCallDisabledReason(_t("Ongoing call"));
|
||||
setVideoCallDisabledReason(_t("Ongoing call"));
|
||||
} else if (memberCount <= 1) {
|
||||
setVoiceCallDisabledReason(_t("There's no one here to call"));
|
||||
setVideoCallDisabledReason(_t("There's no one here to call"));
|
||||
} else if (memberCount === 2 || mayEditWidgets) {
|
||||
setVoiceCallType("legacy_or_jitsi");
|
||||
setVideoCallType("legacy_or_jitsi");
|
||||
} else {
|
||||
setVoiceCallDisabledReason(_t("You do not have permission to start voice calls"));
|
||||
setVideoCallDisabledReason(_t("You do not have permission to start video calls"));
|
||||
}
|
||||
}, [
|
||||
memberCount,
|
||||
groupCallsEnabled,
|
||||
hasGroupCall,
|
||||
hasJitsiWidget,
|
||||
hasLegacyCall,
|
||||
mayCreateElementCalls,
|
||||
mayEditWidgets,
|
||||
useElementCallExclusively,
|
||||
]);
|
||||
|
||||
console.table({
|
||||
voiceCallDisabledReason,
|
||||
voiceCallType,
|
||||
videoCallDisabledReason,
|
||||
videoCallType,
|
||||
});
|
||||
|
||||
/**
|
||||
* We've gone through all the steps
|
||||
*/
|
||||
return {
|
||||
voiceCallDisabledReason,
|
||||
voiceCallType,
|
||||
videoCallDisabledReason,
|
||||
videoCallType,
|
||||
};
|
||||
};
|
67
src/hooks/room/useRoomThreadNotifications.ts
Normal file
67
src/hooks/room/useRoomThreadNotifications.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { NotificationCountType, Room, RoomEvent, ThreadEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { NotificationColor } from "../../stores/notifications/NotificationColor";
|
||||
import { doesRoomOrThreadHaveUnreadMessages } from "../../Unread";
|
||||
import { useEventEmitter } from "../useEventEmitter";
|
||||
|
||||
/**
|
||||
* Tracks the thread unread state for an entire room
|
||||
* @param room the room to track
|
||||
* @returns the type of notification for this room
|
||||
*/
|
||||
export const useRoomThreadNotifications = (room: Room): NotificationColor => {
|
||||
const [notificationColor, setNotificationColor] = useState(NotificationColor.None);
|
||||
|
||||
const updateNotification = useCallback(() => {
|
||||
switch (room?.threadsAggregateNotificationType) {
|
||||
case NotificationCountType.Highlight:
|
||||
setNotificationColor(NotificationColor.Red);
|
||||
break;
|
||||
case NotificationCountType.Total:
|
||||
setNotificationColor(NotificationColor.Grey);
|
||||
break;
|
||||
}
|
||||
// We don't have any notified messages, but we might have unread messages. Let's
|
||||
// find out.
|
||||
for (const thread of room!.getThreads()) {
|
||||
// If the current thread has unread messages, we're done.
|
||||
if (doesRoomOrThreadHaveUnreadMessages(thread)) {
|
||||
setNotificationColor(NotificationColor.Bold);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
useEventEmitter(room, RoomEvent.UnreadNotifications, updateNotification);
|
||||
useEventEmitter(room, RoomEvent.Receipt, updateNotification);
|
||||
useEventEmitter(room, RoomEvent.Timeline, updateNotification);
|
||||
useEventEmitter(room, RoomEvent.Redaction, updateNotification);
|
||||
useEventEmitter(room, RoomEvent.LocalEchoUpdated, updateNotification);
|
||||
useEventEmitter(room, RoomEvent.MyMembership, updateNotification);
|
||||
useEventEmitter(room, ThreadEvent.New, updateNotification);
|
||||
useEventEmitter(room, ThreadEvent.Update, updateNotification);
|
||||
|
||||
// Compute the notification once when mouting a room
|
||||
useEffect(() => {
|
||||
updateNotification();
|
||||
}, [updateNotification]);
|
||||
|
||||
return notificationColor;
|
||||
};
|
44
src/hooks/useGlobalNotificationState.ts
Normal file
44
src/hooks/useGlobalNotificationState.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { SummarizedNotificationState } from "../stores/notifications/SummarizedNotificationState";
|
||||
import {
|
||||
RoomNotificationStateStore,
|
||||
UPDATE_STATUS_INDICATOR,
|
||||
} from "../stores/notifications/RoomNotificationStateStore";
|
||||
import { useEventEmitter } from "./useEventEmitter";
|
||||
|
||||
/**
|
||||
* Tracks the global notification state of the user's account
|
||||
* @returns A global notification state object
|
||||
*/
|
||||
export const useGlobalNotificationState = (): SummarizedNotificationState => {
|
||||
const [summarizedNotificationState, setSummarizedNotificationState] = useState(
|
||||
RoomNotificationStateStore.instance.globalState,
|
||||
);
|
||||
|
||||
useEventEmitter(
|
||||
RoomNotificationStateStore.instance,
|
||||
UPDATE_STATUS_INDICATOR,
|
||||
(notificationState: SummarizedNotificationState) => {
|
||||
setSummarizedNotificationState(notificationState);
|
||||
},
|
||||
);
|
||||
|
||||
return summarizedNotificationState;
|
||||
};
|
|
@ -19,6 +19,7 @@ import { Room, RoomEvent, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/m
|
|||
import { throttle } from "lodash";
|
||||
|
||||
import { useTypedEventEmitter } from "./useEventEmitter";
|
||||
import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers";
|
||||
|
||||
// Hook to simplify watching Matrix Room joined members
|
||||
export const useRoomMembers = (room: Room, throttleWait = 250): RoomMember[] => {
|
||||
|
@ -37,15 +38,43 @@ export const useRoomMembers = (room: Room, throttleWait = 250): RoomMember[] =>
|
|||
return members;
|
||||
};
|
||||
|
||||
type RoomMemberCountOpts = {
|
||||
/**
|
||||
* Wait time between room member count update
|
||||
*/
|
||||
throttleWait?: number;
|
||||
/**
|
||||
* Whether to include functional members (bots, etc...) in the room count
|
||||
* @default true
|
||||
*/
|
||||
includeFunctional: boolean;
|
||||
};
|
||||
|
||||
// Hook to simplify watching Matrix Room joined member count
|
||||
export const useRoomMemberCount = (room: Room, throttleWait = 250): number => {
|
||||
export const useRoomMemberCount = (
|
||||
room: Room,
|
||||
opts: RoomMemberCountOpts = { throttleWait: 250, includeFunctional: true },
|
||||
): number => {
|
||||
const [count, setCount] = useState<number>(room.getJoinedMemberCount());
|
||||
|
||||
const { throttleWait, includeFunctional } = opts;
|
||||
|
||||
useTypedEventEmitter(
|
||||
room.currentState,
|
||||
RoomStateEvent.Members,
|
||||
throttle(
|
||||
() => {
|
||||
setCount(room.getJoinedMemberCount());
|
||||
// At the time where `RoomStateEvent.Members` is emitted the
|
||||
// summary API has not had a chance to update the `summaryJoinedMemberCount`
|
||||
// value, therefore handling the logic locally here.
|
||||
//
|
||||
// Tracked as part of https://github.com/vector-im/element-web/issues/26033
|
||||
const membersCount = includeFunctional
|
||||
? room.getMembers().reduce((count, m) => {
|
||||
return m.membership === "join" ? count + 1 : count;
|
||||
}, 0)
|
||||
: getJoinedNonFunctionalMembers(room).length;
|
||||
setCount(membersCount);
|
||||
},
|
||||
throttleWait,
|
||||
{ leading: true, trailing: true },
|
||||
|
|
|
@ -954,6 +954,10 @@
|
|||
"Turn on notifications": "Turn on notifications",
|
||||
"Don’t miss a reply or important message": "Don’t miss a reply or important message",
|
||||
"Enable notifications": "Enable notifications",
|
||||
"Ongoing call": "Ongoing call",
|
||||
"You do not have permission to start video calls": "You do not have permission to start video calls",
|
||||
"There's no one here to call": "There's no one here to call",
|
||||
"You do not have permission to start voice calls": "You do not have permission to start voice calls",
|
||||
"Sends the given message with confetti": "Sends the given message with confetti",
|
||||
"sends confetti": "sends confetti",
|
||||
"Sends the given message with fireworks": "Sends the given message with fireworks",
|
||||
|
@ -1716,10 +1720,6 @@
|
|||
"Scroll to most recent messages": "Scroll to most recent messages",
|
||||
"Video call (Jitsi)": "Video call (Jitsi)",
|
||||
"Video call (%(brand)s)": "Video call (%(brand)s)",
|
||||
"Ongoing call": "Ongoing call",
|
||||
"You do not have permission to start video calls": "You do not have permission to start video calls",
|
||||
"There's no one here to call": "There's no one here to call",
|
||||
"You do not have permission to start voice calls": "You do not have permission to start voice calls",
|
||||
"Freedom": "Freedom",
|
||||
"Spotlight": "Spotlight",
|
||||
"Change layout": "Change layout",
|
||||
|
@ -1815,6 +1815,7 @@
|
|||
"Room %(name)s": "Room %(name)s",
|
||||
"Recently visited rooms": "Recently visited rooms",
|
||||
"No recently visited rooms": "No recently visited rooms",
|
||||
"Threads": "Threads",
|
||||
"Video room": "Video room",
|
||||
"Public space": "Public space",
|
||||
"Public room": "Public room",
|
||||
|
|
|
@ -15,9 +15,10 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { Room, EventType, MatrixEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
|
||||
import { getAllByTitle, getByText, getByTitle, render, screen } from "@testing-library/react";
|
||||
import { Room, EventType, MatrixEvent, PendingEventOrdering, MatrixCall } from "matrix-js-sdk/src/matrix";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import { stubClient } from "../../../test-utils";
|
||||
import RoomHeader from "../../../../src/components/views/rooms/RoomHeader";
|
||||
|
@ -25,6 +26,12 @@ import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
|||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore";
|
||||
import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases";
|
||||
import LegacyCallHandler from "../../../../src/LegacyCallHandler";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import SdkConfig from "../../../../src/SdkConfig";
|
||||
import dispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { CallStore } from "../../../../src/stores/CallStore";
|
||||
import { Call, ElementCall } from "../../../../src/models/Call";
|
||||
|
||||
describe("Roomeader", () => {
|
||||
let room: Room;
|
||||
|
@ -45,6 +52,10 @@ describe("Roomeader", () => {
|
|||
setCardSpy = jest.spyOn(RightPanelStore.instance, "setCard");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("renders the room header", () => {
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
expect(container).toHaveTextContent(ROOM_ID);
|
||||
|
@ -71,7 +82,219 @@ describe("Roomeader", () => {
|
|||
it("opens the room summary", async () => {
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
|
||||
await userEvent.click(container.firstChild! as Element);
|
||||
await userEvent.click(getByText(container, ROOM_ID));
|
||||
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
|
||||
});
|
||||
|
||||
it("opens the thread panel", async () => {
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
|
||||
await userEvent.click(getByTitle(container, "Threads"));
|
||||
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel });
|
||||
});
|
||||
|
||||
it("opens the notifications panel", async () => {
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
|
||||
await userEvent.click(getByTitle(container, "Notifications"));
|
||||
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel });
|
||||
});
|
||||
|
||||
describe("groups call disabled", () => {
|
||||
it("you can't call if you're alone", () => {
|
||||
mockRoomMembers(room, 1);
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
for (const button of getAllByTitle(container, "There's no one here to call")) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("you can call when you're two in the room", async () => {
|
||||
mockRoomMembers(room, 2);
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
const voiceButton = getByTitle(container, "Voice call");
|
||||
const videoButton = getByTitle(container, "Video call");
|
||||
expect(voiceButton).not.toBeDisabled();
|
||||
expect(videoButton).not.toBeDisabled();
|
||||
|
||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
|
||||
|
||||
await userEvent.click(voiceButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
|
||||
|
||||
await userEvent.click(videoButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
|
||||
});
|
||||
|
||||
it("you can't call if there's already a call", () => {
|
||||
mockRoomMembers(room, 2);
|
||||
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue(
|
||||
// The JS-SDK does not export the class `MatrixCall` only the type
|
||||
{} as MatrixCall,
|
||||
);
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
for (const button of getAllByTitle(container, "Ongoing call")) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("can calls in large rooms if able to edit widgets", () => {
|
||||
mockRoomMembers(room, 10);
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
|
||||
expect(getByTitle(container, "Voice call")).not.toBeDisabled();
|
||||
expect(getByTitle(container, "Video call")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("disable calls in large rooms by default", () => {
|
||||
mockRoomMembers(room, 10);
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false);
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
expect(getByTitle(container, "You do not have permission to start voice calls")).toBeDisabled();
|
||||
expect(getByTitle(container, "You do not have permission to start video calls")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("group call enabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_group_calls");
|
||||
});
|
||||
|
||||
it("renders only the video call element", async () => {
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||
// allow element calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
|
||||
expect(screen.queryByTitle("Voice call")).toBeNull();
|
||||
|
||||
const videoCallButton = getByTitle(container, "Video call");
|
||||
expect(videoCallButton).not.toBeDisabled();
|
||||
|
||||
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
await userEvent.click(getByTitle(container, "Video call"));
|
||||
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
|
||||
});
|
||||
|
||||
it("can call if there's an ongoing call", () => {
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||
// allow element calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({} as Call);
|
||||
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
expect(getByTitle(container, "Ongoing call")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables calling if there's a jitsi call", () => {
|
||||
mockRoomMembers(room, 2);
|
||||
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue(
|
||||
// The JS-SDK does not export the class `MatrixCall` only the type
|
||||
{} as MatrixCall,
|
||||
);
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
for (const button of getAllByTitle(container, "Ongoing call")) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("can't call if you have no friends", () => {
|
||||
mockRoomMembers(room, 1);
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
for (const button of getAllByTitle(container, "There's no one here to call")) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("calls using legacy or jitsi", async () => {
|
||||
mockRoomMembers(room, 2);
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
|
||||
const voiceButton = getByTitle(container, "Voice call");
|
||||
const videoButton = getByTitle(container, "Video call");
|
||||
expect(voiceButton).not.toBeDisabled();
|
||||
expect(videoButton).not.toBeDisabled();
|
||||
|
||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
|
||||
await userEvent.click(voiceButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
|
||||
|
||||
await userEvent.click(videoButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
|
||||
});
|
||||
|
||||
it("calls using legacy or jitsi for large rooms", async () => {
|
||||
mockRoomMembers(room, 3);
|
||||
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
|
||||
if (key === "im.vector.modular.widgets") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
|
||||
const voiceButton = getByTitle(container, "Voice call");
|
||||
const videoButton = getByTitle(container, "Video call");
|
||||
expect(voiceButton).not.toBeDisabled();
|
||||
expect(videoButton).not.toBeDisabled();
|
||||
|
||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
|
||||
await userEvent.click(voiceButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
|
||||
|
||||
await userEvent.click(videoButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
|
||||
});
|
||||
|
||||
it("calls using element calls for large rooms", async () => {
|
||||
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;
|
||||
return false;
|
||||
});
|
||||
|
||||
const { container } = render(<RoomHeader room={room} />);
|
||||
|
||||
const voiceButton = getByTitle(container, "Voice call");
|
||||
const videoButton = getByTitle(container, "Video call");
|
||||
expect(voiceButton).not.toBeDisabled();
|
||||
expect(videoButton).not.toBeDisabled();
|
||||
|
||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
|
||||
await userEvent.click(voiceButton);
|
||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
|
||||
|
||||
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
await userEvent.click(videoButton);
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param count the number of users to create
|
||||
*/
|
||||
function mockRoomMembers(room: Room, count: number) {
|
||||
const members = Array(count)
|
||||
.fill(0)
|
||||
.map((_, index) => ({
|
||||
userId: `@user-${index}:example.org`,
|
||||
name: `Member ${index}`,
|
||||
rawDisplayName: `Member ${index}`,
|
||||
roomId: room.roomId,
|
||||
membership: "join",
|
||||
getAvatarUrl: () => `mxc://avatar.url/user-${index}.png`,
|
||||
getMxcAvatarUrl: () => `mxc://avatar.url/user-${index}.png`,
|
||||
}));
|
||||
|
||||
room.currentState.setJoinedMemberCount(members.length);
|
||||
room.getJoinedMembers = jest.fn().mockReturnValue(members);
|
||||
}
|
||||
|
|
19
yarn.lock
19
yarn.lock
|
@ -2912,21 +2912,22 @@
|
|||
"@typescript-eslint/types" "5.62.0"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
|
||||
"@vector-im/compound-design-tokens@^0.0.3":
|
||||
version "0.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.0.3.tgz#89214c69108a14f5d3e4a73ddc44852862531f2b"
|
||||
integrity sha512-XxmySUvfjD6EuAM7f6lsGIhuv94TFfoEpKxYh+HKn1hPBFcMEKKImu/jK5tnpOv2xuZOSrK0Pm6qMLnxLwOXOw==
|
||||
"@vector-im/compound-design-tokens@^0.0.4":
|
||||
version "0.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.0.4.tgz#bf31120f026118d9dc379917364e2c27b51cce94"
|
||||
integrity sha512-ZGflwlUANnEbsX/whWqRomyRHS36F1t5AoNBez2EfBVGXMIu7IsURVQfK/UJYPLoSHcArcTFCSbi5KSSsSiymw==
|
||||
dependencies:
|
||||
svg2vectordrawable "^2.9.1"
|
||||
|
||||
"@vector-im/compound-web@^0.2.3":
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-0.2.3.tgz#9dd4ed80109c614666103d05cd66723d1fad4d6c"
|
||||
integrity sha512-7FI6Q1LN8dXur2sarP7UeMtAKcejuFw6AppM9Lu9fFjwLlbuIX2ZEprw1qa+EzgzUTysTU1TTdo7fxNqQwAQcA==
|
||||
version "0.2.10"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-0.2.10.tgz#7178844159338ccaca63e0acce3a1ce94768f79c"
|
||||
integrity sha512-dnD4ffbANPwWlApXxy5g3prcAD8WjGhtGbLMW5JiOruYwLNRFZ75XaTI22pYykfyl03VZeXapAfU/pNm/jZE1A==
|
||||
dependencies:
|
||||
"@radix-ui/react-form" "^0.0.3"
|
||||
classnames "^2.3.2"
|
||||
lodash "^4.17.21"
|
||||
graphemer "^1.4.0"
|
||||
rimraf "^3.0.1"
|
||||
|
||||
abab@^2.0.6:
|
||||
version "2.0.6"
|
||||
|
@ -8694,7 +8695,7 @@ rfdc@^1.3.0:
|
|||
resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
|
||||
integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==
|
||||
|
||||
rimraf@^3.0.0, rimraf@^3.0.2:
|
||||
rimraf@^3.0.0, rimraf@^3.0.1, rimraf@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
|
||||
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
|
||||
|
|
Loading…
Reference in a new issue