Skip to content

Commit b80214e

Browse files
committed
feat(sidebar): wip custom categories
1 parent ea7ea68 commit b80214e

33 files changed

Lines changed: 2223 additions & 301 deletions

.changeset/odd-times-sleep.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@rocket.chat/core-typings': minor
3+
'@rocket.chat/rest-typings': minor
4+
'@rocket.chat/i18n': minor
5+
'@rocket.chat/meteor': minor
6+
---
7+
8+
Introduces custom, user-defined categories to the sidebar. Users can create, rename, delete and reorder categories (menu-driven), and move rooms into them via the room context menu or the room header

apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type CreateChannelModalProps = {
3939
mainRoom?: IRoom;
4040
onClose: () => void;
4141
reload?: () => void;
42+
onSuccess?: (rid: string, name: string) => void | Promise<void>;
4243
};
4344

4445
type CreateChannelModalPayload = {
@@ -70,7 +71,7 @@ const getFederationHintKey = (federationModule: boolean, featureToggle: boolean,
7071

7172
const hasExternalMembers = (members: string[]): boolean => members.some((member) => member.startsWith('@'));
7273

73-
const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateChannelModalProps) => {
74+
const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload, onSuccess }: CreateChannelModalProps) => {
7475
const t = useTranslation();
7576
const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']);
7677
const e2eEnabled = useSetting('E2E_Enable');
@@ -171,9 +172,11 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
171172
try {
172173
if (isPrivate) {
173174
roomData = await createPrivateChannel(params);
175+
await onSuccess?.(roomData.group._id, name);
174176
if (!teamId) goToRoom(roomData.group._id);
175177
} else {
176178
roomData = await createChannel(params);
179+
await onSuccess?.(roomData.channel._id, name);
177180
if (!teamId) goToRoom(roomData.channel._id);
178181
}
179182

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
1+
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
12
import { useAtLeastOnePermission } from '@rocket.chat/ui-contexts';
23
import { useTranslation } from 'react-i18next';
34

45
import { useCreateNewItems } from './useCreateNewItems';
6+
import { useCategoryModals } from '../../../sidebar/categories/useCategoryModals';
57

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

810
export const useCreateNewMenu = () => {
911
const { t } = useTranslation();
1012
const showCreate = useAtLeastOnePermission(CREATE_ROOM_PERMISSIONS);
13+
const { openCreate } = useCategoryModals();
1114

1215
const createRoomItems = useCreateNewItems();
1316

14-
const sections = [{ title: t('Create_new'), items: createRoomItems, permission: showCreate }];
17+
const sections = [
18+
{ title: t('Create_new'), items: createRoomItems, permission: showCreate },
19+
{
20+
items: [{ id: 'category', icon: 'folder', content: t('Category'), onClick: () => openCreate() }] as GenericMenuItemProps[],
21+
permission: true,
22+
},
23+
];
1524

1625
return sections.filter((section) => section.permission);
1726
};

apps/meteor/client/sidebar/RoomList/RoomList.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import RoomListRow from './RoomListRow';
1111
import RoomListRowWrapper from './RoomListRowWrapper';
1212
import RoomListWrapper from './RoomListWrapper';
1313
import { useOpenedRoom } from '../../lib/RoomManager';
14+
import { useAllGroupsOrder } from '../hooks/useAllGroupsOrder';
1415
import { useAvatarTemplate } from '../hooks/useAvatarTemplate';
1516
import { useCollapsedGroups } from '../hooks/useCollapsedGroups';
1617
import { usePreventDefault } from '../hooks/usePreventDefault';
@@ -24,7 +25,8 @@ const RoomList = () => {
2425
const isAnonymous = !userId;
2526

2627
const { collapsedGroups, handleClick, handleKeyDown } = useCollapsedGroups();
27-
const { groupsCount, groupsList, roomList, groupedUnreadInfo } = useRoomList({ collapsedGroups });
28+
const { groups, groupsCount, totalCount } = useRoomList({ collapsedGroups });
29+
const { move: moveGroup } = useAllGroupsOrder();
2830
const avatarTemplate = useAvatarTemplate();
2931
const sideBarItemTemplate = useTemplateByViewMode();
3032
const { ref } = useResizeObserver<HTMLElement>({ debounceDelay: 100 });
@@ -46,6 +48,8 @@ const RoomList = () => {
4648
[avatarTemplate, extended, isAnonymous, openedRoom, sideBarItemTemplate, sidebarViewMode, t, userId],
4749
);
4850

51+
const allGroupKeys = useMemo(() => groups.map((group) => group.key), [groups]);
52+
4953
usePreventDefault(ref);
5054
useShortcutOpenMenu(ref);
5155

@@ -54,17 +58,31 @@ const RoomList = () => {
5458
<VirtualizedScrollbars>
5559
<GroupedVirtuoso
5660
groupCounts={groupsCount}
57-
groupContent={(index) => (
58-
<RoomListCollapser
59-
collapsedGroups={collapsedGroups}
60-
onClick={() => handleClick(groupsList[index])}
61-
onKeyDown={(e) => handleKeyDown(e, groupsList[index])}
62-
groupTitle={groupsList[index]}
63-
unreadCount={groupedUnreadInfo[index]}
64-
/>
65-
)}
66-
{...(roomList.length > 0 && {
67-
itemContent: (index) => roomList[index] && <RoomListRow data={itemData} item={roomList[index]} />,
61+
groupContent={(index) => {
62+
const group = groups[index];
63+
64+
const onMoveUp = () => moveGroup(allGroupKeys, group.key, 'up');
65+
const onMoveDown = () => moveGroup(allGroupKeys, group.key, 'down');
66+
67+
return (
68+
<RoomListCollapser
69+
group={group}
70+
canMoveUp={index > 0}
71+
canMoveDown={index < groups.length - 1}
72+
onMoveUp={onMoveUp}
73+
onMoveDown={onMoveDown}
74+
onClick={() => handleClick(group.key)}
75+
onKeyDown={(e) => handleKeyDown(e, group.key)}
76+
/>
77+
);
78+
}}
79+
{...(totalCount > 0 && {
80+
itemContent: (index, groupIndex) => {
81+
const group = groups[groupIndex];
82+
const correctedIndex = index - groupsCount.slice(0, groupIndex).reduce((acc, count) => acc + count, 0);
83+
const item = group.rooms[correctedIndex];
84+
return item && <RoomListRow data={itemData} item={item} />;
85+
},
6886
})}
6987
components={{ Item: RoomListRowWrapper, List: RoomListWrapper }}
7088
/>

apps/meteor/client/sidebar/RoomList/RoomListCollapser.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,53 @@
1-
import type { ISubscription } from '@rocket.chat/core-typings';
21
import { Badge, SidebarV2CollapseGroup } from '@rocket.chat/fuselage';
2+
import type { TranslationKey } from '@rocket.chat/ui-contexts';
33
import type { HTMLAttributes, KeyboardEvent, MouseEventHandler } from 'react';
44
import { useTranslation } from 'react-i18next';
55

6+
import CategoryMenu from '../categories/CategoryMenu';
7+
import type { SidebarRoomListGroup } from '../hooks/useRoomList';
68
import { useUnreadDisplay } from '../hooks/useUnreadDisplay';
79

8-
export type RoomListCollapserProps = {
9-
groupTitle: string;
10-
collapsedGroups: string[];
10+
type RoomListCollapserProps = {
11+
group: SidebarRoomListGroup;
12+
canMoveUp: boolean;
13+
canMoveDown: boolean;
14+
onMoveUp: () => void;
15+
onMoveDown: () => void;
1116
onClick: MouseEventHandler<HTMLElement>;
1217
onKeyDown: (e: KeyboardEvent) => void;
13-
unreadCount: Pick<ISubscription, 'userMentions' | 'groupMentions' | 'unread' | 'tunread' | 'tunreadUser' | 'tunreadGroup'>;
1418
} & Omit<HTMLAttributes<HTMLElement>, 'onClick' | 'onKeyDown'>;
15-
const RoomListCollapser = ({ groupTitle, unreadCount: unreadGroupCount, collapsedGroups, ...props }: RoomListCollapserProps) => {
19+
20+
const RoomListCollapser = ({ group, canMoveUp, canMoveDown, onMoveUp, onMoveDown, ...props }: RoomListCollapserProps) => {
1621
const { t } = useTranslation();
1722

18-
const { unreadTitle, unreadVariant, showUnread, unreadCount } = useUnreadDisplay(unreadGroupCount);
23+
const { unreadTitle, unreadVariant, showUnread, unreadCount } = useUnreadDisplay(group.unreadInfo);
24+
25+
const title = group.translateTitle ? t(group.title as TranslationKey) : group.title;
1926

2027
return (
2128
<SidebarV2CollapseGroup
22-
title={t(groupTitle)}
23-
expanded={!collapsedGroups.includes(groupTitle)}
29+
title={title}
30+
expanded={!group.empty && !group.collapsed}
2431
badge={
2532
showUnread ? (
2633
<Badge variant={unreadVariant} title={unreadTitle} aria-label={unreadTitle} role='status'>
2734
{unreadCount.total}
2835
</Badge>
2936
) : undefined
3037
}
31-
aria-label={
32-
!collapsedGroups.includes(groupTitle) ? t('Collapse_group', { group: t(groupTitle) }) : t('Expand_group', { group: t(groupTitle) })
38+
menu={
39+
<CategoryMenu
40+
category={group.category}
41+
groupKey={group.key}
42+
showUnreads={group.showUnreads}
43+
keepUnreadsOnTop={group.keepUnreadsOnTop}
44+
canMoveUp={canMoveUp}
45+
canMoveDown={canMoveDown}
46+
onMoveUp={onMoveUp}
47+
onMoveDown={onMoveDown}
48+
/>
3349
}
50+
aria-label={group.collapsed ? t('Expand_group', { group: title }) : t('Collapse_group', { group: title })}
3451
{...props}
3552
/>
3653
);

apps/meteor/client/sidebar/RoomMenu.spec.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const renderOptions = {
3333
Hide: 'Hide',
3434
Mark_unread: 'Mark Unread',
3535
Favorite: 'Favorite',
36+
Favorites: 'Favorites',
37+
Move_to: 'Move to',
38+
New_category: 'New category',
3639
Leave_room: 'Leave',
3740
})
3841
.withSetting('Favorite_Rooms', true)
@@ -48,9 +51,19 @@ it('should display all the menu options for regular rooms', async () => {
4851
await userEvent.click(menu as HTMLElement);
4952

5053
expect(await screen.findByRole('menuitem', { name: 'Hide' })).toBeInTheDocument();
51-
expect(await screen.findByRole('menuitem', { name: 'Favorite' })).toBeInTheDocument();
5254
expect(await screen.findByRole('menuitem', { name: 'Mark Unread' })).toBeInTheDocument();
5355
expect(await screen.findByRole('menuitem', { name: 'Leave' })).toBeInTheDocument();
56+
expect(await screen.findByRole('menuitem', { name: 'Move to' })).toBeInTheDocument();
57+
});
58+
59+
it('should reveal Favorites inside the "Move to" submenu for regular rooms', async () => {
60+
render(<RoomMenu {...defaultProps} />, renderOptions);
61+
62+
await userEvent.click(screen.queryByRole('button') as HTMLElement);
63+
await userEvent.hover(await screen.findByRole('menuitem', { name: 'Move to' }));
64+
65+
expect(await screen.findByRole('menuitem', { name: 'Favorites' })).toBeInTheDocument();
66+
expect(await screen.findByRole('menuitem', { name: 'New category' })).toBeInTheDocument();
5467
});
5568

5669
it('should display only mark unread and favorite for omnichannel rooms', async () => {

apps/meteor/client/sidebar/RoomMenu.tsx

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import type { RoomType } from '@rocket.chat/core-typings';
2-
import { GenericMenu } from '@rocket.chat/ui-client';
3-
import { useTranslation } from '@rocket.chat/ui-contexts';
2+
import { Menu, MenuItem, MenuItemContent, MenuItemIcon, MenuItemInput, MenuSection, MenuSubmenuTrigger } from '@rocket.chat/fuselage';
3+
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
4+
import { GenericMenuItem } from '@rocket.chat/ui-client';
5+
import { useUserSubscription } from '@rocket.chat/ui-contexts';
46
import { memo } from 'react';
7+
import { useTranslation } from 'react-i18next';
58

69
import { useRoomMenuActions } from '../hooks/useRoomMenuActions';
10+
import { useRoomCategoryItems } from './categories/useRoomCategoryItems';
711

812
export type RoomMenuProps = {
913
rid: string;
@@ -17,13 +21,97 @@ export type RoomMenuProps = {
1721
hideDefaultOptions: boolean;
1822
};
1923

24+
const makeHandleAction = (items: GenericMenuItemProps[]) => (id: string | number) => {
25+
const item = items.find((item) => item.id === String(id) && !!item.onClick);
26+
item?.onClick?.();
27+
};
28+
29+
const renderSectionItem = (item: GenericMenuItemProps) => (
30+
<MenuItem
31+
key={item.id}
32+
aria-label={typeof item.content === 'string' ? item.content : item.id}
33+
textValue={typeof item.content === 'string' ? item.content : item.id}
34+
>
35+
<GenericMenuItem {...item} />
36+
</MenuItem>
37+
);
38+
39+
const renderSubmenuItem = (item: GenericMenuItemProps) => (
40+
<MenuItem
41+
key={item.id}
42+
aria-label={typeof item.content === 'string' ? item.content : item.id}
43+
textValue={typeof item.content === 'string' ? item.content : item.id}
44+
>
45+
{item.icon && <MenuItemIcon name={item.icon} color={item.iconColor} />}
46+
{item.content && <MenuItemContent>{item.content}</MenuItemContent>}
47+
{item.addon && <MenuItemInput>{item.addon}</MenuItemInput>}
48+
</MenuItem>
49+
);
50+
2051
const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '', hideDefaultOptions = false }: RoomMenuProps) => {
21-
const t = useTranslation();
52+
const { t } = useTranslation();
53+
const subscription = useUserSubscription(rid);
54+
const buildCategoryItems = useRoomCategoryItems();
2255

2356
const isUnread = alert || unread || threadUnread;
24-
const sections = useRoomMenuActions({ rid, type, name, isUnread, cl, roomOpen, hideDefaultOptions });
57+
const allSections = useRoomMenuActions({ rid, type, name, isUnread, cl, roomOpen, hideDefaultOptions });
58+
59+
if (!hideDefaultOptions && type !== 'l') {
60+
const isFavorite = Boolean(subscription?.f);
61+
const { moveToItems, removeItem } = buildCategoryItems({ rid, name, isFavorite });
62+
63+
// Strip toggleFavorite — the submenu's "Favorites" target replaces it.
64+
const actionSections = allSections
65+
.map((section) => ({ ...section, items: section.items.filter((item) => item.id !== 'toggleFavorite') }))
66+
.filter((section) => section.items.length > 0);
67+
68+
// Separate "New category" from the regular category targets so it always goes last in the submenu.
69+
const categoryTargets = moveToItems.filter((item) => item.id !== 'newCategory');
70+
const newCategoryItem = moveToItems.find((item) => item.id === 'newCategory');
71+
72+
const allItems = [...actionSections.flatMap((s) => s.items), ...moveToItems, ...(removeItem ? [removeItem] : [])];
73+
const disabledKeys = allItems.filter(({ disabled }) => disabled).map(({ id }) => id);
74+
const handleAction = makeHandleAction(allItems);
75+
76+
const submenuActionsItems = [...(newCategoryItem ? [newCategoryItem] : []), ...(removeItem ? [removeItem] : [])];
77+
78+
return (
79+
<Menu detached title={t('Options')} mini icon='kebab' aria-keyshortcuts='alt' onAction={handleAction} disabledKeys={disabledKeys}>
80+
{[
81+
...actionSections.map(({ title, items }, index) => (
82+
<MenuSection key={title || String(index)} aria-label={title || t('Options')} title={title || undefined} items={items}>
83+
{renderSectionItem}
84+
</MenuSection>
85+
)),
86+
<MenuSection key='category' title={t('Category')}>
87+
<MenuSubmenuTrigger key='moveTo' textValue={t('Move_to')}>
88+
<MenuItem aria-label={t('Move_to')}>
89+
<MenuItemIcon name='folder' />
90+
<MenuItemContent>{t('Move_to')}</MenuItemContent>
91+
</MenuItem>
92+
<MenuSection items={categoryTargets}>{renderSubmenuItem}</MenuSection>
93+
{submenuActionsItems.length > 0 && <MenuSection items={submenuActionsItems}>{renderSubmenuItem}</MenuSection>}
94+
</MenuSubmenuTrigger>
95+
</MenuSection>,
96+
]}
97+
</Menu>
98+
);
99+
}
100+
101+
// Livechat rooms or hideDefaultOptions — simple flat menu, no category submenu.
102+
const allItems = allSections.flatMap((s) => s.items);
103+
const disabledKeys = allItems.filter(({ disabled }) => disabled).map(({ id }) => id);
104+
const handleAction = makeHandleAction(allItems);
25105

26-
return <GenericMenu detached title={t('Options')} mini aria-keyshortcuts='alt' sections={sections} />;
106+
return (
107+
<Menu detached title={t('Options')} mini icon='kebab' aria-keyshortcuts='alt' onAction={handleAction} disabledKeys={disabledKeys}>
108+
{allSections.map(({ title, items }, index) => (
109+
<MenuSection key={title || String(index)} aria-label={title || t('Options')} title={title || undefined} items={items}>
110+
{renderSectionItem}
111+
</MenuSection>
112+
))}
113+
</Menu>
114+
);
27115
};
28116

29117
export default memo(RoomMenu);

0 commit comments

Comments
 (0)