Skip to content
Draft

cat #41093

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/custom-sidebar-categories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/meteor': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/i18n': minor
---

Added custom, user-defined categories to the sidebar. Users can create, rename, delete and reorder categories (menu-driven), and move rooms into them via drag-and-drop, the room context menu, or the room-header grouping icon. A room belongs to at most one custom category (mutually exclusive with Favorites), and category state is stored per-user in the `sidebarCustomCategories` preference.
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useAtLeastOnePermission } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';

import { useCreateNewItems } from './useCreateNewItems';
import { useCategoryModals } from '../../../views/navigation/sidebar/categories/useCategoryModals';

const CREATE_ROOM_PERMISSIONS = ['create-c', 'create-p', 'create-d', 'start-discussion', 'start-discussion-other-user'];

export const useCreateNewMenu = () => {
const { t } = useTranslation();
const showCreate = useAtLeastOnePermission(CREATE_ROOM_PERMISSIONS);
const { openCreate } = useCategoryModals();

const createRoomItems = useCreateNewItems();

const sections = [{ title: t('Create_new'), items: createRoomItems, permission: showCreate }];
const categoryItem: GenericMenuItemProps = { id: 'category', icon: 'folder', content: t('Category'), onClick: () => openCreate() };

// Category sits below the room types, separated by a divider (its own section).
const sections = [
{ title: t('Create_new'), items: createRoomItems, permission: showCreate },
{ items: [categoryItem], permission: true },
];

return sections.filter((section) => section.permission);
};
70 changes: 56 additions & 14 deletions apps/meteor/client/sidebar/RoomList/RoomList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,26 @@ import RoomListRow from './RoomListRow';
import RoomListRowWrapper from './RoomListRowWrapper';
import RoomListWrapper from './RoomListWrapper';
import { useOpenedRoom } from '../../lib/RoomManager';
import { useCustomCategories } from '../../views/navigation/hooks/useCustomCategories';
import { useSystemGroupsOrder } from '../../views/navigation/hooks/useSystemGroupsOrder';
import { CategoryDnDProvider } from '../../views/navigation/sidebar/categories/CategoryDnDContext';
import CategoryDropHighlight from '../../views/navigation/sidebar/categories/CategoryDropHighlight';
import CategoryEmptyPlaceholder from '../../views/navigation/sidebar/categories/CategoryEmptyPlaceholder';
import { useAvatarTemplate } from '../hooks/useAvatarTemplate';
import { useCollapsedGroups } from '../hooks/useCollapsedGroups';
import { usePreventDefault } from '../hooks/usePreventDefault';
import { useRoomList } from '../hooks/useRoomList';
import { useShortcutOpenMenu } from '../hooks/useShortcutOpenMenu';
import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode';

const RoomList = () => {
const RoomListInner = () => {
const { t } = useTranslation();
const isAnonymous = !useUserId();

const { collapsedGroups, handleClick, handleKeyDown } = useCollapsedGroups();
const { groupsCount, groupsList, roomList, groupedUnreadInfo } = useRoomList({ collapsedGroups });
const { groups, groupsCount, totalCount } = useRoomList({ collapsedGroups });
const { reorderCategory } = useCustomCategories();
const { move: moveSystemGroup } = useSystemGroupsOrder();
const avatarTemplate = useAvatarTemplate();
const sideBarItemTemplate = useTemplateByViewMode();
const { ref } = useResizeObserver<HTMLElement>({ debounceDelay: 100 });
Expand All @@ -44,25 +51,53 @@ const RoomList = () => {
[avatarTemplate, extended, isAnonymous, openedRoom, sideBarItemTemplate, sidebarViewMode, t],
);

const customCount = groups.filter((group) => group.category).length;
const systemKeys = groups.filter((group) => !group.category).map((group) => group.key);

usePreventDefault(ref);
useShortcutOpenMenu(ref);

return (
<Box position='relative' overflow='hidden' height='full' ref={ref}>
// `isolation: isolate` makes this an own stacking context so the drag-over highlight (z-index -1) sits
// behind the rows but above the sidebar surface.
<Box position='relative' overflow='hidden' height='full' ref={ref} style={{ isolation: 'isolate' }}>
<CategoryDropHighlight containerRef={ref} />
<VirtualizedScrollbars>
<GroupedVirtuoso
groupCounts={groupsCount}
groupContent={(index) => (
<RoomListCollapser
collapsedGroups={collapsedGroups}
onClick={() => handleClick(groupsList[index])}
onKeyDown={(e) => handleKeyDown(e, groupsList[index])}
groupTitle={groupsList[index]}
unreadCount={groupedUnreadInfo[index]}
/>
)}
{...(roomList.length > 0 && {
itemContent: (index) => roomList[index] && <RoomListRow data={itemData} item={roomList[index]} />,
groupContent={(index) => {
const group = groups[index];
const isCustom = Boolean(group.category);
const positionInSegment = isCustom ? index : index - customCount;
const segmentLength = isCustom ? customCount : systemKeys.length;

const onMoveUp = () => (isCustom ? reorderCategory(group.key, 'up') : moveSystemGroup(systemKeys, group.key, 'up'));
const onMoveDown = () => (isCustom ? reorderCategory(group.key, 'down') : moveSystemGroup(systemKeys, group.key, 'down'));

return (
<RoomListCollapser
group={group}
canMoveUp={positionInSegment > 0}
canMoveDown={positionInSegment < segmentLength - 1}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
onClick={() => handleClick(group.key)}
onKeyDown={(e) => handleKeyDown(e, group.key)}
/>
);
}}
{...(totalCount > 0 && {
itemContent: (index, groupIndex) => {
const group = groups[groupIndex];

if (group.empty) {
return <CategoryEmptyPlaceholder categoryId={group.key} />;
}

const correctedIndex = index - groupsCount.slice(0, groupIndex).reduce((acc, count) => acc + count, 0);
const item = group.rooms[correctedIndex];
return item && <RoomListRow data={itemData} item={item} groupKey={group.key} isCustomCategory={Boolean(group.category)} />;
},
})}
components={{ Item: RoomListRowWrapper, List: RoomListWrapper }}
/>
Expand All @@ -71,4 +106,11 @@ const RoomList = () => {
);
};

// eslint-disable-next-line react/no-multi-comp
const RoomList = () => (
<CategoryDnDProvider>
<RoomListInner />
</CategoryDnDProvider>
);

export default RoomList;
120 changes: 98 additions & 22 deletions apps/meteor/client/sidebar/RoomList/RoomListCollapser.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,114 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import { Badge, SidebarV2CollapseGroup } from '@rocket.chat/fuselage';
import { css } from '@rocket.chat/css-in-js';
import { Badge, Box, SidebarV2CollapseGroup } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { HTMLAttributes, KeyboardEvent, MouseEventHandler } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useGroupDrop } from '../../views/navigation/sidebar/categories/CategoryDnDContext';
import CategoryLabel from '../../views/navigation/sidebar/categories/CategoryLabel';
import CategoryMenu from '../../views/navigation/sidebar/categories/CategoryMenu';
import type { SidebarRoomListGroup } from '../hooks/useRoomList';
import { useUnreadDisplay } from '../hooks/useUnreadDisplay';

const barStylingClass = css`
/* The header bar's opaque background (identical to the sidebar's) would hide the wrapper's drag-over
tint; keeping it transparent lets the wrapper's inline background drive the header highlight in the
same render as the room rows. The hover background lives on the bar, so it gets the same rounding;
the reduced inline padding keeps the header icon aligned with the room avatars despite the inset. */
.rcx-sidebar-v2-collapse-group__bar {
min-height: 0;
/* Equal inner padding on all sides (the -1px accounts for the bar's 1px transparent border). */
padding: calc(0.25rem - 1px);
background-color: transparent;
border-radius: var(--rcx-border-radius-medium, 0.25rem);
}

/* Hide the built-in chevron; the group icon and the (hover-revealed) chevron are rendered together in
the title's leading slot so they share one place without shifting the title. */
.rcx-sidebar-v2-collapse-group__bar .rcx-chevron {
display: none;
}
`;

type RoomListCollapserProps = {
groupTitle: string;
collapsedGroups: string[];
group: SidebarRoomListGroup;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onClick: MouseEventHandler<HTMLElement>;
onKeyDown: (e: KeyboardEvent) => void;
unreadCount: Pick<ISubscription, 'userMentions' | 'groupMentions' | 'unread' | 'tunread' | 'tunreadUser' | 'tunreadGroup'>;
} & Omit<HTMLAttributes<HTMLElement>, 'onClick' | 'onKeyDown'>;
const RoomListCollapser = ({ groupTitle, unreadCount: unreadGroupCount, collapsedGroups, ...props }: RoomListCollapserProps) => {

const RoomListCollapser = ({ group, canMoveUp, canMoveDown, onMoveUp, onMoveDown, ...props }: RoomListCollapserProps) => {
const { t } = useTranslation();
const { isDragOver, isFadedOut, dropProps } = useGroupDrop(group.key, Boolean(group.category));

const { unreadTitle, unreadVariant, showUnread, unreadCount } = useUnreadDisplay(group.unreadInfo);

const title = group.translateTitle ? t(group.title as TranslationKey) : group.title;
// `title` (string) drives the accessible name; the collapser renders this node as the visible label, with
// a leading icon (emoji/folder for custom, type icon for system) so all groups align. Cast because the
// prop is typed `string` but the component renders it as JSX children.
const titleContent = (
<CategoryLabel emoji={group.category?.icon} iconName={group.icon} name={title} collapsed={group.collapsed} unread={showUnread} />
) as unknown as string;

const { unreadTitle, unreadVariant, showUnread, unreadCount } = useUnreadDisplay(unreadGroupCount);
// `SidebarV2CollapseGroup` doesn't render an actions slot, so the kebab is overlaid on the header;
// it shows on hover or while its menu is open, replacing the unread badge.
const [hovered, setHovered] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const showActions = hovered || menuOpen;

return (
<SidebarV2CollapseGroup
title={t(groupTitle)}
expanded={!collapsedGroups.includes(groupTitle)}
badge={
showUnread ? (
<Badge variant={unreadVariant} title={unreadTitle} aria-label={unreadTitle} role='status'>
{unreadCount.total}
</Badge>
) : undefined
}
aria-label={
!collapsedGroups.includes(groupTitle) ? t('Collapse_group', { group: t(groupTitle) }) : t('Expand_group', { group: t(groupTitle) })
}
{...props}
/>
// Outer wrapper adds transparent space ABOVE every category (separating it from the previous category's
// items, and the first one from the sidebar header). It must be padding, not margin — virtuoso measures
// the rendered height, and a top margin would collapse out and let the header overlap its items.
<Box {...dropProps} style={{ paddingBlockStart: '0.5rem', opacity: isFadedOut ? 0.4 : undefined }}>
<Box
position='relative'
className={barStylingClass}
data-drop-group={group.key}
style={{
// Inset highlight matching the room rows: the hover tint lives on the bar, the drag-over tint here.
// Drag-over only tints the background, keeping the inset rounding so the drop area stays rounded.
marginInline: '0.5rem',
borderRadius: 'var(--rcx-border-radius-medium, 0.25rem)',
backgroundColor: isDragOver ? 'var(--rcx-color-surface-hover)' : undefined,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<SidebarV2CollapseGroup
title={titleContent}
expanded={!group.collapsed}
badge={
!showActions && showUnread ? (
<Badge variant={unreadVariant} title={unreadTitle} aria-label={unreadTitle} role='status'>
{unreadCount.total}
</Badge>
) : undefined
}
aria-label={group.collapsed ? t('Expand_group', { group: title }) : t('Collapse_group', { group: title })}
{...props}
/>
{showActions && (
<Box position='absolute' insetBlockStart={4} insetInlineEnd={8}>
<CategoryMenu
category={group.category}
groupKey={group.key}
showUnreads={group.showUnreads}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
onOpenChange={setMenuOpen}
/>
</Box>
)}
</Box>
</Box>
);
};

Expand Down
7 changes: 6 additions & 1 deletion apps/meteor/client/sidebar/RoomList/RoomListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ type RoomListRowProps = {
isAnonymous: boolean;
};
item: SubscriptionWithRoom;
/** The sidebar group this row belongs to (translation key for system groups, category id for custom ones). */
groupKey?: string;
isCustomCategory?: boolean;
};

const RoomListRow = ({ data, item }: RoomListRowProps) => {
const RoomListRow = ({ data, item, groupKey, isCustomCategory }: RoomListRowProps) => {
const { extended, t, SidebarItemTemplate, AvatarTemplate, openedRoom, sidebarViewMode } = data;

const acceptCall = useVideoConfAcceptCall();
Expand All @@ -47,6 +50,8 @@ const RoomListRow = ({ data, item }: RoomListRowProps) => {
SidebarItemTemplate={SidebarItemTemplate}
AvatarTemplate={AvatarTemplate}
videoConfActions={videoConfActions}
groupKey={groupKey}
isCustomCategory={isCustomCategory}
/>
);
};
Expand Down
Loading
Loading