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:
parent
d273441596
commit
306a2449e5
7 changed files with 178 additions and 21 deletions
|
@ -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()
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
26
src/dispatcher/payloads/ThreadPayload.ts
Normal file
26
src/dispatcher/payloads/ThreadPayload.ts
Normal 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 */
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue