From fde9a527a748500e081c48a2d2ecf84a8f031d4a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 21 Oct 2022 11:44:33 +0100 Subject: [PATCH 1/6] Improve typing (#9474) --- .node-version | 2 +- src/components/structures/LoggedInView.tsx | 9 +++++---- src/components/structures/MatrixChat.tsx | 5 ++--- src/utils/ErrorUtils.tsx | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.node-version b/.node-version index 8351c19397..b6a7d89c68 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -14 +16 diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 872c69c01d..f359f091f1 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -22,6 +22,7 @@ import classNames from 'classnames'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { MatrixError } from 'matrix-js-sdk/src/matrix'; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -288,8 +289,8 @@ class LoggedInView extends React.Component { }; private onSync = (syncState: SyncState, oldSyncState?: SyncState, data?: ISyncStateData): void => { - const oldErrCode = this.state.syncErrorData?.error?.errcode; - const newErrCode = data && data.error && data.error.errcode; + const oldErrCode = (this.state.syncErrorData?.error as MatrixError)?.errcode; + const newErrCode = (data?.error as MatrixError)?.errcode; if (syncState === oldSyncState && oldErrCode === newErrCode) return; this.setState({ @@ -317,9 +318,9 @@ class LoggedInView extends React.Component { }; private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { - const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; + const error = (syncError?.error as MatrixError)?.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; if (error) { - usageLimitEventContent = syncError.error.data as IUsageLimit; + usageLimitEventContent = (syncError?.error as MatrixError).data as IUsageLimit; } // usageLimitDismissed is true when the user has explicitly hidden the toast diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 0e3fc304e3..cce4b9d0ab 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -24,7 +24,6 @@ import { MatrixEventEvent, } from 'matrix-js-sdk/src/matrix'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; -import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils"; @@ -203,7 +202,7 @@ interface IState { // When showing Modal dialogs we need to set aria-hidden on the root app element // and disable it when there are no dialogs hideToSRUsers: boolean; - syncError?: MatrixError; + syncError?: Error; resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; @@ -1457,7 +1456,7 @@ export default class MatrixChat extends React.PureComponent { if (data.error instanceof InvalidStoreError) { Lifecycle.handleInvalidStoreError(data.error); } - this.setState({ syncError: data.error || {} as MatrixError }); + this.setState({ syncError: data.error }); } else if (this.state.syncError) { this.setState({ syncError: null }); } diff --git a/src/utils/ErrorUtils.tsx b/src/utils/ErrorUtils.tsx index 6af61aca2e..ff78fe076c 100644 --- a/src/utils/ErrorUtils.tsx +++ b/src/utils/ErrorUtils.tsx @@ -57,8 +57,8 @@ export function messageForResourceLimitError( } } -export function messageForSyncError(err: MatrixError): ReactNode { - if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { +export function messageForSyncError(err: Error): ReactNode { + if (err instanceof MatrixError && err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const limitError = messageForResourceLimitError( err.data.limit_type, err.data.admin_contact, From 8ae67aa4dd73d88f4a6ad9bc10880b50143aee3a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 21 Oct 2022 14:45:38 +0100 Subject: [PATCH 2/6] Only show mini avatar uploader in room intro when no avatar yet exists (#9479) --- src/components/views/rooms/NewRoomIntro.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 56c7d7224c..371494c79e 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -175,14 +175,22 @@ const NewRoomIntro = () => { } const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url; - body = - + ); + + if (!avatarUrl) { + avatar = cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, '')} > - - + { avatar } + ; + } + + body = + { avatar }

{ room.name }

From d4f1c573adf109460fcbd86c5043d570bb690714 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Sat, 22 Oct 2022 13:07:39 +0200 Subject: [PATCH 3/6] Fix voice broadcast recording limit (#9478) --- src/audio/VoiceRecording.ts | 11 ++ .../audio/VoiceBroadcastRecorder.ts | 4 +- test/audio/VoiceRecording-test.ts | 105 ++++++++++++++++++ .../audio/VoiceBroadcastRecorder-test.ts | 19 ++-- 4 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 test/audio/VoiceRecording-test.ts diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index 0e18756fe5..99f878868d 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -60,6 +60,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderProcessor: ScriptProcessorNode; private recording = false; private observable: SimpleObservable; + private targetMaxLength: number | null = TARGET_MAX_LENGTH; public amplitudes: number[] = []; // at each second mark, generated private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); public onDataAvailable: (data: ArrayBuffer) => void; @@ -83,6 +84,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return true; // we don't ever care if the event had listeners, so just return "yes" } + public disableMaxLength(): void { + this.targetMaxLength = null; + } + private async makeRecorder() { try { this.recorderStream = await navigator.mediaDevices.getUserMedia({ @@ -203,6 +208,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // In testing, recorder time and worker time lag by about 400ms, which is roughly the // time needed to encode a sample/frame. // + + if (!this.targetMaxLength) { + // skip time checks if max length has been disabled + return; + } + const secondsLeft = TARGET_MAX_LENGTH - this.recorderSeconds; if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts index ff1d22a41c..df7ae362d9 100644 --- a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts +++ b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts @@ -139,5 +139,7 @@ export class VoiceBroadcastRecorder } export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => { - return new VoiceBroadcastRecorder(new VoiceRecording(), getChunkLength()); + const voiceRecording = new VoiceRecording(); + voiceRecording.disableMaxLength(); + return new VoiceBroadcastRecorder(voiceRecording, getChunkLength()); }; diff --git a/test/audio/VoiceRecording-test.ts b/test/audio/VoiceRecording-test.ts new file mode 100644 index 0000000000..ac4f52eabe --- /dev/null +++ b/test/audio/VoiceRecording-test.ts @@ -0,0 +1,105 @@ +/* +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 { VoiceRecording } from "../../src/audio/VoiceRecording"; + +/** + * The tests here are heavily using access to private props. + * While this is not so great, we can at lest test some behaviour easily this way. + */ +describe("VoiceRecording", () => { + let recording: VoiceRecording; + let recorderSecondsSpy: jest.SpyInstance; + + const itShouldNotCallStop = () => { + it("should not call stop", () => { + expect(recording.stop).not.toHaveBeenCalled(); + }); + }; + + const simulateUpdate = (recorderSeconds: number) => { + beforeEach(() => { + recorderSecondsSpy.mockReturnValue(recorderSeconds); + // @ts-ignore + recording.processAudioUpdate(recorderSeconds); + }); + }; + + beforeEach(() => { + recording = new VoiceRecording(); + // @ts-ignore + recording.observable = { + update: jest.fn(), + }; + jest.spyOn(recording, "stop").mockImplementation(); + recorderSecondsSpy = jest.spyOn(recording, "recorderSeconds", "get"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("when recording", () => { + beforeEach(() => { + // @ts-ignore + recording.recording = true; + }); + + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + + it("should call stop", () => { + expect(recording.stop).toHaveBeenCalled(); + }); + }); + + describe("and the max length limit has been disabled", () => { + beforeEach(() => { + recording.disableMaxLength(); + }); + + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + itShouldNotCallStop(); + }); + }); + }); + + describe("when not recording", () => { + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + itShouldNotCallStop(); + }); + }); +}); diff --git a/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts b/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts index e60d7e2d96..df7da24ce5 100644 --- a/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts +++ b/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts @@ -26,6 +26,8 @@ import { VoiceBroadcastRecorderEvent, } from "../../../src/voice-broadcast"; +jest.mock("../../../src/audio/VoiceRecording"); + describe("VoiceBroadcastRecorder", () => { describe("createVoiceBroadcastRecorder", () => { beforeEach(() => { @@ -44,6 +46,7 @@ describe("VoiceBroadcastRecorder", () => { it("should return a VoiceBroadcastRecorder instance with targetChunkLength from config", () => { const voiceBroadcastRecorder = createVoiceBroadcastRecorder(); + expect(mocked(VoiceRecording).mock.instances[0].disableMaxLength).toHaveBeenCalled(); expect(voiceBroadcastRecorder).toBeInstanceOf(VoiceBroadcastRecorder); expect(voiceBroadcastRecorder.targetChunkLength).toBe(1337); }); @@ -72,16 +75,12 @@ describe("VoiceBroadcastRecorder", () => { }; beforeEach(() => { - voiceRecording = { - contentType, - start: jest.fn().mockResolvedValue(undefined), - stop: jest.fn().mockResolvedValue(undefined), - on: jest.fn(), - off: jest.fn(), - emit: jest.fn(), - destroy: jest.fn(), - recorderSeconds: 23, - } as unknown as VoiceRecording; + voiceRecording = new VoiceRecording(); + // @ts-ignore + voiceRecording.recorderSeconds = 23; + // @ts-ignore + voiceRecording.contentType = contentType; + voiceBroadcastRecorder = new VoiceBroadcastRecorder(voiceRecording, chunkLength); jest.spyOn(voiceBroadcastRecorder, "removeAllListeners"); onChunkRecorded = jest.fn(); From 9eb4f8d723863bc17f8d226621e7445ee67d25ec Mon Sep 17 00:00:00 2001 From: Germain Date: Mon, 24 Oct 2022 07:50:21 +0100 Subject: [PATCH 4/6] 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 }; }; From 913af09e619a07471e989a2ad5acde2e3ae806a8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 24 Oct 2022 09:06:20 +0100 Subject: [PATCH 5/6] Convert some tests from Enzyme to RTL (#9483) --- .../views/beacon/ShareLatestLocation.tsx | 2 +- .../views/beacon/BeaconListItem-test.tsx | 67 +++--- .../beacon/LeftPanelLiveShareWarning-test.tsx | 71 +++--- .../views/beacon/ShareLatestLocation-test.tsx | 21 +- .../beacon/StyledLiveBeaconIcon-test.tsx | 10 +- .../BeaconListItem-test.tsx.snap | 67 +++++- .../__snapshots__/DialogSidebar-test.tsx.snap | 2 +- .../LeftPanelLiveShareWarning-test.tsx.snap | 80 ++----- .../ShareLatestLocation-test.tsx.snap | 97 +++------ .../StyledLiveBeaconIcon-test.tsx.snap | 9 + .../views/elements/StyledRadioGroup-test.tsx | 38 ++-- .../StyledRadioGroup-test.tsx.snap | 203 ++++++------------ test/modules/ModuleComponents-test.tsx | 11 +- .../ModuleComponents-test.tsx.snap | 85 +++----- 14 files changed, 313 insertions(+), 450 deletions(-) create mode 100644 test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap diff --git a/src/components/views/beacon/ShareLatestLocation.tsx b/src/components/views/beacon/ShareLatestLocation.tsx index 09c179f6d6..be8bc6f977 100644 --- a/src/components/views/beacon/ShareLatestLocation.tsx +++ b/src/components/views/beacon/ShareLatestLocation.tsx @@ -47,7 +47,7 @@ const ShareLatestLocation: React.FC = ({ latestLocationState }) => { return <> ', () => { beacon: new Beacon(aliceBeaconEvent), }; - const getComponent = (props = {}) => - mount(, { - wrappingComponent: MatrixClientContext.Provider, - wrappingComponentProps: { value: mockClient }, - }); + const getComponent = (props = {}) => render( + + ); const setupRoomWithBeacons = (beaconInfoEvents: MatrixEvent[], locationEvents?: MatrixEvent[]): Beacon[] => { const beacons = makeRoomWithBeacons(roomId, mockClient, beaconInfoEvents, locationEvents); @@ -104,71 +100,72 @@ describe('', () => { { isLive: false }, ); const [beacon] = setupRoomWithBeacons([notLiveBeacon]); - const component = getComponent({ beacon }); - expect(component.html()).toBeNull(); + const { container } = getComponent({ beacon }); + expect(container.innerHTML).toBeFalsy(); }); it('renders null when beacon has no location', () => { const [beacon] = setupRoomWithBeacons([aliceBeaconEvent]); - const component = getComponent({ beacon }); - expect(component.html()).toBeNull(); + const { container } = getComponent({ beacon }); + expect(container.innerHTML).toBeFalsy(); }); describe('when a beacon is live and has locations', () => { it('renders beacon info', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.html()).toMatchSnapshot(); + const { asFragment } = getComponent({ beacon }); + expect(asFragment()).toMatchSnapshot(); }); describe('non-self beacons', () => { it('uses beacon description as beacon name', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('BeaconStatus').props().label).toEqual("Alice's car"); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent("Alice's car"); }); it('uses beacon owner mxid as beacon name for a beacon without description', () => { const [beacon] = setupRoomWithBeacons([pinBeaconWithoutDescription], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('BeaconStatus').props().label).toEqual(aliceId); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent(aliceId); }); it('renders location icon', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('StyledLiveBeaconIcon').length).toBeTruthy(); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_StyledLiveBeaconIcon')).toBeTruthy(); }); }); describe('self locations', () => { it('renders beacon owner avatar', () => { const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('MemberAvatar').length).toBeTruthy(); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_BaseAvatar')).toBeTruthy(); }); it('uses beacon owner name as beacon name', () => { const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('BeaconStatus').props().label).toEqual('Alice'); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent("Alice"); }); }); describe('on location updates', () => { it('updates last updated time on location updated', () => { const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation2]); - const component = getComponent({ beacon }); + const { container } = getComponent({ beacon }); - expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated 9 minutes ago'); + expect(container.querySelector('.mx_BeaconListItem_lastUpdated')) + .toHaveTextContent('Updated 9 minutes ago'); // update to a newer location act(() => { beacon.addLocations([aliceLocation1]); - component.setProps({}); }); - expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago'); + expect(container.querySelector('.mx_BeaconListItem_lastUpdated')) + .toHaveTextContent('Updated a few seconds ago'); }); }); @@ -176,23 +173,19 @@ describe('', () => { it('does not call onClick handler when clicking share button', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); const onClick = jest.fn(); - const component = getComponent({ beacon, onClick }); + const { getByTestId } = getComponent({ beacon, onClick }); - act(() => { - findByTestId(component, 'open-location-in-osm').at(0).simulate('click'); - }); + fireEvent.click(getByTestId('open-location-in-osm')); expect(onClick).not.toHaveBeenCalled(); }); it('calls onClick handler when clicking outside of share buttons', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); const onClick = jest.fn(); - const component = getComponent({ beacon, onClick }); + const { container } = getComponent({ beacon, onClick }); - act(() => { - // click the beacon name - component.find('.mx_BeaconStatus_description').simulate('click'); - }); + // click the beacon name + fireEvent.click(container.querySelector(".mx_BeaconStatus_description")); expect(onClick).toHaveBeenCalled(); }); }); diff --git a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx index 0369fa5cc1..b8ab33b044 100644 --- a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx +++ b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx @@ -16,8 +16,7 @@ limitations under the License. import React from 'react'; import { mocked } from 'jest-mock'; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; +import { fireEvent, render } from "@testing-library/react"; import { act } from 'react-dom/test-utils'; import { Beacon, BeaconIdentifier } from 'matrix-js-sdk/src/matrix'; @@ -48,9 +47,7 @@ jest.mock('../../../../src/stores/OwnBeaconStore', () => { ); describe('', () => { - const defaultProps = {}; - const getComponent = (props = {}) => - mount(); + const getComponent = (props = {}) => render(); const roomId1 = '!room1:server'; const roomId2 = '!room2:server'; @@ -85,8 +82,8 @@ describe('', () => { )); it('renders nothing when user has no live beacons', () => { - const component = getComponent(); - expect(component.html()).toBe(null); + const { container } = getComponent(); + expect(container.innerHTML).toBeFalsy(); }); describe('when user has live location monitor', () => { @@ -110,17 +107,15 @@ describe('', () => { }); it('renders correctly when not minimized', () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); + const { asFragment } = getComponent(); + expect(asFragment()).toMatchSnapshot(); }); it('goes to room of latest beacon when clicked', () => { - const component = getComponent(); + const { container } = getComponent(); const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); - act(() => { - component.simulate('click'); - }); + fireEvent.click(container.querySelector("[role=button]")); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, @@ -134,28 +129,26 @@ describe('', () => { }); it('renders correctly when minimized', () => { - const component = getComponent({ isMinimized: true }); - expect(component).toMatchSnapshot(); + const { asFragment } = getComponent({ isMinimized: true }); + expect(asFragment()).toMatchSnapshot(); }); it('renders location publish error', () => { mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue( [beacon1.identifier], ); - const component = getComponent(); - expect(component).toMatchSnapshot(); + const { asFragment } = getComponent(); + expect(asFragment()).toMatchSnapshot(); }); it('goes to room of latest beacon with location publish error when clicked', () => { mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue( [beacon1.identifier], ); - const component = getComponent(); + const { container } = getComponent(); const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); - act(() => { - component.simulate('click'); - }); + fireEvent.click(container.querySelector("[role=button]")); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, @@ -172,9 +165,9 @@ describe('', () => { mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue( [beacon1.identifier], ); - const component = getComponent(); + const { container, rerender } = getComponent(); // error mode - expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( + expect(container.querySelector('.mx_LeftPanelLiveShareWarning').textContent).toEqual( 'An error occurred whilst sharing your live location', ); @@ -183,18 +176,18 @@ describe('', () => { OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.LocationPublishError, 'abc'); }); - component.setProps({}); + rerender(); // default mode - expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( + expect(container.querySelector('.mx_LeftPanelLiveShareWarning').textContent).toEqual( 'You are sharing your live location', ); }); it('removes itself when user stops having live beacons', async () => { - const component = getComponent({ isMinimized: true }); + const { container, rerender } = getComponent({ isMinimized: true }); // started out rendered - expect(component.html()).toBeTruthy(); + expect(container.innerHTML).toBeTruthy(); act(() => { mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; @@ -202,9 +195,9 @@ describe('', () => { }); await flushPromises(); - component.setProps({}); + rerender(); - expect(component.html()).toBe(null); + expect(container.innerHTML).toBeFalsy(); }); it('refreshes beacon liveness monitors when pagevisibilty changes to visible', () => { @@ -228,21 +221,21 @@ describe('', () => { describe('stopping errors', () => { it('renders stopping error', () => { OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error')); - const component = getComponent(); - expect(component.text()).toEqual('An error occurred while stopping your live location'); + const { container } = getComponent(); + expect(container.textContent).toEqual('An error occurred while stopping your live location'); }); it('starts rendering stopping error on beaconUpdateError emit', () => { - const component = getComponent(); + const { container } = getComponent(); // no error - expect(component.text()).toEqual('You are sharing your live location'); + expect(container.textContent).toEqual('You are sharing your live location'); act(() => { OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error')); OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.BeaconUpdateError, beacon2.identifier, true); }); - expect(component.text()).toEqual('An error occurred while stopping your live location'); + expect(container.textContent).toEqual('An error occurred while stopping your live location'); }); it('renders stopping error when beacons have stopping and location errors', () => { @@ -250,8 +243,8 @@ describe('', () => { [beacon1.identifier], ); OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error')); - const component = getComponent(); - expect(component.text()).toEqual('An error occurred while stopping your live location'); + const { container } = getComponent(); + expect(container.textContent).toEqual('An error occurred while stopping your live location'); }); it('goes to room of latest beacon with stopping error when clicked', () => { @@ -259,12 +252,10 @@ describe('', () => { [beacon1.identifier], ); OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error')); - const component = getComponent(); + const { container } = getComponent(); const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); - act(() => { - component.simulate('click'); - }); + fireEvent.click(container.querySelector("[role=button]")); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, diff --git a/test/components/views/beacon/ShareLatestLocation-test.tsx b/test/components/views/beacon/ShareLatestLocation-test.tsx index 767c712042..1712d7d57c 100644 --- a/test/components/views/beacon/ShareLatestLocation-test.tsx +++ b/test/components/views/beacon/ShareLatestLocation-test.tsx @@ -15,9 +15,7 @@ limitations under the License. */ import React from 'react'; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; +import { fireEvent, render } from "@testing-library/react"; import ShareLatestLocation from '../../../../src/components/views/beacon/ShareLatestLocation'; import { copyPlaintext } from '../../../../src/utils/strings'; @@ -34,26 +32,23 @@ describe('', () => { timestamp: 123, }, }; - const getComponent = (props = {}) => - mount(); + const getComponent = (props = {}) => render(); beforeEach(() => { jest.clearAllMocks(); }); it('renders null when no location', () => { - const component = getComponent({ latestLocationState: undefined }); - expect(component.html()).toBeNull(); + const { container } = getComponent({ latestLocationState: undefined }); + expect(container.innerHTML).toBeFalsy(); }); it('renders share buttons when there is a location', async () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); + const { container, asFragment } = getComponent(); + expect(asFragment()).toMatchSnapshot(); - await act(async () => { - component.find('.mx_CopyableText_copyButton').at(0).simulate('click'); - await flushPromises(); - }); + fireEvent.click(container.querySelector('.mx_CopyableText_copyButton')); + await flushPromises(); expect(copyPlaintext).toHaveBeenCalledWith('51,42'); }); diff --git a/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx b/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx index d6be878a25..e04289c7db 100644 --- a/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx +++ b/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx @@ -15,18 +15,16 @@ limitations under the License. */ import React from 'react'; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; +import { render } from "@testing-library/react"; import StyledLiveBeaconIcon from '../../../../src/components/views/beacon/StyledLiveBeaconIcon'; describe('', () => { const defaultProps = {}; - const getComponent = (props = {}) => - mount(); + const getComponent = (props = {}) => render(); it('renders', () => { - const component = getComponent(); - expect(component).toBeTruthy(); + const { asFragment } = getComponent(); + expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap index 9ddc5dd44c..dd1d607dd4 100644 --- a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap @@ -1,3 +1,68 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` when a beacon is live and has locations renders beacon info 1`] = `"
  • Updated a few seconds ago
  • "`; +exports[` when a beacon is live and has locations renders beacon info 1`] = ` + +
  • +
    +
    +
    +
    + + Alice's car + + + Live until 16:04 + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + + Updated a few seconds ago + +
    +
  • +
    +`; diff --git a/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap b/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap index a92079d2c8..22199fbc91 100644 --- a/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap @@ -75,7 +75,7 @@ exports[` renders sidebar correctly with beacons 1`] = ` tabindex="0" > when user has live location monitor renders correctly when minimized 1`] = ` - - +
    -
    -
    - - + height="10" + /> +
    + `; exports[` when user has live location monitor renders correctly when not minimized 1`] = ` - - +
    -
    - You are sharing your live location -
    - - + You are sharing your live location +
    + `; exports[` when user has live location monitor renders location publish error 1`] = ` - - +
    -
    - An error occurred whilst sharing your live location -
    - - + An error occurred whilst sharing your live location +
    + `; diff --git a/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap b/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap index 5f55d3103d..1162786e30 100644 --- a/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap @@ -1,79 +1,30 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders share buttons when there is a location 1`] = ` - - +
    + +
    + +
    +
    - -
    - -
    - - -
    - - -
    - - -
    - - + aria-label="Copy" + class="mx_AccessibleButton mx_CopyableText_copyButton" + role="button" + tabindex="0" + /> +
    + `; diff --git a/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap b/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap new file mode 100644 index 0000000000..e1e2bf1faa --- /dev/null +++ b/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders 1`] = ` + +
    + +`; diff --git a/test/components/views/elements/StyledRadioGroup-test.tsx b/test/components/views/elements/StyledRadioGroup-test.tsx index 3fa5dd9c53..8868b741bd 100644 --- a/test/components/views/elements/StyledRadioGroup-test.tsx +++ b/test/components/views/elements/StyledRadioGroup-test.tsx @@ -15,9 +15,7 @@ limitations under the License. */ import React from 'react'; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; -import { act } from "react-dom/test-utils"; +import { fireEvent, render } from "@testing-library/react"; import StyledRadioGroup from "../../../../src/components/views/elements/StyledRadioGroup"; @@ -44,16 +42,16 @@ describe('', () => { definitions: defaultDefinitions, onChange: jest.fn(), }; - const getComponent = (props = {}) => mount(); + const getComponent = (props = {}) => render(); - const getInputByValue = (component, value) => component.find(`input[value="${value}"]`); - const getCheckedInput = component => component.find('input[checked=true]'); + const getInputByValue = (component, value) => component.container.querySelector(`input[value="${value}"]`); + const getCheckedInput = component => component.container.querySelector('input[checked]'); it('renders radios correctly when no value is provided', () => { const component = getComponent(); - expect(component).toMatchSnapshot(); - expect(getCheckedInput(component).length).toBeFalsy(); + expect(component.asFragment()).toMatchSnapshot(); + expect(getCheckedInput(component)).toBeFalsy(); }); it('selects correct button when value is provided', () => { @@ -61,7 +59,7 @@ describe('', () => { value: optionC.value, }); - expect(getCheckedInput(component).at(0).props().value).toEqual(optionC.value); + expect(getCheckedInput(component).value).toEqual(optionC.value); }); it('selects correct buttons when definitions have checked prop', () => { @@ -74,10 +72,10 @@ describe('', () => { value: optionC.value, definitions, }); - expect(getInputByValue(component, optionA.value).props().checked).toBeTruthy(); - expect(getInputByValue(component, optionB.value).props().checked).toBeFalsy(); + expect(getInputByValue(component, optionA.value)).toBeChecked(); + expect(getInputByValue(component, optionB.value)).not.toBeChecked(); // optionC.checked = false overrides value matching - expect(getInputByValue(component, optionC.value).props().checked).toBeFalsy(); + expect(getInputByValue(component, optionC.value)).not.toBeChecked(); }); it('disables individual buttons based on definition.disabled', () => { @@ -87,16 +85,16 @@ describe('', () => { { ...optionC, disabled: true }, ]; const component = getComponent({ definitions }); - expect(getInputByValue(component, optionA.value).props().disabled).toBeFalsy(); - expect(getInputByValue(component, optionB.value).props().disabled).toBeTruthy(); - expect(getInputByValue(component, optionC.value).props().disabled).toBeTruthy(); + expect(getInputByValue(component, optionA.value)).not.toBeDisabled(); + expect(getInputByValue(component, optionB.value)).toBeDisabled(); + expect(getInputByValue(component, optionC.value)).toBeDisabled(); }); it('disables all buttons with disabled prop', () => { const component = getComponent({ disabled: true }); - expect(getInputByValue(component, optionA.value).props().disabled).toBeTruthy(); - expect(getInputByValue(component, optionB.value).props().disabled).toBeTruthy(); - expect(getInputByValue(component, optionC.value).props().disabled).toBeTruthy(); + expect(getInputByValue(component, optionA.value)).toBeDisabled(); + expect(getInputByValue(component, optionB.value)).toBeDisabled(); + expect(getInputByValue(component, optionC.value)).toBeDisabled(); }); it('calls onChange on click', () => { @@ -106,9 +104,7 @@ describe('', () => { onChange, }); - act(() => { - getInputByValue(component, optionB.value).simulate('change'); - }); + fireEvent.click(getInputByValue(component, optionB.value)); expect(onChange).toHaveBeenCalledWith(optionB.value); }); diff --git a/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap b/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap index 423c006a72..cb3c3374fd 100644 --- a/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap @@ -1,152 +1,83 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders radios correctly when no value is provided 1`] = ` - - Anteater label - , - "value": "Anteater", - }, - Object { - "label": - Badger label - , - "value": "Badger", - }, - Object { - "description": - Canary description - , - "label": - Canary label - , - "value": "Canary", - }, - ] - } - name="test" - onChange={[MockFunction]} -> - +