Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Use lazy rendering in the AddExistingToSpaceDialog #7369

Merged
merged 2 commits into from
Dec 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/structures/AutoHideScrollbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
189 changes: 104 additions & 85 deletions src/components/views/dialogs/AddExistingToSpaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -229,49 +264,56 @@ 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"
placeholder={filterPlaceholder}
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">
Expand All @@ -285,59 +327,36 @@ 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}
checked={selectedToAdd.has(room)}
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;
Expand Down
19 changes: 9 additions & 10 deletions src/components/views/emojipicker/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand All @@ -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}"]`);
Expand All @@ -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) => {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 => {
Expand Down