diff --git a/package.json b/package.json index 2c85b83ccd..0ee6e35718 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@matrix-org/analytics-events": "^0.8.0", "@matrix-org/emojibase-bindings": "^1.1.2", "@matrix-org/matrix-wysiwyg": "2.4.1", - "@matrix-org/react-sdk-module-api": "^2.1.1", + "@matrix-org/react-sdk-module-api": "^2.2.0", "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^7.0.0", "@sentry/tracing": "^7.0.0", diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 8ca3cd4276..9887e9cc60 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -42,6 +42,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { throttle } from "lodash"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; +import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import shouldHideEvent from "../../shouldHideEvent"; import { _t } from "../../languageHandler"; @@ -246,6 +247,8 @@ export interface IRoomState { canAskToJoin: boolean; promptAskToJoin: boolean; + + viewRoomOpts: ViewRoomOpts; } interface LocalRoomViewProps { @@ -458,6 +461,7 @@ export class RoomView extends React.Component { msc3946ProcessDynamicPredecessor: SettingsStore.getValue("feature_dynamic_room_predecessors"), canAskToJoin: this.askToJoinEnabled, promptAskToJoin: false, + viewRoomOpts: { buttons: [] }, }; this.dispatcherRef = dis.register(this.onAction); @@ -663,6 +667,7 @@ export class RoomView extends React.Component { : false, activeCall: roomId ? CallStore.instance.getActiveCall(roomId) : null, promptAskToJoin: this.context.roomViewStore.promptAskToJoin(), + viewRoomOpts: this.context.roomViewStore.getViewRoomOpts(), }; if ( @@ -1407,6 +1412,8 @@ export class RoomView extends React.Component { tombstone: this.getRoomTombstone(room), liveTimeline: room.getLiveTimeline(), }); + + dis.dispatch({ action: Action.RoomLoaded }); }; private onRoomTimelineReset = (room?: Room): void => { @@ -2601,7 +2608,10 @@ export class RoomView extends React.Component { data-layout={this.state.layout} > {SettingsStore.getValue("feature_new_room_decoration_ui") ? ( - + ) : ( { enableRoomOptionsMenu={!this.viewsLocalRoom} viewingCall={viewingCall} activeCall={this.state.activeCall} + additionalButtons={this.state.viewRoomOpts.buttons} /> )} {mainSplitBody} diff --git a/src/components/views/rooms/LegacyRoomHeader.tsx b/src/components/views/rooms/LegacyRoomHeader.tsx index 81ede27ed8..ab91074469 100644 --- a/src/components/views/rooms/LegacyRoomHeader.tsx +++ b/src/components/views/rooms/LegacyRoomHeader.tsx @@ -20,6 +20,8 @@ import classNames from "classnames"; import { throttle } from "lodash"; import { RoomStateEvent, ISearchResults } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import { IconButton, Tooltip } from "@vector-im/compound-web"; +import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import type { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; @@ -476,6 +478,7 @@ export interface IProps { enableRoomOptionsMenu?: boolean; viewingCall: boolean; activeCall: Call | null; + additionalButtons?: ViewRoomOpts["buttons"]; } interface IState { @@ -669,6 +672,23 @@ export default class RoomHeader extends React.Component { return ( <> + {this.props.additionalButtons?.map((props) => { + const label = props.label(); + + return ( + + { + props.onClick(); + this.forceUpdate(); + }} + title={label} + > + {props.icon} + + + ); + })} {startButtons} + {additionalButtons?.map((props) => { + const label = props.label(); + + return ( + + { + event.stopPropagation(); + props.onClick(); + }} + > + {props.icon} + + + ); + })} {!useElementCallExclusively && ( void; @@ -370,6 +375,10 @@ export class RoomViewStore extends EventEmitter { this.cancelAskToJoin(payload as CancelAskToJoinPayload); break; } + case Action.RoomLoaded: { + this.setViewRoomOpts(); + break; + } } } @@ -805,4 +814,24 @@ export class RoomViewStore extends EventEmitter { }), ); } + + /** + * Gets the current state of the 'viewRoomOpts' property. + * + * @returns {ViewRoomOpts} The value of the 'viewRoomOpts' property. + */ + public getViewRoomOpts(): ViewRoomOpts { + return this.state.viewRoomOpts; + } + + /** + * Invokes the view room lifecycle to set the view room options. + * + * @returns {void} + */ + private setViewRoomOpts(): void { + const viewRoomOpts: ViewRoomOpts = { buttons: [] }; + ModuleRunner.instance.invoke(RoomViewLifecycle.ViewRoom, viewRoomOpts, this.getRoomId()); + this.setState({ viewRoomOpts }); + } } diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index 8f6034d2e1..29a0f51c03 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -585,4 +585,10 @@ describe("RoomView", () => { expect(dis.dispatch).toHaveBeenCalledWith({ action: "cancel_ask_to_join", roomId: room.roomId }); }); }); + + it("fires Action.RoomLoaded", async () => { + jest.spyOn(dis, "dispatch"); + await mountRoomView(); + expect(dis.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded }); + }); }); diff --git a/test/components/views/rooms/LegacyRoomHeader-test.tsx b/test/components/views/rooms/LegacyRoomHeader-test.tsx index f4fc7b3603..3038fa0d49 100644 --- a/test/components/views/rooms/LegacyRoomHeader-test.tsx +++ b/test/components/views/rooms/LegacyRoomHeader-test.tsx @@ -29,6 +29,7 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { ClientWidgetApi, Widget } from "matrix-widget-api"; import EventEmitter from "events"; import { setupJestCanvasMock } from "jest-canvas-mock"; +import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix"; import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; @@ -738,6 +739,34 @@ describe("LegacyRoomHeader", () => { expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_name.mx_AccessibleButton")).toBeFalsy(); }, ); + + it("renders additionalButtons", async () => { + const additionalButtons: ViewRoomOpts["buttons"] = [ + { + icon: <>test-icon, + id: "test-id", + label: () => "test-label", + onClick: () => {}, + }, + ]; + renderHeader({ additionalButtons }); + expect(screen.getByRole("button", { name: "test-icon" })).toBeInTheDocument(); + }); + + it("calls onClick-callback on additionalButtons", () => { + const callback = jest.fn(); + const additionalButtons: ViewRoomOpts["buttons"] = [ + { + icon: <>test-icon, + id: "test-id", + label: () => "test-label", + onClick: callback, + }, + ]; + renderHeader({ additionalButtons }); + fireEvent.click(screen.getByRole("button", { name: "test-icon" })); + expect(callback).toHaveBeenCalled(); + }); }); interface IRoomCreationInfo { diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 293e857a43..f354ae934a 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -18,6 +18,7 @@ import React from "react"; import { CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { EventType, JoinRule, MatrixClient, MatrixEvent, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; import { + createEvent, fireEvent, getAllByLabelText, getByLabelText, @@ -27,6 +28,7 @@ import { screen, waitFor, } from "@testing-library/react"; +import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { filterConsole, mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils"; import RoomHeader from "../../../../src/components/views/rooms/RoomHeader"; @@ -516,6 +518,47 @@ describe("RoomHeader", () => { await waitFor(() => expect(getByLabelText(container, expectedLabel)).toBeInTheDocument()); }); }); + + it("renders additionalButtons", async () => { + const additionalButtons: ViewRoomOpts["buttons"] = [ + { + icon: <>test-icon, + id: "test-id", + label: () => "test-label", + onClick: () => {}, + }, + ]; + render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); + expect(screen.getByRole("button", { name: "test-label" })).toBeInTheDocument(); + }); + + it("calls onClick-callback on additionalButtons", () => { + const callback = jest.fn(); + const additionalButtons: ViewRoomOpts["buttons"] = [ + { + icon: <>test-icon, + id: "test-id", + label: () => "test-label", + onClick: callback, + }, + ]; + + render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); + + const button = screen.getByRole("button", { name: "test-label" }); + const event = createEvent.click(button); + event.stopPropagation = jest.fn(); + fireEvent(button, event); + + expect(callback).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); }); /** diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 7d47af6b65..cc6eb9ec87 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -84,6 +84,7 @@ describe("", () => { msc3946ProcessDynamicPredecessor: false, canAskToJoin: false, promptAskToJoin: false, + viewRoomOpts: { buttons: [] }, }; describe("createMessageContent", () => { const permalinkCreator = jest.fn() as any; diff --git a/test/stores/RoomViewStore-test.ts b/test/stores/RoomViewStore-test.ts index 6322d9acc0..a9b63273fe 100644 --- a/test/stores/RoomViewStore-test.ts +++ b/test/stores/RoomViewStore-test.ts @@ -17,6 +17,7 @@ limitations under the License. import { mocked } from "jest-mock"; import { MatrixError, Room } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; +import { RoomViewLifecycle, ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { RoomViewStore } from "../../src/stores/RoomViewStore"; import { Action } from "../../src/dispatcher/actions"; @@ -43,6 +44,7 @@ import ErrorDialog from "../../src/components/views/dialogs/ErrorDialog"; import { CancelAskToJoinPayload } from "../../src/dispatcher/payloads/CancelAskToJoinPayload"; import { JoinRoomErrorPayload } from "../../src/dispatcher/payloads/JoinRoomErrorPayload"; import { SubmitAskToJoinPayload } from "../../src/dispatcher/payloads/SubmitAskToJoinPayload"; +import { ModuleRunner } from "../../src/modules/ModuleRunner"; jest.mock("../../src/Modal"); @@ -132,6 +134,11 @@ describe("RoomViewStore", function () { await untilDispatch(Action.CancelAskToJoin, dis); }; + const dispatchRoomLoaded = async () => { + dis.dispatch({ action: Action.RoomLoaded }); + await untilDispatch(Action.RoomLoaded, dis); + }; + let roomViewStore: RoomViewStore; let slidingSyncManager: SlidingSyncManager; let dis: MatrixDispatcher; @@ -569,4 +576,30 @@ describe("RoomViewStore", function () { }); }); }); + + describe("getViewRoomOpts", () => { + it("returns viewRoomOpts", () => { + expect(roomViewStore.getViewRoomOpts()).toEqual({ buttons: [] }); + }); + }); + + describe("Action.RoomLoaded", () => { + it("updates viewRoomOpts", async () => { + const buttons: ViewRoomOpts["buttons"] = [ + { + icon: "test-icon", + id: "test-id", + label: () => "test-label", + onClick: () => {}, + }, + ]; + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts) => { + if (lifecycleEvent === RoomViewLifecycle.ViewRoom) { + opts.buttons = buttons; + } + }); + await dispatchRoomLoaded(); + expect(roomViewStore.getViewRoomOpts()).toEqual({ buttons }); + }); + }); }); diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts index 5c4142005a..5d188fb0b6 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -90,6 +90,7 @@ export function getRoomContext(room: Room, override: Partial): IRoom msc3946ProcessDynamicPredecessor: false, canAskToJoin: false, promptAskToJoin: false, + viewRoomOpts: { buttons: [] }, ...override, }; diff --git a/yarn.lock b/yarn.lock index 43b624fc6e..1541aada3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1833,10 +1833,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/olm/-/olm-3.2.15.tgz#55f3c1b70a21bbee3f9195cecd6846b1083451ec" integrity sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q== -"@matrix-org/react-sdk-module-api@^2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.1.1.tgz#54e8617c15185010d608c0325ecaec8d1574d12b" - integrity sha512-dYPY3aXtNwPrg2aEmFeWddMdohus/Ha17XES2QH+WMCawt+hH+uq28jH1EmW1RUOOzxVcdY36lRGOwqRtAJbhA== +"@matrix-org/react-sdk-module-api@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.2.0.tgz#cb284601a82448dc23fac31949c466eb34ec64b4" + integrity sha512-HSicxLdagZRbQp35d3t2SeDFTiT4GmEQDQGih8dWSKRHXK4krVQjb6Kf1NkwweiFDAeU0qgbz2pP4RZqbv0XIg== dependencies: "@babel/runtime" "^7.17.9"