Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Allow adding extra icons to the room header
Browse files Browse the repository at this point in the history
Signed-off-by: Charly Nguyen <charly.nguyen@nordeck.net>
  • Loading branch information
Charly Nguyen committed Oct 25, 2023
1 parent 5e8d274 commit 42e8cb5
Show file tree
Hide file tree
Showing 8 changed files with 254 additions and 1 deletion.
20 changes: 20 additions & 0 deletions src/components/views/rooms/LegacyRoomHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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 type { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
Expand Down Expand Up @@ -70,6 +71,8 @@ import { Alignment } from "../elements/Tooltip";
import RoomCallBanner from "../beacon/RoomCallBanner";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { RenderRoomHeaderPayload } from "../../../dispatcher/payloads/RenderRoomHeaderPayload";
import { RoomHeaderStore } from "../../../stores/RoomHeaderStore";

class DisabledWithReason {
public constructor(public readonly reason: string) {}
Expand Down Expand Up @@ -520,6 +523,10 @@ export default class RoomHeader extends React.Component<IProps, IState> {
public componentDidMount(): void {
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
defaultDispatcher.dispatch<RenderRoomHeaderPayload>({
action: Action.RenderRoomHeader,
roomId: this.props.room.roomId,
});
}

public componentWillUnmount(): void {
Expand Down Expand Up @@ -669,6 +676,19 @@ export default class RoomHeader extends React.Component<IProps, IState> {

return (
<>
{RoomHeaderStore.instance.buttons().map(({ icon, id, label, onClick }) => (

Check failure on line 679 in src/components/views/rooms/LegacyRoomHeader.tsx

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Binding element 'icon' implicitly has an 'any' type.

Check failure on line 679 in src/components/views/rooms/LegacyRoomHeader.tsx

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Binding element 'id' implicitly has an 'any' type.

Check failure on line 679 in src/components/views/rooms/LegacyRoomHeader.tsx

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Binding element 'label' implicitly has an 'any' type.

Check failure on line 679 in src/components/views/rooms/LegacyRoomHeader.tsx

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Binding element 'onClick' implicitly has an 'any' type.
<Tooltip label={label()} key={id}>
<IconButton
onClick={() => {
onClick();
this.forceUpdate();
}}
title={label()}
>
{icon}
</IconButton>
</Tooltip>
))}
{startButtons}
<RoomHeaderButtons
room={this.props.room}
Expand Down
21 changes: 21 additions & 0 deletions src/components/views/rooms/RoomHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ import RoomAvatar from "../avatars/RoomAvatar";
import { formatCount } from "../../../utils/FormattingUtils";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { Linkify, topicToHtml } from "../../../HtmlUtils";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { RenderRoomHeaderPayload } from "../../../dispatcher/payloads/RenderRoomHeaderPayload";
import { RoomHeaderStore } from "../../../stores/RoomHeaderStore";

/**
* A helper to transform a notification color to the what the Compound Icon Button
Expand Down Expand Up @@ -106,6 +110,10 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
[roomTopic?.html, roomTopic?.text],
);

useEffect(() => {
defaultDispatcher.dispatch<RenderRoomHeaderPayload>({ action: Action.RenderRoomHeader, roomId: room.roomId });
}, [room.roomId]);

return (
<Flex
as="header"
Expand Down Expand Up @@ -169,6 +177,19 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
)}
</Box>
<Flex as="nav" align="center" gap="var(--cpd-space-2x)">
{RoomHeaderStore.instance.buttons().map(({ icon, id, label, onClick }) => (

Check failure on line 180 in src/components/views/rooms/RoomHeader.tsx

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Binding element 'icon' implicitly has an 'any' type.

Check failure on line 180 in src/components/views/rooms/RoomHeader.tsx

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Binding element 'id' implicitly has an 'any' type.

Check failure on line 180 in src/components/views/rooms/RoomHeader.tsx

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Binding element 'label' implicitly has an 'any' type.

Check failure on line 180 in src/components/views/rooms/RoomHeader.tsx

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Binding element 'onClick' implicitly has an 'any' type.
<Tooltip label={label()} key={id}>
<IconButton
aria-label={label()}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{icon}
</IconButton>
</Tooltip>
))}
{!useElementCallExclusively && (
<Tooltip label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}>
<IconButton
Expand Down
5 changes: 5 additions & 0 deletions src/dispatcher/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,4 +371,9 @@ export enum Action {
* Fired when we want to open spotlight search. Use with a OpenSpotlightPayload.
*/
OpenSpotlight = "open_spotlight",

/**
* Fired when rendering the room header. Use with a RenderRoomHeaderPayload.
*/
RenderRoomHeader = "render_room_header",
}
27 changes: 27 additions & 0 deletions src/dispatcher/payloads/RenderRoomHeaderPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
*
* Copyright 2023 Nordeck IT + Consulting GmbH
*
* 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 { Action } from "../actions";
import { ActionPayload } from "../payloads";

export interface RenderRoomHeaderPayload extends Pick<ActionPayload, "action"> {
action: Action.RenderRoomHeader;

roomId: string;
}
50 changes: 50 additions & 0 deletions src/stores/RoomHeaderStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
*
* Copyright 2023 Nordeck IT + Consulting GmbH
*
* 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 { RoomHeaderOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";

Check failure on line 20 in src/stores/RoomHeaderStore.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Module '"@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"' has no exported member 'RoomHeaderOpts'.

import dispatcher from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
import { AsyncStore } from "./AsyncStore";
import { ModuleRunner } from "../modules/ModuleRunner";
import { RenderRoomHeaderPayload } from "../dispatcher/payloads/RenderRoomHeaderPayload";

export class RoomHeaderStore extends AsyncStore<RoomHeaderOpts> {
private constructor() {
super(dispatcher, { buttons: [] });
}

protected onDispatch(payload: RenderRoomHeaderPayload): void {
if (payload.action === Action.RenderRoomHeader) {
ModuleRunner.instance.invoke(RoomViewLifecycle.RenderRoomHeader, this.state, payload.roomId);

Check failure on line 35 in src/stores/RoomHeaderStore.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'RenderRoomHeader' does not exist on type 'typeof RoomViewLifecycle'.
}
}

private static internalInstance = (() => {
return new RoomHeaderStore();
})();

public static get instance(): RoomHeaderStore {
return this.internalInstance;
}

public buttons(): RoomHeaderOpts["buttons"] {
return this.state.buttons;
}
}
30 changes: 30 additions & 0 deletions test/components/views/rooms/LegacyRoomHeader-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { RoomHeaderOpts, RoomViewLifecycle } 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";
Expand All @@ -39,6 +40,7 @@ import {
resetAsyncStoreWithClient,
mockPlatformPeg,
mkEvent,
flushPromises,
} from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
Expand All @@ -61,6 +63,7 @@ import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidg
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler";
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../src/settings/UIFeature";
import { ModuleRunner } from "../../../../src/modules/ModuleRunner";

jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
Expand Down Expand Up @@ -738,6 +741,33 @@ describe("LegacyRoomHeader", () => {
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_name.mx_AccessibleButton")).toBeFalsy();
},
);

describe("RoomHeaderStore", () => {
it("dispatches Action.RenderRoomHeader", () => {
const spy = jest.spyOn(defaultDispatcher, "dispatch");
renderHeader();
expect(spy).toHaveBeenCalledWith({ action: Action.RenderRoomHeader, roomId: room.roomId });
});

it("renders additional buttons", async () => {
const buttons: RoomHeaderOpts["buttons"] = [
{
icon: <>test-icon</>,
id: "test-id",
label: () => "test-label",
onClick: () => {},
},
];
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts) => {
if (lifecycleEvent === RoomViewLifecycle.RenderRoomHeader) {
opts.buttons = buttons;
}
});
await flushPromises();
renderHeader();
expect(screen.getByRole("button", { name: "test-icon" })).toBeInTheDocument();
});
});
});

interface IRoomCreationInfo {
Expand Down
32 changes: 31 additions & 1 deletion test/components/views/rooms/RoomHeader-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { RoomHeaderOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";

import { filterConsole, mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils";
import { filterConsole, flushPromises, mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils";
import RoomHeader from "../../../../src/components/views/rooms/RoomHeader";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
Expand All @@ -42,6 +43,8 @@ import { CallStore } from "../../../../src/stores/CallStore";
import { Call, ElementCall } from "../../../../src/models/Call";
import * as ShieldUtils from "../../../../src/utils/ShieldUtils";
import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
import { Action } from "../../../../src/dispatcher/actions";
import { ModuleRunner } from "../../../../src/modules/ModuleRunner";

jest.mock("../../../../src/utils/ShieldUtils");

Expand Down Expand Up @@ -516,6 +519,33 @@ describe("RoomHeader", () => {
await waitFor(() => expect(getByLabelText(container, expectedLabel)).toBeInTheDocument());
});
});

describe("RoomHeaderStore", () => {
it("dispatches Action.RenderRoomHeader", () => {
const spy = jest.spyOn(dispatcher, "dispatch");
render(<RoomHeader room={room} />, withClientContextRenderOptions(MatrixClientPeg.get()!));
expect(spy).toHaveBeenCalledWith({ action: Action.RenderRoomHeader, roomId: room.roomId });
});

it("renders additional buttons", async () => {
const buttons: RoomHeaderOpts["buttons"] = [
{
icon: <>test-icon</>,
id: "test-id",
label: () => "test-label",
onClick: () => {},
},
];
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts) => {
if (lifecycleEvent === RoomViewLifecycle.RenderRoomHeader) {
opts.buttons = buttons;
}
});
await flushPromises();
render(<RoomHeader room={room} />, withClientContextRenderOptions(MatrixClientPeg.get()!));
expect(screen.getByRole("button", { name: "test-label" })).toBeInTheDocument();
});
});
});

/**
Expand Down
70 changes: 70 additions & 0 deletions test/stores/RoomHeaderStore-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
*
* Copyright 2023 Nordeck IT + Consulting GmbH
*
* 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 { RoomHeaderOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";

import defaultDispatcher from "../../src/dispatcher/dispatcher";
import { Action } from "../../src/dispatcher/actions";
import { ActionPayload } from "../../src/dispatcher/payloads";
import { ModuleRunner } from "../../src/modules/ModuleRunner";
import { RenderRoomHeaderPayload } from "../../src/dispatcher/payloads/RenderRoomHeaderPayload";
import { RoomHeaderStore } from "../../src/stores/RoomHeaderStore";
import { flushPromises } from "../test-utils";

describe("RoomHeaderStore", () => {
const roomId = "!test:example.com";
const state: RoomHeaderOpts = { buttons: [] };

let store: RoomHeaderStore;

beforeEach(() => {
jest.resetAllMocks();
store = RoomHeaderStore.instance;
});

it("initializes state with no buttons", () => {
expect(store.buttons()).toEqual(state.buttons);
});

it("updates state with buttons on Action.RenderRoomHeader", async () => {
const buttons: RoomHeaderOpts["buttons"] = [
{
icon: "test-icon",
id: "test-id",
label: () => "test-label",
onClick: () => {},
},
];
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts) => {
if (lifecycleEvent === RoomViewLifecycle.RenderRoomHeader) {
opts.buttons = buttons;
}
});
defaultDispatcher.dispatch<RenderRoomHeaderPayload>({ action: Action.RenderRoomHeader, roomId });
await flushPromises();
expect(store.buttons()).toEqual(buttons);
});

it("does not update state on any other action", async () => {
const spy = jest.spyOn(ModuleRunner.instance, "invoke");
defaultDispatcher.dispatch<ActionPayload>({ action: Action.ViewRoom });
await flushPromises();
expect(spy).not.toHaveBeenCalled();
});
});

0 comments on commit 42e8cb5

Please sign in to comment.