diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx index a60df45770..31719a2cf1 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -27,7 +27,7 @@ interface IProps extends Omit, "onScroll"> { } export default class AutoHideScrollbar extends React.Component { - private containerRef: React.RefObject = React.createRef(); + public readonly containerRef: React.RefObject = React.createRef(); public componentDidMount() { if (this.containerRef.current && this.props.onScroll) { diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 298018798b..0188fa4a06 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -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 }) => { ; }; +type OnChangeFn = (checked: boolean, room: Room) => void; + +type Renderer = ( + rooms: Room[], + selectedToAdd: Set, + 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, - onChange: undefined | ((checked: boolean, room: Room) => void), - truncateAt: number, - overflowTile: (overflowCount: number, totalCount: number) => JSX.Element, - ): ReactNode; - spacesRenderer?( - spaces: Room[], - selectedToAdd: Set, - onChange?: (checked: boolean, room: Room) => void, - ): ReactNode; - dmsRenderer?( - dms: Room[], - selectedToAdd: Set, - 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 = ({ space, footerPrompt, @@ -102,6 +130,13 @@ export const AddExistingToSpace: React.FC = ({ const cli = useContext(MatrixClientContext); const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]); + const scrollRef = useRef(); + const [scrollState, setScrollState] = useState({ + // these are estimates which update as soon as it mounts + scrollTop: 0, + height: 600, + }); + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); const [progress, setProgress] = useState(null); const [error, setError] = useState(null); @@ -229,31 +264,33 @@ export const AddExistingToSpace: React.FC = ({ 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 ( - - } - 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
= ({ onSearch={setQuery} autoFocus={true} /> - + { 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 ? @@ -285,15 +327,20 @@ export const AddExistingToSpace: React.FC = ({
; }; -export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = ( - rooms, selectedToAdd, onChange, truncateAt, overflowTile, +const defaultRendererFactory = (title: string): Renderer => ( + rooms, + selectedToAdd, + { scrollTop, height }, + onChange, ) => (
-

{ _t("Rooms") }

- rooms.slice(start, end).map(room => +

{ _t(title) }

+ ( { onChange(checked, room); } : null} - />, + /> )} - getChildCount={() => rooms.length} />
); -export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => ( -
- { spaces.map(space => { - return { - onChange(checked, space); - } : null} - />; - }) } -
-); - -export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => ( -
-

{ _t("Direct Messages") }

- { dms.map(room => { - return { - onChange(checked, room); - } : null} - />; - }) } -
-); +export const defaultRoomsRenderer = defaultRendererFactory(_td("Rooms")); +export const defaultSpacesRenderer = defaultRendererFactory(_td("Spaces")); +export const defaultDmsRenderer = defaultRendererFactory(_td("Direct Messages")); interface ISubspaceSelectorProps { title: string; diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index e31317b9e5..d0c3b59d42 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -56,9 +56,9 @@ class EmojiPicker extends React.Component { private readonly memoizedDataByCategory: Record; private readonly categories: ICategory[]; - private bodyRef = React.createRef(); + private scrollRef = React.createRef(); - constructor(props) { + constructor(props: IProps) { super(props); this.state = { @@ -133,7 +133,7 @@ class EmojiPicker extends React.Component { } 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 { }; 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 { }; 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 { }; private onEnterFilter = () => { - const btn = this.bodyRef.current.querySelector(".mx_EmojiPicker_item"); + const btn = this.scrollRef.current?.containerRef.current + ?.querySelector(".mx_EmojiPicker_item"); if (btn) { btn.click(); } @@ -241,10 +243,7 @@ class EmojiPicker extends React.Component { { - // @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 => {