Add support for Widget & Room Header Buttons module APIs (#32734)

* Add support for Widget & Room Header Buttons module APIs

To support https://github.com/element-hq/element-modules/pull/217

* Update for new api

* Test addRoomHeaderButtonCallback

* Extra mock api

* Test for widgetapi

* Convert enum

* Convert other enum usage

* Add tests for widget context menu move buttons

Which have just changed because of the enum

* Add tests for moving the widgets

* Fix copyright

Co-authored-by: Florian Duros <florianduros@element.io>

* Update module API

* A little import/export

---------

Co-authored-by: Florian Duros <florianduros@element.io>
This commit is contained in:
David Baker
2026-03-13 13:44:18 +00:00
committed by GitHub
co-authored by Florian Duros
parent 86692ce0a7
commit 09bbf796dc
30 changed files with 553 additions and 225 deletions
@@ -90,7 +90,7 @@ import { CallView } from "../views/voip/CallView";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView";
@@ -141,6 +141,7 @@ import { isRoomEncrypted } from "../../hooks/useIsEncrypted";
import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
import { EncryptionEventViewModel } from "../../viewmodels/event-tiles/EncryptionEventViewModel.ts";
import { ModuleApi } from "../../modules/Api.ts";
const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@@ -953,7 +954,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter.
const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false" : true;
const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, Container.Top);
const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, "top");
return isManuallyShown && widgets.length > 0;
}
@@ -2727,6 +2728,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.state.mainSplitContentType === MainSplitContentType.Call ? "video_room" : "maximised_widget";
}
const extraButtons: JSX.Element[] = [];
for (const cb of ModuleApi.instance.extras.roomHeaderButtonsCallbacks) {
const b = cb(this.state.room.roomId);
if (b) extraButtons.push(b);
}
return (
<ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
<div
@@ -2754,7 +2761,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
{!this.props.hideHeader && (
<RoomHeader
room={this.state.room}
additionalButtons={this.state.viewRoomOpts.buttons}
legacyAdditionalButtons={this.state.viewRoomOpts.buttons}
extraButtons={<>{extraButtons}</>}
/>
)}
{mainSplitBody}
@@ -25,7 +25,7 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import ErrorDialog from "../dialogs/ErrorDialog";
import { WidgetType } from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream";
import { ModuleRunner } from "../../../modules/ModuleRunner";
import { ElementWidget, type WidgetMessaging } from "../../../stores/widgets/WidgetMessaging";
@@ -79,7 +79,7 @@ const showSnapshotButton = (widgetMessaging: WidgetMessaging | undefined): boole
const showMoveButtons = (app: IWidget, room: Room | undefined, showUnpin: boolean | undefined): [boolean, boolean] => {
if (!showUnpin) return [false, false];
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top) : [];
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, "top") : [];
const widgetIndex = pinnedWidgets.findIndex((widget) => widget.id === app.id);
return [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1];
};
@@ -221,7 +221,7 @@ export const WidgetContextMenu: React.FC<IProps> = ({
if (showMoveLeftButton) {
const onClick = (): void => {
if (!room) throw new Error("room must be defined");
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
WidgetLayoutStore.instance.moveWithinContainer(room, "top", app, -1);
onFinished();
};
@@ -232,7 +232,7 @@ export const WidgetContextMenu: React.FC<IProps> = ({
if (showMoveRightButton) {
const onClick = (): void => {
if (!room) throw new Error("room must be defined");
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1);
WidgetLayoutStore.instance.moveWithinContainer(room, "top", app, 1);
onFinished();
};
@@ -47,7 +47,7 @@ import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../s
import WidgetAvatar from "../avatars/WidgetAvatar";
import LegacyCallHandler from "../../../LegacyCallHandler";
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import WidgetUtils from "../../../utils/WidgetUtils";
@@ -682,21 +682,17 @@ export default class AppTile extends React.Component<IProps, IState> {
private onToggleMaximisedClick = (): void => {
if (!this.props.room) return; // ignore action - it shouldn't even be visible
const targetContainer = WidgetLayoutStore.instance.isInContainer(
this.props.room,
this.props.app,
Container.Center,
)
? Container.Top
: Container.Center;
const targetContainer = WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, "center")
? "top"
: "center";
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer);
if (targetContainer === Container.Top) this.closeChatCardIfNeeded();
if (targetContainer === "top") this.closeChatCardIfNeeded();
};
private onMinimiseClicked = (): void => {
if (!this.props.room) return; // ignore action - it shouldn't even be visible
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, Container.Right);
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, "right");
this.closeChatCardIfNeeded();
};
@@ -822,8 +818,7 @@ export default class AppTile extends React.Component<IProps, IState> {
const layoutButtons: ReactNode[] = [];
if (this.props.showLayoutButtons) {
const isMaximised =
this.props.room &&
WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center);
this.props.room && WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, "center");
layoutButtons.push(
<AccessibleButton
@@ -14,7 +14,7 @@ import { EventTileBubble } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
mxEvent: MatrixEvent;
@@ -32,7 +32,7 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
const widget = WidgetStore.instance.getRoom(room.roomId, true).widgets.find((w) => w.id === widgetId);
let joinCopy: string | null = _t("timeline|m.widget|jitsi_join_top_prompt");
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Right)) {
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, "right")) {
joinCopy = _t("timeline|m.widget|jitsi_join_right_prompt");
} else if (!widget) {
joinCopy = null;
@@ -24,7 +24,7 @@ import { useContextMenu } from "../../structures/ContextMenu";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { type IApp } from "../../../stores/WidgetStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import AccessibleButton from "../elements/AccessibleButton";
import WidgetAvatar from "../avatars/WidgetAvatar";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
@@ -58,18 +58,18 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
});
};
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, "top");
const togglePin = isPinned
? () => {
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
WidgetLayoutStore.instance.moveToContainer(room, app, "right");
}
: () => {
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top);
WidgetLayoutStore.instance.moveToContainer(room, app, "top");
};
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, "top");
let pinTitle: string;
if (cannotPin) {
@@ -78,7 +78,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
pinTitle = isPinned ? _t("action|unpin") : _t("action|pin");
}
const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, Container.Center);
const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, "center");
let openTitle = "";
if (isPinned) {
@@ -15,7 +15,7 @@ import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler";
import { ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import Heading from "../typography/Heading";
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel";
@@ -31,7 +31,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
const apps = useWidgets(room);
const app = apps.find((a) => a.id === widgetId);
const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Right);
const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, "right");
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
@@ -22,7 +22,7 @@ import type ResizeNotifier from "../../../utils/ResizeNotifier";
import ResizeHandle from "../elements/ResizeHandle";
import Resizer, { type IConfig } from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import UIStore from "../../../stores/UIStore";
import { type ActionPayload } from "../../../dispatcher/payloads";
import Spinner from "../elements/Spinner";
@@ -39,9 +39,9 @@ interface IProps {
interface IState {
apps: {
[Container.Top]: IWidget[];
[Container.Center]: IWidget[];
[Container.Right]?: IWidget[];
["top"]: IWidget[];
["center"]: IWidget[];
["right"]?: IWidget[];
};
resizingVertical: boolean; // true when changing the height of the apps drawer
resizingHorizontal: boolean; // true when changing the distribution of the width between widgets
@@ -119,7 +119,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
this.resizeContainer?.classList.remove("mx_AppsDrawer--resizing");
WidgetLayoutStore.instance.setResizerDistributions(
this.props.room,
Container.Top,
"top",
this.topApps()
.slice(1)
.map((_, i) => this.resizer.forHandleAt(i)!.size),
@@ -152,7 +152,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) {
// Room has changed, update apps
this.updateApps();
} else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps[Container.Top])) {
} else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps["top"])) {
this.loadResizerPreferences();
}
}
@@ -166,7 +166,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
};
private loadResizerPreferences = (): void => {
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top);
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, "top");
if (this.state.apps && this.topApps().length - 1 === distributions.length) {
distributions.forEach((size, i) => {
const distributor = this.resizer.forHandleAt(i);
@@ -206,11 +206,11 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
};
private getApps = (): IState["apps"] => ({
[Container.Top]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top),
[Container.Center]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Center),
["top"]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, "top"),
["center"]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, "center"),
});
private topApps = (): IWidget[] => this.state.apps[Container.Top];
private centerApps = (): IWidget[] => this.state.apps[Container.Center];
private topApps = (): IWidget[] => this.state.apps["top"];
private centerApps = (): IWidget[] => this.state.apps["center"];
private updateApps = (): void => {
if (this.unmounted) return;
@@ -321,7 +321,7 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
resizeNotifier,
children,
}) => {
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, Container.Top);
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, "top");
// Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window.
if (!minHeight) minHeight = 100;
@@ -352,7 +352,7 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
let newHeight = defaultHeight! + d.height;
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight);
WidgetLayoutStore.instance.setContainerHeight(room, "top", newHeight);
resizeNotifier.stopResizing();
}}
@@ -60,10 +60,12 @@ import { useIsEncrypted } from "../../../../hooks/useIsEncrypted.ts";
function RoomHeaderButtons({
room,
additionalButtons,
legacyAdditionalButtons,
extraButtons,
}: {
room: Room;
additionalButtons?: ViewRoomOpts["buttons"];
legacyAdditionalButtons?: ViewRoomOpts["buttons"];
extraButtons?: JSX.Element;
}): JSX.Element {
const members = useRoomMembers(room, 2500);
const memberCount = useRoomMemberCount(room, { throttleWait: 2500, includeInvited: true });
@@ -297,7 +299,9 @@ function RoomHeaderButtons({
roomContext.mainSplitContentType === MainSplitContentType.Call;
return (
<>
{additionalButtons?.map((props) => {
{extraButtons}
{legacyAdditionalButtons?.map((props) => {
const label = props.label();
return (
@@ -427,11 +431,15 @@ function historyVisibilityIcon(historyVisibility: HistoryVisibility): JSX.Elemen
export default function RoomHeader({
room,
additionalButtons,
extraButtons,
legacyAdditionalButtons,
oobData,
}: {
room: Room | LocalRoom;
additionalButtons?: ViewRoomOpts["buttons"];
// Extra buttons added by a new element web module API module
extraButtons?: JSX.Element;
// DEPRECATED: Buttons added by a legacy react-sdk module API module.
legacyAdditionalButtons?: ViewRoomOpts["buttons"];
oobData?: IOOBData;
}): JSX.Element {
const client = useMatrixClientContext();
@@ -530,7 +538,11 @@ export default function RoomHeader({
</button>
{/* If the room is local-only then we don't want to show any additional buttons, as it won't work */}
{room instanceof LocalRoom === false && (
<RoomHeaderButtons room={room} additionalButtons={additionalButtons} />
<RoomHeaderButtons
room={room}
legacyAdditionalButtons={legacyAdditionalButtons}
extraButtons={extraButtons}
/>
)}
</Flex>
{askToJoinEnabled && <RoomKnocksBar room={room} />}