From a24aa7e0f7a297c0ea449655d9b249461f34f398 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Mon, 25 Mar 2024 19:35:31 +0100 Subject: [PATCH] Video call meta space (#12297) * add video room meta space button Signed-off-by: Timo K * Add videoRoomsSpace to meta space configuration Signed-off-by: Timo K * temp Signed-off-by: Timo K * dont show ppl section in video room space Signed-off-by: Timo K * add i18n strings Signed-off-by: Timo K * revert waitForIframe=false (this is part of another PR) Signed-off-by: Timo K * fix missing mock room method Signed-off-by: Timo K * test snapshot: add video room meta space Signed-off-by: Timo K * rename Conferences -> Conference Signed-off-by: Timo K * space panel snap test Signed-off-by: Timo K * update snapshot Signed-off-by: Timo K * fix test Signed-off-by: Timo K * add video room space tests Signed-off-by: Timo K * better logic Signed-off-by: Timo K * Add Video MetaSpace Test Signed-off-by: Timo K * make room join rule update reactive for the video room meta space Signed-off-by: Timo K * temp Signed-off-by: Timo K * fix description for meta space video room settings Signed-off-by: Timo K * tests Signed-off-by: Timo K * update snapshot Signed-off-by: Timo K * review Signed-off-by: Timo K * i18n Signed-off-by: Timo K * fix tests Signed-off-by: Timo K * put video meta space behind "feature_video_rooms" labs flag Signed-off-by: Timo K * review Signed-off-by: Timo K * update space store on RoomCreate state event Signed-off-by: Timo K * test for updating video room space on room type update Signed-off-by: Timo K * remove comment Signed-off-by: Timo K * also make knock join rule rooms part of the conference section Signed-off-by: Timo K --------- Signed-off-by: Timo K --- res/css/structures/_SpacePanel.pcss | 7 +- src/components/views/rooms/RoomList.tsx | 7 + .../tabs/user/SidebarUserSettingsTab.tsx | 36 ++- src/components/views/spaces/SpacePanel.tsx | 25 +- src/i18n/strings/en_EN.json | 6 + src/stores/room-list/RoomListStore.ts | 7 +- src/stores/room-list/algorithms/Algorithm.ts | 5 +- src/stores/room-list/models.ts | 2 + src/stores/spaces/SpaceStore.ts | 16 +- src/stores/spaces/index.ts | 6 +- test/components/views/rooms/RoomList-test.tsx | 97 +++++- .../tabs/user/SidebarUserSettingsTab-test.tsx | 19 +- .../SidebarUserSettingsTab-test.tsx.snap | 280 +++++++++++++++++- .../views/spaces/SpacePanel-test.tsx | 25 +- .../__snapshots__/SpacePanel-test.tsx.snap | 238 +++++++++++++++ test/stores/SpaceStore-test.ts | 34 +++ test/stores/room-list/RoomListStore-test.ts | 52 +++- test/test-utils/test-utils.ts | 1 + 18 files changed, 834 insertions(+), 29 deletions(-) create mode 100644 test/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 63f8559660..0252da01b7 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -203,7 +203,8 @@ limitations under the License. &.mx_SpaceButton_home, &.mx_SpaceButton_favourites, &.mx_SpaceButton_people, - &.mx_SpaceButton_orphans { + &.mx_SpaceButton_orphans, + &.mx_SpaceButton_videoRooms { .mx_SpaceButton_icon { background-color: $panel-actions; @@ -229,6 +230,10 @@ limitations under the License. mask-image: url("$(res)/img/element-icons/roomlist/hash-circle.svg"); } + &.mx_SpaceButton_videoRooms .mx_SpaceButton_icon::before { + mask-image: url("@vector-im/compound-design-tokens/icons/video-call-solid.svg"); + } + &.mx_SpaceButton_new .mx_SpaceButton_icon { &::before { background-color: $primary-content; diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 28154c40d6..4293e4a215 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -83,6 +83,7 @@ export const TAG_ORDER: TagID[] = [ DefaultTagID.Invite, DefaultTagID.Favourite, DefaultTagID.DM, + DefaultTagID.Conference, DefaultTagID.Untagged, DefaultTagID.LowPriority, DefaultTagID.ServerNotice, @@ -387,6 +388,11 @@ const TAG_AESTHETICS: TagAestheticsMap = { defaultHidden: false, AuxButtonComponent: DmAuxButton, }, + [DefaultTagID.Conference]: { + sectionLabel: _td("voip|metaspace_video_rooms|conference_room_section"), + isInvite: false, + defaultHidden: false, + }, [DefaultTagID.Untagged]: { sectionLabel: _td("common|rooms"), isInvite: false, @@ -594,6 +600,7 @@ export default class RoomList extends React.PureComponent { (this.props.activeSpace === MetaSpace.Favourites && orderedTagId !== DefaultTagID.Favourite) || (this.props.activeSpace === MetaSpace.People && orderedTagId !== DefaultTagID.DM) || (this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) || + (this.props.activeSpace === MetaSpace.VideoRooms && orderedTagId === DefaultTagID.DM) || (!isMetaSpace(this.props.activeSpace) && orderedTagId === DefaultTagID.DM && !SettingsStore.getValue("Spaces.showPeopleInSpace", this.props.activeSpace)) diff --git a/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx index 4d6cf9a40f..7211fa863f 100644 --- a/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ChangeEvent } from "react"; +import React, { ChangeEvent, useMemo } from "react"; +import { Icon as CameraCircle } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg"; import { Icon as HomeIcon } from "../../../../../../res/img/element-icons/home.svg"; import { Icon as FavoriteIcon } from "../../../../../../res/img/element-icons/roomlist/favorite.svg"; @@ -30,6 +31,7 @@ import PosthogTrackers from "../../../../../PosthogTrackers"; import SettingsTab from "../SettingsTab"; import { SettingsSection } from "../../shared/SettingsSection"; import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection"; +import SdkConfig from "../../../../../SdkConfig"; type InteractionName = "WebSettingsSidebarTabSpacesCheckbox" | "WebQuickSettingsPinToSidebarCheckbox"; @@ -44,7 +46,14 @@ export const onMetaSpaceChangeFactory = PosthogTrackers.trackInteraction( interactionName, e, - [MetaSpace.Home, null, MetaSpace.Favourites, MetaSpace.People, MetaSpace.Orphans].indexOf(metaSpace), + [ + MetaSpace.Home, + null, + MetaSpace.Favourites, + MetaSpace.People, + MetaSpace.Orphans, + MetaSpace.VideoRooms, + ].indexOf(metaSpace), ); }; @@ -54,8 +63,15 @@ const SidebarUserSettingsTab: React.FC = () => { [MetaSpace.Favourites]: favouritesEnabled, [MetaSpace.People]: peopleEnabled, [MetaSpace.Orphans]: orphansEnabled, + [MetaSpace.VideoRooms]: videoRoomsEnabled, } = useSettingValue>("Spaces.enabledMetaSpaces"); const allRoomsInHome = useSettingValue("Spaces.allRoomsInHome"); + const guestSpaUrl = useMemo(() => { + return SdkConfig.get("element_call").guest_spa_url; + }, []); + const conferenceSubsectionText = + _t("settings|sidebar|metaspaces_video_rooms_description") + + (guestSpaUrl ? " " + _t("settings|sidebar|metaspaces_video_rooms_description_invite_extension") : ""); const onAllRoomsInHomeToggle = async (event: ChangeEvent): Promise => { await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, event.target.checked); @@ -140,6 +156,22 @@ const SidebarUserSettingsTab: React.FC = () => { {_t("settings|sidebar|metaspaces_orphans_description")} + {SettingsStore.getValue("feature_video_rooms") && ( + + + + {_t("settings|sidebar|metaspaces_video_rooms")} + + {conferenceSubsectionText} + + )} diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 5b1756244b..429a18e134 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -209,6 +209,20 @@ const OrphansButton: React.FC = ({ selected, isPanelCollap ); }; +const VideoRoomsButton: React.FC = ({ selected, isPanelCollapsed }) => { + return ( + + ); +}; + const CreateSpaceButton: React.FC> = ({ isPanelCollapsed, setPanelCollapsed, @@ -263,6 +277,7 @@ const metaSpaceComponentMap: Record = { [MetaSpace.Favourites]: FavouritesButton, [MetaSpace.People]: PeopleButton, [MetaSpace.Orphans]: OrphansButton, + [MetaSpace.VideoRooms]: VideoRoomsButton, }; interface IInnerSpacePanelProps extends DroppableProvidedProps { @@ -279,10 +294,12 @@ const InnerSpacePanel = React.memo( const [invites, metaSpaces, actualSpaces, activeSpace] = useSpaces(); const activeSpaces = activeSpace ? [activeSpace] : []; - const metaSpacesSection = metaSpaces.map((key) => { - const Component = metaSpaceComponentMap[key]; - return ; - }); + const metaSpacesSection = metaSpaces + .filter((key) => !(key === MetaSpace.VideoRooms && !SettingsStore.getValue("feature_video_rooms"))) + .map((key) => { + const Component = metaSpaceComponentMap[key]; + return ; + }); return ( %(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.", diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index dc35cd7b5b..16c4d3be5b 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -234,7 +234,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements return; } } - await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline); + // If the join rule changes we need to update the tags for the room. + // A conference tag is determined by the room public join rule. + if (eventPayload.event.getType() === EventType.RoomJoinRules) + await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.PossibleTagChange); + else await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline); + this.updateFn.trigger(); }; if (!room) { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index acb9be710f..267b9bd742 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room } from "matrix-js-sdk/src/matrix"; +import { JoinRule, Room } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { EventEmitter } from "events"; @@ -577,6 +577,9 @@ export class Algorithm extends EventEmitter { tags = [DefaultTagID.DM]; } } + if (room.isCallRoom() && (room.getJoinRule() === JoinRule.Public || room.getJoinRule() === JoinRule.Knock)) { + tags.push(DefaultTagID.Conference); + } return tags; } diff --git a/src/stores/room-list/models.ts b/src/stores/room-list/models.ts index 1c19f82494..d8b0488842 100644 --- a/src/stores/room-list/models.ts +++ b/src/stores/room-list/models.ts @@ -21,6 +21,7 @@ export enum DefaultTagID { LowPriority = "m.lowpriority", Favourite = "m.favourite", DM = "im.vector.fake.direct", + Conference = "im.vector.fake.conferences", ServerNotice = "m.server_notice", Suggested = "im.vector.fake.suggested", } @@ -29,6 +30,7 @@ export const OrderedDefaultTagIDs = [ DefaultTagID.Invite, DefaultTagID.Favourite, DefaultTagID.DM, + DefaultTagID.Conference, DefaultTagID.Untagged, DefaultTagID.LowPriority, DefaultTagID.ServerNotice, diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index e0fd0090c8..966b564d68 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -75,7 +75,13 @@ interface IState {} const ACTIVE_SPACE_LS_KEY = "mx_active_space"; -const metaSpaceOrder: MetaSpace[] = [MetaSpace.Home, MetaSpace.Favourites, MetaSpace.People, MetaSpace.Orphans]; +const metaSpaceOrder: MetaSpace[] = [ + MetaSpace.Home, + MetaSpace.Favourites, + MetaSpace.People, + MetaSpace.Orphans, + MetaSpace.VideoRooms, +]; const MAX_SUGGESTED_ROOMS = 20; @@ -432,7 +438,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (space === MetaSpace.Home && this.allRoomsInHome) { return true; } - + if (space === MetaSpace.VideoRooms) { + return !!this.matrixClient?.getRoom(roomId)?.isCallRoom(); + } if (this.getSpaceFilteredRoomIds(space, includeDescendantSpaces)?.has(roomId)) { return true; } @@ -1033,6 +1041,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.onRoomsUpdate(); } break; + case EventType.RoomCreate: + // The room might become a video room. We need to tag it for that videoRooms space. + this.onRoomsUpdate(); + break; } }; diff --git a/src/stores/spaces/index.ts b/src/stores/spaces/index.ts index 30f6798bb6..10963fbf02 100644 --- a/src/stores/spaces/index.ts +++ b/src/stores/spaces/index.ts @@ -32,6 +32,7 @@ export enum MetaSpace { Favourites = "favourites-space", People = "people-space", Orphans = "orphans-space", + VideoRooms = "video-rooms-space", } export const getMetaSpaceName = (spaceKey: MetaSpace, allRoomsInHome = false): string => { @@ -44,6 +45,8 @@ export const getMetaSpaceName = (spaceKey: MetaSpace, allRoomsInHome = false): s return _t("common|people"); case MetaSpace.Orphans: return _t("common|orphan_rooms"); + case MetaSpace.VideoRooms: + return _t("voip|metaspace_video_rooms|conference_room_section"); } }; @@ -58,6 +61,7 @@ export function isMetaSpace(spaceKey?: SpaceKey): boolean { spaceKey === MetaSpace.Home || spaceKey === MetaSpace.Favourites || spaceKey === MetaSpace.People || - spaceKey === MetaSpace.Orphans + spaceKey === MetaSpace.Orphans || + spaceKey === MetaSpace.VideoRooms ); } diff --git a/test/components/views/rooms/RoomList-test.tsx b/test/components/views/rooms/RoomList-test.tsx index 5e9fd48df9..d5daa43d5c 100644 --- a/test/components/views/rooms/RoomList-test.tsx +++ b/test/components/views/rooms/RoomList-test.tsx @@ -16,10 +16,11 @@ limitations under the License. */ import React, { ComponentProps } from "react"; -import { render, screen, within } from "@testing-library/react"; +import { cleanup, queryByRole, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { mocked } from "jest-mock"; import { Room } from "matrix-js-sdk/src/matrix"; +import { TooltipProvider } from "@vector-im/compound-web"; import RoomList from "../../../../src/components/views/rooms/RoomList"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; @@ -33,6 +34,9 @@ import { mkSpace, stubClient } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import RoomListStore from "../../../../src/stores/room-list/RoomListStore"; +import { ITagMap } from "../../../../src/stores/room-list/algorithms/models"; +import { DefaultTagID } from "../../../../src/stores/room-list/models"; jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -52,16 +56,18 @@ describe("RoomList", () => { function getComponent(props: Partial> = {}): JSX.Element { return ( - + + + ); } @@ -206,5 +212,74 @@ describe("RoomList", () => { }); }); }); + + describe("when video meta space is active", () => { + const videoRoomPrivate = "!videoRoomPrivate_server"; + const videoRoomPublic = "!videoRoomPublic_server"; + const videoRoomKnock = "!videoRoomKnock_server"; + + beforeEach(async () => { + cleanup(); + const rooms: Room[] = []; + RoomListStore.instance; + testUtils.mkRoom(client, videoRoomPrivate, rooms); + testUtils.mkRoom(client, videoRoomPublic, rooms); + testUtils.mkRoom(client, videoRoomKnock, rooms); + + mocked(client).getRoom.mockImplementation( + (roomId) => rooms.find((room) => room.roomId === roomId) || null, + ); + mocked(client).getRooms.mockImplementation(() => rooms); + + const videoRoomKnockRoom = client.getRoom(videoRoomKnock)!; + const videoRoomPrivateRoom = client.getRoom(videoRoomPrivate)!; + const videoRoomPublicRoom = client.getRoom(videoRoomPublic)!; + + [videoRoomPrivateRoom, videoRoomPublicRoom, videoRoomKnockRoom].forEach((room) => { + (room.isCallRoom as jest.Mock).mockReturnValue(true); + }); + + const roomLists: ITagMap = {}; + roomLists[DefaultTagID.Conference] = [videoRoomKnockRoom, videoRoomPublicRoom]; + roomLists[DefaultTagID.Untagged] = [videoRoomPrivateRoom]; + jest.spyOn(RoomListStore.instance, "orderedLists", "get").mockReturnValue(roomLists); + await testUtils.setupAsyncStoreWithClient(store, client); + + store.setActiveSpace(MetaSpace.VideoRooms); + }); + + it("renders Conferences and Room but no People section", () => { + const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms })); + const roomsEl = renderResult.getByRole("treeitem", { name: "Rooms" }); + const conferenceEl = renderResult.getByRole("treeitem", { name: "Conferences" }); + + const noInvites = screen.queryByRole("treeitem", { name: "Invites" }); + const noFavourites = screen.queryByRole("treeitem", { name: "Favourites" }); + const noPeople = screen.queryByRole("treeitem", { name: "People" }); + const noLowPriority = screen.queryByRole("treeitem", { name: "Low priority" }); + const noHistorical = screen.queryByRole("treeitem", { name: "Historical" }); + + expect(roomsEl).toBeVisible(); + expect(conferenceEl).toBeVisible(); + + expect(noInvites).toBeFalsy(); + expect(noFavourites).toBeFalsy(); + expect(noPeople).toBeFalsy(); + expect(noLowPriority).toBeFalsy(); + expect(noHistorical).toBeFalsy(); + }); + it("renders Public and Knock rooms in Conferences section", () => { + const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms })); + const conferenceList = renderResult.getByRole("group", { name: "Conferences" }); + expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPublic })).toBeVisible(); + expect(queryByRole(conferenceList, "treeitem", { name: videoRoomKnock })).toBeVisible(); + expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPrivate })).toBeFalsy(); + + const roomsList = renderResult.getByRole("group", { name: "Rooms" }); + expect(queryByRole(roomsList, "treeitem", { name: videoRoomPrivate })).toBeVisible(); + expect(queryByRole(roomsList, "treeitem", { name: videoRoomPublic })).toBeFalsy(); + expect(queryByRole(roomsList, "treeitem", { name: videoRoomKnock })).toBeFalsy(); + }); + }); }); }); diff --git a/test/components/views/settings/tabs/user/SidebarUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/SidebarUserSettingsTab-test.tsx index 02e59ecdf5..cd787e2019 100644 --- a/test/components/views/settings/tabs/user/SidebarUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/SidebarUserSettingsTab-test.tsx @@ -23,6 +23,7 @@ import SettingsStore from "../../../../../../src/settings/SettingsStore"; import { MetaSpace } from "../../../../../../src/stores/spaces"; import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; import { flushPromises } from "../../../../../test-utils"; +import SdkConfig from "../../../../../../src/SdkConfig"; describe("", () => { beforeEach(() => { @@ -31,9 +32,25 @@ describe("", () => { jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); }); - it("renders sidebar settings", () => { + it("renders sidebar settings with guest spa url", () => { + const spy = jest.spyOn(SdkConfig, "get").mockReturnValue({ guest_spa_url: "https://somewhere.org" }); + const originalGetValue = SettingsStore.getValue; + const spySettingsStore = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + return setting === "feature_video_rooms" ? true : originalGetValue(setting); + }); const { container } = render(); expect(container).toMatchSnapshot(); + spySettingsStore.mockRestore(); + spy.mockRestore(); + }); + it("renders sidebar settings without guest spa url", () => { + const originalGetValue = SettingsStore.getValue; + const spySettingsStore = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + return setting === "feature_video_rooms" ? true : originalGetValue(setting); + }); + const { container } = render(); + expect(container).toMatchSnapshot(); + spySettingsStore.mockRestore(); }); it("toggles all rooms in home setting", async () => { diff --git a/test/components/views/settings/tabs/user/__snapshots__/SidebarUserSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/SidebarUserSettingsTab-test.tsx.snap index ff18d3d9cb..c5315e99bc 100644 --- a/test/components/views/settings/tabs/user/__snapshots__/SidebarUserSettingsTab-test.tsx.snap +++ b/test/components/views/settings/tabs/user/__snapshots__/SidebarUserSettingsTab-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders sidebar settings 1`] = ` +exports[` renders sidebar settings with guest spa url 1`] = `
renders sidebar settings 1`] = `
+ + +
+ + + + +`; + +exports[` renders sidebar settings without guest spa url 1`] = ` +
+
+
+
+

+ Sidebar +

+
+
+
+

+ Spaces to show +

+
+
+
+ Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too. +
+
+
+ + +