Skip to content

Commit fb2db45

Browse files
committed
feat(right_panel): mvvm widget card
1 parent 795bbfc commit fb2db45

File tree

6 files changed

+336
-1
lines changed

6 files changed

+336
-1
lines changed

src/components/structures/RightPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases
1616
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
1717
import MatrixClientContext from "../../contexts/MatrixClientContext";
1818
import RoomSummaryCardView from "../views/right_panel/RoomSummaryCardView";
19-
import WidgetCard from "../views/right_panel/WidgetCard";
2019
import UserInfo from "../views/right_panel/UserInfo";
2120
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
2221
import FilePanel from "./FilePanel";
@@ -35,6 +34,7 @@ import { type XOR } from "../../@types/common";
3534
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
3635
import MemberListView from "../views/rooms/MemberList/MemberListView";
3736
import { _t } from "../../languageHandler";
37+
import { WidgetCard } from "../viewmodels/right_panel/WidgetCardViewModel";
3838

3939
interface BaseProps {
4040
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
9+
import React, { type JSX, useEffect, useMemo } from "react";
10+
11+
import { BaseViewModel } from "../../../viewmodels/base/BaseViewModel";
12+
import {
13+
type WidgetCardViewSnapshot,
14+
type WidgetCardViewModel as WidgetCardViewModelInterface,
15+
WidgetCardView,
16+
} from "../../../shared-components/right-panel/WidgetCardView/WidgetCardView";
17+
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
18+
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
19+
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
20+
import { MatrixClientPeg } from "../../../MatrixClientPeg";
21+
import { type IApp } from "../../../utils/WidgetUtils-types";
22+
23+
24+
interface WidgetCardProps {
25+
room: Room;
26+
widgetId: string;
27+
onClose(): void;
28+
}
29+
30+
type WidgetCardViewModelProps = WidgetCardProps & {
31+
apps: IApp[];
32+
}
33+
34+
export class WidgetCardViewModel
35+
extends BaseViewModel<WidgetCardViewSnapshot, WidgetCardViewModelProps>
36+
implements WidgetCardViewModelInterface
37+
{
38+
private cli: MatrixClient | null;
39+
40+
public static computeSnapshot = (
41+
props: WidgetCardViewModelProps & { cli: MatrixClient | null },
42+
): WidgetCardViewSnapshot => {
43+
const { room, widgetId } = props;
44+
45+
const app = props.apps.find((a) => a.id === widgetId);
46+
const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Right);
47+
let shouldEmptyWidgetCard = !isRight;
48+
49+
if (!app || !isRight) {
50+
// stop showing this card
51+
RightPanelStore.instance.popCard();
52+
shouldEmptyWidgetCard = true;
53+
}
54+
55+
return {
56+
room,
57+
app,
58+
creatorUserId: app ? app.creatorUserId : undefined,
59+
widgetPageTitle: WidgetUtils.getWidgetDataTitle(app),
60+
userId: props.cli!.getSafeUserId(),
61+
widgetName: WidgetUtils.getWidgetName(app),
62+
shouldEmptyWidgetCard,
63+
};
64+
};
65+
66+
public constructor(props: WidgetCardViewModelProps) {
67+
const cli = MatrixClientPeg?.get();
68+
super(props, WidgetCardViewModel.computeSnapshot({ ...props, cli }));
69+
this.cli = cli;
70+
}
71+
72+
public onClose = (): void => {
73+
this.snapshot.set({
74+
room: this.props.room,
75+
app: undefined,
76+
creatorUserId: undefined,
77+
widgetPageTitle: WidgetUtils.getWidgetDataTitle(),
78+
userId: this.cli!.getSafeUserId(),
79+
widgetName: WidgetUtils.getWidgetName(),
80+
shouldEmptyWidgetCard: true,
81+
});
82+
this.props.onClose();
83+
};
84+
}
85+
86+
/**
87+
* WidgetCard component that initializes the WidgetCardViewModel and renders the WidgetCardView.
88+
*/
89+
export function WidgetCard(props: WidgetCardProps): JSX.Element {
90+
const { room, widgetId, onClose } = props;
91+
const apps = useWidgets(props.room);
92+
const vm = useMemo(() => new WidgetCardViewModel({ room, widgetId, apps, onClose }), [apps, room, widgetId, onClose]);
93+
94+
useEffect(() => {
95+
return () => {
96+
vm.dispose();
97+
};
98+
}, [vm]);
99+
100+
return <WidgetCardView vm={vm} />;
101+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { render, screen } from "jest-matrix-react";
9+
import { composeStories } from "@storybook/react-vite";
10+
import React from "react";
11+
import userEvent from "@testing-library/user-event";
12+
import { fireEvent } from "@testing-library/dom";
13+
14+
import * as stories from "./AudioPlayerView.stories.tsx";
15+
import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView";
16+
import { MockViewModel } from "../../MockViewModel.ts";
17+
18+
const { Default, NoMediaName, NoSize, HasError } = composeStories(stories);
19+
20+
describe("AudioPlayerView", () => {
21+
afterEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
it("renders the audio player in default state", () => {
26+
const { container } = render(<Default />);
27+
expect(container).toMatchSnapshot();
28+
});
29+
30+
it("renders the audio player without media name", () => {
31+
const { container } = render(<NoMediaName />);
32+
expect(container).toMatchSnapshot();
33+
});
34+
35+
it("renders the audio player without size", () => {
36+
const { container } = render(<NoSize />);
37+
expect(container).toMatchSnapshot();
38+
});
39+
40+
it("renders the audio player in error state", () => {
41+
const { container } = render(<HasError />);
42+
expect(container).toMatchSnapshot();
43+
});
44+
45+
const onKeyDown = jest.fn();
46+
const togglePlay = jest.fn();
47+
const onSeekbarChange = jest.fn();
48+
49+
class AudioPlayerViewModel extends MockViewModel<AudioPlayerViewSnapshot> implements AudioPlayerViewActions {
50+
public onKeyDown = onKeyDown;
51+
public togglePlay = togglePlay;
52+
public onSeekbarChange = onSeekbarChange;
53+
}
54+
55+
it("should attach vm methods", async () => {
56+
const user = userEvent.setup();
57+
const vm = new AudioPlayerViewModel({
58+
playbackState: "stopped",
59+
mediaName: "Test Audio",
60+
durationSeconds: 300,
61+
playedSeconds: 120,
62+
percentComplete: 30,
63+
sizeBytes: 3500,
64+
error: false,
65+
});
66+
67+
render(<AudioPlayerView vm={vm} />);
68+
await user.click(screen.getByRole("button", { name: "Play" }));
69+
expect(togglePlay).toHaveBeenCalled();
70+
71+
// user event doesn't support change events on sliders, so we use fireEvent
72+
fireEvent.change(screen.getByRole("slider", { name: "Audio seek bar" }), { target: { value: "50" } });
73+
expect(onSeekbarChange).toHaveBeenCalled();
74+
75+
await user.type(screen.getByLabelText("Audio player"), "{arrowup}");
76+
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "ArrowUp" }));
77+
});
78+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { type JSX } from "react";
9+
import { fn } from "storybook/test";
10+
11+
import type { Meta, StoryFn } from "@storybook/react-vite";
12+
import { ViewWrapper } from "../../ViewWrapper";
13+
import { WidgetCardView, WidgetCardViewModel, WidgetCardViewSnapshot } from "./WidgetCardView";
14+
15+
const WidgeCardViewWrapper = (props: WidgetCardViewModel): JSX.Element => (
16+
<ViewWrapper<WidgetCardViewSnapshot, WidgetCardViewModel> Component={WidgetCardView} props={props} />
17+
);
18+
19+
export default {
20+
title: "RightPanel/WidgetCardView",
21+
component: WidgetCardView,
22+
tags: ["autodocs"],
23+
args: {
24+
room: "roomId",
25+
app: undefined,
26+
userId: "@userId",
27+
widgetPageTitle: "",
28+
widgetName: "",
29+
shouldEmptyWidgetCard: true,
30+
creatorUserId: undefined,
31+
onClose: () => void
32+
}
33+
} as unknown as Meta<typeof WidgeCardViewWrapper>;
34+
35+
const Template: StoryFn<typeof WidgeCardViewWrapper> = (args) => <WidgeCardViewWrapper {...args} />;
36+
37+
export const Default = Template.bind({});
38+
39+
export const WidgetCreated = Template.bind({});
40+
WidgetCreated.args = {
41+
room: "roomId",
42+
app: undefined,
43+
userId: "@userId",
44+
widgetPageTitle: "",
45+
widgetName: "",
46+
shouldEmptyWidgetCard: true,
47+
creatorUserId: undefined,
48+
onClose: () => void
49+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
Copyright 2024 New Vector Ltd.
3+
Copyright 2020 The Matrix.org Foundation C.I.C.
4+
5+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
6+
Please see LICENSE files in the repository root for full details.
7+
*/
8+
9+
import React, { type JSX } from "react";
10+
import { type Room } from "matrix-js-sdk/src/matrix";
11+
12+
import BaseCard from "../../../components/views/right_panel/BaseCard";
13+
import AppTile from "../../../components/views/elements/AppTile";
14+
import { _t } from "../../../languageHandler";
15+
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../../components/structures/ContextMenu";
16+
import { WidgetContextMenu } from "../../../components/views/context_menus/WidgetContextMenu";
17+
import UIStore from "../../../stores/UIStore";
18+
import Heading from "../../../components/views/typography/Heading";
19+
import { type ViewModel } from "../../ViewModel";
20+
import { type IApp } from "../../../utils/WidgetUtils-types";
21+
import { useViewModel } from "../../useViewModel";
22+
23+
24+
export interface WidgetCardViewSnapshot {
25+
room: Room;
26+
app: IApp | undefined;
27+
userId: string;
28+
widgetPageTitle: string;
29+
widgetName: string;
30+
shouldEmptyWidgetCard: boolean;
31+
creatorUserId: string | undefined;
32+
}
33+
34+
interface WidgetCardViewActions {
35+
onClose: () => void;
36+
}
37+
/**
38+
* The view model for the widget card
39+
*/
40+
export type WidgetCardViewModel = ViewModel<WidgetCardViewSnapshot> & WidgetCardViewActions;
41+
42+
interface WidgetCardViewProps {
43+
vm: WidgetCardViewModel;
44+
}
45+
46+
export const WidgetCardView: React.FC<WidgetCardViewProps> = ({ vm }: Readonly<WidgetCardViewProps>) => {
47+
const { room, app, userId, widgetPageTitle, widgetName, shouldEmptyWidgetCard } = useViewModel(vm);
48+
49+
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
50+
51+
let contextMenu: JSX.Element | undefined;
52+
if (menuDisplayed && app) {
53+
const rect = handle.current?.getBoundingClientRect();
54+
const rightMargin = rect ? rect.right : 0;
55+
const bottomMargin = rect ? rect.bottom : 0;
56+
contextMenu = (
57+
<WidgetContextMenu
58+
chevronFace={ChevronFace.None}
59+
right={UIStore.instance.windowWidth - rightMargin - 12}
60+
top={bottomMargin + 12}
61+
onFinished={closeMenu}
62+
app={app}
63+
/>
64+
);
65+
}
66+
67+
const header = (
68+
<div className="mx_BaseCard_header_title">
69+
<Heading size="4" className="mx_BaseCard_header_title_heading" as="h1">
70+
{widgetName}
71+
</Heading>
72+
<ContextMenuButton
73+
className="mx_BaseCard_header_title_button--option"
74+
ref={handle}
75+
onClick={openMenu}
76+
isExpanded={menuDisplayed}
77+
label={_t("common|options")}
78+
/>
79+
{contextMenu}
80+
</div>
81+
);
82+
83+
if (shouldEmptyWidgetCard || !app) return null;
84+
85+
return (
86+
<BaseCard header={header} className="mx_WidgetCard" onClose={vm.onClose} withoutScrollContainer>
87+
<AppTile
88+
app={app}
89+
fullWidth
90+
showMenubar={false}
91+
room={room}
92+
userId={userId}
93+
creatorUserId={app.creatorUserId}
94+
widgetPageTitle={widgetPageTitle}
95+
waitForIframeLoad={app.waitForIframeLoad}
96+
/>
97+
</BaseCard>
98+
);
99+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
export { WidgetCardView } from "./WidgetCardView";

0 commit comments

Comments
 (0)