diff --git a/package.json b/package.json
index 01abd04fa9..5c4f081b14 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,7 @@
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^7.0.0",
"@testing-library/react-hooks": "^8.0.1",
- "@vector-im/compound-design-tokens": "^0.0.7",
+ "@vector-im/compound-design-tokens": "^0.1.0",
"@vector-im/compound-web": "0.8.1",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx
index 306908af20..c19a649e48 100644
--- a/src/components/views/rooms/RoomHeader.tsx
+++ b/src/components/views/rooms/RoomHeader.tsx
@@ -50,6 +50,7 @@ import { formatCount } from "../../../utils/FormattingUtils";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { Linkify, topicToHtml } from "../../../HtmlUtils";
import PosthogTrackers from "../../../PosthogTrackers";
+import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
/**
* A helper to transform a notification color to the what the Compound Icon Button
@@ -215,6 +216,9 @@ export default function RoomHeader({
)}
+ {/* Renders nothing when room is not a video room */}
+
+
= ({ room }) => {
+ const sdkContext = useContext(SDKContext);
+
+ const isVideoRoom = calcIsVideoRoom(room);
+
+ const notificationState = isVideoRoom ? sdkContext.roomNotificationStateStore.getRoomState(room) : undefined;
+ const notificationColor = useEventEmitterState(
+ notificationState,
+ NotificationStateEvents.Update,
+ () => notificationState?.color,
+ );
+
+ if (!isVideoRoom) {
+ return null;
+ }
+
+ const displayUnreadIndicator =
+ !!notificationColor &&
+ [NotificationColor.Bold, NotificationColor.Grey, NotificationColor.Red].includes(notificationColor);
+
+ const onClick = (event: ButtonEvent): void => {
+ // stop event propagating up and triggering RoomHeader bar click
+ // which will open RoomSummary
+ event.stopPropagation();
+ sdkContext.rightPanelStore.showOrHidePanel(RightPanelPhases.Timeline);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/test/components/views/rooms/RoomHeader/VideoRoomChatButton-test.tsx b/test/components/views/rooms/RoomHeader/VideoRoomChatButton-test.tsx
new file mode 100644
index 0000000000..3f8756028d
--- /dev/null
+++ b/test/components/views/rooms/RoomHeader/VideoRoomChatButton-test.tsx
@@ -0,0 +1,144 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+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 React from "react";
+import { MockedObject } from "jest-mock";
+import { Room } from "matrix-js-sdk/src/matrix";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import { VideoRoomChatButton } from "../../../../../src/components/views/rooms/RoomHeader/VideoRoomChatButton";
+import { SDKContext, SdkContextClass } from "../../../../../src/contexts/SDKContext";
+import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore";
+import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils";
+import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
+import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor";
+import { NotificationStateEvents } from "../../../../../src/stores/notifications/NotificationState";
+import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases";
+
+describe("", () => {
+ const roomId = "!room:server.org";
+ let sdkContext!: SdkContextClass;
+ let rightPanelStore!: MockedObject;
+
+ /**
+ * Create a room using mocked client
+ * And mock isElementVideoRoom
+ */
+ const makeRoom = (isVideoRoom = true): Room => {
+ const room = new Room(roomId, sdkContext.client!, sdkContext.client!.getSafeUserId());
+ jest.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom);
+ // stub
+ jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
+ return room;
+ };
+
+ const mockRoomNotificationState = (room: Room, color: NotificationColor): RoomNotificationState => {
+ const roomNotificationState = new RoomNotificationState(room);
+
+ // @ts-ignore ugly mocking
+ roomNotificationState._color = color;
+ jest.spyOn(sdkContext.roomNotificationStateStore, "getRoomState").mockReturnValue(roomNotificationState);
+ return roomNotificationState;
+ };
+
+ const getComponent = (room: Room) =>
+ render(, {
+ wrapper: ({ children }) => {children},
+ });
+
+ beforeEach(() => {
+ const client = getMockClientWithEventEmitter({
+ ...mockClientMethodsUser(),
+ });
+ rightPanelStore = {
+ showOrHidePanel: jest.fn(),
+ } as unknown as MockedObject;
+ sdkContext = new SdkContextClass();
+ sdkContext.client = client;
+ jest.spyOn(sdkContext, "rightPanelStore", "get").mockReturnValue(rightPanelStore);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("does not render button when room is not a video room", () => {
+ const room = makeRoom(false);
+ getComponent(room);
+
+ expect(screen.queryByLabelText("Chat")).not.toBeInTheDocument();
+ });
+
+ it("renders button when room is a video room", () => {
+ const room = makeRoom();
+ getComponent(room);
+
+ expect(screen.getByLabelText("Chat")).toMatchSnapshot();
+ });
+
+ it("toggles timeline in right panel on click", () => {
+ const room = makeRoom();
+ getComponent(room);
+
+ fireEvent.click(screen.getByLabelText("Chat"));
+
+ expect(sdkContext.rightPanelStore.showOrHidePanel).toHaveBeenCalledWith(RightPanelPhases.Timeline);
+ });
+
+ it("renders button with an unread marker when room is unread", () => {
+ const room = makeRoom();
+ mockRoomNotificationState(room, NotificationColor.Bold);
+ getComponent(room);
+
+ // snapshot includes `data-indicator` attribute
+ expect(screen.getByLabelText("Chat")).toMatchSnapshot();
+ expect(screen.getByLabelText("Chat").hasAttribute("data-indicator")).toBeTruthy();
+ });
+
+ it("adds unread marker when room notification state changes to unread", () => {
+ const room = makeRoom();
+ // start in read state
+ const notificationState = mockRoomNotificationState(room, NotificationColor.None);
+ getComponent(room);
+
+ // no unread marker
+ expect(screen.getByLabelText("Chat").hasAttribute("data-indicator")).toBeFalsy();
+
+ // @ts-ignore ugly mocking
+ notificationState._color = NotificationColor.Red;
+ notificationState.emit(NotificationStateEvents.Update);
+
+ // unread marker
+ expect(screen.getByLabelText("Chat").hasAttribute("data-indicator")).toBeTruthy();
+ });
+
+ it("clears unread marker when room notification state changes to read", () => {
+ const room = makeRoom();
+ // start in unread state
+ const notificationState = mockRoomNotificationState(room, NotificationColor.Red);
+ getComponent(room);
+
+ // unread marker
+ expect(screen.getByLabelText("Chat").hasAttribute("data-indicator")).toBeTruthy();
+
+ // @ts-ignore ugly mocking
+ notificationState._color = NotificationColor.None;
+ notificationState.emit(NotificationStateEvents.Update);
+
+ // unread marker cleared
+ expect(screen.getByLabelText("Chat").hasAttribute("data-indicator")).toBeFalsy();
+ });
+});
diff --git a/test/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton-test.tsx.snap b/test/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton-test.tsx.snap
new file mode 100644
index 0000000000..790dbf1eb8
--- /dev/null
+++ b/test/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton-test.tsx.snap
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders button when room is a video room 1`] = `
+
+`;
+
+exports[` renders button with an unread marker when room is unread 1`] = `
+
+`;
diff --git a/yarn.lock b/yarn.lock
index a727768770..a53f4062d5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3037,10 +3037,10 @@
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
-"@vector-im/compound-design-tokens@^0.0.7":
- version "0.0.7"
- resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.0.7.tgz#b0716dd4782dd95900491e45b003b58f93748024"
- integrity sha512-RCQc6qr+s8cp4xKbNi/I3OL43uPCH+N4L9vYf0r+qwRy4WCKdI4QL0TBTV4bOo8hF49z8e+BgU5ZIu5TVQXNMQ==
+"@vector-im/compound-design-tokens@^0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.1.0.tgz#1a574fba872ff93b1de8490f475e30b922cd02a2"
+ integrity sha512-vnDrd1CPPR7CwQLss/JnIE1ga6QwmCkhgBvXm1huMhCs7nIiqf90Sbgc0WugbHNaRXGEEhMVGrE69DaQIUcqOA==
dependencies:
svg2vectordrawable "^2.9.1"