Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show a lobby screen in video rooms #8287

Merged
merged 35 commits into from Apr 20, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5e215b5
Show a lobby screen in video rooms
robintown Apr 11, 2022
5c431c7
Add connecting state
robintown Apr 11, 2022
fb36fe0
Test VideoRoomView
robintown Apr 11, 2022
9a386cf
Test VideoLobby
robintown Apr 11, 2022
1032f07
Merge branch 'develop' into video-room-lobby
robintown Apr 12, 2022
c0ae630
Get the local video stream with useAsyncMemo
robintown Apr 12, 2022
861cce5
Clean up code review nits
robintown Apr 12, 2022
256ca1d
Explicitly state what !important is overriding
robintown Apr 12, 2022
b3522b5
Use spacing variables
robintown Apr 12, 2022
76822b8
Wait for video channel messaging
robintown Apr 12, 2022
5a9cbec
Update join button copy
robintown Apr 12, 2022
c1269d9
Show frame on both the lobby and widget
robintown Apr 12, 2022
cf4f3ef
Force dark theme for video lobby
robintown Apr 12, 2022
4f71425
Wait for the widget to be ready
robintown Apr 13, 2022
56ea403
Make VideoChannelStore constructor private
robintown Apr 13, 2022
4dc71a8
Merge branch 'develop' into video-room-lobby
robintown Apr 13, 2022
8b3c114
Allow video lobby to shrink
robintown Apr 13, 2022
e0d0ac9
Add invite button to video room header
robintown Apr 13, 2022
24d612f
Show connected members on lobby screen
robintown Apr 13, 2022
0d998a7
Make avatars in video lobby clickable
robintown Apr 13, 2022
a257abf
Merge branch 'develop' into video-room-lobby
robintown Apr 13, 2022
edecb6e
Merge branch 'develop' into video-room-lobby
robintown Apr 14, 2022
95213b1
Increase video channel store timeout
robintown Apr 14, 2022
9f77b8c
Fix Jitsi Meet getting wedged on startup in Chrome and Safari
robintown Apr 14, 2022
7b38e58
Merge branch 'develop' into video-room-lobby
robintown Apr 15, 2022
f43fb08
Revert "Fix Jitsi Meet getting wedged on startup in Chrome and Safari"
robintown Apr 15, 2022
fe99a88
Disable device buttons while connecting
robintown Apr 15, 2022
9529461
Factor RoomFacePile into a separate file
robintown Apr 15, 2022
bc43dec
Fix i18n lint
robintown Apr 15, 2022
b3c2cbd
Merge branch 'develop' into video-room-lobby
robintown Apr 18, 2022
1ea8ee2
Fix switching video channels while connected
robintown Apr 18, 2022
64efeb7
Merge branch 'develop' into video-room-lobby
robintown Apr 20, 2022
2147f82
Properly limit number of connected members in face pile
robintown Apr 20, 2022
324c96a
Merge branch 'develop' into video-room-lobby
robintown Apr 20, 2022
a15cd6c
Fix CSS lint
robintown Apr 20, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion res/css/views/elements/_FacePile.scss
Expand Up @@ -20,7 +20,8 @@ limitations under the License.
flex-direction: row-reverse;
vertical-align: middle;

> .mx_FacePile_face + .mx_FacePile_face {
// Overlap the children
> * + * {
margin-right: -8px;
}

Expand Down
12 changes: 11 additions & 1 deletion res/css/views/voip/_VideoLobby.scss
Expand Up @@ -18,14 +18,24 @@ limitations under the License.
min-height: 0;
flex-grow: 1;
padding: $spacing-12;
color: $video-lobby-primary-content;
background-color: $video-lobby-background;
border-radius: 8px;

display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $spacing-40;
gap: $spacing-32;

.mx_FacePile {
width: fit-content;
margin: $spacing-8 auto 0;

.mx_FacePile_faces .mx_BaseAvatar_image {
border-color: $video-lobby-background;
}
}

.mx_VideoLobby_preview {
position: relative;
Expand Down
6 changes: 3 additions & 3 deletions src/components/structures/SpaceRoomView.tsx
Expand Up @@ -58,7 +58,7 @@ import {
} from "../../utils/space";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import FacePile from "../views/elements/FacePile";
import { RoomFacePile } from "../views/elements/FacePile";
import {
AddExistingToSpace,
defaultDmsRenderer,
Expand Down Expand Up @@ -298,7 +298,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp
</div>
}
</RoomTopic>
{ space.getJoinRule() === "public" && <FacePile room={space} /> }
{ space.getJoinRule() === "public" && <RoomFacePile room={space} /> }
<div className="mx_SpaceRoomView_preview_joinButtons">
{ joinButtons }
</div>
Expand Down Expand Up @@ -454,7 +454,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
<div className="mx_SpaceRoomView_landing_infoBar">
<SpaceInfo space={space} />
<div className="mx_SpaceRoomView_landing_infoBar_interactive">
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
<RoomFacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton }
{ settingsButton }
</div>
Expand Down
68 changes: 54 additions & 14 deletions src/components/views/elements/FacePile.tsx
Expand Up @@ -22,21 +22,62 @@ import { sortBy } from "lodash";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import TooltipTarget from "../elements/TooltipTarget";
import TextWithTooltip from "../elements/TextWithTooltip";
import { useRoomMembers } from "../../../hooks/useRoomMembers";
import MatrixClientContext from "../../../contexts/MatrixClientContext";

interface IProps extends HTMLAttributes<HTMLSpanElement> {
members: RoomMember[];
faceSize: number;
overflow: boolean;
tooltip?: ReactNode;
children?: ReactNode;
}

const FacePile: FC<IProps> = ({ members, faceSize, overflow, tooltip, children, ...props }) => {
const faces = members.map(
tooltip ?
m => <MemberAvatar key={m.userId} member={m} width={faceSize} height={faceSize} /> :
m => <TooltipTarget key={m.userId} label={m.name}>
<MemberAvatar member={m} width={faceSize} height={faceSize} />
</TooltipTarget>,
);

const pileContents = <>
{ overflow ? <span className="mx_FacePile_more" /> : null }
{ faces }
</>;

return <div {...props} className="mx_FacePile">
{ tooltip ? (
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
{ pileContents }
</TextWithTooltip>
) : (
<div className="mx_FacePile_faces">
{ pileContents }
</div>
) }
{ children }
</div>;
};

export default FacePile;

const DEFAULT_NUM_FACES = 5;

interface IProps extends HTMLAttributes<HTMLSpanElement> {
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;

interface IRoomProps extends HTMLAttributes<HTMLSpanElement> {
room: Room;
onlyKnownUsers?: boolean;
numShown?: number;
}

const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;

const FacePile: FC<IProps> = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }) => {
export const RoomFacePile: FC<IRoomProps> = (
robintown marked this conversation as resolved.
Show resolved Hide resolved
{ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props },
) => {
const cli = useContext(MatrixClientContext);
const isJoined = room.getMyMembership() === "join";
let members = useRoomMembers(room);
Expand All @@ -57,7 +98,7 @@ const FacePile: FC<IProps> = ({ room, onlyKnownUsers = true, numShown = DEFAULT_

// We reverse the order of the shown faces in CSS to simplify their visual overlap,
// reverse members in tooltip order to make the order between the two match up.
const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", ");
const commaSeparatedMembers = shownMembers.map(m => m.name).reverse().join(", ");

let tooltip: ReactNode;
if (props.onClick) {
Expand Down Expand Up @@ -90,16 +131,15 @@ const FacePile: FC<IProps> = ({ room, onlyKnownUsers = true, numShown = DEFAULT_
}
}

return <div {...props} className="mx_FacePile">
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
{ members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
{ shownMembers.map(m =>
<MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" />) }
</TextWithTooltip>
return <FacePile
members={shownMembers}
faceSize={28}
overflow={members.length > numShown}
tooltip={tooltip}
{...props}
>
{ onlyKnownUsers && <span className="mx_FacePile_summary">
{ _t("%(count)s people you know have already joined", { count: members.length }) }
</span> }
</div>;
</FacePile>;
};

export default FacePile;
17 changes: 17 additions & 0 deletions src/components/views/voip/VideoLobby.tsx
Expand Up @@ -22,6 +22,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { useStateToggle } from "../../../hooks/useStateToggle";
import { useConnectedMembers } from "../../../utils/VideoChannelUtils";
import VideoChannelStore from "../../../stores/VideoChannelStore";
import IconizedContextMenu, {
IconizedContextMenuOption,
Expand All @@ -31,6 +32,7 @@ import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures
import { Alignment } from "../elements/Tooltip";
import AccessibleButton from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import FacePile from "../elements/FacePile";
import MemberAvatar from "../avatars/MemberAvatar";

interface IDeviceButtonProps {
Expand Down Expand Up @@ -100,9 +102,12 @@ const DeviceButton: FC<IDeviceButtonProps> = ({
</div>;
};

const MAX_FACES = 8;

const VideoLobby: FC<{ room: Room }> = ({ room }) => {
const [connecting, setConnecting] = useState(false);
const me = useMemo(() => room.getMember(room.myUserId), [room]);
const connectedMembers = useConnectedMembers(room.currentState);
const videoRef = useRef<HTMLVideoElement>();

const devices = useAsyncMemo(async () => {
Expand Down Expand Up @@ -163,7 +168,19 @@ const VideoLobby: FC<{ room: Room }> = ({ room }) => {
}
};

let facePile;
if (connectedMembers.length) {
const shownMembers = connectedMembers.slice(0, MAX_FACES);
const overflow = connectedMembers.length > shownMembers.length;

facePile = <div className="mx_VideoLobby_connectedMembers">
{ _t("%(count)s people connected", { count: connectedMembers.length }) }
<FacePile members={connectedMembers} faceSize={24} overflow={overflow} />
</div>;
}

return <div className="mx_VideoLobby">
{ facePile }
<div className="mx_VideoLobby_preview">
<MemberAvatar key={me.userId} member={me} width={200} height={200} resizeMethod="scale" />
<video
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/strings/en_EN.json
Expand Up @@ -1009,6 +1009,8 @@
"Your camera is turned off": "Your camera is turned off",
"Your camera is still enabled": "Your camera is still enabled",
"Dial": "Dial",
"%(count)s people connected|other": "%(count)s people connected",
"%(count)s people connected|one": "%(count)s person connected",
"Audio devices": "Audio devices",
"Mute microphone": "Mute microphone",
"Unmute microphone": "Unmute microphone",
Expand Down Expand Up @@ -1761,6 +1763,7 @@
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search",
"Invite": "Invite",
"Start new chat": "Start new chat",
"Invite to space": "Invite to space",
"You do not have permissions to invite people to this space": "You do not have permissions to invite people to this space",
Expand All @@ -1785,7 +1788,6 @@
"Explore all public rooms": "Explore all public rooms",
"%(count)s results|other": "%(count)s results",
"%(count)s results|one": "%(count)s result",
"Invite": "Invite",
"Add space": "Add space",
"You do not have permissions to add spaces to this space": "You do not have permissions to add spaces to this space",
"Join public room": "Join public room",
Expand Down
13 changes: 12 additions & 1 deletion src/utils/VideoChannelUtils.ts
Expand Up @@ -14,10 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { useState } from "react";
import { throttle } from "lodash";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { RoomState, RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";

import { useTypedEventEmitter } from "../hooks/useEventEmitter";
import WidgetStore, { IApp } from "../stores/WidgetStore";
import { WidgetType } from "../widgets/WidgetType";
import WidgetUtils from "./WidgetUtils";
Expand Down Expand Up @@ -45,3 +48,11 @@ export const getConnectedMembers = (state: RoomState): RoomMember[] =>
.filter(e => e.getContent<IVideoChannelMemberContent>().devices?.length)
.map(e => state.getMember(e.getStateKey()))
.filter(member => member.membership === "join");

export const useConnectedMembers = (state: RoomState, throttleMs = 100) => {
const [members, setMembers] = useState<RoomMember[]>(getConnectedMembers(state));
useTypedEventEmitter(state, RoomStateEvent.Update, throttle(() => {
setMembers(getConnectedMembers(state));
}, throttleMs, { leading: true, trailing: true }));
return members;
};
13 changes: 1 addition & 12 deletions test/components/views/rooms/RoomTile-test.tsx
Expand Up @@ -18,34 +18,23 @@ import React from "react";
import { mount } from "enzyme";
import { act } from "react-dom/test-utils";
import { mocked } from "jest-mock";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";

import {
stubClient,
mockStateEventImplementation,
mkRoom,
mkEvent,
mkVideoChannelMember,
stubVideoChannelStore,
} from "../../../test-utils";
import RoomTile from "../../../../src/components/views/rooms/RoomTile";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { DefaultTagID } from "../../../../src/stores/room-list/models";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { VIDEO_CHANNEL_MEMBER } from "../../../../src/utils/VideoChannelUtils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import PlatformPeg from "../../../../src/PlatformPeg";
import BasePlatform from "../../../../src/BasePlatform";

const mkVideoChannelMember = (userId: string, devices: string[]): MatrixEvent => mkEvent({
event: true,
type: VIDEO_CHANNEL_MEMBER,
room: "!1:example.org",
user: userId,
skey: userId,
content: { devices },
});

describe("RoomTile", () => {
jest.spyOn(PlatformPeg, 'get')
.mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform);
Expand Down
55 changes: 53 additions & 2 deletions test/components/views/voip/VideoLobby-test.tsx
Expand Up @@ -18,9 +18,18 @@ import React from "react";
import { mount } from "enzyme";
import { act } from "react-dom/test-utils";
import { mocked } from "jest-mock";

import { stubClient, stubVideoChannelStore, mkRoom } from "../../../test-utils";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";

import {
stubClient,
stubVideoChannelStore,
mkRoom,
mkVideoChannelMember,
mockStateEventImplementation,
} from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import FacePile from "../../../../src/components/views/elements/FacePile";
import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar";
import VideoLobby from "../../../../src/components/views/voip/VideoLobby";

describe("VideoLobby", () => {
Expand All @@ -46,6 +55,48 @@ describe("VideoLobby", () => {
jest.clearAllMocks();
});

describe("connected members", () => {
it("hides when no one is connected", async () => {
const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();

expect(lobby.find(".mx_VideoLobby_connectedMembers").exists()).toEqual(false);
});

it("is shown when someone is connected", async () => {
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([
// A user connected from 2 devices
mkVideoChannelMember("@alice:example.org", ["device 1", "device 2"]),
// A disconnected user
mkVideoChannelMember("@bob:example.org", []),
// A user that claims to have a connected device, but has left the room
mkVideoChannelMember("@chris:example.org", ["device 1"]),
]));

mocked(room.currentState).getMember.mockImplementation(userId => ({
userId,
membership: userId === "@chris:example.org" ? "leave" : "join",
name: userId,
rawDisplayName: userId,
roomId: "!1:example.org",
getAvatarUrl: () => {},
getMxcAvatarUrl: () => {},
}) as unknown as RoomMember);

const lobby = mount(<VideoLobby room={room} />);
// Wait for state to settle
await act(() => Promise.resolve());
lobby.update();

// Only Alice should display as connected
const memberText = lobby.find(".mx_VideoLobby_connectedMembers").children().at(0).text();
expect(memberText).toEqual("1 person connected");
expect(lobby.find(FacePile).find(MemberAvatar).props().member.userId).toEqual("@alice:example.org");
});
});

describe("device buttons", () => {
it("hides when no devices are available", async () => {
const lobby = mount(<VideoLobby room={room} />);
Expand Down
12 changes: 12 additions & 0 deletions test/test-utils/video.ts
Expand Up @@ -15,7 +15,10 @@ limitations under the License.
*/

import { EventEmitter } from "events";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";

import { mkEvent } from "./test-utils";
import { VIDEO_CHANNEL_MEMBER } from "../../src/utils/VideoChannelUtils";
import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../../src/stores/VideoChannelStore";

class StubVideoChannelStore extends EventEmitter {
Expand Down Expand Up @@ -47,3 +50,12 @@ export const stubVideoChannelStore = (): StubVideoChannelStore => {
jest.spyOn(VideoChannelStore, "instance", "get").mockReturnValue(store as unknown as VideoChannelStore);
return store;
};

export const mkVideoChannelMember = (userId: string, devices: string[]): MatrixEvent => mkEvent({
event: true,
type: VIDEO_CHANNEL_MEMBER,
room: "!1:example.org",
user: userId,
skey: userId,
content: { devices },
});