From 9eb4f8d723863bc17f8d226621e7445ee67d25ec Mon Sep 17 00:00:00 2001 From: Germain Date: Mon, 24 Oct 2022 07:50:21 +0100 Subject: [PATCH] Add thread notification with server assistance (MSC3773) (#9400) Co-authored-by: Janne Mareike Koschinski --- cypress/e2e/sliding-sync/sliding-sync.ts | 2 +- res/css/views/rooms/_EventTile.pcss | 18 ++- src/RoomNotifs.ts | 16 ++- src/Unread.ts | 6 - src/components/structures/RoomStatusBar.tsx | 6 +- .../views/right_panel/RoomHeaderButtons.tsx | 54 +++++-- src/components/views/rooms/EventTile.tsx | 73 ++++++---- .../views/rooms/NotificationBadge.tsx | 74 +++------- .../StatelessNotificationBadge.tsx | 81 +++++++++++ .../UnreadNotificationBadge.tsx | 36 +++++ src/hooks/useUnreadNotifications.ts | 93 ++++++++++++ .../notifications/RoomNotificationState.ts | 28 ++-- .../RoomNotificationStateStore.ts | 26 ++-- test/RoomNotifs-test.ts | 79 ++++++++++- .../structures/RoomStatusBar-test.tsx | 91 ++++++++++++ .../right_panel/RoomHeaderButtons-test.tsx | 97 +++++++++++++ .../components/views/rooms/EventTile-test.tsx | 112 +++++++++++++++ .../NotificationBadge-test.tsx | 49 +++++++ .../UnreadNotificationBadge-test.tsx | 132 ++++++++++++++++++ .../RoomNotificationState-test.ts | 19 ++- .../RoomNotificationStateStore-test.ts | 60 ++++++++ test/test-utils/threads.ts | 4 +- 22 files changed, 1014 insertions(+), 142 deletions(-) create mode 100644 src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx create mode 100644 src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx create mode 100644 src/hooks/useUnreadNotifications.ts create mode 100644 test/components/structures/RoomStatusBar-test.tsx create mode 100644 test/components/views/right_panel/RoomHeaderButtons-test.tsx create mode 100644 test/components/views/rooms/EventTile-test.tsx create mode 100644 test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx create mode 100644 test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx create mode 100644 test/stores/notifications/RoomNotificationStateStore-test.ts diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.ts index e0e7c974a7..ebc90443f3 100644 --- a/cypress/e2e/sliding-sync/sliding-sync.ts +++ b/cypress/e2e/sliding-sync/sliding-sync.ts @@ -235,7 +235,7 @@ describe("Sliding Sync", () => { "Test Room", "Dummy", ]); - cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.exist"); + cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.be.visible"); }); it("should update user settings promptly", () => { diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 35cd87b136..55702c787b 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -426,7 +426,7 @@ $left-gutter: 64px; } &.mx_EventTile_selected .mx_EventTile_line { - // TODO: check if this would be necessary + /* TODO: check if this would be necessary; */ padding-inline-start: calc(var(--EventTile_group_line-spacing-inline-start) + 20px); } } @@ -894,15 +894,22 @@ $left-gutter: 64px; } /* Display notification dot */ - &[data-notification]::before { + &[data-notification]::before, + .mx_NotificationBadge { + position: absolute; $notification-inset-block-start: 14px; /* 14px: align the dot with the timestamp row */ - width: $notification-dot-size; - height: $notification-dot-size; + /* !important to fix overly specific CSS selector applied on mx_NotificationBadge */ + width: $notification-dot-size !important; + height: $notification-dot-size !important; border-radius: 50%; inset: $notification-inset-block-start $spacing-8 auto auto; } + .mx_NotificationBadge_count { + display: none; + } + &[data-notification="total"]::before { background-color: $room-icon-unread-color; } @@ -1301,7 +1308,8 @@ $left-gutter: 64px; } } - &[data-shape="ThreadsList"][data-notification]::before { + &[data-shape="ThreadsList"][data-notification]::before, + .mx_NotificationBadge { /* stylelint-disable-next-line declaration-colon-space-after */ inset-block-start: calc($notification-inset-block-start - var(--MatrixChat_useCompactLayout_group-padding-top)); diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index 08c15970c5..6c1e07e66b 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -78,15 +78,23 @@ export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Pr } } -export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number { - let notificationCount = room.getUnreadNotificationCount(type); +export function getUnreadNotificationCount( + room: Room, + type: NotificationCountType, + threadId?: string, +): number { + let notificationCount = (!!threadId + ? room.getThreadUnreadNotificationCount(threadId, type) + : room.getUnreadNotificationCount(type)); // Check notification counts in the old room just in case there's some lost // there. We only go one level down to avoid performance issues, and theory // is that 1st generation rooms will have already been read by the 3rd generation. const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); - if (createEvent && createEvent.getContent()['predecessor']) { - const oldRoomId = createEvent.getContent()['predecessor']['room_id']; + const predecessor = createEvent?.getContent().predecessor; + // Exclude threadId, as the same thread can't continue over a room upgrade + if (!threadId && predecessor) { + const oldRoomId = predecessor.room_id; const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId); if (oldRoom) { // We only ever care if there's highlights in the old room. No point in diff --git a/src/Unread.ts b/src/Unread.ts index 1804ddefb7..60ef9ca19e 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -23,7 +23,6 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; import { haveRendererForEvent } from "./events/EventTileFactory"; import SettingsStore from "./settings/SettingsStore"; -import { RoomNotificationStateStore } from "./stores/notifications/RoomNotificationStateStore"; /** * Returns true if this event arriving in a room should affect the room's @@ -77,11 +76,6 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { return false; } - } else { - const threadState = RoomNotificationStateStore.instance.getThreadsRoomState(room); - if (threadState.color > 0) { - return true; - } } // if the read receipt relates to an event is that part of a thread diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index d46ad12b50..e703252546 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -34,10 +34,12 @@ const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; -export function getUnsentMessages(room: Room): MatrixEvent[] { +export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { - return ev.status === EventStatus.NOT_SENT; + const isNotSent = ev.status === EventStatus.NOT_SENT; + const belongsToTheThread = threadId === ev.threadRootId; + return isNotSent && (!threadId || belongsToTheThread); }); } diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index 262b8fc38d..c6e012fff4 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -20,7 +20,8 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { _t } from '../../../languageHandler'; import HeaderButton from './HeaderButton'; @@ -43,6 +44,7 @@ import { SummarizedNotificationState } from "../../../stores/notifications/Summa import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import PosthogTrackers from "../../../PosthogTrackers"; import { ButtonEvent } from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; const ROOM_INFO_PHASES = [ RightPanelPhases.RoomSummary, @@ -136,32 +138,67 @@ export default class RoomHeaderButtons extends HeaderButtons { private threadNotificationState: ThreadsRoomNotificationState; private globalNotificationState: SummarizedNotificationState; + private get supportsThreadNotifications(): boolean { + const client = MatrixClientPeg.get(); + return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; + } + constructor(props: IProps) { super(props, HeaderKind.Room); - this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room); + if (!this.supportsThreadNotifications) { + this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room); + } this.globalNotificationState = RoomNotificationStateStore.instance.globalState; } public componentDidMount(): void { super.componentDidMount(); - this.threadNotificationState.on(NotificationStateEvents.Update, this.onThreadNotification); + if (!this.supportsThreadNotifications) { + this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate); + } else { + this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + } + this.onNotificationUpdate(); RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } public componentWillUnmount(): void { super.componentWillUnmount(); - this.threadNotificationState.off(NotificationStateEvents.Update, this.onThreadNotification); + if (!this.supportsThreadNotifications) { + this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate); + } else { + this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + } RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } - private onThreadNotification = (): void => { + private onNotificationUpdate = (): void => { + let threadNotificationColor: NotificationColor; + if (!this.supportsThreadNotifications) { + threadNotificationColor = this.threadNotificationState.color; + } else { + threadNotificationColor = this.notificationColor; + } + + // console.log // XXX: why don't we read from this.state.threadNotificationColor in the render methods? this.setState({ - threadNotificationColor: this.threadNotificationState.color, + threadNotificationColor, }); }; + private get notificationColor(): NotificationColor { + switch (this.props.room.threadsAggregateNotificationType) { + case NotificationCountType.Highlight: + return NotificationColor.Red; + case NotificationCountType.Total: + return NotificationColor.Grey; + default: + return NotificationColor.None; + } + } + private onUpdateStatus = (notificationState: SummarizedNotificationState): void => { // XXX: why don't we read from this.state.globalNotificationCount in the render methods? this.globalNotificationState = notificationState; @@ -255,12 +292,13 @@ export default class RoomHeaderButtons extends HeaderButtons { ? 0} + isUnread={this.state.threadNotificationColor > 0} > - + : null, ); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index b13eba33e4..670a291a42 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -27,6 +27,7 @@ import { NotificationCountType, Room, RoomEvent } from 'matrix-js-sdk/src/models import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { UserTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; +import { Feature, ServerSupport } from 'matrix-js-sdk/src/feature'; import { Icon as LinkIcon } from '../../../../res/img/element-icons/link.svg'; import { Icon as ViewInRoomIcon } from '../../../../res/img/element-icons/view-in-room.svg'; @@ -84,6 +85,7 @@ import { useTooltip } from "../../../utils/useTooltip"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { ElementCall } from "../../../models/Call"; +import { UnreadNotificationBadge } from './NotificationBadge/UnreadNotificationBadge'; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -113,7 +115,7 @@ export interface IEventTileType extends React.Component { getEventTileOps?(): IEventTileOps; } -interface IProps { +export interface EventTileProps { // the MatrixEvent to show mxEvent: MatrixEvent; @@ -248,7 +250,7 @@ interface IState { } // MUST be rendered within a RoomContext with a set timelineRenderingType -export class UnwrappedEventTile extends React.Component { +export class UnwrappedEventTile extends React.Component { private suppressReadReceiptAnimation: boolean; private isListeningForReceipts: boolean; private tile = React.createRef(); @@ -267,7 +269,7 @@ export class UnwrappedEventTile extends React.Component { static contextType = RoomContext; public context!: React.ContextType; - constructor(props: IProps, context: React.ContextType) { + constructor(props: EventTileProps, context: React.ContextType) { super(props, context); const thread = this.thread; @@ -394,7 +396,7 @@ export class UnwrappedEventTile extends React.Component { if (SettingsStore.getValue("feature_thread")) { this.props.mxEvent.on(ThreadEvent.Update, this.updateThread); - if (this.thread) { + if (this.thread && !this.supportsThreadNotifications) { this.setupNotificationListener(this.thread); } } @@ -405,33 +407,40 @@ export class UnwrappedEventTile extends React.Component { room?.on(ThreadEvent.New, this.onNewThread); } + private get supportsThreadNotifications(): boolean { + const client = MatrixClientPeg.get(); + return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; + } + private setupNotificationListener(thread: Thread): void { - const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); - - this.threadState = notifications.getThreadRoomState(thread); - - this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); - this.onThreadStateUpdate(); + if (!this.supportsThreadNotifications) { + const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); + this.threadState = notifications.getThreadRoomState(thread); + this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); + this.onThreadStateUpdate(); + } } private onThreadStateUpdate = (): void => { - let threadNotification = null; - switch (this.threadState?.color) { - case NotificationColor.Grey: - threadNotification = NotificationCountType.Total; - break; - case NotificationColor.Red: - threadNotification = NotificationCountType.Highlight; - break; - } + if (!this.supportsThreadNotifications) { + let threadNotification = null; + switch (this.threadState?.color) { + case NotificationColor.Grey: + threadNotification = NotificationCountType.Total; + break; + case NotificationColor.Red: + threadNotification = NotificationCountType.Highlight; + break; + } - this.setState({ - threadNotification, - }); + this.setState({ + threadNotification, + }); + } }; private updateThread = (thread: Thread) => { - if (thread !== this.state.thread) { + if (thread !== this.state.thread && !this.supportsThreadNotifications) { if (this.threadState) { this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate); } @@ -444,7 +453,7 @@ export class UnwrappedEventTile extends React.Component { // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line - UNSAFE_componentWillReceiveProps(nextProps: IProps) { + UNSAFE_componentWillReceiveProps(nextProps: EventTileProps) { // re-check the sender verification as outgoing events progress through // the send process. if (nextProps.eventSendStatus !== this.props.eventSendStatus) { @@ -452,7 +461,7 @@ export class UnwrappedEventTile extends React.Component { } } - shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean { + shouldComponentUpdate(nextProps: EventTileProps, nextState: IState): boolean { if (objectHasDiff(this.state, nextState)) { return true; } @@ -481,7 +490,7 @@ export class UnwrappedEventTile extends React.Component { } } - componentDidUpdate(prevProps: IProps, prevState: IState, snapshot) { + componentDidUpdate() { // If we're not listening for receipts and expect to be, register a listener. if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt); @@ -667,7 +676,7 @@ export class UnwrappedEventTile extends React.Component { }, this.props.onHeightChanged); // Decryption may have caused a change in size } - private propsEqual(objA: IProps, objB: IProps): boolean { + private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { const keysA = Object.keys(objA); const keysB = Object.keys(objB); @@ -1348,6 +1357,7 @@ export class UnwrappedEventTile extends React.Component { ]); } case TimelineRenderingType.ThreadsList: { + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return ( React.createElement(this.props.as || "li", { @@ -1361,7 +1371,9 @@ export class UnwrappedEventTile extends React.Component { "data-shape": this.context.timelineRenderingType, "data-self": isOwnEvent, "data-has-reply": !!replyChain, - "data-notification": this.state.threadNotification, + "data-notification": !this.supportsThreadNotifications + ? this.state.threadNotification + : undefined, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), "onClick": (ev: MouseEvent) => { @@ -1409,6 +1421,9 @@ export class UnwrappedEventTile extends React.Component { { msgOption } + ) ); } @@ -1512,7 +1527,7 @@ export class UnwrappedEventTile extends React.Component { } // Wrap all event tiles with the tile error boundary so that any throws even during construction are captured -const SafeEventTile = forwardRef((props: IProps, ref: RefObject) => { +const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject) => { return ; diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 51745209aa..3555582298 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -15,16 +15,14 @@ limitations under the License. */ import React, { MouseEvent } from "react"; -import classNames from "classnames"; -import { formatCount } from "../../../utils/FormattingUtils"; import SettingsStore from "../../../settings/SettingsStore"; -import AccessibleButton from "../elements/AccessibleButton"; import { XOR } from "../../../@types/common"; import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import Tooltip from "../elements/Tooltip"; import { _t } from "../../../languageHandler"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { StatelessNotificationBadge } from "./NotificationBadge/StatelessNotificationBadge"; interface IProps { notification: NotificationState; @@ -113,61 +111,25 @@ export default class NotificationBadge extends React.PureComponent 0; - let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount; - if (forceCount) { - isEmptyBadge = false; - if (!notification.hasUnreadCount) return null; // Can't render a badge + let label: string; + let tooltip: JSX.Element; + if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) { + label = _t("Message didn't send. Click for info."); + tooltip = ; } - let symbol = notification.symbol || formatCount(notification.count); - if (isEmptyBadge) symbol = ""; - - const classes = classNames({ - 'mx_NotificationBadge': true, - 'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount, - 'mx_NotificationBadge_highlighted': notification.hasMentions, - 'mx_NotificationBadge_dot': isEmptyBadge, - 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, - 'mx_NotificationBadge_3char': symbol.length > 2, - }); - - if (onClick) { - let label: string; - let tooltip: JSX.Element; - if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) { - label = _t("Message didn't send. Click for info."); - tooltip = ; - } - - return ( - - { symbol } - { tooltip } - - ); - } - - return ( -
- { symbol } -
- ); + return + { tooltip } + ; } } diff --git a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx new file mode 100644 index 0000000000..868df3216f --- /dev/null +++ b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx @@ -0,0 +1,81 @@ +/* +Copyright 2022 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, { MouseEvent } from "react"; +import classNames from "classnames"; + +import { formatCount } from "../../../../utils/FormattingUtils"; +import AccessibleButton from "../../elements/AccessibleButton"; +import { NotificationColor } from "../../../../stores/notifications/NotificationColor"; + +interface Props { + symbol: string | null; + count: number; + color: NotificationColor; + onClick?: (ev: MouseEvent) => void; + onMouseOver?: (ev: MouseEvent) => void; + onMouseLeave?: (ev: MouseEvent) => void; + children?: React.ReactChildren | JSX.Element; + label?: string; +} + +export function StatelessNotificationBadge({ + symbol, + count, + color, + ...props }: Props) { + // Don't show a badge if we don't need to + if (color === NotificationColor.None) return null; + + const hasUnreadCount = color >= NotificationColor.Grey && (!!count || !!symbol); + + const isEmptyBadge = symbol === null && count === 0; + + if (symbol === null && count > 0) { + symbol = formatCount(count); + } + + const classes = classNames({ + 'mx_NotificationBadge': true, + 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasUnreadCount, + 'mx_NotificationBadge_highlighted': color === NotificationColor.Red, + 'mx_NotificationBadge_dot': isEmptyBadge, + 'mx_NotificationBadge_2char': symbol?.length > 0 && symbol?.length < 3, + 'mx_NotificationBadge_3char': symbol?.length > 2, + }); + + if (props.onClick) { + return ( + + { symbol } + { props.children } + + ); + } + + return ( +
+ { symbol } +
+ ); +} diff --git a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx new file mode 100644 index 0000000000..a623daa716 --- /dev/null +++ b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx @@ -0,0 +1,36 @@ +/* +Copyright 2022 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 { Room } from "matrix-js-sdk/src/models/room"; +import React from "react"; + +import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications"; +import { StatelessNotificationBadge } from "./StatelessNotificationBadge"; + +interface Props { + room: Room; + threadId?: string; +} + +export function UnreadNotificationBadge({ room, threadId }: Props) { + const { symbol, count, color } = useUnreadNotifications(room, threadId); + + return ; +} diff --git a/src/hooks/useUnreadNotifications.ts b/src/hooks/useUnreadNotifications.ts new file mode 100644 index 0000000000..3262137274 --- /dev/null +++ b/src/hooks/useUnreadNotifications.ts @@ -0,0 +1,93 @@ +/* +Copyright 2022 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 { NotificationCount, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +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 { EffectiveMembership, getEffectiveMembership } from "../utils/membership"; +import { useEventEmitter } from "./useEventEmitter"; + +export const useUnreadNotifications = (room: Room, threadId?: string): { + symbol: string | null; + count: number; + color: NotificationColor; +} => { + const [symbol, setSymbol] = useState(null); + const [count, setCount] = useState(0); + const [color, setColor] = useState(0); + + useEventEmitter(room, RoomEvent.UnreadNotifications, + (unreadNotifications: NotificationCount, evtThreadId?: string) => { + // Discarding all events not related to the thread if one has been setup + if (threadId && threadId !== evtThreadId) return; + updateNotificationState(); + }, + ); + useEventEmitter(room, RoomEvent.Receipt, () => updateNotificationState()); + useEventEmitter(room, RoomEvent.Timeline, () => updateNotificationState()); + useEventEmitter(room, RoomEvent.Redaction, () => updateNotificationState()); + useEventEmitter(room, RoomEvent.LocalEchoUpdated, () => updateNotificationState()); + useEventEmitter(room, RoomEvent.MyMembership, () => updateNotificationState()); + + const updateNotificationState = useCallback(() => { + if (getUnsentMessages(room, threadId).length > 0) { + setSymbol("!"); + setCount(1); + setColor(NotificationColor.Unsent); + } else if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) { + setSymbol("!"); + setCount(1); + setColor(NotificationColor.Red); + } else if (getRoomNotifsState(room.roomId) === RoomNotifState.Mute) { + setSymbol(null); + setCount(0); + setColor(NotificationColor.None); + } else { + const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId); + const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId); + + const trueCount = greyNotifs || redNotifs; + setCount(trueCount); + setSymbol(null); + if (redNotifs > 0) { + setColor(NotificationColor.Red); + } else if (greyNotifs > 0) { + setColor(NotificationColor.Grey); + } else if (!threadId) { + // TODO: No support for `Bold` on threads at the moment + + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + const hasUnread = doesRoomHaveUnreadMessages(room); + setColor(hasUnread ? NotificationColor.Bold : NotificationColor.None); + } + } + }, [room, threadId]); + + useEffect(() => { + updateNotificationState(); + }, [updateNotificationState]); + + return { + symbol, + count, + color, + }; +}; diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index 9c64b7ec42..49e76bedf8 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -17,6 +17,7 @@ limitations under the License. import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { ClientEvent } from "matrix-js-sdk/src/client"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; @@ -32,15 +33,16 @@ import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState"; export class RoomNotificationState extends NotificationState implements IDestroyable { constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) { super(); - this.room.on(RoomEvent.Receipt, this.handleReadReceipt); // for unread indicators - this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); // for redness on invites - this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); // for redness on unsent messages + const cli = this.room.client; + this.room.on(RoomEvent.Receipt, this.handleReadReceipt); + this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); + this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts - if (threadsState) { - threadsState.on(NotificationStateEvents.Update, this.handleThreadsUpdate); + if (cli.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) { + this.threadsState?.on(NotificationStateEvents.Update, this.handleThreadsUpdate); } - MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // for local count calculation - MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); // for push rules + cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + cli.on(ClientEvent.AccountData, this.handleAccountDataUpdate); this.updateNotificationState(); } @@ -50,17 +52,17 @@ export class RoomNotificationState extends NotificationState implements IDestroy public destroy(): void { super.destroy(); + const cli = this.room.client; this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt); this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate); this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); - this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); - if (this.threadsState) { + if (cli.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) { + this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); + } else if (this.threadsState) { this.threadsState.removeListener(NotificationStateEvents.Update, this.handleThreadsUpdate); } - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); - MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate); - } + cli.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); + cli.removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate); } private handleThreadsUpdate = () => { diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 48aa7e7c20..ad9bd9f98d 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -17,6 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { ClientEvent } from "matrix-js-sdk/src/client"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { ActionPayload } from "../../dispatcher/payloads"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -39,9 +40,9 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { instance.start(); return instance; })(); - private roomMap = new Map(); - private roomThreadsMap = new Map(); + + private roomThreadsMap: Map = new Map(); private listMap = new Map(); private _globalState = new SummarizedNotificationState(); @@ -86,18 +87,25 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { */ public getRoomState(room: Room): RoomNotificationState { if (!this.roomMap.has(room)) { - // Not very elegant, but that way we ensure that we start tracking - // threads notification at the same time at rooms. - // There are multiple entry points, and it's unclear which one gets - // called first - const threadState = new ThreadsRoomNotificationState(room); - this.roomThreadsMap.set(room, threadState); + let threadState; + if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) { + // Not very elegant, but that way we ensure that we start tracking + // threads notification at the same time at rooms. + // There are multiple entry points, and it's unclear which one gets + // called first + const threadState = new ThreadsRoomNotificationState(room); + this.roomThreadsMap.set(room, threadState); + } this.roomMap.set(room, new RoomNotificationState(room, threadState)); } return this.roomMap.get(room); } - public getThreadsRoomState(room: Room): ThreadsRoomNotificationState { + public getThreadsRoomState(room: Room): ThreadsRoomNotificationState | null { + if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) { + return null; + } + if (!this.roomThreadsMap.has(room)) { this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room)); } diff --git a/test/RoomNotifs-test.ts b/test/RoomNotifs-test.ts index 3f486205df..8ab37e6945 100644 --- a/test/RoomNotifs-test.ts +++ b/test/RoomNotifs-test.ts @@ -16,10 +16,15 @@ limitations under the License. import { mocked } from 'jest-mock'; import { ConditionKind, PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules"; +import { NotificationCountType, Room } from 'matrix-js-sdk/src/models/room'; -import { stubClient } from "./test-utils"; +import { mkEvent, stubClient } from "./test-utils"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; -import { getRoomNotifsState, RoomNotifState } from "../src/RoomNotifs"; +import { + getRoomNotifsState, + RoomNotifState, + getUnreadNotificationCount, +} from "../src/RoomNotifs"; describe("RoomNotifs test", () => { beforeEach(() => { @@ -83,4 +88,74 @@ describe("RoomNotifs test", () => { }); expect(getRoomNotifsState("!roomId:server")).toBe(RoomNotifState.AllMessagesLoud); }); + + describe("getUnreadNotificationCount", () => { + const ROOM_ID = "!roomId:example.org"; + const THREAD_ID = "$threadId"; + + let cli; + let room: Room; + beforeEach(() => { + cli = MatrixClientPeg.get(); + room = new Room(ROOM_ID, cli, cli.getUserId()); + }); + + it("counts room notification type", () => { + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(0); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(0); + }); + + it("counts notifications type", () => { + room.setUnreadNotificationCount(NotificationCountType.Total, 2); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(2); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(1); + }); + + it("counts predecessor highlight", () => { + room.setUnreadNotificationCount(NotificationCountType.Total, 2); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + + const OLD_ROOM_ID = "!oldRoomId:example.org"; + const oldRoom = new Room(OLD_ROOM_ID, cli, cli.getUserId()); + oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10); + oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6); + + cli.getRoom.mockReset().mockReturnValue(oldRoom); + + const predecessorEvent = mkEvent({ + event: true, + type: "m.room.create", + room: ROOM_ID, + user: cli.getUserId(), + content: { + creator: cli.getUserId(), + room_version: "5", + predecessor: { + room_id: OLD_ROOM_ID, + event_id: "$someevent", + }, + }, + ts: Date.now(), + }); + room.addLiveEvents([predecessorEvent]); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(8); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(7); + }); + + it("counts thread notification type", () => { + expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(0); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(0); + }); + + it("counts notifications type", () => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 2); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(2); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(1); + }); + }); }); diff --git a/test/components/structures/RoomStatusBar-test.tsx b/test/components/structures/RoomStatusBar-test.tsx new file mode 100644 index 0000000000..db8b0e03ff --- /dev/null +++ b/test/components/structures/RoomStatusBar-test.tsx @@ -0,0 +1,91 @@ +/* +Copyright 2022 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 { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { mkEvent, stubClient } from "../../test-utils/test-utils"; +import { mkThread } from "../../test-utils/threads"; + +describe("RoomStatusBar", () => { + const ROOM_ID = "!roomId:example.org"; + let room: Room; + let client: MatrixClient; + let event: MatrixEvent; + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.get(); + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + event = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: "!room1:server", + content: {}, + }); + event.status = EventStatus.NOT_SENT; + }); + + describe("getUnsentMessages", () => { + it("returns no unsent messages", () => { + expect(getUnsentMessages(room)).toHaveLength(0); + }); + + it("checks the event status", () => { + room.addPendingEvent(event, "123"); + + expect(getUnsentMessages(room)).toHaveLength(1); + event.status = EventStatus.SENT; + + expect(getUnsentMessages(room)).toHaveLength(0); + }); + + it("only returns events related to a thread", () => { + room.addPendingEvent(event, "123"); + + const { rootEvent, events } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@alice:example.org"], + length: 2, + }); + rootEvent.status = EventStatus.NOT_SENT; + room.addPendingEvent(rootEvent, rootEvent.getId()); + for (const event of events) { + event.status = EventStatus.NOT_SENT; + room.addPendingEvent(event, Date.now() + Math.random() + ""); + } + + const pendingEvents = getUnsentMessages(room, rootEvent.getId()); + + expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId()); + expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId()); + expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId()); + + // Filters out the non thread events + expect(pendingEvents.every(ev => ev.getId() !== event.getId())).toBe(true); + }); + }); +}); diff --git a/test/components/views/right_panel/RoomHeaderButtons-test.tsx b/test/components/views/right_panel/RoomHeaderButtons-test.tsx new file mode 100644 index 0000000000..5d873f4b86 --- /dev/null +++ b/test/components/views/right_panel/RoomHeaderButtons-test.tsx @@ -0,0 +1,97 @@ +/* +Copyright 2022 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 { render } from "@testing-library/react"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import React from "react"; + +import RoomHeaderButtons from "../../../../src/components/views/right_panel/RoomHeaderButtons"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { stubClient } from "../../../test-utils"; + +describe("RoomHeaderButtons-test.tsx", function() { + const ROOM_ID = "!roomId:example.org"; + let room: Room; + let client: MatrixClient; + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.get(); + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "feature_thread") return true; + }); + }); + + function getComponent(room: Room) { + return render(); + } + + function getThreadButton(container) { + return container.querySelector(".mx_RightPanel_threadsButton"); + } + + function isIndicatorOfType(container, type: "red" | "gray") { + return container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator") + .className + .includes(type); + } + + it("shows the thread button", () => { + const { container } = getComponent(room); + expect(getThreadButton(container)).not.toBeNull(); + }); + + it("hides the thread button", () => { + jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false); + const { container } = getComponent(room); + expect(getThreadButton(container)).toBeNull(); + }); + + it("room wide notification does not change the thread button", () => { + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + + const { container } = getComponent(room); + + expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); + }); + + it("room wide notification does not change the thread button", () => { + const { container } = getComponent(room); + + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1); + expect(isIndicatorOfType(container, "gray")).toBe(true); + + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1); + expect(isIndicatorOfType(container, "red")).toBe(true); + + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 0); + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 0); + + expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); + }); +}); diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx new file mode 100644 index 0000000000..6de3a262cd --- /dev/null +++ b/test/components/views/rooms/EventTile-test.tsx @@ -0,0 +1,112 @@ +/* +Copyright 2022 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 { act, render } from "@testing-library/react"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import React from "react"; + +import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile"; +import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { getRoomContext, mkMessage, stubClient } from "../../../test-utils"; +import { mkThread } from "../../../test-utils/threads"; + +describe("EventTile", () => { + const ROOM_ID = "!roomId:example.org"; + let mxEvent: MatrixEvent; + let room: Room; + let client: MatrixClient; + // let changeEvent: (event: MatrixEvent) => void; + + function TestEventTile(props: Partial) { + // const [event] = useState(mxEvent); + // Give a way for a test to update the event prop. + // changeEvent = setEvent; + + return ; + } + + function getComponent( + overrides: Partial = {}, + renderingType: TimelineRenderingType = TimelineRenderingType.Room, + ) { + const context = getRoomContext(room, { + timelineRenderingType: renderingType, + }); + return render( + + + , + ); + } + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.get(); + + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + jest.spyOn(client, "getRoom").mockReturnValue(room); + + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + }); + + describe("EventTile renderingType: ThreadsList", () => { + beforeEach(() => { + const { rootEvent } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@alice:example.org"], + }); + mxEvent = rootEvent; + }); + + it("shows an unread notification bage", () => { + const { container } = getComponent({}, TimelineRenderingType.ThreadsList); + + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0); + + act(() => { + room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Total, 3); + }); + + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); + expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(0); + + act(() => { + room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Highlight, 1); + }); + + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); + expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(1); + }); + }); +}); diff --git a/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx new file mode 100644 index 0000000000..95d598a704 --- /dev/null +++ b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx @@ -0,0 +1,49 @@ +/* +Copyright 2022 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 { fireEvent, render } from "@testing-library/react"; +import React from "react"; + +import { + StatelessNotificationBadge, +} from "../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge"; +import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor"; + +describe("NotificationBadge", () => { + describe("StatelessNotificationBadge", () => { + it("lets you click it", () => { + const cb = jest.fn(); + + const { container } = render(); + + fireEvent.click(container.firstChild); + expect(cb).toHaveBeenCalledTimes(1); + + fireEvent.mouseEnter(container.firstChild); + expect(cb).toHaveBeenCalledTimes(2); + + fireEvent.mouseLeave(container.firstChild); + expect(cb).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx new file mode 100644 index 0000000000..20289dc6b9 --- /dev/null +++ b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx @@ -0,0 +1,132 @@ +/* +Copyright 2022 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 "jest-mock"; +import { screen, act, render } from "@testing-library/react"; +import { MatrixClient, 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 { + UnreadNotificationBadge, +} from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge"; +import { mkMessage, stubClient } from "../../../../test-utils/test-utils"; +import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import * as RoomNotifs from "../../../../../src/RoomNotifs"; + +jest.mock("../../../../../src/RoomNotifs"); +jest.mock('../../../../../src/RoomNotifs', () => ({ + ...(jest.requireActual('../../../../../src/RoomNotifs') as Object), + getRoomNotifsState: jest.fn(), +})); + +const ROOM_ID = "!roomId:example.org"; +let THREAD_ID; + +describe("UnreadNotificationBadge", () => { + let mockClient: MatrixClient; + let room: Room; + + function getComponent(threadId?: string) { + return ; + } + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + mockClient = mocked(MatrixClientPeg.get()); + + room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + + jest.spyOn(RoomNotifs, "getRoomNotifsState").mockReturnValue(RoomNotifs.RoomNotifState.AllMessages); + }); + + it("renders unread notification badge", () => { + const { container } = render(getComponent()); + + expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy(); + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy(); + + act(() => { + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + }); + + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeTruthy(); + }); + + it("renders unread thread notification badge", () => { + const { container } = render(getComponent(THREAD_ID)); + + expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy(); + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy(); + + act(() => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1); + }); + + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeTruthy(); + }); + + it("hides unread notification badge", () => { + act(() => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + const { container } = render(getComponent(THREAD_ID)); + expect(container.querySelector(".mx_NotificationBadge_visible")).toBeFalsy(); + }); + }); + + it("adds a warning for unsent messages", () => { + const evt = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + evt.status = EventStatus.NOT_SENT; + + room.addPendingEvent(evt, "123"); + + render(getComponent()); + + expect(screen.queryByText("!")).not.toBeNull(); + }); + + it("adds a warning for invites", () => { + jest.spyOn(room, "getMyMembership").mockReturnValue("invite"); + render(getComponent()); + expect(screen.queryByText("!")).not.toBeNull(); + }); + + it("hides counter for muted rooms", () => { + jest.spyOn(RoomNotifs, "getRoomNotifsState") + .mockReset() + .mockReturnValue(RoomNotifs.RoomNotifState.Mute); + + const { container } = render(getComponent()); + expect(container.querySelector(".mx_NotificationBadge")).toBeNull(); + }); +}); diff --git a/test/stores/notifications/RoomNotificationState-test.ts b/test/stores/notifications/RoomNotificationState-test.ts index 904e068909..c9ee6dd497 100644 --- a/test/stores/notifications/RoomNotificationState-test.ts +++ b/test/stores/notifications/RoomNotificationState-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; -import { MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixEventEvent, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; import { stubClient } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; @@ -24,12 +24,16 @@ import * as testUtils from "../../test-utils"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; describe("RoomNotificationState", () => { - stubClient(); - const client = MatrixClientPeg.get(); + let testRoom: Room; + let client: MatrixClient; + + beforeEach(() => { + stubClient(); + client = MatrixClientPeg.get(); + testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client); + }); it("Updates on event decryption", () => { - const testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client); - const roomNotifState = new RoomNotificationState(testRoom as any as Room); const listener = jest.fn(); roomNotifState.addListener(NotificationStateEvents.Update, listener); @@ -40,4 +44,9 @@ describe("RoomNotificationState", () => { client.emit(MatrixEventEvent.Decrypted, testEvent); expect(listener).toHaveBeenCalled(); }); + + it("removes listeners", () => { + const roomNotifState = new RoomNotificationState(testRoom as any as Room); + expect(() => roomNotifState.destroy()).not.toThrow(); + }); }); diff --git a/test/stores/notifications/RoomNotificationStateStore-test.ts b/test/stores/notifications/RoomNotificationStateStore-test.ts new file mode 100644 index 0000000000..e5d24881ae --- /dev/null +++ b/test/stores/notifications/RoomNotificationStateStore-test.ts @@ -0,0 +1,60 @@ +/* +Copyright 2022 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 { PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; +import { stubClient } from "../../test-utils"; + +describe("RoomNotificationStateStore", () => { + const ROOM_ID = "!roomId:example.org"; + + let room; + let client; + + beforeEach(() => { + stubClient(); + client = MatrixClientPeg.get(); + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + }); + + it("does not use legacy thread notification store", () => { + client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable); + expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull(); + }); + + it("use legacy thread notification store", () => { + client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported); + expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull(); + }); + + it("does not use legacy thread notification store", () => { + client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable); + RoomNotificationStateStore.instance.getRoomState(room); + expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull(); + }); + + it("use legacy thread notification store", () => { + client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported); + RoomNotificationStateStore.instance.getRoomState(room); + expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull(); + }); +}); diff --git a/test/test-utils/threads.ts b/test/test-utils/threads.ts index 419b09b2b8..2259527178 100644 --- a/test/test-utils/threads.ts +++ b/test/test-utils/threads.ts @@ -106,7 +106,7 @@ export const mkThread = ({ participantUserIds, length = 2, ts = 1, -}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent } => { +}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent, events: MatrixEvent[] } => { const { rootEvent, events } = makeThreadEvents({ roomId: room.roomId, authorId, @@ -120,5 +120,5 @@ export const mkThread = ({ // So that we do not have to mock the thread loading thread.initialEventsFetched = true; - return { thread, rootEvent }; + return { thread, rootEvent, events }; };