diff --git a/playwright/e2e/integration-manager/utils.ts b/playwright/e2e/integration-manager/utils.ts index c6a2fb998ee..0ea59e6ff73 100644 --- a/playwright/e2e/integration-manager/utils.ts +++ b/playwright/e2e/integration-manager/utils.ts @@ -19,8 +19,6 @@ import type { ElementAppPage } from "../../pages/ElementAppPage"; export async function openIntegrationManager(app: ElementAppPage) { const { page } = app; await app.toggleRoomInfoPanel(); - await page - .locator(".mx_RoomSummaryCard_appsGroup") - .getByRole("button", { name: "Add widgets, bridges & bots" }) - .click(); + await page.getByRole("tab", { name: "Extensions" }).click(); + await page.getByRole("button", { name: "Add extensions" }).click(); } diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts index f282d83d62c..f7b29585096 100644 --- a/playwright/e2e/right-panel/right-panel.spec.ts +++ b/playwright/e2e/right-panel/right-panel.spec.ts @@ -73,7 +73,8 @@ test.describe("RightPanel", () => { test("should handle clicking add widgets", async ({ page, app }) => { await viewRoomSummaryByName(page, app, ROOM_NAME); - await page.getByRole("button", { name: "Add widgets, bridges & bots" }).click(); + await page.getByRole("tab", { name: "Extensions" }).click(); + await page.getByRole("button", { name: "Add extensions" }).click(); await expect(page.locator(".mx_IntegrationManager")).toBeVisible(); }); diff --git a/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png index 2c6160f2a19..d7c90ce5fe3 100644 Binary files a/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png and b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png index 943cc9dfc8b..0501086f9e3 100644 Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 20a6d2fe54b..f1509c58e6d 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -260,6 +260,7 @@ @import "./views/right_panel/_BaseCard.pcss"; @import "./views/right_panel/_EmptyState.pcss"; @import "./views/right_panel/_EncryptionInfo.pcss"; +@import "./views/right_panel/_ExtensionsCard.pcss"; @import "./views/right_panel/_PinnedMessagesCard.pcss"; @import "./views/right_panel/_RightPanelTabs.pcss"; @import "./views/right_panel/_RoomSummaryCard.pcss"; diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss index 692f7d23b39..47092c124fc 100644 --- a/res/css/views/right_panel/_BaseCard.pcss +++ b/res/css/views/right_panel/_BaseCard.pcss @@ -98,50 +98,6 @@ limitations under the License. scrollbar-gutter: stable; } - .mx_BaseCard_Group { - margin: $spacing-20 0 $spacing-16; - - & > * { - margin-left: $spacing-12; - margin-right: $spacing-12; - } - - > h2 { - color: $tertiary-content; - font: var(--cpd-font-body-sm-medium); - margin: $spacing-12; - } - - .mx_BaseCard_Button { - padding: 10px; - padding-inline-start: $spacing-12; - margin: 0; - position: relative; - font: var(--cpd-font-heading-sm-medium); - height: 20px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - display: flex; - - .mx_BaseCard_Button_sublabel { - color: $tertiary-content; - margin-left: auto; - } - - &:hover { - background-color: rgba(141, 151, 165, 0.1); - } - - &.mx_AccessibleButton_disabled { - padding-right: $spacing-12; - &::after { - content: unset; - } - } - } - } - .mx_BaseCard_footer { padding-top: $spacing-4; text-align: center; diff --git a/res/css/views/right_panel/_ExtensionsCard.pcss b/res/css/views/right_panel/_ExtensionsCard.pcss new file mode 100644 index 00000000000..ea5431fb368 --- /dev/null +++ b/res/css/views/right_panel/_ExtensionsCard.pcss @@ -0,0 +1,145 @@ +/* +Copyright 2024 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_ExtensionsCard { + --cpd-separator-inset: var(--cpd-space-4x); + --cpd-separator-spacing: var(--cpd-space-4x); + + .mx_BaseCard_header { + /* Hide the line between the header and the body of the card */ + border-block-end: none; + + /* Styling for the "Add extensions" button */ + button { + width: 100%; + } + } + + .mx_AutoHideScrollbar { + padding: 0 var(--cpd-space-4x); + box-sizing: border-box; + } + + .mx_ExtensionsCard_container { + text-align: center; + margin: $spacing-20 var(--cpd-space-4x) 0; + } + + .mx_ExtensionsCard_Button { + /* this button is special so we have to override some of the original styling */ + /* as we will be applying it in its children */ + padding: 0; + height: auto; + color: $tertiary-content; + position: relative; + + .mx_WidgetAvatar { + flex-shrink: 0; + } + + .mx_ExtensionsCard_icon_app { + padding: var(--cpd-space-2x) var(--cpd-space-12x) var(--cpd-space-2x) var(--cpd-space-3x); + text-overflow: ellipsis; + overflow: hidden; + display: flex; + align-items: center; + + p { + margin: 0 var(--cpd-space-3x); + color: $primary-content; + } + } + + .mx_ExtensionsCard_app_pinToggle, + .mx_ExtensionsCard_app_options { + position: absolute; + top: 0; + height: 100%; /* to give bigger interactive zone */ + width: 24px; + padding: var(--cpd-space-3x) var(--cpd-space-1x); + box-sizing: border-box; + min-width: 24px; /* prevent flexbox crushing */ + + &:hover { + &::after { + content: ""; + position: absolute; + height: 24px; + width: 24px; + top: var(--cpd-space-2x); /* equal to padding-top of parent */ + left: 0; + border-radius: 12px; + background-color: rgba(141, 151, 165, 0.1); + } + } + + &::before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 16px; + background-color: $icon-button-color; + } + } + + .mx_ExtensionsCard_app_pinToggle { + right: 8px; + + &::before { + mask-image: url("$(res)/img/element-icons/room/pin-upright.svg"); + } + } + + .mx_ExtensionsCard_app_options { + right: 32px; /* 24 + 8 */ + &::before { + mask-image: url("$(res)/img/element-icons/room/ellipsis.svg"); + } + } + + &.mx_ExtensionsCard_Button_pinned { + &::after { + opacity: 0.2; + } + + .mx_ExtensionsCard_app_pinToggle::before { + background-color: $accent; + } + } + + &::before { + content: unset; + } + + &::after { + top: var(--cpd-space-2x); /* re-align based on the height change */ + pointer-events: none; /* pass through to the real button */ + } + } + + /* Set layout for everyone button */ + a[data-kind="primary"] { + margin-top: var(--cpd-space-10x); + } + + .mx_EmptyState::before { + /* Overlap the Add extensions button */ + top: -76px; + } +} diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss index 75f0178cddf..5c3cab320ca 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -33,24 +33,6 @@ limitations under the License. text-overflow: ellipsis; } - .mx_RoomSummaryCard_aboutGroup { - .mx_RoomSummaryCard_Button { - padding-left: 44px; - - &::before { - content: ""; - position: absolute; - top: 8px; - left: 10px; - height: 24px; - width: 24px; - mask-repeat: no-repeat; - mask-position: center; - background-color: $icon-button-color; - } - } - } - .mx_RoomSummaryCard_topic { padding: 0 12px; color: var(--cpd-color-text-secondary); @@ -99,131 +81,6 @@ limitations under the License. } } - .mx_RoomSummaryCard_appsGroup { - .mx_RoomSummaryCard_Button { - /* this button is special so we have to override some of the original styling */ - /* as we will be applying it in its children */ - padding: 0; - height: auto; - color: $tertiary-content; - - .mx_RoomSummaryCard_icon_app { - padding: 10px 48px 10px 12px; /* based on typical mx_RoomSummaryCard_Button padding */ - text-overflow: ellipsis; - overflow: hidden; - display: flex; - justify-content: center; - span { - /* Center aligned and Spacing matched with the About section above the Widgets section */ - margin-right: 10px; - display: flex; - justify-content: center; - align-items: center; - color: $primary-content; - } - } - - .mx_RoomSummaryCard_app_pinToggle, - .mx_RoomSummaryCard_app_maximiseToggle, - .mx_RoomSummaryCard_app_options { - position: absolute; - top: 0; - height: 100%; /* to give bigger interactive zone */ - width: 24px; - padding: 12px 4px; - box-sizing: border-box; - min-width: 24px; /* prevent flexbox crushing */ - - &:hover { - &::after { - content: ""; - position: absolute; - height: 24px; - width: 24px; - top: 8px; /* equal to padding-top of parent */ - left: 0; - border-radius: 12px; - background-color: rgba(141, 151, 165, 0.1); - } - } - - &::before { - content: ""; - position: absolute; - height: 16px; - width: 16px; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 16px; - background-color: $icon-button-color; - } - } - - .mx_RoomSummaryCard_app_pinToggle { - right: 8px; - - &::before { - mask-image: url("$(res)/img/element-icons/room/pin-upright.svg"); - } - } - .mx_RoomSummaryCard_app_maximiseToggle { - right: 32px; /* 24 + 8 */ - - &::before { - mask-size: 14px; - mask-image: url("$(res)/img/element-icons/maximise-expand.svg"); - } - } - - .mx_RoomSummaryCard_app_options { - right: 56px; /* 2*24 + 8 */ - display: none; - &::before { - mask-image: url("$(res)/img/element-icons/room/ellipsis.svg"); - } - } - - &.mx_RoomSummaryCard_Button_pinned { - &::after { - opacity: 0.2; - } - - .mx_RoomSummaryCard_app_pinToggle::before { - background-color: $accent; - } - } - - &.mx_RoomSummaryCard_Button_maximised { - &::after { - opacity: 0.2; - } - - .mx_RoomSummaryCard_app_maximiseToggle::before { - background-color: $accent; - } - } - - &:hover { - .mx_RoomSummaryCard_icon_app { - padding-right: 72px; - } - - .mx_RoomSummaryCard_app_options { - display: unset; - } - } - - &::before { - content: unset; - } - - &::after { - top: 8px; /* re-align based on the height change */ - pointer-events: none; /* pass through to the real button */ - } - } - } - .mx_AccessibleButton_kind_link { margin-top: 12px; margin-bottom: 12px; diff --git a/res/themes/light-high-contrast/css/_light-high-contrast.pcss b/res/themes/light-high-contrast/css/_light-high-contrast.pcss index a306e769b00..213c6414401 100644 --- a/res/themes/light-high-contrast/css/_light-high-contrast.pcss +++ b/res/themes/light-high-contrast/css/_light-high-contrast.pcss @@ -64,14 +64,6 @@ $accent-1400: var(--cpd-color-green-1400); outline-offset: 2px; } -/* Add padding, so the outline is not chopped off on the left */ -.mx_BaseCard { - padding-left: 4px !important; /* Remove 4 to allow 4 in mx_BaseCard_Group */ -} -.mx_BaseCard_Group { - padding-left: 4px !important; -} - .mx_BasicMessageComposer .mx_BasicMessageComposer_inputEmpty > :first-child::before { color: $secondary-content; opacity: 1 !important; diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index bc80692459a..1d6bf101b6e 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -43,6 +43,7 @@ import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/ import { Action } from "../../dispatcher/actions"; import { XOR } from "../../@types/common"; import { RightPanelTabs } from "../views/right_panel/RightPanelTabs"; +import ExtensionsCard from "../views/right_panel/ExtensionsCard"; interface BaseProps { overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView) @@ -306,6 +307,12 @@ export default class RightPanel extends React.Component { } break; + case RightPanelPhases.Extensions: + if (!!this.props.room) { + card = ; + } + break; + case RightPanelPhases.Widget: if (!!this.props.room && !!cardState?.widgetId) { card = ; @@ -315,7 +322,7 @@ export default class RightPanel extends React.Component { return ( ); diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx index fc7b1bcf695..8443da220e7 100644 --- a/src/components/views/right_panel/BaseCard.tsx +++ b/src/components/views/right_panel/BaseCard.tsx @@ -41,26 +41,11 @@ interface IProps { onKeyDown?(ev: KeyboardEvent): void; cardState?: any; ref?: Ref; - // Ref for the 'close' button the the card + // Ref for the 'close' button the card closeButtonRef?: Ref; children: ReactNode; } -interface IGroupProps { - className?: string; - title: string; - children: ReactNode; -} - -export const Group: React.FC = ({ className, title, children }) => { - return ( -
-

{title}

- {children} -
- ); -}; - const BaseCard: React.FC = forwardRef( ( { diff --git a/src/components/views/right_panel/ExtensionsCard.tsx b/src/components/views/right_panel/ExtensionsCard.tsx new file mode 100644 index 00000000000..22ea8bad99f --- /dev/null +++ b/src/components/views/right_panel/ExtensionsCard.tsx @@ -0,0 +1,214 @@ +/* +Copyright 2024 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, { useEffect, useMemo, useState } from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; +import classNames from "classnames"; +import { Button, Link, Separator, Text } from "@vector-im/compound-web"; +import { Icon as PlusIcon } from "@vector-im/compound-design-tokens/icons/plus.svg"; +import { Icon as ExtensionsIcon } from "@vector-im/compound-design-tokens/icons/extensions.svg"; + +import BaseCard from "./BaseCard"; +import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils"; +import { _t } from "../../../languageHandler"; +import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; +import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; +import UIStore from "../../../stores/UIStore"; +import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; +import { IApp } from "../../../stores/WidgetStore"; +import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; +import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; +import AccessibleButton from "../elements/AccessibleButton"; +import WidgetAvatar from "../avatars/WidgetAvatar"; +import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; +import EmptyState from "./EmptyState"; + +interface Props { + room: Room; + onClose(): void; +} + +interface IAppRowProps { + app: IApp; + room: Room; +} + +const AppRow: React.FC = ({ app, room }) => { + const name = WidgetUtils.getWidgetName(app); + const [canModifyWidget, setCanModifyWidget] = useState(); + + useEffect(() => { + setCanModifyWidget(WidgetUtils.canUserModifyWidgets(room.client, room.roomId)); + }, [room.client, room.roomId]); + + const onOpenWidgetClick = (): void => { + RightPanelStore.instance.pushCard({ + phase: RightPanelPhases.Widget, + state: { widgetId: app.id }, + }); + }; + + const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top); + const togglePin = isPinned + ? () => { + WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); + } + : () => { + WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); + }; + + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + let contextMenu; + if (menuDisplayed) { + const rect = handle.current?.getBoundingClientRect(); + const rightMargin = rect?.right ?? 0; + const topMargin = rect?.top ?? 0; + contextMenu = ( + + ); + } + + const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top); + + let pinTitle: string; + if (cannotPin) { + pinTitle = _t("right_panel|pinned_messages|limits", { count: MAX_PINNED }); + } else { + pinTitle = isPinned ? _t("action|unpin") : _t("action|pin"); + } + + const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, Container.Center); + + let openTitle = ""; + if (isPinned) { + openTitle = _t("widget|unpin_to_view_right_panel"); + } else if (isMaximised) { + openTitle = _t("widget|close_to_view_right_panel"); + } + + const classes = classNames("mx_BaseCard_Button mx_ExtensionsCard_Button", { + mx_ExtensionsCard_Button_pinned: isPinned, + }); + + return ( +
+ + + + {name} + + + + {canModifyWidget && ( + + )} + + + + {contextMenu} +
+ ); +}; + +/** + * A right panel card displaying a list of widgets in the room and allowing the user to manage them. + * @param room the room to manage widgets for + * @param onClose callback when the card is closed + */ +const ExtensionsCard: React.FC = ({ room, onClose }) => { + const apps = useWidgets(room); + // Filter out virtual widgets + const realApps = useMemo(() => apps.filter((app) => app.eventId !== undefined), [apps]); + + const onManageIntegrations = (): void => { + const managers = IntegrationManagers.sharedInstance(); + if (!managers.hasManager()) { + managers.openNoManagerDialog(); + } else { + // noinspection JSIgnoredPromiseFromCall + managers.getPrimaryManager()?.open(room); + } + }; + + // The button is in the header to keep it outside the scrollable region + const header = ( + + ); + + let body: JSX.Element; + if (realApps.length < 1) { + body = ( + + ); + } else { + let copyLayoutBtn: JSX.Element | null = null; + if (WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) { + copyLayoutBtn = ( + WidgetLayoutStore.instance.copyLayoutToRoom(room)}> + {_t("widget|set_room_layout")} + + ); + } + + body = ( + <> + + {realApps.map((app) => ( + + ))} + {copyLayoutBtn} + + ); + } + + return ( + + {body} + + ); +}; + +export default ExtensionsCard; diff --git a/src/components/views/right_panel/RightPanelTabs.tsx b/src/components/views/right_panel/RightPanelTabs.tsx index fc2eeb17fac..300856e28fd 100644 --- a/src/components/views/right_panel/RightPanelTabs.tsx +++ b/src/components/views/right_panel/RightPanelTabs.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { useRef } from "react"; import { NavBar, NavItem } from "@vector-im/compound-web"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; @@ -24,17 +25,27 @@ import PosthogTrackers from "../../../PosthogTrackers"; import { useDispatcher } from "../../../hooks/useDispatcher"; import dispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; +import SettingsStore from "../../../settings/SettingsStore"; +import { UIComponent, UIFeature } from "../../../settings/UIFeature"; +import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; +import { useIsVideoRoom } from "../../../utils/video-rooms"; function shouldShowTabsForPhase(phase?: RightPanelPhases): boolean { - const tabs = [RightPanelPhases.RoomSummary, RightPanelPhases.RoomMemberList, RightPanelPhases.ThreadPanel]; + const tabs = [ + RightPanelPhases.RoomSummary, + RightPanelPhases.RoomMemberList, + RightPanelPhases.ThreadPanel, + RightPanelPhases.Extensions, + ]; return !!phase && tabs.includes(phase); } type Props = { + room?: Room; phase: RightPanelPhases; }; -export const RightPanelTabs: React.FC = ({ phase }): JSX.Element | null => { +export const RightPanelTabs: React.FC = ({ phase, room }): JSX.Element | null => { const threadsTabRef = useRef(null); useDispatcher(dispatcher, (payload) => { @@ -45,6 +56,8 @@ export const RightPanelTabs: React.FC = ({ phase }): JSX.Element | null = } }); + const isVideoRoom = useIsVideoRoom(room); + if (!shouldShowTabsForPhase(phase)) return null; return ( @@ -81,6 +94,20 @@ export const RightPanelTabs: React.FC = ({ phase }): JSX.Element | null = > {_t("common|threads")} + {SettingsStore.getValue(UIFeature.Widgets) && + !isVideoRoom && + shouldShowComponent(UIComponent.AddIntegrations) && ( + { + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.Extensions }, true); + }} + active={phase === RightPanelPhases.Extensions} + > + {_t("common|extensions")} + + )} ); }; diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 9896289e25c..bb35242bc79 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -14,16 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { - ChangeEvent, - SyntheticEvent, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { ChangeEvent, SyntheticEvent, useContext, useEffect, useRef, useState } from "react"; import classNames from "classnames"; import { MenuItem, @@ -55,35 +46,23 @@ import { EventType, JoinRule, Room, RoomStateEvent } from "matrix-js-sdk/src/mat import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; -import BaseCard, { Group } from "./BaseCard"; +import BaseCard from "./BaseCard"; import { _t } from "../../../languageHandler"; import RoomAvatar from "../avatars/RoomAvatar"; -import AccessibleButton from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import Modal from "../../../Modal"; import ShareDialog from "../dialogs/ShareDialog"; -import { useEventEmitter, useEventEmitterState } from "../../../hooks/useEventEmitter"; -import WidgetUtils from "../../../utils/WidgetUtils"; -import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; -import SettingsStore from "../../../settings/SettingsStore"; -import WidgetAvatar from "../avatars/WidgetAvatar"; -import WidgetStore, { IApp } from "../../../stores/WidgetStore"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { E2EStatus } from "../../../utils/ShieldUtils"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; -import { UIComponent, UIFeature } from "../../../settings/UIFeature"; -import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; -import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import { usePinnedEvents } from "./PinnedMessagesCard"; -import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import RoomName from "../elements/RoomName"; -import UIStore from "../../../stores/UIStore"; import ExportDialog from "../dialogs/ExportDialog"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import PosthogTrackers from "../../../PosthogTrackers"; -import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { PollHistoryDialog } from "../dialogs/PollHistoryDialog"; import { Flex } from "../../utils/Flex"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; @@ -111,182 +90,6 @@ interface IProps { focusRoomSearch?: boolean; } -interface IAppsSectionProps { - room: Room; -} - -export const useWidgets = (room: Room): IApp[] => { - const [apps, setApps] = useState(() => WidgetStore.instance.getApps(room.roomId)); - - const updateApps = useCallback(() => { - // Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings - setApps([...WidgetStore.instance.getApps(room.roomId)]); - }, [room]); - - useEffect(updateApps, [room, updateApps]); - useEventEmitter(WidgetStore.instance, room.roomId, updateApps); - useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateApps); - - return apps; -}; - -interface IAppRowProps { - app: IApp; - room: Room; -} - -const AppRow: React.FC = ({ app, room }) => { - const name = WidgetUtils.getWidgetName(app); - const dataTitle = WidgetUtils.getWidgetDataTitle(app); - const subtitle = dataTitle && " - " + dataTitle; - const [canModifyWidget, setCanModifyWidget] = useState(); - - useEffect(() => { - setCanModifyWidget(WidgetUtils.canUserModifyWidgets(room.client, room.roomId)); - }, [room.client, room.roomId]); - - const onOpenWidgetClick = (): void => { - RightPanelStore.instance.pushCard({ - phase: RightPanelPhases.Widget, - state: { widgetId: app.id }, - }); - }; - - const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top); - const togglePin = isPinned - ? () => { - WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); - } - : () => { - WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); - }; - - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - let contextMenu; - if (menuDisplayed) { - const rect = handle.current?.getBoundingClientRect(); - const rightMargin = rect?.right ?? 0; - const topMargin = rect?.top ?? 0; - contextMenu = ( - - ); - } - - const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top); - - let pinTitle: string; - if (cannotPin) { - pinTitle = _t("right_panel|pinned_messages|limits", { count: MAX_PINNED }); - } else { - pinTitle = isPinned ? _t("action|unpin") : _t("action|pin"); - } - - const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, Container.Center); - const toggleMaximised = isMaximised - ? () => { - WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); - } - : () => { - WidgetLayoutStore.instance.moveToContainer(room, app, Container.Center); - }; - - const maximiseTitle = isMaximised ? _t("action|close") : _t("action|maximise"); - - let openTitle = ""; - if (isPinned) { - openTitle = _t("widget|unpin_to_view_right_panel"); - } else if (isMaximised) { - openTitle = _t("widget|close_to_view_right_panel"); - } - - const classes = classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", { - mx_RoomSummaryCard_Button_pinned: isPinned, - mx_RoomSummaryCard_Button_maximised: isMaximised, - }); - - return ( -
- - - {name} - {subtitle} - - - {canModifyWidget && ( - - )} - - - - - {contextMenu} -
- ); -}; - -const AppsSection: React.FC = ({ room }) => { - const apps = useWidgets(room); - // Filter out virtual widgets - const realApps = useMemo(() => apps.filter((app) => app.eventId !== undefined), [apps]); - - const onManageIntegrations = (): void => { - const managers = IntegrationManagers.sharedInstance(); - if (!managers.hasManager()) { - managers.openNoManagerDialog(); - } else { - // noinspection JSIgnoredPromiseFromCall - managers.getPrimaryManager()?.open(room); - } - }; - - let copyLayoutBtn: JSX.Element | null = null; - if (realApps.length > 0 && WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) { - copyLayoutBtn = ( - WidgetLayoutStore.instance.copyLayoutToRoom(room)}> - {_t("widget|set_room_layout")} - - ); - } - - return ( - - {realApps.map((app) => ( - - ))} - {copyLayoutBtn} - - {realApps.length > 0 ? _t("right_panel|edit_integrations") : _t("right_panel|add_integrations")} - - - ); -}; - const onRoomFilesClick = (): void => { RightPanelStore.instance.pushCard({ phase: RightPanelPhases.FilePanel }, true); }; @@ -622,10 +425,6 @@ const RoomSummaryCard: React.FC = ({ onSelect={onLeaveRoomClick} /> - - {SettingsStore.getValue(UIFeature.Widgets) && - !isVideoRoom && - shouldShowComponent(UIComponent.AddIntegrations) && } ); }; diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index ca7cdebb21f..210e673556a 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -19,10 +19,9 @@ import { Room } from "matrix-js-sdk/src/matrix"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import BaseCard from "./BaseCard"; -import WidgetUtils from "../../../utils/WidgetUtils"; +import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils"; import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; -import { useWidgets } from "./RoomSummaryCard"; import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; diff --git a/src/components/views/rooms/LegacyRoomHeader.tsx b/src/components/views/rooms/LegacyRoomHeader.tsx index c6fa28fc7ca..b3f76e980f1 100644 --- a/src/components/views/rooms/LegacyRoomHeader.tsx +++ b/src/components/views/rooms/LegacyRoomHeader.tsx @@ -54,7 +54,7 @@ import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHa import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings"; import SdkConfig from "../../../SdkConfig"; import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; -import { useWidgets } from "../right_panel/RoomSummaryCard"; +import { useWidgets } from "../../../utils/WidgetUtils"; import { WidgetType } from "../../../widgets/WidgetType"; import { useCall, useLayout } from "../../../hooks/useCall"; import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts index 8dc18040a12..65be125bf32 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -22,7 +22,7 @@ import { useFeatureEnabled } from "../useSettings"; import SdkConfig from "../../SdkConfig"; import { useEventEmitter, useEventEmitterState } from "../useEventEmitter"; import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; -import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard"; +import { useWidgets } from "../../utils/WidgetUtils"; import { WidgetType } from "../../widgets/WidgetType"; import { useCall, useConnectionState, useParticipantCount } from "../useCall"; import { useRoomMemberCount } from "../useRoomMembers"; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a542fc7c601..a17b04a9f69 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -474,6 +474,7 @@ "encrypted": "Encrypted", "encryption_enabled": "Encryption enabled", "error": "Error", + "extensions": "Extensions", "faq": "FAQ", "favourites": "Favourites", "feedback": "Feedback", @@ -1830,10 +1831,11 @@ "restore_failed_error": "Unable to restore backup" }, "right_panel": { - "add_integrations": "Add widgets, bridges & bots", + "add_integrations": "Add extensions", "add_topic": "Add topic", - "edit_integrations": "Edit widgets, bridges & bots", "export_chat_button": "Export chat", + "extensions_empty_description": "Select “%(addIntegrations)s” to browse and add extensions to this room", + "extensions_empty_title": "Boost productivity with more tools, widgets and bots", "files_button": "Files", "info": "Info", "pinned_messages": { @@ -4067,7 +4069,7 @@ "title": "Allow this widget to verify your identity" }, "popout": "Popout widget", - "set_room_layout": "Set my room layout for everyone", + "set_room_layout": "Set layout for everyone", "shared_data_avatar": "Your profile picture URL", "shared_data_device_id": "Your device ID", "shared_data_lang": "Your language", diff --git a/src/stores/right-panel/RightPanelStorePhases.ts b/src/stores/right-panel/RightPanelStorePhases.ts index 2353d3fe43f..4cae6768302 100644 --- a/src/stores/right-panel/RightPanelStorePhases.ts +++ b/src/stores/right-panel/RightPanelStorePhases.ts @@ -28,6 +28,7 @@ export enum RightPanelPhases { Widget = "Widget", PinnedMessages = "PinnedMessages", Timeline = "Timeline", + Extensions = "Extensions", Room3pidMemberInfo = "Room3pidMemberInfo", diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 3272a14e4e9..40712f633a1 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -15,6 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { useCallback, useEffect, useState } from "react"; import { base32 } from "rfc4648"; import { IWidget, IWidgetData } from "matrix-widget-api"; import { Room, ClientEvent, MatrixClient, RoomStateEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; @@ -32,8 +33,10 @@ import { WidgetType } from "../widgets/WidgetType"; import { Jitsi } from "../widgets/Jitsi"; import { objectClone } from "./objects"; import { _t } from "../languageHandler"; -import { IApp, isAppWidget } from "../stores/WidgetStore"; +import WidgetStore, { IApp, isAppWidget } from "../stores/WidgetStore"; import { parseUrl } from "./UrlUtils"; +import { useEventEmitter } from "../hooks/useEventEmitter"; +import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; // How long we wait for the state event echo to come back from the server // before waitFor[Room/User]Widget rejects its promise @@ -562,3 +565,22 @@ export default class WidgetUtils { return false; } } + +/** + * Hook to get the widgets for a room and update when they change + * @param room the room to get widgets for + */ +export const useWidgets = (room: Room): IApp[] => { + const [apps, setApps] = useState(() => WidgetStore.instance.getApps(room.roomId)); + + const updateApps = useCallback(() => { + // Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings + setApps([...WidgetStore.instance.getApps(room.roomId)]); + }, [room]); + + useEffect(updateApps, [room, updateApps]); + useEventEmitter(WidgetStore.instance, room.roomId, updateApps); + useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateApps); + + return apps; +}; diff --git a/test/components/views/right_panel/ExtensionsCard-test.tsx b/test/components/views/right_panel/ExtensionsCard-test.tsx new file mode 100644 index 00000000000..b34db13b7ae --- /dev/null +++ b/test/components/views/right_panel/ExtensionsCard-test.tsx @@ -0,0 +1,159 @@ +/* +Copyright 2024 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 from "react"; +import { mocked, Mocked } from "jest-mock"; +import { render, screen } from "@testing-library/react"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { MatrixWidgetType } from "matrix-widget-api"; +import userEvent from "@testing-library/user-event"; + +import ExtensionsCard from "../../../../src/components/views/right_panel/ExtensionsCard"; +import { stubClient } from "../../../test-utils"; +import { IApp } from "../../../../src/stores/WidgetStore"; +import WidgetUtils, { useWidgets } from "../../../../src/utils/WidgetUtils"; +import { WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore"; +import { IntegrationManagers } from "../../../../src/integrations/IntegrationManagers"; + +jest.mock("../../../../src/utils/WidgetUtils"); + +describe("", () => { + let client: Mocked; + let room: Room; + + beforeEach(() => { + client = mocked(stubClient()); + room = new Room("!room:server", client, client.getSafeUserId()); + mocked(WidgetUtils.getWidgetName).mockImplementation((app) => app?.name ?? "No Name"); + }); + + it("should render empty state", () => { + mocked(useWidgets).mockReturnValue([]); + const { asFragment } = render(); + expect(screen.getByText("Boost productivity with more tools, widgets and bots")).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render widgets", async () => { + mocked(useWidgets).mockReturnValue([ + { + id: "id", + roomId: room.roomId, + eventId: "$event1", + creatorUserId: client.getSafeUserId(), + type: MatrixWidgetType.Custom, + name: "Custom Widget", + url: "http://url1", + }, + { + id: "jitsi", + roomId: room.roomId, + eventId: "$event2", + creatorUserId: client.getSafeUserId(), + type: MatrixWidgetType.JitsiMeet, + name: "Jitsi", + url: "http://jitsi", + }, + ] satisfies IApp[]); + + const { asFragment } = render(); + expect(screen.getByText("Custom Widget")).toBeInTheDocument(); + expect(screen.getByText("Jitsi")).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should show context menu on widget row", async () => { + jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true); + mocked(useWidgets).mockReturnValue([ + { + id: "id", + roomId: room.roomId, + eventId: "$event1", + creatorUserId: client.getSafeUserId(), + type: MatrixWidgetType.Custom, + name: "Custom Widget", + url: "http://url1", + }, + ] satisfies IApp[]); + + const { container } = render(); + await userEvent.click(container.querySelector(".mx_ExtensionsCard_app_options")!); + expect(document.querySelector(".mx_IconizedContextMenu")).toMatchSnapshot(); + }); + + it("should show set room layout button", async () => { + jest.spyOn(WidgetLayoutStore.instance, "canCopyLayoutToRoom").mockReturnValue(true); + mocked(useWidgets).mockReturnValue([ + { + id: "id", + roomId: room.roomId, + eventId: "$event1", + creatorUserId: client.getSafeUserId(), + type: MatrixWidgetType.Custom, + name: "Custom Widget", + url: "http://url1", + }, + ] satisfies IApp[]); + + render(); + expect(screen.getByText("Set layout for everyone")).toBeInTheDocument(); + }); + + it("should show widget as pinned", async () => { + jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true); + mocked(useWidgets).mockReturnValue([ + { + id: "id", + roomId: room.roomId, + eventId: "$event1", + creatorUserId: client.getSafeUserId(), + type: MatrixWidgetType.Custom, + name: "Custom Widget", + url: "http://url1", + }, + ] satisfies IApp[]); + + render(); + expect(screen.getByText("Custom Widget").closest(".mx_ExtensionsCard_Button_pinned")).toBeInTheDocument(); + }); + + it("should show cannot pin warning", async () => { + jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false); + jest.spyOn(WidgetLayoutStore.instance, "canAddToContainer").mockReturnValue(false); + mocked(useWidgets).mockReturnValue([ + { + id: "id", + roomId: room.roomId, + eventId: "$event1", + creatorUserId: client.getSafeUserId(), + type: MatrixWidgetType.Custom, + name: "Custom Widget", + url: "http://url1", + }, + ] satisfies IApp[]); + + render(); + expect(screen.getByLabelText("You can only pin up to 3 widgets")).toBeInTheDocument(); + }); + + it("should should open integration manager on click", async () => { + jest.spyOn(IntegrationManagers.sharedInstance(), "hasManager").mockReturnValue(false); + const spy = jest.spyOn(IntegrationManagers.sharedInstance(), "openNoManagerDialog"); + render(); + await userEvent.click(screen.getByText("Add extensions")); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/right_panel/RightPanelTabs-test.tsx b/test/components/views/right_panel/RightPanelTabs-test.tsx index dae7b1a79a5..4f702a46aef 100644 --- a/test/components/views/right_panel/RightPanelTabs-test.tsx +++ b/test/components/views/right_panel/RightPanelTabs-test.tsx @@ -38,8 +38,8 @@ describe("", () => { const { container } = render(); expect(container).toMatchSnapshot(); // Assert that the active tab is Info - expect(container.querySelectorAll("[aria-selected='true'").length).toEqual(1); - expect(container.querySelector("[aria-selected='true'")).toHaveAccessibleName("People"); + expect(container.querySelectorAll("[aria-selected='true']").length).toEqual(1); + expect(container.querySelector("[aria-selected='true']")).toHaveAccessibleName("People"); }); it("Renders nothing for some phases, eg: FilePanel", () => { diff --git a/test/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap b/test/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap new file mode 100644 index 00000000000..2cff7803ac9 --- /dev/null +++ b/test/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap @@ -0,0 +1,194 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render empty state 1`] = ` + +
+
+ +
+
+
+
+

+ Boost productivity with more tools, widgets and bots +

+

+ Select “Add extensions” to browse and add extensions to this room +

+
+
+
+ +`; + +exports[` should render widgets 1`] = ` + +
+
+ +
+
+ @@ -113,6 +127,20 @@ exports[` Correct tab is active 1`] = ` Threads +
diff --git a/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap b/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap index e2bee3ac7cd..1986c2c0362 100644 --- a/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap +++ b/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap @@ -414,20 +414,6 @@ exports[` has button to edit topic 1`] = `
-
-

- Widgets -

- -
@@ -820,20 +806,6 @@ exports[` renders the room summary 1`] = ` -
-

- Widgets -

- -
@@ -1253,20 +1225,6 @@ exports[` renders the room topic in the summary 1`] = ` -
-

- Widgets -

- -