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.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;
};
export const useExploreFeaturedSelected = (): boolean => {
export const useExploreFeaturedRooms = (): boolean => {
const match = useMatch({
path: getExploreFeaturedPath(),
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 FocusTrap from 'focus-trap-react';
import {
@ -9,26 +9,30 @@ 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,
useExploreFeaturedRooms,
useExploreServer,
} from '../../../hooks/router/useExploreSelected';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -37,17 +41,27 @@ 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';
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 navigate = useNavigate();
const [dialog, setDialog] = useState(false);
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,22 +69,24 @@ 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));
await mx.publicRooms({ server, limit: 1 });
await onSubmit(server, save);
if (alive()) {
setDialog(false);
};
}
},
[alive, onSubmit, mx]
);
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 (
<>
@ -93,79 +109,181 @@ export function AddServer() {
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Add Server</Text>
</Box>
<Box grow="Yes">{header}</Box>
<IconButton size="300" onClick={() => setDialog(false)} 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"
onClick={handleView}
variant="Secondary"
fill="Soft"
before={
exploreState.status === AsyncStatus.Loading ? (
viewState.status === AsyncStatus.Loading && (
<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>
</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>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Button
variant="Secondary"
fill="Soft"
size="300"
before={<Icon size="100" src={Icons.Plus} />}
onClick={() => setDialog(true)}
>
<Text size="B300" truncate>
Add Server
</Text>
</Button>
<NavItem variant="Background" aria-selected={selected}>
<NavButton onClick={() => setDialog(true)}>
<NavItemContent>{children}</NavItemContent>
</NavButton>
</NavItem>
</>
);
}
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() {
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(featuredServers);
const featuredSelected = useExploreFeaturedSelected();
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 (
<PageNav>
@ -182,12 +300,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 +317,68 @@ 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.Home}
/>
)}
{exploringUnlistedServer && selectedServer !== undefined && (
<ExploreServerNavItem
server={selectedServer}
selected
icon={Icons.Server}
action={{
alwaysVisible: true,
icon: Icons.Plus,
onClick: () => addServerCallback(selectedServer, true),
}}
/>
</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}
variant="Background"
radii="400"
aria-selected={server === selectedServer}
server={server}
selected={server === selectedServer}
icon={Icons.Server}
/>
))}
{exploreServers.map((server) => (
<ExploreServerNavItem
key={server}
server={server}
selected={server === selectedServer}
icon={Icons.Server}
action={{
alwaysVisible: false,
icon: Icons.Minus,
onClick: () => removeServerCallback(server),
}}
/>
))}
<AddExploreServerPrompt
onSubmit={addServerCallback}
header={<Text size="H4">Add Server</Text>}
>
<NavLink to={getExploreServerPath(server)}>
<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>
</NavItem>
))}
</AddExploreServerPrompt>
</NavCategory>
)}
<Box direction="Column">
<AddServer />
</Box>
</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',