This commit is contained in:
Ginger 2025-10-16 16:46:47 +02:00 committed by GitHub
commit 106e8e2c22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 450 additions and 168 deletions

View file

@ -1,5 +1,8 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View file

@ -0,0 +1,45 @@
import { useCallback, useMemo } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { useAccountData } from './useAccountData';
import { useMatrixClient } from './useMatrixClient';
export type InCinnyExploreServersContent = {
servers?: string[];
};
export const useExploreServers = (): [
string[],
(server: string) => Promise<void>,
(server: string) => Promise<void>
] => {
const mx = useMatrixClient();
const accountData = useAccountData(AccountDataEvent.CinnyExplore);
const userAddedServers = useMemo(
() => accountData?.getContent<InCinnyExploreServersContent>()?.servers ?? [],
[accountData]
);
const addServer = useCallback(
async (server: string) => {
if (userAddedServers.indexOf(server) === -1) {
// @ts-expect-error Custom account data event
await mx.setAccountData(AccountDataEvent.CinnyExplore, {
servers: [...userAddedServers, server],
});
}
},
[mx, userAddedServers]
);
const removeServer = useCallback(
async (server: string) => {
// @ts-expect-error Custom account data event
await mx.setAccountData(AccountDataEvent.CinnyExplore, {
servers: userAddedServers.filter((addedServer) => server !== addedServer),
});
},
[mx, userAddedServers]
);
return [userAddedServers, addServer, removeServer];
};

View file

@ -1,4 +1,4 @@
import React, { FormEventHandler, useCallback, useRef, useState } from 'react';
import React, { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
import {
@ -9,24 +9,28 @@ import {
Header,
Icon,
IconButton,
IconSrc,
Icons,
Input,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
color,
config,
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavItem,
NavItemContent,
NavItemOptions,
NavLink,
} from '../../../components/nav';
import { getExploreFeaturedPath, getExploreServerPath } from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import {
useExploreFeaturedSelected,
useExploreServer,
@ -37,17 +41,24 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { stopPropagation } from '../../../utils/keyboard';
import { useExploreServers } from '../../../hooks/useExploreServers';
import { useAlive } from '../../../hooks/useAlive';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { UseStateProvider } from '../../../components/UseStateProvider';
export function AddServer() {
const mx = useMatrixClient();
const navigate = useNavigate();
const [dialog, setDialog] = useState(false);
type AddExploreServerPromptProps = {
onSubmit: (server: string, save: boolean) => Promise<void>;
open: boolean;
requestClose: () => void;
};
export function AddExploreServerPrompt({
onSubmit,
open,
requestClose,
}: AddExploreServerPromptProps) {
const alive = useAlive();
const serverInputRef = useRef<HTMLInputElement>(null);
const [exploreState] = useAsyncCallback(
useCallback((server: string) => mx.publicRooms({ server, limit: 1 }), [mx])
);
const getInputServer = (): string | undefined => {
const serverInput = serverInputRef.current;
if (!serverInput) return undefined;
@ -55,32 +66,32 @@ export function AddServer() {
return server || undefined;
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const submit = useCallback(
async (save: boolean) => {
const server = getInputServer();
if (!server) return;
// explore(server);
navigate(getExploreServerPath(server));
setDialog(false);
};
await onSubmit(server, save);
if (alive()) {
requestClose();
}
},
[onSubmit, alive, requestClose]
);
const handleView = () => {
const server = getInputServer();
if (!server) return;
navigate(getExploreServerPath(server));
setDialog(false);
};
const [viewState, handleView] = useAsyncCallback(() => submit(false));
const [saveViewState, handleSaveView] = useAsyncCallback(() => submit(true));
const busy =
viewState.status === AsyncStatus.Loading || saveViewState.status === AsyncStatus.Loading;
return (
<>
<Overlay open={dialog} backdrop={<OverlayBackdrop />}>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setDialog(false),
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}
>
@ -96,76 +107,162 @@ export function AddServer() {
<Box grow="Yes">
<Text size="H4">Add Server</Text>
</Box>
<IconButton size="300" onClick={() => setDialog(false)} radii="300">
<IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400">Add server name to explore public communities.</Text>
<Box direction="Column" gap="100">
<Text size="L400">Server Name</Text>
<Input ref={serverInputRef} name="serverInput" variant="Background" required />
{exploreState.status === AsyncStatus.Error && (
{viewState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to load public rooms. Please try again.
</Text>
)}
</Box>
<Box direction="Column" gap="200">
{/* <Button
<Button
type="submit"
variant="Secondary"
onClick={handleView}
variant="Primary"
fill="Soft"
before={
exploreState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Secondary" size="200" />
) : undefined
viewState.status === AsyncStatus.Loading && (
<Spinner fill="Soft" variant="Secondary" size="200" />
)
}
aria-disabled={exploreState.status === AsyncStatus.Loading}
disabled={busy}
>
<Text size="B400">Save</Text>
</Button> */}
<Button type="submit" onClick={handleView} variant="Secondary" fill="Soft">
<Text size="B400">View</Text>
</Button>
<Button
type="submit"
onClick={handleSaveView}
variant="Primary"
fill="Solid"
before={
saveViewState.status === AsyncStatus.Loading && (
<Spinner fill="Solid" variant="Secondary" size="200" />
)
}
disabled={busy}
>
<Text size="B400">Save & View</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Button
variant="Secondary"
fill="Soft"
size="300"
before={<Icon size="100" src={Icons.Plus} />}
onClick={() => setDialog(true)}
);
}
type ExploreServerNavItemProps = {
server: string;
selected: boolean;
icon: IconSrc;
after?: (hover: boolean) => ReactNode;
};
export function ExploreServerNavItem({ server, selected, icon, after }: ExploreServerNavItemProps) {
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
return (
<NavItem
variant="Background"
radii="400"
aria-selected={selected}
{...hoverProps}
{...focusWithinProps}
>
<Text size="B300" truncate>
Add Server
<NavLink to={getExploreServerPath(server)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={icon} size="100" filled={selected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{server}
</Text>
</Button>
</>
</Box>
</Box>
</NavItemContent>
</NavLink>
{/* {action !== undefined && (hover || actionInProgress || action.alwaysVisible) && (
<NavItemOptions>
<IconButton
onClick={actionCallback}
variant="Background"
fill="None"
size="300"
radii="300"
disabled={actionInProgress}
>
{actionInProgress ? (
<Spinner variant="Secondary" fill="Solid" size="200" />
) : (
<Icon size="50" src={action.icon} />
)}
</IconButton>
</NavItemOptions>
)} */}
{after && <NavItemOptions>{after(hover)}</NavItemOptions>}
</NavItem>
);
}
export function Explore() {
const mx = useMatrixClient();
const navigate = useNavigate();
useNavToActivePathMapper('explore');
const userId = mx.getUserId();
const clientConfig = useClientConfig();
const userServer = userId ? getMxIdServer(userId) : undefined;
const servers =
clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? [];
const featuredServers = useMemo(
() =>
clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? [],
[clientConfig, userServer]
);
const [exploreServers, addServer, removeServer] = useExploreServers();
const featuredSelected = useExploreFeaturedSelected();
const selectedServer = useExploreServer();
const exploringFeaturedRooms = useExploreFeaturedSelected();
const exploringUnlistedServer = useMemo(
() =>
!(
selectedServer === undefined ||
selectedServer === userServer ||
featuredServers.includes(selectedServer) ||
exploreServers.includes(selectedServer)
),
[exploreServers, selectedServer, userServer, featuredServers]
);
const [addServerState, addServerCallback] = useAsyncCallback(
useCallback(
async (server: string, save: boolean) => {
if (save && server !== userServer && !featuredServers.includes(server) && selectedServer) {
await addServer(server);
}
navigate(getExploreServerPath(server));
},
[addServer, navigate, userServer, featuredServers, selectedServer]
)
);
const [removeServerState, removeServerCallback] = useAsyncCallback(
useCallback(
async (server: string) => {
await removeServer(server);
},
[removeServer]
)
);
return (
<PageNav>
@ -182,12 +279,12 @@ export function Explore() {
<PageNavContent>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={featuredSelected}>
<NavItem variant="Background" radii="400" aria-selected={exploringFeaturedRooms}>
<NavLink to={getExploreFeaturedPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Bulb} size="100" filled={featuredSelected} />
<Icon src={Icons.Bulb} size="100" filled={exploringFeaturedRooms} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
@ -199,67 +296,116 @@ export function Explore() {
</NavLink>
</NavItem>
{userServer && (
<NavItem
variant="Background"
radii="400"
aria-selected={selectedServer === userServer}
>
<NavLink to={getExploreServerPath(userServer)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon
src={Icons.Server}
size="100"
filled={selectedServer === userServer}
<ExploreServerNavItem
server={userServer}
selected={userServer === selectedServer}
icon={Icons.Server}
/>
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{userServer}
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
)}
</NavCategory>
{servers.length > 0 && (
<NavCategory>
<NavCategoryHeader>
<Text size="O400" style={{ paddingLeft: config.space.S200 }}>
Servers
</Text>
</NavCategoryHeader>
{servers.map((server) => (
<NavItem
{featuredServers.map((server) => (
<ExploreServerNavItem
key={server}
server={server}
selected={server === selectedServer}
icon={Icons.Server}
/>
))}
{exploreServers
.filter((server) => !featuredServers.includes(server))
.map((server) => (
<ExploreServerNavItem
key={server}
server={server}
selected={server === selectedServer}
icon={Icons.Server}
after={(hover) => {
const busy = removeServerState.status === AsyncStatus.Loading;
if (!(hover || busy)) {
return undefined;
}
return (
<IconButton
onClick={() => removeServerCallback(server)}
variant="Background"
radii="400"
aria-selected={server === selectedServer}
fill="None"
size="300"
radii="300"
disabled={busy}
>
<NavLink to={getExploreServerPath(server)}>
{busy ? (
<Spinner variant="Secondary" fill="Solid" size="200" />
) : (
<Icon size="50" src={Icons.Minus} />
)}
</IconButton>
);
}}
/>
))}
{exploringUnlistedServer && selectedServer !== undefined && (
<ExploreServerNavItem
server={selectedServer}
selected
icon={Icons.Server}
after={() => {
const busy = addServerState.status === AsyncStatus.Loading;
return (
<IconButton
onClick={() => addServerCallback(selectedServer, true)}
variant="Background"
fill="None"
size="300"
radii="300"
disabled={busy}
>
{busy ? (
<Spinner variant="Secondary" fill="Solid" size="200" />
) : (
<Icon size="50" src={Icons.Bookmark} />
)}
</IconButton>
);
}}
/>
)}
<UseStateProvider initial={false}>
{(dialogOpen, setDialogOpen) => (
<>
<NavItem variant="Background">
<NavButton onClick={() => setDialogOpen(true)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Server} size="100" filled={server === selectedServer} />
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{server}
Add Server
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavButton>
</NavItem>
))}
</NavCategory>
<AddExploreServerPrompt
onSubmit={addServerCallback}
open={dialogOpen}
requestClose={() => setDialogOpen(false)}
/>
</>
)}
<Box direction="Column">
<AddServer />
</Box>
</UseStateProvider>
</NavCategory>
</Box>
</PageNavContent>
</PageNav>

View file

@ -24,6 +24,8 @@ import {
Scroll,
Spinner,
Text,
Tooltip,
TooltipProvider,
config,
toRem,
} from 'folds';
@ -45,6 +47,8 @@ import { getMxIdServer } from '../../../utils/matrix';
import { stopPropagation } from '../../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useExploreServers } from '../../../hooks/useExploreServers';
const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams =>
useMemo(
@ -348,6 +352,7 @@ export function PublicRooms() {
const allRooms = useAtomValue(allRoomsAtom);
const { navigateSpace, navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [searchParams] = useSearchParams();
const serverSearchParams = useServerSearchParams(searchParams);
@ -356,6 +361,9 @@ export function PublicRooms() {
const searchInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const roomTypeFilters = useRoomTypeFilters();
const [exploreServers, , removeServer] = useExploreServers();
const isUserAddedServer = server && exploreServers.includes(server);
const isUserHomeServer = server && server === userServer;
const currentLimit: number = useMemo(() => {
const limitParam = serverSearchParams.limit;
@ -468,6 +476,20 @@ export function PublicRooms() {
explore({ instance: instanceId, since: undefined });
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const [removeServerState, handleRemoveServer] = useAsyncCallback(
useCallback(async () => {
if (!server) return;
setMenuAnchor(undefined);
await removeServer(server);
}, [server, removeServer])
);
const isRemoving = removeServerState.status === AsyncStatus.Loading;
return (
<Page>
<PageHeader balance>
@ -506,13 +528,78 @@ export function PublicRooms() {
</BackRouteHandler>
)}
</Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Server} />}
<Box grow="Yes" basis="Yes" justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={isUserHomeServer ? Icons.Home : Icons.Server} />}
<Text size="H3" truncate>
{server}
</Text>
</Box>
<Box grow="Yes" basis="No" />
<Box shrink="No" grow="Yes" basis="No" justifyContent="End">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>More Options</Text>
</Tooltip>
}
>
{(triggerRef) =>
isUserAddedServer && (
<IconButton
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton>
)
}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleRemoveServer}
variant="Critical"
fill="None"
size="300"
after={
isRemoving ? (
<Spinner fill="Solid" variant="Secondary" size="200" />
) : (
<Icon size="100" src={Icons.Delete} />
)
}
radii="300"
disabled={isRemoving}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Remove Server
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</Box>
</>
)}
</PageHeader>

View file

@ -4,6 +4,7 @@ export enum AccountDataEvent {
IgnoredUserList = 'm.ignored_user_list',
CinnySpaces = 'in.cinny.spaces',
CinnyExplore = 'in.cinny.explore',
ElementRecentEmoji = 'io.element.recent_emoji',