diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index 29df6fb37bb..ee20e37ba9e 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -79,7 +79,7 @@ export default class VoipUserMapper { return findDMForUser(MatrixClientPeg.get(), virtualUser); } - public nativeRoomForVirtualRoom(roomId: string): string { + public nativeRoomForVirtualRoom(roomId: string): string | null { const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId); if (cachedNativeRoomId) { logger.log( diff --git a/src/components/structures/LegacyCallEventGrouper.ts b/src/components/structures/LegacyCallEventGrouper.ts index f6defe766f6..30963c4c87a 100644 --- a/src/components/structures/LegacyCallEventGrouper.ts +++ b/src/components/structures/LegacyCallEventGrouper.ts @@ -44,13 +44,18 @@ export enum CustomCallState { Missed = "missed", } +const isCallEventType = (eventType: string): boolean => + eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call."); + +export const isCallEvent = (event: MatrixEvent): boolean => isCallEventType(event.getType()); + export function buildLegacyCallEventGroupers( callEventGroupers: Map, events?: MatrixEvent[], ): Map { const newCallEventGroupers = new Map(); events?.forEach(ev => { - if (!ev.getType().startsWith("m.call.") && !ev.getType().startsWith("org.matrix.call.")) { + if (!isCallEvent(ev)) { return; } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 1346cb49b9d..6214142da93 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -110,6 +110,8 @@ import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { Call } from "../../models/Call"; import { RoomSearchView } from './RoomSearchView'; import eventSearch from "../../Searching"; +import VoipUserMapper from '../../VoipUserMapper'; +import { isCallEvent } from './LegacyCallEventGrouper'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -144,6 +146,7 @@ enum MainSplitContentType { } export interface IRoomState { room?: Room; + virtualRoom?: Room; roomId?: string; roomAlias?: string; roomLoading: boolean; @@ -654,7 +657,11 @@ export class RoomView extends React.Component { // NB: This does assume that the roomID will not change for the lifetime of // the RoomView instance if (initial) { - newState.room = this.context.client.getRoom(newState.roomId); + const virtualRoom = newState.roomId ? + await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(newState.roomId) : undefined; + + newState.room = this.context.client!.getRoom(newState.roomId) || undefined; + newState.virtualRoom = virtualRoom || undefined; if (newState.room) { newState.showApps = this.shouldShowApps(newState.room); this.onRoomLoaded(newState.room); @@ -1264,7 +1271,7 @@ export class RoomView extends React.Component { }); } - private onRoom = (room: Room) => { + private onRoom = async (room: Room) => { if (!room || room.roomId !== this.state.roomId) { return; } @@ -1277,8 +1284,10 @@ export class RoomView extends React.Component { ); } + const virtualRoom = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(room.roomId); this.setState({ room: room, + virtualRoom: virtualRoom || undefined, }, () => { this.onRoomLoaded(room); }); @@ -1286,7 +1295,7 @@ export class RoomView extends React.Component { private onDeviceVerificationChanged = (userId: string) => { const room = this.state.room; - if (!room.currentState.getMember(userId)) { + if (!room?.currentState.getMember(userId)) { return; } this.updateE2EStatus(room); @@ -2093,7 +2102,7 @@ export class RoomView extends React.Component { hideMessagePanel = true; } - let highlightedEventId = null; + let highlightedEventId: string | undefined; if (this.state.isInitialEventHighlighted) { highlightedEventId = this.state.initialEventId; } @@ -2102,6 +2111,8 @@ export class RoomView extends React.Component { boolean; showReadReceipts?: boolean; // Enable managing RRs and RMs. These require the timelineSet to have a room. manageReadReceipts?: boolean; @@ -236,14 +244,15 @@ class TimelinePanel extends React.Component { private readonly messagePanel = createRef(); private readonly dispatcherRef: string; private timelineWindow?: TimelineWindow; + private overlayTimelineWindow?: TimelineWindow; private unmounted = false; - private readReceiptActivityTimer: Timer; - private readMarkerActivityTimer: Timer; + private readReceiptActivityTimer: Timer | null = null; + private readMarkerActivityTimer: Timer | null = null; // A map of private callEventGroupers = new Map(); - constructor(props, context) { + constructor(props: IProps, context: React.ContextType) { super(props, context); this.context = context; @@ -642,7 +651,12 @@ class TimelinePanel extends React.Component { data: IRoomTimelineData, ): void => { // ignore events for other timeline sets - if (data.timeline.getTimelineSet() !== this.props.timelineSet) return; + if ( + data.timeline.getTimelineSet() !== this.props.timelineSet + && data.timeline.getTimelineSet() !== this.props.overlayTimelineSet + ) { + return; + } if (!Thread.hasServerSideSupport && this.context.timelineRenderingType === TimelineRenderingType.Thread) { if (toStartOfTimeline && !this.state.canBackPaginate) { @@ -680,21 +694,27 @@ class TimelinePanel extends React.Component { // timeline window. // // see https://github.com/vector-im/vector-web/issues/1035 - this.timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => { - if (this.unmounted) { return; } - - const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); - this.buildLegacyCallEventGroupers(events); - const lastLiveEvent = liveEvents[liveEvents.length - 1]; - - const updatedState: Partial = { - events, - liveEvents, - firstVisibleEventIndex, - }; + this.timelineWindow!.paginate(EventTimeline.FORWARDS, 1, false) + .then(() => { + if (this.overlayTimelineWindow) { + return this.overlayTimelineWindow.paginate(EventTimeline.FORWARDS, 1, false); + } + }) + .then(() => { + if (this.unmounted) { return; } + + const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); + this.buildLegacyCallEventGroupers(events); + const lastLiveEvent = liveEvents[liveEvents.length - 1]; + + const updatedState: Partial = { + events, + liveEvents, + firstVisibleEventIndex, + }; - let callRMUpdated; - if (this.props.manageReadMarkers) { + let callRMUpdated = false; + if (this.props.manageReadMarkers) { // when a new event arrives when the user is not watching the // window, but the window is in its auto-scroll mode, make sure the // read marker is visible. @@ -703,28 +723,28 @@ class TimelinePanel extends React.Component { // read-marker when a remote echo of an event we have just sent takes // more than the timeout on userActiveRecently. // - const myUserId = MatrixClientPeg.get().credentials.userId; - callRMUpdated = false; - if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) { - updatedState.readMarkerVisible = true; - } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { + const myUserId = MatrixClientPeg.get().credentials.userId; + callRMUpdated = false; + if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) { + updatedState.readMarkerVisible = true; + } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { // we know we're stuckAtBottom, so we can advance the RM // immediately, to save a later render cycle - this.setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true); - updatedState.readMarkerVisible = false; - updatedState.readMarkerEventId = lastLiveEvent.getId(); - callRMUpdated = true; + this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true); + updatedState.readMarkerVisible = false; + updatedState.readMarkerEventId = lastLiveEvent.getId(); + callRMUpdated = true; + } } - } - this.setState(updatedState, () => { - this.messagePanel.current?.updateTimelineMinHeight(); - if (callRMUpdated) { - this.props.onReadMarkerUpdated?.(); - } + this.setState(updatedState as IState, () => { + this.messagePanel.current?.updateTimelineMinHeight(); + if (callRMUpdated) { + this.props.onReadMarkerUpdated?.(); + } + }); }); - }); }; private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => { @@ -735,7 +755,7 @@ class TimelinePanel extends React.Component { } }; - public canResetTimeline = () => this.messagePanel?.current.isAtBottom(); + public canResetTimeline = () => this.messagePanel?.current?.isAtBottom(); private onRoomRedaction = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; @@ -1337,6 +1357,9 @@ class TimelinePanel extends React.Component { private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number, scrollIntoView = true): void { const cli = MatrixClientPeg.get(); this.timelineWindow = new TimelineWindow(cli, this.props.timelineSet, { windowLimit: this.props.timelineCap }); + this.overlayTimelineWindow = this.props.overlayTimelineSet + ? new TimelineWindow(cli, this.props.overlayTimelineSet, { windowLimit: this.props.timelineCap }) + : undefined; const onLoaded = () => { if (this.unmounted) return; @@ -1351,8 +1374,8 @@ class TimelinePanel extends React.Component { this.advanceReadMarkerPastMyEvents(); this.setState({ - canBackPaginate: this.timelineWindow.canPaginate(EventTimeline.BACKWARDS), - canForwardPaginate: this.timelineWindow.canPaginate(EventTimeline.FORWARDS), + canBackPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS), + canForwardPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS), timelineLoading: false, }, () => { // initialise the scroll state of the message panel @@ -1433,12 +1456,19 @@ class TimelinePanel extends React.Component { // if we've got an eventId, and the timeline exists, we can skip // the promise tick. this.timelineWindow.load(eventId, INITIAL_SIZE); + this.overlayTimelineWindow?.load(undefined, INITIAL_SIZE); // in this branch this method will happen in sync time onLoaded(); return; } - const prom = this.timelineWindow.load(eventId, INITIAL_SIZE); + const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async () => { + if (this.overlayTimelineWindow) { + // @TODO(kerrya) use timestampToEvent to load the overlay timeline + // with more correct position when main TL eventId is truthy + await this.overlayTimelineWindow.load(undefined, INITIAL_SIZE); + } + }); this.buildLegacyCallEventGroupers(); this.setState({ events: [], @@ -1471,7 +1501,23 @@ class TimelinePanel extends React.Component { // get the list of events from the timeline window and the pending event list private getEvents(): Pick { - const events: MatrixEvent[] = this.timelineWindow.getEvents(); + const mainEvents: MatrixEvent[] = this.timelineWindow?.getEvents() || []; + const eventFilter = this.props.overlayTimelineSetFilter || Boolean; + const overlayEvents = this.overlayTimelineWindow?.getEvents().filter(eventFilter) || []; + + // maintain the main timeline event order as returned from the HS + // merge overlay events at approximately the right position based on local timestamp + const events = overlayEvents.reduce((acc: MatrixEvent[], overlayEvent: MatrixEvent) => { + // find the first main tl event with a later timestamp + const index = acc.findIndex(event => event.localTimestamp > overlayEvent.localTimestamp); + // insert overlay event into timeline at approximately the right place + if (index > -1) { + acc.splice(index, 0, overlayEvent); + } else { + acc.push(overlayEvent); + } + return acc; + }, [...mainEvents]); // `arrayFastClone` performs a shallow copy of the array // we want the last event to be decrypted first but displayed last @@ -1483,20 +1529,20 @@ class TimelinePanel extends React.Component { client.decryptEventIfNeeded(event); }); - const firstVisibleEventIndex = this.checkForPreJoinUISI(events); + const firstVisibleEventIndex = this.checkForPreJoinUISI(mainEvents); // Hold onto the live events separately. The read receipt and read marker // should use this list, so that they don't advance into pending events. const liveEvents = [...events]; // if we're at the end of the live timeline, append the pending events - if (!this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + if (!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS)) { const pendingEvents = this.props.timelineSet.getPendingEvents(); events.push(...pendingEvents.filter(event => { const { shouldLiveInRoom, threadId, - } = this.props.timelineSet.room.eventShouldLiveIn(event, pendingEvents); + } = this.props.timelineSet.room!.eventShouldLiveIn(event, pendingEvents); if (this.context.timelineRenderingType === TimelineRenderingType.Thread) { return threadId === this.context.threadId; diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index a4131100c5a..0a8163416f9 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -17,15 +17,21 @@ limitations under the License. import React from "react"; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from "enzyme"; -import { act } from "react-dom/test-utils"; import { mocked, MockedObject } from "jest-mock"; -import { MatrixClient } from "matrix-js-sdk/src/client"; +import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from "matrix-js-sdk/src/matrix"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; - -import { stubClient, mockPlatformPeg, unmockPlatformPeg, wrapInMatrixClientContext } from "../../test-utils"; +import { fireEvent, render } from "@testing-library/react"; + +import { + stubClient, + mockPlatformPeg, + unmockPlatformPeg, + wrapInMatrixClientContext, + flushPromises, +} from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { Action } from "../../../src/dispatcher/actions"; import { defaultDispatcher } from "../../../src/dispatcher/dispatcher"; @@ -42,6 +48,7 @@ import { DirectoryMember } from "../../../src/utils/direct-messages"; import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom"; import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; import { SdkContextClass, SDKContext } from "../../../src/contexts/SDKContext"; +import VoipUserMapper from "../../../src/VoipUserMapper"; const RoomView = wrapInMatrixClientContext(_RoomView); @@ -67,6 +74,8 @@ describe("RoomView", () => { stores = new SdkContextClass(); stores.client = cli; stores.rightPanelStore.useUnitTestClient(cli); + + jest.spyOn(VoipUserMapper.sharedInstance(), 'getVirtualRoomForRoom').mockResolvedValue(null); }); afterEach(async () => { @@ -89,7 +98,7 @@ describe("RoomView", () => { defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, - metricsTrigger: null, + metricsTrigger: undefined, }); await switchedRoom; @@ -98,16 +107,52 @@ describe("RoomView", () => { const roomView = mount( , ); - await act(() => Promise.resolve()); // Allow state to settle + await flushPromises(); + return roomView; + }; + + const renderRoomView = async (): Promise> => { + if (stores.roomViewStore.getRoomId() !== room.roomId) { + const switchedRoom = new Promise(resolve => { + const subFn = () => { + if (stores.roomViewStore.getRoomId()) { + stores.roomViewStore.off(UPDATE_EVENT, subFn); + resolve(); + } + }; + stores.roomViewStore.on(UPDATE_EVENT, subFn); + }); + + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + metricsTrigger: undefined, + }); + + await switchedRoom; + } + + const roomView = render( + + + , + ); + await flushPromises(); return roomView; }; const getRoomViewInstance = async (): Promise<_RoomView> => @@ -137,7 +182,7 @@ describe("RoomView", () => { // and fake an encryption event into the room to prompt it to re-check room.addLiveEvents([new MatrixEvent({ type: "m.room.encryption", - sender: cli.getUserId(), + sender: cli.getUserId()!, content: {}, event_id: "someid", room_id: room.roomId, @@ -155,6 +200,26 @@ describe("RoomView", () => { expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline); }); + describe('with virtual rooms', () => { + it("checks for a virtual room on initial load", async () => { + const { container } = await renderRoomView(); + expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId); + + // quick check that rendered without error + expect(container.querySelector('.mx_ErrorBoundary')).toBeFalsy(); + }); + + it("checks for a virtual room on room event", async () => { + await renderRoomView(); + expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId); + + cli.emit(ClientEvent.Room, room); + + // called again after room event + expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledTimes(2); + }); + }); + describe("video rooms", () => { beforeEach(async () => { // Make it a video room @@ -178,7 +243,6 @@ describe("RoomView", () => { describe("for a local room", () => { let localRoom: LocalRoom; - let roomView: ReactWrapper; beforeEach(async () => { localRoom = room = await createDmLocalRoom(cli, [new DirectoryMember({ user_id: "@user:example.com" })]); @@ -186,15 +250,15 @@ describe("RoomView", () => { }); it("should remove the room from the store on unmount", async () => { - roomView = await mountRoomView(); - roomView.unmount(); + const { unmount } = await renderRoomView(); + unmount(); expect(cli.store.removeRoom).toHaveBeenCalledWith(room.roomId); }); describe("in state NEW", () => { it("should match the snapshot", async () => { - roomView = await mountRoomView(); - expect(roomView.html()).toMatchSnapshot(); + const { container } = await renderRoomView(); + expect(container).toMatchSnapshot(); }); describe("that is encrypted", () => { @@ -208,8 +272,8 @@ describe("RoomView", () => { content: { algorithm: MEGOLM_ALGORITHM, }, - user_id: cli.getUserId(), - sender: cli.getUserId(), + user_id: cli.getUserId()!, + sender: cli.getUserId()!, state_key: "", room_id: localRoom.roomId, origin_server_ts: Date.now(), @@ -218,33 +282,32 @@ describe("RoomView", () => { }); it("should match the snapshot", async () => { - const roomView = await mountRoomView(); - expect(roomView.html()).toMatchSnapshot(); + const { container } = await renderRoomView(); + expect(container).toMatchSnapshot(); }); }); }); it("in state CREATING should match the snapshot", async () => { localRoom.state = LocalRoomState.CREATING; - roomView = await mountRoomView(); - expect(roomView.html()).toMatchSnapshot(); + const { container } = await renderRoomView(); + expect(container).toMatchSnapshot(); }); describe("in state ERROR", () => { beforeEach(async () => { localRoom.state = LocalRoomState.ERROR; - roomView = await mountRoomView(); }); it("should match the snapshot", async () => { - expect(roomView.html()).toMatchSnapshot(); + const { container } = await renderRoomView(); + expect(container).toMatchSnapshot(); }); - it("clicking retry should set the room state to new dispatch a local room event", () => { + it("clicking retry should set the room state to new dispatch a local room event", async () => { jest.spyOn(defaultDispatcher, "dispatch"); - roomView.findWhere((w: ReactWrapper) => { - return w.hasClass("mx_RoomStatusBar_unsentRetry") && w.text() === "Retry"; - }).first().simulate("click"); + const { getByText } = await renderRoomView(); + fireEvent.click(getByText('Retry')); expect(localRoom.state).toBe(LocalRoomState.NEW); expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "local_room_event", diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx index 3a55fd3fdfd..38997568184 100644 --- a/test/components/structures/TimelinePanel-test.tsx +++ b/test/components/structures/TimelinePanel-test.tsx @@ -26,6 +26,8 @@ import { MatrixEvent, PendingEventOrdering, Room, + RoomEvent, + TimelineWindow, } from 'matrix-js-sdk/src/matrix'; import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; import { @@ -41,7 +43,8 @@ import TimelinePanel from '../../../src/components/structures/TimelinePanel'; import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from '../../../src/MatrixClientPeg'; import SettingsStore from "../../../src/settings/SettingsStore"; -import { mkRoom, stubClient } from "../../test-utils"; +import { isCallEvent } from '../../../src/components/structures/LegacyCallEventGrouper'; +import { flushPromises, mkRoom, stubClient } from "../../test-utils"; const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => { const receiptContent = { @@ -80,7 +83,7 @@ const mockEvents = (room: Room, count = 2): MatrixEvent[] => { for (let index = 0; index < count; index++) { events.push(new MatrixEvent({ room_id: room.roomId, - event_id: `event_${index}`, + event_id: `${room.roomId}_event_${index}`, type: EventType.RoomMessage, user_id: "userId", content: MessageEvent.from(`Event${index}`).serialize().content, @@ -90,6 +93,13 @@ const mockEvents = (room: Room, count = 2): MatrixEvent[] => { return events; }; +const setupTestData = (): [MatrixClient, Room, MatrixEvent[]] => { + const client = MatrixClientPeg.get(); + const room = mkRoom(client, "roomId"); + const events = mockEvents(room); + return [client, room, events]; +}; + describe('TimelinePanel', () => { beforeEach(() => { stubClient(); @@ -155,9 +165,7 @@ describe('TimelinePanel', () => { }); it("sends public read receipt when enabled", () => { - const client = MatrixClientPeg.get(); - const room = mkRoom(client, "roomId"); - const events = mockEvents(room); + const [client, room, events] = setupTestData(); const getValueCopy = SettingsStore.getValue; SettingsStore.getValue = jest.fn().mockImplementation((name: string) => { @@ -170,9 +178,7 @@ describe('TimelinePanel', () => { }); it("does not send public read receipt when enabled", () => { - const client = MatrixClientPeg.get(); - const room = mkRoom(client, "roomId"); - const events = mockEvents(room); + const [client, room, events] = setupTestData(); const getValueCopy = SettingsStore.getValue; SettingsStore.getValue = jest.fn().mockImplementation((name: string) => { @@ -202,6 +208,146 @@ describe('TimelinePanel', () => { expect(props.onEventScrolledIntoView).toHaveBeenCalledWith(events[1].getId()); }); + describe('onRoomTimeline', () => { + it('ignores events for other timelines', () => { + const [client, room, events] = setupTestData(); + + const otherTimelineSet = { room: room as Room } as EventTimelineSet; + const otherTimeline = new EventTimeline(otherTimelineSet); + + const props = { + ...getProps(room, events), + onEventScrolledIntoView: jest.fn(), + }; + + const paginateSpy = jest.spyOn(TimelineWindow.prototype, 'paginate').mockClear(); + + render(); + + const event = new MatrixEvent({ type: RoomEvent.Timeline }); + const data = { timeline: otherTimeline, liveEvent: true }; + client.emit(RoomEvent.Timeline, event, room, false, false, data); + + expect(paginateSpy).not.toHaveBeenCalled(); + }); + + it('ignores timeline updates without a live event', () => { + const [client, room, events] = setupTestData(); + + const props = getProps(room, events); + + const paginateSpy = jest.spyOn(TimelineWindow.prototype, 'paginate').mockClear(); + + render(); + + const event = new MatrixEvent({ type: RoomEvent.Timeline }); + const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: false }; + client.emit(RoomEvent.Timeline, event, room, false, false, data); + + expect(paginateSpy).not.toHaveBeenCalled(); + }); + + it('ignores timeline where toStartOfTimeline is true', () => { + const [client, room, events] = setupTestData(); + + const props = getProps(room, events); + + const paginateSpy = jest.spyOn(TimelineWindow.prototype, 'paginate').mockClear(); + + render(); + + const event = new MatrixEvent({ type: RoomEvent.Timeline }); + const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: false }; + const toStartOfTimeline = true; + client.emit(RoomEvent.Timeline, event, room, toStartOfTimeline, false, data); + + expect(paginateSpy).not.toHaveBeenCalled(); + }); + + it('advances the timeline window', () => { + const [client, room, events] = setupTestData(); + + const props = getProps(room, events); + + const paginateSpy = jest.spyOn(TimelineWindow.prototype, 'paginate').mockClear(); + + render(); + + const event = new MatrixEvent({ type: RoomEvent.Timeline }); + const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true }; + client.emit(RoomEvent.Timeline, event, room, false, false, data); + + expect(paginateSpy).toHaveBeenCalledWith(EventTimeline.FORWARDS, 1, false); + }); + + it('advances the overlay timeline window', async () => { + const [client, room, events] = setupTestData(); + + const virtualRoom = mkRoom(client, "virtualRoomId"); + const virtualEvents = mockEvents(virtualRoom); + const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents); + + const props = { + ...getProps(room, events), + overlayTimelineSet, + }; + + const paginateSpy = jest.spyOn(TimelineWindow.prototype, 'paginate').mockClear(); + + render(); + + const event = new MatrixEvent({ type: RoomEvent.Timeline }); + const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true }; + client.emit(RoomEvent.Timeline, event, room, false, false, data); + + await flushPromises(); + + expect(paginateSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('with overlayTimeline', () => { + it('renders merged timeline', () => { + const [client, room, events] = setupTestData(); + const virtualRoom = mkRoom(client, "virtualRoomId"); + const virtualCallInvite = new MatrixEvent({ + type: 'm.call.invite', + room_id: virtualRoom.roomId, + event_id: `virtualCallEvent1`, + }); + const virtualCallMetaEvent = new MatrixEvent({ + type: 'org.matrix.call.sdp_stream_metadata_changed', + room_id: virtualRoom.roomId, + event_id: `virtualCallEvent2`, + }); + const virtualEvents = [ + virtualCallInvite, + ...mockEvents(virtualRoom), + virtualCallMetaEvent, + ]; + const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents); + + const props = { + ...getProps(room, events), + overlayTimelineSet, + overlayTimelineSetFilter: isCallEvent, + }; + + const { container } = render(); + + const eventTiles = container.querySelectorAll('.mx_EventTile'); + const eventTileIds = [...eventTiles].map(tileElement => tileElement.getAttribute('data-event-id')); + expect(eventTileIds).toEqual([ + // main timeline events are included + events[1].getId(), + events[0].getId(), + // virtual timeline call event is included + virtualCallInvite.getId(), + // virtual call event has no tile renderer => not rendered + ]); + }); + }); + describe("when a thread updates", () => { let client: MatrixClient; let room: Room; diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index 52804a51361..47318525d56 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -1,9 +1,824 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
@user:example.com
We're creating a room with @user:example.com
"`; +exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = ` +
+
+
+
+
+
+ + + + +
+
+
+
+
+ @user:example.com +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ We're creating a room with @user:example.com +
+
+
+
+
+`; -exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = ` +
+
+
+
+
+
+ + + + +
+
+
+
+
+ @user:example.com +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
    +
  1. +
    +
    + End-to-end encryption isn't enabled +
    +
    + + + Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. + + + +
    +
    + + + + +

    + @user:example.com +

    +

    + + Send your first message to invite + + @user:example.com + + to chat + +

    +
  2. +
+
+
+
+
+
+
+
+ + ! + +
+
+
+
+ Some of your messages have not been sent +
+
+
+
+ Retry +
+
+
+
+
+
+
+`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = ` +
+
+
+
+
+
+ + + + +
+
+
+
+
+ @user:example.com +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
    +
  1. +
    +
    + End-to-end encryption isn't enabled +
    +
    + + + Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. + + + +
    +
    + + + + +

    + @user:example.com +

    +

    + + Send your first message to invite + + @user:example.com + + to chat + +

    +
  2. +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+`; -exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = ` +
+
+
+
+
+
+ + + + +
+
+
+
+
+ @user:example.com +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
    +
    +
    + Encryption enabled +
    +
    + Messages in this chat will be end-to-end encrypted. +
    +
    +
  1. + + + + +

    + @user:example.com +

    +

    + + Send your first message to invite + + @user:example.com + + to chat + +

    +
  2. +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+`;