Iterate video room designs in labs (#8499)

* Remove blank header from video room view frame

* Add video room option to space context menu

* Remove duplicate tooltips from face piles

* Factor RoomInfoLine out of SpaceRoomView

* Factor RoomPreviewCard out of SpaceRoomView

* Adapt RoomPreviewCard for video rooms

* "New video room" → "Video room"

* Add comment about unused cases in RoomPreviewCard

* Make widgets in video rooms mutable again to de-risk future upgrades

* Ensure that the video channel exists when mounting VideoRoomView
This commit is contained in:
Robin 2022-05-04 17:02:06 -04:00 committed by GitHub
parent cda31136b9
commit 658ff4dfe6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 617 additions and 434 deletions

View file

@ -256,9 +256,11 @@
@import "./views/rooms/_ReplyTile.scss";
@import "./views/rooms/_RoomBreadcrumbs.scss";
@import "./views/rooms/_RoomHeader.scss";
@import "./views/rooms/_RoomInfoLine.scss";
@import "./views/rooms/_RoomList.scss";
@import "./views/rooms/_RoomListHeader.scss";
@import "./views/rooms/_RoomPreviewBar.scss";
@import "./views/rooms/_RoomPreviewCard.scss";
@import "./views/rooms/_RoomSublist.scss";
@import "./views/rooms/_RoomTile.scss";
@import "./views/rooms/_RoomUpgradeWarningBar.scss";

View file

@ -137,124 +137,6 @@ $SpaceRoomViewInnerWidth: 428px;
}
}
.mx_SpaceRoomView_preview,
.mx_SpaceRoomView_landing {
.mx_SpaceRoomView_info_memberCount {
color: inherit;
position: relative;
padding: 0 0 0 16px;
font-size: $font-15px;
display: inline; // cancel inline-flex
&::before {
content: "·"; // visual separator
position: absolute;
left: 6px;
}
}
}
.mx_SpaceRoomView_preview {
padding: 32px 24px !important; // override default padding from above
margin: auto;
max-width: 480px;
box-sizing: border-box;
box-shadow: 2px 15px 30px $dialog-shadow-color;
border-radius: 8px;
position: relative;
// XXX remove this when spaces leaves Beta
.mx_BetaCard_betaPill {
position: absolute;
right: 24px;
top: 32px;
}
// XXX remove this when spaces leaves Beta
.mx_SpaceRoomView_preview_spaceBetaPrompt {
font-weight: $font-semi-bold;
font-size: $font-14px;
line-height: $font-24px;
color: $primary-content;
margin-top: 24px;
position: relative;
padding-left: 24px;
.mx_AccessibleButton_kind_link {
display: inline;
padding: 0;
font-size: inherit;
line-height: inherit;
}
&::before {
content: "";
position: absolute;
height: $font-24px;
width: 20px;
left: 0;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
background-color: $secondary-content;
}
}
.mx_SpaceRoomView_preview_inviter {
display: flex;
align-items: center;
margin-bottom: 20px;
font-size: $font-15px;
> div {
margin-left: 8px;
.mx_SpaceRoomView_preview_inviter_name {
line-height: $font-18px;
}
.mx_SpaceRoomView_preview_inviter_mxid {
line-height: $font-24px;
color: $secondary-content;
}
}
}
> .mx_RoomAvatar_isSpaceRoom {
&.mx_BaseAvatar_image, .mx_BaseAvatar_image {
border-radius: 12px;
}
}
h1.mx_SpaceRoomView_preview_name {
margin: 20px 0 !important; // override default margin from above
}
.mx_SpaceRoomView_preview_topic {
font-size: $font-14px;
line-height: $font-22px;
color: $secondary-content;
margin: 20px 0;
max-height: 160px;
overflow-y: auto;
}
.mx_SpaceRoomView_preview_joinButtons {
margin-top: 20px;
.mx_AccessibleButton {
width: 200px;
box-sizing: border-box;
padding: 14px 0;
& + .mx_AccessibleButton {
margin-left: 20px;
}
}
}
}
.mx_SpaceRoomView_landing {
display: flex;
flex-direction: column;
@ -314,40 +196,6 @@ $SpaceRoomViewInnerWidth: 428px;
flex-wrap: wrap;
line-height: $font-24px;
.mx_SpaceRoomView_info {
color: $secondary-content;
font-size: $font-15px;
display: inline-block;
.mx_SpaceRoomView_info_public,
.mx_SpaceRoomView_info_private {
padding-left: 20px;
position: relative;
&::before {
position: absolute;
content: "";
width: 20px;
height: 20px;
top: 0;
left: -2px;
mask-position: center;
mask-repeat: no-repeat;
background-color: $tertiary-content;
}
}
.mx_SpaceRoomView_info_public::before {
mask-size: 12px;
mask-image: url("$(res)/img/globe.svg");
}
.mx_SpaceRoomView_info_private::before {
mask-size: 14px;
mask-image: url("$(res)/img/element-icons/lock.svg");
}
}
.mx_SpaceRoomView_landing_infoBar_interactive {
display: flex;
flex-wrap: wrap;

View file

@ -24,8 +24,7 @@ limitations under the License.
margin-right: calc($container-gap-width / 2);
background-color: $header-panel-bg-color;
padding-top: 33px; // to match the right panel chat heading
border: 8px solid $header-panel-bg-color;
padding: 8px;
border-radius: 8px;
.mx_AppTile {

View file

@ -15,6 +15,9 @@ limitations under the License.
*/
.mx_FacePile {
display: flex;
align-items: center;
.mx_FacePile_faces {
display: inline-flex;
flex-direction: row-reverse;

View file

@ -0,0 +1,58 @@
/*
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_RoomInfoLine {
color: $secondary-content;
display: inline-block;
&::before {
content: "";
display: inline-block;
height: 1.2em;
mask-position-y: center;
mask-repeat: no-repeat;
background-color: $tertiary-content;
vertical-align: text-bottom;
margin-right: 6px;
}
&.mx_RoomInfoLine_public::before {
width: 12px;
mask-size: 12px;
mask-image: url("$(res)/img/globe.svg");
}
&.mx_RoomInfoLine_private::before {
width: 14px;
mask-size: 14px;
mask-image: url("$(res)/img/element-icons/lock.svg");
}
&.mx_RoomInfoLine_video::before {
width: 16px;
mask-size: 16px;
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
}
.mx_RoomInfoLine_members {
color: inherit;
&::before {
content: "·"; // visual separator
margin: 0 6px;
}
}
}

View file

@ -0,0 +1,136 @@
/*
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_RoomPreviewCard {
padding: $spacing-32 $spacing-24 !important; // Override SpaceRoomView's default padding
margin: auto;
flex-grow: 1;
max-width: 480px;
box-sizing: border-box;
background-color: $system;
border-radius: 8px;
position: relative;
font-size: $font-14px;
.mx_RoomPreviewCard_notice {
font-weight: $font-semi-bold;
line-height: $font-24px;
color: $primary-content;
margin-top: $spacing-24;
position: relative;
padding-left: calc(20px + $spacing-8);
.mx_AccessibleButton_kind_link {
display: inline;
padding: 0;
font-size: inherit;
line-height: inherit;
}
&::before {
content: "";
position: absolute;
height: $font-24px;
width: 20px;
left: 0;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
background-color: $secondary-content;
}
}
.mx_RoomPreviewCard_inviter {
display: flex;
align-items: center;
margin-bottom: $spacing-20;
font-size: $font-15px;
> div {
margin-left: $spacing-8;
.mx_RoomPreviewCard_inviter_name {
line-height: $font-18px;
}
.mx_RoomPreviewCard_inviter_mxid {
color: $secondary-content;
}
}
}
.mx_RoomPreviewCard_avatar {
display: flex;
align-items: center;
.mx_RoomAvatar_isSpaceRoom {
&.mx_BaseAvatar_image, .mx_BaseAvatar_image {
border-radius: 12px;
}
}
.mx_RoomPreviewCard_video {
width: 50px;
height: 50px;
border-radius: calc((50px + 2 * 3px) / 2);
background-color: $accent;
border: 3px solid $system;
position: relative;
left: calc(-50px / 4 - 3px);
&::before {
content: "";
background-color: $button-primary-fg-color;
position: absolute;
width: 50px;
height: 50px;
mask-size: 22px;
mask-position: center;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
}
}
h1.mx_RoomPreviewCard_name {
margin: $spacing-16 0 !important; // Override SpaceRoomView's default margins
}
.mx_RoomPreviewCard_topic {
line-height: $font-22px;
margin-top: $spacing-16;
max-height: 160px;
overflow-y: auto;
}
.mx_FacePile {
margin-top: $spacing-20;
}
.mx_RoomPreviewCard_joinButtons {
margin-top: $spacing-20;
display: flex;
gap: $spacing-20;
.mx_AccessibleButton {
max-width: 200px;
padding: 14px 0;
flex-grow: 1;
}
}
}

View file

@ -65,6 +65,7 @@ import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
import SearchBar, { SearchScope } from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import AuxPanel from "../views/rooms/AuxPanel";
@ -1831,6 +1832,21 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
const myMembership = this.state.room.getMyMembership();
if (
this.state.room.isElementVideoRoom() &&
!(SettingsStore.getValue("feature_video_rooms") && myMembership === "join")
) {
return <ErrorBoundary>
<div className="mx_MainSplit">
<RoomPreviewCard
room={this.state.room}
onJoinButtonClicked={this.onJoinButtonClicked}
onRejectButtonClicked={this.onRejectButtonClicked}
/>
</div>;
</ErrorBoundary>;
}
// SpaceRoomView handles invites itself
if (myMembership === "invite" && !this.state.room.isSpaceRoom()) {
if (this.state.joining || this.state.rejecting) {

View file

@ -26,13 +26,10 @@ import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import RoomName from "../views/elements/RoomName";
import RoomTopic from "../views/elements/RoomTopic";
import InlineSpinner from "../views/elements/InlineSpinner";
import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite";
import { useRoomMembers } from "../../hooks/useRoomMembers";
import { useFeatureEnabled } from "../../hooks/useSettings";
import createRoom, { IOpts } from "../../createRoom";
import Field from "../views/elements/Field";
import { useTypedEventEmitter } from "../../hooks/useEventEmitter";
import withValidation from "../views/elements/Validation";
import * as Email from "../../email";
import defaultDispatcher from "../../dispatcher/dispatcher";
@ -56,7 +53,6 @@ import {
showSpaceSettings,
} from "../../utils/space";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import RoomFacePile from "../views/elements/RoomFacePile";
import {
AddExistingToSpace,
@ -70,11 +66,10 @@ import IconizedContextMenu, {
} from "../views/context_menus/IconizedContextMenu";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { BetaPill } from "../views/beta/BetaCard";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import { useDispatcher } from "../../hooks/useDispatcher";
import { useRoomState } from "../../hooks/useRoomState";
import RoomInfoLine from "../views/rooms/RoomInfoLine";
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
import { useMyRoomMembership } from "../../hooks/useRoomMembers";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
@ -106,205 +101,6 @@ enum Phase {
PrivateExistingRooms,
}
const RoomMemberCount = ({ room, children }) => {
const members = useRoomMembers(room);
const count = members.length;
if (children) return children(count);
return count;
};
const useMyRoomMembership = (room: Room) => {
const [membership, setMembership] = useState(room.getMyMembership());
useTypedEventEmitter(room, RoomEvent.MyMembership, () => {
setMembership(room.getMyMembership());
});
return membership;
};
const SpaceInfo = ({ space }: { space: Room }) => {
// summary will begin as undefined whilst loading and go null if it fails to load or we are not invited.
const summary = useAsyncMemo(async () => {
if (space.getMyMembership() !== "invite") return null;
try {
return space.client.getRoomSummary(space.roomId);
} catch (e) {
return null;
}
}, [space]);
const joinRule = useRoomState(space, state => state.getJoinRule());
const membership = useMyRoomMembership(space);
let visibilitySection;
if (joinRule === JoinRule.Public) {
visibilitySection = <span className="mx_SpaceRoomView_info_public">
{ _t("Public space") }
</span>;
} else {
visibilitySection = <span className="mx_SpaceRoomView_info_private">
{ _t("Private space") }
</span>;
}
let memberSection;
if (membership === "invite" && summary) {
// Don't trust local state and instead use the summary API
memberSection = <span className="mx_SpaceRoomView_info_memberCount">
{ _t("%(count)s members", { count: summary.num_joined_members }) }
</span>;
} else if (summary !== undefined) { // summary is not still loading
memberSection = <RoomMemberCount room={space}>
{ (count) => count > 0 ? (
<AccessibleButton
kind="link"
className="mx_SpaceRoomView_info_memberCount"
onClick={() => {
RightPanelStore.instance.setCard({ phase: RightPanelPhases.SpaceMemberList });
}}
>
{ _t("%(count)s members", { count }) }
</AccessibleButton>
) : null }
</RoomMemberCount>;
}
return <div className="mx_SpaceRoomView_info">
{ visibilitySection }
{ memberSection }
</div>;
};
interface ISpacePreviewProps {
space: Room;
onJoinButtonClicked(): void;
onRejectButtonClicked(): void;
}
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
useDispatcher(defaultDispatcher, payload => {
if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) {
setBusy(false); // stop the spinner, join failed
}
});
const [busy, setBusy] = useState(false);
const joinRule = useRoomState(space, state => state.getJoinRule());
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
&& joinRule !== JoinRule.Public;
let inviterSection;
let joinButtons;
if (myMembership === "join") {
// XXX remove this when spaces leaves Beta
joinButtons = (
<AccessibleButton
kind="danger_outline"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
>
{ _t("Leave") }
</AccessibleButton>
);
} else if (myMembership === "invite") {
const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender();
const inviter = inviteSender && space.getMember(inviteSender);
if (inviteSender) {
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
<div>
<div className="mx_SpaceRoomView_preview_inviter_name">
{ _t("<inviter/> invites you", {}, {
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
}) }
</div>
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
{ inviteSender }
</div> : null }
</div>
</div>;
}
joinButtons = <>
<AccessibleButton
kind="secondary"
onClick={() => {
setBusy(true);
onRejectButtonClicked();
}}
>
{ _t("Reject") }
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={() => {
setBusy(true);
onJoinButtonClicked();
}}
>
{ _t("Accept") }
</AccessibleButton>
</>;
} else {
joinButtons = (
<AccessibleButton
kind="primary"
onClick={() => {
onJoinButtonClicked();
if (!cli.isGuest()) {
// user will be shown a modal that won't fire a room join error
setBusy(true);
}
}}
disabled={cannotJoin}
>
{ _t("Join") }
</AccessibleButton>
);
}
if (busy) {
joinButtons = <InlineSpinner />;
}
let footer;
if (cannotJoin) {
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ _t("To view %(spaceName)s, you need an invite", {
spaceName: space.name,
}) }
</div>;
}
return <div className="mx_SpaceRoomView_preview">
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">
<RoomName room={space} />
</h1>
<SpaceInfo space={space} />
<RoomTopic room={space}>
{ (topic, ref) =>
<div className="mx_SpaceRoomView_preview_topic" ref={ref}>
{ topic }
</div>
}
</RoomTopic>
{ space.getJoinRule() === "public" && <RoomFacePile room={space} /> }
<div className="mx_SpaceRoomView_preview_joinButtons">
{ joinButtons }
</div>
{ footer }
</div>;
};
const SpaceLandingAddButton = ({ space }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
@ -339,7 +135,7 @@ const SpaceLandingAddButton = ({ space }) => {
}}
/>
{ videoRoomsEnabled && <IconizedContextMenuOption
label={_t("New video room")}
label={_t("Video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={async (e) => {
e.preventDefault();
@ -451,7 +247,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
</RoomName>
</div>
<div className="mx_SpaceRoomView_landing_infoBar">
<SpaceInfo space={space} />
<RoomInfoLine room={space} />
<div className="mx_SpaceRoomView_landing_infoBar_interactive">
<RoomFacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton }
@ -846,8 +642,8 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
if (this.state.myMembership === "join") {
return <SpaceLanding space={this.props.space} />;
} else {
return <SpacePreview
space={this.props.space}
return <RoomPreviewCard
room={this.props.space}
onJoinButtonClicked={this.props.onJoinButtonClicked}
onRejectButtonClicked={this.props.onRejectButtonClicked}
/>;

View file

@ -20,28 +20,47 @@ import { Room } from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitter } from "../../hooks/useEventEmitter";
import { getVideoChannel } from "../../utils/VideoChannelUtils";
import WidgetStore from "../../stores/WidgetStore";
import WidgetUtils from "../../utils/WidgetUtils";
import { addVideoChannel, getVideoChannel } from "../../utils/VideoChannelUtils";
import WidgetStore, { IApp } from "../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore";
import AppTile from "../views/elements/AppTile";
import VideoLobby from "../views/voip/VideoLobby";
const VideoRoomView: FC<{ room: Room, resizing: boolean }> = ({ room, resizing }) => {
interface IProps {
room: Room;
resizing: boolean;
}
const VideoRoomView: FC<IProps> = ({ room, resizing }) => {
const cli = useContext(MatrixClientContext);
const store = VideoChannelStore.instance;
// In case we mount before the WidgetStore knows about our Jitsi widget
const [widgetStoreReady, setWidgetStoreReady] = useState(Boolean(WidgetStore.instance.matrixClient));
const [widgetLoaded, setWidgetLoaded] = useState(false);
useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => {
if (roomId === null || roomId === room.roomId) setWidgetLoaded(true);
if (roomId === null) setWidgetStoreReady(true);
if (roomId === null || roomId === room.roomId) {
setWidgetLoaded(Boolean(getVideoChannel(room.roomId)));
}
});
const app = useMemo(() => {
const app: IApp = useMemo(() => {
if (widgetStoreReady) {
const app = getVideoChannel(room.roomId);
if (!app) logger.warn(`No video channel for room ${room.roomId}`);
if (!app) {
logger.warn(`No video channel for room ${room.roomId}`);
// Since widgets in video rooms are mutable, we'll take this opportunity to
// reinstate the Jitsi widget in case another client removed it
if (WidgetUtils.canUserModifyWidgets(room.roomId)) {
addVideoChannel(room.roomId, room.name);
}
}
return app;
}, [room, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
}
}, [room, widgetStoreReady, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId);
useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId));

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
@ -136,6 +136,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms);
const canAddVideoRooms = canAddRooms && SettingsStore.getValue("feature_video_rooms");
const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces);
let newRoomSection: JSX.Element;
@ -149,6 +150,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
onFinished();
};
const onNewVideoRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(space, RoomType.ElementVideo);
onFinished();
};
const onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -169,6 +178,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
onClick={onNewRoomClick}
/>
}
{ canAddVideoRooms &&
<IconizedContextMenuOption
data-test-id='new-video-room-option'
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Video room")}
onClick={onNewVideoRoomClick}
/>
}
{ canAddSubSpaces &&
<IconizedContextMenuOption
data-test-id='new-subspace-option'

View file

@ -31,10 +31,16 @@ interface IProps extends HTMLAttributes<HTMLSpanElement> {
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} viewUserOnClick={!props.onClick} />
tooltip
? m => <MemberAvatar key={m.userId} member={m} width={faceSize} height={faceSize} hideTitle />
: m => <TooltipTarget key={m.userId} label={m.name}>
<MemberAvatar
member={m}
width={faceSize}
height={faceSize}
viewUserOnClick={!props.onClick}
hideTitle
/>
</TooltipTarget>,
);

View file

@ -0,0 +1,86 @@
/*
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, { FC } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from "../../../languageHandler";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { useRoomState } from "../../../hooks/useRoomState";
import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers";
import AccessibleButton from "../elements/AccessibleButton";
interface IProps {
room: Room;
}
const RoomInfoLine: FC<IProps> = ({ room }) => {
// summary will begin as undefined whilst loading and go null if it fails to load or we are not invited.
const summary = useAsyncMemo(async () => {
if (room.getMyMembership() !== "invite") return null;
try {
return room.client.getRoomSummary(room.roomId);
} catch (e) {
return null;
}
}, [room]);
const joinRule = useRoomState(room, state => state.getJoinRule());
const membership = useMyRoomMembership(room);
const memberCount = useRoomMemberCount(room);
let iconClass: string;
let roomType: string;
if (room.isElementVideoRoom()) {
iconClass = "mx_RoomInfoLine_video";
roomType = _t("Video room");
} else if (joinRule === JoinRule.Public) {
iconClass = "mx_RoomInfoLine_public";
roomType = room.isSpaceRoom() ? _t("Public space") : _t("Public room");
} else {
iconClass = "mx_RoomInfoLine_private";
roomType = room.isSpaceRoom() ? _t("Private space") : _t("Private room");
}
let members: JSX.Element;
if (membership === "invite" && summary) {
// Don't trust local state and instead use the summary API
members = <span className="mx_RoomInfoLine_members">
{ _t("%(count)s members", { count: summary.num_joined_members }) }
</span>;
} else if (memberCount && summary !== undefined) { // summary is not still loading
const viewMembers = () => RightPanelStore.instance.setCard({
phase: room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList : RightPanelPhases.RoomMemberList,
});
members = <AccessibleButton
kind="link"
className="mx_RoomInfoLine_members"
onClick={viewMembers}
>
{ _t("%(count)s members", { count: memberCount }) }
</AccessibleButton>;
}
return <div className={`mx_RoomInfoLine ${iconClass}`}>
{ roomType }
{ members }
</div>;
};
export default RoomInfoLine;

View file

@ -239,7 +239,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
: _t("You do not have permissions to create new rooms in this space")}
/>
{ SettingsStore.getValue("feature_video_rooms") && <IconizedContextMenuOption
label={_t("New video room")}
label={_t("Video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
@ -283,7 +283,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
}}
/>
{ SettingsStore.getValue("feature_video_rooms") && <IconizedContextMenuOption
label={_t("New video room")}
label={_t("Video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();

View file

@ -222,7 +222,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
/>
{ videoRoomsEnabled && <IconizedContextMenuOption
iconClassName="mx_RoomListHeader_iconNewVideoRoom"
label={_t("New video room")}
label={_t("Video room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -313,7 +313,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
}}
/>
{ videoRoomsEnabled && <IconizedContextMenuOption
label={_t("New video room")}
label={_t("Video room")}
iconClassName="mx_RoomListHeader_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();

View file

@ -0,0 +1,202 @@
/*
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, { FC, useContext, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserTab";
import { EffectiveMembership, getEffectiveMembership } from "../../../utils/membership";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { useRoomState } from "../../../hooks/useRoomState";
import { useMyRoomMembership } from "../../../hooks/useRoomMembers";
import AccessibleButton from "../elements/AccessibleButton";
import InlineSpinner from "../elements/InlineSpinner";
import RoomName from "../elements/RoomName";
import RoomTopic from "../elements/RoomTopic";
import RoomFacePile from "../elements/RoomFacePile";
import RoomAvatar from "../avatars/RoomAvatar";
import MemberAvatar from "../avatars/MemberAvatar";
import RoomInfoLine from "./RoomInfoLine";
interface IProps {
room: Room;
onJoinButtonClicked: () => void;
onRejectButtonClicked: () => void;
}
// XXX This component is currently only used for spaces and video rooms, though
// surely we should expand its use to all rooms for consistency? This already
// handles the text room case, though we would need to add support for ignoring
// and viewing invite reasons to achieve parity with the default invite screen.
const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButtonClicked }) => {
const cli = useContext(MatrixClientContext);
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const myMembership = useMyRoomMembership(room);
useDispatcher(defaultDispatcher, payload => {
if (payload.action === Action.JoinRoomError && payload.roomId === room.roomId) {
setBusy(false); // stop the spinner, join failed
}
});
const [busy, setBusy] = useState(false);
const joinRule = useRoomState(room, state => state.getJoinRule());
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
&& joinRule !== JoinRule.Public;
const viewLabs = () => defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
let inviterSection: JSX.Element;
let joinButtons: JSX.Element;
if (myMembership === "join") {
joinButtons = (
<AccessibleButton
kind="danger_outline"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: room.roomId,
});
}}
>
{ _t("Leave") }
</AccessibleButton>
);
} else if (myMembership === "invite") {
const inviteSender = room.getMember(cli.getUserId())?.events.member?.getSender();
const inviter = inviteSender && room.getMember(inviteSender);
if (inviteSender) {
inviterSection = <div className="mx_RoomPreviewCard_inviter">
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
<div>
<div className="mx_RoomPreviewCard_inviter_name">
{ _t("<inviter/> invites you", {}, {
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
}) }
</div>
{ inviter ? <div className="mx_RoomPreviewCard_inviter_mxid">
{ inviteSender }
</div> : null }
</div>
</div>;
}
joinButtons = <>
<AccessibleButton
kind="secondary"
onClick={() => {
setBusy(true);
onRejectButtonClicked();
}}
>
{ _t("Reject") }
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={() => {
setBusy(true);
onJoinButtonClicked();
}}
>
{ _t("Accept") }
</AccessibleButton>
</>;
} else {
joinButtons = (
<AccessibleButton
kind="primary"
onClick={() => {
onJoinButtonClicked();
if (!cli.isGuest()) {
// user will be shown a modal that won't fire a room join error
setBusy(true);
}
}}
disabled={cannotJoin}
>
{ _t("Join") }
</AccessibleButton>
);
}
if (busy) {
joinButtons = <InlineSpinner />;
}
let avatarRow: JSX.Element;
if (room.isElementVideoRoom()) {
avatarRow = <>
<RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />
<div className="mx_RoomPreviewCard_video" />
</>;
} else if (room.isSpaceRoom()) {
avatarRow = <RoomAvatar room={room} height={80} width={80} viewAvatarOnClick />;
} else {
avatarRow = <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />;
}
let notice: string;
if (cannotJoin) {
notice = _t("To view %(roomName)s, you need an invite", {
roomName: room.name,
});
} else if (room.isElementVideoRoom() && !videoRoomsEnabled) {
notice = myMembership === "join"
? _t("To view, please enable video rooms in Labs first")
: _t("To join, please enable video rooms in Labs first");
joinButtons = <AccessibleButton kind="primary" onClick={viewLabs}>
{ _t("Show Labs settings") }
</AccessibleButton>;
}
return <div className="mx_RoomPreviewCard">
{ inviterSection }
<div className="mx_RoomPreviewCard_avatar">
{ avatarRow }
</div>
<h1 className="mx_RoomPreviewCard_name">
<RoomName room={room} />
</h1>
<RoomInfoLine room={room} />
<RoomTopic room={room}>
{ (topic, ref) =>
topic ? <div className="mx_RoomPreviewCard_topic" ref={ref}>
{ topic }
</div> : null
}
</RoomTopic>
{ room.getJoinRule() === "public" && <RoomFacePile room={room} /> }
{ notice ? <div className="mx_RoomPreviewCard_notice">
{ notice }
</div> : null }
<div className="mx_RoomPreviewCard_joinButtons">
{ joinButtons }
</div>
</div>;
};
export default RoomPreviewCard;

View file

@ -132,8 +132,6 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
events: {
// Allow all users to send video member updates
[VIDEO_CHANNEL_MEMBER]: 0,
// Make widgets immutable, even to admins
"im.vector.modular.widgets": 200,
// Annoyingly, we have to reiterate all the defaults here
[EventType.RoomName]: 50,
[EventType.RoomAvatar]: 50,
@ -144,10 +142,6 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
[EventType.RoomServerAcl]: 100,
[EventType.RoomEncryption]: 100,
},
users: {
// Temporarily give ourselves the power to set up a widget
[client.getUserId()]: 200,
},
};
}
}
@ -270,11 +264,6 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
if (opts.roomType === RoomType.ElementVideo) {
// Set up video rooms with a Jitsi widget
await addVideoChannel(roomId, createOpts.name);
// Reset our power level back to admin so that the widget becomes immutable
const room = client.getRoom(roomId);
const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId(), 100, plEvent);
}
}).then(function() {
// NB createRoom doesn't block on the client seeing the echo that the

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import { useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { throttle } from "lodash";
@ -23,7 +23,7 @@ import { throttle } from "lodash";
import { useTypedEventEmitter } from "./useEventEmitter";
// Hook to simplify watching Matrix Room joined members
export const useRoomMembers = (room: Room, throttleWait = 250) => {
export const useRoomMembers = (room: Room, throttleWait = 250): RoomMember[] => {
const [members, setMembers] = useState<RoomMember[]>(room.getJoinedMembers());
useTypedEventEmitter(room.currentState, RoomStateEvent.Update, throttle(() => {
setMembers(room.getJoinedMembers());
@ -32,10 +32,19 @@ export const useRoomMembers = (room: Room, throttleWait = 250) => {
};
// Hook to simplify watching Matrix Room joined member count
export const useRoomMemberCount = (room: Room, throttleWait = 250) => {
export const useRoomMemberCount = (room: Room, throttleWait = 250): number => {
const [count, setCount] = useState<number>(room.getJoinedMemberCount());
useTypedEventEmitter(room.currentState, RoomStateEvent.Update, throttle(() => {
setCount(room.getJoinedMemberCount());
}, throttleWait, { leading: true, trailing: true }));
return count;
};
// Hook to simplify watching the local user's membership in a room
export const useMyRoomMembership = (room: Room): string => {
const [membership, setMembership] = useState<string>(room.getMyMembership());
useTypedEventEmitter(room, RoomEvent.MyMembership, () => {
setMembership(room.getMyMembership());
});
return membership;
};

View file

@ -1784,6 +1784,13 @@
"Show Widgets": "Show Widgets",
"Search": "Search",
"Invite": "Invite",
"Video room": "Video room",
"Public space": "Public space",
"Public room": "Public room",
"Private space": "Private space",
"Private room": "Private room",
"%(count)s members|other": "%(count)s members",
"%(count)s members|one": "%(count)s member",
"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",
@ -1792,7 +1799,6 @@
"Explore rooms": "Explore rooms",
"New room": "New room",
"You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space",
"New video room": "New video room",
"Add existing room": "Add existing room",
"You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space",
"Explore public rooms": "Explore public rooms",
@ -1865,6 +1871,12 @@
"This room or space is not accessible at this time.": "This room or space is not accessible at this time.",
"Try again later, or ask a room or space admin to check if you have access.": "Try again later, or ask a room or space admin to check if you have access.",
"%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.",
"Leave": "Leave",
"<inviter/> invites you": "<inviter/> invites you",
"To view %(roomName)s, you need an invite": "To view %(roomName)s, you need an invite",
"To view, please enable video rooms in Labs first": "To view, please enable video rooms in Labs first",
"To join, please enable video rooms in Labs first": "To join, please enable video rooms in Labs first",
"Show Labs settings": "Show Labs settings",
"Appearance": "Appearance",
"Show rooms with unread messages first": "Show rooms with unread messages first",
"Show previews of messages": "Show previews of messages",
@ -1883,7 +1895,6 @@
"Favourite": "Favourite",
"Low Priority": "Low Priority",
"Copy room link": "Copy room link",
"Leave": "Leave",
"Video": "Video",
"Connecting...": "Connecting...",
"Connected": "Connected",
@ -2471,7 +2482,6 @@
"Topic (optional)": "Topic (optional)",
"Room visibility": "Room visibility",
"Private room (invite only)": "Private room (invite only)",
"Public room": "Public room",
"Visible to space members": "Visible to space members",
"Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.",
"Create video room": "Create video room",
@ -2482,7 +2492,6 @@
"Add a space to a space you manage.": "Add a space to a space you manage.",
"Space visibility": "Space visibility",
"Private space (invite only)": "Private space (invite only)",
"Public space": "Public space",
"Want to add an existing space instead?": "Want to add an existing space instead?",
"Adding...": "Adding...",
"Sign out": "Sign out",
@ -2650,8 +2659,6 @@
"Manually export keys": "Manually export keys",
"You'll lose access to your encrypted messages": "You'll lose access to your encrypted messages",
"Are you sure you want to sign out?": "Are you sure you want to sign out?",
"%(count)s members|other": "%(count)s members",
"%(count)s members|one": "%(count)s member",
"%(count)s rooms|other": "%(count)s rooms",
"%(count)s rooms|one": "%(count)s room",
"You're removing all spaces. Access will default to invite only": "You're removing all spaces. Access will default to invite only",
@ -3115,9 +3122,6 @@
"Results": "Results",
"Rooms and spaces": "Rooms and spaces",
"Search names and descriptions": "Search names and descriptions",
"Private space": "Private space",
"<inviter/> invites you": "<inviter/> invites you",
"To view %(spaceName)s, you need an invite": "To view %(spaceName)s, you need an invite",
"Welcome to <name/>": "Welcome to <name/>",
"Random": "Random",
"Support": "Support",

View file

@ -17,9 +17,17 @@ limitations under the License.
import React from "react";
import { mount } from "enzyme";
import { act } from "react-dom/test-utils";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixWidgetType } from "matrix-widget-api";
import { stubClient, stubVideoChannelStore, mkRoom, wrapInMatrixClientContext } from "../../test-utils";
import {
stubClient,
stubVideoChannelStore,
StubVideoChannelStore,
mkRoom,
wrapInMatrixClientContext,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { VIDEO_CHANNEL } from "../../../src/utils/VideoChannelUtils";
import WidgetStore from "../../../src/stores/WidgetStore";
@ -30,7 +38,6 @@ import AppTile from "../../../src/components/views/elements/AppTile";
const VideoRoomView = wrapInMatrixClientContext(_VideoRoomView);
describe("VideoRoomView", () => {
stubClient();
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{
id: VIDEO_CHANNEL,
eventId: "$1:example.org",
@ -45,22 +52,22 @@ describe("VideoRoomView", () => {
value: { enumerateDevices: () => [] },
});
const cli = MatrixClientPeg.get();
const room = mkRoom(cli, "!1:example.org");
let cli: MatrixClient;
let room: Room;
let store: StubVideoChannelStore;
let store;
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.get();
jest.spyOn(WidgetStore.instance, "matrixClient", "get").mockReturnValue(cli);
store = stubVideoChannelStore();
});
afterEach(() => {
jest.clearAllMocks();
room = mkRoom(cli, "!1:example.org");
});
it("shows lobby and keeps widget loaded when disconnected", async () => {
const view = mount(<VideoRoomView room={room} resizing={false} />);
// Wait for state to settle
await act(async () => Promise.resolve());
await act(() => Promise.resolve());
expect(view.find(VideoLobby).exists()).toEqual(true);
expect(view.find(AppTile).exists()).toEqual(true);
@ -70,7 +77,7 @@ describe("VideoRoomView", () => {
store.connect("!1:example.org");
const view = mount(<VideoRoomView room={room} resizing={false} />);
// Wait for state to settle
await act(async () => Promise.resolve());
await act(() => Promise.resolve());
expect(view.find(VideoLobby).exists()).toEqual(false);
expect(view.find(AppTile).exists()).toEqual(true);

View file

@ -37,35 +37,21 @@ describe("createRoom", () => {
setupAsyncStoreWithClient(WidgetStore.instance, client);
jest.spyOn(WidgetUtils, "waitForRoomWidget").mockResolvedValue();
const userId = client.getUserId();
const roomId = await createRoom({ roomType: RoomType.ElementVideo });
const [[{
power_level_content_override: {
users: {
[userId]: userPower,
events: { [VIDEO_CHANNEL_MEMBER]: videoMemberPower },
},
events: {
"im.vector.modular.widgets": widgetPower,
[VIDEO_CHANNEL_MEMBER]: videoMemberPower,
},
},
}]] = mocked(client.createRoom).mock.calls as any;
}]] = mocked(client.createRoom).mock.calls as any; // no good type
const [[widgetRoomId, widgetStateKey, , widgetId]] = mocked(client.sendStateEvent).mock.calls;
// We should have had enough power to be able to set up the Jitsi widget
expect(userPower).toBeGreaterThanOrEqual(widgetPower);
// and should have actually set it up
// We should have set up the Jitsi widget
expect(widgetRoomId).toEqual(roomId);
expect(widgetStateKey).toEqual("im.vector.modular.widgets");
expect(widgetId).toEqual(VIDEO_CHANNEL);
// All members should be able to update their connected devices
expect(videoMemberPower).toEqual(0);
// Jitsi widget should be immutable for admins
expect(widgetPower).toBeGreaterThan(100);
// and we should have been reset back to admin
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
});
});