diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts
index 29df6fb37b..ee20e37ba9 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 f6defe766f..30963c4c87 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<string, LegacyCallEventGrouper>,
     events?: MatrixEvent[],
 ): Map<string, LegacyCallEventGrouper> {
     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 1346cb49b9..6214142da9 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<IRoomProps, IRoomState> {
         // 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<IRoomProps, IRoomState> {
         });
     }
 
-    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<IRoomProps, IRoomState> {
             );
         }
 
+        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<IRoomProps, IRoomState> {
 
     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<IRoomProps, IRoomState> {
             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<IRoomProps, IRoomState> {
             <TimelinePanel
                 ref={this.gatherTimelinePanelRef}
                 timelineSet={this.state.room.getUnfilteredTimelineSet()}
+                overlayTimelineSet={this.state.virtualRoom?.getUnfilteredTimelineSet()}
+                overlayTimelineSetFilter={isCallEvent}
                 showReadReceipts={this.state.showReadReceipts}
                 manageReadReceipts={!this.state.isPeeking}
                 sendReadReceiptOnLoad={!this.state.wasContextSwitch}
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx
index 06b866a49e..fc99d66a08 100644
--- a/src/components/structures/TimelinePanel.tsx
+++ b/src/components/structures/TimelinePanel.tsx
@@ -76,6 +76,14 @@ interface IProps {
     // a timeline representing.  If it has a room, we maintain RRs etc for
     // that room.
     timelineSet: EventTimelineSet;
+    // overlay events from a second timelineset on the main timeline
+    // added to support virtual rooms
+    // events from the overlay timeline set will be added by localTimestamp
+    // into the main timeline
+    // back paging not yet supported
+    overlayTimelineSet?: EventTimelineSet;
+    // filter events from overlay timeline
+    overlayTimelineSetFilter?: (event: MatrixEvent) => 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<IProps, IState> {
     private readonly messagePanel = createRef<MessagePanel>();
     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 <callId, LegacyCallEventGrouper>
     private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
 
-    constructor(props, context) {
+    constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
         super(props, context);
         this.context = context;
 
@@ -642,7 +651,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
         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<IProps, IState> {
         // timeline window.
         //
         // see https://github.com/vector-im/vector-web/issues/1035
-        this.timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => {
-            if (this.unmounted) { return; }
+        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 { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
+                this.buildLegacyCallEventGroupers(events);
+                const lastLiveEvent = liveEvents[liveEvents.length - 1];
 
-            const updatedState: Partial<IState> = {
-                events,
-                liveEvents,
-                firstVisibleEventIndex,
-            };
+                const updatedState: Partial<IState> = {
+                    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<IProps, IState> {
                 // 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<null>(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<IProps, IState> {
         }
     };
 
-    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<IProps, IState> {
     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<IProps, IState> {
             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<IProps, IState> {
             // 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<IProps, IState> {
 
     // get the list of events from the timeline window and the pending event list
     private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
-        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<IProps, IState> {
                 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 a4131100c5..0a8163416f 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 { fireEvent, render } from "@testing-library/react";
 
-import { stubClient, mockPlatformPeg, unmockPlatformPeg, wrapInMatrixClientContext } from "../../test-utils";
+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<ViewRoomPayload>({
                 action: Action.ViewRoom,
                 room_id: room.roomId,
-                metricsTrigger: null,
+                metricsTrigger: undefined,
             });
 
             await switchedRoom;
@@ -98,16 +107,52 @@ describe("RoomView", () => {
         const roomView = mount(
             <SDKContext.Provider value={stores}>
                 <RoomView
-                    threepidInvite={null}
-                    oobData={null}
+                    // threepidInvite should be optional on RoomView props
+                    // it is treated as optional in RoomView
+                    threepidInvite={undefined as any}
                     resizeNotifier={new ResizeNotifier()}
-                    justCreatedOpts={null}
                     forceTimeline={false}
-                    onRegistered={null}
                 />
             </SDKContext.Provider>,
         );
-        await act(() => Promise.resolve()); // Allow state to settle
+        await flushPromises();
+        return roomView;
+    };
+
+    const renderRoomView = async (): Promise<ReturnType<typeof render>> => {
+        if (stores.roomViewStore.getRoomId() !== room.roomId) {
+            const switchedRoom = new Promise<void>(resolve => {
+                const subFn = () => {
+                    if (stores.roomViewStore.getRoomId()) {
+                        stores.roomViewStore.off(UPDATE_EVENT, subFn);
+                        resolve();
+                    }
+                };
+                stores.roomViewStore.on(UPDATE_EVENT, subFn);
+            });
+
+            defaultDispatcher.dispatch<ViewRoomPayload>({
+                action: Action.ViewRoom,
+                room_id: room.roomId,
+                metricsTrigger: undefined,
+            });
+
+            await switchedRoom;
+        }
+
+        const roomView = render(
+            <SDKContext.Provider value={stores}>
+                <RoomView
+                    // threepidInvite should be optional on RoomView props
+                    // it is treated as optional in RoomView
+                    threepidInvite={undefined as any}
+                    resizeNotifier={new ResizeNotifier()}
+                    forceTimeline={false}
+                    onRegistered={jest.fn()}
+                />
+            </SDKContext.Provider>,
+        );
+        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 3a55fd3fdf..3899756818 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(<TimelinePanel {...props} />);
+
+            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(<TimelinePanel {...props} />);
+
+            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(<TimelinePanel {...props} />);
+
+            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(<TimelinePanel {...props} />);
+
+            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(<TimelinePanel {...props} />);
+
+            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(<TimelinePanel {...props} />);
+
+            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 52804a5136..47318525d5 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`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true" data-testid="avatar-img"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><div class="mx_RoomView_body"><div class="mx_LargeLoader"><div class="mx_Spinner"><div class="mx_Spinner_icon" style="width: 45px; height: 45px;" aria-label="Loading..." role="progressbar" data-testid="spinner"></div></div><div class="mx_LargeLoader_text">We're creating a room with @user:example.com</div></div></div></div>"`;
+exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `
+<div>
+  <div
+    class="mx_RoomView mx_RoomView--local"
+  >
+    <header
+      class="mx_RoomHeader light-panel"
+    >
+      <div
+        class="mx_RoomHeader_wrapper"
+      >
+        <div
+          class="mx_RoomHeader_avatar"
+        >
+          <div
+            class="mx_DecoratedRoomAvatar"
+          >
+            <span
+              class="mx_BaseAvatar"
+              role="presentation"
+            >
+              <span
+                aria-hidden="true"
+                class="mx_BaseAvatar_initial"
+                style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
+              >
+                U
+              </span>
+              <img
+                alt=""
+                aria-hidden="true"
+                class="mx_BaseAvatar_image"
+                data-testid="avatar-img"
+                src="data:image/png;base64,00"
+                style="width: 24px; height: 24px;"
+              />
+            </span>
+          </div>
+        </div>
+        <div
+          class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"
+        />
+        <div
+          class="mx_RoomHeader_name mx_RoomHeader_name--textonly"
+        >
+          <div
+            aria-level="1"
+            class="mx_RoomHeader_nametext"
+            dir="auto"
+            role="heading"
+            title="@user:example.com"
+          >
+            @user:example.com
+          </div>
+        </div>
+        <div
+          class="mx_RoomHeader_topic mx_RoomTopic"
+          dir="auto"
+        >
+          <div
+            tabindex="0"
+          >
+            <div>
+              <span
+                dir="auto"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </header>
+    <div
+      class="mx_RoomView_body"
+    >
+      <div
+        class="mx_LargeLoader"
+      >
+        <div
+          class="mx_Spinner"
+        >
+          <div
+            aria-label="Loading..."
+            class="mx_Spinner_icon"
+            data-testid="spinner"
+            role="progressbar"
+            style="width: 45px; height: 45px;"
+          />
+        </div>
+        <div
+          class="mx_LargeLoader_text"
+        >
+          We're creating a room with @user:example.com
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+`;
 
-exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true" data-testid="avatar-img"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><li class="mx_NewRoomIntro"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon_warning"><div class="mx_EventTileBubble_title">End-to-end encryption isn't enabled</div><div class="mx_EventTileBubble_subtitle"><span> 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.  </span></div></div><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true" data-testid="avatar-img"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"><div role="alert"><div class="mx_RoomStatusBar_unsentBadge"><div class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_highlighted mx_NotificationBadge_2char"><span class="mx_NotificationBadge_count">!</span></div></div><div><div class="mx_RoomStatusBar_unsentTitle">Some of your messages have not been sent</div></div><div class="mx_RoomStatusBar_unsentButtonBar"><div role="button" tabindex="0" class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry">Retry</div></div></div></div></main></div>"`;
+exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `
+<div>
+  <div
+    class="mx_RoomView mx_RoomView--local"
+  >
+    <header
+      class="mx_RoomHeader light-panel"
+    >
+      <div
+        class="mx_RoomHeader_wrapper"
+      >
+        <div
+          class="mx_RoomHeader_avatar"
+        >
+          <div
+            class="mx_DecoratedRoomAvatar"
+          >
+            <span
+              class="mx_BaseAvatar"
+              role="presentation"
+            >
+              <span
+                aria-hidden="true"
+                class="mx_BaseAvatar_initial"
+                style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
+              >
+                U
+              </span>
+              <img
+                alt=""
+                aria-hidden="true"
+                class="mx_BaseAvatar_image"
+                data-testid="avatar-img"
+                src="data:image/png;base64,00"
+                style="width: 24px; height: 24px;"
+              />
+            </span>
+          </div>
+        </div>
+        <div
+          class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"
+        />
+        <div
+          class="mx_RoomHeader_name mx_RoomHeader_name--textonly"
+        >
+          <div
+            aria-level="1"
+            class="mx_RoomHeader_nametext"
+            dir="auto"
+            role="heading"
+            title="@user:example.com"
+          >
+            @user:example.com
+          </div>
+        </div>
+        <div
+          class="mx_RoomHeader_topic mx_RoomTopic"
+          dir="auto"
+        >
+          <div
+            tabindex="0"
+          >
+            <div>
+              <span
+                dir="auto"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </header>
+    <main
+      class="mx_RoomView_body"
+    >
+      <div
+        class="mx_RoomView_timeline"
+      >
+        <div
+          class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel"
+          tabindex="-1"
+        >
+          <div
+            class="mx_RoomView_messageListWrapper"
+          >
+            <ol
+              aria-live="polite"
+              class="mx_RoomView_MessageList"
+              style="height: 400px;"
+            >
+              <li
+                class="mx_NewRoomIntro"
+              >
+                <div
+                  class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon_warning"
+                >
+                  <div
+                    class="mx_EventTileBubble_title"
+                  >
+                    End-to-end encryption isn't enabled
+                  </div>
+                  <div
+                    class="mx_EventTileBubble_subtitle"
+                  >
+                    <span>
+                       
+                      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.
+                       
+                       
+                    </span>
+                  </div>
+                </div>
+                <span
+                  aria-label="Avatar"
+                  aria-live="off"
+                  class="mx_AccessibleButton mx_BaseAvatar"
+                  role="button"
+                  tabindex="0"
+                >
+                  <span
+                    aria-hidden="true"
+                    class="mx_BaseAvatar_initial"
+                    style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
+                  >
+                    U
+                  </span>
+                  <img
+                    alt=""
+                    aria-hidden="true"
+                    class="mx_BaseAvatar_image"
+                    data-testid="avatar-img"
+                    src="data:image/png;base64,00"
+                    style="width: 52px; height: 52px;"
+                  />
+                </span>
+                <h2>
+                  @user:example.com
+                </h2>
+                <p>
+                  <span>
+                    Send your first message to invite 
+                    <b>
+                      @user:example.com
+                    </b>
+                     to chat
+                  </span>
+                </p>
+              </li>
+            </ol>
+          </div>
+        </div>
+      </div>
+      <div
+        class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
+      >
+        <div
+          role="alert"
+        >
+          <div
+            class="mx_RoomStatusBar_unsentBadge"
+          >
+            <div
+              class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_highlighted mx_NotificationBadge_2char"
+            >
+              <span
+                class="mx_NotificationBadge_count"
+              >
+                !
+              </span>
+            </div>
+          </div>
+          <div>
+            <div
+              class="mx_RoomStatusBar_unsentTitle"
+            >
+              Some of your messages have not been sent
+            </div>
+          </div>
+          <div
+            class="mx_RoomStatusBar_unsentButtonBar"
+          >
+            <div
+              class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry"
+              role="button"
+              tabindex="0"
+            >
+              Retry
+            </div>
+          </div>
+        </div>
+      </div>
+    </main>
+  </div>
+</div>
+`;
 
-exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true" data-testid="avatar-img"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><li class="mx_NewRoomIntro"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon_warning"><div class="mx_EventTileBubble_title">End-to-end encryption isn't enabled</div><div class="mx_EventTileBubble_subtitle"><span> 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.  </span></div></div><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true" data-testid="avatar-img"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_MessageComposer"><div class="mx_MessageComposer_wrapper"><div class="mx_MessageComposer_row"><div class="mx_SendMessageComposer"><div class="mx_BasicMessageComposer"><div class="mx_MessageComposerFormatBar"><button type="button" aria-label="Bold" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconBold"></button><button type="button" aria-label="Italics" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconItalic"></button><button type="button" aria-label="Strikethrough" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconStrikethrough"></button><button type="button" aria-label="Code block" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconCode"></button><button type="button" aria-label="Quote" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconQuote"></button><button type="button" aria-label="Insert link" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconInsertLink"></button></div><div class="mx_BasicMessageComposer_input mx_BasicMessageComposer_input_shouldShowPillAvatar mx_BasicMessageComposer_inputEmpty" contenteditable="true" tabindex="0" aria-label="Send a message…" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" dir="auto" aria-disabled="false" data-testid="basicmessagecomposer" style="--placeholder: 'Send a message…';"><div><br></div></div></div></div><div class="mx_MessageComposer_actions"><div aria-label="Emoji" role="button" tabindex="0" class="mx_AccessibleButton mx_EmojiButton mx_MessageComposer_button mx_EmojiButton_icon"></div><div aria-label="Attachment" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_upload"></div><div aria-label="More options" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"></div><input type="file" style="display: none;" multiple=""></div></div></div></div></main></div>"`;
+exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `
+<div>
+  <div
+    class="mx_RoomView mx_RoomView--local"
+  >
+    <header
+      class="mx_RoomHeader light-panel"
+    >
+      <div
+        class="mx_RoomHeader_wrapper"
+      >
+        <div
+          class="mx_RoomHeader_avatar"
+        >
+          <div
+            class="mx_DecoratedRoomAvatar"
+          >
+            <span
+              class="mx_BaseAvatar"
+              role="presentation"
+            >
+              <span
+                aria-hidden="true"
+                class="mx_BaseAvatar_initial"
+                style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
+              >
+                U
+              </span>
+              <img
+                alt=""
+                aria-hidden="true"
+                class="mx_BaseAvatar_image"
+                data-testid="avatar-img"
+                src="data:image/png;base64,00"
+                style="width: 24px; height: 24px;"
+              />
+            </span>
+          </div>
+        </div>
+        <div
+          class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"
+        />
+        <div
+          class="mx_RoomHeader_name mx_RoomHeader_name--textonly"
+        >
+          <div
+            aria-level="1"
+            class="mx_RoomHeader_nametext"
+            dir="auto"
+            role="heading"
+            title="@user:example.com"
+          >
+            @user:example.com
+          </div>
+        </div>
+        <div
+          class="mx_RoomHeader_topic mx_RoomTopic"
+          dir="auto"
+        >
+          <div
+            tabindex="0"
+          >
+            <div>
+              <span
+                dir="auto"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </header>
+    <main
+      class="mx_RoomView_body"
+    >
+      <div
+        class="mx_RoomView_timeline"
+      >
+        <div
+          class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel"
+          tabindex="-1"
+        >
+          <div
+            class="mx_RoomView_messageListWrapper"
+          >
+            <ol
+              aria-live="polite"
+              class="mx_RoomView_MessageList"
+              style="height: 400px;"
+            >
+              <li
+                class="mx_NewRoomIntro"
+              >
+                <div
+                  class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon_warning"
+                >
+                  <div
+                    class="mx_EventTileBubble_title"
+                  >
+                    End-to-end encryption isn't enabled
+                  </div>
+                  <div
+                    class="mx_EventTileBubble_subtitle"
+                  >
+                    <span>
+                       
+                      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.
+                       
+                       
+                    </span>
+                  </div>
+                </div>
+                <span
+                  aria-label="Avatar"
+                  aria-live="off"
+                  class="mx_AccessibleButton mx_BaseAvatar"
+                  role="button"
+                  tabindex="0"
+                >
+                  <span
+                    aria-hidden="true"
+                    class="mx_BaseAvatar_initial"
+                    style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
+                  >
+                    U
+                  </span>
+                  <img
+                    alt=""
+                    aria-hidden="true"
+                    class="mx_BaseAvatar_image"
+                    data-testid="avatar-img"
+                    src="data:image/png;base64,00"
+                    style="width: 52px; height: 52px;"
+                  />
+                </span>
+                <h2>
+                  @user:example.com
+                </h2>
+                <p>
+                  <span>
+                    Send your first message to invite 
+                    <b>
+                      @user:example.com
+                    </b>
+                     to chat
+                  </span>
+                </p>
+              </li>
+            </ol>
+          </div>
+        </div>
+      </div>
+      <div
+        class="mx_MessageComposer"
+      >
+        <div
+          class="mx_MessageComposer_wrapper"
+        >
+          <div
+            class="mx_MessageComposer_row"
+          >
+            <div
+              class="mx_SendMessageComposer"
+            >
+              <div
+                class="mx_BasicMessageComposer"
+              >
+                <div
+                  class="mx_MessageComposerFormatBar"
+                >
+                  <button
+                    aria-label="Bold"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconBold"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Italics"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconItalic"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Strikethrough"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconStrikethrough"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Code block"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconCode"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Quote"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconQuote"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Insert link"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconInsertLink"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                </div>
+                <div
+                  aria-autocomplete="list"
+                  aria-disabled="false"
+                  aria-haspopup="listbox"
+                  aria-label="Send a message…"
+                  aria-multiline="true"
+                  class="mx_BasicMessageComposer_input mx_BasicMessageComposer_input_shouldShowPillAvatar mx_BasicMessageComposer_inputEmpty"
+                  contenteditable="true"
+                  data-testid="basicmessagecomposer"
+                  dir="auto"
+                  role="textbox"
+                  style="--placeholder: 'Send a message…';"
+                  tabindex="0"
+                >
+                  <div>
+                    <br />
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div
+              class="mx_MessageComposer_actions"
+            >
+              <div
+                aria-label="Emoji"
+                class="mx_AccessibleButton mx_EmojiButton mx_MessageComposer_button mx_EmojiButton_icon"
+                role="button"
+                tabindex="0"
+              />
+              <div
+                aria-label="Attachment"
+                class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_upload"
+                role="button"
+                tabindex="0"
+              />
+              <div
+                aria-label="More options"
+                class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
+                role="button"
+                tabindex="0"
+              />
+              <input
+                multiple=""
+                style="display: none;"
+                type="file"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </main>
+  </div>
+</div>
+`;
 
-exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true" data-testid="avatar-img"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"><div class="mx_EventTileBubble_title">Encryption enabled</div><div class="mx_EventTileBubble_subtitle">Messages in this chat will be end-to-end encrypted.</div></div><li class="mx_NewRoomIntro"><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true" data-testid="avatar-img"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_MessageComposer"><div class="mx_MessageComposer_wrapper"><div class="mx_MessageComposer_row"><div class="mx_SendMessageComposer"><div class="mx_BasicMessageComposer"><div class="mx_MessageComposerFormatBar"><button type="button" aria-label="Bold" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconBold"></button><button type="button" aria-label="Italics" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconItalic"></button><button type="button" aria-label="Strikethrough" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconStrikethrough"></button><button type="button" aria-label="Code block" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconCode"></button><button type="button" aria-label="Quote" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconQuote"></button><button type="button" aria-label="Insert link" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconInsertLink"></button></div><div class="mx_BasicMessageComposer_input mx_BasicMessageComposer_input_shouldShowPillAvatar mx_BasicMessageComposer_inputEmpty" contenteditable="true" tabindex="0" aria-label="Send a message…" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" dir="auto" aria-disabled="false" data-testid="basicmessagecomposer" style="--placeholder: 'Send a message…';"><div><br></div></div></div></div><div class="mx_MessageComposer_actions"><div aria-label="Emoji" role="button" tabindex="0" class="mx_AccessibleButton mx_EmojiButton mx_MessageComposer_button mx_EmojiButton_icon"></div><div aria-label="Attachment" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_upload"></div><div aria-label="More options" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"></div><input type="file" style="display: none;" multiple=""></div></div></div></div></main></div>"`;
+exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `
+<div>
+  <div
+    class="mx_RoomView mx_RoomView--local"
+  >
+    <header
+      class="mx_RoomHeader light-panel"
+    >
+      <div
+        class="mx_RoomHeader_wrapper"
+      >
+        <div
+          class="mx_RoomHeader_avatar"
+        >
+          <div
+            class="mx_DecoratedRoomAvatar"
+          >
+            <span
+              class="mx_BaseAvatar"
+              role="presentation"
+            >
+              <span
+                aria-hidden="true"
+                class="mx_BaseAvatar_initial"
+                style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
+              >
+                U
+              </span>
+              <img
+                alt=""
+                aria-hidden="true"
+                class="mx_BaseAvatar_image"
+                data-testid="avatar-img"
+                src="data:image/png;base64,00"
+                style="width: 24px; height: 24px;"
+              />
+            </span>
+          </div>
+        </div>
+        <div
+          class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"
+        />
+        <div
+          class="mx_RoomHeader_name mx_RoomHeader_name--textonly"
+        >
+          <div
+            aria-level="1"
+            class="mx_RoomHeader_nametext"
+            dir="auto"
+            role="heading"
+            title="@user:example.com"
+          >
+            @user:example.com
+          </div>
+        </div>
+        <div
+          class="mx_RoomHeader_topic mx_RoomTopic"
+          dir="auto"
+        >
+          <div
+            tabindex="0"
+          >
+            <div>
+              <span
+                dir="auto"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </header>
+    <main
+      class="mx_RoomView_body"
+    >
+      <div
+        class="mx_RoomView_timeline"
+      >
+        <div
+          class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel"
+          tabindex="-1"
+        >
+          <div
+            class="mx_RoomView_messageListWrapper"
+          >
+            <ol
+              aria-live="polite"
+              class="mx_RoomView_MessageList"
+              style="height: 400px;"
+            >
+              <div
+                class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"
+              >
+                <div
+                  class="mx_EventTileBubble_title"
+                >
+                  Encryption enabled
+                </div>
+                <div
+                  class="mx_EventTileBubble_subtitle"
+                >
+                  Messages in this chat will be end-to-end encrypted.
+                </div>
+              </div>
+              <li
+                class="mx_NewRoomIntro"
+              >
+                <span
+                  aria-label="Avatar"
+                  aria-live="off"
+                  class="mx_AccessibleButton mx_BaseAvatar"
+                  role="button"
+                  tabindex="0"
+                >
+                  <span
+                    aria-hidden="true"
+                    class="mx_BaseAvatar_initial"
+                    style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
+                  >
+                    U
+                  </span>
+                  <img
+                    alt=""
+                    aria-hidden="true"
+                    class="mx_BaseAvatar_image"
+                    data-testid="avatar-img"
+                    src="data:image/png;base64,00"
+                    style="width: 52px; height: 52px;"
+                  />
+                </span>
+                <h2>
+                  @user:example.com
+                </h2>
+                <p>
+                  <span>
+                    Send your first message to invite 
+                    <b>
+                      @user:example.com
+                    </b>
+                     to chat
+                  </span>
+                </p>
+              </li>
+            </ol>
+          </div>
+        </div>
+      </div>
+      <div
+        class="mx_MessageComposer"
+      >
+        <div
+          class="mx_MessageComposer_wrapper"
+        >
+          <div
+            class="mx_MessageComposer_row"
+          >
+            <div
+              class="mx_SendMessageComposer"
+            >
+              <div
+                class="mx_BasicMessageComposer"
+              >
+                <div
+                  class="mx_MessageComposerFormatBar"
+                >
+                  <button
+                    aria-label="Bold"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconBold"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Italics"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconItalic"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Strikethrough"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconStrikethrough"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Code block"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconCode"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Quote"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconQuote"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                  <button
+                    aria-label="Insert link"
+                    class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconInsertLink"
+                    role="button"
+                    tabindex="0"
+                    type="button"
+                  />
+                </div>
+                <div
+                  aria-autocomplete="list"
+                  aria-disabled="false"
+                  aria-haspopup="listbox"
+                  aria-label="Send a message…"
+                  aria-multiline="true"
+                  class="mx_BasicMessageComposer_input mx_BasicMessageComposer_input_shouldShowPillAvatar mx_BasicMessageComposer_inputEmpty"
+                  contenteditable="true"
+                  data-testid="basicmessagecomposer"
+                  dir="auto"
+                  role="textbox"
+                  style="--placeholder: 'Send a message…';"
+                  tabindex="0"
+                >
+                  <div>
+                    <br />
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div
+              class="mx_MessageComposer_actions"
+            >
+              <div
+                aria-label="Emoji"
+                class="mx_AccessibleButton mx_EmojiButton mx_MessageComposer_button mx_EmojiButton_icon"
+                role="button"
+                tabindex="0"
+              />
+              <div
+                aria-label="Attachment"
+                class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_upload"
+                role="button"
+                tabindex="0"
+              />
+              <div
+                aria-label="More options"
+                class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
+                role="button"
+                tabindex="0"
+              />
+              <input
+                multiple=""
+                style="display: none;"
+                type="file"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </main>
+  </div>
+</div>
+`;