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) { if (actions?.notify) {
this._performCustomEventHandling(ev); 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() && UserActivity.sharedInstance().userActiveRecently() &&
!Modal.hasDialogs() !Modal.hasDialogs()
) { ) {

View file

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

View file

@ -116,6 +116,11 @@ export enum Action {
*/ */
ViewRoom = "view_room", 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. * 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 { UPDATE_EVENT } from "./AsyncStore";
import { SdkContextClass } from "../contexts/SDKContext"; import { SdkContextClass } from "../contexts/SDKContext";
import { CallStore } from "./CallStore"; import { CallStore } from "./CallStore";
import { ThreadPayload } from "../dispatcher/payloads/ThreadPayload";
const NUM_JOIN_RETRY = 5; const NUM_JOIN_RETRY = 5;
@ -66,6 +67,10 @@ interface State {
* The ID of the room currently being viewed * The ID of the room currently being viewed
*/ */
roomId: string | null; 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) * The ID of the room being subscribed to (in Sliding Sync)
*/ */
@ -109,6 +114,7 @@ const INITIAL_STATE: State = {
joining: false, joining: false,
joinError: null, joinError: null,
roomId: null, roomId: null,
threadId: null,
subscribingRoomId: null, subscribingRoomId: null,
initialEventId: null, initialEventId: null,
initialEventPixelOffset: null, initialEventPixelOffset: null,
@ -200,6 +206,9 @@ export class RoomViewStore extends EventEmitter {
case Action.ViewRoom: case Action.ViewRoom:
this.viewRoom(payload); this.viewRoom(payload);
break; break;
case Action.ViewThread:
this.viewThread(payload);
break;
// for these events blank out the roomId as we are no longer in the RoomView // for these events blank out the roomId as we are no longer in the RoomView
case 'view_welcome_page': case 'view_welcome_page':
case Action.ViewHomePage: 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 { private viewRoomError(payload: ViewRoomErrorPayload): void {
this.setState({ this.setState({
roomId: payload.room_id, roomId: payload.room_id,
@ -550,6 +565,10 @@ export class RoomViewStore extends EventEmitter {
return this.state.roomId; return this.state.roomId;
} }
public getThreadId(): Optional<string> {
return this.state.threadId;
}
// The event to scroll to when the room is first viewed // The event to scroll to when the room is first viewed
public getInitialEventId(): Optional<string> { public getInitialEventId(): Optional<string> {
return this.state.initialEventId; 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 { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SyncState } from "matrix-js-sdk/src/sync"; import { SyncState } from "matrix-js-sdk/src/sync";
import { waitFor } from "@testing-library/react";
import BasePlatform from "../src/BasePlatform"; import BasePlatform from "../src/BasePlatform";
import { ElementCall } from "../src/models/Call"; import { ElementCall } from "../src/models/Call";
@ -29,8 +30,15 @@ import {
createLocalNotificationSettingsIfNeeded, createLocalNotificationSettingsIfNeeded,
getLocalNotificationAccountDataEventType, getLocalNotificationAccountDataEventType,
} from "../src/utils/notifications"; } 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 { 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", () => ({ jest.mock("../src/utils/notifications", () => ({
// @ts-ignore // @ts-ignore
@ -50,10 +58,12 @@ describe("Notifier", () => {
let MockPlatform: MockedObject<BasePlatform>; let MockPlatform: MockedObject<BasePlatform>;
let mockClient: MockedObject<MatrixClient>; let mockClient: MockedObject<MatrixClient>;
let testRoom: MockedObject<Room>; let testRoom: Room;
let accountDataEventKey: string; let accountDataEventKey: string;
let accountDataStore = {}; let accountDataStore = {};
let mockSettings: Record<string, boolean> = {};
const userId = "@bob:example.org"; const userId = "@bob:example.org";
beforeEach(() => { beforeEach(() => {
@ -78,7 +88,7 @@ describe("Notifier", () => {
}; };
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
testRoom = mkRoom(mockClient, roomId); testRoom = new Room(roomId, mockClient, mockClient.getUserId());
MockPlatform = mockPlatformPeg({ MockPlatform = mockPlatformPeg({
supportsNotifications: jest.fn().mockReturnValue(true), supportsNotifications: jest.fn().mockReturnValue(true),
@ -89,7 +99,9 @@ describe("Notifier", () => {
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); 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', () => { describe('triggering notification from events', () => {
@ -121,13 +133,14 @@ describe("Notifier", () => {
}, },
}); });
const enabledSettings = [ mockSettings = {
'notificationsEnabled', 'notificationsEnabled': true,
'audioNotificationsEnabled', 'audioNotificationsEnabled': true,
]; };
// enable notifications by default // enable notifications by default
jest.spyOn(SettingsStore, "getValue").mockImplementation( jest.spyOn(SettingsStore, "getValue").mockReset().mockImplementation(
settingName => enabledSettings.includes(settingName), settingName => mockSettings[settingName] ?? false,
); );
}); });
@ -253,16 +266,13 @@ describe("Notifier", () => {
}); });
const callOnEvent = (type?: string) => { const callOnEvent = (type?: string) => {
const callEvent = { const callEvent = mkEvent({
getContent: () => { }, type: type ?? ElementCall.CALL_EVENT_TYPE.name,
getRoomId: () => roomId, user: "@alice:foo",
isBeingDecrypted: () => false, room: roomId,
isDecryptionFailure: () => false, content: {},
getSender: () => "@alice:foo", event: true,
getType: () => type ?? ElementCall.CALL_EVENT_TYPE.name, });
getStateKey: () => "state_key",
} as unknown as MatrixEvent;
Notifier.onEvent(callEvent); Notifier.onEvent(callEvent);
return callEvent; return callEvent;
}; };
@ -345,4 +355,72 @@ describe("Notifier", () => {
expect(createLocalNotificationSettingsIfNeededMock).toHaveBeenCalled(); 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 ThreadView from "../../../src/components/structures/ThreadView";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext"; import RoomContext from "../../../src/contexts/RoomContext";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../src/utils/DMRoomMap";
import ResizeNotifier from "../../../src/utils/ResizeNotifier"; import ResizeNotifier from "../../../src/utils/ResizeNotifier";
@ -155,4 +156,13 @@ describe("ThreadView", () => {
ROOM_ID, rootEvent2.getId(), expectedMessageBody(rootEvent2, "yolo"), 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());
});
}); });