/* Copyright 2024 New Vector Ltd. Copyright 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React, { ReactElement, ReactNode, useContext, useMemo, useRef, useState } from "react"; import classNames from "classnames"; import { Room, EventType } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { sleep } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t, _td, TranslationKey } from "../../../languageHandler"; import BaseDialog from "./BaseDialog"; import Dropdown from "../elements/Dropdown"; import SearchBox from "../../structures/SearchBox"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import RoomAvatar from "../avatars/RoomAvatar"; import { getDisplayAliasForRoom } from "../../../Rooms"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import DMRoomMap from "../../../utils/DMRoomMap"; import { calculateRoomVia } from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import ProgressBar from "../elements/ProgressBar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import QueryMatcher from "../../../autocomplete/QueryMatcher"; import LazyRenderList from "../elements/LazyRenderList"; import { useSettingValue } from "../../../hooks/useSettings"; import { filterBoolean } from "../../../utils/arrays"; import { NonEmptyArray } from "../../../@types/common"; // These values match CSS const ROW_HEIGHT = 32 + 12; const HEADER_HEIGHT = 15; const GROUP_MARGIN = 24; interface IProps { space: Room; onCreateRoomClick(ev: ButtonEvent): void; onAddSubspaceClick(): void; onFinished(added?: boolean): void; } export const Entry: React.FC<{ room: Room; checked: boolean; onChange?(value: boolean): void; }> = ({ room, checked, onChange }) => { return ( ); }; 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?: 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, emptySelectionButton, filterPlaceholder, roomsRenderer, dmsRenderer, spacesRenderer, onFinished, }) => { const cli = useContext(MatrixClientContext); const msc3946ProcessDynamicPredecessor = useSettingValue("feature_dynamic_room_predecessors"); const visibleRooms = useMemo( () => cli ?.getVisibleRooms(msc3946ProcessDynamicPredecessor) .filter((r) => r.getMyMembership() === KnownMembership.Join) ?? [], [cli, msc3946ProcessDynamicPredecessor], ); const scrollRef = useRef>(null); 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(false); const [query, setQuery] = useState(""); const lcQuery = query.toLowerCase().trim(); const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]); const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]); const [spaces, rooms, dms] = useMemo(() => { let rooms = visibleRooms; if (lcQuery) { const matcher = new QueryMatcher(visibleRooms, { keys: ["name"], funcs: [(r) => filterBoolean([r.getCanonicalAlias(), ...r.getAltAliases()])], shouldMatchWordsOnly: false, }); rooms = matcher.match(lcQuery); } const joinRule = space.getJoinRule(); return sortRooms(rooms).reduce<[spaces: Room[], rooms: Room[], dms: Room[]]>( (arr, room) => { if (room.isSpaceRoom()) { if (room !== space && !existingSubspacesSet.has(room)) { arr[0].push(room); } } else if (!existingRoomsSet.has(room)) { if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { arr[1].push(room); } else if (joinRule !== "public") { // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. arr[2].push(room); } } return arr; }, [[], [], []], ); }, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]); const addRooms = async (): Promise => { setError(false); setProgress(0); let error = false; for (const room of selectedToAdd) { const via = calculateRoomVia(room); try { await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async (e): Promise => { if (e.errcode === "M_LIMIT_EXCEEDED") { await sleep(e.data.retry_after_ms); await SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry return; } throw e; }); setProgress((i) => (i ?? 0) + 1); } catch (e) { logger.error("Failed to add rooms to space", e); error = true; break; } } if (!error) { onFinished(true); } else { setError(error); } }; const busy = progress !== null; let footer; if (error) { footer = ( <>
{_t("space|add_existing_room_space|error_heading")}
{_t("action|try_again")}
{_t("action|retry")} ); } else if (busy) { footer = (
{_t("space|add_existing_room_space|progress_text", { count: selectedToAdd.size, progress, })}
); } else { let button = emptySelectionButton; if (!button || selectedToAdd.size > 0) { button = ( {_t("action|add")} ); } footer = ( <> {footerPrompt} {button} ); } const onChange = !busy && !error ? (checked: boolean, room: Room) => { if (checked) { selectedToAdd.add(room); } else { selectedToAdd.delete(room); } setSelectedToAdd(new Set(selectedToAdd)); } : undefined; // 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; const numRooms = roomsRenderer ? rooms.length : 0; const numDms = dmsRenderer ? dms.length : 0; let noResults = true; if (numSpaces > 0 || numRooms > 0 || numDms > 0) { noResults = false; } const onScroll = (): void => { const body = scrollRef.current?.containerRef.current; if (!body) return; setScrollState({ scrollTop: body.scrollTop, height: body.clientHeight, }); }; const wrappedRef = (body: HTMLDivElement | null): void => { if (!body) return; setScrollState({ scrollTop: body.scrollTop, height: body.clientHeight, }); }; const roomsScrollState = getScrollState(scrollState, numRooms); const spacesScrollState = getScrollState(scrollState, numSpaces, numRooms); const dmsScrollState = getScrollState(scrollState, numDms, numSpaces, numRooms); return (
{rooms.length > 0 && roomsRenderer ? roomsRenderer(rooms, selectedToAdd, roomsScrollState, onChange) : undefined} {spaces.length > 0 && spacesRenderer ? spacesRenderer(spaces, selectedToAdd, spacesScrollState, onChange) : null} {dms.length > 0 && dmsRenderer ? dmsRenderer(dms, selectedToAdd, dmsScrollState, onChange) : null} {noResults ? ( {_t("common|no_results")} ) : undefined}
{footer}
); }; const defaultRendererFactory = (title: TranslationKey): Renderer => (rooms, selectedToAdd, { scrollTop, height }, onChange) => (

{_t(title)}

( { onChange(checked, room); } : undefined } /> )} />
); export const defaultRoomsRenderer = defaultRendererFactory(_td("common|rooms")); export const defaultSpacesRenderer = defaultRendererFactory(_td("common|spaces")); export const defaultDmsRenderer = defaultRendererFactory(_td("space|add_existing_room_space|dm_heading")); interface ISubspaceSelectorProps { title: string; space: Room; value: Room; onChange(space: Room): void; } export const SubspaceSelector: React.FC = ({ title, space, value, onChange }) => { const options = useMemo(() => { return [ space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter((space) => { return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.getSafeUserId()); }), ]; }, [space]); let body; if (options.length > 1) { body = ( { onChange(options.find((space) => space.roomId === key) || space); }} value={value.roomId} label={_t("space|add_existing_room_space|space_dropdown_label")} > { options.map((space) => { const classes = classNames({ mx_SubspaceSelector_dropdownOptionActive: space === value, }); return (
{space.name || getDisplayAliasForRoom(space) || space.roomId}
); }) as NonEmptyArray }
); } else { body = (
{space.name || getDisplayAliasForRoom(space) || space.roomId}
); } return (

{title}

{body}
); }; const AddExistingToSpaceDialog: React.FC = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => { const [selectedSpace, setSelectedSpace] = useState(space); return ( } className="mx_AddExistingToSpaceDialog" contentId="mx_AddExistingToSpace" onFinished={onFinished} fixedWidth={false} >
{_t("space|add_existing_room_space|create")}
{ onCreateRoomClick(ev); onFinished(); }} > {_t("space|add_existing_room_space|create_prompt")} } filterPlaceholder={_t("space|room_filter_placeholder")} roomsRenderer={defaultRoomsRenderer} spacesRenderer={() => (

{_t("common|spaces")}

{ onAddSubspaceClick(); onFinished(); }} > {_t("space|add_existing_room_space|subspace_moved_note")}
)} dmsRenderer={defaultDmsRenderer} />
); }; export default AddExistingToSpaceDialog;