diff --git a/src/Unread.ts b/src/Unread.ts index 4f38c8e76ab..268b2a4a7fa 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -70,7 +70,7 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { return false; } -function doesRoomOrThreadHaveUnreadMessages(room: Room | Thread): boolean { +export function doesRoomOrThreadHaveUnreadMessages(room: Room | Thread): boolean { const myUserId = MatrixClientPeg.get().getUserId(); // as we don't send RRs for our own messages, make sure we special case that diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index c58e373197f..bf443405563 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -44,6 +44,7 @@ import { NotificationStateEvents } from "../../../stores/notifications/Notificat import PosthogTrackers from "../../../PosthogTrackers"; import { ButtonEvent } from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread"; const ROOM_INFO_PHASES = [ RightPanelPhases.RoomSummary, @@ -191,9 +192,17 @@ export default class RoomHeaderButtons extends HeaderButtons { return NotificationColor.Red; case NotificationCountType.Total: return NotificationColor.Grey; - default: - return NotificationColor.None; } + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + for (const thread of this.props.room!.getThreads()) { + // If the current thread has unread messages, we're done. + if (doesRoomOrThreadHaveUnreadMessages(thread)) { + return NotificationColor.Bold; + } + } + // Otherwise, no notification color. + return NotificationColor.None; } private onUpdateStatus = (notificationState: SummarizedNotificationState): void => { diff --git a/src/hooks/useUnreadNotifications.ts b/src/hooks/useUnreadNotifications.ts index bca2b0c2d42..22236d832f9 100644 --- a/src/hooks/useUnreadNotifications.ts +++ b/src/hooks/useUnreadNotifications.ts @@ -15,12 +15,13 @@ limitations under the License. */ import { NotificationCount, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { Thread } from "matrix-js-sdk/src/models/thread"; import { useCallback, useEffect, useState } from "react"; import { getUnsentMessages } from "../components/structures/RoomStatusBar"; import { getRoomNotifsState, getUnreadNotificationCount, RoomNotifState } from "../RoomNotifs"; import { NotificationColor } from "../stores/notifications/NotificationColor"; -import { doesRoomHaveUnreadMessages } from "../Unread"; +import { doesRoomOrThreadHaveUnreadMessages } from "../Unread"; import { EffectiveMembership, getEffectiveMembership } from "../utils/membership"; import { useEventEmitter } from "./useEventEmitter"; @@ -75,12 +76,14 @@ export const useUnreadNotifications = ( setColor(NotificationColor.Red); } else if (greyNotifs > 0) { setColor(NotificationColor.Grey); - } else if (!threadId) { - // TODO: No support for `Bold` on threads at the moment - + } else { // We don't have any notified messages, but we might have unread messages. Let's // find out. - const hasUnread = doesRoomHaveUnreadMessages(room); + let roomOrThread: Room | Thread = room; + if (threadId) { + roomOrThread = room.getThread(threadId)!; + } + const hasUnread = doesRoomOrThreadHaveUnreadMessages(roomOrThread); setColor(hasUnread ? NotificationColor.Bold : NotificationColor.None); } } diff --git a/test/components/views/right_panel/RoomHeaderButtons-test.tsx b/test/components/views/right_panel/RoomHeaderButtons-test.tsx index 76e33f9b2cf..0b99573fbd1 100644 --- a/test/components/views/right_panel/RoomHeaderButtons-test.tsx +++ b/test/components/views/right_panel/RoomHeaderButtons-test.tsx @@ -24,6 +24,7 @@ import RoomHeaderButtons from "../../../../src/components/views/right_panel/Room import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { stubClient } from "../../../test-utils"; +import { mkThread } from "../../../test-utils/threads"; describe("RoomHeaderButtons-test.tsx", function () { const ROOM_ID = "!roomId:example.org"; @@ -52,7 +53,7 @@ describe("RoomHeaderButtons-test.tsx", function () { return container.querySelector(".mx_RightPanel_threadsButton"); } - function isIndicatorOfType(container, type: "red" | "gray") { + function isIndicatorOfType(container, type: "red" | "gray" | "bold") { return container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator").className.includes(type); } @@ -76,7 +77,7 @@ describe("RoomHeaderButtons-test.tsx", function () { expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); }); - it("room wide notification does not change the thread button", () => { + it.only("thread notification does change the thread button", () => { const { container } = getComponent(room); room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1); @@ -89,6 +90,10 @@ describe("RoomHeaderButtons-test.tsx", function () { room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 0); expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); + + // Thread activity should appear on the icon. + mkThread({ room, client, authorId: client.getUserId()!, participantUserIds: ["@alice:example.org"] }); + expect(isIndicatorOfType(getComponent(room), "bold")).toBe(true); }); it("does not explode without a room", () => { diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index d0fe524d029..020dd92640c 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -137,7 +137,8 @@ describe("EventTile", () => { it("shows an unread notification bage", () => { const { container } = getComponent({}, TimelineRenderingType.ThreadsList); - expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0); + // By default, the thread will assume there's unread activity in it. + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); act(() => { room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Total, 3); diff --git a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx index acffe31ff3e..00ce21a3adf 100644 --- a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx @@ -17,13 +17,14 @@ limitations under the License. import React from "react"; import "jest-mock"; import { screen, act, render } from "@testing-library/react"; -import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { MsgType } from "matrix-js-sdk/src/matrix"; +import { PendingEventOrdering } from "matrix-js-sdk/src/client"; import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; -import { mocked } from "jest-mock"; import { EventStatus } from "matrix-js-sdk/src/models/event-status"; +import { mkThread } from "../../../../test-utils/threads"; import { UnreadNotificationBadge } from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge"; -import { mkMessage, stubClient } from "../../../../test-utils/test-utils"; +import { mkEvent, mkMessage, stubClient } from "../../../../test-utils/test-utils"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import * as RoomNotifs from "../../../../../src/RoomNotifs"; @@ -34,28 +35,38 @@ jest.mock("../../../../../src/RoomNotifs", () => ({ })); const ROOM_ID = "!roomId:example.org"; -let THREAD_ID; +let THREAD_ID: string; describe("UnreadNotificationBadge", () => { - let mockClient: MatrixClient; + stubClient(); + const client = MatrixClientPeg.get(); let room: Room; function getComponent(threadId?: string) { return ; } + beforeAll(() => { + client.supportsExperimentalThreads = () => true; + }); + beforeEach(() => { jest.clearAllMocks(); - stubClient(); - mockClient = mocked(MatrixClientPeg.get()); - - room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { + room = new Room(ROOM_ID, client, client.getUserId()!, { pendingEventOrdering: PendingEventOrdering.Detached, }); room.setUnreadNotificationCount(NotificationCountType.Total, 1); room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + const { rootEvent } = mkThread({ + room, + client, + authorId: client.getUserId()!, + participantUserIds: [client.getUserId()!], + }); + THREAD_ID = rootEvent.getId()!; + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1); room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); @@ -125,4 +136,33 @@ describe("UnreadNotificationBadge", () => { const { container } = render(getComponent()); expect(container.querySelector(".mx_NotificationBadge")).toBeNull(); }); + + it("activity renders unread notification badge", () => { + act(() => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + + // Add another event on the thread which is not sent by us. + const event = mkEvent({ + event: true, + type: "m.room.message", + user: "@alice:server.org", + room: room.roomId, + content: { + "msgtype": MsgType.Text, + "body": "Hello from Bob", + "m.relates_to": { + event_id: THREAD_ID, + rel_type: "m.thread", + }, + }, + }); + room.addLiveEvents([event]); + }); + + const { container } = render(getComponent(THREAD_ID)); + expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy(); + expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy(); + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy(); + }); });