refactor public rooms component

This commit is contained in:
Ajay Bura 2024-03-16 20:41:55 +05:30
parent 958f14712c
commit 538b7a45a5
4 changed files with 271 additions and 207 deletions

View file

@ -91,6 +91,7 @@ export const RoomCardTopic = as<'p'>(({ className, ...props }, ref) => (
size="T200"
className={classNames(css.RoomCardTopic, className)}
{...props}
priority="400"
ref={ref}
/>
));

View file

@ -31,7 +31,7 @@ export const RoomTopicViewer = as<
</Header>
<Scroll className={css.ModalScroll} size="300" hideTrack>
<Box className={css.ModalContent} direction="Column" gap="100">
<Text size="T300" className={css.ModalTopic}>
<Text size="T300" className={css.ModalTopic} priority="400">
{emojifyAndLinkify(topic, true)}
</Text>
</Box>

View file

@ -1,6 +1,7 @@
import React, {
FormEventHandler,
MouseEventHandler,
RefObject,
useCallback,
useEffect,
useMemo,
@ -20,6 +21,7 @@ import {
Spinner,
Text,
config,
toRem,
} from 'folds';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
@ -28,9 +30,10 @@ 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, RoomCardGrid } from '../../../components/room-card';
import { RoomCard, RoomCardBase, RoomCardGrid } from '../../../components/room-card';
import { ExploreServerPathSearchParams } from '../../paths';
import { getExploreServerPath, withSearchParam } from '../../pathUtils';
import * as css from './style.css';
const getServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => ({
limit: searchParams.get('limit') ?? undefined,
@ -64,17 +67,176 @@ const useRoomTypeFilters = (): RoomTypeFilter[] =>
const FALLBACK_ROOMS_LIMIT = 24;
type SearchProps = {
active?: boolean;
loading?: boolean;
searchInputRef: RefObject<HTMLInputElement>;
onSearch: (term: string) => void;
onReset: () => void;
};
function Search({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) {
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { searchInput } = evt.target as HTMLFormElement & {
searchInput: HTMLInputElement;
};
const searchTerm = searchInput.value.trim() || undefined;
if (searchTerm) {
onSearch(searchTerm);
}
};
return (
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
<span data-spacing-node />
<Text size="L400">Search</Text>
<Input
ref={searchInputRef}
style={{ paddingRight: config.space.S300 }}
name="searchInput"
size="500"
variant="Background"
placeholder="Search for keyword"
before={
active && loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Icon size="200" src={Icons.Search} />
)
}
after={
active ? (
<Chip
type="button"
variant="Secondary"
size="400"
radii="Pill"
outlined
after={<Icon size="50" src={Icons.Cross} />}
onClick={onReset}
>
<Text size="B300">Clear</Text>
</Chip>
) : (
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
<Text size="B300">Enter</Text>
</Chip>
)
}
/>
</Box>
);
}
type LimitButtonProps = {
limit: number;
onLimitChange: (limit: string) => void;
};
function LimitButton({ limit, onLimitChange }: LimitButtonProps) {
const [openLimit, setOpenLimit] = useState(false);
const handleLimitSubmit: FormEventHandler<HTMLFormElement> = (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) => {
setOpenLimit(false);
onLimitChange(l);
};
return (
<PopOut
open={openLimit}
align="End"
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpenLimit(false),
clickOutsideDeactivates: true,
}}
>
<Menu variant="Surface">
<Box direction="Column" gap="400" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="L400">Presets</Text>
<Box gap="100" wrap="Wrap">
<Chip variant="SurfaceVariant" onClick={() => setLimit('24')} radii="Pill">
<Text size="T200">24</Text>
</Chip>
<Chip variant="SurfaceVariant" onClick={() => setLimit('48')} radii="Pill">
<Text size="T200">48</Text>
</Chip>
<Chip variant="SurfaceVariant" onClick={() => setLimit('96')} radii="Pill">
<Text size="T200">96</Text>
</Chip>
</Box>
</Box>
<Box as="form" onSubmit={handleLimitSubmit} direction="Column" gap="300">
<Box direction="Column" gap="100">
<Text size="L400">Custom Limit</Text>
<Input
name="limitInput"
size="300"
variant="Background"
defaultValue={limit}
min={1}
step={1}
outlined
type="number"
radii="400"
aria-label="Per Page Item Limit"
/>
</Box>
<Button type="submit" size="300" variant="Primary" radii="400">
<Text size="B300">Change Limit</Text>
</Button>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
{(anchorRef) => (
<Chip
ref={anchorRef}
onClick={() => setOpenLimit(!openLimit)}
aria-pressed={openLimit}
radii="Pill"
size="400"
variant="SurfaceVariant"
after={<Icon size="100" src={Icons.ChevronBottom} />}
>
<Text size="T200" truncate>{`Page Limit: ${limit}`}</Text>
</Chip>
)}
</PopOut>
);
}
export function PublicRooms() {
const { server } = useParams();
const mx = useMatrixClient();
const [searchParams] = useSearchParams();
const serverSearchParams = getServerSearchParams(searchParams);
const isSearch = serverSearchParams.term;
const isSearch = !!serverSearchParams.term;
const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const roomTypeFilters = useRoomTypeFilters();
const [openLimit, setOpenLimit] = useState(false);
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;
@ -117,9 +279,10 @@ export function PublicRooms() {
],
queryFn: fetchPublicRooms,
});
useEffect(() => {
resetScroll();
}, [data, resetScroll]);
if (isLoading) resetScroll();
}, [isLoading, resetScroll]);
const explore = (newSearchParams: ExploreServerPathSearchParams) => {
if (!server) return;
@ -145,15 +308,9 @@ export function PublicRooms() {
explore({ since: token });
};
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { searchInput } = evt.target as HTMLFormElement & {
searchInput: HTMLInputElement;
};
const searchTerm = searchInput.value.trim() || undefined;
const handleSearch = (term: string) => {
explore({
term: searchTerm,
term,
since: undefined,
});
};
@ -176,20 +333,10 @@ export function PublicRooms() {
});
};
const setLimit = (limit: string) => {
setOpenLimit(false);
const handleLimitChange = (limit: string) => {
explore({ limit });
};
const handleLimitSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const limitInput = evt.currentTarget.limitInput as HTMLInputElement;
if (!limitInput) return;
const limit = limitInput.value.trim();
if (!limit) return;
setLimit(limit);
};
return (
<Page>
<PageHeader>
@ -228,193 +375,80 @@ export function PublicRooms() {
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Box direction="Column" gap="200">
<Box direction="Column" gap="500">
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
<span data-spacing-node />
<Text size="L400">Search</Text>
<Input
ref={searchInputRef}
style={{ paddingRight: config.space.S300 }}
name="searchInput"
size="500"
variant="Background"
placeholder="Search for keyword"
before={
isSearch && isLoading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Icon size="200" src={Icons.Search} />
)
}
after={
isSearch ? (
<Chip
type="button"
variant="Secondary"
size="400"
radii="Pill"
outlined
after={<Icon size="50" src={Icons.Cross} />}
onClick={handleSearchClear}
>
<Text size="B300">Clear</Text>
</Chip>
) : (
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
<Text size="B300">Enter</Text>
</Chip>
)
}
/>
</Box>
<Box direction="Column" gap="400">
<Box direction="Column" gap="300">
{isSearch ? (
<Text size="H4">{`Public Communities for "${serverSearchParams.term}"`}</Text>
) : (
<Text size="H4">Public Communities</Text>
)}
<Box gap="200">
{roomTypeFilters.map((filter) => (
<Chip
key={filter.title}
onClick={handleRoomFilterClick}
data-room-filter={filter.value}
variant={
filter.value === serverSearchParams.type ? 'Success' : 'Surface'
}
aria-pressed={filter.value === serverSearchParams.type}
before={
filter.value === serverSearchParams.type && (
<Icon size="100" src={Icons.Check} />
)
}
outlined
>
<Text size="T200">{filter.title}</Text>
</Chip>
))}
<Box grow="Yes" data-spacing-node />
<PopOut
open={openLimit}
align="End"
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpenLimit(false),
clickOutsideDeactivates: true,
}}
>
<Menu variant="Surface">
<Box
direction="Column"
gap="300"
style={{ padding: config.space.S200 }}
>
<Box direction="Column" gap="100">
<Text size="L400">Presets</Text>
<Box gap="100" wrap="Wrap">
<Chip
variant="SurfaceVariant"
onClick={() => setLimit('24')}
radii="Pill"
>
<Text size="T200">24</Text>
</Chip>
<Chip
variant="SurfaceVariant"
onClick={() => setLimit('48')}
radii="Pill"
>
<Text size="T200">48</Text>
</Chip>
<Chip
variant="SurfaceVariant"
onClick={() => setLimit('96')}
radii="Pill"
>
<Text size="T200">96</Text>
</Chip>
</Box>
</Box>
<Box
as="form"
onSubmit={handleLimitSubmit}
direction="Column"
gap="200"
>
<Box direction="Column" gap="100">
<Text size="L400">Custom Limit</Text>
<Input
name="limitInput"
size="300"
variant="Background"
defaultValue={
serverSearchParams.limit ?? FALLBACK_ROOMS_LIMIT
}
min={1}
step={1}
outlined
type="number"
radii="400"
aria-label="Per Page Item Limit"
/>
</Box>
<Button type="submit" size="300" variant="Primary" radii="400">
<Text size="B300">Change Limit</Text>
</Button>
</Box>
</Box>
</Menu>
</FocusTrap>
<Box direction="Column" gap="600">
<Search
active={isSearch}
loading={isLoading}
searchInputRef={searchInputRef}
onSearch={handleSearch}
onReset={handleSearchClear}
/>
<Box direction="Column" gap="400">
<Box direction="Column" gap="300">
{isSearch ? (
<Text size="H4">{`Results for "${serverSearchParams.term}"`}</Text>
) : (
<Text size="H4">Public Communities</Text>
)}
<Box gap="200">
{roomTypeFilters.map((filter) => (
<Chip
key={filter.title}
onClick={handleRoomFilterClick}
data-room-filter={filter.value}
variant={filter.value === serverSearchParams.type ? 'Success' : 'Surface'}
aria-pressed={filter.value === serverSearchParams.type}
before={
filter.value === serverSearchParams.type && (
<Icon size="100" src={Icons.Check} />
)
}
outlined
>
{(anchorRef) => (
<Chip
ref={anchorRef}
onClick={() => setOpenLimit(!openLimit)}
aria-pressed={openLimit}
radii="Pill"
size="400"
variant="SurfaceVariant"
after={<Icon size="100" src={Icons.ChevronBottom} />}
>
<Text size="T200" truncate>{`Page Limit: ${
serverSearchParams.limit ?? FALLBACK_ROOMS_LIMIT
}`}</Text>
</Chip>
)}
</PopOut>
</Box>
<Text size="T200">{filter.title}</Text>
</Chip>
))}
<Box grow="Yes" data-spacing-node />
<LimitButton limit={currentLimit} onLimitChange={handleLimitChange} />
</Box>
{isLoading && !error && <Text>loading...</Text>}
{error && <Text>{error.message}</Text>}
</Box>
{isLoading && (
<RoomCardGrid>
{data?.chunk.map((chunkRoom) => (
<RoomCard
key={chunkRoom.room_id}
roomIdOrAlias={chunkRoom.canonical_alias ?? chunkRoom.room_id}
joinedRoomId={mx.getRoom(chunkRoom.room_id)?.roomId}
avatarUrl={chunkRoom.avatar_url}
name={chunkRoom.name}
topic={chunkRoom.topic}
memberCount={chunkRoom.num_joined_members}
roomType={chunkRoom.room_type}
renderTopicViewer={(name, topic, requestClose) => (
<RoomTopicViewer
name={name}
topic={topic}
requestClose={requestClose}
/>
)}
/>
{[...Array(currentLimit).keys()].map((item) => (
<RoomCardBase key={item} style={{ minHeight: toRem(260) }} />
))}
</RoomCardGrid>
{data && (
)}
{error && (
<Box direction="Column" className={css.PublicRoomsError} gap="200">
<Text size="L400">{error.name}</Text>
<Text size="T300">{error.message}</Text>
</Box>
)}
{data &&
(data.chunk.length > 0 ? (
<>
<RoomCardGrid>
{data?.chunk.map((chunkRoom) => (
<RoomCard
key={chunkRoom.room_id}
roomIdOrAlias={chunkRoom.canonical_alias ?? chunkRoom.room_id}
joinedRoomId={mx.getRoom(chunkRoom.room_id)?.roomId}
avatarUrl={chunkRoom.avatar_url}
name={chunkRoom.name}
topic={chunkRoom.topic}
memberCount={chunkRoom.num_joined_members}
roomType={chunkRoom.room_type}
renderTopicViewer={(name, topic, requestClose) => (
<RoomTopicViewer
name={name}
topic={topic}
requestClose={requestClose}
/>
)}
/>
))}
</RoomCardGrid>
<span data-spacing-node />
<Box justifyContent="Center" gap="200">
<Button
@ -440,8 +474,18 @@ export function PublicRooms() {
</Button>
</Box>
</>
)}
</Box>
) : (
<Box
className={css.PublicRoomsInfo}
direction="Column"
justifyContent="Center"
alignItems="Center"
gap="200"
>
<Icon size="400" src={Icons.Info} />
<Text size="T300">No communities found!</Text>
</Box>
))}
</Box>
</Box>
</PageContentCenter>

View file

@ -0,0 +1,19 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { ContainerColor } from '../../../styles/ContainerColor.css';
export const PublicRoomsInfo = style([
ContainerColor({ variant: 'SurfaceVariant' }),
{
padding: `${config.space.S700} ${config.space.S300}`,
borderRadius: config.radii.R400,
},
]);
export const PublicRoomsError = style([
ContainerColor({ variant: 'Critical' }),
{
padding: config.space.S300,
borderRadius: config.radii.R400,
},
]);