mirror of
https://github.com/element-hq/element-web
synced 2024-11-27 11:47:23 +03:00
d92fdc1f5b
* Fix bug with message context menu * fix bug where ThreadSummary failed if no last reply is available * Fix relations direction API * Use same API for threads as for any other timeline * Determine if event belongs to thread on jumping to event * properly listen to thread deletion * Add thread redaction tests * Add fetchInitialEvent tests * Paginate using default TimelinePanel behaviour * Remove unused threads deleted code Co-authored-by: Germain <germain@souquet.com> Co-authored-by: Germain <germains@element.io>
433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
/*
|
|
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 { M_LOCATION } from "matrix-js-sdk/src/@types/location";
|
|
import {
|
|
EventStatus,
|
|
EventType,
|
|
IEvent,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
MsgType,
|
|
PendingEventOrdering,
|
|
RelationType,
|
|
Room,
|
|
} from "matrix-js-sdk/src/matrix";
|
|
import { Thread } from "matrix-js-sdk/src/models/thread";
|
|
|
|
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
|
import {
|
|
canCancel,
|
|
canEditContent,
|
|
canEditOwnEvent,
|
|
fetchInitialEvent,
|
|
isContentActionable,
|
|
isLocationEvent,
|
|
isVoiceMessage,
|
|
} from "../../src/utils/EventUtils";
|
|
import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../test-utils";
|
|
|
|
describe('EventUtils', () => {
|
|
const userId = '@user:server';
|
|
const roomId = '!room:server';
|
|
const mockClient = getMockClientWithEventEmitter({
|
|
getUserId: jest.fn().mockReturnValue(userId),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockClient.getUserId.mockClear().mockReturnValue(userId);
|
|
});
|
|
afterAll(() => {
|
|
jest.spyOn(MatrixClientPeg, 'get').mockRestore();
|
|
});
|
|
|
|
// setup events
|
|
const unsentEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
});
|
|
unsentEvent.status = EventStatus.ENCRYPTING;
|
|
|
|
const redactedEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
});
|
|
redactedEvent.makeRedacted(redactedEvent);
|
|
|
|
const stateEvent = new MatrixEvent({
|
|
type: EventType.RoomTopic,
|
|
state_key: '',
|
|
});
|
|
const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId);
|
|
|
|
const roomMemberEvent = new MatrixEvent({
|
|
type: EventType.RoomMember,
|
|
sender: userId,
|
|
});
|
|
|
|
const stickerEvent = new MatrixEvent({
|
|
type: EventType.Sticker,
|
|
sender: userId,
|
|
});
|
|
|
|
const pollStartEvent = makePollStartEvent('What?', userId);
|
|
|
|
const notDecryptedEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: 'm.bad.encrypted',
|
|
},
|
|
});
|
|
|
|
const noMsgType = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: undefined,
|
|
},
|
|
});
|
|
|
|
const noContentBody = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: MsgType.Image,
|
|
},
|
|
});
|
|
|
|
const emptyContentBody = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: '',
|
|
},
|
|
});
|
|
|
|
const objectContentBody = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: MsgType.File,
|
|
body: {},
|
|
},
|
|
});
|
|
|
|
const niceTextMessage = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: 'Hello',
|
|
},
|
|
});
|
|
|
|
const bobsTextMessage = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: '@bob:server',
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: 'Hello from Bob',
|
|
},
|
|
});
|
|
|
|
describe('isContentActionable()', () => {
|
|
type TestCase = [string, MatrixEvent];
|
|
it.each<TestCase>([
|
|
['unsent event', unsentEvent],
|
|
['redacted event', redactedEvent],
|
|
['state event', stateEvent],
|
|
['undecrypted event', notDecryptedEvent],
|
|
['room member event', roomMemberEvent],
|
|
['event without msgtype', noMsgType],
|
|
['event without content body property', noContentBody],
|
|
])('returns false for %s', (_description, event) => {
|
|
expect(isContentActionable(event)).toBe(false);
|
|
});
|
|
|
|
it.each<TestCase>([
|
|
['sticker event', stickerEvent],
|
|
['poll start event', pollStartEvent],
|
|
['event with empty content body', emptyContentBody],
|
|
['event with a content body', niceTextMessage],
|
|
['beacon_info event', beaconInfoEvent],
|
|
])('returns true for %s', (_description, event) => {
|
|
expect(isContentActionable(event)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('editable content helpers', () => {
|
|
const replaceRelationEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: 'Hello',
|
|
['m.relates_to']: {
|
|
rel_type: RelationType.Replace,
|
|
event_id: '1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const referenceRelationEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: 'Hello',
|
|
['m.relates_to']: {
|
|
rel_type: RelationType.Reference,
|
|
event_id: '1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const emoteEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
content: {
|
|
msgtype: MsgType.Emote,
|
|
body: '🧪',
|
|
},
|
|
});
|
|
|
|
type TestCase = [string, MatrixEvent];
|
|
|
|
const uneditableCases: TestCase[] = [
|
|
['redacted event', redactedEvent],
|
|
['state event', stateEvent],
|
|
['event that is not room message', roomMemberEvent],
|
|
['event without msgtype', noMsgType],
|
|
['event without content body property', noContentBody],
|
|
['event with empty content body property', emptyContentBody],
|
|
['event with non-string body', objectContentBody],
|
|
['event not sent by current user', bobsTextMessage],
|
|
['event with a replace relation', replaceRelationEvent],
|
|
];
|
|
|
|
const editableCases: TestCase[] = [
|
|
['event with reference relation', referenceRelationEvent],
|
|
['emote event', emoteEvent],
|
|
['poll start event', pollStartEvent],
|
|
['event with a content body', niceTextMessage],
|
|
];
|
|
|
|
describe('canEditContent()', () => {
|
|
it.each<TestCase>(uneditableCases)('returns false for %s', (_description, event) => {
|
|
expect(canEditContent(event)).toBe(false);
|
|
});
|
|
|
|
it.each<TestCase>(editableCases)('returns true for %s', (_description, event) => {
|
|
expect(canEditContent(event)).toBe(true);
|
|
});
|
|
});
|
|
describe('canEditOwnContent()', () => {
|
|
it.each<TestCase>(uneditableCases)('returns false for %s', (_description, event) => {
|
|
expect(canEditOwnEvent(event)).toBe(false);
|
|
});
|
|
|
|
it.each<TestCase>(editableCases)('returns true for %s', (_description, event) => {
|
|
expect(canEditOwnEvent(event)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isVoiceMessage()', () => {
|
|
it('returns true for an event with msc2516.voice content', () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
['org.matrix.msc2516.voice']: {},
|
|
},
|
|
});
|
|
|
|
expect(isVoiceMessage(event)).toBe(true);
|
|
});
|
|
|
|
it('returns true for an event with msc3245.voice content', () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
['org.matrix.msc3245.voice']: {},
|
|
},
|
|
});
|
|
|
|
expect(isVoiceMessage(event)).toBe(true);
|
|
});
|
|
|
|
it('returns false for an event with voice content', () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
body: 'hello',
|
|
},
|
|
});
|
|
|
|
expect(isVoiceMessage(event)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isLocationEvent()', () => {
|
|
it('returns true for an event with m.location stable type', () => {
|
|
const event = new MatrixEvent({
|
|
type: M_LOCATION.altName,
|
|
});
|
|
expect(isLocationEvent(event)).toBe(true);
|
|
});
|
|
it('returns true for an event with m.location unstable prefixed type', () => {
|
|
const event = new MatrixEvent({
|
|
type: M_LOCATION.name,
|
|
});
|
|
expect(isLocationEvent(event)).toBe(true);
|
|
});
|
|
it('returns true for a room message with stable m.location msgtype', () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
msgtype: M_LOCATION.altName,
|
|
},
|
|
});
|
|
expect(isLocationEvent(event)).toBe(true);
|
|
});
|
|
it('returns true for a room message with unstable m.location msgtype', () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
msgtype: M_LOCATION.name,
|
|
},
|
|
});
|
|
expect(isLocationEvent(event)).toBe(true);
|
|
});
|
|
it('returns false for a non location event', () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
body: 'Hello',
|
|
},
|
|
});
|
|
expect(isLocationEvent(event)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('canCancel()', () => {
|
|
it.each([
|
|
[EventStatus.QUEUED],
|
|
[EventStatus.NOT_SENT],
|
|
[EventStatus.ENCRYPTING],
|
|
])('return true for status %s', (status) => {
|
|
expect(canCancel(status)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
[EventStatus.SENDING],
|
|
[EventStatus.CANCELLED],
|
|
[EventStatus.SENT],
|
|
['invalid-status' as unknown as EventStatus],
|
|
])('return false for status %s', (status) => {
|
|
expect(canCancel(status)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("fetchInitialEvent", () => {
|
|
const ROOM_ID = "!roomId:example.org";
|
|
let room: Room;
|
|
let client: MatrixClient;
|
|
|
|
const NORMAL_EVENT = "$normalEvent";
|
|
const THREAD_ROOT = "$threadRoot";
|
|
const THREAD_REPLY = "$threadReply";
|
|
|
|
const events: Record<string, Partial<IEvent>> = {
|
|
[NORMAL_EVENT]: {
|
|
event_id: NORMAL_EVENT,
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
"body": "Classic event",
|
|
"msgtype": MsgType.Text,
|
|
},
|
|
},
|
|
[THREAD_ROOT]: {
|
|
event_id: THREAD_ROOT,
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
"body": "Thread root",
|
|
"msgtype": "m.text",
|
|
},
|
|
unsigned: {
|
|
"m.relations": {
|
|
[RelationType.Thread]: {
|
|
latest_event: {
|
|
event_id: THREAD_REPLY,
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
"body": "Thread reply",
|
|
"msgtype": MsgType.Text,
|
|
"m.relates_to": {
|
|
event_id: "$threadRoot",
|
|
rel_type: RelationType.Thread,
|
|
},
|
|
},
|
|
},
|
|
count: 1,
|
|
current_user_participated: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
[THREAD_REPLY]: {
|
|
event_id: THREAD_REPLY,
|
|
type: EventType.RoomMessage,
|
|
content: {
|
|
"body": "Thread reply",
|
|
"msgtype": MsgType.Text,
|
|
"m.relates_to": {
|
|
event_id: THREAD_ROOT,
|
|
rel_type: RelationType.Thread,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
stubClient();
|
|
client = MatrixClientPeg.get();
|
|
|
|
room = new Room(ROOM_ID, client, client.getUserId(), {
|
|
pendingEventOrdering: PendingEventOrdering.Detached,
|
|
});
|
|
|
|
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
|
|
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
|
jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
|
|
return events[eventId] ?? Promise.reject();
|
|
});
|
|
});
|
|
|
|
it("returns null for unknown events", async () => {
|
|
expect(await fetchInitialEvent(client, room.roomId, "$UNKNOWN")).toBeNull();
|
|
expect(await fetchInitialEvent(client, room.roomId, NORMAL_EVENT)).toBeInstanceOf(MatrixEvent);
|
|
});
|
|
|
|
it("creates a thread when needed", async () => {
|
|
await fetchInitialEvent(client, room.roomId, THREAD_REPLY);
|
|
expect(room.getThread(THREAD_ROOT)).toBeInstanceOf(Thread);
|
|
});
|
|
});
|
|
});
|