diff --git a/res/css/_components.pcss b/res/css/_components.pcss index f916d5925d..00661bd56b 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -281,6 +281,7 @@ @import "./views/rooms/_ReplyPreview.pcss"; @import "./views/rooms/_ReplyTile.pcss"; @import "./views/rooms/_RoomBreadcrumbs.pcss"; +@import "./views/rooms/_RoomCallBanner.pcss"; @import "./views/rooms/_RoomHeader.pcss"; @import "./views/rooms/_RoomInfoLine.pcss"; @import "./views/rooms/_RoomList.pcss"; diff --git a/res/css/views/rooms/_RoomCallBanner.pcss b/res/css/views/rooms/_RoomCallBanner.pcss new file mode 100644 index 0000000000..4b05b72d91 --- /dev/null +++ b/res/css/views/rooms/_RoomCallBanner.pcss @@ -0,0 +1,54 @@ +/* +Copyright 2022 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. +*/ + +.mx_RoomCallBanner { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + + box-sizing: border-box; + padding: $spacing-12 $spacing-16; + + color: $primary-content; + background-color: $system; + cursor: pointer; +} + +.mx_RoomCallBanner_text { + display: flex; + flex: 1; + align-items: center; +} + +.mx_RoomCallBanner_label { + color: $primary-content; + font-weight: 600; + padding-right: $spacing-8; + + &::before { + display: inline-block; + vertical-align: text-top; + content: ""; + background-color: $secondary-content; + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 4px; + bottom: 2px; + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } +} diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx new file mode 100644 index 0000000000..736c88649f --- /dev/null +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -0,0 +1,131 @@ +/* +Copyright 2022 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 React, { useCallback } from "react"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { _t } from "../../../languageHandler"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import dispatcher, { defaultDispatcher } from "../../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import { Call, ConnectionState, ElementCall } from "../../../models/Call"; +import { useCall } from "../../../hooks/useCall"; +import { RoomViewStore } from "../../../stores/RoomViewStore"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import { + OwnBeaconStore, + OwnBeaconStoreEvent, +} from "../../../stores/OwnBeaconStore"; +import { CallDurationFromEvent } from "../voip/CallDuration"; + +interface RoomCallBannerProps { + roomId: Room["roomId"]; + call: Call; +} + +const RoomCallBannerInner: React.FC = ({ + roomId, + call, +}) => { + const callEvent: MatrixEvent | null = (call as ElementCall)?.groupCall; + + const connect = useCallback( + (ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + view_call: true, + metricsTrigger: undefined, + }); + }, + [roomId], + ); + + const onClick = useCallback(() => { + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: undefined, + event_id: callEvent.getId(), + scroll_into_view: true, + highlighted: true, + }); + }, [callEvent, roomId]); + + return ( +
+
+ { _t("Video call") } + +
+ + + { _t("Join") } + +
+ ); +}; + +interface Props { + roomId: Room["roomId"]; +} + +const RoomCallBanner: React.FC = ({ roomId }) => { + const call = useCall(roomId); + + // this section is to check if we have a live location share. If so, we dont show the call banner + const isMonitoringLiveLocation = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.MonitoringLivePosition, + () => OwnBeaconStore.instance.isMonitoringLiveLocation, + ); + + const liveBeaconIds = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.LivenessChange, + () => OwnBeaconStore.instance.getLiveBeaconIds(roomId), + ); + + if (isMonitoringLiveLocation && liveBeaconIds.length) { + return null; + } + + // Check if the call is already showing. No banner is needed in this case. + if (RoomViewStore.instance.isViewingCall()) { + return null; + } + + // Split into outer/inner to avoid watching various parts if there is no call + if (call) { + // No banner if the call is connected (or connecting/disconnecting) + if (call.connectionState !== ConnectionState.Disconnected) return null; + + return ; + } + return null; +}; + +export default RoomCallBanner; diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index f5b5c1a720..ca94ea98f3 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -141,6 +141,7 @@ const RoomLiveShareWarning: React.FC = ({ roomId }) => { ); if (!isMonitoringLiveLocation || !liveBeaconIds.length) { + // This logic is entangled with the RoomCallBanner-test's. The tests need updating if this logic changes. return null; } diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 013a992698..a1006b99c3 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -67,6 +67,7 @@ import IconizedContextMenu, { import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { CallDurationFromEvent } from "../voip/CallDuration"; import { Alignment } from "../elements/Tooltip"; +import RoomCallBanner from '../beacon/RoomCallBanner'; class DisabledWithReason { constructor(public readonly reason: string) { } @@ -733,6 +734,7 @@ export default class RoomHeader extends React.Component { { betaPill } { buttons } + ); diff --git a/test/components/views/beacon/RoomCallBanner-test.tsx b/test/components/views/beacon/RoomCallBanner-test.tsx new file mode 100644 index 0000000000..52d0ed27d3 --- /dev/null +++ b/test/components/views/beacon/RoomCallBanner-test.tsx @@ -0,0 +1,144 @@ +/* +Copyright 2022 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 React from "react"; +import { act } from "react-dom/test-utils"; +import { + Room, + PendingEventOrdering, + MatrixClient, + RoomMember, + RoomStateEvent, +} from "matrix-js-sdk/src/matrix"; +import { ClientWidgetApi, Widget } from "matrix-widget-api"; +import { + cleanup, + render, + screen, +} from "@testing-library/react"; +import { mocked, Mocked } from "jest-mock"; + +import { + mkRoomMember, + MockedCall, + setupAsyncStoreWithClient, + stubClient, + useMockedCalls, +} from "../../../test-utils"; +import RoomCallBanner from "../../../../src/components/views/beacon/RoomCallBanner"; +import { CallStore } from "../../../../src/stores/CallStore"; +import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { RoomViewStore } from "../../../../src/stores/RoomViewStore"; +import { ConnectionState } from "../../../../src/models/Call"; + +describe("", () => { + let client: Mocked; + let room: Room; + let alice: RoomMember; + useMockedCalls(); + + const defaultProps = { + roomId: "!1:example.org", + }; + + beforeEach(() => { + stubClient(); + + client = mocked(MatrixClientPeg.get()); + + room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + alice = mkRoomMember(room.roomId, "@alice:example.org"); + jest.spyOn(room, "getMember").mockImplementation((userId) => + userId === alice.userId ? alice : null, + ); + + client.getRoom.mockImplementation((roomId) => + roomId === room.roomId ? room : null, + ); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + setupAsyncStoreWithClient(CallStore.instance, client); + setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); + }); + + afterEach(async () => { + client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); + }); + + const renderBanner = async (props = {}): Promise => { + render(); + await act(() => Promise.resolve()); // Let effects settle + }; + + it("renders nothing when there is no call", async () => { + await renderBanner(); + const banner = await screen.queryByText("Video call"); + expect(banner).toBeFalsy(); + }); + + describe("call started", () => { + let call: MockedCall; + let widget: Widget; + + beforeEach(() => { + MockedCall.create(room, "1"); + const maybeCall = CallStore.instance.getCall(room.roomId); + if (!(maybeCall instanceof MockedCall)) {throw new Error("Failed to create call");} + call = maybeCall; + + widget = new Widget(call.widget); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => {}, + } as unknown as ClientWidgetApi); + }); + afterEach(() => { + cleanup(); // Unmount before we do any cleanup that might update the component + call.destroy(); + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + }); + + it("renders if there is a call", async () => { + await renderBanner(); + await screen.findByText("Video call"); + }); + + it("shows Join button if the user has not joined", async () => { + await renderBanner(); + await screen.findByText("Join"); + }); + + it("doesn't show banner if the call is connected", async () => { + call.setConnectionState(ConnectionState.Connected); + await renderBanner(); + const banner = await screen.queryByText("Video call"); + expect(banner).toBeFalsy(); + }); + + it("doesn't show banner if the call is shown", async () => { + jest.spyOn(RoomViewStore.instance, 'isViewingCall').mockReturnValue(true); + await renderBanner(); + const banner = await screen.queryByText("Video call"); + expect(banner).toBeFalsy(); + }); + }); + + // TODO: test clicking buttons + // TODO: add live location share warning test (should not render if there is an active live location share) +}); diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index 1268410ce4..fc0f358054 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -20,7 +20,7 @@ import type { Room } from "matrix-js-sdk/src/models/room"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { mkEvent } from "./test-utils"; -import { Call, ElementCall, JitsiCall } from "../../src/models/Call"; +import { Call, ConnectionState, ElementCall, JitsiCall } from "../../src/models/Call"; export class MockedCall extends Call { public static readonly EVENT_TYPE = "org.example.mocked_call"; @@ -61,6 +61,10 @@ export class MockedCall extends Call { })]); } + public get groupCall(): MatrixEvent { + return this.event; + } + public get participants(): Set { return super.participants; } @@ -68,6 +72,10 @@ export class MockedCall extends Call { super.participants = value; } + public setConnectionState(value: ConnectionState): void { + super.connectionState = value; + } + // No action needed for any of the following methods since this is just a mock protected getDevices(): string[] { return []; } protected async setDevices(): Promise { }