Use lazy rendering in the AddExistingToSpaceDialog (#7369)
This commit is contained in:
parent
53081f52fb
commit
5163ad216f
3 changed files with 114 additions and 96 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
Loading…
Reference in a new issue