Show thread notification if thread timeline is closed (#9495)

* Show thread notification if thread timeline is closed

* Simplify isViewingEventTimeline statement

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix show desktop notifications

* Add RoomViewStore thread id assertions

* Add Notifier tests

* fix lint

* Remove it.only

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Germain 2022-10-25 17:53:31 +01:00 committed by GitHub
parent d273441596
commit 306a2449e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 178 additions and 21 deletions

View file

@ -435,7 +435,16 @@ export const Notifier = {
if (actions?.notify) {
this._performCustomEventHandling(ev);
if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId &&
const store = SdkContextClass.instance.roomViewStore;
const isViewingRoom = store.getRoomId() === room.roomId;
const threadId: string | undefined = ev.getId() !== ev.threadRootId
? ev.threadRootId
: undefined;
const isViewingThread = store.getThreadId() === threadId;
const isViewingEventTimeline = isViewingRoom && (!threadId || isViewingThread);
if (isViewingEventTimeline &&
UserActivity.sharedInstance().userActiveRecently() &&
!Modal.hasDialogs()
) {

View file

@ -55,6 +55,7 @@ import Spinner from "../views/elements/Spinner";
import { ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload";
import Heading from '../views/typography/Heading';
import { SdkContextClass } from '../../contexts/SDKContext';
import { ThreadPayload } from '../../dispatcher/payloads/ThreadPayload';
interface IProps {
room: Room;
@ -132,6 +133,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
metricsTrigger: undefined, // room doesn't change
});
}
dis.dispatch<ThreadPayload>({
action: Action.ViewThread,
thread_id: null,
});
}
public componentDidUpdate(prevProps) {
@ -225,6 +231,10 @@ export default class ThreadView extends React.Component<IProps, IState> {
};
private async postThreadUpdate(thread: Thread): Promise<void> {
dis.dispatch<ThreadPayload>({
action: Action.ViewThread,
thread_id: thread.id,
});
thread.emit(ThreadEvent.ViewThread);
await thread.fetchInitialEvents();
this.updateThreadRelation();

View file

@ -116,6 +116,11 @@ export enum Action {
*/
ViewRoom = "view_room",
/**
* Changes thread based on payload parameters. Should be used with ThreadPayload.
*/
ViewThread = "view_thread",
/**
* Changes room based on room list order and payload parameters. Should be used with ViewRoomDeltaPayload.
*/

View file

@ -0,0 +1,26 @@
/*
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 { ActionPayload } from "../payloads";
import { Action } from "../actions";
/* eslint-disable camelcase */
export interface ThreadPayload extends Pick<ActionPayload, "action"> {
action: Action.ViewThread;
thread_id: string | null;
}
/* eslint-enable camelcase */

View file

@ -50,6 +50,7 @@ import { awaitRoomDownSync } from "../utils/RoomUpgrade";
import { UPDATE_EVENT } from "./AsyncStore";
import { SdkContextClass } from "../contexts/SDKContext";
import { CallStore } from "./CallStore";
import { ThreadPayload } from "../dispatcher/payloads/ThreadPayload";
const NUM_JOIN_RETRY = 5;
@ -66,6 +67,10 @@ interface State {
* The ID of the room currently being viewed
*/
roomId: string | null;
/**
* The ID of the thread currently being viewed
*/
threadId: string | null;
/**
* The ID of the room being subscribed to (in Sliding Sync)
*/
@ -109,6 +114,7 @@ const INITIAL_STATE: State = {
joining: false,
joinError: null,
roomId: null,
threadId: null,
subscribingRoomId: null,
initialEventId: null,
initialEventPixelOffset: null,
@ -200,6 +206,9 @@ export class RoomViewStore extends EventEmitter {
case Action.ViewRoom:
this.viewRoom(payload);
break;
case Action.ViewThread:
this.viewThread(payload);
break;
// for these events blank out the roomId as we are no longer in the RoomView
case 'view_welcome_page':
case Action.ViewHomePage:
@ -430,6 +439,12 @@ export class RoomViewStore extends EventEmitter {
}
}
private viewThread(payload: ThreadPayload): void {
this.setState({
threadId: payload.thread_id,
});
}
private viewRoomError(payload: ViewRoomErrorPayload): void {
this.setState({
roomId: payload.room_id,
@ -550,6 +565,10 @@ export class RoomViewStore extends EventEmitter {
return this.state.roomId;
}
public getThreadId(): Optional<string> {
return this.state.threadId;
}
// The event to scroll to when the room is first viewed
public getInitialEventId(): Optional<string> {
return this.state.initialEventId;

View file

@ -19,6 +19,7 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SyncState } from "matrix-js-sdk/src/sync";
import { waitFor } from "@testing-library/react";
import BasePlatform from "../src/BasePlatform";
import { ElementCall } from "../src/models/Call";
@ -29,8 +30,15 @@ import {
createLocalNotificationSettingsIfNeeded,
getLocalNotificationAccountDataEventType,
} from "../src/utils/notifications";
import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
import { getMockClientWithEventEmitter, mkEvent, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
import { IncomingCallToast } from "../src/toasts/IncomingCallToast";
import { SdkContextClass } from "../src/contexts/SDKContext";
import UserActivity from "../src/UserActivity";
import Modal from "../src/Modal";
import { mkThread } from "./test-utils/threads";
import dis from "../src/dispatcher/dispatcher";
import { ThreadPayload } from "../src/dispatcher/payloads/ThreadPayload";
import { Action } from "../src/dispatcher/actions";
jest.mock("../src/utils/notifications", () => ({
// @ts-ignore
@ -50,10 +58,12 @@ describe("Notifier", () => {
let MockPlatform: MockedObject<BasePlatform>;
let mockClient: MockedObject<MatrixClient>;
let testRoom: MockedObject<Room>;
let testRoom: Room;
let accountDataEventKey: string;
let accountDataStore = {};
let mockSettings: Record<string, boolean> = {};
const userId = "@bob:example.org";
beforeEach(() => {
@ -78,7 +88,7 @@ describe("Notifier", () => {
};
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
testRoom = mkRoom(mockClient, roomId);
testRoom = new Room(roomId, mockClient, mockClient.getUserId());
MockPlatform = mockPlatformPeg({
supportsNotifications: jest.fn().mockReturnValue(true),
@ -89,7 +99,9 @@ describe("Notifier", () => {
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
mockClient.getRoom.mockReturnValue(testRoom);
mockClient.getRoom.mockImplementation(id => {
return id === roomId ? testRoom : new Room(id, mockClient, mockClient.getUserId());
});
});
describe('triggering notification from events', () => {
@ -121,13 +133,14 @@ describe("Notifier", () => {
},
});
const enabledSettings = [
'notificationsEnabled',
'audioNotificationsEnabled',
];
mockSettings = {
'notificationsEnabled': true,
'audioNotificationsEnabled': true,
};
// enable notifications by default
jest.spyOn(SettingsStore, "getValue").mockImplementation(
settingName => enabledSettings.includes(settingName),
jest.spyOn(SettingsStore, "getValue").mockReset().mockImplementation(
settingName => mockSettings[settingName] ?? false,
);
});
@ -253,16 +266,13 @@ describe("Notifier", () => {
});
const callOnEvent = (type?: string) => {
const callEvent = {
getContent: () => { },
getRoomId: () => roomId,
isBeingDecrypted: () => false,
isDecryptionFailure: () => false,
getSender: () => "@alice:foo",
getType: () => type ?? ElementCall.CALL_EVENT_TYPE.name,
getStateKey: () => "state_key",
} as unknown as MatrixEvent;
const callEvent = mkEvent({
type: type ?? ElementCall.CALL_EVENT_TYPE.name,
user: "@alice:foo",
room: roomId,
content: {},
event: true,
});
Notifier.onEvent(callEvent);
return callEvent;
};
@ -345,4 +355,72 @@ describe("Notifier", () => {
expect(createLocalNotificationSettingsIfNeededMock).toHaveBeenCalled();
});
});
describe('_evaluateEvent', () => {
beforeEach(() => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId")
.mockReturnValue(testRoom.roomId);
jest.spyOn(UserActivity.sharedInstance(), "userActiveRecently")
.mockReturnValue(true);
jest.spyOn(Modal, "hasDialogs").mockReturnValue(false);
jest.spyOn(Notifier, "_displayPopupNotification").mockReset();
jest.spyOn(Notifier, "isEnabled").mockReturnValue(true);
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: true,
},
});
});
it("should show a pop-up", () => {
expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier._evaluateEvent(testEvent);
expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0);
const eventFromOtherRoom = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!otherroom:example.org",
content: {},
});
Notifier._evaluateEvent(eventFromOtherRoom);
expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1);
});
it("should a pop-up for thread event", async () => {
const { events, rootEvent } = mkThread({
room: testRoom,
client: mockClient,
authorId: "@bob:example.org",
participantUserIds: ["@bob:example.org"],
});
expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier._evaluateEvent(rootEvent);
expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier._evaluateEvent(events[1]);
expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1);
dis.dispatch<ThreadPayload>({
action: Action.ViewThread,
thread_id: rootEvent.getId(),
});
await waitFor(() =>
expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId()),
);
Notifier._evaluateEvent(events[1]);
expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -28,6 +28,7 @@ import { act } from "react-dom/test-utils";
import ThreadView from "../../../src/components/structures/ThreadView";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
@ -155,4 +156,13 @@ describe("ThreadView", () => {
ROOM_ID, rootEvent2.getId(), expectedMessageBody(rootEvent2, "yolo"),
);
});
it("sets the correct thread in the room view store", async () => {
// expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBeNull();
const { unmount } = await getComponent();
expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId());
unmount();
await waitFor(() => expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBeNull());
});
});