diff --git a/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView-test.tsx b/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView-test.tsx new file mode 100644 index 00000000000..d3852d9290f --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView-test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render, screen } from "jest-matrix-react"; +import { composeStories } from "@storybook/react-vite"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { fireEvent } from "@testing-library/dom"; + +import * as stories from "./AudioPlayerView.stories.tsx"; +import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView"; +import { MockViewModel } from "../../MockViewModel.ts"; + +const { Default, NoMediaName, NoSize, HasError } = composeStories(stories); + +describe("AudioPlayerView", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders the audio player in default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player without media name", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player without size", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player in error state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + const onKeyDown = jest.fn(); + const togglePlay = jest.fn(); + const onSeekbarChange = jest.fn(); + + class AudioPlayerViewModel extends MockViewModel implements AudioPlayerViewActions { + public onKeyDown = onKeyDown; + public togglePlay = togglePlay; + public onSeekbarChange = onSeekbarChange; + } + + it("should attach vm methods", async () => { + const user = userEvent.setup(); + const vm = new AudioPlayerViewModel({ + playbackState: "stopped", + mediaName: "Test Audio", + durationSeconds: 300, + playedSeconds: 120, + percentComplete: 30, + sizeBytes: 3500, + error: false, + }); + + render(); + await user.click(screen.getByRole("button", { name: "Play" })); + expect(togglePlay).toHaveBeenCalled(); + + // user event doesn't support change events on sliders, so we use fireEvent + fireEvent.change(screen.getByRole("slider", { name: "Audio seek bar" }), { target: { value: "50" } }); + expect(onSeekbarChange).toHaveBeenCalled(); + + await user.type(screen.getByLabelText("Audio player"), "{arrowup}"); + expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "ArrowUp" })); + }); +}); diff --git a/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView.stories.tsx b/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView.stories.tsx new file mode 100644 index 00000000000..1a33e2fddad --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView.stories.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { ViewWrapper } from "../../ViewWrapper"; +import { WidgetCardView, WidgetCardViewActions, WidgetCardViewModel, WidgetCardViewSnapshot } from "./WidgetCardView"; +import { Room } from "matrix-js-sdk/src/matrix"; + +type WidgetCardProps = WidgetCardViewSnapshot & WidgetCardViewActions; + +const WidgeCardViewWrapper = (props: WidgetCardProps): JSX.Element => ( + Component={WidgetCardView} props={props} /> +); + +export default { + title: "RightPanel/WidgetCardView", + component: WidgeCardViewWrapper, + tags: ["autodocs"], + args: { + room: new Room("roomId"), + app: undefined, + userId: "@userId", + widgetPageTitle: "", + widgetName: "", + shouldEmptyWidgetCard: true, + creatorUserId: undefined, + onClose: () => void + } +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +// export const WidgetCreated = Template.bind({}); +// WidgetCreated.args = { +// room: "roomId", +// app: undefined, +// userId: "@userId", +// widgetPageTitle: "", +// widgetName: "", +// shouldEmptyWidgetCard: true, +// creatorUserId: undefined, +// onClose: () => void +// }; diff --git a/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView.tsx b/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView.tsx new file mode 100644 index 00000000000..9ca51dc4462 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView.tsx @@ -0,0 +1,99 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; + +import BaseCard from "../../../components/views/right_panel/BaseCard"; +import AppTile from "../../../components/views/elements/AppTile"; +import { _t } from "../../../languageHandler"; +import { ChevronFace, ContextMenuButton, useContextMenu } from "../../../components/structures/ContextMenu"; +import { WidgetContextMenu } from "../../../components/views/context_menus/WidgetContextMenu"; +import UIStore from "../../../stores/UIStore"; +import Heading from "../../../components/views/typography/Heading"; +import { type ViewModel } from "../../ViewModel"; +import { type IApp } from "../../../utils/WidgetUtils-types"; +import { useViewModel } from "../../useViewModel"; + + +export interface WidgetCardViewSnapshot { + room: Room; + app: IApp | undefined; + userId: string; + widgetPageTitle: string; + widgetName: string; + shouldEmptyWidgetCard: boolean; + creatorUserId: string | undefined; +} + +export interface WidgetCardViewActions { + onClose: () => void; +} +/** + * The view model for the widget card + */ +export type WidgetCardViewModel = ViewModel & WidgetCardViewActions; + +interface WidgetCardViewProps { + vm: WidgetCardViewModel; +} + +export const WidgetCardView: React.FC = ({ vm }: Readonly) => { + const { room, app, userId, widgetPageTitle, widgetName, shouldEmptyWidgetCard } = useViewModel(vm); + + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + let contextMenu: JSX.Element | undefined; + if (menuDisplayed && app) { + const rect = handle.current?.getBoundingClientRect(); + const rightMargin = rect ? rect.right : 0; + const bottomMargin = rect ? rect.bottom : 0; + contextMenu = ( + + ); + } + + const header = ( +
+ + {widgetName} + + + {contextMenu} +
+ ); + + if (shouldEmptyWidgetCard || !app) return null; + + return ( + + + + ); +}; diff --git a/packages/shared-components/src/right-panel/WidgetCardView/index.ts b/packages/shared-components/src/right-panel/WidgetCardView/index.ts new file mode 100644 index 00000000000..d44f3afd639 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetCardView/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { WidgetCardView } from "./WidgetCardView"; diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index e006b1d92aa..3f28acedf03 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -16,7 +16,6 @@ import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import RoomSummaryCardView from "../views/right_panel/RoomSummaryCardView"; -import WidgetCard from "../views/right_panel/WidgetCard"; import UserInfo from "../views/right_panel/UserInfo"; import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; import FilePanel from "./FilePanel"; @@ -35,6 +34,7 @@ import { type XOR } from "../../@types/common"; import ExtensionsCard from "../views/right_panel/ExtensionsCard"; import MemberListView from "../views/rooms/MemberList/MemberListView"; import { _t } from "../../languageHandler"; +import { WidgetCard } from "../viewmodels/right_panel/WidgetCardViewModel"; interface BaseProps { overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView) diff --git a/src/components/viewmodels/right_panel/WidgetCardViewModel.tsx b/src/components/viewmodels/right_panel/WidgetCardViewModel.tsx new file mode 100644 index 00000000000..9470bedc3fa --- /dev/null +++ b/src/components/viewmodels/right_panel/WidgetCardViewModel.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, useEffect, useMemo } from "react"; + +import { BaseViewModel } from "../../../viewmodels/base/BaseViewModel"; +import { + type WidgetCardViewSnapshot, + type WidgetCardViewModel as WidgetCardViewModelInterface, + WidgetCardView, +} from "../../../../packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView"; +import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils"; +import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; +import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { type IApp } from "../../../utils/WidgetUtils-types"; + + +interface WidgetCardProps { + room: Room; + widgetId: string; + onClose(): void; +} + +type WidgetCardViewModelProps = WidgetCardProps & { + apps: IApp[]; +} + +export class WidgetCardViewModel + extends BaseViewModel + implements WidgetCardViewModelInterface +{ + private cli: MatrixClient | null; + + public static computeSnapshot = ( + props: WidgetCardViewModelProps & { cli: MatrixClient | null }, + ): WidgetCardViewSnapshot => { + const { room, widgetId } = props; + + const app = props.apps.find((a) => a.id === widgetId); + const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Right); + let shouldEmptyWidgetCard = !isRight; + + if (!app || !isRight) { + // stop showing this card + RightPanelStore.instance.popCard(); + shouldEmptyWidgetCard = true; + } + + return { + room, + app, + creatorUserId: app ? app.creatorUserId : undefined, + widgetPageTitle: WidgetUtils.getWidgetDataTitle(app), + userId: props.cli!.getSafeUserId(), + widgetName: WidgetUtils.getWidgetName(app), + shouldEmptyWidgetCard, + }; + }; + + public constructor(props: WidgetCardViewModelProps) { + const cli = MatrixClientPeg?.get(); + super(props, WidgetCardViewModel.computeSnapshot({ ...props, cli })); + this.cli = cli; + } + + public onClose = (): void => { + this.snapshot.set({ + room: this.props.room, + app: undefined, + creatorUserId: undefined, + widgetPageTitle: WidgetUtils.getWidgetDataTitle(), + userId: this.cli!.getSafeUserId(), + widgetName: WidgetUtils.getWidgetName(), + shouldEmptyWidgetCard: true, + }); + this.props.onClose(); + }; +} + +/** + * WidgetCard component that initializes the WidgetCardViewModel and renders the WidgetCardView. + */ +export function WidgetCard(props: WidgetCardProps): JSX.Element { + const { room, widgetId, onClose } = props; + const apps = useWidgets(props.room); + const vm = useMemo(() => new WidgetCardViewModel({ room, widgetId, apps, onClose }), [apps, room, widgetId, onClose]); + + useEffect(() => { + return () => { + vm.dispose(); + }; + }, [vm]); + + return ; +}