add room nav item component in space room list

This commit is contained in:
Ajay Bura 2024-04-09 08:27:03 +05:30
parent 0b8a9d64f9
commit 45a5717560
8 changed files with 305 additions and 61 deletions

View file

@ -0,0 +1,17 @@
import React, { ComponentProps } from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
export const NavItemOptions = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box
className={classNames(css.NavItemOptions, className)}
alignItems="Center"
shrink="No"
gap="0"
{...props}
ref={ref}
/>
)
);

View file

@ -3,3 +3,4 @@ export * from './NavCategoryHeader';
export * from './NavEmptyLayout';
export * from './NavItem';
export * from './NavItemContent';
export * from './NavItemOptions';

View file

@ -52,13 +52,15 @@ const NavItemBase = style({
color: OnContainer,
outline: 'none',
minHeight: toRem(38),
gap: config.space.S200,
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: ContainerHover,
},
'&:active': {
'&[data-hover=true]': {
backgroundColor: ContainerHover,
},
[`&:has(.${NavLink}:active)`]: {
backgroundColor: ContainerActive,
},
'&[aria-selected=true]': {
@ -121,3 +123,7 @@ export const NavItemContent = style({
},
},
});
export const NavItemOptions = style({
paddingRight: config.space.S200,
});

View file

@ -0,0 +1,252 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
Icon,
IconButton,
Icons,
Text,
Menu,
MenuItem,
config,
PopOut,
toRem,
Line,
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { copyToClipboard } from '../../utils/dom';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pages/pathUtils';
import { markAsRead } from '../../../client/action/notifications';
import { openInviteUser } from '../../../client/action/navigation';
type RoomNavItemMenuProps = {
room: Room;
linkPath: string;
requestClose: () => void;
};
const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
({ room, linkPath, requestClose }, ref) => {
const mx = useMatrixClient();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const { getPowerLevel, canDoAction } = usePowerLevels(room);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const handleMarkAsRead = () => {
markAsRead(room.roomId);
requestClose();
};
const handleInvite = () => {
openInviteUser(room.roomId);
requestClose();
};
const handleCopyLink = () => {
copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(), linkPath));
requestClose();
};
const handleRoomSettings = () => {
alert('Work In Progress...');
requestClose();
};
const handleLeaveRoom = () => {
mx.leave(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Room Settings
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleLeaveRoom}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Room
</Text>
</MenuItem>
</Box>
</Menu>
);
}
);
type RoomNavItemProps = {
room: Room;
selected: boolean;
linkPath: string;
muted?: boolean;
direct?: boolean;
};
export function RoomNavItem({ room, selected, direct, muted, linkPath }: RoomNavItemProps) {
const mx = useMatrixClient();
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menu, setMenu] = useState(false);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();
setMenu(true);
};
const optionsVisible = hover || menu;
return (
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined || selected}
aria-selected={selected}
data-hover={menu}
onContextMenu={handleContextMenu}
{...hoverProps}
{...focusWithinProps}
>
<NavLink to={linkPath}>
<NavItemContent size="T300">
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
{direct ? (
<RoomAvatar
variant="Background"
src={getRoomAvatarUrl(mx, room, 96)}
alt={room.name}
renderInitials={() => <Text size="H6">{nameInitials(room.name)}</Text>}
/>
) : (
<RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
<Box as="span" grow="Yes">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name}
</Text>
</Box>
{!optionsVisible && unread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
{muted && !optionsVisible && <Icon size="50" src={Icons.BellMute} />}
</Box>
</NavItemContent>
</NavLink>
{optionsVisible && (
<NavItemOptions>
<PopOut
open={menu}
alignOffset={-5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenu(false),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<RoomNavItemMenu
room={room}
linkPath={linkPath}
requestClose={() => setMenu(false)}
/>
</FocusTrap>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
onClick={() => setMenu(true)}
aria-pressed={menu}
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
)}
</PopOut>
</NavItemOptions>
)}
</NavItem>
);
}

View file

@ -803,7 +803,7 @@ export const Message = as<'div', MessageProps>(
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<Menu {...props} ref={ref}>
<Menu>
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {

View file

@ -35,7 +35,7 @@ import {
_SERVER_PATH,
} from './paths';
import { isAuthenticated } from '../../client/state/auth';
import { getAbsolutePathFromHref, getHomePath, getLoginPath } from './pathUtils';
import { getAbsolutePathFromHref, getHomePath, getLoginPath, getOriginBaseUrl } from './pathUtils';
import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
import { FeatureCheck } from './FeatureCheck';
import { ClientLayout, ClientRoot } from './client';
@ -59,7 +59,7 @@ const createRouter = (clientConfig: ClientConfig) => {
loader={() => {
if (isAuthenticated()) return redirect(getHomePath());
const afterLoginPath = getAbsolutePathFromHref(window.location.href);
const afterLoginPath = getAbsolutePathFromHref(getOriginBaseUrl(), window.location.href);
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath());
}}
@ -73,7 +73,10 @@ const createRouter = (clientConfig: ClientConfig) => {
<Route
loader={() => {
if (!isAuthenticated()) {
const afterLoginPath = getAbsolutePathFromHref(window.location.href);
const afterLoginPath = getAbsolutePathFromHref(
getOriginBaseUrl(),
window.location.href
);
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath());
}

View file

@ -16,21 +16,18 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { RoomUnreadProvider } from '../../../components/RoomUnreadProvider';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
useSpaceLobbySelected,
useSpaceSearchSelected,
} from '../../../hooks/router/useSelectedSpace';
import { getRoomAvatarUrl } from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import { useSpace } from '../../../hooks/useSpace';
import { VirtualTile } from '../../../components/virtualizer';
import { useSpaceHierarchy } from './useSpaceHierarchy';
import { RoomNavItem } from '../../../features/room-nav-item/RoomNavItem';
import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
export function Space() {
const mx = useMatrixClient();
@ -38,6 +35,8 @@ export function Space() {
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
const scrollRef = useRef<HTMLDivElement>(null);
const mDirects = useAtomValue(mDirectAtom);
const muteChanges = useAtomValue(muteChangesAtom);
const mutedRooms = muteChanges.added;
const hierarchy = useSpaceHierarchy(space.roomId);
@ -158,54 +157,13 @@ export function Space() {
</NavCategoryHeader>
</div>
)}
<RoomUnreadProvider roomId={roomId}>
{(unread) => (
<NavItem
variant="Background"
radii="400"
highlight={!!unread || selected}
aria-selected={selected}
>
<NavLink to={getToLink(roomId)}>
<NavItemContent size="T300">
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
{direct ? (
<RoomAvatar
variant="Background"
src={getRoomAvatarUrl(mx, room, 96)}
alt={room.name}
renderInitials={() => (
<Text size="H6">{nameInitials(room.name)}</Text>
)}
<RoomNavItem
room={room}
selected={selected}
direct={direct}
linkPath={getToLink(roomId)}
muted={mutedRooms.includes(roomId)}
/>
) : (
<RoomIcon
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{room.name}
</Text>
</Box>
{unread && (
<UnreadBadgeCenter>
<UnreadBadge
highlight={unread.highlight > 0}
count={unread.total}
/>
</UnreadBadgeCenter>
)}
</Box>
</NavItemContent>
</NavLink>
</NavItem>
)}
</RoomUnreadProvider>
</VirtualTile>
);
})}

View file

@ -36,8 +36,15 @@ export const withSearchParam = <T extends Record<string, string>>(
export const encodeSearchParamValueArray = (ids: string[]): string => ids.join(',');
export const decodeSearchParamValueArray = (idsParam: string): string[] => idsParam.split(',');
export const getAbsolutePathFromHref = (href: string): string | undefined => {
export const getOriginBaseUrl = (): string => {
const baseUrl = `${trimTrailingSlash(window.location.origin)}${import.meta.env.BASE_URL}`;
return baseUrl;
};
export const withOriginBaseUrl = (baseUrl: string, path: string): string =>
`${trimTrailingSlash(baseUrl)}${path}`;
export const getAbsolutePathFromHref = (baseUrl: string, href: string): string | undefined => {
const [, path] = href.split(trimTrailingSlash(baseUrl));
return path;
@ -121,7 +128,7 @@ export const getExplorePath = (): string => EXPLORE_PATH;
export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
export const getExploreServerPath = (server: string): string => {
const params = {
server,
server: encodeURIComponent(server),
};
return generatePath(EXPLORE_SERVER_PATH, params);
};