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> {
|
||||
private containerRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
public readonly containerRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
|
||||
public componentDidMount() {
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useContext, useMemo, useState } from "react";
|
||||
import React, { ReactNode, useContext, useMemo, useRef, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
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 DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
||||
import TruncatedList from "../elements/TruncatedList";
|
||||
import EntityTile from "../rooms/EntityTile";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import LazyRenderList from "../elements/LazyRenderList";
|
||||
|
||||
// These values match CSS
|
||||
const ROW_HEIGHT = 32 + 12;
|
||||
const HEADER_HEIGHT = 15;
|
||||
const GROUP_MARGIN = 24;
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -64,31 +67,56 @@ export const Entry = ({ room, checked, onChange }) => {
|
|||
</label>;
|
||||
};
|
||||
|
||||
type OnChangeFn = (checked: boolean, room: Room) => void;
|
||||
|
||||
type Renderer = (
|
||||
rooms: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
scrollState: IScrollState,
|
||||
onChange: undefined | OnChangeFn,
|
||||
) => ReactNode;
|
||||
|
||||
interface IAddExistingToSpaceProps {
|
||||
space: Room;
|
||||
footerPrompt?: ReactNode;
|
||||
filterPlaceholder: string;
|
||||
emptySelectionButton?: ReactNode;
|
||||
onFinished(added: boolean): void;
|
||||
roomsRenderer?(
|
||||
rooms: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
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;
|
||||
roomsRenderer?: Renderer;
|
||||
spacesRenderer?: Renderer;
|
||||
dmsRenderer?: Renderer;
|
||||
}
|
||||
|
||||
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> = ({
|
||||
space,
|
||||
footerPrompt,
|
||||
|
@ -102,6 +130,13 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
const cli = useContext(MatrixClientContext);
|
||||
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 [progress, setProgress] = useState<number>(null);
|
||||
const [error, setError] = useState<Error>(null);
|
||||
|
@ -229,31 +264,33 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
} : null;
|
||||
|
||||
const [truncateAt, setTruncateAt] = useState(20);
|
||||
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// only count spaces when alone as they're shown on a separate modal all on their own
|
||||
const numSpaces = (spacesRenderer && !dmsRenderer && !roomsRenderer) ? spaces.length : 0;
|
||||
|
||||
let noResults = true;
|
||||
if ((roomsRenderer && rooms.length > 0) ||
|
||||
(dmsRenderer && dms.length > 0) ||
|
||||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
|
||||
) {
|
||||
if ((roomsRenderer && rooms.length > 0) || (dmsRenderer && dms.length > 0) || (numSpaces > 0)) {
|
||||
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">
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
|
@ -261,17 +298,22 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
|
||||
<AutoHideScrollbar
|
||||
className="mx_AddExistingToSpace_content"
|
||||
onScroll={onScroll}
|
||||
wrappedRef={wrappedRef}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{ rooms.length > 0 && roomsRenderer ? (
|
||||
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
|
||||
roomsRenderer(rooms, selectedToAdd, roomsScrollState, onChange)
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 && spacesRenderer ? (
|
||||
spacesRenderer(spaces, selectedToAdd, onChange)
|
||||
spacesRenderer(spaces, selectedToAdd, spacesScrollState, onChange)
|
||||
) : null }
|
||||
|
||||
{ dms.length > 0 && dmsRenderer ? (
|
||||
dmsRenderer(dms, selectedToAdd, onChange)
|
||||
dmsRenderer(dms, selectedToAdd, dmsScrollState, onChange)
|
||||
) : null }
|
||||
|
||||
{ noResults ? <span className="mx_AddExistingToSpace_noResults">
|
||||
|
@ -285,15 +327,20 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
</div>;
|
||||
};
|
||||
|
||||
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
|
||||
rooms, selectedToAdd, onChange, truncateAt, overflowTile,
|
||||
const defaultRendererFactory = (title: string): Renderer => (
|
||||
rooms,
|
||||
selectedToAdd,
|
||||
{ scrollTop, height },
|
||||
onChange,
|
||||
) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
<TruncatedList
|
||||
truncateAt={truncateAt}
|
||||
createOverflowElement={overflowTile}
|
||||
getChildren={(start, end) => rooms.slice(start, end).map(room =>
|
||||
<h3>{ _t(title) }</h3>
|
||||
<LazyRenderList
|
||||
itemHeight={ROW_HEIGHT}
|
||||
items={rooms}
|
||||
scrollTop={scrollTop}
|
||||
height={height}
|
||||
renderItem={room => (
|
||||
<Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
|
@ -301,43 +348,15 @@ export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
|
|||
onChange={onChange ? (checked: boolean) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>,
|
||||
/>
|
||||
)}
|
||||
getChildCount={() => rooms.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
{ spaces.map(space => {
|
||||
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>
|
||||
);
|
||||
export const defaultRoomsRenderer = defaultRendererFactory(_td("Rooms"));
|
||||
export const defaultSpacesRenderer = defaultRendererFactory(_td("Spaces"));
|
||||
export const defaultDmsRenderer = defaultRendererFactory(_td("Direct Messages"));
|
||||
|
||||
interface ISubspaceSelectorProps {
|
||||
title: string;
|
||||
|
|
|
@ -56,9 +56,9 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||
private readonly memoizedDataByCategory: Record<CategoryKey, IEmoji[]>;
|
||||
private readonly categories: ICategory[];
|
||||
|
||||
private bodyRef = React.createRef<HTMLDivElement>();
|
||||
private scrollRef = React.createRef<AutoHideScrollbar>();
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
@ -133,7 +133,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private onScroll = () => {
|
||||
const body = this.bodyRef.current;
|
||||
const body = this.scrollRef.current?.containerRef.current;
|
||||
this.setState({
|
||||
scrollTop: body.scrollTop,
|
||||
viewportHeight: body.clientHeight,
|
||||
|
@ -142,7 +142,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private updateVisibility = () => {
|
||||
const body = this.bodyRef.current;
|
||||
const body = this.scrollRef.current?.containerRef.current;
|
||||
const rect = body.getBoundingClientRect();
|
||||
for (const cat of this.categories) {
|
||||
const elem = body.querySelector(`[data-category-id="${cat.id}"]`);
|
||||
|
@ -169,7 +169,8 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
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) => {
|
||||
|
@ -202,7 +203,8 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
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) {
|
||||
btn.click();
|
||||
}
|
||||
|
@ -241,10 +243,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||
<Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
|
||||
<AutoHideScrollbar
|
||||
className="mx_EmojiPicker_body"
|
||||
wrappedRef={ref => {
|
||||
// @ts-ignore - AutoHideScrollbar should accept a RefObject or fall back to its own instead
|
||||
this.bodyRef.current = ref;
|
||||
}}
|
||||
ref={this.scrollRef}
|
||||
onScroll={this.onScroll}
|
||||
>
|
||||
{ this.categories.map(category => {
|
||||
|
|
Loading…
Reference in a new issue