From 658ff4dfe6c93ca602060133054e45e859c6f32d Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 4 May 2022 17:02:06 -0400 Subject: [PATCH] Iterate video room designs in labs (#8499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- res/css/_components.scss | 2 + res/css/structures/_SpaceRoomView.scss | 152 ------------ res/css/structures/_VideoRoomView.scss | 3 +- res/css/views/elements/_FacePile.scss | 3 + res/css/views/rooms/_RoomInfoLine.scss | 58 +++++ res/css/views/rooms/_RoomPreviewCard.scss | 136 +++++++++++ src/components/structures/RoomView.tsx | 16 ++ src/components/structures/SpaceRoomView.tsx | 218 +----------------- src/components/structures/VideoRoomView.tsx | 37 ++- .../views/context_menus/SpaceContextMenu.tsx | 19 +- src/components/views/elements/FacePile.tsx | 14 +- src/components/views/rooms/RoomInfoLine.tsx | 86 +++++++ src/components/views/rooms/RoomList.tsx | 4 +- src/components/views/rooms/RoomListHeader.tsx | 4 +- .../views/rooms/RoomPreviewCard.tsx | 202 ++++++++++++++++ src/createRoom.ts | 11 - src/hooks/useRoomMembers.ts | 15 +- src/i18n/strings/en_EN.json | 22 +- .../structures/VideoRoomView-test.tsx | 29 ++- test/createRoom-test.ts | 20 +- 20 files changed, 617 insertions(+), 434 deletions(-) create mode 100644 res/css/views/rooms/_RoomInfoLine.scss create mode 100644 res/css/views/rooms/_RoomPreviewCard.scss create mode 100644 src/components/views/rooms/RoomInfoLine.tsx create mode 100644 src/components/views/rooms/RoomPreviewCard.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index f0b102777a..c77b5ea706 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index eed3d8830f..f4d37e0e24 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -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; diff --git a/res/css/structures/_VideoRoomView.scss b/res/css/structures/_VideoRoomView.scss index d99b3f5894..3577e7b73e 100644 --- a/res/css/structures/_VideoRoomView.scss +++ b/res/css/structures/_VideoRoomView.scss @@ -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 { diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 90f1c590a1..e40695fcf1 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -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; diff --git a/res/css/views/rooms/_RoomInfoLine.scss b/res/css/views/rooms/_RoomInfoLine.scss new file mode 100644 index 0000000000..5c0aea7c0b --- /dev/null +++ b/res/css/views/rooms/_RoomInfoLine.scss @@ -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; + } + } +} diff --git a/res/css/views/rooms/_RoomPreviewCard.scss b/res/css/views/rooms/_RoomPreviewCard.scss new file mode 100644 index 0000000000..b561bf666d --- /dev/null +++ b/res/css/views/rooms/_RoomPreviewCard.scss @@ -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; + } + } +} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 2f55f1b217..a7a4ec2a9e 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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 { } const myMembership = this.state.room.getMyMembership(); + if ( + this.state.room.isElementVideoRoom() && + !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") + ) { + return +
+ +
; +
; + } + // SpaceRoomView handles invites itself if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { if (this.state.joining || this.state.rejecting) { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 4e258f5258..695fa7a749 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -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 = - { _t("Public space") } - ; - } else { - visibilitySection = - { _t("Private space") } - ; - } - - let memberSection; - if (membership === "invite" && summary) { - // Don't trust local state and instead use the summary API - memberSection = - { _t("%(count)s members", { count: summary.num_joined_members }) } - ; - } else if (summary !== undefined) { // summary is not still loading - memberSection = - { (count) => count > 0 ? ( - { - RightPanelStore.instance.setCard({ phase: RightPanelPhases.SpaceMemberList }); - }} - > - { _t("%(count)s members", { count }) } - - ) : null } - ; - } - - return
- { visibilitySection } - { memberSection } -
; -}; - -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 = ( - { - defaultDispatcher.dispatch({ - action: "leave_room", - room_id: space.roomId, - }); - }} - > - { _t("Leave") } - - ); - } else if (myMembership === "invite") { - const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); - const inviter = inviteSender && space.getMember(inviteSender); - - if (inviteSender) { - inviterSection =
- -
-
- { _t(" invites you", {}, { - inviter: () => { inviter?.name || inviteSender }, - }) } -
- { inviter ?
- { inviteSender } -
: null } -
-
; - } - - joinButtons = <> - { - setBusy(true); - onRejectButtonClicked(); - }} - > - { _t("Reject") } - - { - setBusy(true); - onJoinButtonClicked(); - }} - > - { _t("Accept") } - - ; - } else { - joinButtons = ( - { - onJoinButtonClicked(); - if (!cli.isGuest()) { - // user will be shown a modal that won't fire a room join error - setBusy(true); - } - }} - disabled={cannotJoin} - > - { _t("Join") } - - ); - } - - if (busy) { - joinButtons = ; - } - - let footer; - if (cannotJoin) { - footer =
- { _t("To view %(spaceName)s, you need an invite", { - spaceName: space.name, - }) } -
; - } - - return
- { inviterSection } - -

- -

- - - { (topic, ref) => -
- { topic } -
- } -
- { space.getJoinRule() === "public" && } -
- { joinButtons } -
- { footer } -
; -}; - const SpaceLandingAddButton = ({ space }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms); @@ -339,7 +135,7 @@ const SpaceLandingAddButton = ({ space }) => { }} /> { videoRoomsEnabled && { e.preventDefault(); @@ -451,7 +247,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
- +
{ inviteButton } @@ -846,8 +642,8 @@ export default class SpaceRoomView extends React.PureComponent { if (this.state.myMembership === "join") { return ; } else { - return ; diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx index 2695dafa79..e2cd62e08d 100644 --- a/src/components/structures/VideoRoomView.tsx +++ b/src/components/structures/VideoRoomView.tsx @@ -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 = ({ 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 = getVideoChannel(room.roomId); - if (!app) logger.warn(`No video channel for room ${room.roomId}`); - return app; - }, [room, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps + const app: IApp = useMemo(() => { + if (widgetStoreReady) { + const app = getVideoChannel(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, 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)); diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index d9286c618b..5d04590045 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -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 && + + } { canAddSubSpaces && { const FacePile: FC = ({ members, faceSize, overflow, tooltip, children, ...props }) => { const faces = members.map( - tooltip ? - m => : - m => - + tooltip + ? m => + : m => + , ); diff --git a/src/components/views/rooms/RoomInfoLine.tsx b/src/components/views/rooms/RoomInfoLine.tsx new file mode 100644 index 0000000000..09214043d6 --- /dev/null +++ b/src/components/views/rooms/RoomInfoLine.tsx @@ -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 = ({ 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 = + { _t("%(count)s members", { count: summary.num_joined_members }) } + ; + } else if (memberCount && summary !== undefined) { // summary is not still loading + const viewMembers = () => RightPanelStore.instance.setCard({ + phase: room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList : RightPanelPhases.RoomMemberList, + }); + + members = + { _t("%(count)s members", { count: memberCount }) } + ; + } + + return
+ { roomType } + { members } +
; +}; + +export default RoomInfoLine; diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index a0632d763e..e534b713f8 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -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") && { e.preventDefault(); @@ -283,7 +283,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => { }} /> { SettingsStore.getValue("feature_video_rooms") && { e.preventDefault(); diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index b9a33e1f44..036902d12b 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -222,7 +222,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { /> { videoRoomsEnabled && { e.preventDefault(); e.stopPropagation(); @@ -313,7 +313,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { }} /> { videoRoomsEnabled && { e.preventDefault(); diff --git a/src/components/views/rooms/RoomPreviewCard.tsx b/src/components/views/rooms/RoomPreviewCard.tsx new file mode 100644 index 0000000000..e197a3259c --- /dev/null +++ b/src/components/views/rooms/RoomPreviewCard.tsx @@ -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 = ({ 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 = ( + { + defaultDispatcher.dispatch({ + action: "leave_room", + room_id: room.roomId, + }); + }} + > + { _t("Leave") } + + ); + } else if (myMembership === "invite") { + const inviteSender = room.getMember(cli.getUserId())?.events.member?.getSender(); + const inviter = inviteSender && room.getMember(inviteSender); + + if (inviteSender) { + inviterSection =
+ +
+
+ { _t(" invites you", {}, { + inviter: () => { inviter?.name || inviteSender }, + }) } +
+ { inviter ?
+ { inviteSender } +
: null } +
+
; + } + + joinButtons = <> + { + setBusy(true); + onRejectButtonClicked(); + }} + > + { _t("Reject") } + + { + setBusy(true); + onJoinButtonClicked(); + }} + > + { _t("Accept") } + + ; + } else { + joinButtons = ( + { + onJoinButtonClicked(); + if (!cli.isGuest()) { + // user will be shown a modal that won't fire a room join error + setBusy(true); + } + }} + disabled={cannotJoin} + > + { _t("Join") } + + ); + } + + if (busy) { + joinButtons = ; + } + + let avatarRow: JSX.Element; + if (room.isElementVideoRoom()) { + avatarRow = <> + +
+ ; + } else if (room.isSpaceRoom()) { + avatarRow = ; + } else { + avatarRow = ; + } + + 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 = + { _t("Show Labs settings") } + ; + } + + return
+ { inviterSection } +
+ { avatarRow } +
+

+ +

+ + + { (topic, ref) => + topic ?
+ { topic } +
: null + } +
+ { room.getJoinRule() === "public" && } + { notice ?
+ { notice } +
: null } +
+ { joinButtons } +
+
; +}; + +export default RoomPreviewCard; diff --git a/src/createRoom.ts b/src/createRoom.ts index 66177d812b..955e3a5707 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -132,8 +132,6 @@ export default async function createRoom(opts: IOpts): Promise { 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 { [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 { 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 diff --git a/src/hooks/useRoomMembers.ts b/src/hooks/useRoomMembers.ts index a2d4e0a2c8..52dc3853b8 100644 --- a/src/hooks/useRoomMembers.ts +++ b/src/hooks/useRoomMembers.ts @@ -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(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(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(room.getMyMembership()); + useTypedEventEmitter(room, RoomEvent.MyMembership, () => { + setMembership(room.getMyMembership()); + }); + return membership; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7f885a25b4..3f3dc530d2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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 submit a bug report.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.", + "Leave": "Leave", + " invites you": " 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", - " invites you": " invites you", - "To view %(spaceName)s, you need an invite": "To view %(spaceName)s, you need an invite", "Welcome to ": "Welcome to ", "Random": "Random", "Support": "Support", diff --git a/test/components/structures/VideoRoomView-test.tsx b/test/components/structures/VideoRoomView-test.tsx index 11d747103d..1b936f488a 100644 --- a/test/components/structures/VideoRoomView-test.tsx +++ b/test/components/structures/VideoRoomView-test.tsx @@ -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(); // 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(); // 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); diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts index 5846823cfd..c37edaff86 100644 --- a/test/createRoom-test.ts +++ b/test/createRoom-test.ts @@ -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: { - "im.vector.modular.widgets": widgetPower, - [VIDEO_CHANNEL_MEMBER]: videoMemberPower, - }, + events: { [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); }); });