mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-02-23 13:43:07 +01:00
add room nav item component in space room list
This commit is contained in:
parent
0b8a9d64f9
commit
45a5717560
8 changed files with 305 additions and 61 deletions
17
src/app/components/nav/NavItemOptions.tsx
Normal file
17
src/app/components/nav/NavItemOptions.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
);
|
|
@ -3,3 +3,4 @@ export * from './NavCategoryHeader';
|
|||
export * from './NavEmptyLayout';
|
||||
export * from './NavItem';
|
||||
export * from './NavItemContent';
|
||||
export * from './NavItemOptions';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
252
src/app/features/room-nav-item/RoomNavItem.tsx
Normal file
252
src/app/features/room-nav-item/RoomNavItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue