import React, { FormEventHandler, MouseEventHandler, RefObject, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { Box, Button, Chip, Icon, IconButton, Icons, Input, Line, Menu, MenuItem, PopOut, RectCords, Scroll, Spinner, Text, config, toRem, } from 'folds'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import FocusTrap from 'focus-trap-react'; import { useAtomValue } from 'jotai'; import { useQuery } from '@tanstack/react-query'; import { MatrixClient, Method, RoomType } from 'matrix-js-sdk'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import { RoomCard, RoomCardBase, RoomCardGrid } from '../../../components/room-card'; import { ExploreServerPathSearchParams } from '../../paths'; import { getExploreServerPath, withSearchParam } from '../../pathUtils'; import * as css from './style.css'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { getMxIdServer } from '../../../utils/matrix'; import { stopPropagation } from '../../../utils/keyboard'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => useMemo( () => ({ limit: searchParams.get('limit') ?? undefined, since: searchParams.get('since') ?? undefined, term: searchParams.get('term') ?? undefined, type: searchParams.get('type') ?? undefined, instance: searchParams.get('instance') ?? undefined, }), [searchParams] ); type RoomTypeFilter = { title: string; value: string | undefined; }; const useRoomTypeFilters = (): RoomTypeFilter[] => useMemo( () => [ { title: 'All', value: undefined, }, { title: 'Spaces', value: RoomType.Space, }, { title: 'Rooms', value: 'null', }, ], [] ); const FALLBACK_ROOMS_LIMIT = 24; type SearchProps = { active?: boolean; loading?: boolean; searchInputRef: RefObject; onSearch: (term: string) => void; onReset: () => void; }; function Search({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) { const handleSearchSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const { searchInput } = evt.target as HTMLFormElement & { searchInput: HTMLInputElement; }; const searchTerm = searchInput.value.trim() || undefined; if (searchTerm) { onSearch(searchTerm); } }; return ( Search ) : ( ) } after={ active ? ( } onClick={onReset} > Clear ) : ( Enter ) } /> ); } const DEFAULT_INSTANCE_NAME = 'Matrix'; function ThirdPartyProtocolsSelector({ instanceId, onChange, }: { instanceId?: string; onChange: (instanceId?: string) => void; }) { const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); const { data } = useQuery({ queryKey: ['thirdparty', 'protocols'], queryFn: () => mx.getThirdpartyProtocols(), }); const handleInstanceSelect: MouseEventHandler = (evt): void => { const insId = evt.currentTarget.getAttribute('data-instance-id') ?? undefined; onChange(insId); setMenuAnchor(undefined); }; const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; const instances = data && Object.keys(data).flatMap((protocol) => data[protocol].instances); if (!instances || instances.length === 0) return null; const selectedInstance = instances.find((instance) => instanceId === instance.instance_id); return ( setMenuAnchor(undefined), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > Protocols {DEFAULT_INSTANCE_NAME} {instances.map((instance) => ( {instance.desc} ))} } > } > {selectedInstance?.desc ?? DEFAULT_INSTANCE_NAME} ); } type LimitButtonProps = { limit: number; onLimitChange: (limit: string) => void; }; function LimitButton({ limit, onLimitChange }: LimitButtonProps) { const [menuAnchor, setMenuAnchor] = useState(); const handleLimitSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const limitInput = evt.currentTarget.limitInput as HTMLInputElement; if (!limitInput) return; const newLimit = limitInput.value.trim(); if (!newLimit) return; onLimitChange(newLimit); }; const setLimit = (l: string) => { setMenuAnchor(undefined); onLimitChange(l); }; const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; return ( setMenuAnchor(undefined), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > Presets setLimit('24')} radii="Pill"> 24 setLimit('48')} radii="Pill"> 48 setLimit('96')} radii="Pill"> 96 Custom Limit } > } > {`Page Limit: ${limit}`} ); } export function PublicRooms() { const { server } = useParams(); const mx = useMatrixClient(); const userId = mx.getUserId(); const userServer = userId && getMxIdServer(userId); const allRooms = useAtomValue(allRoomsAtom); const { navigateSpace, navigateRoom } = useRoomNavigate(); const screenSize = useScreenSizeContext(); const [searchParams] = useSearchParams(); const serverSearchParams = useServerSearchParams(searchParams); const isSearch = !!serverSearchParams.term; const scrollRef = useRef(null); const searchInputRef = useRef(null); const navigate = useNavigate(); const roomTypeFilters = useRoomTypeFilters(); const currentLimit: number = useMemo(() => { const limitParam = serverSearchParams.limit; if (!limitParam) return FALLBACK_ROOMS_LIMIT; return parseInt(limitParam, 10) || FALLBACK_ROOMS_LIMIT; }, [serverSearchParams.limit]); const resetScroll = useCallback(() => { const scroll = scrollRef.current; if (scroll) scroll.scrollTop = 0; }, []); const fetchPublicRooms = useCallback(() => { const limit = typeof serverSearchParams.limit === 'string' ? parseInt(serverSearchParams.limit, 10) : FALLBACK_ROOMS_LIMIT; const roomType: string | null | undefined = serverSearchParams.type === 'null' ? null : serverSearchParams.type; return mx.http.authedRequest>>( Method.Post, '/publicRooms', { server, }, { limit, since: serverSearchParams.since, filter: { generic_search_term: serverSearchParams.term, room_types: roomType !== undefined ? [roomType] : undefined, }, third_party_instance_id: serverSearchParams.instance, } ); }, [mx, server, serverSearchParams]); const { data, isLoading, error } = useQuery({ queryKey: [ server, 'publicRooms', serverSearchParams.limit, serverSearchParams.since, serverSearchParams.term, serverSearchParams.type, serverSearchParams.instance, ], queryFn: fetchPublicRooms, }); useEffect(() => { if (isLoading) resetScroll(); }, [isLoading, resetScroll]); const explore = (newSearchParams: ExploreServerPathSearchParams) => { if (!server) return; const sParams: Record = { ...serverSearchParams, ...newSearchParams, }; Object.keys(sParams).forEach((key) => { if (sParams[key] === undefined) delete sParams[key]; }); const path = withSearchParam(getExploreServerPath(server), sParams); navigate(path); }; const paginateBack = () => { const token = data?.prev_batch; explore({ since: token }); }; const paginateFront = () => { const token = data?.next_batch; explore({ since: token }); }; const handleSearch = (term: string) => { explore({ term, since: undefined, }); }; const handleSearchClear = () => { if (searchInputRef.current) { searchInputRef.current.value = ''; } explore({ term: undefined, since: undefined, }); }; const handleRoomFilterClick: MouseEventHandler = (evt) => { const filter = evt.currentTarget.getAttribute('data-room-filter'); explore({ type: filter ?? undefined, since: undefined, }); }; const handleLimitChange = (limit: string) => { explore({ limit }); }; const handleInstanceIdChange = (instanceId?: string) => { explore({ instance: instanceId, since: undefined }); }; return ( {isSearch ? ( <> } onClick={handleSearchClear} > {server} {screenSize !== ScreenSize.Mobile && } Search ) : ( <> {screenSize === ScreenSize.Mobile && ( {(onBack) => ( )} )} {screenSize !== ScreenSize.Mobile && } {server} )} {isSearch ? ( {`Results for "${serverSearchParams.term}"`} ) : ( Popular Communities )} {roomTypeFilters.map((filter) => ( ) } outlined > {filter.title} ))} {userServer === server && ( <> )} {isLoading && ( {[...Array(currentLimit).keys()].map((item) => ( ))} )} {error && ( {error.name} {error.message} )} {data && (data.chunk.length > 0 ? ( <> {data?.chunk.map((chunkRoom) => ( ( )} /> ))} {(data.prev_batch || data.next_batch) && ( )} ) : ( No communities found! ))} ); }