Use lazy rendering in the AddExistingToSpaceDialog (#7369)

This commit is contained in:
Michael Telatynski 2021-12-15 09:55:53 +00:00 committed by GitHub
parent 53081f52fb
commit 5163ad216f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 114 additions and 96 deletions

View file

@ -27,7 +27,7 @@ interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onScroll"> {
} }
export default class AutoHideScrollbar extends React.Component<IProps> { export default class AutoHideScrollbar extends React.Component<IProps> {
private containerRef: React.RefObject<HTMLDivElement> = React.createRef(); public readonly containerRef: React.RefObject<HTMLDivElement> = React.createRef();
public componentDidMount() { public componentDidMount() {
if (this.containerRef.current && this.props.onScroll) { if (this.containerRef.current && this.props.onScroll) {

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ReactNode, useContext, useMemo, useState } from "react"; import React, { ReactNode, useContext, useMemo, useRef, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown"; import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox"; import SearchBox from "../../structures/SearchBox";
@ -38,9 +38,12 @@ import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/Rece
import ProgressBar from "../elements/ProgressBar"; import ProgressBar from "../elements/ProgressBar";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher"; import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList"; import LazyRenderList from "../elements/LazyRenderList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar"; // These values match CSS
const ROW_HEIGHT = 32 + 12;
const HEADER_HEIGHT = 15;
const GROUP_MARGIN = 24;
interface IProps { interface IProps {
space: Room; space: Room;
@ -64,31 +67,56 @@ export const Entry = ({ room, checked, onChange }) => {
</label>; </label>;
}; };
type OnChangeFn = (checked: boolean, room: Room) => void;
type Renderer = (
rooms: Room[],
selectedToAdd: Set<Room>,
scrollState: IScrollState,
onChange: undefined | OnChangeFn,
) => ReactNode;
interface IAddExistingToSpaceProps { interface IAddExistingToSpaceProps {
space: Room; space: Room;
footerPrompt?: ReactNode; footerPrompt?: ReactNode;
filterPlaceholder: string; filterPlaceholder: string;
emptySelectionButton?: ReactNode; emptySelectionButton?: ReactNode;
onFinished(added: boolean): void; onFinished(added: boolean): void;
roomsRenderer?( roomsRenderer?: Renderer;
rooms: Room[], spacesRenderer?: Renderer;
selectedToAdd: Set<Room>, dmsRenderer?: Renderer;
onChange: undefined | ((checked: boolean, room: Room) => void),
truncateAt: number,
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
): ReactNode;
spacesRenderer?(
spaces: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
dmsRenderer?(
dms: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
} }
interface IScrollState {
scrollTop: number;
height: number;
}
const getScrollState = (
{ scrollTop, height }: IScrollState,
numItems: number,
...prevGroupSizes: number[]
): IScrollState => {
let heightBefore = 0;
prevGroupSizes.forEach(size => {
heightBefore += GROUP_MARGIN + HEADER_HEIGHT + (size * ROW_HEIGHT);
});
const viewportTop = scrollTop;
const viewportBottom = viewportTop + height;
const listTop = heightBefore + HEADER_HEIGHT;
const listBottom = listTop + (numItems * ROW_HEIGHT);
const top = Math.max(viewportTop, listTop);
const bottom = Math.min(viewportBottom, listBottom);
// the viewport height and scrollTop passed to the LazyRenderList
// is capped at the intersection with the real viewport, so lists
// out of view are passed height 0, so they won't render any items.
return {
scrollTop: Math.max(0, scrollTop - listTop),
height: Math.max(0, bottom - top),
};
};
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space, space,
footerPrompt, footerPrompt,
@ -102,6 +130,13 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]); const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]);
const scrollRef = useRef<AutoHideScrollbar>();
const [scrollState, setScrollState] = useState<IScrollState>({
// these are estimates which update as soon as it mounts
scrollTop: 0,
height: 600,
});
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>()); const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [progress, setProgress] = useState<number>(null); const [progress, setProgress] = useState<number>(null);
const [error, setError] = useState<Error>(null); const [error, setError] = useState<Error>(null);
@ -229,31 +264,33 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
setSelectedToAdd(new Set(selectedToAdd)); setSelectedToAdd(new Set(selectedToAdd));
} : null; } : null;
const [truncateAt, setTruncateAt] = useState(20); // only count spaces when alone as they're shown on a separate modal all on their own
function overflowTile(overflowCount: number, totalCount: number): JSX.Element { const numSpaces = (spacesRenderer && !dmsRenderer && !roomsRenderer) ? spaces.length : 0;
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)}
/>
);
}
let noResults = true; let noResults = true;
if ((roomsRenderer && rooms.length > 0) || if ((roomsRenderer && rooms.length > 0) || (dmsRenderer && dms.length > 0) || (numSpaces > 0)) {
(dmsRenderer && dms.length > 0) ||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
) {
noResults = false; noResults = false;
} }
const onScroll = () => {
const body = scrollRef.current?.containerRef.current;
setScrollState({
scrollTop: body.scrollTop,
height: body.clientHeight,
});
};
const wrappedRef = (body: HTMLDivElement) => {
setScrollState({
scrollTop: body.scrollTop,
height: body.clientHeight,
});
};
const roomsScrollState = getScrollState(scrollState, rooms.length);
const spacesScrollState = getScrollState(scrollState, numSpaces, rooms.length);
const dmsScrollState = getScrollState(scrollState, dms.length, numSpaces, rooms.length);
return <div className="mx_AddExistingToSpace"> return <div className="mx_AddExistingToSpace">
<SearchBox <SearchBox
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
@ -261,17 +298,22 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
onSearch={setQuery} onSearch={setQuery}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_AddExistingToSpace_content"> <AutoHideScrollbar
className="mx_AddExistingToSpace_content"
onScroll={onScroll}
wrappedRef={wrappedRef}
ref={scrollRef}
>
{ rooms.length > 0 && roomsRenderer ? ( { rooms.length > 0 && roomsRenderer ? (
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile) roomsRenderer(rooms, selectedToAdd, roomsScrollState, onChange)
) : undefined } ) : undefined }
{ spaces.length > 0 && spacesRenderer ? ( { spaces.length > 0 && spacesRenderer ? (
spacesRenderer(spaces, selectedToAdd, onChange) spacesRenderer(spaces, selectedToAdd, spacesScrollState, onChange)
) : null } ) : null }
{ dms.length > 0 && dmsRenderer ? ( { dms.length > 0 && dmsRenderer ? (
dmsRenderer(dms, selectedToAdd, onChange) dmsRenderer(dms, selectedToAdd, dmsScrollState, onChange)
) : null } ) : null }
{ noResults ? <span className="mx_AddExistingToSpace_noResults"> { noResults ? <span className="mx_AddExistingToSpace_noResults">
@ -285,15 +327,20 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</div>; </div>;
}; };
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = ( const defaultRendererFactory = (title: string): Renderer => (
rooms, selectedToAdd, onChange, truncateAt, overflowTile, rooms,
selectedToAdd,
{ scrollTop, height },
onChange,
) => ( ) => (
<div className="mx_AddExistingToSpace_section"> <div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3> <h3>{ _t(title) }</h3>
<TruncatedList <LazyRenderList
truncateAt={truncateAt} itemHeight={ROW_HEIGHT}
createOverflowElement={overflowTile} items={rooms}
getChildren={(start, end) => rooms.slice(start, end).map(room => scrollTop={scrollTop}
height={height}
renderItem={room => (
<Entry <Entry
key={room.roomId} key={room.roomId}
room={room} room={room}
@ -301,43 +348,15 @@ export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
onChange={onChange ? (checked: boolean) => { onChange={onChange ? (checked: boolean) => {
onChange(checked, room); onChange(checked, room);
} : null} } : null}
/>, />
)} )}
getChildCount={() => rooms.length}
/> />
</div> </div>
); );
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => ( export const defaultRoomsRenderer = defaultRendererFactory(_td("Rooms"));
<div className="mx_AddExistingToSpace_section"> export const defaultSpacesRenderer = defaultRendererFactory(_td("Spaces"));
{ spaces.map(space => { export const defaultDmsRenderer = defaultRendererFactory(_td("Direct Messages"));
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
);
export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
);
interface ISubspaceSelectorProps { interface ISubspaceSelectorProps {
title: string; title: string;

View file

@ -56,9 +56,9 @@ class EmojiPicker extends React.Component<IProps, IState> {
private readonly memoizedDataByCategory: Record<CategoryKey, IEmoji[]>; private readonly memoizedDataByCategory: Record<CategoryKey, IEmoji[]>;
private readonly categories: ICategory[]; private readonly categories: ICategory[];
private bodyRef = React.createRef<HTMLDivElement>(); private scrollRef = React.createRef<AutoHideScrollbar>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
@ -133,7 +133,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
} }
private onScroll = () => { private onScroll = () => {
const body = this.bodyRef.current; const body = this.scrollRef.current?.containerRef.current;
this.setState({ this.setState({
scrollTop: body.scrollTop, scrollTop: body.scrollTop,
viewportHeight: body.clientHeight, viewportHeight: body.clientHeight,
@ -142,7 +142,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
}; };
private updateVisibility = () => { private updateVisibility = () => {
const body = this.bodyRef.current; const body = this.scrollRef.current?.containerRef.current;
const rect = body.getBoundingClientRect(); const rect = body.getBoundingClientRect();
for (const cat of this.categories) { for (const cat of this.categories) {
const elem = body.querySelector(`[data-category-id="${cat.id}"]`); const elem = body.querySelector(`[data-category-id="${cat.id}"]`);
@ -169,7 +169,8 @@ class EmojiPicker extends React.Component<IProps, IState> {
}; };
private scrollToCategory = (category: string) => { private scrollToCategory = (category: string) => {
this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView(); this.scrollRef.current?.containerRef.current
?.querySelector(`[data-category-id="${category}"]`).scrollIntoView();
}; };
private onChangeFilter = (filter: string) => { private onChangeFilter = (filter: string) => {
@ -202,7 +203,8 @@ class EmojiPicker extends React.Component<IProps, IState> {
}; };
private onEnterFilter = () => { private onEnterFilter = () => {
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item"); const btn = this.scrollRef.current?.containerRef.current
?.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");
if (btn) { if (btn) {
btn.click(); btn.click();
} }
@ -241,10 +243,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
<Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} /> <Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
<AutoHideScrollbar <AutoHideScrollbar
className="mx_EmojiPicker_body" className="mx_EmojiPicker_body"
wrappedRef={ref => { ref={this.scrollRef}
// @ts-ignore - AutoHideScrollbar should accept a RefObject or fall back to its own instead
this.bodyRef.current = ref;
}}
onScroll={this.onScroll} onScroll={this.onScroll}
> >
{ this.categories.map(category => { { this.categories.map(category => {