From cecf0ce299b7208fc2fe03e54c7c7adbeb06a98d Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 22 Jun 2021 20:41:26 +0100
Subject: [PATCH] Convert MessagePanel, TimelinePanel, ScrollPanel, and more to
 Typescript

---
 src/@types/global.d.ts                        |  32 +-
 .../structures/AutoHideScrollbar.tsx          |   6 +-
 .../{MessagePanel.js => MessagePanel.tsx}     | 723 +++++++++---------
 .../structures/NotificationPanel.tsx          |   3 +-
 src/components/structures/RoomDirectory.tsx   |  17 +-
 src/components/structures/RoomView.tsx        |   7 +-
 .../{ScrollPanel.js => ScrollPanel.tsx}       | 431 ++++++-----
 .../{TimelinePanel.js => TimelinePanel.tsx}   | 637 ++++++++-------
 .../views/dialogs/ForwardDialog.tsx           |  33 +-
 .../{ErrorBoundary.js => ErrorBoundary.tsx}   |  35 +-
 .../views/elements/EventListSummary.tsx       |  14 +-
 .../views/elements/EventTilePreview.tsx       |  11 +-
 .../views/elements/MemberEventListSummary.tsx |  14 +-
 .../{DateSeparator.js => DateSeparator.tsx}   |  26 +-
 ...ErrorBoundary.js => TileErrorBoundary.tsx} |  28 +-
 src/components/views/rooms/EventTile.tsx      |  18 +-
 16 files changed, 1087 insertions(+), 948 deletions(-)
 rename src/components/structures/{MessagePanel.js => MessagePanel.tsx} (64%)
 rename src/components/structures/{ScrollPanel.js => ScrollPanel.tsx} (73%)
 rename src/components/structures/{TimelinePanel.js => TimelinePanel.tsx} (75%)
 rename src/components/views/elements/{ErrorBoundary.js => ErrorBoundary.tsx} (80%)
 rename src/components/views/messages/{DateSeparator.js => DateSeparator.tsx} (82%)
 rename src/components/views/messages/{TileErrorBoundary.js => TileErrorBoundary.tsx} (77%)

diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 7eff341095..f75c17aaf4 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -16,6 +16,7 @@ limitations under the License.
 
 import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
 import * as ModernizrStatic from "modernizr";
+
 import ContentMessages from "../ContentMessages";
 import { IMatrixClientPeg } from "../MatrixClientPeg";
 import ToastStore from "../stores/ToastStore";
@@ -23,25 +24,25 @@ import DeviceListener from "../DeviceListener";
 import { RoomListStoreClass } from "../stores/room-list/RoomListStore";
 import { PlatformPeg } from "../PlatformPeg";
 import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
-import {IntegrationManagers} from "../integrations/IntegrationManagers";
-import {ModalManager} from "../Modal";
+import { IntegrationManagers } from "../integrations/IntegrationManagers";
+import { ModalManager } from "../Modal";
 import SettingsStore from "../settings/SettingsStore";
-import {ActiveRoomObserver} from "../ActiveRoomObserver";
-import {Notifier} from "../Notifier";
-import type {Renderer} from "react-dom";
+import { ActiveRoomObserver } from "../ActiveRoomObserver";
+import { Notifier } from "../Notifier";
+import type { Renderer } from "react-dom";
 import RightPanelStore from "../stores/RightPanelStore";
 import WidgetStore from "../stores/WidgetStore";
 import CallHandler from "../CallHandler";
-import {Analytics} from "../Analytics";
+import { Analytics } from "../Analytics";
 import CountlyAnalytics from "../CountlyAnalytics";
 import UserActivity from "../UserActivity";
-import {ModalWidgetStore} from "../stores/ModalWidgetStore";
+import { ModalWidgetStore } from "../stores/ModalWidgetStore";
 import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
 import VoipUserMapper from "../VoipUserMapper";
-import {SpaceStoreClass} from "../stores/SpaceStore";
+import { SpaceStoreClass } from "../stores/SpaceStore";
 import TypingStore from "../stores/TypingStore";
 import { EventIndexPeg } from "../indexing/EventIndexPeg";
-import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
+import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
 import PerformanceMonitor from "../performance";
 import UIStore from "../stores/UIStore";
 import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
@@ -127,11 +128,24 @@ declare global {
         setSinkId(outputId: string);
     }
 
+    // Add Chrome-specific `instant` ScrollBehaviour
+    type _ScrollBehavior = ScrollBehavior | "instant";
+
+    interface _ScrollOptions {
+        behavior?: _ScrollBehavior;
+    }
+
+    interface _ScrollIntoViewOptions extends _ScrollOptions {
+        block?: ScrollLogicalPosition;
+        inline?: ScrollLogicalPosition;
+    }
+
     interface Element {
         // Safari & IE11 only have this prefixed: we used prefixed versions
         // previously so let's continue to support them for now
         webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
         msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
+        scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void;
     }
 
     interface Error {
diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx
index 66f998b616..3b7fee3a08 100644
--- a/src/components/structures/AutoHideScrollbar.tsx
+++ b/src/components/structures/AutoHideScrollbar.tsx
@@ -15,12 +15,12 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from "react";
+import React, { WheelEvent } from "react";
 
 interface IProps {
     className?: string;
-    onScroll?: () => void;
-    onWheel?: () => void;
+    onScroll?: (event: Event) => void;
+    onWheel?: (event: WheelEvent) => void;
     style?: React.CSSProperties
     tabIndex?: number,
     wrappedRef?: (ref: HTMLDivElement) => void;
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.tsx
similarity index 64%
rename from src/components/structures/MessagePanel.js
rename to src/components/structures/MessagePanel.tsx
index eb9611a6fc..492d9d9a53 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.tsx
@@ -1,7 +1,5 @@
 /*
-Copyright 2016 OpenMarket Ltd
-Copyright 2018 New Vector Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -16,32 +14,46 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, {createRef} from 'react';
+import React, { createRef, KeyboardEvent, ReactNode, SyntheticEvent, TransitionEvent } from 'react';
 import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import shouldHideEvent from '../../shouldHideEvent';
-import {wantsDateSeparator} from '../../DateUtils';
-import * as sdk from '../../index';
+import { Room } from 'matrix-js-sdk/src/models/room';
+import { EventType } from 'matrix-js-sdk/src/@types/event';
+import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
+import { Relations } from "matrix-js-sdk/src/models/relations";
+import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
 
-import {MatrixClientPeg} from '../../MatrixClientPeg';
+import shouldHideEvent from '../../shouldHideEvent';
+import { wantsDateSeparator } from '../../DateUtils';
+import { MatrixClientPeg } from '../../MatrixClientPeg';
 import SettingsStore from '../../settings/SettingsStore';
 import RoomContext from "../../contexts/RoomContext";
-import {Layout, LayoutPropType} from "../../settings/Layout";
-import {_t} from "../../languageHandler";
-import {haveTileForEvent} from "../views/rooms/EventTile";
-import {hasText} from "../../TextForEvent";
+import { Layout } from "../../settings/Layout";
+import { _t } from "../../languageHandler";
+import EventTile, { haveTileForEvent, IReadReceiptProps, TileShape } from "../views/rooms/EventTile";
+import { hasText } from "../../TextForEvent";
 import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
 import DMRoomMap from "../../utils/DMRoomMap";
 import NewRoomIntro from "../views/rooms/NewRoomIntro";
-import {replaceableComponent} from "../../utils/replaceableComponent";
+import { replaceableComponent } from "../../utils/replaceableComponent";
 import defaultDispatcher from '../../dispatcher/dispatcher';
+import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile';
+import ScrollPanel, { IScrollState } from "./ScrollPanel";
+import EventListSummary from '../views/elements/EventListSummary';
+import MemberEventListSummary from '../views/elements/MemberEventListSummary';
+import DateSeparator from '../views/messages/DateSeparator';
+import ErrorBoundary from '../views/elements/ErrorBoundary';
+import ResizeNotifier from "../../utils/ResizeNotifier";
+import Spinner from "../views/elements/Spinner";
+import TileErrorBoundary from '../views/messages/TileErrorBoundary';
+import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
+import EditorStateTransfer from "../../utils/EditorStateTransfer";
 
 const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
-const continuedTypes = ['m.sticker', 'm.room.message'];
+const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
 
 // check if there is a previous event and it has the same sender as this event
 // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
-function shouldFormContinuation(prevEvent, mxEvent) {
+function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean {
     // sanity check inputs
     if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
     // check if within the max continuation period
@@ -52,8 +64,8 @@ function shouldFormContinuation(prevEvent, mxEvent) {
 
     // Some events should appear as continuations from previous events of different types.
     if (mxEvent.getType() !== prevEvent.getType() &&
-        (!continuedTypes.includes(mxEvent.getType()) ||
-            !continuedTypes.includes(prevEvent.getType()))) return false;
+        (!continuedTypes.includes(mxEvent.getType() as EventType) ||
+            !continuedTypes.includes(prevEvent.getType() as EventType))) return false;
 
     // Check if the sender is the same and hasn't changed their displayname/avatar between these events
     if (mxEvent.sender.userId !== prevEvent.sender.userId ||
@@ -66,96 +78,161 @@ function shouldFormContinuation(prevEvent, mxEvent) {
     return true;
 }
 
-const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() === 'm.room.third_party_invite';
+const isMembershipChange = (e: MatrixEvent): boolean => {
+    return e.getType() === EventType.RoomMember || e.getType() === EventType.RoomThirdPartyInvite;
+}
+
+interface IProps {
+    // the list of MatrixEvents to display
+    events: MatrixEvent[];
+
+    // true to give the component a 'display: none' style.
+    hidden?: boolean;
+
+    // true to show a spinner at the top of the timeline to indicate
+    // back-pagination in progress
+    backPaginating?: boolean;
+
+    // true to show a spinner at the end of the timeline to indicate
+    // forward-pagination in progress
+    forwardPaginating?: boolean;
+
+    // ID of an event to highlight. If undefined, no event will be highlighted.
+    highlightedEventId?: string;
+
+    // The room these events are all in together, if any.
+    // (The notification panel won't have a room here, for example.)
+    room?: Room;
+
+    // Should we show URL Previews
+    showUrlPreview?: boolean;
+
+    // event after which we should show a read marker
+    readMarkerEventId?: string;
+
+    // whether the read marker should be visible
+    readMarkerVisible?: boolean;
+
+    // the userid of our user. This is used to suppress the read marker
+    // for pending messages.
+    ourUserId?: string;
+
+    // true to suppress the date at the start of the timeline
+    suppressFirstDateSeparator?: boolean;
+
+    // whether to show read receipts
+    showReadReceipts?: boolean;
+
+    // true if updates to the event list should cause the scroll panel to
+    // scroll down when we are at the bottom of the window. See ScrollPanel
+    // for more details.
+    stickyBottom?: boolean;
+
+    // className for the panel
+    className: string;
+
+    // shape parameter to be passed to EventTiles
+    tileShape?: TileShape;
+
+    // show twelve hour timestamps
+    isTwelveHour?: boolean;
+
+    // show timestamps always
+    alwaysShowTimestamps?: boolean;
+
+    // whether to show reactions for an event
+    showReactions?: boolean;
+
+    // which layout to use
+    layout?: Layout;
+
+    // whether or not to show flair at all
+    enableFlair?: boolean;
+
+    resizeNotifier: ResizeNotifier;
+    permalinkCreator?: RoomPermalinkCreator;
+    editState?: EditorStateTransfer;
+
+    // callback which is called when the panel is scrolled.
+    onScroll?(event: Event): void;
+
+    // callback which is called when the user interacts with the room timeline
+    onUserScroll(event: SyntheticEvent): void;
+
+    // callback which is called when more content is needed.
+    onFillRequest?(backwards: boolean): Promise<boolean>;
+
+    // helper function to access relations for an event
+    onUnfillRequest?(backwards: boolean, scrollToken: string): void;
+
+    getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
+}
+
+interface IState {
+    ghostReadMarkers: string[];
+    showTypingNotifications: boolean;
+}
+
+interface IReadReceiptForUser {
+    lastShownEventId: string;
+    receipt: IReadReceiptProps;
+}
 
 /* (almost) stateless UI component which builds the event tiles in the room timeline.
  */
 @replaceableComponent("structures.MessagePanel")
-export default class MessagePanel extends React.Component {
-    static propTypes = {
-        // true to give the component a 'display: none' style.
-        hidden: PropTypes.bool,
-
-        // true to show a spinner at the top of the timeline to indicate
-        // back-pagination in progress
-        backPaginating: PropTypes.bool,
-
-        // true to show a spinner at the end of the timeline to indicate
-        // forward-pagination in progress
-        forwardPaginating: PropTypes.bool,
-
-        // the list of MatrixEvents to display
-        events: PropTypes.array.isRequired,
-
-        // ID of an event to highlight. If undefined, no event will be highlighted.
-        highlightedEventId: PropTypes.string,
-
-        // The room these events are all in together, if any.
-        // (The notification panel won't have a room here, for example.)
-        room: PropTypes.object,
-
-        // Should we show URL Previews
-        showUrlPreview: PropTypes.bool,
-
-        // event after which we should show a read marker
-        readMarkerEventId: PropTypes.string,
-
-        // whether the read marker should be visible
-        readMarkerVisible: PropTypes.bool,
-
-        // the userid of our user. This is used to suppress the read marker
-        // for pending messages.
-        ourUserId: PropTypes.string,
-
-        // true to suppress the date at the start of the timeline
-        suppressFirstDateSeparator: PropTypes.bool,
-
-        // whether to show read receipts
-        showReadReceipts: PropTypes.bool,
-
-        // true if updates to the event list should cause the scroll panel to
-        // scroll down when we are at the bottom of the window. See ScrollPanel
-        // for more details.
-        stickyBottom: PropTypes.bool,
-
-        // callback which is called when the panel is scrolled.
-        onScroll: PropTypes.func,
-
-        // callback which is called when the user interacts with the room timeline
-        onUserScroll: PropTypes.func,
-
-        // callback which is called when more content is needed.
-        onFillRequest: PropTypes.func,
-
-        // className for the panel
-        className: PropTypes.string.isRequired,
-
-        // shape parameter to be passed to EventTiles
-        tileShape: PropTypes.string,
-
-        // show twelve hour timestamps
-        isTwelveHour: PropTypes.bool,
-
-        // show timestamps always
-        alwaysShowTimestamps: PropTypes.bool,
-
-        // helper function to access relations for an event
-        getRelationsForEvent: PropTypes.func,
-
-        // whether to show reactions for an event
-        showReactions: PropTypes.bool,
-
-        // which layout to use
-        layout: LayoutPropType,
-
-        // whether or not to show flair at all
-        enableFlair: PropTypes.bool,
-    };
-
+export default class MessagePanel extends React.Component<IProps, IState> {
     static contextType = RoomContext;
 
-    constructor(props) {
-        super(props);
+    // opaque readreceipt info for each userId; used by ReadReceiptMarker
+    // to manage its animations
+    private readonly readReceiptMap: Record<string, object> = {};
+
+    // Track read receipts by event ID. For each _shown_ event ID, we store
+    // the list of read receipts to display:
+    //   [
+    //       {
+    //           userId: string,
+    //           member: RoomMember,
+    //           ts: number,
+    //       },
+    //   ]
+    // This is recomputed on each render. It's only stored on the component
+    // for ease of passing the data around since it's computed in one pass
+    // over all events.
+    private readReceiptsByEvent: Record<string, IReadReceiptProps[]> = {};
+
+    // Track read receipts by user ID. For each user ID we've ever shown a
+    // a read receipt for, we store an object:
+    //   {
+    //       lastShownEventId: string,
+    //       receipt: {
+    //           userId: string,
+    //           member: RoomMember,
+    //           ts: number,
+    //       },
+    //   }
+    // so that we can always keep receipts displayed by reverting back to
+    // the last shown event for that user ID when needed. This may feel like
+    // it duplicates the receipt storage in the room, but at this layer, we
+    // are tracking _shown_ event IDs, which the JS SDK knows nothing about.
+    // This is recomputed on each render, using the data from the previous
+    // render as our fallback for any user IDs we can't match a receipt to a
+    // displayed event in the current render cycle.
+    private readReceiptsByUserId: Record<string, IReadReceiptForUser> = {};
+
+    private readonly showHiddenEventsInTimeline: boolean;
+    private isMounted = false;
+
+    private readMarkerNode = createRef<HTMLLIElement>();
+    private whoIsTyping = createRef<WhoIsTypingTile>();
+    private scrollPanel = createRef<ScrollPanel>();
+
+    private readonly showTypingNotificationsWatcherRef: string;
+    private eventNodes: Record<string, HTMLElement>;
+
+    constructor(props, context) {
+        super(props, context);
 
         this.state = {
             // previous positions the read marker has been in, so we can
@@ -164,65 +241,21 @@ export default class MessagePanel extends React.Component {
             showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
         };
 
-        // opaque readreceipt info for each userId; used by ReadReceiptMarker
-        // to manage its animations
-        this._readReceiptMap = {};
-
-        // Track read receipts by event ID. For each _shown_ event ID, we store
-        // the list of read receipts to display:
-        //   [
-        //       {
-        //           userId: string,
-        //           member: RoomMember,
-        //           ts: number,
-        //       },
-        //   ]
-        // This is recomputed on each render. It's only stored on the component
-        // for ease of passing the data around since it's computed in one pass
-        // over all events.
-        this._readReceiptsByEvent = {};
-
-        // Track read receipts by user ID. For each user ID we've ever shown a
-        // a read receipt for, we store an object:
-        //   {
-        //       lastShownEventId: string,
-        //       receipt: {
-        //           userId: string,
-        //           member: RoomMember,
-        //           ts: number,
-        //       },
-        //   }
-        // so that we can always keep receipts displayed by reverting back to
-        // the last shown event for that user ID when needed. This may feel like
-        // it duplicates the receipt storage in the room, but at this layer, we
-        // are tracking _shown_ event IDs, which the JS SDK knows nothing about.
-        // This is recomputed on each render, using the data from the previous
-        // render as our fallback for any user IDs we can't match a receipt to a
-        // displayed event in the current render cycle.
-        this._readReceiptsByUserId = {};
-
         // Cache hidden events setting on mount since Settings is expensive to
         // query, and we check this in a hot code path.
-        this._showHiddenEventsInTimeline =
-            SettingsStore.getValue("showHiddenEventsInTimeline");
+        this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline");
 
-        this._isMounted = false;
-
-        this._readMarkerNode = createRef();
-        this._whoIsTyping = createRef();
-        this._scrollPanel = createRef();
-
-        this._showTypingNotificationsWatcherRef =
+        this.showTypingNotificationsWatcherRef =
             SettingsStore.watchSetting("showTypingNotifications", null, this.onShowTypingNotificationsChange);
     }
 
     componentDidMount() {
-        this._isMounted = true;
+        this.isMounted = true;
     }
 
     componentWillUnmount() {
-        this._isMounted = false;
-        SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
+        this.isMounted = false;
+        SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef);
     }
 
     componentDidUpdate(prevProps, prevState) {
@@ -235,14 +268,14 @@ export default class MessagePanel extends React.Component {
         }
     }
 
-    onShowTypingNotificationsChange = () => {
+    private onShowTypingNotificationsChange = (): void => {
         this.setState({
             showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
         });
     };
 
     /* get the DOM node representing the given event */
-    getNodeForEventId(eventId) {
+    public getNodeForEventId(eventId: string): HTMLElement {
         if (!this.eventNodes) {
             return undefined;
         }
@@ -252,8 +285,8 @@ export default class MessagePanel extends React.Component {
 
     /* return true if the content is fully scrolled down right now; else false.
      */
-    isAtBottom() {
-        return this._scrollPanel.current && this._scrollPanel.current.isAtBottom();
+    public isAtBottom(): boolean {
+        return this.scrollPanel.current?.isAtBottom();
     }
 
     /* get the current scroll state. See ScrollPanel.getScrollState for
@@ -261,8 +294,8 @@ export default class MessagePanel extends React.Component {
      *
      * returns null if we are not mounted.
      */
-    getScrollState() {
-        return this._scrollPanel.current ? this._scrollPanel.current.getScrollState() : null;
+    public getScrollState(): IScrollState {
+        return this.scrollPanel.current?.getScrollState() ?? null;
     }
 
     // returns one of:
@@ -271,15 +304,15 @@ export default class MessagePanel extends React.Component {
     //  -1: read marker is above the window
     //   0: read marker is within the window
     //  +1: read marker is below the window
-    getReadMarkerPosition() {
-        const readMarker = this._readMarkerNode.current;
-        const messageWrapper = this._scrollPanel.current;
+    public getReadMarkerPosition(): number {
+        const readMarker = this.readMarkerNode.current;
+        const messageWrapper = this.scrollPanel.current;
 
         if (!readMarker || !messageWrapper) {
             return null;
         }
 
-        const wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
+        const wrapperRect = (ReactDOM.findDOMNode(messageWrapper) as HTMLElement).getBoundingClientRect();
         const readMarkerRect = readMarker.getBoundingClientRect();
 
         // the read-marker pretends to have zero height when it is actually
@@ -295,17 +328,17 @@ export default class MessagePanel extends React.Component {
 
     /* jump to the top of the content.
      */
-    scrollToTop() {
-        if (this._scrollPanel.current) {
-            this._scrollPanel.current.scrollToTop();
+    public scrollToTop(): void {
+        if (this.scrollPanel.current) {
+            this.scrollPanel.current.scrollToTop();
         }
     }
 
     /* jump to the bottom of the content.
      */
-    scrollToBottom() {
-        if (this._scrollPanel.current) {
-            this._scrollPanel.current.scrollToBottom();
+    public scrollToBottom(): void {
+        if (this.scrollPanel.current) {
+            this.scrollPanel.current.scrollToBottom();
         }
     }
 
@@ -314,9 +347,9 @@ export default class MessagePanel extends React.Component {
      *
      * @param {number} mult: -1 to page up, +1 to page down
      */
-    scrollRelative(mult) {
-        if (this._scrollPanel.current) {
-            this._scrollPanel.current.scrollRelative(mult);
+    public scrollRelative(mult: number): void {
+        if (this.scrollPanel.current) {
+            this.scrollPanel.current.scrollRelative(mult);
         }
     }
 
@@ -325,9 +358,9 @@ export default class MessagePanel extends React.Component {
      *
      * @param {KeyboardEvent} ev: the keyboard event to handle
      */
-    handleScrollKey(ev) {
-        if (this._scrollPanel.current) {
-            this._scrollPanel.current.handleScrollKey(ev);
+    public handleScrollKey(ev: KeyboardEvent): void {
+        if (this.scrollPanel.current) {
+            this.scrollPanel.current.handleScrollKey(ev);
         }
     }
 
@@ -341,38 +374,41 @@ export default class MessagePanel extends React.Component {
      * node (specifically, the bottom of it) will be positioned. If omitted, it
      * defaults to 0.
      */
-    scrollToEvent(eventId, pixelOffset, offsetBase) {
-        if (this._scrollPanel.current) {
-            this._scrollPanel.current.scrollToToken(eventId, pixelOffset, offsetBase);
+    public scrollToEvent(eventId: string, pixelOffset: number, offsetBase: number): void {
+        if (this.scrollPanel.current) {
+            this.scrollPanel.current.scrollToToken(eventId, pixelOffset, offsetBase);
         }
     }
 
-    scrollToEventIfNeeded(eventId) {
+    public scrollToEventIfNeeded(eventId: string): void {
         const node = this.eventNodes[eventId];
         if (node) {
-            node.scrollIntoView({block: "nearest", behavior: "instant"});
+            node.scrollIntoView({
+                block: "nearest",
+                behavior: "instant",
+            });
         }
     }
 
     /* check the scroll state and send out pagination requests if necessary.
      */
-    checkFillState() {
-        if (this._scrollPanel.current) {
-            this._scrollPanel.current.checkFillState();
+    public checkFillState(): void {
+        if (this.scrollPanel.current) {
+            this.scrollPanel.current.checkFillState();
         }
     }
 
-    _isUnmounting = () => {
-        return !this._isMounted;
+    private isUnmounting = (): boolean => {
+        return !this.isMounted;
     };
 
     // TODO: Implement granular (per-room) hide options
-    _shouldShowEvent(mxEv) {
+    public shouldShowEvent(mxEv: MatrixEvent): boolean {
         if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) {
             return false; // ignored = no show (only happens if the ignore happens after an event was received)
         }
 
-        if (this._showHiddenEventsInTimeline) {
+        if (this.showHiddenEventsInTimeline) {
             return true;
         }
 
@@ -386,7 +422,7 @@ export default class MessagePanel extends React.Component {
         return !shouldHideEvent(mxEv, this.context);
     }
 
-    _readMarkerForEvent(eventId, isLastEvent) {
+    public readMarkerForEvent(eventId: string, isLastEvent: boolean): ReactNode {
         const visible = !isLastEvent && this.props.readMarkerVisible;
 
         if (this.props.readMarkerEventId === eventId) {
@@ -405,7 +441,7 @@ export default class MessagePanel extends React.Component {
 
             return (
                 <li key={"readMarker_"+eventId}
-                    ref={this._readMarkerNode}
+                    ref={this.readMarkerNode}
                     className="mx_RoomView_myReadMarker_container"
                     data-scroll-tokens={eventId}
                 >
@@ -424,8 +460,8 @@ export default class MessagePanel extends React.Component {
             // transition (ie. the read markers do but the event tiles do not)
             // and TransitionGroup requires that all its children are Transitions.
             const hr = <hr className="mx_RoomView_myReadMarker"
-                ref={this._collectGhostReadMarker}
-                onTransitionEnd={this._onGhostTransitionEnd}
+                ref={this.collectGhostReadMarker}
+                onTransitionEnd={this.onGhostTransitionEnd}
                 data-eventid={eventId}
             />;
 
@@ -445,7 +481,7 @@ export default class MessagePanel extends React.Component {
         return null;
     }
 
-    _collectGhostReadMarker = (node) => {
+    private collectGhostReadMarker = (node: HTMLElement): void => {
         if (node) {
             // now the element has appeared, change the style which will trigger the CSS transition
             requestAnimationFrame(() => {
@@ -455,15 +491,15 @@ export default class MessagePanel extends React.Component {
         }
     };
 
-    _onGhostTransitionEnd = (ev) => {
+    private onGhostTransitionEnd = (ev: TransitionEvent): void => {
         // we can now clean up the ghost element
-        const finishedEventId = ev.target.dataset.eventid;
+        const finishedEventId = (ev.target as HTMLElement).dataset.eventid;
         this.setState({
             ghostReadMarkers: this.state.ghostReadMarkers.filter(eid => eid !== finishedEventId),
         });
     };
 
-    _getNextEventInfo(arr, i) {
+    private getNextEventInfo(arr: MatrixEvent[], i: number): { nextEvent: MatrixEvent, nextTile: MatrixEvent } {
         const nextEvent = i < arr.length - 1
             ? arr[i + 1]
             : null;
@@ -472,16 +508,16 @@ export default class MessagePanel extends React.Component {
         // when rendering the tile. The shouldShowEvent function is pretty quick at what
         // it does, so this should have no significant cost even when a room is used for
         // not-chat purposes.
-        const nextTile = arr.slice(i + 1).find(e => this._shouldShowEvent(e));
+        const nextTile = arr.slice(i + 1).find(e => this.shouldShowEvent(e));
 
-        return {nextEvent, nextTile};
+        return { nextEvent, nextTile };
     }
 
-    get _roomHasPendingEdit() {
+    private get roomHasPendingEdit(): string {
         return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`);
     }
 
-    _getEventTiles() {
+    private getEventTiles(): ReactNode[] {
         this.eventNodes = {};
 
         let i;
@@ -497,7 +533,7 @@ export default class MessagePanel extends React.Component {
         let lastShownNonLocalEchoIndex = -1;
         for (i = this.props.events.length-1; i >= 0; i--) {
             const mxEv = this.props.events[i];
-            if (!this._shouldShowEvent(mxEv)) {
+            if (!this.shouldShowEvent(mxEv)) {
                 continue;
             }
 
@@ -521,18 +557,18 @@ export default class MessagePanel extends React.Component {
         // Note: the EventTile might still render a "sent/sending receipt" independent of
         // this information. When not providing read receipt information, the tile is likely
         // to assume that sent receipts are to be shown more often.
-        this._readReceiptsByEvent = {};
+        this.readReceiptsByEvent = {};
         if (this.props.showReadReceipts) {
-            this._readReceiptsByEvent = this._getReadReceiptsByShownEvent();
+            this.readReceiptsByEvent = this.getReadReceiptsByShownEvent();
         }
 
-        let grouper = null;
+        let grouper: BaseGrouper = null;
 
         for (i = 0; i < this.props.events.length; i++) {
             const mxEv = this.props.events[i];
             const eventId = mxEv.getId();
             const last = (mxEv === lastShownEvent);
-            const {nextEvent, nextTile} = this._getNextEventInfo(this.props.events, i);
+            const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i);
 
             if (grouper) {
                 if (grouper.shouldGroup(mxEv)) {
@@ -553,26 +589,25 @@ export default class MessagePanel extends React.Component {
                 }
             }
             if (!grouper) {
-                const wantTile = this._shouldShowEvent(mxEv);
+                const wantTile = this.shouldShowEvent(mxEv);
                 const isGrouped = false;
                 if (wantTile) {
-                    // make sure we unpack the array returned by _getTilesForEvent,
+                    // make sure we unpack the array returned by getTilesForEvent,
                     // otherwise react will auto-generate keys and we will end up
                     // replacing all of the DOM elements every time we paginate.
-                    ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped,
-                        nextEvent, nextTile));
+                    ret.push(...this.getTilesForEvent(prevEvent, mxEv, last, isGrouped, nextEvent, nextTile));
                     prevEvent = mxEv;
                 }
 
-                const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
+                const readMarker = this.readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
                 if (readMarker) ret.push(readMarker);
             }
         }
 
-        if (!this.props.editState && this._roomHasPendingEdit) {
+        if (!this.props.editState && this.roomHasPendingEdit) {
             defaultDispatcher.dispatch({
                 action: "edit_event",
-                event: this.props.room.findEventById(this._roomHasPendingEdit),
+                event: this.props.room.findEventById(this.roomHasPendingEdit),
             });
         }
 
@@ -583,10 +618,14 @@ export default class MessagePanel extends React.Component {
         return ret;
     }
 
-    _getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) {
-        const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
-        const EventTile = sdk.getComponent('rooms.EventTile');
-        const DateSeparator = sdk.getComponent('messages.DateSeparator');
+    public getTilesForEvent(
+        prevEvent: MatrixEvent,
+        mxEv: MatrixEvent,
+        last = false,
+        isGrouped = false,
+        nextEvent?: MatrixEvent,
+        nextEventWithTile?: MatrixEvent,
+    ): ReactNode[] {
         const ret = [];
 
         const isEditing = this.props.editState &&
@@ -601,7 +640,7 @@ export default class MessagePanel extends React.Component {
         }
 
         // do we need a date separator since the last event?
-        const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate);
+        const wantsDateSeparator = this.wantsDateSeparator(prevEvent, eventDate);
         if (wantsDateSeparator && !isGrouped) {
             const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
             ret.push(dateSeparator);
@@ -609,7 +648,7 @@ export default class MessagePanel extends React.Component {
 
         let willWantDateSeparator = false;
         if (nextEvent) {
-            willWantDateSeparator = this._wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
+            willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
         }
 
         // is this a continuation of the previous message?
@@ -618,12 +657,12 @@ export default class MessagePanel extends React.Component {
         const eventId = mxEv.getId();
         const highlight = (eventId === this.props.highlightedEventId);
 
-        const readReceipts = this._readReceiptsByEvent[eventId];
+        const readReceipts = this.readReceiptsByEvent[eventId];
 
         let isLastSuccessful = false;
         const isSentState = s => !s || s === 'sent';
         const isSent = isSentState(mxEv.getAssociatedStatus());
-        const hasNextEvent = nextEvent && this._shouldShowEvent(nextEvent);
+        const hasNextEvent = nextEvent && this.shouldShowEvent(nextEvent);
         if (!hasNextEvent && isSent) {
             isLastSuccessful = true;
         } else if (hasNextEvent && isSent && !isSentState(nextEvent.getAssociatedStatus())) {
@@ -649,18 +688,18 @@ export default class MessagePanel extends React.Component {
             <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
                 <EventTile
                     as="li"
-                    ref={this._collectEventNode.bind(this, eventId)}
+                    ref={this.collectEventNode.bind(this, eventId)}
                     alwaysShowTimestamps={this.props.alwaysShowTimestamps}
                     mxEvent={mxEv}
                     continuation={continuation}
                     isRedacted={mxEv.isRedacted()}
                     replacingEventId={mxEv.replacingEventId()}
                     editState={isEditing && this.props.editState}
-                    onHeightChanged={this._onHeightChanged}
+                    onHeightChanged={this.onHeightChanged}
                     readReceipts={readReceipts}
-                    readReceiptMap={this._readReceiptMap}
+                    readReceiptMap={this.readReceiptMap}
                     showUrlPreview={this.props.showUrlPreview}
-                    checkUnmounting={this._isUnmounting}
+                    checkUnmounting={this.isUnmounting}
                     eventSendStatus={mxEv.getAssociatedStatus()}
                     tileShape={this.props.tileShape}
                     isTwelveHour={this.props.isTwelveHour}
@@ -681,7 +720,7 @@ export default class MessagePanel extends React.Component {
         return ret;
     }
 
-    _wantsDateSeparator(prevEvent, nextEventDate) {
+    public wantsDateSeparator(prevEvent: MatrixEvent, nextEventDate: Date): boolean {
         if (prevEvent == null) {
             // first event in the panel: depends if we could back-paginate from
             // here.
@@ -692,7 +731,7 @@ export default class MessagePanel extends React.Component {
 
     // Get a list of read receipts that should be shown next to this event
     // Receipts are objects which have a 'userId', 'roomMember' and 'ts'.
-    _getReadReceiptsForEvent(event) {
+    private getReadReceiptsForEvent(event: MatrixEvent): IReadReceiptProps[] {
         const myUserId = MatrixClientPeg.get().credentials.userId;
 
         // get list of read receipts, sorted most recent first
@@ -700,7 +739,7 @@ export default class MessagePanel extends React.Component {
         if (!room) {
             return null;
         }
-        const receipts = [];
+        const receipts: IReadReceiptProps[] = [];
         room.getReceiptsForEvent(event).forEach((r) => {
             if (!r.userId || r.type !== "m.read" || r.userId === myUserId) {
                 return; // ignore non-read receipts and receipts from self.
@@ -721,13 +760,13 @@ export default class MessagePanel extends React.Component {
     // Get an object that maps from event ID to a list of read receipts that
     // should be shown next to that event. If a hidden event has read receipts,
     // they are folded into the receipts of the last shown event.
-    _getReadReceiptsByShownEvent() {
+    private getReadReceiptsByShownEvent(): Record<string, IReadReceiptProps[]> {
         const receiptsByEvent = {};
         const receiptsByUserId = {};
 
         let lastShownEventId;
         for (const event of this.props.events) {
-            if (this._shouldShowEvent(event)) {
+            if (this.shouldShowEvent(event)) {
                 lastShownEventId = event.getId();
             }
             if (!lastShownEventId) {
@@ -735,7 +774,7 @@ export default class MessagePanel extends React.Component {
             }
 
             const existingReceipts = receiptsByEvent[lastShownEventId] || [];
-            const newReceipts = this._getReadReceiptsForEvent(event);
+            const newReceipts = this.getReadReceiptsForEvent(event);
             receiptsByEvent[lastShownEventId] = existingReceipts.concat(newReceipts);
 
             // Record these receipts along with their last shown event ID for
@@ -754,16 +793,16 @@ export default class MessagePanel extends React.Component {
         // someone which had one in the last. By looking through our previous
         // mapping of receipts by user ID, we can cover recover any receipts
         // that would have been lost by using the same event ID from last time.
-        for (const userId in this._readReceiptsByUserId) {
+        for (const userId in this.readReceiptsByUserId) {
             if (receiptsByUserId[userId]) {
                 continue;
             }
-            const { lastShownEventId, receipt } = this._readReceiptsByUserId[userId];
+            const { lastShownEventId, receipt } = this.readReceiptsByUserId[userId];
             const existingReceipts = receiptsByEvent[lastShownEventId] || [];
             receiptsByEvent[lastShownEventId] = existingReceipts.concat(receipt);
             receiptsByUserId[userId] = { lastShownEventId, receipt };
         }
-        this._readReceiptsByUserId = receiptsByUserId;
+        this.readReceiptsByUserId = receiptsByUserId;
 
         // After grouping receipts by shown events, do another pass to sort each
         // receipt list.
@@ -776,21 +815,21 @@ export default class MessagePanel extends React.Component {
         return receiptsByEvent;
     }
 
-    _collectEventNode = (eventId, node) => {
+    private collectEventNode = (eventId: string, node: EventTile): void => {
         this.eventNodes[eventId] = node?.ref?.current;
     }
 
     // once dynamic content in the events load, make the scrollPanel check the
     // scroll offsets.
-    _onHeightChanged = () => {
-        const scrollPanel = this._scrollPanel.current;
+    public onHeightChanged = (): void => {
+        const scrollPanel = this.scrollPanel.current;
         if (scrollPanel) {
             scrollPanel.checkScroll();
         }
     };
 
-    _onTypingShown = () => {
-        const scrollPanel = this._scrollPanel.current;
+    private onTypingShown = (): void => {
+        const scrollPanel = this.scrollPanel.current;
         // this will make the timeline grow, so checkScroll
         scrollPanel.checkScroll();
         if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
@@ -798,8 +837,8 @@ export default class MessagePanel extends React.Component {
         }
     };
 
-    _onTypingHidden = () => {
-        const scrollPanel = this._scrollPanel.current;
+    private onTypingHidden = (): void => {
+        const scrollPanel = this.scrollPanel.current;
         if (scrollPanel) {
             // as hiding the typing notifications doesn't
             // update the scrollPanel, we tell it to apply
@@ -811,12 +850,12 @@ export default class MessagePanel extends React.Component {
         }
     };
 
-    updateTimelineMinHeight() {
-        const scrollPanel = this._scrollPanel.current;
+    public updateTimelineMinHeight(): void {
+        const scrollPanel = this.scrollPanel.current;
 
         if (scrollPanel) {
             const isAtBottom = scrollPanel.isAtBottom();
-            const whoIsTyping = this._whoIsTyping.current;
+            const whoIsTyping = this.whoIsTyping.current;
             const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
             // when messages get added to the timeline,
             // but somebody else is still typing,
@@ -828,18 +867,14 @@ export default class MessagePanel extends React.Component {
         }
     }
 
-    onTimelineReset() {
-        const scrollPanel = this._scrollPanel.current;
+    public onTimelineReset(): void {
+        const scrollPanel = this.scrollPanel.current;
         if (scrollPanel) {
             scrollPanel.clearPreventShrinking();
         }
     }
 
     render() {
-        const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary');
-        const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
-        const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
-        const Spinner = sdk.getComponent("elements.Spinner");
         let topSpinner;
         let bottomSpinner;
         if (this.props.backPaginating) {
@@ -855,9 +890,9 @@ export default class MessagePanel extends React.Component {
         if (this.props.room && !this.props.tileShape && this.state.showTypingNotifications) {
             whoIsTyping = (<WhoIsTypingTile
                 room={this.props.room}
-                onShown={this._onTypingShown}
-                onHidden={this._onTypingHidden}
-                ref={this._whoIsTyping} />
+                onShown={this.onTypingShown}
+                onHidden={this.onTypingHidden}
+                ref={this.whoIsTyping} />
             );
         }
 
@@ -873,11 +908,10 @@ export default class MessagePanel extends React.Component {
         return (
             <ErrorBoundary>
                 <ScrollPanel
-                    ref={this._scrollPanel}
+                    ref={this.scrollPanel}
                     className={this.props.className}
                     onScroll={this.props.onScroll}
                     onUserScroll={this.props.onUserScroll}
-                    onResize={this.onResize}
                     onFillRequest={this.props.onFillRequest}
                     onUnfillRequest={this.props.onUnfillRequest}
                     style={style}
@@ -886,7 +920,7 @@ export default class MessagePanel extends React.Component {
                     fixedChildren={ircResizer}
                 >
                     { topSpinner }
-                    { this._getEventTiles() }
+                    { this.getEventTiles() }
                     { whoIsTyping }
                     { bottomSpinner }
                 </ScrollPanel>
@@ -895,6 +929,31 @@ export default class MessagePanel extends React.Component {
     }
 }
 
+abstract class BaseGrouper {
+    static canStartGroup = (panel: MessagePanel, ev: MatrixEvent): boolean => true;
+
+    public events: MatrixEvent[] = [];
+    // events that we include in the group but then eject out and place above the group.
+    public ejectedEvents: MatrixEvent[] = [];
+    public readMarker: ReactNode;
+
+    constructor(
+        public readonly panel: MessagePanel,
+        public readonly event: MatrixEvent,
+        public readonly prevEvent: MatrixEvent,
+        public readonly lastShownEvent: MatrixEvent,
+        public readonly nextEvent?: MatrixEvent,
+        public readonly nextEventTile?: MatrixEvent,
+    ) {
+        this.readMarker = panel.readMarkerForEvent(event.getId(), event === lastShownEvent);
+    }
+
+    public abstract shouldGroup(ev: MatrixEvent): boolean;
+    public abstract add(ev: MatrixEvent): void;
+    public abstract getTiles(): ReactNode[];
+    public abstract getNewPrevEvent(): MatrixEvent;
+}
+
 /* Grouper classes determine when events can be grouped together in a summary.
  * Groupers should have the following methods:
  * - canStartGroup (static): determines if a new group should be started with the
@@ -910,36 +969,21 @@ export default class MessagePanel extends React.Component {
 // Wrap initial room creation events into an EventListSummary
 // Grouping only events sent by the same user that sent the `m.room.create` and only until
 // the first non-state event or membership event which is not regarding the sender of the `m.room.create` event
-class CreationGrouper {
-    static canStartGroup = function(panel, ev) {
-        return ev.getType() === "m.room.create";
+class CreationGrouper extends BaseGrouper {
+    static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
+        return ev.getType() === EventType.RoomCreate;
     };
 
-    constructor(panel, createEvent, prevEvent, lastShownEvent) {
-        this.panel = panel;
-        this.createEvent = createEvent;
-        this.prevEvent = prevEvent;
-        this.lastShownEvent = lastShownEvent;
-        this.events = [];
-        // events that we include in the group but then eject out and place
-        // above the group.
-        this.ejectedEvents = [];
-        this.readMarker = panel._readMarkerForEvent(
-            createEvent.getId(),
-            createEvent === lastShownEvent,
-        );
-    }
-
-    shouldGroup(ev) {
+    public shouldGroup(ev: MatrixEvent): boolean {
         const panel = this.panel;
-        const createEvent = this.createEvent;
-        if (!panel._shouldShowEvent(ev)) {
+        const createEvent = this.event;
+        if (!panel.shouldShowEvent(ev)) {
             return true;
         }
-        if (panel._wantsDateSeparator(this.createEvent, ev.getDate())) {
+        if (panel.wantsDateSeparator(this.event, ev.getDate())) {
             return false;
         }
-        if (ev.getType() === "m.room.member"
+        if (ev.getType() === EventType.RoomMember
             && (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) {
             return false;
         }
@@ -949,37 +993,35 @@ class CreationGrouper {
         return false;
     }
 
-    add(ev) {
+    public add(ev: MatrixEvent): void {
         const panel = this.panel;
-        this.readMarker = this.readMarker || panel._readMarkerForEvent(
+        this.readMarker = this.readMarker || panel.readMarkerForEvent(
             ev.getId(),
             ev === this.lastShownEvent,
         );
-        if (!panel._shouldShowEvent(ev)) {
+        if (!panel.shouldShowEvent(ev)) {
             return;
         }
-        if (ev.getType() === "m.room.encryption") {
+        if (ev.getType() === EventType.RoomEncryption) {
             this.ejectedEvents.push(ev);
         } else {
             this.events.push(ev);
         }
     }
 
-    getTiles() {
+    public getTiles(): ReactNode[] {
         // If we don't have any events to group, don't even try to group them. The logic
         // below assumes that we have a group of events to deal with, but we might not if
         // the events we were supposed to group were redacted.
         if (!this.events || !this.events.length) return [];
 
-        const DateSeparator = sdk.getComponent('messages.DateSeparator');
-        const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
         const panel = this.panel;
         const ret = [];
         const isGrouped = true;
-        const createEvent = this.createEvent;
+        const createEvent = this.event;
         const lastShownEvent = this.lastShownEvent;
 
-        if (panel._wantsDateSeparator(this.prevEvent, createEvent.getDate())) {
+        if (panel.wantsDateSeparator(this.prevEvent, createEvent.getDate())) {
             const ts = createEvent.getTs();
             ret.push(
                 <li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
@@ -987,13 +1029,13 @@ class CreationGrouper {
         }
 
         // If this m.room.create event should be shown (room upgrade) then show it before the summary
-        if (panel._shouldShowEvent(createEvent)) {
+        if (panel.shouldShowEvent(createEvent)) {
             // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
-            ret.push(...panel._getTilesForEvent(createEvent, createEvent));
+            ret.push(...panel.getTilesForEvent(createEvent, createEvent));
         }
 
         for (const ejected of this.ejectedEvents) {
-            ret.push(...panel._getTilesForEvent(
+            ret.push(...panel.getTilesForEvent(
                 createEvent, ejected, createEvent === lastShownEvent, isGrouped,
             ));
         }
@@ -1003,7 +1045,7 @@ class CreationGrouper {
             // of EventListSummary, render each member event as if the previous
             // one was itself. This way, the timestamp of the previous event === the
             // timestamp of the current event, and no DateSeparator is inserted.
-            return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
+            return panel.getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
         }).reduce((a, b) => a.concat(b), []);
         // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
         const ev = this.events[this.events.length - 1];
@@ -1023,7 +1065,7 @@ class CreationGrouper {
             <EventListSummary
                 key="roomcreationsummary"
                 events={this.events}
-                onToggle={panel._onHeightChanged} // Update scroll state
+                onToggle={panel.onHeightChanged} // Update scroll state
                 summaryMembers={[ev.sender]}
                 summaryText={summaryText}
             >
@@ -1038,62 +1080,59 @@ class CreationGrouper {
         return ret;
     }
 
-    getNewPrevEvent() {
-        return this.createEvent;
+    public getNewPrevEvent(): MatrixEvent {
+        return this.event;
     }
 }
 
-class RedactionGrouper {
-    static canStartGroup = function(panel, ev) {
-        return panel._shouldShowEvent(ev) && ev.isRedacted();
+class RedactionGrouper extends BaseGrouper {
+    static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
+        return panel.shouldShowEvent(ev) && ev.isRedacted();
     }
 
-    constructor(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile) {
-        this.panel = panel;
-        this.readMarker = panel._readMarkerForEvent(
-            ev.getId(),
-            ev === lastShownEvent,
-        );
+    constructor(
+        panel: MessagePanel,
+        ev: MatrixEvent,
+        prevEvent: MatrixEvent,
+        lastShownEvent: MatrixEvent,
+        nextEvent: MatrixEvent,
+        nextEventTile: MatrixEvent,
+    ) {
+        super(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile);
         this.events = [ev];
-        this.prevEvent = prevEvent;
-        this.lastShownEvent = lastShownEvent;
-        this.nextEvent = nextEvent;
-        this.nextEventTile = nextEventTile;
     }
 
-    shouldGroup(ev) {
+    public shouldGroup(ev: MatrixEvent): boolean {
         // absorb hidden events so that they do not break up streams of messages & redaction events being grouped
-        if (!this.panel._shouldShowEvent(ev)) {
+        if (!this.panel.shouldShowEvent(ev)) {
             return true;
         }
-        if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) {
+        if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
             return false;
         }
         return ev.isRedacted();
     }
 
-    add(ev) {
-        this.readMarker = this.readMarker || this.panel._readMarkerForEvent(
+    public add(ev: MatrixEvent): void {
+        this.readMarker = this.readMarker || this.panel.readMarkerForEvent(
             ev.getId(),
             ev === this.lastShownEvent,
         );
-        if (!this.panel._shouldShowEvent(ev)) {
+        if (!this.panel.shouldShowEvent(ev)) {
             return;
         }
         this.events.push(ev);
     }
 
-    getTiles() {
+    public getTiles(): ReactNode[] {
         if (!this.events || !this.events.length) return [];
 
-        const DateSeparator = sdk.getComponent('messages.DateSeparator');
-        const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
         const isGrouped = true;
         const panel = this.panel;
         const ret = [];
         const lastShownEvent = this.lastShownEvent;
 
-        if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
+        if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
             const ts = this.events[0].getTs();
             ret.push(
                 <li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
@@ -1104,11 +1143,11 @@ class RedactionGrouper {
             this.prevEvent ? this.events[0].getId() : "initial"
         );
 
-        const senders = new Set();
+        const senders = new Set<RoomMember>();
         let eventTiles = this.events.map((e, i) => {
             senders.add(e.sender);
             const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1];
-            return panel._getTilesForEvent(
+            return panel.getTilesForEvent(
                 prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile);
         }).reduce((a, b) => a.concat(b), []);
 
@@ -1121,7 +1160,7 @@ class RedactionGrouper {
                 key={key}
                 threshold={2}
                 events={this.events}
-                onToggle={panel._onHeightChanged} // Update scroll state
+                onToggle={panel.onHeightChanged} // Update scroll state
                 summaryMembers={Array.from(senders)}
                 summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })}
             >
@@ -1136,61 +1175,58 @@ class RedactionGrouper {
         return ret;
     }
 
-    getNewPrevEvent() {
+    public getNewPrevEvent(): MatrixEvent {
         return this.events[this.events.length - 1];
     }
 }
 
 // Wrap consecutive member events in a ListSummary, ignore if redacted
-class MemberGrouper {
-    static canStartGroup = function(panel, ev) {
-        return panel._shouldShowEvent(ev) && isMembershipChange(ev);
+class MemberGrouper extends BaseGrouper {
+    static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
+        return panel.shouldShowEvent(ev) && isMembershipChange(ev);
     }
 
-    constructor(panel, ev, prevEvent, lastShownEvent) {
-        this.panel = panel;
-        this.readMarker = panel._readMarkerForEvent(
-            ev.getId(),
-            ev === lastShownEvent,
-        );
-        this.events = [ev];
-        this.prevEvent = prevEvent;
-        this.lastShownEvent = lastShownEvent;
+    constructor(
+        public readonly panel: MessagePanel,
+        public readonly event: MatrixEvent,
+        public readonly prevEvent: MatrixEvent,
+        public readonly lastShownEvent: MatrixEvent,
+    ) {
+        super(panel, event, prevEvent, lastShownEvent);
+        this.events = [event];
     }
 
-    shouldGroup(ev) {
-        if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) {
+    public shouldGroup(ev: MatrixEvent): boolean {
+        if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
             return false;
         }
         return isMembershipChange(ev);
     }
 
-    add(ev) {
-        if (ev.getType() === 'm.room.member') {
+    public add(ev: MatrixEvent): void {
+        if (ev.getType() === EventType.RoomMember) {
             // We can ignore any events that don't actually have a message to display
             if (!hasText(ev)) return;
         }
-        this.readMarker = this.readMarker || this.panel._readMarkerForEvent(
+        this.readMarker = this.readMarker || this.panel.readMarkerForEvent(
             ev.getId(),
             ev === this.lastShownEvent,
         );
         this.events.push(ev);
     }
 
-    getTiles() {
+    public getTiles(): ReactNode[] {
         // If we don't have any events to group, don't even try to group them. The logic
         // below assumes that we have a group of events to deal with, but we might not if
         // the events we were supposed to group were redacted.
         if (!this.events || !this.events.length) return [];
 
-        const DateSeparator = sdk.getComponent('messages.DateSeparator');
-        const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
         const isGrouped = true;
         const panel = this.panel;
         const lastShownEvent = this.lastShownEvent;
         const ret = [];
 
-        if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
+        if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
             const ts = this.events[0].getTs();
             ret.push(
                 <li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
@@ -1218,7 +1254,7 @@ class MemberGrouper {
             // of MemberEventListSummary, render each member event as if the previous
             // one was itself. This way, the timestamp of the previous event === the
             // timestamp of the current event, and no DateSeparator is inserted.
-            return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
+            return panel.getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
         }).reduce((a, b) => a.concat(b), []);
 
         if (eventTiles.length === 0) {
@@ -1226,9 +1262,10 @@ class MemberGrouper {
         }
 
         ret.push(
-            <MemberEventListSummary key={key}
+            <MemberEventListSummary
+                key={key}
                 events={this.events}
-                onToggle={panel._onHeightChanged} // Update scroll state
+                onToggle={panel.onHeightChanged} // Update scroll state
                 startExpanded={highlightInMels}
             >
                 { eventTiles }
@@ -1242,7 +1279,7 @@ class MemberGrouper {
         return ret;
     }
 
-    getNewPrevEvent() {
+    public getNewPrevEvent(): MatrixEvent {
         return this.events[0];
     }
 }
diff --git a/src/components/structures/NotificationPanel.tsx b/src/components/structures/NotificationPanel.tsx
index 6c22835447..8c8fab7ece 100644
--- a/src/components/structures/NotificationPanel.tsx
+++ b/src/components/structures/NotificationPanel.tsx
@@ -22,6 +22,7 @@ import BaseCard from "../views/right_panel/BaseCard";
 import { replaceableComponent } from "../../utils/replaceableComponent";
 import TimelinePanel from "./TimelinePanel";
 import Spinner from "../views/elements/Spinner";
+import { TileShape } from "../views/rooms/EventTile";
 
 interface IProps {
     onClose(): void;
@@ -48,7 +49,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
                     manageReadMarkers={false}
                     timelineSet={timelineSet}
                     showUrlPreview={false}
-                    tileShape="notif"
+                    tileShape={TileShape.Notif}
                     empty={emptyState}
                     alwaysShowTimestamps={true}
                 />
diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx
index 1e0605f263..7770b32f04 100644
--- a/src/components/structures/RoomDirectory.tsx
+++ b/src/components/structures/RoomDirectory.tsx
@@ -207,9 +207,9 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
         this.getMoreRooms();
     };
 
-    private getMoreRooms() {
-        if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
-        if (!MatrixClientPeg.get()) return Promise.resolve();
+    private getMoreRooms(): Promise<boolean> {
+        if (this.state.selectedCommunityId) return Promise.resolve(false); // no more rooms
+        if (!MatrixClientPeg.get()) return Promise.resolve(false);
 
         this.setState({
             loading: true,
@@ -239,12 +239,12 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
                 // if the filter or server has changed since this request was sent,
                 // throw away the result (don't even clear the busy flag
                 // since we must still have a request in flight)
-                return;
+                return false;
             }
 
             if (this.unmounted) {
                 // if we've been unmounted, we don't care either.
-                return;
+                return false;
             }
 
             if (this.state.filterString) {
@@ -264,14 +264,13 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
                 filterString != this.state.filterString ||
                 roomServer != this.state.roomServer ||
                 nextBatch != this.nextBatch) {
-                // as above: we don't care about errors for old
-                // requests either
-                return;
+                // as above: we don't care about errors for old requests either
+                return false;
             }
 
             if (this.unmounted) {
                 // if we've been unmounted, we don't care either.
-                return;
+                return false;
             }
 
             console.error("Failed to get publicRooms: %s", JSON.stringify(err));
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 885851e8e6..338da29875 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -1125,7 +1125,7 @@ export default class RoomView extends React.Component<IProps, IState> {
         }
     }
 
-    private onSearchResultsFillRequest = (backwards: boolean) => {
+    private onSearchResultsFillRequest = (backwards: boolean): Promise<boolean> => {
         if (!backwards) {
             return Promise.resolve(false);
         }
@@ -1291,7 +1291,7 @@ export default class RoomView extends React.Component<IProps, IState> {
         this.handleSearchResult(searchPromise);
     };
 
-    private handleSearchResult(searchPromise: Promise<any>) {
+    private handleSearchResult(searchPromise: Promise<any>): Promise<boolean> {
         // keep a record of the current search id, so that if the search terms
         // change before we get a response, we can ignore the results.
         const localSearchId = this.searchId;
@@ -1304,7 +1304,7 @@ export default class RoomView extends React.Component<IProps, IState> {
             debuglog("search complete");
             if (this.unmounted || !this.state.searching || this.searchId != localSearchId) {
                 console.error("Discarding stale search results");
-                return;
+                return false;
             }
 
             // postgres on synapse returns us precise details of the strings
@@ -1336,6 +1336,7 @@ export default class RoomView extends React.Component<IProps, IState> {
                 description: ((error && error.message) ? error.message :
                     _t("Server may be unavailable, overloaded, or search timed out :(")),
             });
+            return false;
         }).finally(() => {
             this.setState({
                 searchInProgress: false,
diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.tsx
similarity index 73%
rename from src/components/structures/ScrollPanel.js
rename to src/components/structures/ScrollPanel.tsx
index f6e1530537..b8e0cdbc34 100644
--- a/src/components/structures/ScrollPanel.js
+++ b/src/components/structures/ScrollPanel.tsx
@@ -1,5 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, {createRef} from "react";
-import PropTypes from 'prop-types';
+import React, { createRef, CSSProperties, ReactNode, SyntheticEvent, KeyboardEvent } from "react";
+
 import Timer from '../../utils/Timer';
 import AutoHideScrollbar from "./AutoHideScrollbar";
-import {replaceableComponent} from "../../utils/replaceableComponent";
-import {getKeyBindingsManager, RoomAction} from "../../KeyBindingsManager";
+import { replaceableComponent } from "../../utils/replaceableComponent";
+import { getKeyBindingsManager, RoomAction } from "../../KeyBindingsManager";
+import ResizeNotifier from "../../utils/ResizeNotifier";
 
 const DEBUG_SCROLL = false;
 
 // The amount of extra scroll distance to allow prior to unfilling.
-// See _getExcessHeight.
+// See getExcessHeight.
 const UNPAGINATION_PADDING = 6000;
 // The number of milliseconds to debounce calls to onUnfillRequest, to prevent
 // many scroll events causing many unfilling requests.
@@ -43,6 +44,75 @@ if (DEBUG_SCROLL) {
     debuglog = function() {};
 }
 
+interface IProps {
+    /* stickyBottom: if set to true, then once the user hits the bottom of
+     * the list, any new children added to the list will cause the list to
+     * scroll down to show the new element, rather than preserving the
+     * existing view.
+     */
+    stickyBottom?: boolean;
+
+    /* startAtBottom: if set to true, the view is assumed to start
+     * scrolled to the bottom.
+     * XXX: It's likely this is unnecessary and can be derived from
+     * stickyBottom, but I'm adding an extra parameter to ensure
+     * behaviour stays the same for other uses of ScrollPanel.
+     * If so, let's remove this parameter down the line.
+     */
+    startAtBottom?: boolean;
+
+    /* className: classnames to add to the top-level div
+     */
+    className?: string;
+
+    /* style: styles to add to the top-level div
+     */
+    style?: CSSProperties;
+
+    /* resizeNotifier: ResizeNotifier to know when middle column has changed size
+     */
+    resizeNotifier?: ResizeNotifier;
+
+    /* fixedChildren: allows for children to be passed which are rendered outside
+     * of the wrapper
+     */
+    fixedChildren?: ReactNode;
+
+    /* onFillRequest(backwards): a callback which is called on scroll when
+     * the user nears the start (backwards = true) or end (backwards =
+     * false) of the list.
+     *
+     * This should return a promise; no more calls will be made until the
+     * promise completes.
+     *
+     * The promise should resolve to true if there is more data to be
+     * retrieved in this direction (in which case onFillRequest may be
+     * called again immediately), or false if there is no more data in this
+     * directon (at this time) - which will stop the pagination cycle until
+     * the user scrolls again.
+     */
+    onFillRequest?(backwards: boolean): Promise<boolean>;
+
+    /* onUnfillRequest(backwards): a callback which is called on scroll when
+     * there are children elements that are far out of view and could be removed
+     * without causing pagination to occur.
+     *
+     * This function should accept a boolean, which is true to indicate the back/top
+     * of the panel and false otherwise, and a scroll token, which refers to the
+     * first element to remove if removing from the front/bottom, and last element
+     * to remove if removing from the back/top.
+     */
+    onUnfillRequest?(backwards: boolean, scrollToken: string): void;
+
+    /* onScroll: a callback which is called whenever any scroll happens.
+     */
+    onScroll?(event: Event): void;
+
+    /* onUserScroll: callback which is called when the user interacts with the room timeline
+     */
+    onUserScroll?(event: SyntheticEvent): void;
+}
+
 /* This component implements an intelligent scrolling list.
  *
  * It wraps a list of <li> children; when items are added to the start or end
@@ -84,97 +154,54 @@ if (DEBUG_SCROLL) {
  * offset as normal.
  */
 
+export interface IScrollState {
+    stuckAtBottom: boolean;
+    trackedNode?: HTMLElement;
+    trackedScrollToken?: string;
+    bottomOffset?: number;
+    pixelOffset?: number;
+}
+
+interface IPreventShrinkingState {
+    offsetFromBottom: number;
+    offsetNode: HTMLElement;
+}
+
 @replaceableComponent("structures.ScrollPanel")
-export default class ScrollPanel extends React.Component {
-    static propTypes = {
-        /* stickyBottom: if set to true, then once the user hits the bottom of
-         * the list, any new children added to the list will cause the list to
-         * scroll down to show the new element, rather than preserving the
-         * existing view.
-         */
-        stickyBottom: PropTypes.bool,
-
-        /* startAtBottom: if set to true, the view is assumed to start
-         * scrolled to the bottom.
-         * XXX: It's likely this is unnecessary and can be derived from
-         * stickyBottom, but I'm adding an extra parameter to ensure
-         * behaviour stays the same for other uses of ScrollPanel.
-         * If so, let's remove this parameter down the line.
-         */
-        startAtBottom: PropTypes.bool,
-
-        /* onFillRequest(backwards): a callback which is called on scroll when
-         * the user nears the start (backwards = true) or end (backwards =
-         * false) of the list.
-         *
-         * This should return a promise; no more calls will be made until the
-         * promise completes.
-         *
-         * The promise should resolve to true if there is more data to be
-         * retrieved in this direction (in which case onFillRequest may be
-         * called again immediately), or false if there is no more data in this
-         * directon (at this time) - which will stop the pagination cycle until
-         * the user scrolls again.
-         */
-        onFillRequest: PropTypes.func,
-
-        /* onUnfillRequest(backwards): a callback which is called on scroll when
-         * there are children elements that are far out of view and could be removed
-         * without causing pagination to occur.
-         *
-         * This function should accept a boolean, which is true to indicate the back/top
-         * of the panel and false otherwise, and a scroll token, which refers to the
-         * first element to remove if removing from the front/bottom, and last element
-         * to remove if removing from the back/top.
-         */
-        onUnfillRequest: PropTypes.func,
-
-        /* onScroll: a callback which is called whenever any scroll happens.
-         */
-        onScroll: PropTypes.func,
-
-        /* onUserScroll: callback which is called when the user interacts with the room timeline
-         */
-        onUserScroll: PropTypes.func,
-
-        /* className: classnames to add to the top-level div
-         */
-        className: PropTypes.string,
-
-        /* style: styles to add to the top-level div
-         */
-        style: PropTypes.object,
-
-        /* resizeNotifier: ResizeNotifier to know when middle column has changed size
-         */
-        resizeNotifier: PropTypes.object,
-
-        /* fixedChildren: allows for children to be passed which are rendered outside
-         * of the wrapper
-         */
-        fixedChildren: PropTypes.node,
-    };
-
+export default class ScrollPanel extends React.Component<IProps> {
     static defaultProps = {
         stickyBottom: true,
         startAtBottom: true,
-        onFillRequest: function(backwards) { return Promise.resolve(false); },
-        onUnfillRequest: function(backwards, scrollToken) {},
+        onFillRequest: function(backwards: boolean) { return Promise.resolve(false); },
+        onUnfillRequest: function(backwards: boolean, scrollToken: string) {},
         onScroll: function() {},
     };
 
-    constructor(props) {
-        super(props);
+    private readonly pendingFillRequests: Record<"b" | "f", boolean> = {
+        b: null,
+        f: null,
+    };
+    private readonly itemlist = createRef<HTMLOListElement>();
+    private unmounted = false;
+    private scrollTimeout: Timer;
+    private isFilling: boolean;
+    private fillRequestWhileRunning: boolean;
+    private scrollState: IScrollState;
+    private preventShrinkingState: IPreventShrinkingState;
+    private unfillDebouncer: NodeJS.Timeout;
+    private bottomGrowth: number;
+    private pages: number;
+    private heightUpdateInProgress: boolean;
+    private divScroll: HTMLDivElement;
 
-        this._pendingFillRequests = {b: null, f: null};
+    constructor(props, context) {
+        super(props, context);
 
         if (this.props.resizeNotifier) {
             this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
         }
 
         this.resetScrollState();
-
-        this._itemlist = createRef();
     }
 
     componentDidMount() {
@@ -203,18 +230,18 @@ export default class ScrollPanel extends React.Component {
         }
     }
 
-    onScroll = ev => {
+    private onScroll = ev => {
         // skip scroll events caused by resizing
         if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return;
-        debuglog("onScroll", this._getScrollNode().scrollTop);
-        this._scrollTimeout.restart();
-        this._saveScrollState();
+        debuglog("onScroll", this.getScrollNode().scrollTop);
+        this.scrollTimeout.restart();
+        this.saveScrollState();
         this.updatePreventShrinking();
         this.props.onScroll(ev);
         this.checkFillState();
     };
 
-    onResize = () => {
+    private onResize = () => {
         debuglog("onResize");
         this.checkScroll();
         // update preventShrinkingState if present
@@ -225,11 +252,11 @@ export default class ScrollPanel extends React.Component {
 
     // after an update to the contents of the panel, check that the scroll is
     // where it ought to be, and set off pagination requests if necessary.
-    checkScroll = () => {
+    public checkScroll = () => {
         if (this.unmounted) {
             return;
         }
-        this._restoreSavedScrollState();
+        this.restoreSavedScrollState();
         this.checkFillState();
     };
 
@@ -238,8 +265,8 @@ export default class ScrollPanel extends React.Component {
     // note that this is independent of the 'stuckAtBottom' state - it is simply
     // about whether the content is scrolled down right now, irrespective of
     // whether it will stay that way when the children update.
-    isAtBottom = () => {
-        const sn = this._getScrollNode();
+    public isAtBottom = () => {
+        const sn = this.getScrollNode();
         // fractional values (both too big and too small)
         // for scrollTop happen on certain browsers/platforms
         // when scrolled all the way down. E.g. Chrome 72 on debian.
@@ -278,10 +305,10 @@ export default class ScrollPanel extends React.Component {
     //   |#########|   -                                   |
     //   |#########|                                       |
     //   `---------'                                       -
-    _getExcessHeight(backwards) {
-        const sn = this._getScrollNode();
-        const contentHeight = this._getMessagesHeight();
-        const listHeight = this._getListHeight();
+    private getExcessHeight(backwards: boolean): number {
+        const sn = this.getScrollNode();
+        const contentHeight = this.getMessagesHeight();
+        const listHeight = this.getListHeight();
         const clippedHeight = contentHeight - listHeight;
         const unclippedScrollTop = sn.scrollTop + clippedHeight;
 
@@ -293,13 +320,13 @@ export default class ScrollPanel extends React.Component {
     }
 
     // check the scroll state and send out backfill requests if necessary.
-    checkFillState = async (depth=0) => {
+    public checkFillState = async (depth = 0): Promise<void> => {
         if (this.unmounted) {
             return;
         }
 
         const isFirstCall = depth === 0;
-        const sn = this._getScrollNode();
+        const sn = this.getScrollNode();
 
         // if there is less than a screenful of messages above or below the
         // viewport, try to get some more messages.
@@ -330,17 +357,17 @@ export default class ScrollPanel extends React.Component {
         // do make a note when a new request comes in while already running one,
         // so we can trigger a new chain of calls once done.
         if (isFirstCall) {
-            if (this._isFilling) {
-                debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request");
-                this._fillRequestWhileRunning = true;
+            if (this.isFilling) {
+                debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
+                this.fillRequestWhileRunning = true;
                 return;
             }
-            debuglog("_isFilling: setting");
-            this._isFilling = true;
+            debuglog("isFilling: setting");
+            this.isFilling = true;
         }
 
-        const itemlist = this._itemlist.current;
-        const firstTile = itemlist && itemlist.firstElementChild;
+        const itemlist = this.itemlist.current;
+        const firstTile = itemlist && itemlist.firstElementChild as HTMLElement;
         const contentTop = firstTile && firstTile.offsetTop;
         const fillPromises = [];
 
@@ -348,13 +375,13 @@ export default class ScrollPanel extends React.Component {
         // try backward filling
         if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
             // need to back-fill
-            fillPromises.push(this._maybeFill(depth, true));
+            fillPromises.push(this.maybeFill(depth, true));
         }
         // if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
         // try forward filling
         if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
             // need to forward-fill
-            fillPromises.push(this._maybeFill(depth, false));
+            fillPromises.push(this.maybeFill(depth, false));
         }
 
         if (fillPromises.length) {
@@ -365,26 +392,26 @@ export default class ScrollPanel extends React.Component {
             }
         }
         if (isFirstCall) {
-            debuglog("_isFilling: clearing");
-            this._isFilling = false;
+            debuglog("isFilling: clearing");
+            this.isFilling = false;
         }
 
-        if (this._fillRequestWhileRunning) {
-            this._fillRequestWhileRunning = false;
+        if (this.fillRequestWhileRunning) {
+            this.fillRequestWhileRunning = false;
             this.checkFillState();
         }
     };
 
     // check if unfilling is possible and send an unfill request if necessary
-    _checkUnfillState(backwards) {
-        let excessHeight = this._getExcessHeight(backwards);
+    private checkUnfillState(backwards: boolean): void {
+        let excessHeight = this.getExcessHeight(backwards);
         if (excessHeight <= 0) {
             return;
         }
 
         const origExcessHeight = excessHeight;
 
-        const tiles = this._itemlist.current.children;
+        const tiles = this.itemlist.current.children;
 
         // The scroll token of the first/last tile to be unpaginated
         let markerScrollToken = null;
@@ -413,11 +440,11 @@ export default class ScrollPanel extends React.Component {
         if (markerScrollToken) {
             // Use a debouncer to prevent multiple unfill calls in quick succession
             // This is to make the unfilling process less aggressive
-            if (this._unfillDebouncer) {
-                clearTimeout(this._unfillDebouncer);
+            if (this.unfillDebouncer) {
+                clearTimeout(this.unfillDebouncer);
             }
-            this._unfillDebouncer = setTimeout(() => {
-                this._unfillDebouncer = null;
+            this.unfillDebouncer = setTimeout(() => {
+                this.unfillDebouncer = null;
                 debuglog("unfilling now", backwards, origExcessHeight);
                 this.props.onUnfillRequest(backwards, markerScrollToken);
             }, UNFILL_REQUEST_DEBOUNCE_MS);
@@ -425,9 +452,9 @@ export default class ScrollPanel extends React.Component {
     }
 
     // check if there is already a pending fill request. If not, set one off.
-    _maybeFill(depth, backwards) {
+    private maybeFill(depth: number, backwards: boolean): Promise<void> {
         const dir = backwards ? 'b' : 'f';
-        if (this._pendingFillRequests[dir]) {
+        if (this.pendingFillRequests[dir]) {
             debuglog("Already a "+dir+" fill in progress - not starting another");
             return;
         }
@@ -436,7 +463,7 @@ export default class ScrollPanel extends React.Component {
 
         // onFillRequest can end up calling us recursively (via onScroll
         // events) so make sure we set this before firing off the call.
-        this._pendingFillRequests[dir] = true;
+        this.pendingFillRequests[dir] = true;
 
         // wait 1ms before paginating, because otherwise
         // this will block the scroll event handler for +700ms
@@ -445,13 +472,13 @@ export default class ScrollPanel extends React.Component {
         return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
             return this.props.onFillRequest(backwards);
         }).finally(() => {
-            this._pendingFillRequests[dir] = false;
+            this.pendingFillRequests[dir] = false;
         }).then((hasMoreResults) => {
             if (this.unmounted) {
                 return;
             }
             // Unpaginate once filling is complete
-            this._checkUnfillState(!backwards);
+            this.checkUnfillState(!backwards);
 
             debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
             if (hasMoreResults) {
@@ -477,7 +504,7 @@ export default class ScrollPanel extends React.Component {
      *   the number of pixels the bottom of the tracked child is above the
      *   bottom of the scroll panel.
      */
-    getScrollState = () => this.scrollState;
+    public getScrollState = (): IScrollState => this.scrollState;
 
     /* reset the saved scroll state.
      *
@@ -491,35 +518,35 @@ export default class ScrollPanel extends React.Component {
      * no use if no children exist yet, or if you are about to replace the
      * child list.)
      */
-    resetScrollState = () => {
+    public resetScrollState = (): void => {
         this.scrollState = {
             stuckAtBottom: this.props.startAtBottom,
         };
-        this._bottomGrowth = 0;
-        this._pages = 0;
-        this._scrollTimeout = new Timer(100);
-        this._heightUpdateInProgress = false;
+        this.bottomGrowth = 0;
+        this.pages = 0;
+        this.scrollTimeout = new Timer(100);
+        this.heightUpdateInProgress = false;
     };
 
     /**
      * jump to the top of the content.
      */
-    scrollToTop = () => {
-        this._getScrollNode().scrollTop = 0;
-        this._saveScrollState();
+    public scrollToTop = (): void => {
+        this.getScrollNode().scrollTop = 0;
+        this.saveScrollState();
     };
 
     /**
      * jump to the bottom of the content.
      */
-    scrollToBottom = () => {
+    public scrollToBottom = (): void => {
         // the easiest way to make sure that the scroll state is correctly
         // saved is to do the scroll, then save the updated state. (Calculating
         // it ourselves is hard, and we can't rely on an onScroll callback
         // happening, since there may be no user-visible change here).
-        const sn = this._getScrollNode();
+        const sn = this.getScrollNode();
         sn.scrollTop = sn.scrollHeight;
-        this._saveScrollState();
+        this.saveScrollState();
     };
 
     /**
@@ -527,18 +554,18 @@ export default class ScrollPanel extends React.Component {
      *
      * @param {number} mult: -1 to page up, +1 to page down
      */
-    scrollRelative = mult => {
-        const scrollNode = this._getScrollNode();
+    public scrollRelative = (mult: number): void => {
+        const scrollNode = this.getScrollNode();
         const delta = mult * scrollNode.clientHeight * 0.9;
         scrollNode.scrollBy(0, delta);
-        this._saveScrollState();
+        this.saveScrollState();
     };
 
     /**
      * Scroll up/down in response to a scroll key
      * @param {object} ev the keyboard event
      */
-    handleScrollKey = ev => {
+    public handleScrollKey = (ev: KeyboardEvent) => {
         let isScrolling = false;
         const roomAction = getKeyBindingsManager().getRoomAction(ev);
         switch (roomAction) {
@@ -575,17 +602,17 @@ export default class ScrollPanel extends React.Component {
      * node (specifically, the bottom of it) will be positioned. If omitted, it
      * defaults to 0.
      */
-    scrollToToken = (scrollToken, pixelOffset, offsetBase) => {
+    public scrollToToken = (scrollToken: string, pixelOffset: number, offsetBase: number): void => {
         pixelOffset = pixelOffset || 0;
         offsetBase = offsetBase || 0;
 
-        // set the trackedScrollToken so we can get the node through _getTrackedNode
+        // set the trackedScrollToken so we can get the node through getTrackedNode
         this.scrollState = {
             stuckAtBottom: false,
             trackedScrollToken: scrollToken,
         };
-        const trackedNode = this._getTrackedNode();
-        const scrollNode = this._getScrollNode();
+        const trackedNode = this.getTrackedNode();
+        const scrollNode = this.getScrollNode();
         if (trackedNode) {
             // set the scrollTop to the position we want.
             // note though, that this might not succeed if the combination of offsetBase and pixelOffset
@@ -595,34 +622,34 @@ export default class ScrollPanel extends React.Component {
             // enough so it ends up in the top of the viewport.
             debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
             scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
-            this._saveScrollState();
+            this.saveScrollState();
         }
     };
 
-    _saveScrollState() {
+    private saveScrollState(): void {
         if (this.props.stickyBottom && this.isAtBottom()) {
             this.scrollState = { stuckAtBottom: true };
             debuglog("saved stuckAtBottom state");
             return;
         }
 
-        const scrollNode = this._getScrollNode();
+        const scrollNode = this.getScrollNode();
         const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
 
-        const itemlist = this._itemlist.current;
+        const itemlist = this.itemlist.current;
         const messages = itemlist.children;
         let node = null;
 
         // TODO: do a binary search here, as items are sorted by offsetTop
         // loop backwards, from bottom-most message (as that is the most common case)
-        for (let i = messages.length-1; i >= 0; --i) {
-            if (!messages[i].dataset.scrollTokens) {
+        for (let i = messages.length - 1; i >= 0; --i) {
+            if (!(messages[i] as HTMLElement).dataset.scrollTokens) {
                 continue;
             }
             node = messages[i];
             // break at the first message (coming from the bottom)
             // that has it's offsetTop above the bottom of the viewport.
-            if (this._topFromBottom(node) > viewportBottom) {
+            if (this.topFromBottom(node) > viewportBottom) {
                 // Use this node as the scrollToken
                 break;
             }
@@ -634,7 +661,7 @@ export default class ScrollPanel extends React.Component {
         }
         const scrollToken = node.dataset.scrollTokens.split(',')[0];
         debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
-        const bottomOffset = this._topFromBottom(node);
+        const bottomOffset = this.topFromBottom(node);
         this.scrollState = {
             stuckAtBottom: false,
             trackedNode: node,
@@ -644,35 +671,35 @@ export default class ScrollPanel extends React.Component {
         };
     }
 
-    async _restoreSavedScrollState() {
+    private async restoreSavedScrollState(): Promise<void> {
         const scrollState = this.scrollState;
 
         if (scrollState.stuckAtBottom) {
-            const sn = this._getScrollNode();
+            const sn = this.getScrollNode();
             if (sn.scrollTop !== sn.scrollHeight) {
                 sn.scrollTop = sn.scrollHeight;
             }
         } else if (scrollState.trackedScrollToken) {
-            const itemlist = this._itemlist.current;
-            const trackedNode = this._getTrackedNode();
+            const itemlist = this.itemlist.current;
+            const trackedNode = this.getTrackedNode();
             if (trackedNode) {
-                const newBottomOffset = this._topFromBottom(trackedNode);
+                const newBottomOffset = this.topFromBottom(trackedNode);
                 const bottomDiff = newBottomOffset - scrollState.bottomOffset;
-                this._bottomGrowth += bottomDiff;
+                this.bottomGrowth += bottomDiff;
                 scrollState.bottomOffset = newBottomOffset;
-                const newHeight = `${this._getListHeight()}px`;
+                const newHeight = `${this.getListHeight()}px`;
                 if (itemlist.style.height !== newHeight) {
                     itemlist.style.height = newHeight;
                 }
                 debuglog("balancing height because messages below viewport grew by", bottomDiff);
             }
         }
-        if (!this._heightUpdateInProgress) {
-            this._heightUpdateInProgress = true;
+        if (!this.heightUpdateInProgress) {
+            this.heightUpdateInProgress = true;
             try {
-                await this._updateHeight();
+                await this.updateHeight();
             } finally {
-                this._heightUpdateInProgress = false;
+                this.heightUpdateInProgress = false;
             }
         } else {
             debuglog("not updating height because request already in progress");
@@ -680,11 +707,11 @@ export default class ScrollPanel extends React.Component {
     }
 
     // need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
-    async _updateHeight() {
+    private async updateHeight(): Promise<void> {
         // wait until user has stopped scrolling
-        if (this._scrollTimeout.isRunning()) {
+        if (this.scrollTimeout.isRunning()) {
             debuglog("updateHeight waiting for scrolling to end ... ");
-            await this._scrollTimeout.finished();
+            await this.scrollTimeout.finished();
         } else {
             debuglog("updateHeight getting straight to business, no scrolling going on.");
         }
@@ -694,14 +721,14 @@ export default class ScrollPanel extends React.Component {
             return;
         }
 
-        const sn = this._getScrollNode();
-        const itemlist = this._itemlist.current;
-        const contentHeight = this._getMessagesHeight();
+        const sn = this.getScrollNode();
+        const itemlist = this.itemlist.current;
+        const contentHeight = this.getMessagesHeight();
         const minHeight = sn.clientHeight;
         const height = Math.max(minHeight, contentHeight);
-        this._pages = Math.ceil(height / PAGE_SIZE);
-        this._bottomGrowth = 0;
-        const newHeight = `${this._getListHeight()}px`;
+        this.pages = Math.ceil(height / PAGE_SIZE);
+        this.bottomGrowth = 0;
+        const newHeight = `${this.getListHeight()}px`;
 
         const scrollState = this.scrollState;
         if (scrollState.stuckAtBottom) {
@@ -713,7 +740,7 @@ export default class ScrollPanel extends React.Component {
             }
             debuglog("updateHeight to", newHeight);
         } else if (scrollState.trackedScrollToken) {
-            const trackedNode = this._getTrackedNode();
+            const trackedNode = this.getTrackedNode();
             // if the timeline has been reloaded
             // this can be called before scrollToBottom or whatever has been called
             // so don't do anything if the node has disappeared from
@@ -735,17 +762,17 @@ export default class ScrollPanel extends React.Component {
         }
     }
 
-    _getTrackedNode() {
+    private getTrackedNode(): HTMLElement {
         const scrollState = this.scrollState;
         const trackedNode = scrollState.trackedNode;
 
         if (!trackedNode || !trackedNode.parentElement) {
             let node;
-            const messages = this._itemlist.current.children;
+            const messages = this.itemlist.current.children;
             const scrollToken = scrollState.trackedScrollToken;
 
             for (let i = messages.length-1; i >= 0; --i) {
-                const m = messages[i];
+                const m = messages[i] as HTMLElement;
                 // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
                 // There might only be one scroll token
                 if (m.dataset.scrollTokens &&
@@ -768,45 +795,45 @@ export default class ScrollPanel extends React.Component {
         return scrollState.trackedNode;
     }
 
-    _getListHeight() {
-        return this._bottomGrowth + (this._pages * PAGE_SIZE);
+    private getListHeight(): number {
+        return this.bottomGrowth + (this.pages * PAGE_SIZE);
     }
 
-    _getMessagesHeight() {
-        const itemlist = this._itemlist.current;
-        const lastNode = itemlist.lastElementChild;
+    private getMessagesHeight(): number {
+        const itemlist = this.itemlist.current;
+        const lastNode = itemlist.lastElementChild as HTMLElement;
         const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
-        const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0;
+        const firstNodeTop = itemlist.firstElementChild ? (itemlist.firstElementChild as HTMLElement).offsetTop : 0;
         // 18 is itemlist padding
         return lastNodeBottom - firstNodeTop + (18 * 2);
     }
 
-    _topFromBottom(node) {
+    private topFromBottom(node: HTMLElement): number {
         // current capped height - distance from top = distance from bottom of container to top of tracked element
-        return this._itemlist.current.clientHeight - node.offsetTop;
+        return this.itemlist.current.clientHeight - node.offsetTop;
     }
 
     /* get the DOM node which has the scrollTop property we care about for our
      * message panel.
      */
-    _getScrollNode() {
+    private getScrollNode(): HTMLDivElement {
         if (this.unmounted) {
             // this shouldn't happen, but when it does, turn the NPE into
             // something more meaningful.
-            throw new Error("ScrollPanel._getScrollNode called when unmounted");
+            throw new Error("ScrollPanel.getScrollNode called when unmounted");
         }
 
-        if (!this._divScroll) {
+        if (!this.divScroll) {
             // Likewise, we should have the ref by this point, but if not
             // turn the NPE into something meaningful.
-            throw new Error("ScrollPanel._getScrollNode called before AutoHideScrollbar ref collected");
+            throw new Error("ScrollPanel.getScrollNode called before AutoHideScrollbar ref collected");
         }
 
-        return this._divScroll;
+        return this.divScroll;
     }
 
-    _collectScroll = divScroll => {
-        this._divScroll = divScroll;
+    private collectScroll = (divScroll: HTMLDivElement) => {
+        this.divScroll = divScroll;
     };
 
     /**
@@ -814,15 +841,15 @@ export default class ScrollPanel extends React.Component {
     anything below it changes, by calling updatePreventShrinking, to keep
     the same minimum bottom offset, effectively preventing the timeline to shrink.
     */
-    preventShrinking = () => {
-        const messageList = this._itemlist.current;
+    public preventShrinking = (): void => {
+        const messageList = this.itemlist.current;
         const tiles = messageList && messageList.children;
         if (!messageList) {
             return;
         }
         let lastTileNode;
         for (let i = tiles.length - 1; i >= 0; i--) {
-            const node = tiles[i];
+            const node = tiles[i] as HTMLElement;
             if (node.dataset.scrollTokens) {
                 lastTileNode = node;
                 break;
@@ -841,8 +868,8 @@ export default class ScrollPanel extends React.Component {
     };
 
     /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
-    clearPreventShrinking = () => {
-        const messageList = this._itemlist.current;
+    public clearPreventShrinking = (): void => {
+        const messageList = this.itemlist.current;
         const balanceElement = messageList && messageList.parentElement;
         if (balanceElement) balanceElement.style.paddingBottom = null;
         this.preventShrinkingState = null;
@@ -857,11 +884,11 @@ export default class ScrollPanel extends React.Component {
     from the bottom of the marked tile grows larger than
     what it was when marking.
     */
-    updatePreventShrinking = () => {
+    public updatePreventShrinking = (): void => {
         if (this.preventShrinkingState) {
-            const sn = this._getScrollNode();
+            const sn = this.getScrollNode();
             const scrollState = this.scrollState;
-            const messageList = this._itemlist.current;
+            const messageList = this.itemlist.current;
             const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
             // element used to set paddingBottom to balance the typing notifs disappearing
             const balanceElement = messageList.parentElement;
@@ -898,13 +925,15 @@ export default class ScrollPanel extends React.Component {
         // list-style-type: none; is no longer a list
         return (
             <AutoHideScrollbar
-                wrappedRef={this._collectScroll}
+                wrappedRef={this.collectScroll}
                 onScroll={this.onScroll}
                 onWheel={this.props.onUserScroll}
-                className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
+                className={`mx_ScrollPanel ${this.props.className}`}
+                style={this.props.style}
+            >
                 { this.props.fixedChildren }
                 <div className="mx_RoomView_messageListWrapper">
-                    <ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
+                    <ol ref={this.itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
                         { this.props.children }
                     </ol>
                 </div>
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.tsx
similarity index 75%
rename from src/components/structures/TimelinePanel.js
rename to src/components/structures/TimelinePanel.tsx
index 03d0b5c6d7..c2e7a6f346 100644
--- a/src/components/structures/TimelinePanel.js
+++ b/src/components/structures/TimelinePanel.tsx
@@ -1,8 +1,5 @@
 /*
-Copyright 2016 OpenMarket Ltd
-Copyright 2017 Vector Creations Ltd
-Copyright 2019 New Vector Ltd
-Copyright 2019-2020 The Matrix.org Foundation C.I.C.
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -17,13 +14,16 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import SettingsStore from "../../settings/SettingsStore";
-import { LayoutPropType } from "../../settings/Layout";
-import React, { createRef } from 'react';
+import React, { createRef, ReactNode, SyntheticEvent } from 'react';
 import ReactDOM from "react-dom";
-import PropTypes from 'prop-types';
+import { Room } from "matrix-js-sdk/src/models/room";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { TimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
 import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
 import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
+
+import SettingsStore from "../../settings/SettingsStore";
+import { Layout } from "../../settings/Layout";
 import { _t } from '../../languageHandler';
 import { MatrixClientPeg } from "../../MatrixClientPeg";
 import RoomContext from "../../contexts/RoomContext";
@@ -35,11 +35,19 @@ import { Key } from '../../Keyboard';
 import Timer from '../../utils/Timer';
 import shouldHideEvent from '../../shouldHideEvent';
 import EditorStateTransfer from '../../utils/EditorStateTransfer';
-import { haveTileForEvent } from "../views/rooms/EventTile";
+import { haveTileForEvent, TileShape } from "../views/rooms/EventTile";
 import { UIFeature } from "../../settings/UIFeature";
 import { replaceableComponent } from "../../utils/replaceableComponent";
 import { arrayFastClone } from "../../utils/arrays";
 import { Action } from "../../dispatcher/actions";
+import MessagePanel from "./MessagePanel";
+import { SyncState } from 'matrix-js-sdk/src/sync.api';
+import { IScrollState } from "./ScrollPanel";
+import { ActionPayload } from "../../dispatcher/payloads";
+import { EventType } from 'matrix-js-sdk/src/@types/event';
+import ResizeNotifier from "../../utils/ResizeNotifier";
+import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
+import Spinner from "../views/elements/Spinner";
 
 const PAGINATE_SIZE = 20;
 const INITIAL_SIZE = 20;
@@ -47,90 +55,159 @@ const READ_RECEIPT_INTERVAL_MS = 500;
 
 const DEBUG = false;
 
-let debuglog = function() {};
+let debuglog = function(...s: any[]) {};
 if (DEBUG) {
     // using bind means that we get to keep useful line numbers in the console
     debuglog = console.log.bind(console);
 }
 
+interface IProps {
+    // The js-sdk EventTimelineSet object for the timeline sequence we are
+    // representing.  This may or may not have a room, depending on what it's
+    // a timeline representing.  If it has a room, we maintain RRs etc for
+    // that room.
+    timelineSet: TimelineSet;
+    showReadReceipts?: boolean;
+    // Enable managing RRs and RMs. These require the timelineSet to have a room.
+    manageReadReceipts?: boolean;
+    sendReadReceiptOnLoad?: boolean;
+    manageReadMarkers?: boolean;
+
+    // true to give the component a 'display: none' style.
+    hidden?: boolean;
+
+    // ID of an event to highlight. If undefined, no event will be highlighted.
+    // typically this will be either 'eventId' or undefined.
+    highlightedEventId?: string;
+
+    // id of an event to jump to. If not given, will go to the end of the live timeline.
+    eventId?: string;
+
+    // where to position the event given by eventId, in pixels from the bottom of the viewport.
+    // If not given, will try to put the event half way down the viewport.
+    eventPixelOffset?: number;
+
+    // Should we show URL Previews
+    showUrlPreview?: boolean;
+
+    // maximum number of events to show in a timeline
+    timelineCap?: number;
+
+    // classname to use for the messagepanel
+    className?: string;
+
+    // shape property to be passed to EventTiles
+    tileShape?: TileShape;
+
+    // placeholder to use if the timeline is empty
+    empty?: ReactNode;
+
+    // whether to show reactions for an event
+    showReactions?: boolean;
+
+    // which layout to use
+    layout?: Layout;
+
+    // whether to always show timestamps for an event
+    alwaysShowTimestamps?: boolean;
+
+    resizeNotifier?: ResizeNotifier;
+    editState?: EditorStateTransfer;
+    permalinkCreator?: RoomPermalinkCreator;
+    membersLoaded?: boolean;
+
+    // callback which is called when the panel is scrolled.
+    onScroll?(event: Event): void;
+
+    // callback which is called when the user interacts with the room timeline
+    onUserScroll?(event: SyntheticEvent): void;
+
+    // callback which is called when the read-up-to mark is updated.
+    onReadMarkerUpdated?(): void;
+
+    // callback which is called when we wish to paginate the timeline window.
+    onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>,
+}
+
+interface IState {
+    events: MatrixEvent[];
+    liveEvents: MatrixEvent[];
+    // track whether our room timeline is loading
+    timelineLoading: boolean;
+
+    // the index of the first event that is to be shown
+    firstVisibleEventIndex: number;
+
+    // canBackPaginate == false may mean:
+    //
+    // * we haven't (successfully) loaded the timeline yet, or:
+    //
+    // * we have got to the point where the room was created, or:
+    //
+    // * the server indicated that there were no more visible events
+    //  (normally implying we got to the start of the room), or:
+    //
+    // * we gave up asking the server for more events
+    canBackPaginate: boolean;
+
+    // canForwardPaginate == false may mean:
+    //
+    // * we haven't (successfully) loaded the timeline yet
+    //
+    // * we have got to the end of time and are now tracking the live
+    //   timeline, or:
+    //
+    // * the server indicated that there were no more visible events
+    //   (not sure if this ever happens when we're not at the live
+    //   timeline), or:
+    //
+    // * we are looking at some historical point, but gave up asking
+    //   the server for more events
+    canForwardPaginate: boolean;
+
+    // start with the read-marker visible, so that we see its animated
+    // disappearance when switching into the room.
+    readMarkerVisible: boolean;
+
+    readMarkerEventId: string;
+
+    backPaginating: boolean;
+    forwardPaginating: boolean;
+
+    // cache of matrixClient.getSyncState() (but from the 'sync' event)
+    clientSyncState: SyncState;
+
+    // should the event tiles have twelve hour times
+    isTwelveHour: boolean;
+
+    // always show timestamps on event tiles?
+    alwaysShowTimestamps: boolean;
+
+    // how long to show the RM for when it's visible in the window
+    readMarkerInViewThresholdMs: number;
+
+    // how long to show the RM for when it's scrolled off-screen
+    readMarkerOutOfViewThresholdMs: number;
+
+    editState?: EditorStateTransfer;
+}
+
+interface IEventIndexOpts {
+    ignoreOwn?: boolean;
+    allowPartial?: boolean;
+}
+
 /*
  * Component which shows the event timeline in a room view.
  *
  * Also responsible for handling and sending read receipts.
  */
 @replaceableComponent("structures.TimelinePanel")
-class TimelinePanel extends React.Component {
-    static propTypes = {
-        // The js-sdk EventTimelineSet object for the timeline sequence we are
-        // representing.  This may or may not have a room, depending on what it's
-        // a timeline representing.  If it has a room, we maintain RRs etc for
-        // that room.
-        timelineSet: PropTypes.object.isRequired,
-
-        showReadReceipts: PropTypes.bool,
-        // Enable managing RRs and RMs. These require the timelineSet to have a room.
-        manageReadReceipts: PropTypes.bool,
-        sendReadReceiptOnLoad: PropTypes.bool,
-        manageReadMarkers: PropTypes.bool,
-
-        // true to give the component a 'display: none' style.
-        hidden: PropTypes.bool,
-
-        // ID of an event to highlight. If undefined, no event will be highlighted.
-        // typically this will be either 'eventId' or undefined.
-        highlightedEventId: PropTypes.string,
-
-        // id of an event to jump to. If not given, will go to the end of the
-        // live timeline.
-        eventId: PropTypes.string,
-
-        // where to position the event given by eventId, in pixels from the
-        // bottom of the viewport. If not given, will try to put the event
-        // half way down the viewport.
-        eventPixelOffset: PropTypes.number,
-
-        // Should we show URL Previews
-        showUrlPreview: PropTypes.bool,
-
-        // callback which is called when the panel is scrolled.
-        onScroll: PropTypes.func,
-
-        // callback which is called when the user interacts with the room timeline
-        onUserScroll: PropTypes.func,
-
-        // callback which is called when the read-up-to mark is updated.
-        onReadMarkerUpdated: PropTypes.func,
-
-        // callback which is called when we wish to paginate the timeline
-        // window.
-        onPaginationRequest: PropTypes.func,
-
-        // maximum number of events to show in a timeline
-        timelineCap: PropTypes.number,
-
-        // classname to use for the messagepanel
-        className: PropTypes.string,
-
-        // shape property to be passed to EventTiles
-        tileShape: PropTypes.string,
-
-        // placeholder to use if the timeline is empty
-        empty: PropTypes.node,
-
-        // whether to show reactions for an event
-        showReactions: PropTypes.bool,
-
-        // which layout to use
-        layout: LayoutPropType,
-
-        // whether to always show timestamps for an event
-        alwaysShowTimestamps: PropTypes.bool,
-    }
-
+class TimelinePanel extends React.Component<IProps, IState> {
     static contextType = RoomContext;
 
     // a map from room id to read marker event timestamp
-    static roomReadMarkerTsMap = {};
+    static roomReadMarkerTsMap: Record<string, number> = {};
 
     static defaultProps = {
         // By default, disable the timelineCap in favour of unpaginating based on
@@ -140,16 +217,21 @@ class TimelinePanel extends React.Component {
         sendReadReceiptOnLoad: true,
     };
 
-    constructor(props) {
-        super(props);
+    private lastRRSentEventId: string = undefined;
+    private lastRMSentEventId: string = undefined;
+
+    private readonly messagePanel = createRef<MessagePanel>();
+    private readonly dispatcherRef: string;
+    private timelineWindow?: TimelineWindow;
+    private unmounted = false;
+    private readReceiptActivityTimer: Timer;
+    private readMarkerActivityTimer: Timer;
+
+    constructor(props, context) {
+        super(props, context);
 
         debuglog("TimelinePanel: mounting");
 
-        this.lastRRSentEventId = undefined;
-        this.lastRMSentEventId = undefined;
-
-        this._messagePanel = createRef();
-
         // XXX: we could track RM per TimelineSet rather than per Room.
         // but for now we just do it per room for simplicity.
         let initialReadMarker = null;
@@ -158,82 +240,41 @@ class TimelinePanel extends React.Component {
             if (readmarker) {
                 initialReadMarker = readmarker.getContent().event_id;
             } else {
-                initialReadMarker = this._getCurrentReadReceipt();
+                initialReadMarker = this.getCurrentReadReceipt();
             }
         }
 
         this.state = {
             events: [],
             liveEvents: [],
-            timelineLoading: true, // track whether our room timeline is loading
-
-            // the index of the first event that is to be shown
+            timelineLoading: true,
             firstVisibleEventIndex: 0,
-
-            // canBackPaginate == false may mean:
-            //
-            // * we haven't (successfully) loaded the timeline yet, or:
-            //
-            // * we have got to the point where the room was created, or:
-            //
-            // * the server indicated that there were no more visible events
-            //  (normally implying we got to the start of the room), or:
-            //
-            // * we gave up asking the server for more events
             canBackPaginate: false,
-
-            // canForwardPaginate == false may mean:
-            //
-            // * we haven't (successfully) loaded the timeline yet
-            //
-            // * we have got to the end of time and are now tracking the live
-            //   timeline, or:
-            //
-            // * the server indicated that there were no more visible events
-            //   (not sure if this ever happens when we're not at the live
-            //   timeline), or:
-            //
-            // * we are looking at some historical point, but gave up asking
-            //   the server for more events
             canForwardPaginate: false,
-
-            // start with the read-marker visible, so that we see its animated
-            // disappearance when switching into the room.
             readMarkerVisible: true,
-
             readMarkerEventId: initialReadMarker,
-
             backPaginating: false,
             forwardPaginating: false,
-
-            // cache of matrixClient.getSyncState() (but from the 'sync' event)
             clientSyncState: MatrixClientPeg.get().getSyncState(),
-
-            // should the event tiles have twelve hour times
             isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"),
-
-            // always show timestamps on event tiles?
             alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
-
-            // how long to show the RM for when it's visible in the window
             readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
-
-            // how long to show the RM for when it's scrolled off-screen
             readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
         };
 
         this.dispatcherRef = dis.register(this.onAction);
-        MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
-        MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset);
-        MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction);
+        const cli = MatrixClientPeg.get();
+        cli.on("Room.timeline", this.onRoomTimeline);
+        cli.on("Room.timelineReset", this.onRoomTimelineReset);
+        cli.on("Room.redaction", this.onRoomRedaction);
         // same event handler as Room.redaction as for both we just do forceUpdate
-        MatrixClientPeg.get().on("Room.redactionCancelled", this.onRoomRedaction);
-        MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
-        MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
-        MatrixClientPeg.get().on("Room.accountData", this.onAccountData);
-        MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
-        MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced);
-        MatrixClientPeg.get().on("sync", this.onSync);
+        cli.on("Room.redactionCancelled", this.onRoomRedaction);
+        cli.on("Room.receipt", this.onRoomReceipt);
+        cli.on("Room.localEchoUpdated", this.onLocalEchoUpdated);
+        cli.on("Room.accountData", this.onAccountData);
+        cli.on("Event.decrypted", this.onEventDecrypted);
+        cli.on("Event.replaced", this.onEventReplaced);
+        cli.on("sync", this.onSync);
     }
 
     // TODO: [REACT-WARNING] Move into constructor
@@ -246,7 +287,7 @@ class TimelinePanel extends React.Component {
             this.updateReadMarkerOnUserActivity();
         }
 
-        this._initTimeline(this.props);
+        this.initTimeline(this.props);
     }
 
     // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@@ -272,7 +313,7 @@ class TimelinePanel extends React.Component {
         if (differentEventId || differentHighlightedEventId) {
             console.log("TimelinePanel switching to eventId " + newProps.eventId +
                         " (was " + this.props.eventId + ")");
-            return this._initTimeline(newProps);
+            return this.initTimeline(newProps);
         }
     }
 
@@ -282,13 +323,13 @@ class TimelinePanel extends React.Component {
         //
         // (We could use isMounted, but facebook have deprecated that.)
         this.unmounted = true;
-        if (this._readReceiptActivityTimer) {
-            this._readReceiptActivityTimer.abort();
-            this._readReceiptActivityTimer = null;
+        if (this.readReceiptActivityTimer) {
+            this.readReceiptActivityTimer.abort();
+            this.readReceiptActivityTimer = null;
         }
-        if (this._readMarkerActivityTimer) {
-            this._readMarkerActivityTimer.abort();
-            this._readMarkerActivityTimer = null;
+        if (this.readMarkerActivityTimer) {
+            this.readMarkerActivityTimer.abort();
+            this.readMarkerActivityTimer = null;
         }
 
         dis.unregister(this.dispatcherRef);
@@ -308,7 +349,7 @@ class TimelinePanel extends React.Component {
         }
     }
 
-    onMessageListUnfillRequest = (backwards, scrollToken) => {
+    private onMessageListUnfillRequest = (backwards: boolean, scrollToken: string): void => {
         // If backwards, unpaginate from the back (i.e. the start of the timeline)
         const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
         debuglog("TimelinePanel: unpaginating events in direction", dir);
@@ -327,21 +368,30 @@ class TimelinePanel extends React.Component {
 
         if (count > 0) {
             debuglog("TimelinePanel: Unpaginating", count, "in direction", dir);
-            this._timelineWindow.unpaginate(count, backwards);
+            this.timelineWindow.unpaginate(count, backwards);
 
-            // We can now paginate in the unpaginated direction
-            const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate';
-            const { events, liveEvents, firstVisibleEventIndex } = this._getEvents();
-            this.setState({
-                [canPaginateKey]: true,
+            const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
+            const newState: Partial<IState> = {
                 events,
                 liveEvents,
                 firstVisibleEventIndex,
-            });
+            }
+
+            // We can now paginate in the unpaginated direction
+            if (backwards) {
+                newState.canBackPaginate = true;
+            } else {
+                newState.canForwardPaginate = true;
+            }
+            this.setState<null>(newState);
         }
     };
 
-    onPaginationRequest = (timelineWindow, direction, size) => {
+    private onPaginationRequest = (
+        timelineWindow: TimelineWindow,
+        direction: string,
+        size: number,
+    ): Promise<boolean> => {
         if (this.props.onPaginationRequest) {
             return this.props.onPaginationRequest(timelineWindow, direction, size);
         } else {
@@ -350,8 +400,8 @@ class TimelinePanel extends React.Component {
     };
 
     // set off a pagination request.
-    onMessageListFillRequest = backwards => {
-        if (!this._shouldPaginate()) return Promise.resolve(false);
+    private onMessageListFillRequest = (backwards: boolean): Promise<boolean> => {
+        if (!this.shouldPaginate()) return Promise.resolve(false);
 
         const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
         const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate';
@@ -362,9 +412,9 @@ class TimelinePanel extends React.Component {
             return Promise.resolve(false);
         }
 
-        if (!this._timelineWindow.canPaginate(dir)) {
+        if (!this.timelineWindow.canPaginate(dir)) {
             debuglog("TimelinePanel: can't", dir, "paginate any further");
-            this.setState({[canPaginateKey]: false});
+            this.setState<null>({ [canPaginateKey]: false });
             return Promise.resolve(false);
         }
 
@@ -374,15 +424,15 @@ class TimelinePanel extends React.Component {
         }
 
         debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards);
-        this.setState({[paginatingKey]: true});
+        this.setState<null>({ [paginatingKey]: true });
 
-        return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then((r) => {
+        return this.onPaginationRequest(this.timelineWindow, dir, PAGINATE_SIZE).then((r) => {
             if (this.unmounted) { return; }
 
             debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);
 
-            const { events, liveEvents, firstVisibleEventIndex } = this._getEvents();
-            const newState = {
+            const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
+            const newState: Partial<IState> = {
                 [paginatingKey]: false,
                 [canPaginateKey]: r,
                 events,
@@ -395,7 +445,7 @@ class TimelinePanel extends React.Component {
             const otherDirection = backwards ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS;
             const canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate';
             if (!this.state[canPaginateOtherWayKey] &&
-                    this._timelineWindow.canPaginate(otherDirection)) {
+                    this.timelineWindow.canPaginate(otherDirection)) {
                 debuglog('TimelinePanel: can now', otherDirection, 'paginate again');
                 newState[canPaginateOtherWayKey] = true;
             }
@@ -406,9 +456,9 @@ class TimelinePanel extends React.Component {
             // has in memory because we never gave the component a chance to scroll
             // itself into the right place
             return new Promise((resolve) => {
-                this.setState(newState, () => {
+                this.setState<null>(newState, () => {
                     // we can continue paginating in the given direction if:
-                    // - _timelineWindow.paginate says we can
+                    // - timelineWindow.paginate says we can
                     // - we're paginating forwards, or we won't be trying to
                     //   paginate backwards past the first visible event
                     resolve(r && (!backwards || firstVisibleEventIndex === 0));
@@ -417,7 +467,7 @@ class TimelinePanel extends React.Component {
         });
     };
 
-    onMessageListScroll = e => {
+    private onMessageListScroll = e => {
         if (this.props.onScroll) {
             this.props.onScroll(e);
         }
@@ -428,18 +478,18 @@ class TimelinePanel extends React.Component {
             // it goes back off the top of the screen (presumably because the user
             // clicks on the 'jump to bottom' button), we need to re-enable it.
             if (rmPosition < 0) {
-                this.setState({readMarkerVisible: true});
+                this.setState({ readMarkerVisible: true });
             }
 
             // if read marker position goes between 0 and -1/1,
             // (and user is active), switch timeout
-            const timeout = this._readMarkerTimeout(rmPosition);
+            const timeout = this.readMarkerTimeout(rmPosition);
             // NO-OP when timeout already has set to the given value
-            this._readMarkerActivityTimer.changeTimeout(timeout);
+            this.readMarkerActivityTimer.changeTimeout(timeout);
         }
     };
 
-    onAction = payload => {
+    private onAction = (payload: ActionPayload): void => {
         switch (payload.action) {
             case "ignore_state_changed":
                 this.forceUpdate();
@@ -447,9 +497,9 @@ class TimelinePanel extends React.Component {
 
             case "edit_event": {
                 const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
-                this.setState({editState}, () => {
-                    if (payload.event && this._messagePanel.current) {
-                        this._messagePanel.current.scrollToEventIfNeeded(
+                this.setState({ editState }, () => {
+                    if (payload.event && this.messagePanel.current) {
+                        this.messagePanel.current.scrollToEventIfNeeded(
                             payload.event.getId(),
                         );
                     }
@@ -479,7 +529,16 @@ class TimelinePanel extends React.Component {
         }
     };
 
-    onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
+    private onRoomTimeline = (
+        ev: MatrixEvent,
+        room: Room,
+        toStartOfTimeline: boolean,
+        removed: boolean,
+        data: {
+            timeline: EventTimeline;
+            liveEvent?: boolean;
+        },
+    ): void => {
         // ignore events for other timeline sets
         if (data.timeline.getTimelineSet() !== this.props.timelineSet) return;
 
@@ -487,9 +546,9 @@ class TimelinePanel extends React.Component {
         // updates from pagination will happen when the paginate completes.
         if (toStartOfTimeline || !data || !data.liveEvent) return;
 
-        if (!this._messagePanel.current) return;
+        if (!this.messagePanel.current) return;
 
-        if (!this._messagePanel.current.getScrollState().stuckAtBottom) {
+        if (!this.messagePanel.current.getScrollState().stuckAtBottom) {
             // we won't load this event now, because we don't want to push any
             // events off the other end of the timeline. But we need to note
             // that we can now paginate.
@@ -506,13 +565,13 @@ 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(() => {
+        this.timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => {
             if (this.unmounted) { return; }
 
-            const { events, liveEvents, firstVisibleEventIndex } = this._getEvents();
+            const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
             const lastLiveEvent = liveEvents[liveEvents.length - 1];
 
-            const updatedState = {
+            const updatedState: Partial<IState> = {
                 events,
                 liveEvents,
                 firstVisibleEventIndex,
@@ -537,15 +596,15 @@ class TimelinePanel extends React.Component {
                     // 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);
+                    this.setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true);
                     updatedState.readMarkerVisible = false;
                     updatedState.readMarkerEventId = lastLiveEvent.getId();
                     callRMUpdated = true;
                 }
             }
 
-            this.setState(updatedState, () => {
-                this._messagePanel.current.updateTimelineMinHeight();
+            this.setState<null>(updatedState, () => {
+                this.messagePanel.current.updateTimelineMinHeight();
                 if (callRMUpdated) {
                     this.props.onReadMarkerUpdated();
                 }
@@ -553,17 +612,17 @@ class TimelinePanel extends React.Component {
         });
     };
 
-    onRoomTimelineReset = (room, timelineSet) => {
+    private onRoomTimelineReset = (room: Room, timelineSet: TimelineSet): void => {
         if (timelineSet !== this.props.timelineSet) return;
 
-        if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) {
-            this._loadTimeline();
+        if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) {
+            this.loadTimeline();
         }
     };
 
-    canResetTimeline = () => this._messagePanel.current && this._messagePanel.current.isAtBottom();
+    public canResetTimeline = () => this.messagePanel?.current.isAtBottom();
 
-    onRoomRedaction = (ev, room) => {
+    private onRoomRedaction = (ev: MatrixEvent, room: Room): void => {
         if (this.unmounted) return;
 
         // ignore events for other rooms
@@ -574,7 +633,7 @@ class TimelinePanel extends React.Component {
         this.forceUpdate();
     };
 
-    onEventReplaced = (replacedEvent, room) => {
+    private onEventReplaced = (replacedEvent: MatrixEvent, room: Room): void => {
         if (this.unmounted) return;
 
         // ignore events for other rooms
@@ -585,7 +644,7 @@ class TimelinePanel extends React.Component {
         this.forceUpdate();
     };
 
-    onRoomReceipt = (ev, room) => {
+    private onRoomReceipt = (ev: MatrixEvent, room: Room): void => {
         if (this.unmounted) return;
 
         // ignore events for other rooms
@@ -594,22 +653,22 @@ class TimelinePanel extends React.Component {
         this.forceUpdate();
     };
 
-    onLocalEchoUpdated = (ev, room, oldEventId) => {
+    private onLocalEchoUpdated = (ev: MatrixEvent, room: Room, oldEventId: string): void => {
         if (this.unmounted) return;
 
         // ignore events for other rooms
         if (room !== this.props.timelineSet.room) return;
 
-        this._reloadEvents();
+        this.reloadEvents();
     };
 
-    onAccountData = (ev, room) => {
+    private onAccountData = (ev: MatrixEvent, room: Room): void => {
         if (this.unmounted) return;
 
         // ignore events for other rooms
         if (room !== this.props.timelineSet.room) return;
 
-        if (ev.getType() !== "m.fully_read") return;
+        if (ev.getType() !== EventType.FullyRead) return;
 
         // XXX: roomReadMarkerTsMap not updated here so it is now inconsistent. Replace
         // this mechanism of determining where the RM is relative to the view-port with
@@ -619,7 +678,7 @@ class TimelinePanel extends React.Component {
         }, this.props.onReadMarkerUpdated);
     };
 
-    onEventDecrypted = ev => {
+    private onEventDecrypted = (ev: MatrixEvent): void => {
         // Can be null for the notification timeline, etc.
         if (!this.props.timelineSet.room) return;
 
@@ -634,46 +693,46 @@ class TimelinePanel extends React.Component {
         }
     };
 
-    onSync = (state, prevState, data) => {
-        this.setState({clientSyncState: state});
+    private onSync = (clientSyncState: SyncState, prevState: SyncState, data: object): void => {
+        this.setState({ clientSyncState });
     };
 
-    _readMarkerTimeout(readMarkerPosition) {
+    private readMarkerTimeout(readMarkerPosition: number): number {
         return readMarkerPosition === 0 ?
             this.state.readMarkerInViewThresholdMs :
             this.state.readMarkerOutOfViewThresholdMs;
     }
 
-    async updateReadMarkerOnUserActivity() {
-        const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition());
-        this._readMarkerActivityTimer = new Timer(initialTimeout);
+    private async updateReadMarkerOnUserActivity(): Promise<void> {
+        const initialTimeout = this.readMarkerTimeout(this.getReadMarkerPosition());
+        this.readMarkerActivityTimer = new Timer(initialTimeout);
 
-        while (this._readMarkerActivityTimer) { //unset on unmount
-            UserActivity.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer);
+        while (this.readMarkerActivityTimer) { //unset on unmount
+            UserActivity.sharedInstance().timeWhileActiveRecently(this.readMarkerActivityTimer);
             try {
-                await this._readMarkerActivityTimer.finished();
+                await this.readMarkerActivityTimer.finished();
             } catch (e) { continue; /* aborted */ }
             // outside of try/catch to not swallow errors
             this.updateReadMarker();
         }
     }
 
-    async updateReadReceiptOnUserActivity() {
-        this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS);
-        while (this._readReceiptActivityTimer) { //unset on unmount
-            UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer);
+    private async updateReadReceiptOnUserActivity(): Promise<void> {
+        this.readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS);
+        while (this.readReceiptActivityTimer) { //unset on unmount
+            UserActivity.sharedInstance().timeWhileActiveNow(this.readReceiptActivityTimer);
             try {
-                await this._readReceiptActivityTimer.finished();
+                await this.readReceiptActivityTimer.finished();
             } catch (e) { continue; /* aborted */ }
             // outside of try/catch to not swallow errors
             this.sendReadReceipt();
         }
     }
 
-    sendReadReceipt = () => {
+    private sendReadReceipt = (): void => {
         if (SettingsStore.getValue("lowBandwidth")) return;
 
-        if (!this._messagePanel.current) return;
+        if (!this.messagePanel.current) return;
         if (!this.props.manageReadReceipts) return;
         // This happens on user_activity_end which is delayed, and it's
         // very possible have logged out within that timeframe, so check
@@ -684,8 +743,8 @@ class TimelinePanel extends React.Component {
 
         let shouldSendRR = true;
 
-        const currentRREventId = this._getCurrentReadReceipt(true);
-        const currentRREventIndex = this._indexForEventId(currentRREventId);
+        const currentRREventId = this.getCurrentReadReceipt(true);
+        const currentRREventIndex = this.indexForEventId(currentRREventId);
         // We want to avoid sending out read receipts when we are looking at
         // events in the past which are before the latest RR.
         //
@@ -700,11 +759,11 @@ class TimelinePanel extends React.Component {
         // the user eventually hits the live timeline.
         //
         if (currentRREventId && currentRREventIndex === null &&
-                this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
+                this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
             shouldSendRR = false;
         }
 
-        const lastReadEventIndex = this._getLastDisplayedEventIndex({
+        const lastReadEventIndex = this.getLastDisplayedEventIndex({
             ignoreOwn: true,
         });
         if (lastReadEventIndex === null) {
@@ -778,7 +837,7 @@ class TimelinePanel extends React.Component {
 
     // if the read marker is on the screen, we can now assume we've caught up to the end
     // of the screen, so move the marker down to the bottom of the screen.
-    updateReadMarker = () => {
+    private updateReadMarker = (): void => {
         if (!this.props.manageReadMarkers) return;
         if (this.getReadMarkerPosition() === 1) {
             // the read marker is at an event below the viewport,
@@ -788,7 +847,7 @@ class TimelinePanel extends React.Component {
         // move the RM to *after* the message at the bottom of the screen. This
         // avoids a problem whereby we never advance the RM if there is a huge
         // message which doesn't fit on the screen.
-        const lastDisplayedIndex = this._getLastDisplayedEventIndex({
+        const lastDisplayedIndex = this.getLastDisplayedEventIndex({
             allowPartial: true,
         });
 
@@ -796,7 +855,7 @@ class TimelinePanel extends React.Component {
             return;
         }
         const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
-        this._setReadMarker(
+        this.setReadMarker(
             lastDisplayedEvent.getId(),
             lastDisplayedEvent.getTs(),
         );
@@ -815,13 +874,13 @@ class TimelinePanel extends React.Component {
 
 
     // advance the read marker past any events we sent ourselves.
-    _advanceReadMarkerPastMyEvents() {
+    private advanceReadMarkerPastMyEvents(): void {
         if (!this.props.manageReadMarkers) return;
 
-        // we call `_timelineWindow.getEvents()` rather than using
+        // we call `timelineWindow.getEvents()` rather than using
         // `this.state.liveEvents`, because React batches the update to the
         // latter, so it may not have been updated yet.
-        const events = this._timelineWindow.getEvents();
+        const events = this.timelineWindow.getEvents();
 
         // first find where the current RM is
         let i;
@@ -846,22 +905,22 @@ class TimelinePanel extends React.Component {
         i--;
 
         const ev = events[i];
-        this._setReadMarker(ev.getId(), ev.getTs());
+        this.setReadMarker(ev.getId(), ev.getTs());
     }
 
     /* jump down to the bottom of this room, where new events are arriving
      */
-    jumpToLiveTimeline = () => {
+    public jumpToLiveTimeline = (): void => {
         // if we can't forward-paginate the existing timeline, then there
         // is no point reloading it - just jump straight to the bottom.
         //
         // Otherwise, reload the timeline rather than trying to paginate
         // through all of space-time.
-        if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
-            this._loadTimeline();
+        if (this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
+            this.loadTimeline();
         } else {
-            if (this._messagePanel.current) {
-                this._messagePanel.current.scrollToBottom();
+            if (this.messagePanel.current) {
+                this.messagePanel.current.scrollToBottom();
             }
         }
     };
@@ -869,22 +928,22 @@ class TimelinePanel extends React.Component {
     /* scroll to show the read-up-to marker. We put it 1/3 of the way down
      * the container.
      */
-    jumpToReadMarker = () => {
+    public jumpToReadMarker = (): void => {
         if (!this.props.manageReadMarkers) return;
-        if (!this._messagePanel.current) return;
+        if (!this.messagePanel.current) return;
         if (!this.state.readMarkerEventId) return;
 
         // we may not have loaded the event corresponding to the read-marker
-        // into the _timelineWindow. In that case, attempts to scroll to it
+        // into the timelineWindow. In that case, attempts to scroll to it
         // will fail.
         //
         // a quick way to figure out if we've loaded the relevant event is
         // simply to check if the messagepanel knows where the read-marker is.
-        const ret = this._messagePanel.current.getReadMarkerPosition();
+        const ret = this.messagePanel.current.getReadMarkerPosition();
         if (ret !== null) {
             // The messagepanel knows where the RM is, so we must have loaded
             // the relevant event.
-            this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId,
+            this.messagePanel.current.scrollToEvent(this.state.readMarkerEventId,
                 0, 1/3);
             return;
         }
@@ -892,15 +951,15 @@ class TimelinePanel extends React.Component {
         // Looks like we haven't loaded the event corresponding to the read-marker.
         // As with jumpToLiveTimeline, we want to reload the timeline around the
         // read-marker.
-        this._loadTimeline(this.state.readMarkerEventId, 0, 1/3);
+        this.loadTimeline(this.state.readMarkerEventId, 0, 1/3);
     };
 
     /* update the read-up-to marker to match the read receipt
      */
-    forgetReadMarker = () => {
+    public forgetReadMarker = (): void => {
         if (!this.props.manageReadMarkers) return;
 
-        const rmId = this._getCurrentReadReceipt();
+        const rmId = this.getCurrentReadReceipt();
 
         // see if we know the timestamp for the rr event
         const tl = this.props.timelineSet.getTimelineForEvent(rmId);
@@ -912,17 +971,17 @@ class TimelinePanel extends React.Component {
             }
         }
 
-        this._setReadMarker(rmId, rmTs);
+        this.setReadMarker(rmId, rmTs);
     };
 
     /* return true if the content is fully scrolled down and we are
      * at the end of the live timeline.
      */
-    isAtEndOfLiveTimeline = () => {
-        return this._messagePanel.current
-            && this._messagePanel.current.isAtBottom()
-            && this._timelineWindow
-            && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
+    public isAtEndOfLiveTimeline = (): boolean => {
+        return this.messagePanel.current
+            && this.messagePanel.current.isAtBottom()
+            && this.timelineWindow
+            && !this.timelineWindow.canPaginate(EventTimeline.FORWARDS);
     }
 
 
@@ -931,9 +990,9 @@ class TimelinePanel extends React.Component {
      *
      * returns null if we are not mounted.
      */
-    getScrollState = () => {
-        if (!this._messagePanel.current) { return null; }
-        return this._messagePanel.current.getScrollState();
+    public getScrollState = (): IScrollState => {
+        if (!this.messagePanel.current) { return null; }
+        return this.messagePanel.current.getScrollState();
     };
 
     // returns one of:
@@ -942,11 +1001,11 @@ class TimelinePanel extends React.Component {
     //  -1: read marker is above the window
     //   0: read marker is visible
     //  +1: read marker is below the window
-    getReadMarkerPosition = () => {
+    public getReadMarkerPosition = (): number => {
         if (!this.props.manageReadMarkers) return null;
-        if (!this._messagePanel.current) return null;
+        if (!this.messagePanel.current) return null;
 
-        const ret = this._messagePanel.current.getReadMarkerPosition();
+        const ret = this.messagePanel.current.getReadMarkerPosition();
         if (ret !== null) {
             return ret;
         }
@@ -965,7 +1024,7 @@ class TimelinePanel extends React.Component {
         return null;
     };
 
-    canJumpToReadMarker = () => {
+    public canJumpToReadMarker = (): boolean => {
         // 1. Do not show jump bar if neither the RM nor the RR are set.
         // 3. We want to show the bar if the read-marker is off the top of the screen.
         // 4. Also, if pos === null, the event might not be paginated - show the unread bar
@@ -980,19 +1039,19 @@ class TimelinePanel extends React.Component {
      *
      * We pass it down to the scroll panel.
      */
-    handleScrollKey = ev => {
-        if (!this._messagePanel.current) { return; }
+    public handleScrollKey = ev => {
+        if (!this.messagePanel.current) { return; }
 
         // jump to the live timeline on ctrl-end, rather than the end of the
         // timeline window.
         if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.END) {
             this.jumpToLiveTimeline();
         } else {
-            this._messagePanel.current.handleScrollKey(ev);
+            this.messagePanel.current.handleScrollKey(ev);
         }
     };
 
-    _initTimeline(props) {
+    private initTimeline(props: IProps): void {
         const initialEvent = props.eventId;
         const pixelOffset = props.eventPixelOffset;
 
@@ -1003,7 +1062,7 @@ class TimelinePanel extends React.Component {
             offsetBase = 0.5;
         }
 
-        return this._loadTimeline(initialEvent, pixelOffset, offsetBase);
+        return this.loadTimeline(initialEvent, pixelOffset, offsetBase);
     }
 
     /**
@@ -1019,34 +1078,32 @@ class TimelinePanel extends React.Component {
      * @param {number?} offsetBase the reference point for the pixelOffset. 0
      *     means the top of the container, 1 means the bottom, and fractional
      *     values mean somewhere in the middle. If omitted, it defaults to 0.
-     *
-     * returns a promise which will resolve when the load completes.
      */
-    _loadTimeline(eventId, pixelOffset, offsetBase) {
-        this._timelineWindow = new TimelineWindow(
+    private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number): void {
+        this.timelineWindow = new TimelineWindow(
             MatrixClientPeg.get(), this.props.timelineSet,
             {windowLimit: this.props.timelineCap});
 
         const onLoaded = () => {
             // clear the timeline min-height when
             // (re)loading the timeline
-            if (this._messagePanel.current) {
-                this._messagePanel.current.onTimelineReset();
+            if (this.messagePanel.current) {
+                this.messagePanel.current.onTimelineReset();
             }
-            this._reloadEvents();
+            this.reloadEvents();
 
             // If we switched away from the room while there were pending
             // outgoing events, the read-marker will be before those events.
             // We need to skip over any which have subsequently been sent.
-            this._advanceReadMarkerPastMyEvents();
+            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
-                if (!this._messagePanel.current) {
+                if (!this.messagePanel.current) {
                     // this shouldn't happen - we know we're mounted because
                     // we're in a setState callback, and we know
                     // timelineLoading is now false, so render() should have
@@ -1056,10 +1113,10 @@ class TimelinePanel extends React.Component {
                     return;
                 }
                 if (eventId) {
-                    this._messagePanel.current.scrollToEvent(eventId, pixelOffset,
+                    this.messagePanel.current.scrollToEvent(eventId, pixelOffset,
                         offsetBase);
                 } else {
-                    this._messagePanel.current.scrollToBottom();
+                    this.messagePanel.current.scrollToBottom();
                 }
 
                 if (this.props.sendReadReceiptOnLoad) {
@@ -1121,10 +1178,10 @@ class TimelinePanel extends React.Component {
         if (timeline) {
             // This is a hot-path optimization by skipping a promise tick
             // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
-            this._timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time
+            this.timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time
             onLoaded();
         } else {
-            const prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
+            const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
             this.setState({
                 events: [],
                 liveEvents: [],
@@ -1139,17 +1196,17 @@ class TimelinePanel extends React.Component {
     // handle the completion of a timeline load or localEchoUpdate, by
     // reloading the events from the timelinewindow and pending event list into
     // the state.
-    _reloadEvents() {
+    private reloadEvents(): void {
         // we might have switched rooms since the load started - just bin
         // the results if so.
         if (this.unmounted) return;
 
-        this.setState(this._getEvents());
+        this.setState(this.getEvents());
     }
 
     // get the list of events from the timeline window and the pending event list
-    _getEvents() {
-        const events = this._timelineWindow.getEvents();
+    private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
+        const events: MatrixEvent[] = this.timelineWindow.getEvents();
 
         // `arrayFastClone` performs a shallow copy of the array
         // we want the last event to be decrypted first but displayed last
@@ -1161,14 +1218,14 @@ class TimelinePanel extends React.Component {
                 client.decryptEventIfNeeded(event);
             });
 
-        const firstVisibleEventIndex = this._checkForPreJoinUISI(events);
+        const firstVisibleEventIndex = this.checkForPreJoinUISI(events);
 
         // 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)) {
             events.push(...this.props.timelineSet.getPendingEvents());
         }
 
@@ -1189,7 +1246,7 @@ class TimelinePanel extends React.Component {
      * undecryptable event that was sent while the user was not in the room.  If no
      * such events were found, then it returns 0.
      */
-    _checkForPreJoinUISI(events) {
+    private checkForPreJoinUISI(events: MatrixEvent[]): number {
         const room = this.props.timelineSet.room;
 
         if (events.length === 0 || !room ||
@@ -1253,7 +1310,7 @@ class TimelinePanel extends React.Component {
         return 0;
     }
 
-    _indexForEventId(evId) {
+    private indexForEventId(evId: string): number | null {
         for (let i = 0; i < this.state.events.length; ++i) {
             if (evId == this.state.events[i].getId()) {
                 return i;
@@ -1262,15 +1319,14 @@ class TimelinePanel extends React.Component {
         return null;
     }
 
-    _getLastDisplayedEventIndex(opts) {
-        opts = opts || {};
+    private getLastDisplayedEventIndex(opts: IEventIndexOpts = {}): number | null {
         const ignoreOwn = opts.ignoreOwn || false;
         const allowPartial = opts.allowPartial || false;
 
-        const messagePanel = this._messagePanel.current;
+        const messagePanel = this.messagePanel.current;
         if (!messagePanel) return null;
 
-        const messagePanelNode = ReactDOM.findDOMNode(messagePanel);
+        const messagePanelNode = ReactDOM.findDOMNode(messagePanel) as HTMLElement;
         if (!messagePanelNode) return null; // sometimes this happens for fresh rooms/post-sync
         const wrapperRect = messagePanelNode.getBoundingClientRect();
         const myUserId = MatrixClientPeg.get().credentials.userId;
@@ -1347,7 +1403,7 @@ class TimelinePanel extends React.Component {
      *                                    SDK.
      * @return {String} the event ID
      */
-    _getCurrentReadReceipt(ignoreSynthesized) {
+    private getCurrentReadReceipt(ignoreSynthesized = false): string {
         const client = MatrixClientPeg.get();
         // the client can be null on logout
         if (client == null) {
@@ -1358,7 +1414,7 @@ class TimelinePanel extends React.Component {
         return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized);
     }
 
-    _setReadMarker(eventId, eventTs, inhibitSetState) {
+    private setReadMarker(eventId: string, eventTs: number, inhibitSetState = false): void {
         const roomId = this.props.timelineSet.room.roomId;
 
         // don't update the state (and cause a re-render) if there is
@@ -1383,7 +1439,7 @@ class TimelinePanel extends React.Component {
         }, this.props.onReadMarkerUpdated);
     }
 
-    _shouldPaginate() {
+    private shouldPaginate(): boolean {
         // don't try to paginate while events in the timeline are
         // still being decrypted. We don't render events while they're
         // being decrypted, so they don't take up space in the timeline.
@@ -1394,12 +1450,9 @@ class TimelinePanel extends React.Component {
         });
     }
 
-    getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args);
+    private getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args);
 
     render() {
-        const MessagePanel = sdk.getComponent("structures.MessagePanel");
-        const Loader = sdk.getComponent("elements.Spinner");
-
         // just show a spinner while the timeline loads.
         //
         // put it in a div of the right class (mx_RoomView_messagePanel) so
@@ -1414,7 +1467,7 @@ class TimelinePanel extends React.Component {
         if (this.state.timelineLoading) {
             return (
                 <div className="mx_RoomView_messagePanelSpinner">
-                    <Loader />
+                    <Spinner />
                 </div>
             );
         }
@@ -1435,7 +1488,7 @@ class TimelinePanel extends React.Component {
         // forwards, otherwise if somebody hits the bottom of the loaded
         // events when viewing historical messages, we get stuck in a loop
         // of paginating our way through the entire history of the room.
-        const stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
+        const stickyBottom = !this.timelineWindow.canPaginate(EventTimeline.FORWARDS);
 
         // If the state is PREPARED or CATCHUP, we're still waiting for the js-sdk to sync with
         // the HS and fetch the latest events, so we are effectively forward paginating.
@@ -1448,7 +1501,7 @@ class TimelinePanel extends React.Component {
             : this.state.events;
         return (
             <MessagePanel
-                ref={this._messagePanel}
+                ref={this.messagePanel}
                 room={this.props.timelineSet.room}
                 permalinkCreator={this.props.permalinkCreator}
                 hidden={this.props.hidden}
diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx
index a83f3f177c..c78b45e5e6 100644
--- a/src/components/views/dialogs/ForwardDialog.tsx
+++ b/src/components/views/dialogs/ForwardDialog.tsx
@@ -14,30 +14,31 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, {useMemo, useState, useEffect} from "react";
+import React, { useMemo, useState, useEffect } from "react";
 import classnames from "classnames";
-import {MatrixEvent} from "matrix-js-sdk/src/models/event";
-import {Room} from "matrix-js-sdk/src/models/room";
-import {MatrixClient} from "matrix-js-sdk/src/client";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { MatrixClient } from "matrix-js-sdk/src/client";
+import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 
-import {_t} from "../../../languageHandler";
+import { _t } from "../../../languageHandler";
 import dis from "../../../dispatcher/dispatcher";
-import {useSettingValue, useFeatureEnabled} from "../../../hooks/useSettings";
-import {UIFeature} from "../../../settings/UIFeature";
-import {Layout} from "../../../settings/Layout";
-import {IDialogProps} from "./IDialogProps";
+import { useSettingValue, useFeatureEnabled } from "../../../hooks/useSettings";
+import { UIFeature } from "../../../settings/UIFeature";
+import { Layout } from "../../../settings/Layout";
+import { IDialogProps } from "./IDialogProps";
 import BaseDialog from "./BaseDialog";
-import {avatarUrlForUser} from "../../../Avatar";
+import { avatarUrlForUser } from "../../../Avatar";
 import EventTile from "../rooms/EventTile";
 import SearchBox from "../../structures/SearchBox";
 import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
-import {Alignment} from '../elements/Tooltip';
+import { Alignment } from '../elements/Tooltip';
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
-import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
+import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
 import NotificationBadge from "../rooms/NotificationBadge";
-import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
-import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
+import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
+import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
 import QueryMatcher from "../../../autocomplete/QueryMatcher";
 
 const AVATAR_SIZE = 30;
@@ -166,12 +167,12 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
         userId,
         getAvatarUrl: (..._) => {
             return avatarUrlForUser(
-                { avatarUrl: profileInfo.avatar_url },
+                {avatarUrl: profileInfo.avatar_url},
                 AVATAR_SIZE, AVATAR_SIZE, "crop",
             );
         },
         getMxcAvatarUrl: () => profileInfo.avatar_url,
-    };
+    } as RoomMember;
 
     const [query, setQuery] = useState("");
     const lcQuery = query.toLowerCase();
diff --git a/src/components/views/elements/ErrorBoundary.js b/src/components/views/elements/ErrorBoundary.tsx
similarity index 80%
rename from src/components/views/elements/ErrorBoundary.js
rename to src/components/views/elements/ErrorBoundary.tsx
index 9037287f49..f967b8c594 100644
--- a/src/components/views/elements/ErrorBoundary.js
+++ b/src/components/views/elements/ErrorBoundary.tsx
@@ -1,5 +1,5 @@
 /*
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,21 +14,27 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
-import * as sdk from '../../../index';
+import React, { ErrorInfo } from 'react';
+
 import { _t } from '../../../languageHandler';
-import {MatrixClientPeg} from '../../../MatrixClientPeg';
+import { MatrixClientPeg } from '../../../MatrixClientPeg';
 import PlatformPeg from '../../../PlatformPeg';
 import Modal from '../../../Modal';
 import SdkConfig from "../../../SdkConfig";
-import {replaceableComponent} from "../../../utils/replaceableComponent";
+import { replaceableComponent } from "../../../utils/replaceableComponent";
+import BugReportDialog from '../dialogs/BugReportDialog';
+import AccessibleButton from './AccessibleButton';
+
+interface IState {
+    error: Error;
+}
 
 /**
  * This error boundary component can be used to wrap large content areas and
  * catch exceptions during rendering in the component tree below them.
  */
 @replaceableComponent("views.elements.ErrorBoundary")
-export default class ErrorBoundary extends React.PureComponent {
+export default class ErrorBoundary extends React.PureComponent<{}, IState> {
     constructor(props) {
         super(props);
 
@@ -37,13 +43,13 @@ export default class ErrorBoundary extends React.PureComponent {
         };
     }
 
-    static getDerivedStateFromError(error) {
+    static getDerivedStateFromError(error: Error): Partial<IState> {
         // Side effects are not permitted here, so we only update the state so
         // that the next render shows an error message.
         return { error };
     }
 
-    componentDidCatch(error, { componentStack }) {
+    componentDidCatch(error: Error, { componentStack }: ErrorInfo): void {
         // Browser consoles are better at formatting output when native errors are passed
         // in their own `console.error` invocation.
         console.error(error);
@@ -53,7 +59,7 @@ export default class ErrorBoundary extends React.PureComponent {
         );
     }
 
-    _onClearCacheAndReload = () => {
+    private onClearCacheAndReload = (): void => {
         if (!PlatformPeg.get()) return;
 
         MatrixClientPeg.get().stopClient();
@@ -62,11 +68,7 @@ export default class ErrorBoundary extends React.PureComponent {
         });
     };
 
-    _onBugReport = () => {
-        const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
-        if (!BugReportDialog) {
-            return;
-        }
+    private onBugReport = (): void => {
         Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
             label: 'react-soft-crash',
         });
@@ -74,7 +76,6 @@ export default class ErrorBoundary extends React.PureComponent {
 
     render() {
         if (this.state.error) {
-            const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
             const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
 
             let bugReportSection;
@@ -95,7 +96,7 @@ export default class ErrorBoundary extends React.PureComponent {
                         "the rooms or groups you have visited and the usernames of " +
                         "other users. They do not contain messages.",
                     )}</p>
-                    <AccessibleButton onClick={this._onBugReport} kind='primary'>
+                    <AccessibleButton onClick={this.onBugReport} kind='primary'>
                         {_t("Submit debug logs")}
                     </AccessibleButton>
                 </React.Fragment>;
@@ -105,7 +106,7 @@ export default class ErrorBoundary extends React.PureComponent {
                 <div className="mx_ErrorBoundary_body">
                     <h1>{_t("Something went wrong!")}</h1>
                     { bugReportSection }
-                    <AccessibleButton onClick={this._onClearCacheAndReload} kind='danger'>
+                    <AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
                         {_t("Clear cache and reload")}
                     </AccessibleButton>
                 </div>
diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx
index 86d3e082ad..ab647db9ed 100644
--- a/src/components/views/elements/EventListSummary.tsx
+++ b/src/components/views/elements/EventListSummary.tsx
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, {ReactChildren, useEffect} from 'react';
-import {MatrixEvent} from "matrix-js-sdk/src/models/event";
-import {RoomMember} from "matrix-js-sdk/src/models/room-member";
+import React, { ReactNode, useEffect } from 'react';
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 
 import MemberAvatar from '../avatars/MemberAvatar';
 import { _t } from '../../../languageHandler';
-import {useStateToggle} from "../../../hooks/useStateToggle";
+import { useStateToggle } from "../../../hooks/useStateToggle";
 import AccessibleButton from "./AccessibleButton";
 
 interface IProps {
@@ -31,11 +31,11 @@ interface IProps {
     // Whether or not to begin with state.expanded=true
     startExpanded?: boolean,
     // The list of room members for which to show avatars next to the summary
-    summaryMembers?: RoomMember[],
+    summaryMembers?: RoomMember[];
     // The text to show as the summary of this event list
-    summaryText?: string,
+    summaryText?: string;
     // An array of EventTiles to render when expanded
-    children: ReactChildren,
+    children: ReactNode[];
     // Called when the event list expansion is toggled
     onToggle?(): void;
 }
diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx
index cf3b7a6e61..8e73b3d9ca 100644
--- a/src/components/views/elements/EventTilePreview.tsx
+++ b/src/components/views/elements/EventTilePreview.tsx
@@ -17,13 +17,14 @@ limitations under the License.
 import React from 'react';
 import classnames from 'classnames';
 import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
+import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
 
 import * as Avatar from '../../../Avatar';
 import EventTile from '../rooms/EventTile';
 import SettingsStore from "../../../settings/SettingsStore";
-import {Layout} from "../../../settings/Layout";
-import {UIFeature} from "../../../settings/UIFeature";
-import {replaceableComponent} from "../../../utils/replaceableComponent";
+import { Layout } from "../../../settings/Layout";
+import { UIFeature } from "../../../settings/UIFeature";
+import { replaceableComponent } from "../../../utils/replaceableComponent";
 
 interface IProps {
     /**
@@ -105,12 +106,12 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
             userId: this.props.userId,
             getAvatarUrl: (..._) => {
                 return Avatar.avatarUrlForUser(
-                    { avatarUrl: this.props.avatarUrl },
+                    {avatarUrl: this.props.avatarUrl},
                     AVATAR_SIZE, AVATAR_SIZE, "crop",
                 );
             },
             getMxcAvatarUrl: () => this.props.avatarUrl,
-        };
+        } as RoomMember;
 
         return event;
     }
diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx
index f10884ce9d..8d411c5f6c 100644
--- a/src/components/views/elements/MemberEventListSummary.tsx
+++ b/src/components/views/elements/MemberEventListSummary.tsx
@@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, { ReactChildren } from 'react';
+import React, { ComponentProps } from 'react';
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 
@@ -26,21 +26,11 @@ import { isValid3pidInvite } from "../../../RoomInvite";
 import EventListSummary from "./EventListSummary";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 
-interface IProps {
-    // An array of member events to summarise
-    events: MatrixEvent[];
+interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
     // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
     summaryLength?: number;
     // The maximum number of avatars to display in the summary
     avatarsMaxLength?: number;
-    // The minimum number of events needed to trigger summarisation
-    threshold?: number,
-    // Whether or not to begin with state.expanded=true
-    startExpanded?: boolean,
-    // An array of EventTiles to render when expanded
-    children: ReactChildren;
-    // Called when the MELS expansion is toggled
-    onToggle?(): void,
 }
 
 interface IUserEvents {
diff --git a/src/components/views/messages/DateSeparator.js b/src/components/views/messages/DateSeparator.tsx
similarity index 82%
rename from src/components/views/messages/DateSeparator.js
rename to src/components/views/messages/DateSeparator.tsx
index 82ce8dc4ae..5d43e2182d 100644
--- a/src/components/views/messages/DateSeparator.js
+++ b/src/components/views/messages/DateSeparator.tsx
@@ -1,6 +1,6 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
 Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -16,12 +16,12 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import { _t } from '../../../languageHandler';
-import {formatFullDateNoTime} from '../../../DateUtils';
-import {replaceableComponent} from "../../../utils/replaceableComponent";
 
-function getdaysArray() {
+import { _t } from '../../../languageHandler';
+import { formatFullDateNoTime } from '../../../DateUtils';
+import { replaceableComponent } from "../../../utils/replaceableComponent";
+
+function getDaysArray(): string[] {
     return [
         _t('Sunday'),
         _t('Monday'),
@@ -33,17 +33,17 @@ function getdaysArray() {
     ];
 }
 
-@replaceableComponent("views.messages.DateSeparator")
-export default class DateSeparator extends React.Component {
-    static propTypes = {
-        ts: PropTypes.number.isRequired,
-    };
+interface IProps {
+    ts: number;
+}
 
-    getLabel() {
+@replaceableComponent("views.messages.DateSeparator")
+export default class DateSeparator extends React.Component<IProps> {
+    private getLabel() {
         const date = new Date(this.props.ts);
         const today = new Date();
         const yesterday = new Date();
-        const days = getdaysArray();
+        const days = getDaysArray();
         yesterday.setDate(today.getDate() - 1);
 
         if (date.toDateString() === today.toDateString()) {
diff --git a/src/components/views/messages/TileErrorBoundary.js b/src/components/views/messages/TileErrorBoundary.tsx
similarity index 77%
rename from src/components/views/messages/TileErrorBoundary.js
rename to src/components/views/messages/TileErrorBoundary.tsx
index 0e9a7b6128..967127d275 100644
--- a/src/components/views/messages/TileErrorBoundary.js
+++ b/src/components/views/messages/TileErrorBoundary.tsx
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Matrix.org Foundation C.I.C.
+Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -16,14 +16,24 @@ limitations under the License.
 
 import React from 'react';
 import classNames from 'classnames';
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+
 import { _t } from '../../../languageHandler';
-import * as sdk from '../../../index';
 import Modal from '../../../Modal';
 import SdkConfig from "../../../SdkConfig";
-import {replaceableComponent} from "../../../utils/replaceableComponent";
+import { replaceableComponent } from "../../../utils/replaceableComponent";
+import BugReportDialog from '../dialogs/BugReportDialog';
+
+interface IProps {
+    mxEvent: MatrixEvent;
+}
+
+interface IState {
+    error: Error;
+}
 
 @replaceableComponent("views.messages.TileErrorBoundary")
-export default class TileErrorBoundary extends React.Component {
+export default class TileErrorBoundary extends React.Component<IProps, IState> {
     constructor(props) {
         super(props);
 
@@ -32,17 +42,13 @@ export default class TileErrorBoundary extends React.Component {
         };
     }
 
-    static getDerivedStateFromError(error) {
+    static getDerivedStateFromError(error: Error): Partial<IState> {
         // Side effects are not permitted here, so we only update the state so
         // that the next render shows an error message.
         return { error };
     }
 
-    _onBugReport = () => {
-        const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
-        if (!BugReportDialog) {
-            return;
-        }
+    private onBugReport = (): void => {
         Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
             label: 'react-soft-crash-tile',
         });
@@ -60,7 +66,7 @@ export default class TileErrorBoundary extends React.Component {
 
             let submitLogsButton;
             if (SdkConfig.get().bug_report_endpoint_url) {
-                submitLogsButton = <a onClick={this._onBugReport} href="#">
+                submitLogsButton = <a onClick={this.onBugReport} href="#">
                     {_t("Submit logs")}
                 </a>;
             }
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index 3d674efe04..6c306904f5 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
+import React, { createRef } from 'react';
 import classNames from "classnames";
 import { EventType } from "matrix-js-sdk/src/@types/event";
 import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
@@ -176,12 +176,19 @@ const MAX_READ_AVATARS = 5;
 // |    '--------------------------------------'              |
 // '----------------------------------------------------------'
 
-interface IReadReceiptProps {
+export interface IReadReceiptProps {
     userId: string;
     roomMember: RoomMember;
     ts: number;
 }
 
+export enum TileShape {
+    Notif = "notif",
+    FileGrid = "file_grid",
+    Reply = "reply",
+    ReplyPreview = "reply_preview",
+}
+
 interface IProps {
     // the MatrixEvent to show
     mxEvent: MatrixEvent;
@@ -248,7 +255,7 @@ interface IProps {
     // It could also be done by subclassing EventTile, but that'd be quite
     // boiilerplatey.  So just make the necessary render decisions conditional
     // for now.
-    tileShape?: 'notif' | 'file_grid' | 'reply' | 'reply_preview';
+    tileShape?: TileShape;
 
     // show twelve hour timestamps
     isTwelveHour?: boolean;
@@ -306,10 +313,11 @@ interface IState {
 export default class EventTile extends React.Component<IProps, IState> {
     private suppressReadReceiptAnimation: boolean;
     private isListeningForReceipts: boolean;
-    private ref: React.RefObject<unknown>;
     private tile = React.createRef();
     private replyThread = React.createRef();
 
+    public readonly ref = createRef<HTMLElement>();
+
     static defaultProps = {
         // no-op function because onHeightChanged is optional yet some sub-components assume its existence
         onHeightChanged: function() {},
@@ -345,8 +353,6 @@ export default class EventTile extends React.Component<IProps, IState> {
         // to determine if we've already subscribed and use a combination of other flags to find
         // out if we should even be subscribed at all.
         this.isListeningForReceipts = false;
-
-        this.ref = React.createRef();
     }
 
     /**