You've already forked matrix-react-sdk
							
							
				mirror of
				https://github.com/matrix-org/matrix-react-sdk.git
				synced 2025-11-04 11:51:45 +03:00 
			
		
		
		
	Improve discovery of rooms in a space
This commit is contained in:
		@@ -14,27 +14,30 @@ See the License for the specific language governing permissions and
 | 
			
		||||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import React, {useMemo, useRef, useState} from "react";
 | 
			
		||||
import React, {useMemo, useState} from "react";
 | 
			
		||||
import Room from "matrix-js-sdk/src/models/room";
 | 
			
		||||
import MatrixEvent from "matrix-js-sdk/src/models/event";
 | 
			
		||||
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
 | 
			
		||||
import classNames from "classnames";
 | 
			
		||||
import {sortBy} from "lodash";
 | 
			
		||||
 | 
			
		||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
 | 
			
		||||
import dis from "../../dispatcher/dispatcher";
 | 
			
		||||
import {_t} from "../../languageHandler";
 | 
			
		||||
import AccessibleButton from "../views/elements/AccessibleButton";
 | 
			
		||||
import BaseDialog from "../views/dialogs/BaseDialog";
 | 
			
		||||
import FormButton from "../views/elements/FormButton";
 | 
			
		||||
import Spinner from "../views/elements/Spinner";
 | 
			
		||||
import SearchBox from "./SearchBox";
 | 
			
		||||
import RoomAvatar from "../views/avatars/RoomAvatar";
 | 
			
		||||
import RoomName from "../views/elements/RoomName";
 | 
			
		||||
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
 | 
			
		||||
import {shouldShowSpaceSettings} from "../../utils/space";
 | 
			
		||||
import {EnhancedMap} from "../../utils/maps";
 | 
			
		||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
 | 
			
		||||
import AutoHideScrollbar from "./AutoHideScrollbar";
 | 
			
		||||
import BaseAvatar from "../views/avatars/BaseAvatar";
 | 
			
		||||
import {mediaFromMxc} from "../../customisations/Media";
 | 
			
		||||
import InfoTooltip from "../views/elements/InfoTooltip";
 | 
			
		||||
import TextWithTooltip from "../views/elements/TextWithTooltip";
 | 
			
		||||
import {useStateToggle} from "../../hooks/useStateToggle";
 | 
			
		||||
 | 
			
		||||
interface IProps {
 | 
			
		||||
    space: Room;
 | 
			
		||||
@@ -72,215 +75,98 @@ export interface ISpaceSummaryEvent {
 | 
			
		||||
}
 | 
			
		||||
/* eslint-enable camelcase */
 | 
			
		||||
 | 
			
		||||
interface ISubspaceProps {
 | 
			
		||||
    space: ISpaceSummaryRoom;
 | 
			
		||||
    event?: MatrixEvent;
 | 
			
		||||
interface ITileProps {
 | 
			
		||||
    room: ISpaceSummaryRoom;
 | 
			
		||||
    editing?: boolean;
 | 
			
		||||
    onPreviewClick?(): void;
 | 
			
		||||
    queueAction?(action: IAction): void;
 | 
			
		||||
    onJoinClick?(): void;
 | 
			
		||||
    suggested?: boolean;
 | 
			
		||||
    selected?: boolean;
 | 
			
		||||
    numChildRooms?: number;
 | 
			
		||||
    hasPermissions?: boolean;
 | 
			
		||||
    onViewRoomClick(autoJoin: boolean): void;
 | 
			
		||||
    onToggleClick?(): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SubSpace: React.FC<ISubspaceProps> = ({
 | 
			
		||||
    space,
 | 
			
		||||
const Tile: React.FC<ITileProps> = ({
 | 
			
		||||
    room,
 | 
			
		||||
    editing,
 | 
			
		||||
    event,
 | 
			
		||||
    queueAction,
 | 
			
		||||
    onJoinClick,
 | 
			
		||||
    onPreviewClick,
 | 
			
		||||
    suggested,
 | 
			
		||||
    selected,
 | 
			
		||||
    hasPermissions,
 | 
			
		||||
    onToggleClick,
 | 
			
		||||
    onViewRoomClick,
 | 
			
		||||
    numChildRooms,
 | 
			
		||||
    children,
 | 
			
		||||
}) => {
 | 
			
		||||
    const name = space.name || space.canonical_alias || space.aliases?.[0] || _t("Unnamed Space");
 | 
			
		||||
    const name = room.name || room.canonical_alias || room.aliases?.[0]
 | 
			
		||||
        || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
 | 
			
		||||
 | 
			
		||||
    const evContent = event?.getContent();
 | 
			
		||||
    const [suggested, _setSuggested] = useState(evContent?.suggested);
 | 
			
		||||
    const [removed, _setRemoved] = useState(!evContent?.via);
 | 
			
		||||
 | 
			
		||||
    const cli = MatrixClientPeg.get();
 | 
			
		||||
    const cliRoom = cli.getRoom(space.room_id);
 | 
			
		||||
    const myMembership = cliRoom?.getMyMembership();
 | 
			
		||||
 | 
			
		||||
    // TODO DRY code
 | 
			
		||||
    let actions;
 | 
			
		||||
    if (editing && queueAction) {
 | 
			
		||||
        if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
 | 
			
		||||
            const setSuggested = () => {
 | 
			
		||||
                _setSuggested(v => {
 | 
			
		||||
                    queueAction({
 | 
			
		||||
                        event,
 | 
			
		||||
                        removed,
 | 
			
		||||
                        suggested: !v,
 | 
			
		||||
                    });
 | 
			
		||||
                    return !v;
 | 
			
		||||
                });
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            const setRemoved = () => {
 | 
			
		||||
                _setRemoved(v => {
 | 
			
		||||
                    queueAction({
 | 
			
		||||
                        event,
 | 
			
		||||
                        removed: !v,
 | 
			
		||||
                        suggested,
 | 
			
		||||
                    });
 | 
			
		||||
                    return !v;
 | 
			
		||||
                });
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            if (removed) {
 | 
			
		||||
                actions = <React.Fragment>
 | 
			
		||||
                    <FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
 | 
			
		||||
                </React.Fragment>;
 | 
			
		||||
            } else {
 | 
			
		||||
                actions = <React.Fragment>
 | 
			
		||||
                    <FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
 | 
			
		||||
                    <StyledCheckbox checked={suggested} onChange={setSuggested} />
 | 
			
		||||
                </React.Fragment>;
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            actions = <span className="mx_SpaceRoomDirectory_actionsText">
 | 
			
		||||
                { _t("No permissions")}
 | 
			
		||||
            </span>;
 | 
			
		||||
        }
 | 
			
		||||
        // TODO confirm remove from space click behaviour here
 | 
			
		||||
    } else {
 | 
			
		||||
        if (myMembership === "join") {
 | 
			
		||||
            actions = <span className="mx_SpaceRoomDirectory_actionsText">
 | 
			
		||||
                { _t("You're in this space")}
 | 
			
		||||
            </span>;
 | 
			
		||||
        } else if (onJoinClick) {
 | 
			
		||||
            actions = <React.Fragment>
 | 
			
		||||
                <AccessibleButton onClick={onPreviewClick} kind="link">
 | 
			
		||||
                    { _t("Preview") }
 | 
			
		||||
                </AccessibleButton>
 | 
			
		||||
                <FormButton onClick={onJoinClick} label={_t("Join")} />
 | 
			
		||||
            </React.Fragment>
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let url: string;
 | 
			
		||||
    if (space.avatar_url) {
 | 
			
		||||
        url = mediaFromMxc(space.avatar_url).getSquareThumbnailHttp(Math.floor(24 * window.devicePixelRatio));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return <div className="mx_SpaceRoomDirectory_subspace">
 | 
			
		||||
        <div className="mx_SpaceRoomDirectory_subspace_info">
 | 
			
		||||
            <BaseAvatar name={name} idName={space.room_id} url={url} width={24} height={24} />
 | 
			
		||||
            { name }
 | 
			
		||||
 | 
			
		||||
            <div className="mx_SpaceRoomDirectory_actions">
 | 
			
		||||
                { actions }
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="mx_SpaceRoomDirectory_subspace_children">
 | 
			
		||||
            { children }
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface IAction {
 | 
			
		||||
    event: MatrixEvent;
 | 
			
		||||
    suggested: boolean;
 | 
			
		||||
    removed: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IRoomTileProps {
 | 
			
		||||
    room: ISpaceSummaryRoom;
 | 
			
		||||
    event?: MatrixEvent;
 | 
			
		||||
    editing?: boolean;
 | 
			
		||||
    onPreviewClick(): void;
 | 
			
		||||
    queueAction?(action: IAction): void;
 | 
			
		||||
    onJoinClick?(): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinClick }: IRoomTileProps) => {
 | 
			
		||||
    const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Unnamed Room");
 | 
			
		||||
 | 
			
		||||
    const evContent = event?.getContent();
 | 
			
		||||
    const [suggested, _setSuggested] = useState(evContent?.suggested);
 | 
			
		||||
    const [removed, _setRemoved] = useState(!evContent?.via);
 | 
			
		||||
    const [showChildren, toggleShowChildren] = useStateToggle(true);
 | 
			
		||||
 | 
			
		||||
    const cli = MatrixClientPeg.get();
 | 
			
		||||
    const cliRoom = cli.getRoom(room.room_id);
 | 
			
		||||
    const myMembership = cliRoom?.getMyMembership();
 | 
			
		||||
 | 
			
		||||
    let actions;
 | 
			
		||||
    if (editing && queueAction) {
 | 
			
		||||
        if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
 | 
			
		||||
            const setSuggested = () => {
 | 
			
		||||
                _setSuggested(v => {
 | 
			
		||||
                    queueAction({
 | 
			
		||||
                        event,
 | 
			
		||||
                        removed,
 | 
			
		||||
                        suggested: !v,
 | 
			
		||||
                    });
 | 
			
		||||
                    return !v;
 | 
			
		||||
                });
 | 
			
		||||
            };
 | 
			
		||||
    const onPreviewClick = () => onViewRoomClick(false);
 | 
			
		||||
    const onJoinClick = () => onViewRoomClick(true);
 | 
			
		||||
 | 
			
		||||
            const setRemoved = () => {
 | 
			
		||||
                _setRemoved(v => {
 | 
			
		||||
                    queueAction({
 | 
			
		||||
                        event,
 | 
			
		||||
                        removed: !v,
 | 
			
		||||
                        suggested,
 | 
			
		||||
                    });
 | 
			
		||||
                    return !v;
 | 
			
		||||
                });
 | 
			
		||||
            };
 | 
			
		||||
    let button;
 | 
			
		||||
    if (myMembership === "join") {
 | 
			
		||||
        button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
 | 
			
		||||
            { _t("Open") }
 | 
			
		||||
        </AccessibleButton>;
 | 
			
		||||
    } else if (onJoinClick) {
 | 
			
		||||
        button = <AccessibleButton onClick={onJoinClick} kind="primary">
 | 
			
		||||
            { _t("Join") }
 | 
			
		||||
        </AccessibleButton>;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
            if (removed) {
 | 
			
		||||
                actions = <React.Fragment>
 | 
			
		||||
                    <FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
 | 
			
		||||
                </React.Fragment>;
 | 
			
		||||
            } else {
 | 
			
		||||
                actions = <React.Fragment>
 | 
			
		||||
                    <FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
 | 
			
		||||
                    <StyledCheckbox checked={suggested} onChange={setSuggested} />
 | 
			
		||||
                </React.Fragment>;
 | 
			
		||||
            }
 | 
			
		||||
    let checkbox;
 | 
			
		||||
    if (onToggleClick) {
 | 
			
		||||
        if (hasPermissions) {
 | 
			
		||||
            checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
 | 
			
		||||
        } else {
 | 
			
		||||
            actions = <span className="mx_SpaceRoomDirectory_actionsText">
 | 
			
		||||
                { _t("No permissions")}
 | 
			
		||||
            </span>;
 | 
			
		||||
        }
 | 
			
		||||
        // TODO confirm remove from space click behaviour here
 | 
			
		||||
    } else {
 | 
			
		||||
        if (myMembership === "join") {
 | 
			
		||||
            actions = <span className="mx_SpaceRoomDirectory_actionsText">
 | 
			
		||||
                { _t("You're in this room")}
 | 
			
		||||
            </span>;
 | 
			
		||||
        } else if (onJoinClick) {
 | 
			
		||||
            actions = <React.Fragment>
 | 
			
		||||
                <AccessibleButton onClick={onPreviewClick} kind="link">
 | 
			
		||||
                    { _t("Preview") }
 | 
			
		||||
                </AccessibleButton>
 | 
			
		||||
                <FormButton onClick={onJoinClick} label={_t("Join")} />
 | 
			
		||||
            </React.Fragment>
 | 
			
		||||
            checkbox = <TextWithTooltip
 | 
			
		||||
                tooltip={_t("You don't have permission")}
 | 
			
		||||
                onClick={ev => { ev.stopPropagation() }}
 | 
			
		||||
            >
 | 
			
		||||
                <StyledCheckbox disabled={true} />
 | 
			
		||||
            </TextWithTooltip>;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let url: string;
 | 
			
		||||
    if (room.avatar_url) {
 | 
			
		||||
        url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(32 * window.devicePixelRatio));
 | 
			
		||||
        url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(20 * window.devicePixelRatio));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let description = _t("%(count)s members", { count: room.num_joined_members });
 | 
			
		||||
    if (numChildRooms) {
 | 
			
		||||
        description += " · " + _t("%(count)s rooms", { count: numChildRooms });
 | 
			
		||||
    }
 | 
			
		||||
    if (room.topic) {
 | 
			
		||||
        description += " · " + room.topic;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let suggestedSection;
 | 
			
		||||
    if (suggested) {
 | 
			
		||||
        suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
 | 
			
		||||
            { _t("Suggested") }
 | 
			
		||||
        </InfoTooltip>;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const content = <React.Fragment>
 | 
			
		||||
        <BaseAvatar name={name} idName={room.room_id} url={url} width={32} height={32} />
 | 
			
		||||
        <BaseAvatar name={name} idName={room.room_id} url={url} width={20} height={20} />
 | 
			
		||||
        <div className="mx_SpaceRoomDirectory_roomTile_name">
 | 
			
		||||
            { name }
 | 
			
		||||
            { suggestedSection }
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="mx_SpaceRoomDirectory_roomTile_info">
 | 
			
		||||
            <div className="mx_SpaceRoomDirectory_roomTile_name">
 | 
			
		||||
                { name }
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="mx_SpaceRoomDirectory_roomTile_topic">
 | 
			
		||||
                { room.topic }
 | 
			
		||||
            </div>
 | 
			
		||||
            { description }
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="mx_SpaceRoomDirectory_roomTile_memberCount">
 | 
			
		||||
            { room.num_joined_members }
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="mx_SpaceRoomDirectory_actions">
 | 
			
		||||
            { actions }
 | 
			
		||||
            { button }
 | 
			
		||||
            { checkbox }
 | 
			
		||||
        </div>
 | 
			
		||||
    </React.Fragment>;
 | 
			
		||||
 | 
			
		||||
@@ -290,9 +176,38 @@ const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinCli
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return <AccessibleButton className="mx_SpaceRoomDirectory_roomTile" onClick={onPreviewClick}>
 | 
			
		||||
        { content }
 | 
			
		||||
    </AccessibleButton>;
 | 
			
		||||
    let childToggle;
 | 
			
		||||
    let childSection;
 | 
			
		||||
    if (children) {
 | 
			
		||||
        // the chevron is purposefully a div rather than a button as it should be ignored for a11y
 | 
			
		||||
        childToggle = <div
 | 
			
		||||
            className={classNames("mx_SpaceRoomDirectory_subspace_toggle", {
 | 
			
		||||
                mx_SpaceRoomDirectory_subspace_toggle_shown: showChildren,
 | 
			
		||||
            })}
 | 
			
		||||
            onClick={ev => {
 | 
			
		||||
                ev.stopPropagation();
 | 
			
		||||
                toggleShowChildren();
 | 
			
		||||
            }}
 | 
			
		||||
        />;
 | 
			
		||||
        if (showChildren) {
 | 
			
		||||
            childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
 | 
			
		||||
                { children }
 | 
			
		||||
            </div>;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return <>
 | 
			
		||||
        <AccessibleButton
 | 
			
		||||
            className={classNames("mx_SpaceRoomDirectory_roomTile", {
 | 
			
		||||
                mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
 | 
			
		||||
            })}
 | 
			
		||||
            onClick={hasPermissions ? onToggleClick : onPreviewClick}
 | 
			
		||||
        >
 | 
			
		||||
            { content }
 | 
			
		||||
            { childToggle }
 | 
			
		||||
        </AccessibleButton>
 | 
			
		||||
        { childSection }
 | 
			
		||||
    </>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
 | 
			
		||||
@@ -325,88 +240,77 @@ export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoi
 | 
			
		||||
interface IHierarchyLevelProps {
 | 
			
		||||
    spaceId: string;
 | 
			
		||||
    rooms: Map<string, ISpaceSummaryRoom>;
 | 
			
		||||
    editing?: boolean;
 | 
			
		||||
    relations: EnhancedMap<string, string[]>;
 | 
			
		||||
    relations: EnhancedMap<string, Map<string, ISpaceSummaryEvent>>;
 | 
			
		||||
    parents: Set<string>;
 | 
			
		||||
    queueAction?(action: IAction): void;
 | 
			
		||||
    onPreviewClick(roomId: string): void;
 | 
			
		||||
    onRemoveFromSpaceClick?(roomId: string): void;
 | 
			
		||||
    onJoinClick?(roomId: string): void;
 | 
			
		||||
    selectedMap?: Map<string, Set<string>>;
 | 
			
		||||
    onViewRoomClick(roomId: string, autoJoin: boolean): void;
 | 
			
		||||
    onToggleClick?(parentId: string, childId: string): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const HierarchyLevel = ({
 | 
			
		||||
    spaceId,
 | 
			
		||||
    rooms,
 | 
			
		||||
    editing,
 | 
			
		||||
    relations,
 | 
			
		||||
    parents,
 | 
			
		||||
    onPreviewClick,
 | 
			
		||||
    onJoinClick,
 | 
			
		||||
    queueAction,
 | 
			
		||||
    selectedMap,
 | 
			
		||||
    onViewRoomClick,
 | 
			
		||||
    onToggleClick,
 | 
			
		||||
}: IHierarchyLevelProps) => {
 | 
			
		||||
    const cli = MatrixClientPeg.get();
 | 
			
		||||
    const space = cli.getRoom(spaceId);
 | 
			
		||||
    // TODO respect order
 | 
			
		||||
    const [subspaces, childRooms] = relations.get(spaceId)?.reduce((result, roomId: string) => {
 | 
			
		||||
        if (!rooms.has(roomId)) return result; // TODO wat
 | 
			
		||||
    const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId())
 | 
			
		||||
 | 
			
		||||
    const sortedChildren = sortBy([...relations.get(spaceId)?.values()], ev => ev.content.order || null);
 | 
			
		||||
    const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
 | 
			
		||||
        const roomId = ev.state_key;
 | 
			
		||||
        if (!rooms.has(roomId)) return result;
 | 
			
		||||
        result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
 | 
			
		||||
        return result;
 | 
			
		||||
    }, [[], []]) || [[], []];
 | 
			
		||||
 | 
			
		||||
    // Don't render this subspace if it has no rooms we can show
 | 
			
		||||
    // TODO this is broken - as a space may have subspaces we still need to show
 | 
			
		||||
    // if (!childRooms.length) return null;
 | 
			
		||||
 | 
			
		||||
    const userId = cli.getUserId();
 | 
			
		||||
 | 
			
		||||
    const newParents = new Set(parents).add(spaceId);
 | 
			
		||||
    return <React.Fragment>
 | 
			
		||||
        {
 | 
			
		||||
            childRooms.map(roomId => (
 | 
			
		||||
                <RoomTile
 | 
			
		||||
                <Tile
 | 
			
		||||
                    key={roomId}
 | 
			
		||||
                    room={rooms.get(roomId)}
 | 
			
		||||
                    event={space?.currentState.maySendStateEvent(EventType.SpaceChild, userId)
 | 
			
		||||
                        ? space?.currentState.getStateEvents(EventType.SpaceChild, roomId)
 | 
			
		||||
                        : undefined}
 | 
			
		||||
                    editing={editing}
 | 
			
		||||
                    queueAction={queueAction}
 | 
			
		||||
                    onPreviewClick={() => {
 | 
			
		||||
                        onPreviewClick(roomId);
 | 
			
		||||
                    suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
 | 
			
		||||
                    selected={selectedMap?.get(spaceId)?.has(roomId)}
 | 
			
		||||
                    onViewRoomClick={(autoJoin) => {
 | 
			
		||||
                        onViewRoomClick(roomId, autoJoin);
 | 
			
		||||
                    }}
 | 
			
		||||
                    onJoinClick={onJoinClick ? () => {
 | 
			
		||||
                        onJoinClick(roomId);
 | 
			
		||||
                    } : undefined}
 | 
			
		||||
                    hasPermissions={hasPermissions}
 | 
			
		||||
                    onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
 | 
			
		||||
                />
 | 
			
		||||
            ))
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        {
 | 
			
		||||
            subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
 | 
			
		||||
                <SubSpace
 | 
			
		||||
                <Tile
 | 
			
		||||
                    key={roomId}
 | 
			
		||||
                    space={rooms.get(roomId)}
 | 
			
		||||
                    event={space?.currentState.getStateEvents(EventType.SpaceChild, roomId)}
 | 
			
		||||
                    editing={editing}
 | 
			
		||||
                    queueAction={queueAction}
 | 
			
		||||
                    onPreviewClick={() => {
 | 
			
		||||
                        onPreviewClick(roomId);
 | 
			
		||||
                    }}
 | 
			
		||||
                    onJoinClick={() => {
 | 
			
		||||
                        onJoinClick(roomId);
 | 
			
		||||
                    room={rooms.get(roomId)}
 | 
			
		||||
                    numChildRooms={Array.from(relations.get(roomId)?.values() || [])
 | 
			
		||||
                        .filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).length}
 | 
			
		||||
                    suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
 | 
			
		||||
                    selected={selectedMap?.get(spaceId)?.has(roomId)}
 | 
			
		||||
                    onViewRoomClick={(autoJoin) => {
 | 
			
		||||
                        onViewRoomClick(roomId, autoJoin);
 | 
			
		||||
                    }}
 | 
			
		||||
                    hasPermissions={hasPermissions}
 | 
			
		||||
                    onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
 | 
			
		||||
                >
 | 
			
		||||
                    <HierarchyLevel
 | 
			
		||||
                        spaceId={roomId}
 | 
			
		||||
                        rooms={rooms}
 | 
			
		||||
                        editing={editing}
 | 
			
		||||
                        relations={relations}
 | 
			
		||||
                        parents={newParents}
 | 
			
		||||
                        onPreviewClick={onPreviewClick}
 | 
			
		||||
                        onJoinClick={onJoinClick}
 | 
			
		||||
                        queueAction={queueAction}
 | 
			
		||||
                        selectedMap={selectedMap}
 | 
			
		||||
                        onViewRoomClick={onViewRoomClick}
 | 
			
		||||
                        onToggleClick={onToggleClick}
 | 
			
		||||
                    />
 | 
			
		||||
                </SubSpace>
 | 
			
		||||
                </Tile>
 | 
			
		||||
            ))
 | 
			
		||||
        }
 | 
			
		||||
    </React.Fragment>
 | 
			
		||||
@@ -415,8 +319,8 @@ export const HierarchyLevel = ({
 | 
			
		||||
const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinished }) => {
 | 
			
		||||
    // TODO pagination
 | 
			
		||||
    const cli = MatrixClientPeg.get();
 | 
			
		||||
    const userId = cli.getUserId();
 | 
			
		||||
    const [query, setQuery] = useState(initialText);
 | 
			
		||||
    const [isEditing, setIsEditing] = useState(false);
 | 
			
		||||
 | 
			
		||||
    const onCreateRoomClick = () => {
 | 
			
		||||
        dis.dispatch({
 | 
			
		||||
@@ -426,51 +330,19 @@ const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinis
 | 
			
		||||
        onFinished();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // stored within a ref as we don't need to re-render when it changes
 | 
			
		||||
    const pendingActions = useRef(new Map<string, IAction>());
 | 
			
		||||
    const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
 | 
			
		||||
 | 
			
		||||
    let adminButton;
 | 
			
		||||
    if (shouldShowSpaceSettings(cli, space)) { // TODO this is an imperfect test
 | 
			
		||||
        const onManageButtonClicked = () => {
 | 
			
		||||
            setIsEditing(true);
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const onSaveButtonClicked = () => {
 | 
			
		||||
            // TODO setBusy
 | 
			
		||||
            pendingActions.current.forEach(({event, suggested, removed}) => {
 | 
			
		||||
                const content = {
 | 
			
		||||
                    ...event.getContent(),
 | 
			
		||||
                    suggested,
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                if (removed) {
 | 
			
		||||
                    delete content["via"];
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                cli.sendStateEvent(event.getRoomId(), event.getType(), content, event.getStateKey());
 | 
			
		||||
            });
 | 
			
		||||
            setIsEditing(false);
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (isEditing) {
 | 
			
		||||
            adminButton = <React.Fragment>
 | 
			
		||||
                <FormButton label={_t("Save changes")} onClick={onSaveButtonClicked} />
 | 
			
		||||
                <span>{ _t("Promoted to users") }</span>
 | 
			
		||||
            </React.Fragment>;
 | 
			
		||||
        } else {
 | 
			
		||||
            adminButton = <FormButton label={_t("Manage rooms")} onClick={onManageButtonClicked} />;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const [rooms, relations, viaMap] = useAsyncMemo(async () => {
 | 
			
		||||
    const [rooms, parentChildMap, childParentMap, viaMap] = useAsyncMemo(async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            const data = await cli.getSpaceSummary(space.roomId);
 | 
			
		||||
 | 
			
		||||
            const parentChildRelations = new EnhancedMap<string, string[]>();
 | 
			
		||||
            const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
 | 
			
		||||
            const childParentRelations = new EnhancedMap<string, Set<string>>();
 | 
			
		||||
            const viaMap = new EnhancedMap<string, Set<string>>();
 | 
			
		||||
            data.events.map((ev: ISpaceSummaryEvent) => {
 | 
			
		||||
                if (ev.type === EventType.SpaceChild) {
 | 
			
		||||
                    parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
 | 
			
		||||
                    parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
 | 
			
		||||
                    childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
 | 
			
		||||
                }
 | 
			
		||||
                if (Array.isArray(ev.content["via"])) {
 | 
			
		||||
                    const set = viaMap.getOrCreate(ev.state_key, new Set());
 | 
			
		||||
@@ -478,7 +350,7 @@ const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinis
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            return [data.rooms, parentChildRelations, viaMap];
 | 
			
		||||
            return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, childParentRelations, viaMap];
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
            console.error(e); // TODO
 | 
			
		||||
        }
 | 
			
		||||
@@ -488,54 +360,204 @@ const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinis
 | 
			
		||||
 | 
			
		||||
    const roomsMap = useMemo(() => {
 | 
			
		||||
        if (!rooms) return null;
 | 
			
		||||
        const lcQuery = query.toLowerCase();
 | 
			
		||||
        const lcQuery = query.toLowerCase().trim();
 | 
			
		||||
 | 
			
		||||
        const filteredRooms = rooms.filter(r => {
 | 
			
		||||
            return r.room_type === RoomType.Space // always include spaces to allow filtering of sub-space rooms
 | 
			
		||||
                || r.name?.toLowerCase().includes(lcQuery)
 | 
			
		||||
                || r.topic?.toLowerCase().includes(lcQuery);
 | 
			
		||||
        const roomsMap = new Map<string, ISpaceSummaryRoom>(rooms.map(r => [r.room_id, r]));
 | 
			
		||||
        if (!lcQuery) return roomsMap;
 | 
			
		||||
 | 
			
		||||
        const directMatches = rooms.filter(r => {
 | 
			
		||||
            return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return new Map<string, ISpaceSummaryRoom>(filteredRooms.map(r => [r.room_id, r]));
 | 
			
		||||
        // const root = rooms.get(space.roomId);
 | 
			
		||||
    }, [rooms, query]);
 | 
			
		||||
        // Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
 | 
			
		||||
        const visited = new Set<string>();
 | 
			
		||||
        const queue = [...directMatches.map(r => r.room_id)];
 | 
			
		||||
        while (queue.length) {
 | 
			
		||||
            const roomId = queue.pop();
 | 
			
		||||
            visited.add(roomId);
 | 
			
		||||
            childParentMap.get(roomId)?.forEach(parentId => {
 | 
			
		||||
                if (!visited.has(parentId)) {
 | 
			
		||||
                    queue.push(parentId);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Remove any mappings for rooms which were not visited in the walk
 | 
			
		||||
        Array.from(roomsMap.keys()).forEach(roomId => {
 | 
			
		||||
            if (!visited.has(roomId)) {
 | 
			
		||||
                roomsMap.delete(roomId);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        return roomsMap;
 | 
			
		||||
    }, [rooms, childParentMap, query]);
 | 
			
		||||
 | 
			
		||||
    const title = <React.Fragment>
 | 
			
		||||
        <RoomAvatar room={space} height={40} width={40} />
 | 
			
		||||
        <RoomAvatar room={space} height={32} width={32} />
 | 
			
		||||
        <div>
 | 
			
		||||
            <h1>{ _t("Explore rooms") }</h1>
 | 
			
		||||
            <div><RoomName room={space} /></div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </React.Fragment>;
 | 
			
		||||
 | 
			
		||||
    const explanation =
 | 
			
		||||
        _t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
 | 
			
		||||
        _t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.", null,
 | 
			
		||||
            {a: sub => {
 | 
			
		||||
                return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{sub}</AccessibleButton>;
 | 
			
		||||
            }},
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
    const [error, setError] = useState("");
 | 
			
		||||
    const [removing, setRemoving] = useState(false);
 | 
			
		||||
    const [saving, setSaving] = useState(false);
 | 
			
		||||
 | 
			
		||||
    let content;
 | 
			
		||||
    if (roomsMap) {
 | 
			
		||||
        content = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
 | 
			
		||||
            <HierarchyLevel
 | 
			
		||||
                spaceId={space.roomId}
 | 
			
		||||
                rooms={roomsMap}
 | 
			
		||||
                editing={isEditing}
 | 
			
		||||
                relations={relations}
 | 
			
		||||
                parents={new Set()}
 | 
			
		||||
                queueAction={action => {
 | 
			
		||||
                    pendingActions.current.set(action.event.room_id, action);
 | 
			
		||||
                }}
 | 
			
		||||
                onPreviewClick={roomId => {
 | 
			
		||||
                    showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), false);
 | 
			
		||||
                    onFinished();
 | 
			
		||||
                }}
 | 
			
		||||
                onJoinClick={(roomId) => {
 | 
			
		||||
                    showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), true);
 | 
			
		||||
                    onFinished();
 | 
			
		||||
                }}
 | 
			
		||||
            />
 | 
			
		||||
        </AutoHideScrollbar>;
 | 
			
		||||
        const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length;
 | 
			
		||||
        const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
 | 
			
		||||
 | 
			
		||||
        let countsStr;
 | 
			
		||||
        if (numSpaces > 1) {
 | 
			
		||||
            countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
 | 
			
		||||
        } else if (numSpaces > 0) {
 | 
			
		||||
            countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
 | 
			
		||||
        } else {
 | 
			
		||||
            countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let editSection;
 | 
			
		||||
        if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
 | 
			
		||||
            const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
 | 
			
		||||
                return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            let buttons;
 | 
			
		||||
            if (selectedRelations.length) {
 | 
			
		||||
                const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
 | 
			
		||||
                    return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                const disabled = removing || saving;
 | 
			
		||||
 | 
			
		||||
                buttons = <>
 | 
			
		||||
                    <AccessibleButton
 | 
			
		||||
                        onClick={async () => {
 | 
			
		||||
                            setRemoving(true);
 | 
			
		||||
                            try {
 | 
			
		||||
                                for (const [parentId, childId] of selectedRelations) {
 | 
			
		||||
                                    await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
 | 
			
		||||
                                    parentChildMap.get(parentId).get(childId).content = {};
 | 
			
		||||
                                    parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
 | 
			
		||||
                                }
 | 
			
		||||
                            } catch (e) {
 | 
			
		||||
                                setError(_t("Failed to remove some rooms. Try again later"));
 | 
			
		||||
                            }
 | 
			
		||||
                            setRemoving(false);
 | 
			
		||||
                        }}
 | 
			
		||||
                        kind="danger_outline"
 | 
			
		||||
                        disabled={disabled}
 | 
			
		||||
                    >
 | 
			
		||||
                        { removing ? _t("Removing...") : _t("Remove") }
 | 
			
		||||
                    </AccessibleButton>
 | 
			
		||||
                    <AccessibleButton
 | 
			
		||||
                        onClick={async () => {
 | 
			
		||||
                            setSaving(true);
 | 
			
		||||
                            try {
 | 
			
		||||
                                for (const [parentId, childId] of selectedRelations) {
 | 
			
		||||
                                    const suggested = !selectionAllSuggested;
 | 
			
		||||
                                    const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
 | 
			
		||||
                                    if (!existingContent || existingContent.suggested === suggested) continue;
 | 
			
		||||
 | 
			
		||||
                                    const content = {
 | 
			
		||||
                                        ...existingContent,
 | 
			
		||||
                                        suggested: !selectionAllSuggested,
 | 
			
		||||
                                    };
 | 
			
		||||
 | 
			
		||||
                                    await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
 | 
			
		||||
 | 
			
		||||
                                    parentChildMap.get(parentId).get(childId).content = content;
 | 
			
		||||
                                    parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
 | 
			
		||||
                                }
 | 
			
		||||
                            } catch (e) {
 | 
			
		||||
                                setError("Failed to update some suggestions. Try again later");
 | 
			
		||||
                            }
 | 
			
		||||
                            setSaving(false);
 | 
			
		||||
                        }}
 | 
			
		||||
                        kind="primary_outline"
 | 
			
		||||
                        disabled={disabled}
 | 
			
		||||
                    >
 | 
			
		||||
                        { saving
 | 
			
		||||
                            ? _t("Saving...")
 | 
			
		||||
                            : (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
 | 
			
		||||
                        }
 | 
			
		||||
                    </AccessibleButton>
 | 
			
		||||
                </>;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            editSection = <span>
 | 
			
		||||
                { buttons }
 | 
			
		||||
            </span>;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let results;
 | 
			
		||||
        if (roomsMap.size) {
 | 
			
		||||
            results = <>
 | 
			
		||||
                <HierarchyLevel
 | 
			
		||||
                    spaceId={space.roomId}
 | 
			
		||||
                    rooms={roomsMap}
 | 
			
		||||
                    relations={parentChildMap}
 | 
			
		||||
                    parents={new Set()}
 | 
			
		||||
                    selectedMap={selected}
 | 
			
		||||
                    onToggleClick={(parentId, childId) => {
 | 
			
		||||
                        setError("");
 | 
			
		||||
                        if (!selected.has(parentId)) {
 | 
			
		||||
                            setSelected(new Map(selected.set(parentId, new Set([childId]))));
 | 
			
		||||
                            return;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        const parentSet = selected.get(parentId);
 | 
			
		||||
                        if (!parentSet.has(childId)) {
 | 
			
		||||
                            setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
 | 
			
		||||
                            return;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        parentSet.delete(childId);
 | 
			
		||||
                        setSelected(new Map(selected.set(parentId, new Set(parentSet))));
 | 
			
		||||
                    }}
 | 
			
		||||
                    onViewRoomClick={(roomId, autoJoin) => {
 | 
			
		||||
                        showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
 | 
			
		||||
                        onFinished();
 | 
			
		||||
                    }}
 | 
			
		||||
                />
 | 
			
		||||
                <hr />
 | 
			
		||||
            </>;
 | 
			
		||||
        } else {
 | 
			
		||||
            results = <div className="mx_SpaceRoomDirectory_noResults">
 | 
			
		||||
                <h3>{ _t("No results found") }</h3>
 | 
			
		||||
                <div>{ _t("You may want to try a different search or check for typos.") }</div>
 | 
			
		||||
            </div>;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        content = <>
 | 
			
		||||
            <div className="mx_SpaceRoomDirectory_listHeader">
 | 
			
		||||
                { countsStr }
 | 
			
		||||
                { editSection }
 | 
			
		||||
            </div>
 | 
			
		||||
            { error && <div className="mx_SpaceRoomDirectory_error">
 | 
			
		||||
                { error }
 | 
			
		||||
            </div> }
 | 
			
		||||
            <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
 | 
			
		||||
                { results }
 | 
			
		||||
                <AccessibleButton
 | 
			
		||||
                    onClick={onCreateRoomClick}
 | 
			
		||||
                    kind="primary"
 | 
			
		||||
                    className="mx_SpaceRoomDirectory_createRoom"
 | 
			
		||||
                >
 | 
			
		||||
                    { _t("Create room") }
 | 
			
		||||
                </AccessibleButton>
 | 
			
		||||
            </AutoHideScrollbar>
 | 
			
		||||
        </>;
 | 
			
		||||
    } else {
 | 
			
		||||
        content = <Spinner />;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // TODO loading state/error state
 | 
			
		||||
@@ -546,13 +568,10 @@ const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinis
 | 
			
		||||
 | 
			
		||||
                <SearchBox
 | 
			
		||||
                    className="mx_textinput_icon mx_textinput_search"
 | 
			
		||||
                    placeholder={ _t("Find a room...") }
 | 
			
		||||
                    placeholder={ _t("Search names and description") }
 | 
			
		||||
                    onSearch={setQuery}
 | 
			
		||||
                />
 | 
			
		||||
 | 
			
		||||
                <div className="mx_SpaceRoomDirectory_listHeader">
 | 
			
		||||
                    { adminButton }
 | 
			
		||||
                </div>
 | 
			
		||||
                { content }
 | 
			
		||||
            </div>
 | 
			
		||||
        </BaseDialog>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user