diff --git a/.vscode/settings.json b/.vscode/settings.json index 8272ea1e..8134a7fd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" + } } diff --git a/src/app/hooks/useExploreServers.ts b/src/app/hooks/useExploreServers.ts new file mode 100644 index 00000000..fc07653f --- /dev/null +++ b/src/app/hooks/useExploreServers.ts @@ -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, + (server: string) => Promise +] => { + const mx = useMatrixClient(); + const accountData = useAccountData(AccountDataEvent.CinnyExplore); + const userAddedServers = useMemo( + () => accountData?.getContent()?.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]; +}; diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index dae83166..f1e8b184 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -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; + open: boolean; + requestClose: () => void; +}; +export function AddExploreServerPrompt({ + onSubmit, + open, + requestClose, +}: AddExploreServerPromptProps) { + const alive = useAlive(); const serverInputRef = useRef(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,117 +66,203 @@ export function AddServer() { return server || undefined; }; - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - const server = getInputServer(); - if (!server) return; - // explore(server); + const submit = useCallback( + async (save: boolean) => { + const server = getInputServer(); + if (!server) return; - 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 ( - <> - }> - - setDialog(false), - escapeDeactivates: stopPropagation, - }} - > - -
- - Add Server - - setDialog(false)} radii="300"> - - -
- - Add server name to explore public communities. - - Server Name - - {exploreState.status === AsyncStatus.Error && ( - - Failed to load public rooms. Please try again. - - )} - - - {/* */} - - - + }> + + + +
+ + Add Server -
-
-
-
- - + + + + + + Add server name to explore public communities. + + Server Name + + {viewState.status === AsyncStatus.Error && ( + + Failed to load public rooms. Please try again. + + )} + + + + + + +
+
+
+
+ ); +} + +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 ( + + + + + + + + + + {server} + + + + + + {/* {action !== undefined && (hover || actionInProgress || action.alwaysVisible) && ( + + + {actionInProgress ? ( + + ) : ( + + )} + + + )} */} + {after && {after(hover)}} + ); } 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 ( @@ -182,12 +279,12 @@ export function Explore() { - + - + @@ -199,67 +296,116 @@ export function Explore() { {userServer && ( - - - - - - - - - - {userServer} - - - - - - + )} - {servers.length > 0 && ( - - - - Servers - - - {servers.map((server) => ( - + + + Servers + + + {featuredServers.map((server) => ( + + ))} + {exploreServers + .filter((server) => !featuredServers.includes(server)) + .map((server) => ( + - - - - - - - - - {server} - - - - - - + server={server} + selected={server === selectedServer} + icon={Icons.Server} + after={(hover) => { + const busy = removeServerState.status === AsyncStatus.Loading; + + if (!(hover || busy)) { + return undefined; + } + + return ( + removeServerCallback(server)} + variant="Background" + fill="None" + size="300" + radii="300" + disabled={busy} + > + {busy ? ( + + ) : ( + + )} + + ); + }} + /> ))} - - )} - - - + {exploringUnlistedServer && selectedServer !== undefined && ( + { + const busy = addServerState.status === AsyncStatus.Loading; + + return ( + addServerCallback(selectedServer, true)} + variant="Background" + fill="None" + size="300" + radii="300" + disabled={busy} + > + {busy ? ( + + ) : ( + + )} + + ); + }} + /> + )} + + {(dialogOpen, setDialogOpen) => ( + <> + + setDialogOpen(true)}> + + + + + + + + Add Server + + + + + + + setDialogOpen(false)} + /> + + )} + + diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 48f267cc..9a0618a1 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -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(); const [searchParams] = useSearchParams(); const serverSearchParams = useServerSearchParams(searchParams); @@ -356,6 +361,9 @@ export function PublicRooms() { const searchInputRef = useRef(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 = (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 ( @@ -506,13 +528,78 @@ export function PublicRooms() { )} - - {screenSize !== ScreenSize.Mobile && } + + {screenSize !== ScreenSize.Mobile && } {server} - + + + More Options + + } + > + {(triggerRef) => + isUserAddedServer && ( + + + + ) + } + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + ) : ( + + ) + } + radii="300" + disabled={isRemoving} + > + + Remove Server + + + + + + } + /> + )} diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 98715996..b9d81492 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -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',