This commit is contained in:
Ginger 2025-09-12 12:41:25 -04:00 committed by GitHub
commit c359e08e04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 376 additions and 122 deletions

View file

@ -1,5 +1,8 @@
{ {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode", "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

@ -11,7 +11,7 @@ export const useExploreSelected = (): boolean => {
return !!match; return !!match;
}; };
export const useExploreFeaturedSelected = (): boolean => { export const useExploreFeaturedRooms = (): boolean => {
const match = useMatch({ const match = useMatch({
path: getExploreFeaturedPath(), path: getExploreFeaturedPath(),
caseSensitive: true, caseSensitive: true,

View file

@ -0,0 +1,44 @@
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 = (
exclude?: string[]
): [string[], (server: string) => Promise<void>, (server: string) => Promise<void>] => {
const mx = useMatrixClient();
const accountData = useAccountData(AccountDataEvent.CinnyExplore);
const userAddedServers = useMemo(
() =>
accountData
?.getContent<InCinnyExploreServersContent>()
?.servers?.filter((server) => !exclude?.includes(server)) ?? [],
[exclude, accountData]
);
const addServer = useCallback(
async (server: string) => {
if (userAddedServers.indexOf(server) === -1) {
await mx.setAccountData(AccountDataEvent.CinnyExplore, {
servers: [...userAddedServers, server],
});
}
},
[mx, userAddedServers]
);
const removeServer = useCallback(
async (server: string) => {
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 { useNavigate } from 'react-router-dom';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
@ -9,26 +9,30 @@ import {
Header, Header,
Icon, Icon,
IconButton, IconButton,
IconSrc,
Icons, Icons,
Input, Input,
Overlay, Overlay,
OverlayBackdrop, OverlayBackdrop,
OverlayCenter, OverlayCenter,
Spinner,
Text, Text,
color, color,
config, config,
} from 'folds'; } from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import { import {
NavButton,
NavCategory, NavCategory,
NavCategoryHeader, NavCategoryHeader,
NavItem, NavItem,
NavItemContent, NavItemContent,
NavItemOptions,
NavLink, NavLink,
} from '../../../components/nav'; } from '../../../components/nav';
import { getExploreFeaturedPath, getExploreServerPath } from '../../pathUtils'; import { getExploreFeaturedPath, getExploreServerPath } from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { import {
useExploreFeaturedSelected, useExploreFeaturedRooms,
useExploreServer, useExploreServer,
} from '../../../hooks/router/useExploreSelected'; } from '../../../hooks/router/useExploreSelected';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -37,17 +41,27 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { useExploreServers } from '../../../hooks/useExploreServers';
import { useAlive } from '../../../hooks/useAlive';
import { useClientConfig } from '../../../hooks/useClientConfig';
export function AddServer() { type AddExploreServerPromptProps = {
onSubmit: (server: string, save: boolean) => Promise<void>;
header: ReactNode;
children: ReactNode;
selected?: boolean;
};
export function AddExploreServerPrompt({
onSubmit,
header,
children,
selected = false,
}: AddExploreServerPromptProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate();
const [dialog, setDialog] = useState(false); const [dialog, setDialog] = useState(false);
const alive = useAlive();
const serverInputRef = useRef<HTMLInputElement>(null); const serverInputRef = useRef<HTMLInputElement>(null);
const [exploreState] = useAsyncCallback(
useCallback((server: string) => mx.publicRooms({ server, limit: 1 }), [mx])
);
const getInputServer = (): string | undefined => { const getInputServer = (): string | undefined => {
const serverInput = serverInputRef.current; const serverInput = serverInputRef.current;
if (!serverInput) return undefined; if (!serverInput) return undefined;
@ -55,22 +69,24 @@ export function AddServer() {
return server || undefined; return server || undefined;
}; };
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => { const submit = useCallback(
evt.preventDefault(); async (save: boolean) => {
const server = getInputServer(); const server = getInputServer();
if (!server) return; if (!server) return;
// explore(server);
navigate(getExploreServerPath(server)); await mx.publicRooms({ server, limit: 1 });
setDialog(false); await onSubmit(server, save);
}; if (alive()) {
setDialog(false);
}
},
[alive, onSubmit, mx]
);
const handleView = () => { const [viewState, handleView] = useAsyncCallback(() => submit(false));
const server = getInputServer(); const [saveViewState, handleSaveView] = useAsyncCallback(() => submit(true));
if (!server) return; const busy =
navigate(getExploreServerPath(server)); viewState.status === AsyncStatus.Loading || saveViewState.status === AsyncStatus.Loading;
setDialog(false);
};
return ( return (
<> <>
@ -93,79 +109,181 @@ export function AddServer() {
variant="Surface" variant="Surface"
size="500" size="500"
> >
<Box grow="Yes"> <Box grow="Yes">{header}</Box>
<Text size="H4">Add Server</Text>
</Box>
<IconButton size="300" onClick={() => setDialog(false)} radii="300"> <IconButton size="300" onClick={() => setDialog(false)} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
<Box <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Text priority="400">Add server name to explore public communities.</Text> <Text priority="400">Add server name to explore public communities.</Text>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Server Name</Text> <Text size="L400">Server Name</Text>
<Input ref={serverInputRef} name="serverInput" variant="Background" required /> <Input ref={serverInputRef} name="serverInput" variant="Background" required />
{exploreState.status === AsyncStatus.Error && ( {viewState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300"> <Text style={{ color: color.Critical.Main }} size="T300">
Failed to load public rooms. Please try again. Failed to load public rooms. Please try again.
</Text> </Text>
)} )}
</Box> </Box>
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
{/* <Button <Button
type="submit" type="submit"
onClick={handleView}
variant="Secondary" variant="Secondary"
fill="Soft"
before={ before={
exploreState.status === AsyncStatus.Loading ? ( viewState.status === AsyncStatus.Loading && (
<Spinner fill="Solid" variant="Secondary" size="200" /> <Spinner fill="Solid" variant="Secondary" size="200" />
) : undefined )
} }
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> <Text size="B400">View</Text>
</Button> </Button>
<Button
type="submit"
onClick={handleSaveView}
variant="Primary"
fill="Soft"
before={
saveViewState.status === AsyncStatus.Loading && (
<Spinner fill="Solid" variant="Secondary" size="200" />
)
}
disabled={busy}
>
<Text size="B400">Save & View</Text>
</Button>
</Box> </Box>
</Box> </Box>
</Dialog> </Dialog>
</FocusTrap> </FocusTrap>
</OverlayCenter> </OverlayCenter>
</Overlay> </Overlay>
<Button <NavItem variant="Background" aria-selected={selected}>
variant="Secondary" <NavButton onClick={() => setDialog(true)}>
fill="Soft" <NavItemContent>{children}</NavItemContent>
size="300" </NavButton>
before={<Icon size="100" src={Icons.Plus} />} </NavItem>
onClick={() => setDialog(true)}
>
<Text size="B300" truncate>
Add Server
</Text>
</Button>
</> </>
); );
} }
type ExploreServerNavItemAction = {
onClick: () => Promise<void>;
icon: IconSrc;
alwaysVisible: boolean;
};
type ExploreServerNavItemProps = {
server: string;
selected: boolean;
icon: IconSrc;
action?: ExploreServerNavItemAction;
};
export function ExploreServerNavItem({
server,
selected,
icon,
action,
}: ExploreServerNavItemProps) {
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [actionState, actionCallback] = useAsyncCallback(
useCallback(async () => {
await action?.onClick();
}, [action])
);
const actionInProgress =
actionState.status === AsyncStatus.Loading || actionState.status === AsyncStatus.Success;
return (
<NavItem
variant="Background"
radii="400"
aria-selected={selected}
{...hoverProps}
{...focusWithinProps}
>
<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>
</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>
)}
</NavItem>
);
}
export function Explore() { export function Explore() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate();
useNavToActivePathMapper('explore'); useNavToActivePathMapper('explore');
const userId = mx.getUserId(); const userId = mx.getUserId();
const clientConfig = useClientConfig(); const clientConfig = useClientConfig();
const userServer = userId ? getMxIdServer(userId) : undefined; const userServer = userId ? getMxIdServer(userId) : undefined;
const servers = const featuredServers = useMemo(
clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? []; () =>
clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? [],
[clientConfig, userServer]
);
const [exploreServers, addServer, removeServer] = useExploreServers(featuredServers);
const featuredSelected = useExploreFeaturedSelected();
const selectedServer = useExploreServer(); const selectedServer = useExploreServer();
const exploringFeaturedRooms = useExploreFeaturedRooms();
const exploringUnlistedServer = useMemo(
() =>
!(
selectedServer === undefined ||
selectedServer === userServer ||
featuredServers.includes(selectedServer) ||
exploreServers.includes(selectedServer)
),
[exploreServers, selectedServer, userServer, featuredServers]
);
const addServerCallback = 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 removeServerCallback = useCallback(
async (server: string) => {
await removeServer(server);
},
[removeServer]
);
return ( return (
<PageNav> <PageNav>
@ -182,12 +300,12 @@ export function Explore() {
<PageNavContent> <PageNavContent>
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<NavCategory> <NavCategory>
<NavItem variant="Background" radii="400" aria-selected={featuredSelected}> <NavItem variant="Background" radii="400" aria-selected={exploringFeaturedRooms}>
<NavLink to={getExploreFeaturedPath()}> <NavLink to={getExploreFeaturedPath()}>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">
<Icon src={Icons.Bulb} size="100" filled={featuredSelected} /> <Icon src={Icons.Bulb} size="100" filled={exploringFeaturedRooms} />
</Avatar> </Avatar>
<Box as="span" grow="Yes"> <Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate> <Text as="span" size="Inherit" truncate>
@ -199,67 +317,68 @@ export function Explore() {
</NavLink> </NavLink>
</NavItem> </NavItem>
{userServer && ( {userServer && (
<NavItem <ExploreServerNavItem
variant="Background" server={userServer}
radii="400" selected={userServer === selectedServer}
aria-selected={selectedServer === userServer} icon={Icons.Home}
> />
<NavLink to={getExploreServerPath(userServer)}> )}
<NavItemContent> {exploringUnlistedServer && selectedServer !== undefined && (
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <ExploreServerNavItem
<Avatar size="200" radii="400"> server={selectedServer}
<Icon selected
src={Icons.Server} icon={Icons.Server}
size="100" action={{
filled={selectedServer === userServer} alwaysVisible: true,
/> icon: Icons.Plus,
</Avatar> onClick: () => addServerCallback(selectedServer, true),
<Box as="span" grow="Yes"> }}
<Text as="span" size="Inherit" truncate> />
{userServer}
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
)} )}
</NavCategory> </NavCategory>
{servers.length > 0 && ( <NavCategory>
<NavCategory> <NavCategoryHeader>
<NavCategoryHeader> <Text size="O400" style={{ paddingLeft: config.space.S200 }}>
<Text size="O400" style={{ paddingLeft: config.space.S200 }}> Servers
Servers </Text>
</Text> </NavCategoryHeader>
</NavCategoryHeader> {featuredServers.map((server) => (
{servers.map((server) => ( <ExploreServerNavItem
<NavItem key={server}
key={server} server={server}
variant="Background" selected={server === selectedServer}
radii="400" icon={Icons.Server}
aria-selected={server === selectedServer} />
> ))}
<NavLink to={getExploreServerPath(server)}> {exploreServers.map((server) => (
<NavItemContent> <ExploreServerNavItem
<Box as="span" grow="Yes" alignItems="Center" gap="200"> key={server}
<Avatar size="200" radii="400"> server={server}
<Icon src={Icons.Server} size="100" filled={server === selectedServer} /> selected={server === selectedServer}
</Avatar> icon={Icons.Server}
<Box as="span" grow="Yes"> action={{
<Text as="span" size="Inherit" truncate> alwaysVisible: false,
{server} icon: Icons.Minus,
</Text> onClick: () => removeServerCallback(server),
</Box> }}
</Box> />
</NavItemContent> ))}
</NavLink> <AddExploreServerPrompt
</NavItem> onSubmit={addServerCallback}
))} header={<Text size="H4">Add Server</Text>}
</NavCategory> >
)} <Box as="span" grow="Yes" alignItems="Center" gap="200">
<Box direction="Column"> <Avatar size="200" radii="400">
<AddServer /> <Icon src={Icons.Plus} size="100" />
</Box> </Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Add Server
</Text>
</Box>
</Box>
</AddExploreServerPrompt>
</NavCategory>
</Box> </Box>
</PageNavContent> </PageNavContent>
</PageNav> </PageNav>

View file

@ -24,6 +24,8 @@ import {
Scroll, Scroll,
Spinner, Spinner,
Text, Text,
Tooltip,
TooltipProvider,
config, config,
toRem, toRem,
} from 'folds'; } from 'folds';
@ -45,6 +47,8 @@ import { getMxIdServer } from '../../../utils/matrix';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useExploreServers } from '../../../hooks/useExploreServers';
const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams =>
useMemo( useMemo(
@ -348,6 +352,7 @@ export function PublicRooms() {
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
const { navigateSpace, navigateRoom } = useRoomNavigate(); const { navigateSpace, navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const serverSearchParams = useServerSearchParams(searchParams); const serverSearchParams = useServerSearchParams(searchParams);
@ -356,6 +361,9 @@ export function PublicRooms() {
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const roomTypeFilters = useRoomTypeFilters(); const roomTypeFilters = useRoomTypeFilters();
const [exploreServers, , removeServer] = useExploreServers();
const isUserAddedServer = server && exploreServers.includes(server);
const isUserHomeServer = server && server === userServer;
const currentLimit: number = useMemo(() => { const currentLimit: number = useMemo(() => {
const limitParam = serverSearchParams.limit; const limitParam = serverSearchParams.limit;
@ -468,6 +476,20 @@ export function PublicRooms() {
explore({ instance: instanceId, since: undefined }); 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 ( return (
<Page> <Page>
<PageHeader balance> <PageHeader balance>
@ -506,13 +528,78 @@ export function PublicRooms() {
</BackRouteHandler> </BackRouteHandler>
)} )}
</Box> </Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" basis="Yes" justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Server} />} {screenSize !== ScreenSize.Mobile && <Icon size="400" src={isUserHomeServer ? Icons.Home : Icons.Server} />}
<Text size="H3" truncate> <Text size="H3" truncate>
{server} {server}
</Text> </Text>
</Box> </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> </PageHeader>

View file

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