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/router/useExploreSelected.ts b/src/app/hooks/router/useExploreSelected.ts index f0ffdc86..be7615ca 100644 --- a/src/app/hooks/router/useExploreSelected.ts +++ b/src/app/hooks/router/useExploreSelected.ts @@ -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, diff --git a/src/app/hooks/useExploreServers.ts b/src/app/hooks/useExploreServers.ts new file mode 100644 index 00000000..9e60eda9 --- /dev/null +++ b/src/app/hooks/useExploreServers.ts @@ -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, (server: string) => Promise] => { + const mx = useMatrixClient(); + const accountData = useAccountData(AccountDataEvent.CinnyExplore); + const userAddedServers = useMemo( + () => + accountData + ?.getContent() + ?.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]; +}; diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index dae83166..bec600eb 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,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; + 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(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 = (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 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" > - - Add Server - + {header} setDialog(false)} radii="300"> - + Add server name to explore public communities. Server Name - {exploreState.status === AsyncStatus.Error && ( + {viewState.status === AsyncStatus.Error && ( Failed to load public rooms. Please try again. )} - {/* */} - - + - + + setDialog(true)}> + {children} + + ); } +type ExploreServerNavItemAction = { + onClick: () => Promise; + 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 ( + + + + + + + + + + {server} + + + + + + {action !== undefined && (hover || actionInProgress || action.alwaysVisible) && ( + + + {actionInProgress ? ( + + ) : ( + + )} + + + )} + + ); +} + 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 ( @@ -182,12 +300,12 @@ export function Explore() { - + - + @@ -199,67 +317,68 @@ export function Explore() { {userServer && ( - - - - - - - - - - {userServer} - - - - - - + + )} + {exploringUnlistedServer && selectedServer !== undefined && ( + addServerCallback(selectedServer, true), + }} + /> )} - {servers.length > 0 && ( - - - - Servers - - - {servers.map((server) => ( - - - - - - - - - - {server} - - - - - - - ))} - - )} - - - + + + + Servers + + + {featuredServers.map((server) => ( + + ))} + {exploreServers.map((server) => ( + removeServerCallback(server), + }} + /> + ))} + Add Server} + > + + + + + + + Add Server + + + + + 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',