Extract Extensions into their own right panel tab (#12844)
* Extract useIsVideoRoom hook Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Move useWidgets hook to WidgetUtils Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Extract Extensions into their own right panel tab Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove unused components & classes Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update screenshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
fae5bf1612
commit
b55653ddf0
25 changed files with 820 additions and 475 deletions
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
Binary file not shown.
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
145
res/css/views/right_panel/_ExtensionsCard.pcss
Normal file
145
res/css/views/right_panel/_ExtensionsCard.pcss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<Props, IState> {
|
|||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Extensions:
|
||||
if (!!this.props.room) {
|
||||
card = <ExtensionsCard room={this.props.room} onClose={this.onClose} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Widget:
|
||||
if (!!this.props.room && !!cardState?.widgetId) {
|
||||
card = <WidgetCard room={this.props.room} widgetId={cardState.widgetId} onClose={this.onClose} />;
|
||||
|
@ -315,7 +322,7 @@ export default class RightPanel extends React.Component<Props, IState> {
|
|||
|
||||
return (
|
||||
<aside className="mx_RightPanel" id="mx_RightPanel">
|
||||
{phase && <RightPanelTabs phase={phase} />}
|
||||
{phase && <RightPanelTabs room={this.props.room} phase={phase} />}
|
||||
{card}
|
||||
</aside>
|
||||
);
|
||||
|
|
|
@ -41,26 +41,11 @@ interface IProps {
|
|||
onKeyDown?(ev: KeyboardEvent): void;
|
||||
cardState?: any;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
// Ref for the 'close' button the the card
|
||||
// Ref for the 'close' button the card
|
||||
closeButtonRef?: Ref<HTMLButtonElement>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface IGroupProps {
|
||||
className?: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Group: React.FC<IGroupProps> = ({ className, title, children }) => {
|
||||
return (
|
||||
<div className={classNames("mx_BaseCard_Group", className)}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
|
||||
(
|
||||
{
|
||||
|
|
214
src/components/views/right_panel/ExtensionsCard.tsx
Normal file
214
src/components/views/right_panel/ExtensionsCard.tsx
Normal file
|
@ -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<IAppRowProps> = ({ app, room }) => {
|
||||
const name = WidgetUtils.getWidgetName(app);
|
||||
const [canModifyWidget, setCanModifyWidget] = useState<boolean>();
|
||||
|
||||
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<HTMLDivElement>();
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const rect = handle.current?.getBoundingClientRect();
|
||||
const rightMargin = rect?.right ?? 0;
|
||||
const topMargin = rect?.top ?? 0;
|
||||
contextMenu = (
|
||||
<WidgetContextMenu
|
||||
chevronFace={ChevronFace.None}
|
||||
right={UIStore.instance.windowWidth - rightMargin}
|
||||
bottom={UIStore.instance.windowHeight - topMargin}
|
||||
onFinished={closeMenu}
|
||||
app={app}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classes} ref={handle}>
|
||||
<AccessibleButton
|
||||
className="mx_ExtensionsCard_icon_app"
|
||||
onClick={onOpenWidgetClick}
|
||||
// only show a tooltip if the widget is pinned
|
||||
title={!(isPinned || isMaximised) ? undefined : openTitle}
|
||||
disabled={isPinned || isMaximised}
|
||||
>
|
||||
<WidgetAvatar app={app} size="24px" />
|
||||
<Text size="md" weight="medium" className="mx_lineClamp">
|
||||
{name}
|
||||
</Text>
|
||||
</AccessibleButton>
|
||||
|
||||
{canModifyWidget && (
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_ExtensionsCard_app_options"
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={openMenu}
|
||||
title={_t("common|options")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AccessibleButton
|
||||
className="mx_ExtensionsCard_app_pinToggle"
|
||||
onClick={togglePin}
|
||||
title={pinTitle}
|
||||
disabled={cannotPin}
|
||||
/>
|
||||
|
||||
{contextMenu}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<Props> = ({ 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 = (
|
||||
<Button size="sm" onClick={onManageIntegrations} kind="secondary" Icon={PlusIcon}>
|
||||
{_t("right_panel|add_integrations")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
let body: JSX.Element;
|
||||
if (realApps.length < 1) {
|
||||
body = (
|
||||
<EmptyState
|
||||
Icon={ExtensionsIcon}
|
||||
title={_t("right_panel|extensions_empty_title")}
|
||||
description={_t("right_panel|extensions_empty_description", {
|
||||
addIntegrations: _t("right_panel|add_integrations"),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
let copyLayoutBtn: JSX.Element | null = null;
|
||||
if (WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) {
|
||||
copyLayoutBtn = (
|
||||
<Link onClick={() => WidgetLayoutStore.instance.copyLayoutToRoom(room)}>
|
||||
{_t("widget|set_room_layout")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
body = (
|
||||
<>
|
||||
<Separator />
|
||||
{realApps.map((app) => (
|
||||
<AppRow key={app.id} app={app} room={room} />
|
||||
))}
|
||||
{copyLayoutBtn}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseCard header={header} className="mx_ExtensionsCard" onClose={onClose} hideHeaderButtons>
|
||||
{body}
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtensionsCard;
|
|
@ -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<Props> = ({ phase }): JSX.Element | null => {
|
||||
export const RightPanelTabs: React.FC<Props> = ({ phase, room }): JSX.Element | null => {
|
||||
const threadsTabRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useDispatcher(dispatcher, (payload) => {
|
||||
|
@ -45,6 +56,8 @@ export const RightPanelTabs: React.FC<Props> = ({ phase }): JSX.Element | null =
|
|||
}
|
||||
});
|
||||
|
||||
const isVideoRoom = useIsVideoRoom(room);
|
||||
|
||||
if (!shouldShowTabsForPhase(phase)) return null;
|
||||
|
||||
return (
|
||||
|
@ -81,6 +94,20 @@ export const RightPanelTabs: React.FC<Props> = ({ phase }): JSX.Element | null =
|
|||
>
|
||||
{_t("common|threads")}
|
||||
</NavItem>
|
||||
{SettingsStore.getValue(UIFeature.Widgets) &&
|
||||
!isVideoRoom &&
|
||||
shouldShowComponent(UIComponent.AddIntegrations) && (
|
||||
<NavItem
|
||||
aria-controls="thread-panel"
|
||||
id="extensions-panel-tab"
|
||||
onClick={() => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.Extensions }, true);
|
||||
}}
|
||||
active={phase === RightPanelPhases.Extensions}
|
||||
>
|
||||
{_t("common|extensions")}
|
||||
</NavItem>
|
||||
)}
|
||||
</NavBar>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<IApp[]>(() => 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<IAppRowProps> = ({ app, room }) => {
|
||||
const name = WidgetUtils.getWidgetName(app);
|
||||
const dataTitle = WidgetUtils.getWidgetDataTitle(app);
|
||||
const subtitle = dataTitle && " - " + dataTitle;
|
||||
const [canModifyWidget, setCanModifyWidget] = useState<boolean>();
|
||||
|
||||
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<HTMLDivElement>();
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const rect = handle.current?.getBoundingClientRect();
|
||||
const rightMargin = rect?.right ?? 0;
|
||||
const topMargin = rect?.top ?? 0;
|
||||
contextMenu = (
|
||||
<WidgetContextMenu
|
||||
chevronFace={ChevronFace.None}
|
||||
right={UIStore.instance.windowWidth - rightMargin}
|
||||
bottom={UIStore.instance.windowHeight - topMargin}
|
||||
onFinished={closeMenu}
|
||||
app={app}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classes} ref={handle}>
|
||||
<AccessibleButton
|
||||
className="mx_RoomSummaryCard_icon_app"
|
||||
onClick={onOpenWidgetClick}
|
||||
// only show a tooltip if the widget is pinned
|
||||
title={!(isPinned || isMaximised) ? undefined : openTitle}
|
||||
disabled={isPinned || isMaximised}
|
||||
>
|
||||
<WidgetAvatar app={app} size="20px" />
|
||||
<span>{name}</span>
|
||||
{subtitle}
|
||||
</AccessibleButton>
|
||||
|
||||
{canModifyWidget && (
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_RoomSummaryCard_app_options"
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={openMenu}
|
||||
title={_t("common|options")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AccessibleButton
|
||||
className="mx_RoomSummaryCard_app_pinToggle"
|
||||
onClick={togglePin}
|
||||
title={pinTitle}
|
||||
disabled={cannotPin}
|
||||
/>
|
||||
<AccessibleButton
|
||||
className="mx_RoomSummaryCard_app_maximiseToggle"
|
||||
onClick={toggleMaximised}
|
||||
title={maximiseTitle}
|
||||
/>
|
||||
|
||||
{contextMenu}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppsSection: React.FC<IAppsSectionProps> = ({ 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 = (
|
||||
<AccessibleButton kind="link" onClick={() => WidgetLayoutStore.instance.copyLayoutToRoom(room)}>
|
||||
{_t("widget|set_room_layout")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group className="mx_RoomSummaryCard_appsGroup" title={_t("right_panel|widgets_section")}>
|
||||
{realApps.map((app) => (
|
||||
<AppRow key={app.id} app={app} room={room} />
|
||||
))}
|
||||
{copyLayoutBtn}
|
||||
<AccessibleButton kind="link" onClick={onManageIntegrations}>
|
||||
{realApps.length > 0 ? _t("right_panel|edit_integrations") : _t("right_panel|add_integrations")}
|
||||
</AccessibleButton>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const onRoomFilesClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.FilePanel }, true);
|
||||
};
|
||||
|
@ -622,10 +425,6 @@ const RoomSummaryCard: React.FC<IProps> = ({
|
|||
onSelect={onLeaveRoomClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{SettingsStore.getValue(UIFeature.Widgets) &&
|
||||
!isVideoRoom &&
|
||||
shouldShowComponent(UIComponent.AddIntegrations) && <AppsSection room={room} />}
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -28,6 +28,7 @@ export enum RightPanelPhases {
|
|||
Widget = "Widget",
|
||||
PinnedMessages = "PinnedMessages",
|
||||
Timeline = "Timeline",
|
||||
Extensions = "Extensions",
|
||||
|
||||
Room3pidMemberInfo = "Room3pidMemberInfo",
|
||||
|
||||
|
|
|
@ -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<IApp[]>(() => 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;
|
||||
};
|
||||
|
|
159
test/components/views/right_panel/ExtensionsCard-test.tsx
Normal file
159
test/components/views/right_panel/ExtensionsCard-test.tsx
Normal file
|
@ -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("<ExtensionsCard />", () => {
|
||||
let client: Mocked<MatrixClient>;
|
||||
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(<ExtensionsCard room={room} onClose={jest.fn()} />);
|
||||
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(<ExtensionsCard room={room} onClose={jest.fn()} />);
|
||||
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(<ExtensionsCard room={room} onClose={jest.fn()} />);
|
||||
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(<ExtensionsCard room={room} onClose={jest.fn()} />);
|
||||
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(<ExtensionsCard room={room} onClose={jest.fn()} />);
|
||||
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(<ExtensionsCard room={room} onClose={jest.fn()} />);
|
||||
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(<ExtensionsCard room={room} onClose={jest.fn()} />);
|
||||
await userEvent.click(screen.getByText("Add extensions"));
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -38,8 +38,8 @@ describe("<RightPanelTabs />", () => {
|
|||
const { container } = render(<RightPanelTabs phase={RightPanelPhases.RoomMemberList} />);
|
||||
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", () => {
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<ExtensionsCard /> should render empty state 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_BaseCard mx_ExtensionsCard"
|
||||
>
|
||||
<div
|
||||
class="mx_BaseCard_header"
|
||||
>
|
||||
<button
|
||||
class="_button_zt6rp_17 _has-icon_zt6rp_61"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
height="20"
|
||||
width="20"
|
||||
/>
|
||||
Add extensions
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_AutoHideScrollbar"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Flex mx_EmptyState"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x);"
|
||||
>
|
||||
<div
|
||||
height="32px"
|
||||
width="32px"
|
||||
/>
|
||||
<p
|
||||
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
|
||||
>
|
||||
Boost productivity with more tools, widgets and bots
|
||||
</p>
|
||||
<p
|
||||
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
|
||||
>
|
||||
Select “Add extensions” to browse and add extensions to this room
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<ExtensionsCard /> should render widgets 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_BaseCard mx_ExtensionsCard"
|
||||
>
|
||||
<div
|
||||
class="mx_BaseCard_header"
|
||||
>
|
||||
<button
|
||||
class="_button_zt6rp_17 _has-icon_zt6rp_61"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
height="20"
|
||||
width="20"
|
||||
/>
|
||||
Add extensions
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_AutoHideScrollbar"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="_separator_144s5_17"
|
||||
data-kind="primary"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
<div
|
||||
class="mx_BaseCard_Button mx_ExtensionsCard_Button"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_ExtensionsCard_icon_app"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
aria-label="Avatar"
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar mx_WidgetAvatar"
|
||||
data-color="1"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 24px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_mcap2_50"
|
||||
data-type="round"
|
||||
height="24px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="image-file-stub"
|
||||
width="24px"
|
||||
/>
|
||||
</span>
|
||||
<p
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_lineClamp"
|
||||
>
|
||||
Custom Widget
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Pin"
|
||||
class="mx_AccessibleButton mx_ExtensionsCard_app_pinToggle"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mx_BaseCard_Button mx_ExtensionsCard_Button"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_ExtensionsCard_icon_app"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
aria-label="Avatar"
|
||||
class="_avatar_mcap2_17 mx_BaseAvatar mx_WidgetAvatar"
|
||||
data-color="1"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 24px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_mcap2_50"
|
||||
data-type="round"
|
||||
height="24px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="image-file-stub"
|
||||
width="24px"
|
||||
/>
|
||||
</span>
|
||||
<p
|
||||
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_lineClamp"
|
||||
>
|
||||
Jitsi
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Pin"
|
||||
class="mx_AccessibleButton mx_ExtensionsCard_app_pinToggle"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<ExtensionsCard /> should show context menu on widget row 1`] = `
|
||||
<ul
|
||||
class="mx_IconizedContextMenu"
|
||||
role="none"
|
||||
>
|
||||
<div
|
||||
class="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst"
|
||||
>
|
||||
<li
|
||||
aria-label="Remove for everyone"
|
||||
class="mx_AccessibleButton mx_IconizedContextMenu_item"
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_IconizedContextMenu_label"
|
||||
>
|
||||
Remove for everyone
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
`;
|
|
@ -54,6 +54,20 @@ exports[`<RightPanelTabs /> Component renders the correct tabs 1`] = `
|
|||
Threads
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
class="_nav-tab_135dy_33"
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
aria-controls="thread-panel"
|
||||
aria-selected="false"
|
||||
class="_nav-item_135dy_55"
|
||||
id="extensions-panel-tab"
|
||||
role="tab"
|
||||
>
|
||||
Extensions
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
@ -113,6 +127,20 @@ exports[`<RightPanelTabs /> Correct tab is active 1`] = `
|
|||
Threads
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
class="_nav-tab_135dy_33"
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
aria-controls="thread-panel"
|
||||
aria-selected="false"
|
||||
class="_nav-item_135dy_55"
|
||||
id="extensions-panel-tab"
|
||||
role="tab"
|
||||
>
|
||||
Extensions
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
@ -414,20 +414,6 @@ exports[`<RoomSummaryCard /> has button to edit topic 1`] = `
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_BaseCard_Group mx_RoomSummaryCard_appsGroup"
|
||||
>
|
||||
<h2>
|
||||
Widgets
|
||||
</h2>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Add widgets, bridges & bots
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -820,20 +806,6 @@ exports[`<RoomSummaryCard /> renders the room summary 1`] = `
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_BaseCard_Group mx_RoomSummaryCard_appsGroup"
|
||||
>
|
||||
<h2>
|
||||
Widgets
|
||||
</h2>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Add widgets, bridges & bots
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1253,20 +1225,6 @@ exports[`<RoomSummaryCard /> renders the room topic in the summary 1`] = `
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_BaseCard_Group mx_RoomSummaryCard_appsGroup"
|
||||
>
|
||||
<h2>
|
||||
Widgets
|
||||
</h2>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Add widgets, bridges & bots
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue