Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 372720e

Browse files
toger5robintown
andauthored
Room call banner (#9378)
Signed-off-by: Timo K <[email protected]> Co-authored-by: Timo K <[email protected]> Co-authored-by: Robin <[email protected]>
1 parent 13db1b1 commit 372720e

File tree

7 files changed

+342
-1
lines changed

7 files changed

+342
-1
lines changed

res/css/_components.pcss

+1
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@
281281
@import "./views/rooms/_ReplyPreview.pcss";
282282
@import "./views/rooms/_ReplyTile.pcss";
283283
@import "./views/rooms/_RoomBreadcrumbs.pcss";
284+
@import "./views/rooms/_RoomCallBanner.pcss";
284285
@import "./views/rooms/_RoomHeader.pcss";
285286
@import "./views/rooms/_RoomInfoLine.pcss";
286287
@import "./views/rooms/_RoomList.pcss";
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
.mx_RoomCallBanner {
18+
width: 100%;
19+
display: flex;
20+
flex-direction: row;
21+
align-items: center;
22+
23+
box-sizing: border-box;
24+
padding: $spacing-12 $spacing-16;
25+
26+
color: $primary-content;
27+
background-color: $system;
28+
cursor: pointer;
29+
}
30+
31+
.mx_RoomCallBanner_text {
32+
display: flex;
33+
flex: 1;
34+
align-items: center;
35+
}
36+
37+
.mx_RoomCallBanner_label {
38+
color: $primary-content;
39+
font-weight: 600;
40+
padding-right: $spacing-8;
41+
42+
&::before {
43+
display: inline-block;
44+
vertical-align: text-top;
45+
content: "";
46+
background-color: $secondary-content;
47+
mask-size: 16px;
48+
width: 16px;
49+
height: 16px;
50+
margin-right: 4px;
51+
bottom: 2px;
52+
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { useCallback } from "react";
18+
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
19+
20+
import { _t } from "../../../languageHandler";
21+
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
22+
import dispatcher, { defaultDispatcher } from "../../../dispatcher/dispatcher";
23+
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
24+
import { Action } from "../../../dispatcher/actions";
25+
import { Call, ConnectionState, ElementCall } from "../../../models/Call";
26+
import { useCall } from "../../../hooks/useCall";
27+
import { RoomViewStore } from "../../../stores/RoomViewStore";
28+
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
29+
import {
30+
OwnBeaconStore,
31+
OwnBeaconStoreEvent,
32+
} from "../../../stores/OwnBeaconStore";
33+
import { CallDurationFromEvent } from "../voip/CallDuration";
34+
35+
interface RoomCallBannerProps {
36+
roomId: Room["roomId"];
37+
call: Call;
38+
}
39+
40+
const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({
41+
roomId,
42+
call,
43+
}) => {
44+
const callEvent: MatrixEvent | null = (call as ElementCall)?.groupCall;
45+
46+
const connect = useCallback(
47+
(ev: ButtonEvent) => {
48+
ev.preventDefault();
49+
defaultDispatcher.dispatch<ViewRoomPayload>({
50+
action: Action.ViewRoom,
51+
room_id: roomId,
52+
view_call: true,
53+
metricsTrigger: undefined,
54+
});
55+
},
56+
[roomId],
57+
);
58+
59+
const onClick = useCallback(() => {
60+
dispatcher.dispatch<ViewRoomPayload>({
61+
action: Action.ViewRoom,
62+
room_id: roomId,
63+
metricsTrigger: undefined,
64+
event_id: callEvent.getId(),
65+
scroll_into_view: true,
66+
highlighted: true,
67+
});
68+
}, [callEvent, roomId]);
69+
70+
return (
71+
<div
72+
className="mx_RoomCallBanner"
73+
onClick={onClick}
74+
>
75+
<div className="mx_RoomCallBanner_text">
76+
<span className="mx_RoomCallBanner_label">{ _t("Video call") }</span>
77+
<CallDurationFromEvent mxEvent={callEvent} />
78+
</div>
79+
80+
<AccessibleButton
81+
onClick={connect}
82+
kind="primary"
83+
element="button"
84+
disabled={false}
85+
>
86+
{ _t("Join") }
87+
</AccessibleButton>
88+
</div>
89+
);
90+
};
91+
92+
interface Props {
93+
roomId: Room["roomId"];
94+
}
95+
96+
const RoomCallBanner: React.FC<Props> = ({ roomId }) => {
97+
const call = useCall(roomId);
98+
99+
// this section is to check if we have a live location share. If so, we dont show the call banner
100+
const isMonitoringLiveLocation = useEventEmitterState(
101+
OwnBeaconStore.instance,
102+
OwnBeaconStoreEvent.MonitoringLivePosition,
103+
() => OwnBeaconStore.instance.isMonitoringLiveLocation,
104+
);
105+
106+
const liveBeaconIds = useEventEmitterState(
107+
OwnBeaconStore.instance,
108+
OwnBeaconStoreEvent.LivenessChange,
109+
() => OwnBeaconStore.instance.getLiveBeaconIds(roomId),
110+
);
111+
112+
if (isMonitoringLiveLocation && liveBeaconIds.length) {
113+
return null;
114+
}
115+
116+
// Check if the call is already showing. No banner is needed in this case.
117+
if (RoomViewStore.instance.isViewingCall()) {
118+
return null;
119+
}
120+
121+
// Split into outer/inner to avoid watching various parts if there is no call
122+
if (call) {
123+
// No banner if the call is connected (or connecting/disconnecting)
124+
if (call.connectionState !== ConnectionState.Disconnected) return null;
125+
126+
return <RoomCallBannerInner call={call} roomId={roomId} />;
127+
}
128+
return null;
129+
};
130+
131+
export default RoomCallBanner;

src/components/views/beacon/RoomLiveShareWarning.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ const RoomLiveShareWarning: React.FC<Props> = ({ roomId }) => {
141141
);
142142

143143
if (!isMonitoringLiveLocation || !liveBeaconIds.length) {
144+
// This logic is entangled with the RoomCallBanner-test's. The tests need updating if this logic changes.
144145
return null;
145146
}
146147

src/components/views/rooms/RoomHeader.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import IconizedContextMenu, {
6767
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
6868
import { CallDurationFromEvent } from "../voip/CallDuration";
6969
import { Alignment } from "../elements/Tooltip";
70+
import RoomCallBanner from '../beacon/RoomCallBanner';
7071

7172
class DisabledWithReason {
7273
constructor(public readonly reason: string) { }
@@ -733,6 +734,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
733734
{ betaPill }
734735
{ buttons }
735736
</div>
737+
<RoomCallBanner roomId={this.props.room.roomId} />
736738
<RoomLiveShareWarning roomId={this.props.room.roomId} />
737739
</header>
738740
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import { act } from "react-dom/test-utils";
19+
import {
20+
Room,
21+
PendingEventOrdering,
22+
MatrixClient,
23+
RoomMember,
24+
RoomStateEvent,
25+
} from "matrix-js-sdk/src/matrix";
26+
import { ClientWidgetApi, Widget } from "matrix-widget-api";
27+
import {
28+
cleanup,
29+
render,
30+
screen,
31+
} from "@testing-library/react";
32+
import { mocked, Mocked } from "jest-mock";
33+
34+
import {
35+
mkRoomMember,
36+
MockedCall,
37+
setupAsyncStoreWithClient,
38+
stubClient,
39+
useMockedCalls,
40+
} from "../../../test-utils";
41+
import RoomCallBanner from "../../../../src/components/views/beacon/RoomCallBanner";
42+
import { CallStore } from "../../../../src/stores/CallStore";
43+
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
44+
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
45+
import { RoomViewStore } from "../../../../src/stores/RoomViewStore";
46+
import { ConnectionState } from "../../../../src/models/Call";
47+
48+
describe("<RoomCallBanner />", () => {
49+
let client: Mocked<MatrixClient>;
50+
let room: Room;
51+
let alice: RoomMember;
52+
useMockedCalls();
53+
54+
const defaultProps = {
55+
roomId: "!1:example.org",
56+
};
57+
58+
beforeEach(() => {
59+
stubClient();
60+
61+
client = mocked(MatrixClientPeg.get());
62+
63+
room = new Room("!1:example.org", client, "@alice:example.org", {
64+
pendingEventOrdering: PendingEventOrdering.Detached,
65+
});
66+
alice = mkRoomMember(room.roomId, "@alice:example.org");
67+
jest.spyOn(room, "getMember").mockImplementation((userId) =>
68+
userId === alice.userId ? alice : null,
69+
);
70+
71+
client.getRoom.mockImplementation((roomId) =>
72+
roomId === room.roomId ? room : null,
73+
);
74+
client.getRooms.mockReturnValue([room]);
75+
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
76+
77+
setupAsyncStoreWithClient(CallStore.instance, client);
78+
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
79+
});
80+
81+
afterEach(async () => {
82+
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
83+
});
84+
85+
const renderBanner = async (props = {}): Promise<void> => {
86+
render(<RoomCallBanner {...defaultProps} {...props} />);
87+
await act(() => Promise.resolve()); // Let effects settle
88+
};
89+
90+
it("renders nothing when there is no call", async () => {
91+
await renderBanner();
92+
const banner = await screen.queryByText("Video call");
93+
expect(banner).toBeFalsy();
94+
});
95+
96+
describe("call started", () => {
97+
let call: MockedCall;
98+
let widget: Widget;
99+
100+
beforeEach(() => {
101+
MockedCall.create(room, "1");
102+
const maybeCall = CallStore.instance.getCall(room.roomId);
103+
if (!(maybeCall instanceof MockedCall)) {throw new Error("Failed to create call");}
104+
call = maybeCall;
105+
106+
widget = new Widget(call.widget);
107+
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
108+
stop: () => {},
109+
} as unknown as ClientWidgetApi);
110+
});
111+
afterEach(() => {
112+
cleanup(); // Unmount before we do any cleanup that might update the component
113+
call.destroy();
114+
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
115+
});
116+
117+
it("renders if there is a call", async () => {
118+
await renderBanner();
119+
await screen.findByText("Video call");
120+
});
121+
122+
it("shows Join button if the user has not joined", async () => {
123+
await renderBanner();
124+
await screen.findByText("Join");
125+
});
126+
127+
it("doesn't show banner if the call is connected", async () => {
128+
call.setConnectionState(ConnectionState.Connected);
129+
await renderBanner();
130+
const banner = await screen.queryByText("Video call");
131+
expect(banner).toBeFalsy();
132+
});
133+
134+
it("doesn't show banner if the call is shown", async () => {
135+
jest.spyOn(RoomViewStore.instance, 'isViewingCall').mockReturnValue(true);
136+
await renderBanner();
137+
const banner = await screen.queryByText("Video call");
138+
expect(banner).toBeFalsy();
139+
});
140+
});
141+
142+
// TODO: test clicking buttons
143+
// TODO: add live location share warning test (should not render if there is an active live location share)
144+
});

test/test-utils/call.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type { Room } from "matrix-js-sdk/src/models/room";
2020
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
2121
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
2222
import { mkEvent } from "./test-utils";
23-
import { Call, ElementCall, JitsiCall } from "../../src/models/Call";
23+
import { Call, ConnectionState, ElementCall, JitsiCall } from "../../src/models/Call";
2424

2525
export class MockedCall extends Call {
2626
public static readonly EVENT_TYPE = "org.example.mocked_call";
@@ -61,13 +61,21 @@ export class MockedCall extends Call {
6161
})]);
6262
}
6363

64+
public get groupCall(): MatrixEvent {
65+
return this.event;
66+
}
67+
6468
public get participants(): Set<RoomMember> {
6569
return super.participants;
6670
}
6771
public set participants(value: Set<RoomMember>) {
6872
super.participants = value;
6973
}
7074

75+
public setConnectionState(value: ConnectionState): void {
76+
super.connectionState = value;
77+
}
78+
7179
// No action needed for any of the following methods since this is just a mock
7280
protected getDevices(): string[] { return []; }
7381
protected async setDevices(): Promise<void> { }

0 commit comments

Comments
 (0)