diff --git a/.changeset/custom-sidebar-categories.md b/.changeset/custom-sidebar-categories.md new file mode 100644 index 0000000000000..47dfbd6fc7233 --- /dev/null +++ b/.changeset/custom-sidebar-categories.md @@ -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. diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/hooks/useCreateNewMenu.ts b/apps/meteor/client/navbar/NavBarPagesGroup/hooks/useCreateNewMenu.ts index 633b193c48398..f130c927f5d7d 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/hooks/useCreateNewMenu.ts +++ b/apps/meteor/client/navbar/NavBarPagesGroup/hooks/useCreateNewMenu.ts @@ -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); }; diff --git a/apps/meteor/client/sidebar/RoomList/RoomList.tsx b/apps/meteor/client/sidebar/RoomList/RoomList.tsx index 2403621675507..973a086bd7494 100644 --- a/apps/meteor/client/sidebar/RoomList/RoomList.tsx +++ b/apps/meteor/client/sidebar/RoomList/RoomList.tsx @@ -11,6 +11,11 @@ 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'; @@ -18,12 +23,14 @@ 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({ debounceDelay: 100 }); @@ -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 ( - + // `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. + + ( - handleClick(groupsList[index])} - onKeyDown={(e) => handleKeyDown(e, groupsList[index])} - groupTitle={groupsList[index]} - unreadCount={groupedUnreadInfo[index]} - /> - )} - {...(roomList.length > 0 && { - itemContent: (index) => 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 ( + 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 ; + } + + const correctedIndex = index - groupsCount.slice(0, groupIndex).reduce((acc, count) => acc + count, 0); + const item = group.rooms[correctedIndex]; + return item && ; + }, })} components={{ Item: RoomListRowWrapper, List: RoomListWrapper }} /> @@ -71,4 +106,11 @@ const RoomList = () => { ); }; +// eslint-disable-next-line react/no-multi-comp +const RoomList = () => ( + + + +); + export default RoomList; diff --git a/apps/meteor/client/sidebar/RoomList/RoomListCollapser.tsx b/apps/meteor/client/sidebar/RoomList/RoomListCollapser.tsx index 465197e41db82..656648d762835 100644 --- a/apps/meteor/client/sidebar/RoomList/RoomListCollapser.tsx +++ b/apps/meteor/client/sidebar/RoomList/RoomListCollapser.tsx @@ -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; onKeyDown: (e: KeyboardEvent) => void; - unreadCount: Pick; } & Omit, '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 = ( + + ) 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 ( - - {unreadCount.total} - - ) : 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. + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {unreadCount.total} + + ) : undefined + } + aria-label={group.collapsed ? t('Expand_group', { group: title }) : t('Collapse_group', { group: title })} + {...props} + /> + {showActions && ( + + + + )} + + ); }; diff --git a/apps/meteor/client/sidebar/RoomList/RoomListRow.tsx b/apps/meteor/client/sidebar/RoomList/RoomListRow.tsx index 832699a092ecc..882f6211ef990 100644 --- a/apps/meteor/client/sidebar/RoomList/RoomListRow.tsx +++ b/apps/meteor/client/sidebar/RoomList/RoomListRow.tsx @@ -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(); @@ -47,6 +50,8 @@ const RoomListRow = ({ data, item }: RoomListRowProps) => { SidebarItemTemplate={SidebarItemTemplate} AvatarTemplate={AvatarTemplate} videoConfActions={videoConfActions} + groupKey={groupKey} + isCustomCategory={isCustomCategory} /> ); }; diff --git a/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx index e86949055b943..cd3ad004f04c9 100644 --- a/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx @@ -1,15 +1,16 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { SidebarV2Action, SidebarV2Actions, SidebarV2ItemIcon } from '@rocket.chat/fuselage'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useLayout } from '@rocket.chat/ui-contexts'; +import { useLayout, useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; import type { TFunction } from 'i18next'; -import type { AllHTMLAttributes, ComponentType, ReactNode } from 'react'; -import { memo, useMemo } from 'react'; +import type { AllHTMLAttributes, ComponentType, MouseEvent, ReactNode } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { RoomIcon } from '../../components/RoomIcon'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -import { isIOsDevice } from '../../lib/utils/isIOsDevice'; import { getMessagePreview } from '../../lib/utils/normalizeMessagePreview/getMessagePreview'; +import { useGroupDrop, useRoomDrag } from '../../views/navigation/sidebar/categories/CategoryDnDContext'; +import { CLASSIC_NATIVE_KEYS, getNativeCategoryKey } from '../../views/navigation/sidebar/categories/nativeCategory'; import { useOmnichannelPriorities } from '../../views/omnichannel/hooks/useOmnichannelPriorities'; import RoomMenu from '../RoomMenu'; import SidebarItemBadges from '../badges/SidebarItemBadges'; @@ -54,6 +55,10 @@ type RoomListRowProps = { videoConfActions?: { [action: string]: () => void; }; + + /** The sidebar group this row belongs to (translation key for system groups, category id for custom ones). */ + groupKey?: string; + isCustomCategory?: boolean; }; const SidebarItemTemplateWithData = ({ @@ -67,12 +72,52 @@ const SidebarItemTemplateWithData = ({ t, isAnonymous, videoConfActions, + groupKey, + isCustomCategory, }: RoomListRowProps) => { const { sidebar } = useLayout(); const href = roomCoordinator.getRouteLink(room.t, room) || ''; const title = roomCoordinator.getRoomName(room.t, room) || ''; + const sidebarGroupByType = useUserPreference('sidebarGroupByType'); + const discussionEnabled = useSetting('Discussion_enabled'); + const nativeKey = getNativeCategoryKey(room, { + groupByType: Boolean(sidebarGroupByType), + discussionEnabled: Boolean(discussionEnabled), + keys: CLASSIC_NATIVE_KEYS, + }); + + const { isDragging, ...dragProps } = useRoomDrag({ rid: room.rid, name: title, isFavorite: room.f, fromGroup: groupKey, nativeKey }); + const { isDragOver, isFadedOut, dropProps } = useGroupDrop(groupKey, Boolean(isCustomCategory)); + + const dragStyle = { + ...style, + // Suppress the iOS Safari long-press preview/callout on the room link; long-press opens the menu instead. + WebkitTouchCallout: 'none' as const, + // Inset + rounded hover/selected highlight (Slack-style): margins keep it off the sidebar edges. + // Rooms are indented (content ~24px from the edge) so they read as nested under the category header. + marginInline: '0.5rem', + marginBlock: '1px', + paddingInlineStart: '1.5rem', + paddingInlineEnd: 'calc(0.5rem - 1px)', + borderRadius: 'var(--rcx-border-radius-medium, 0.25rem)', + ...(isDragging || isFadedOut ? { opacity: isDragging ? 0.5 : 0.4 } : {}), + // During drag-over, only tint the background — keep the inset margins and rounding so the drop area stays + // rounded and the row's height doesn't change. + ...(isDragOver ? { backgroundColor: 'var(--rcx-color-surface-hover)' } : {}), + }; + + // Long-press (iOS) / right-click opens the room menu (the kebab) instead of the native preview/context menu. + const handleContextMenu = useCallback((event: MouseEvent) => { + const trigger = event.currentTarget.querySelector('.rcx-sidebar-v2-item__menu-wrapper button'); + if (!trigger) { + return; + } + event.preventDefault(); + trigger.click(); + }, []); + const { unreadTitle, showUnread, unreadCount, highlightUnread: highlighted } = useUnreadDisplay(room); const { lastMessage, unread = 0, alert, rid, t: type, cl } = room; @@ -106,24 +151,28 @@ const SidebarItemTemplateWithData = ({ is='a' id={id} data-unread={highlighted} + data-drop-group={groupKey} unread={highlighted} selected={selected} aria-current={selected ? 'page' : undefined} href={href} + {...dragProps} + {...dropProps} onClick={(): void => { if (!selected) sidebar.toggle(); }} + onContextMenu={handleContextMenu} aria-label={showUnread ? t('__unreadTitle__from__roomTitle__', { unreadTitle, roomTitle: title }) : title} title={title} time={lastMessage?.ts} subtitle={subtitle} icon={icon} - style={style} + style={dragStyle} badges={} avatar={AvatarTemplate && } actions={actions} menu={ - !isIOsDevice && !isAnonymous && (!isQueued || (isQueued && isPriorityEnabled)) + !isAnonymous && (!isQueued || (isQueued && isPriorityEnabled)) ? () => ( { diff --git a/apps/meteor/client/sidebar/RoomMenu.spec.tsx b/apps/meteor/client/sidebar/RoomMenu.spec.tsx index f720575ca2b99..a06ec7c7cc92b 100644 --- a/apps/meteor/client/sidebar/RoomMenu.spec.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.spec.tsx @@ -33,6 +33,9 @@ const renderOptions = { Hide: 'Hide', Mark_unread: 'Mark Unread', Favorite: 'Favorite', + Favorites: 'Favorites', + Move_to: 'Move to', + New_category: 'New category', Leave_room: 'Leave', }) .withSetting('Favorite_Rooms', true) @@ -48,9 +51,21 @@ it('should display all the menu options for regular rooms', async () => { await userEvent.click(menu as HTMLElement); expect(await screen.findByRole('menuitem', { name: 'Hide' })).toBeInTheDocument(); - expect(await screen.findByRole('menuitem', { name: 'Favorite' })).toBeInTheDocument(); expect(await screen.findByRole('menuitem', { name: 'Mark Unread' })).toBeInTheDocument(); expect(await screen.findByRole('menuitem', { name: 'Leave' })).toBeInTheDocument(); + // Favoriting moved into the "Move to" submenu, replacing the top-level "Favorite" action. + expect(await screen.findByRole('menuitem', { name: 'Move to' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Favorite' })).not.toBeInTheDocument(); +}); + +it('should reveal Favorites inside the "Move to" submenu for regular rooms', async () => { + render(, renderOptions); + + await userEvent.click(screen.queryByRole('button') as HTMLElement); + await userEvent.hover(await screen.findByRole('menuitem', { name: 'Move to' })); + + expect(await screen.findByRole('menuitem', { name: 'Favorites' })).toBeInTheDocument(); + expect(await screen.findByRole('menuitem', { name: 'New category' })).toBeInTheDocument(); }); it('should display only mark unread and favorite for omnichannel rooms', async () => { diff --git a/apps/meteor/client/sidebar/RoomMenu.tsx b/apps/meteor/client/sidebar/RoomMenu.tsx index c6f2561ca9081..14c3bfbc281de 100644 --- a/apps/meteor/client/sidebar/RoomMenu.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -1,9 +1,10 @@ import type { RoomType } from '@rocket.chat/core-typings'; import { GenericMenu } from '@rocket.chat/ui-client'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useTranslation, useUserSubscription } from '@rocket.chat/ui-contexts'; import { memo } from 'react'; import { useRoomMenuActions } from '../hooks/useRoomMenuActions'; +import RoomMenuWithCategories from '../views/navigation/sidebar/categories/RoomMenuWithCategories'; type RoomMenuProps = { rid: string; @@ -19,10 +20,16 @@ type RoomMenuProps = { const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '', hideDefaultOptions = false }: RoomMenuProps) => { const t = useTranslation(); + const subscription = useUserSubscription(rid); const isUnread = alert || unread || threadUnread; const sections = useRoomMenuActions({ rid, type, name, isUnread, cl, roomOpen, hideDefaultOptions }); + // Regular rooms get the kebab menu with the "Move to" category submenu; omnichannel/queued items keep the plain menu. + if (!hideDefaultOptions && type !== 'l') { + return ; + } + return ; }; diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx b/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx index ea8ca667ec079..3b2a039176d4b 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx +++ b/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx @@ -4,7 +4,26 @@ import { VideoConfContext } from '@rocket.chat/ui-video-conf'; import { renderHook } from '@testing-library/react'; import { useRoomList } from './useRoomList'; +import type { SidebarRoomListGroup } from './useRoomList'; import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../tests/mocks/data'; +import { useShowUnreadsGroups } from '../../views/navigation/hooks/useShowUnreadsGroups'; + +const mockedUseShowUnreadsGroups = jest.mocked(useShowUnreadsGroups); + +// `useRoomList` reads the open room to keep it visible while collapsed; mock it (no room open in these tests) +// to avoid loading the real RoomManager module graph (which imports non-JS assets jest can't transform). +jest.mock('../../lib/RoomManager', () => ({ + useOpenedRoom: () => undefined, +})); + +// System groups read their "Show unreads" flag from this hook (localStorage-backed in the app). Mock it so +// tests control it deterministically; it defaults ON (matching the real default), and the off-path tests +// override it for a specific group. +jest.mock('../../views/navigation/hooks/useShowUnreadsGroups'); + +// The hook returns a rich `groups` array; these helpers reproduce the legacy flat views used by the assertions. +const groupsListOf = (groups: SidebarRoomListGroup[]) => groups.map((group) => group.key); +const roomListOf = (groups: SidebarRoomListGroup[]) => groups.flatMap((group) => group.rooms); const user = createFakeUser({ active: true, @@ -94,29 +113,29 @@ const getWrapperSettings = ({ .withUserPreference('sidebarShowUnread', sidebarShowUnread) .withSetting('Discussion_enabled', isDiscussionEnabled); +// System groups default to "Show unreads" ON; off-path tests override this per test. +beforeEach(() => { + mockedUseShowUnreadsGroups.mockReturnValue({ isShowUnreads: () => true, toggleShowUnreads: jest.fn() }); +}); + it('should return roomList, groupsCount and groupsList', async () => { - const { - result: { - current: { roomList, groupsList, groupsCount }, - }, - } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({}).build(), }); - expect(roomList).toBeDefined(); - expect(groupsList).toBeDefined(); - expect(groupsCount).toBeDefined(); + expect(roomListOf(result.current.groups)).toBeDefined(); + expect(groupsListOf(result.current.groups)).toBeDefined(); + expect(result.current.groupsCount).toBeDefined(); }); it('should return groupsCount with the correct count', async () => { - const { - result: { - current: { groupsCount, roomList }, - }, - } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({}).build(), }); + const { groupsCount } = result.current; + const roomList = roomListOf(result.current.groups); + expect(groupsCount).toContain(fakeRooms.length); expect(groupsCount).not.toContain(fakeRooms.length + 5); expect(groupsCount.reduce((a, b) => a + b, 0)).toBe(fakeRooms.length); @@ -124,104 +143,105 @@ it('should return groupsCount with the correct count', async () => { }); it('should return roomList with the subscribed rooms and the correct length', async () => { - const { - result: { - current: { roomList }, - }, - } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({}).build(), }); + + const roomList = roomListOf(result.current.groups); expect(roomList).toContain(fakeRooms[0]); expect(roomList).toHaveLength(fakeRooms.length); }); it('should return groupsList with "Conversations" if preference sidebarGroupByType is not enabled', async () => { - const { - result: { - current: { groupsList }, - }, - } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({}).build(), }); + + const groupsList = groupsListOf(result.current.groups); expect(groupsList).toContain('Conversations'); expect(groupsList).toHaveLength(1); }); it('should return groupsList with "Teams" if sidebarGroupByType is enabled and roomList has teams', async () => { - const { - result: { - current: { groupsList, groupsCount }, - }, - } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(), }); + const groupsList = groupsListOf(result.current.groups); const teamsIndex = groupsList.indexOf('Teams'); expect(groupsList).toContain('Teams'); - expect(groupsCount[teamsIndex]).toEqual(teams.length); + expect(result.current.groupsCount[teamsIndex]).toEqual(teams.length); }); it('should return groupsList with "Favorites" if sidebarShowFavorites is enabled', async () => { - const { - result: { - current: { groupsList, groupsCount }, - }, - } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({ sidebarShowFavorites: true, sidebarGroupByType: true }).build(), }); + const groupsList = groupsListOf(result.current.groups); const favoritesIndex = groupsList.indexOf('Favorites'); expect(groupsList).toContain('Favorites'); - expect(groupsCount[favoritesIndex]).toEqual(favoriteRooms.length); + expect(result.current.groupsCount[favoritesIndex]).toEqual(favoriteRooms.length); }); it('should return groupsList with "Discussions" if isDiscussionEnabled is enabled', async () => { - const { - result: { - current: { groupsList, groupsCount }, - }, - } = renderHook(() => useRoomList({ collapsedGroups: [] }), { + const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({ isDiscussionEnabled: true, sidebarGroupByType: true }).build(), }); + const groupsList = groupsListOf(result.current.groups); const discussionIndex = groupsList.indexOf('Discussions'); expect(groupsList).toContain('Discussions'); - expect(groupsCount[discussionIndex]).toEqual(discussionRooms.length); + expect(result.current.groupsCount[discussionIndex]).toEqual(discussionRooms.length); }); it('should return groupsList without "Discussions" if isDiscussionEnabled is disabled', async () => { const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({ isDiscussionEnabled: false, sidebarGroupByType: true }).build(), }); - expect(result.current.groupsList).not.toContain('Discussions'); + expect(groupsListOf(result.current.groups)).not.toContain('Discussions'); +}); + +it('should remove corresponding items from roomList and return groupCount 0 when group is collapsed and "Show unreads" is off', async () => { + // "Show unreads" defaults ON, which keeps unread rooms visible while collapsed; turn it off for Channels. + mockedUseShowUnreadsGroups.mockReturnValue({ isShowUnreads: (group) => group !== 'Channels', toggleShowUnreads: jest.fn() }); + const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), { + wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(), + }); + const groupsList = groupsListOf(result.current.groups); + const roomList = roomListOf(result.current.groups); + const channelsIndex = groupsList.indexOf('Channels'); + expect(result.current.groupsCount[channelsIndex]).toEqual(0); + expect(roomList.length).toEqual(result.current.groupsCount.reduce((a, b) => a + b, 0)); }); -it('should remove corresponding items from roomList and return groupCount 0 when group is collapsed', async () => { - const { - result: { - current: { roomList, groupsCount, groupsList }, - }, - } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), { +it('should keep unread rooms visible (and show no header badge) when a group is collapsed and "Show unreads" is on by default', async () => { + const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), { wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(), }); + const groupsList = groupsListOf(result.current.groups); const channelsIndex = groupsList.indexOf('Channels'); - expect(groupsCount[channelsIndex]).toEqual(0); - expect(roomList.length).toEqual(groupsCount.reduce((a, b) => a + b, 0)); + // All 4 seeded channels are unread, so they stay visible despite the group being collapsed. + expect(result.current.groupsCount[channelsIndex]).toEqual(unreadChannels.length); + // With unreads visible, the header total badge is suppressed. + expect(result.current.groups[channelsIndex].unreadInfo.unread).toEqual(0); + expect(result.current.groups[channelsIndex].unreadInfo.tunread).toEqual([]); }); it('should always return groupsCount and groupsList with the same length', async () => { const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(), }); - expect(result.current.groupsCount.length).toEqual(result.current.groupsList.length); + expect(result.current.groupsCount.length).toEqual(groupsListOf(result.current.groups).length); }); it('should return "Unread" group with the correct items if sidebarShowUnread is enabled', async () => { const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), { wrapper: getWrapperSettings({ sidebarShowUnread: true, sidebarGroupByType: true }).build(), }); - const unreadIndex = result.current.groupsList.indexOf('Unread'); - expect(result.current.groupsList).toContain('Unread'); + const groupsList = groupsListOf(result.current.groups); + const unreadIndex = groupsList.indexOf('Unread'); + expect(groupsList).toContain('Unread'); expect(result.current.groupsCount[unreadIndex]).toEqual(unreadChannels.length); }); @@ -236,20 +256,24 @@ it('should not include unread room in unread group if hideUnreadStatus is enable } as unknown as SubscriptionWithRoom, }).build(), }); - const unreadIndex = result.current.groupsList.indexOf('Unread'); - const roomListUnread = result.current.roomList.filter((room) => room.unread); + const groupsList = groupsListOf(result.current.groups); + const unreadIndex = groupsList.indexOf('Unread'); + const roomListUnread = roomListOf(result.current.groups).filter((room) => room.unread); expect(result.current.groupsCount[unreadIndex]).toEqual(unreadChannels.length); expect(roomListUnread.length).not.toEqual(unreadChannels.length); }); -it('should accumulate unread data into `groupedUnreadInfo` when group is collapsed', async () => { +it('should accumulate unread data into `groupedUnreadInfo` when group is collapsed and "Show unreads" is off', async () => { + // The header total badge only accumulates when unreads are hidden, i.e. "Show unreads" off for the group. + mockedUseShowUnreadsGroups.mockReturnValue({ isShowUnreads: (group) => group !== 'Channels', toggleShowUnreads: jest.fn() }); const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), { wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(), }); - const channelsIndex = result.current.groupsList.indexOf('Channels'); - const { groupMentions, unread, userMentions, tunread, tunreadUser } = result.current.groupedUnreadInfo[channelsIndex]; + const groupsList = groupsListOf(result.current.groups); + const channelsIndex = groupsList.indexOf('Channels'); + const { groupMentions, unread, userMentions, tunread, tunreadUser } = result.current.groups[channelsIndex].unreadInfo; expect(groupMentions).toEqual(fakeRooms.reduce((acc, cv) => acc + cv.groupMentions, 0)); expect(unread).toEqual(fakeRooms.reduce((acc, cv) => acc + cv.unread, 0)); @@ -271,7 +295,8 @@ it('should add to unread group when has thread unread, even if alert is false', }).build(), }); - const unreadGroup = result.current.roomList.splice(0, result.current.groupsCount[0]); + const roomList = roomListOf(result.current.groups); + const unreadGroup = roomList.splice(0, result.current.groupsCount[0]); expect(unreadGroup.find((room) => room.name === fakeRoom.name)).toBeDefined(); }); @@ -288,6 +313,7 @@ it('should not add room to unread group if thread unread is an empty array', asy }).build(), }); - const unreadGroup = result.current.roomList.splice(0, result.current.groupsCount[0]); + const roomList = roomListOf(result.current.groups); + const unreadGroup = roomList.splice(0, result.current.groupsCount[0]); expect(unreadGroup.find((room) => room.name === fakeRoom.name)).toBeUndefined(); }); diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index eda1362011b7b..481039858360d 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -1,12 +1,17 @@ -import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings'; +import type { ILivechatInquiryRecord, ISidebarCustomCategory } from '@rocket.chat/core-typings'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import type { Keys as IconName } from '@rocket.chat/icons'; import { useFeaturePreview } from '@rocket.chat/ui-client'; -import type { SubscriptionWithRoom, TranslationKey } from '@rocket.chat/ui-contexts'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { useUserPreference, useUserSubscriptions, useSetting } from '@rocket.chat/ui-contexts'; import { useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf'; import { useMemo } from 'react'; import { useSortQueryOptions } from '../../hooks/useSortQueryOptions'; +import { useOpenedRoom } from '../../lib/RoomManager'; +import { useCustomCategories } from '../../views/navigation/hooks/useCustomCategories'; +import { useShowUnreadsGroups } from '../../views/navigation/hooks/useShowUnreadsGroups'; +import { useSystemGroupsOrder } from '../../views/navigation/hooks/useSystemGroupsOrder'; import { useOmnichannelEnabled } from '../../views/omnichannel/hooks/useOmnichannelEnabled'; import { useQueuedInquiries } from '../../views/omnichannel/hooks/useQueuedInquiries'; @@ -29,15 +34,57 @@ const order = [ 'Conversations', ] as const; +// Leading icon per system group, so system groups align with custom categories (which use folder/emoji). +const SYSTEM_GROUP_ICONS: Record = { + Incoming_Calls: 'phone', + Incoming_Livechats: 'burger-arrow-left', + Open_Livechats: 'user-arrow-right', + On_Hold_Chats: 'pause-unfilled', + Unread: 'flag', + Drafts: 'pencil', + Favorites: 'star', + Teams: 'team', + Discussions: 'balloons', + Channels: 'hashtag', + Direct_Messages: 'at', + Conversations: 'chat', +}; + +type GroupUnreadInfo = { + userMentions: number; + groupMentions: number; + tunread: string[]; + tunreadUser: string[]; + unread: number; +}; + +export type SidebarRoomListGroup = { + /** Collapse/show-unreads identity: translation key for system groups, category id for custom ones. */ + key: string; + /** Raw title — a translation key for system groups (translate it), the category name for custom ones. */ + title: string; + translateTitle: boolean; + /** Leading icon: folder for custom categories (overridden by their emoji), the type icon for system groups. */ + icon: IconName; + category?: ISidebarCustomCategory; + showUnreads: boolean; + collapsed: boolean; + /** Rooms to render — already filtered for collapse + "Show unreads". */ + rooms: SubscriptionWithRoom[]; + unreadInfo: GroupUnreadInfo; + /** A custom category with no rooms (renders the "drag rooms here" placeholder). */ + empty: boolean; +}; + type useRoomListReturnType = { - roomList: Array; + groups: SidebarRoomListGroup[]; groupsCount: number[]; - groupsList: TranslationKey[]; - groupedUnreadInfo: Pick< - SubscriptionWithRoom, - 'userMentions' | 'groupMentions' | 'unread' | 'tunread' | 'tunreadUser' | 'tunreadGroup' | 'alert' | 'hideUnreadStatus' - >[]; + totalCount: number; }; + +const isUnreadRoom = (room: SubscriptionWithRoom): boolean => + !room.hideUnreadStatus && Boolean(room.alert || room.unread || room.tunread?.length); + export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }): useRoomListReturnType => { const showOmnichannel = useOmnichannelEnabled(); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); @@ -47,6 +94,12 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const isDiscussionEnabled = useSetting('Discussion_enabled'); const sidebarShowUnread = useUserPreference('sidebarShowUnread'); + const { categories: customCategories } = useCustomCategories(); + const { isShowUnreads } = useShowUnreadsGroups(); + const { sortGroups } = useSystemGroupsOrder(); + + const openedRoom = useOpenedRoom(); + const options = useSortQueryOptions(); const rooms = useUserSubscriptions(query, options); @@ -57,21 +110,29 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const queue = inquiries.enabled ? inquiries.queue : emptyQueue; - const { groupsCount, groupsList, roomList, groupedUnreadInfo } = useDebouncedValue( + return useDebouncedValue( useMemo(() => { - const isCollapsed = (groupTitle: string) => collapsedGroups?.includes(groupTitle); - - const drafts = new Set(); - const incomingCall = new Set(); - const favorite = new Set(); - const team = new Set(); - const omnichannel = new Set(); - const unread = new Set(); - const channels = new Set(); - const direct = new Set(); - const discussion = new Set(); - const conversation = new Set(); - const onHold = new Set(); + const isCollapsed = (key: string) => collapsedGroups?.includes(key) ?? false; + + const drafts = new Set(); + const incomingCall = new Set(); + const favorite = new Set(); + const team = new Set(); + const omnichannel = new Set(); + const unread = new Set(); + const channels = new Set(); + const direct = new Set(); + const discussion = new Set(); + const conversation = new Set(); + const onHold = new Set(); + + // Map assigned rooms to their custom category, seeding an (initially empty) set per category. + const roomToCategory = new Map(); + const customSets = new Map>(); + customCategories.forEach((category) => { + customSets.set(category._id, new Set()); + category.rooms?.forEach((rid) => roomToCategory.set(rid, category._id)); + }); rooms.forEach((room) => { if (room.archived) { @@ -82,7 +143,14 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) return incomingCall.add(room); } - if (sidebarShowUnread && (room.alert || room.unread || room.tunread?.length) && !room.hideUnreadStatus) { + // A room in a custom category is shown only there (exclusive with Favorites and system groups). + const categoryId = roomToCategory.get(room.rid); + if (categoryId && customSets.has(categoryId)) { + customSets.get(categoryId)?.add(room); + return; + } + + if (sidebarShowUnread && isUnreadRoom(room)) { return unread.add(room); } @@ -107,11 +175,11 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) } if (room.t === 'l' && room.onHold) { - return showOmnichannel && onHold.add(room); + return void (showOmnichannel && onHold.add(room)); } if (room.t === 'l') { - return showOmnichannel && omnichannel.add(room); + return void (showOmnichannel && omnichannel.add(room)); } if (room.t === 'd') { @@ -121,10 +189,13 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) conversation.add(room); }); - const groups = new Map>(); + const groups = new Map>(); incomingCall.size && groups.set('Incoming_Calls', incomingCall); - showOmnichannel && inquiries.enabled && queue.length && groups.set('Incoming_Livechats', new Set(queue)); + showOmnichannel && + inquiries.enabled && + queue.length && + groups.set('Incoming_Livechats', new Set(queue) as unknown as Set); showOmnichannel && omnichannel.size && groups.set('Open_Livechats', omnichannel); showOmnichannel && onHold.size && groups.set('On_Hold_Chats', onHold); @@ -144,61 +215,83 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) !sidebarGroupByType && groups.set('Conversations', conversation); - const { groupsCount, groupsList, roomList, groupedUnreadInfo } = sidebarOrder.reduce( - (acc, key) => { - const value = groups.get(key); + const emptyUnreadInfo = (): GroupUnreadInfo => ({ userMentions: 0, groupMentions: 0, tunread: [], tunreadUser: [], unread: 0 }); - if (!value) { - return acc; + const buildUnreadInfo = (set: Set): GroupUnreadInfo => + [...set].reduce((counter, room) => { + if (room.hideUnreadStatus) { + return counter; } + counter.userMentions += room.userMentions || 0; + counter.groupMentions += room.groupMentions || 0; + counter.tunread = [...counter.tunread, ...(room.tunread || [])]; + counter.tunreadUser = [...counter.tunreadUser, ...(room.tunreadUser || [])]; + counter.unread += room.unread || 0; + !room.unread && !room.tunread?.length && room.alert && (counter.unread += 1); + return counter; + }, emptyUnreadInfo()); + + const makeGroup = ( + key: string, + title: string, + translateTitle: boolean, + set: Set, + category?: ISidebarCustomCategory, + ): SidebarRoomListGroup => { + const collapsed = isCollapsed(key); + const showUnreads = category ? category.showUnreads !== false : isShowUnreads(key); + const allRooms = [...set]; + // When collapsed, keep unread rooms (if enabled) plus the currently-open room always visible. + const collapsedRooms = allRooms.filter((room) => (showUnreads && isUnreadRoom(room)) || room.rid === openedRoom); + const displayRooms = collapsed ? collapsedRooms : allRooms; + + return { + key, + title, + translateTitle, + icon: category ? 'folder' : (SYSTEM_GROUP_ICONS[key] ?? 'hashtag'), + category, + showUnreads, + collapsed, + rooms: displayRooms, + // The header total badge is only useful when the unread rooms are hidden — i.e. collapsed AND + // "Show unreads" off. With "Show unreads" on, the unread rooms stay visible (with their own + // counters) even collapsed, so the header acts as when open and shows no badge. + unreadInfo: collapsed && !showUnreads ? buildUnreadInfo(set) : emptyUnreadInfo(), + empty: allRooms.length === 0, + }; + }; + + // Custom categories render first (above the system groups) and persist even when empty. + const customGroups = customCategories.map((category) => + makeGroup(category._id, category.name, false, customSets.get(category._id) ?? new Set(), category), + ); - acc.groupsList.push(key as TranslationKey); - - const groupedUnreadInfoAcc = { - userMentions: 0, - groupMentions: 0, - tunread: [], - tunreadUser: [], - unread: 0, - }; - - if (isCollapsed(key)) { - const groupedUnreadInfo = [...value].reduce( - (counter, { userMentions, groupMentions, tunread, tunreadUser, unread, alert, hideUnreadStatus }) => { - if (hideUnreadStatus) { - return counter; - } - - counter.userMentions += userMentions || 0; - counter.groupMentions += groupMentions || 0; - counter.tunread = [...counter.tunread, ...(tunread || [])]; - counter.tunreadUser = [...counter.tunreadUser, ...(tunreadUser || [])]; - counter.unread += unread || 0; - !unread && !tunread?.length && alert && (counter.unread += 1); - return counter; - }, - groupedUnreadInfoAcc, - ); - - acc.groupedUnreadInfo.push(groupedUnreadInfo); - acc.groupsCount.push(0); - return acc; + const systemGroups = sortGroups( + sidebarOrder.reduce((acc, key) => { + const set = groups.get(key); + if (set) { + acc.push(makeGroup(key, key, true, set)); } - - acc.groupedUnreadInfo.push(groupedUnreadInfoAcc); - acc.groupsCount.push(value.size); - acc.roomList.push(...value); return acc; - }, - { - groupsCount: [], - groupsList: [], - roomList: [], - groupedUnreadInfo: [], - } as useRoomListReturnType, + }, []), ); - return { groupsCount, groupsList, roomList, groupedUnreadInfo }; + const allGroups = [...customGroups, ...systemGroups]; + + const groupsCount = allGroups.map((group) => { + // An expanded empty custom category reserves a single row for the "drag rooms here" placeholder. + if (group.empty) { + return group.collapsed ? 0 : 1; + } + return group.rooms.length; + }); + + return { + groups: allGroups, + groupsCount, + totalCount: groupsCount.reduce((acc, count) => acc + count, 0), + }; }, [ rooms, showOmnichannel, @@ -212,14 +305,11 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) sidebarOrder, collapsedGroups, incomingCalls, + customCategories, + isShowUnreads, + sortGroups, + openedRoom, ]), 50, ); - - return { - roomList, - groupsCount, - groupsList, - groupedUnreadInfo, - }; }; diff --git a/apps/meteor/client/views/navigation/contexts/RoomsNavigationContext.ts b/apps/meteor/client/views/navigation/contexts/RoomsNavigationContext.ts index ce6d09bdca41d..86688594fd7c8 100644 --- a/apps/meteor/client/views/navigation/contexts/RoomsNavigationContext.ts +++ b/apps/meteor/client/views/navigation/contexts/RoomsNavigationContext.ts @@ -1,11 +1,22 @@ -import { type ISubscription, type ILivechatInquiryRecord, type IRoom, isTeamRoom, isDirectMessageRoom } from '@rocket.chat/core-typings'; +import { + type ISidebarCustomCategory, + type ISubscription, + type ILivechatInquiryRecord, + type IRoom, + isTeamRoom, + isDirectMessageRoom, +} from '@rocket.chat/core-typings'; import { useStableCallback, useLocalStorage } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; import { isTruthy } from '@rocket.chat/tools'; import type { SubscriptionWithRoom, TranslationKey } from '@rocket.chat/ui-contexts'; import { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOpenedRoom } from '../../../lib/RoomManager'; import { useCollapsedGroups } from '../hooks/useCollapsedGroups'; +import { useShowUnreadsGroups } from '../hooks/useShowUnreadsGroups'; +import { useSystemGroupsOrder } from '../hooks/useSystemGroupsOrder'; export const sidePanelFiltersConfig: { [Key in AllGroupsKeys]: { title: TranslationKey; icon: IconName } } = { all: { @@ -84,6 +95,9 @@ export type RoomsNavigationContextValue = { currentFilter: AllGroupsKeysWithUnread; setFilter: (filter: AllGroupsKeys, unread: boolean, parentRid?: IRoom['_id']) => void; unreadGroupData: Map; + customCategories: ISidebarCustomCategory[]; + customGroups: Map>; + customUnreadData: Map; parentRid?: IRoom['_id']; }; @@ -126,37 +140,107 @@ export const getEmptyUnreadInfo = (): GroupedUnreadInfoData => ({ }); // Hooks -type RoomListGroup = { - group: T; - rooms: Array; + +/** + * A renderable section of the sidebar room list. Either a system/standard group (Teams, Channels, …) + * or a user-defined custom category (`category` is set). + */ +export type SideBarRoomListItem = { + /** Group key: a system filter key or a custom category id. Used for collapse state and DnD. */ + key: string; + /** Resolved display title (translated for system groups, raw name for custom categories). */ + title: string; + icon: IconName; + /** Rooms to render — already filtered for collapse + "Show unreads" behavior. */ + rooms: SubscriptionWithRoom[]; unreadInfo: GroupedUnreadInfoData; + collapsed: boolean; + showUnreads: boolean; + /** True for a custom category that currently has no rooms (renders the "drag rooms here" placeholder). */ + empty: boolean; + category?: ISidebarCustomCategory; +}; + +const getDisplayRooms = ( + rooms: SubscriptionWithRoom[], + collapsed: boolean, + showUnreads: boolean, + openedRoom: string | undefined, +): SubscriptionWithRoom[] => { + if (!collapsed) { + return rooms; + } + // When collapsed, keep unread rooms (if enabled) plus the currently-open room always visible. + return rooms.filter((room) => (showUnreads && isUnreadSubscription(room)) || room.rid === openedRoom); }; export const useSideBarRoomsList = (): { - roomListGroups: RoomListGroup[]; + roomListGroups: SideBarRoomListItem[]; groupCounts: number[]; totalCount: number; } & ReturnType => { + const { t } = useTranslation(); const { collapsedGroups, handleClick, handleKeyDown } = useCollapsedGroups(); - const { groups, unreadGroupData } = useRoomsListContext(); + const { isShowUnreads } = useShowUnreadsGroups(); + const { sortGroups } = useSystemGroupsOrder(); + const { groups, unreadGroupData, customCategories, customGroups, customUnreadData } = useRoomsListContext(); + + const openedRoom = useOpenedRoom(); + + // Custom categories render first (above the system groups) and persist even when empty. + const customItems: SideBarRoomListItem[] = customCategories.map((category) => { + const roomSet = customGroups.get(category._id); + const rooms = roomSet ? Array.from(roomSet) : []; + const collapsed = collapsedGroups.includes(category._id); + const showUnreads = category.showUnreads !== false; + + return { + key: category._id, + title: category.name, + icon: 'folder', + rooms: getDisplayRooms(rooms, collapsed, showUnreads, openedRoom), + // The header total badge is only useful when the unread rooms are hidden — i.e. collapsed AND + // "Show unreads" off. With "Show unreads" on, the unread rooms stay visible (with their own + // counters) even collapsed, so the header acts as when open and shows no badge. + unreadInfo: collapsed && !showUnreads ? customUnreadData.get(category._id) || getEmptyUnreadInfo() : getEmptyUnreadInfo(), + collapsed, + showUnreads, + empty: rooms.length === 0, + category, + }; + }); - const roomListGroups = collapsibleFilters - .map((group) => { + const systemItems: SideBarRoomListItem[] = collapsibleFilters + .map((group): SideBarRoomListItem | undefined => { const roomSet = (groups as Map>).get(group); const rooms = roomSet ? Array.from(roomSet) : []; - const unreadInfo = unreadGroupData.get(group) || getEmptyUnreadInfo(); if (!rooms.length) { return undefined; } - return { group, rooms, unreadInfo }; + const collapsed = collapsedGroups.includes(group); + const showUnreads = isShowUnreads(group); + + return { + key: group, + title: t(sidePanelFiltersConfig[group].title), + icon: sidePanelFiltersConfig[group].icon, + rooms: getDisplayRooms(rooms, collapsed, showUnreads, openedRoom), + unreadInfo: collapsed && !showUnreads ? unreadGroupData.get(group) || getEmptyUnreadInfo() : getEmptyUnreadInfo(), + collapsed, + showUnreads, + empty: false, + }; }) .filter(isTruthy); + const roomListGroups = [...customItems, ...sortGroups(systemItems)]; + const groupCounts = roomListGroups.map((group) => { - if (collapsedGroups.includes(group.group)) { - return 0; + // An expanded empty custom category reserves a single row for the "drag rooms here" placeholder. + if (group.empty) { + return group.collapsed ? 0 : 1; } return group.rooms.length; }); diff --git a/apps/meteor/client/views/navigation/hooks/useCustomCategories.ts b/apps/meteor/client/views/navigation/hooks/useCustomCategories.ts new file mode 100644 index 0000000000000..21a5830a0cbb0 --- /dev/null +++ b/apps/meteor/client/views/navigation/hooks/useCustomCategories.ts @@ -0,0 +1,236 @@ +import type { ISidebarCustomCategory } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { useEndpoint, useToastMessageDispatch, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { toggleFavoriteRoom } from '../../../lib/mutationEffects/room'; + +export const MAX_CATEGORY_NAME_LENGTH = 30; + +export type CategoryNameError = 'empty' | 'duplicate'; + +/** A room being moved between groupings. */ +export type MovableRoom = { rid: string; name: string; isFavorite?: boolean }; + +/** The `favorites` sentinel is mutually exclusive with the custom categories. */ +export const FAVORITES_TARGET = 'favorites'; + +const EMPTY: ISidebarCustomCategory[] = []; + +const stripRoom = (categories: ISidebarCustomCategory[], rid: string): ISidebarCustomCategory[] => + categories.map((category) => + category.rooms?.includes(rid) ? { ...category, rooms: category.rooms.filter((room) => room !== rid) } : category, + ); + +/** + * Per-user custom sidebar categories, persisted in the `sidebarCustomCategories` user preference. + * All mutations are silent except `moveRoom`/`createCategoryAndMoveRoom`, which dispatch the move toast. + */ +export const useCustomCategories = () => { + const { t } = useTranslation(); + const uid = useUserId(); + const dispatchToastMessage = useToastMessageDispatch(); + const categories = useUserPreference('sidebarCustomCategories', EMPTY) ?? EMPTY; + + const saveUserPreferences = useEndpoint('POST', '/v1/users.setPreferences'); + const toggleFavoriteEndpoint = useEndpoint('POST', '/v1/rooms.favorite'); + + const persist = useCallback( + (next: ISidebarCustomCategory[]) => saveUserPreferences({ data: { sidebarCustomCategories: next } }), + [saveUserPreferences], + ); + + const setFavorite = useCallback( + async (rid: string, favorite: boolean) => { + await toggleFavoriteEndpoint({ roomId: rid, favorite }); + toggleFavoriteRoom(rid, favorite, uid ?? undefined); + }, + [toggleFavoriteEndpoint, uid], + ); + + const validateName = useCallback( + (name: string, excludeId?: string): CategoryNameError | undefined => { + const trimmed = name.trim(); + if (!trimmed) { + return 'empty'; + } + const normalized = trimmed.toLowerCase(); + if (categories.some((category) => category._id !== excludeId && category.name.trim().toLowerCase() === normalized)) { + return 'duplicate'; + } + return undefined; + }, + [categories], + ); + + const createCategory = useCallback( + async (name: string, icon?: string): Promise => { + const category: ISidebarCustomCategory = { + _id: Random.id(), + name: name.trim(), + showUnreads: true, + rooms: [], + ...(icon ? { icon } : {}), + }; + await persist([...categories, category]); + return category; + }, + [categories, persist], + ); + + /** Updates a category's name and emoji icon. A falsy `icon` clears it (back to the folder icon). */ + const renameCategory = useCallback( + (categoryId: string, name: string, icon?: string) => + persist( + categories.map((category) => { + if (category._id !== categoryId) { + return category; + } + const { icon: _previous, ...rest } = category; + return { ...rest, name: name.trim(), ...(icon ? { icon } : {}) }; + }), + ), + [categories, persist], + ); + + const deleteCategory = useCallback( + (categoryId: string) => persist(categories.filter((category) => category._id !== categoryId)), + [categories, persist], + ); + + const reorderCategory = useCallback( + (categoryId: string, direction: 'up' | 'down') => { + const index = categories.findIndex((category) => category._id === categoryId); + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (index === -1 || targetIndex < 0 || targetIndex >= categories.length) { + return undefined; + } + const next = [...categories]; + [next[index], next[targetIndex]] = [next[targetIndex], next[index]]; + return persist(next); + }, + [categories, persist], + ); + + const toggleShowUnreads = useCallback( + (categoryId: string) => + persist( + categories.map((category) => + category._id === categoryId ? { ...category, showUnreads: category.showUnreads === false } : category, + ), + ), + [categories, persist], + ); + + /** Move a room into a custom category (by id) or to Favorites. Assignment is exclusive. */ + const moveRoom = useCallback( + async (room: MovableRoom, target: string) => { + const stripped = stripRoom(categories, room.rid); + + if (target === FAVORITES_TARGET) { + await persist(stripped); + if (!room.isFavorite) { + await setFavorite(room.rid, true); + } + dispatchToastMessage({ + type: 'success', + message: t('__roomName__moved_to__categoryName__', { roomName: room.name, categoryName: t('Favorites') }), + }); + return; + } + + const category = categories.find((current) => current._id === target); + if (!category) { + return; + } + await persist( + stripped.map((current) => (current._id === target ? { ...current, rooms: [...(current.rooms ?? []), room.rid] } : current)), + ); + if (room.isFavorite) { + await setFavorite(room.rid, false); + } + dispatchToastMessage({ + type: 'success', + message: t('__roomName__moved_to__categoryName__', { roomName: room.name, categoryName: category.name }), + }); + }, + [categories, persist, setFavorite, dispatchToastMessage, t], + ); + + /** Create a category and move a room into it in a single persisted action (flow D). */ + const createCategoryAndMoveRoom = useCallback( + async (name: string, room: MovableRoom, icon?: string) => { + const category: ISidebarCustomCategory = { + _id: Random.id(), + name: name.trim(), + showUnreads: true, + rooms: [room.rid], + ...(icon ? { icon } : {}), + }; + await persist([...stripRoom(categories, room.rid), category]); + if (room.isFavorite) { + await setFavorite(room.rid, false); + } + dispatchToastMessage({ + type: 'success', + message: t('__roomName__moved_to__categoryName__', { roomName: room.name, categoryName: category.name }), + }); + }, + [categories, persist, setFavorite, dispatchToastMessage, t], + ); + + const getRoomCategory = useCallback( + (rid: string): ISidebarCustomCategory | undefined => categories.find((category) => category.rooms?.includes(rid)), + [categories], + ); + + /** Remove a room from its current grouping (custom category or Favorites) — it returns to its system group. */ + const removeRoom = useCallback( + async (room: MovableRoom) => { + const current = categories.find((category) => category.rooms?.includes(room.rid)); + const fromName = current?.name ?? (room.isFavorite ? t('Favorites') : ''); + + await persist(stripRoom(categories, room.rid)); + if (room.isFavorite) { + await setFavorite(room.rid, false); + } + if (fromName) { + dispatchToastMessage({ + type: 'success', + message: t('__roomName__removed_from__categoryName__', { roomName: room.name, categoryName: fromName }), + }); + } + }, + [categories, persist, setFavorite, dispatchToastMessage, t], + ); + + return useMemo( + () => ({ + categories, + validateName, + createCategory, + renameCategory, + deleteCategory, + reorderCategory, + toggleShowUnreads, + moveRoom, + createCategoryAndMoveRoom, + removeRoom, + getRoomCategory, + }), + [ + categories, + validateName, + createCategory, + renameCategory, + deleteCategory, + reorderCategory, + toggleShowUnreads, + moveRoom, + createCategoryAndMoveRoom, + removeRoom, + getRoomCategory, + ], + ); +}; diff --git a/apps/meteor/client/views/navigation/hooks/useShowUnreadsGroups.ts b/apps/meteor/client/views/navigation/hooks/useShowUnreadsGroups.ts new file mode 100644 index 0000000000000..d57c2c6bde586 --- /dev/null +++ b/apps/meteor/client/views/navigation/hooks/useShowUnreadsGroups.ts @@ -0,0 +1,29 @@ +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useCallback } from 'react'; + +/** + * Controls the "Show unreads" behavior of the system/standard sidebar groups (Teams, Channels, …). + * Custom categories keep this flag on their own preference object instead. + * + * Defaults to ON for every group (matching custom categories, whose `showUnreads` defaults to true): + * localStorage only stores the group keys whose toggle has been turned OFF. A dedicated key is used so it + * doesn't collide with the previous (default-OFF) `sidebarShownUnreadGroups` data. + */ +export const useShowUnreadsGroups = () => { + const [hiddenUnreadGroups, setHiddenUnreadGroups] = useLocalStorage('sidebarHiddenUnreadGroups', []); + + const isShowUnreads = useCallback((group: string) => !hiddenUnreadGroups.includes(group), [hiddenUnreadGroups]); + + const toggleShowUnreads = useCallback( + (group: string) => { + if (hiddenUnreadGroups.includes(group)) { + setHiddenUnreadGroups(hiddenUnreadGroups.filter((item) => item !== group)); + } else { + setHiddenUnreadGroups([...hiddenUnreadGroups, group]); + } + }, + [hiddenUnreadGroups, setHiddenUnreadGroups], + ); + + return { isShowUnreads, toggleShowUnreads }; +}; diff --git a/apps/meteor/client/views/navigation/hooks/useSystemGroupsOrder.ts b/apps/meteor/client/views/navigation/hooks/useSystemGroupsOrder.ts new file mode 100644 index 0000000000000..96da4e88329cd --- /dev/null +++ b/apps/meteor/client/views/navigation/hooks/useSystemGroupsOrder.ts @@ -0,0 +1,41 @@ +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useCallback } from 'react'; + +/** + * Persists a user-chosen order for the system/standard sidebar groups (menu-driven Move up / Move down). + * Custom categories are ordered by their preference array instead. + */ +export const useSystemGroupsOrder = () => { + const [order, setOrder] = useLocalStorage('sidebarSystemGroupsOrder', []); + + const sortGroups = useCallback( + (groups: T[]): T[] => { + if (!order.length) { + return groups; + } + const rank = (key: string) => { + const index = order.indexOf(key); + return index === -1 ? Number.MAX_SAFE_INTEGER : index; + }; + return [...groups].sort((a, b) => rank(a.key) - rank(b.key)); + }, + [order], + ); + + /** Swap a group with its neighbor among the currently-visible system groups. */ + const move = useCallback( + (visibleKeys: string[], key: string, direction: 'up' | 'down') => { + const index = visibleKeys.indexOf(key); + const target = direction === 'up' ? index - 1 : index + 1; + if (index === -1 || target < 0 || target >= visibleKeys.length) { + return; + } + const next = [...visibleKeys]; + [next[index], next[target]] = [next[target], next[index]]; + setOrder(next); + }, + [setOrder], + ); + + return { sortGroups, move }; +}; diff --git a/apps/meteor/client/views/navigation/providers/RoomsNavigationProvider.tsx b/apps/meteor/client/views/navigation/providers/RoomsNavigationProvider.tsx index 23ae31a1d0b85..b126e024cf639 100644 --- a/apps/meteor/client/views/navigation/providers/RoomsNavigationProvider.tsx +++ b/apps/meteor/client/views/navigation/providers/RoomsNavigationProvider.tsx @@ -1,5 +1,5 @@ import { isDirectMessageRoom, isDiscussion, isOmnichannelRoom, isPrivateRoom, isPublicRoom, isTeamRoom } from '@rocket.chat/core-typings'; -import type { ILivechatInquiryRecord, IRoom } from '@rocket.chat/core-typings'; +import type { ILivechatInquiryRecord, IRoom, ISidebarCustomCategory } from '@rocket.chat/core-typings'; import { useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks'; import type { SubscriptionWithRoom, TranslationKey } from '@rocket.chat/ui-contexts'; import { useSetting, useUserPreference, useUserSubscriptions, useLayout } from '@rocket.chat/ui-contexts'; @@ -54,11 +54,24 @@ const hasMention = (room: SubscriptionWithRoom) => room.userMentions || room.groupMentions || room.tunreadUser?.length || room.tunreadGroup?.length; type UnreadGroupDataMap = Map; +type CustomGroupMap = Map>; +type CustomUnreadDataMap = Map; -const useRoomsGroups = (): [RoomsNavigationGroup, UnreadGroupDataMap] => { +const emptyCategories: ISidebarCustomCategory[] = []; + +type RoomsGroupsResult = { + groups: RoomsNavigationGroup; + unreadGroupData: UnreadGroupDataMap; + customCategories: ISidebarCustomCategory[]; + customGroups: CustomGroupMap; + customUnreadData: CustomUnreadDataMap; +}; + +const useRoomsGroups = (): RoomsGroupsResult => { const showOmnichannel = useOmnichannelEnabled(); const sidebarShowUnread = useUserPreference('sidebarShowUnread'); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); + const customCategories = useUserPreference('sidebarCustomCategories', emptyCategories) ?? emptyCategories; const isDiscussionEnabled = useSetting('Discussion_enabled'); const options = useSortQueryOptions(); @@ -67,13 +80,23 @@ const useRoomsGroups = (): [RoomsNavigationGroup, UnreadGroupDataMap] => { const inquiries = useQueuedInquiries(); const queue = inquiries.enabled ? inquiries.queue : emptyQueue; - return useDebouncedValue( + const { groups, unreadGroupData, customGroups, customUnreadData } = useDebouncedValue( useMemo(() => { const groups: RoomsNavigationGroup = new Map(); showOmnichannel && groups.set('queue', new Set(queue)); const unreadGroupData: UnreadGroupDataMap = new Map(); + // Map each assigned room to its custom category and seed an (initially empty) set per category, + // so empty categories still render their "drag rooms here" placeholder. + const roomToCategory = new Map(); + const customGroups: CustomGroupMap = new Map(); + const customUnreadData: CustomUnreadDataMap = new Map(); + customCategories.forEach((category) => { + customGroups.set(category._id, new Set()); + category.rooms?.forEach((rid) => roomToCategory.set(rid, category._id)); + }); + const setGroupRoom = (key: AllGroupsKeys, room: SubscriptionWithRoom) => { const getGroupSet = (key: AllGroupsKeysWithUnread) => { const roomSet = groups.get(key) || new Set(); @@ -94,6 +117,15 @@ const useRoomsGroups = (): [RoomsNavigationGroup, UnreadGroupDataMap] => { } }; + const setCustomGroupRoom = (categoryId: string, room: SubscriptionWithRoom) => { + customGroups.get(categoryId)?.add(room); + + if (isUnreadSubscription(room)) { + const currentUnreadData = customUnreadData.get(categoryId) || getEmptyUnreadInfo(); + customUnreadData.set(categoryId, updateGroupUnreadInfo(room, currentUnreadData)); + } + }; + rooms.forEach((room) => { if (room.archived) { return; @@ -113,6 +145,13 @@ const useRoomsGroups = (): [RoomsNavigationGroup, UnreadGroupDataMap] => { setGroupRoom('mentions', room); } + // A room in a custom category is shown only there (exclusive with Favorites and system groups). + const categoryId = roomToCategory.get(room.rid); + if (categoryId && customGroups.has(categoryId)) { + setCustomGroupRoom(categoryId, room); + return; + } + if (room.f) { setGroupRoom('favorites', room); } @@ -145,10 +184,12 @@ const useRoomsGroups = (): [RoomsNavigationGroup, UnreadGroupDataMap] => { } }); - return [groups, unreadGroupData]; - }, [showOmnichannel, queue, rooms, sidebarShowUnread, sidebarGroupByType, isDiscussionEnabled]), + return { groups, unreadGroupData, customGroups, customUnreadData }; + }, [showOmnichannel, queue, rooms, sidebarShowUnread, sidebarGroupByType, isDiscussionEnabled, customCategories]), 50, ); + + return { groups, unreadGroupData, customCategories, customGroups, customUnreadData }; }; const RoomsNavigationContextProvider = ({ children }: { children: ReactNode }) => { @@ -165,7 +206,7 @@ const RoomsNavigationContextProvider = ({ children }: { children: ReactNode }) = setParentRoom(filter, parentRid); }); - const [groups, unreadGroupData] = useRoomsGroups(); + const { groups, unreadGroupData, customCategories, customGroups, customUnreadData } = useRoomsGroups(); const handleRoomOpened = useStableCallback((rid: string) => { const room = Rooms.use.getState().find((r) => r._id === rid); @@ -219,9 +260,12 @@ const RoomsNavigationContextProvider = ({ children }: { children: ReactNode }) = setFilter, groups, unreadGroupData, + customCategories, + customGroups, + customUnreadData, parentRid, }; - }, [parentRid, currentFilter, setFilter, groups, unreadGroupData]); + }, [parentRid, currentFilter, setFilter, groups, unreadGroupData, customCategories, customGroups, customUnreadData]); return {children}; }; diff --git a/apps/meteor/client/views/navigation/sidebar/RoomList/RoomList.tsx b/apps/meteor/client/views/navigation/sidebar/RoomList/RoomList.tsx index 93c283a62b189..690abbdcc92bc 100644 --- a/apps/meteor/client/views/navigation/sidebar/RoomList/RoomList.tsx +++ b/apps/meteor/client/views/navigation/sidebar/RoomList/RoomList.tsx @@ -12,15 +12,22 @@ import RoomListRow from './RoomListRow'; import RoomListRowWrapper from './RoomListRowWrapper'; import RoomListWrapper from './RoomListWrapper'; import { useOpenedRoom } from '../../../../lib/RoomManager'; -import { useSideBarRoomsList, sidePanelFiltersConfig } from '../../contexts/RoomsNavigationContext'; +import { useSideBarRoomsList } from '../../contexts/RoomsNavigationContext'; +import { useCustomCategories } from '../../hooks/useCustomCategories'; +import { useSystemGroupsOrder } from '../../hooks/useSystemGroupsOrder'; +import { CategoryDnDProvider } from '../categories/CategoryDnDContext'; +import CategoryDropHighlight from '../categories/CategoryDropHighlight'; +import CategoryEmptyPlaceholder from '../categories/CategoryEmptyPlaceholder'; import { usePreventDefault } from '../hooks/usePreventDefault'; import { useShortcutOpenMenu } from '../hooks/useShortcutOpenMenu'; -const RoomList = () => { +const RoomListInner = () => { const { t } = useTranslation(); const isAnonymous = !useUserId(); - const { roomListGroups, groupCounts, collapsedGroups, handleClick, handleKeyDown, totalCount } = useSideBarRoomsList(); + const { roomListGroups, groupCounts, handleClick, handleKeyDown, totalCount } = useSideBarRoomsList(); + const { reorderCategory } = useCustomCategories(); + const { move: moveSystemGroup } = useSystemGroupsOrder(); const { ref } = useResizeObserver({ debounceDelay: 100 }); const openedRoom = useOpenedRoom() ?? ''; @@ -33,35 +40,59 @@ const RoomList = () => { [isAnonymous, openedRoom, t], ); + const customCount = roomListGroups.filter((group) => group.category).length; + const systemKeys = roomListGroups.filter((group) => !group.category).map((group) => group.key); + usePreventDefault(ref); useShortcutOpenMenu(ref); return ( - + // `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. + + { - const { group, unreadInfo } = roomListGroups[index]; + const group = roomListGroups[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 ( handleClick(group)} - onKeyDown={(e) => handleKeyDown(e, group)} - groupTitle={sidePanelFiltersConfig[group].title} group={group} - unreadCount={unreadInfo} + 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 { rooms } = roomListGroups[groupIndex]; + const group = roomListGroups[groupIndex]; + + if (group.empty) { + return ; + } + // Grouped virtuoso index increases linearly, but we're indexing the list by group. - // Either we go back to providing a single list, or we do this. const correctedIndex = index - groupCounts.slice(0, groupIndex).reduce((acc, count) => acc + count, 0); - return ; + return ( + + ); }, })} components={{ Header: RoomsListFilters, Item: RoomListRowWrapper, List: RoomListWrapper }} @@ -71,4 +102,11 @@ const RoomList = () => { ); }; +// eslint-disable-next-line react/no-multi-comp +const RoomList = () => ( + + + +); + export default RoomList; diff --git a/apps/meteor/client/views/navigation/sidebar/RoomList/RoomListCollapser.tsx b/apps/meteor/client/views/navigation/sidebar/RoomList/RoomListCollapser.tsx index 01eb2c0b60d1e..d073cdb8dbde6 100644 --- a/apps/meteor/client/views/navigation/sidebar/RoomList/RoomListCollapser.tsx +++ b/apps/meteor/client/views/navigation/sidebar/RoomList/RoomListCollapser.tsx @@ -1,40 +1,112 @@ -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 { HTMLAttributes, KeyboardEvent, MouseEventHandler } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { AllGroupsKeys } from '../../contexts/RoomsNavigationContext'; +import type { SideBarRoomListItem } from '../../contexts/RoomsNavigationContext'; +import { useGroupDrop } from '../categories/CategoryDnDContext'; +import CategoryLabel from '../categories/CategoryLabel'; +import CategoryMenu from '../categories/CategoryMenu'; 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 = { - group: AllGroupsKeys; - groupTitle: string; - collapsedGroups: string[]; + group: SideBarRoomListItem; + canMoveUp: boolean; + canMoveDown: boolean; + onMoveUp: () => void; + onMoveDown: () => void; onClick: MouseEventHandler; onKeyDown: (e: KeyboardEvent) => void; - unreadCount: Pick; } & Omit, 'onClick' | 'onKeyDown'>; -const RoomListCollapser = ({ groupTitle, unreadCount: unreadGroupCount, collapsedGroups, group, ...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); + + // `group.title` (string) drives the accessible name; this node is rendered 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 = ( + + ) as unknown as string; + + // `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; - const { unreadTitle, unreadVariant, showUnread, unreadCount } = useUnreadDisplay(unreadGroupCount); return ( - - {unreadCount.total} - - ) : undefined - } - aria-label={ - !collapsedGroups.includes(group) ? 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. + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {unreadCount.total} + + ) : undefined + } + aria-label={group.collapsed ? t('Expand_group', { group: group.title }) : t('Collapse_group', { group: group.title })} + {...props} + /> + {showActions && ( + + + + )} + + ); }; diff --git a/apps/meteor/client/views/navigation/sidebar/RoomList/RoomListRow.tsx b/apps/meteor/client/views/navigation/sidebar/RoomList/RoomListRow.tsx index 65c1bd9664b26..8f48aba341abc 100644 --- a/apps/meteor/client/views/navigation/sidebar/RoomList/RoomListRow.tsx +++ b/apps/meteor/client/views/navigation/sidebar/RoomList/RoomListRow.tsx @@ -12,9 +12,12 @@ type RoomListRowProps = { isAnonymous: boolean; }; item: SubscriptionWithRoom; + /** The sidebar group this row belongs to (system filter key or custom category id). */ + groupKey?: string; + isCustomCategory?: boolean; }; -const RoomListRow = ({ data, item }: RoomListRowProps) => { +const RoomListRow = ({ data, item, groupKey, isCustomCategory }: RoomListRowProps) => { const { t } = data; const acceptCall = useVideoConfAcceptCall(); @@ -31,7 +34,9 @@ const RoomListRow = ({ data, item }: RoomListRowProps) => { [acceptCall, rejectCall, currentCall], ); - return ; + return ( + + ); }; export default memo(RoomListRow); diff --git a/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItemMenu.tsx b/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItemMenu.tsx new file mode 100644 index 0000000000000..00177320e7131 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItemMenu.tsx @@ -0,0 +1,32 @@ +import type { SubscriptionWithRoom, LocationPathname } from '@rocket.chat/ui-contexts'; +import { memo } from 'react'; + +import { useOpenedRoom } from '../../../../lib/RoomManager'; +import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; +import { useRoomMenuActions } from '../../sidepanel/hooks/useRoomMenuActions'; +import RoomMenuWithCategories from '../categories/RoomMenuWithCategories'; + +/** Kebab menu for a room row in the main sidebar: default room actions + a "Move to" category submenu. */ +const SidebarItemMenu = ({ room }: { room: SubscriptionWithRoom }) => { + const openedRoom = useOpenedRoom(); + + const { rid, t: type, cl, f: isFavorite, unread, alert } = room; + const title = roomCoordinator.getRoomName(room.t, room) || ''; + const href = (roomCoordinator.getRouteLink(room.t, room) || undefined) as LocationPathname | undefined; + const isUnread = Boolean(alert || unread); + + const sections = useRoomMenuActions({ + rid, + type, + name: title, + isUnread, + cl, + roomOpen: rid === openedRoom, + hideDefaultOptions: false, + href, + }); + + return ; +}; + +export default memo(SidebarItemMenu); diff --git a/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItemWithData.tsx b/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItemWithData.tsx index 79b933e59ca3d..460febaa6dbd0 100644 --- a/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItemWithData.tsx +++ b/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItemWithData.tsx @@ -2,15 +2,19 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { SidebarV2Action, SidebarV2Actions, SidebarV2ItemIcon } from '@rocket.chat/fuselage'; import { useButtonPattern } from '@rocket.chat/fuselage-hooks'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; import type { TFunction } from 'i18next'; -import type { AllHTMLAttributes } from 'react'; -import { memo, useMemo } from 'react'; +import type { AllHTMLAttributes, MouseEvent } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import SidebarItem from './SidebarItem'; +import SidebarItemMenu from './SidebarItemMenu'; import { RoomIcon } from '../../../../components/RoomIcon'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { useRoomsListContext, useIsRoomFilter, useRedirectToFilter } from '../../contexts/RoomsNavigationContext'; import SidebarItemBadges from '../badges/SidebarItemBadges'; +import { useGroupDrop, useRoomDrag } from '../categories/CategoryDnDContext'; +import { NAVIGATION_NATIVE_KEYS, getNativeCategoryKey } from '../categories/nativeCategory'; import { useUnreadDisplay } from '../hooks/useUnreadDisplay'; type RoomListRowProps = { @@ -23,15 +27,52 @@ type RoomListRowProps = { /* @deprecated */ style?: AllHTMLAttributes['style']; + /** The sidebar group this row belongs to (system filter key or custom category id). */ + groupKey?: string; + isCustomCategory?: boolean; + videoConfActions?: { [action: string]: () => void; }; }; -const SidebarItemWithData = ({ room, id, style, t, videoConfActions }: RoomListRowProps) => { +const SidebarItemWithData = ({ room, id, style, t, videoConfActions, groupKey, isCustomCategory }: RoomListRowProps) => { const title = roomCoordinator.getRoomName(room.t, room) || ''; const href = roomCoordinator.getRouteLink(room.t, room) || ''; + const sidebarGroupByType = useUserPreference('sidebarGroupByType'); + const nativeKey = getNativeCategoryKey(room, { groupByType: Boolean(sidebarGroupByType), keys: NAVIGATION_NATIVE_KEYS }); + + const { isDragging, ...dragProps } = useRoomDrag({ rid: room.rid, name: title, isFavorite: room.f, fromGroup: groupKey, nativeKey }); + const { isDragOver, isFadedOut, dropProps } = useGroupDrop(groupKey, Boolean(isCustomCategory)); + + const dragStyle = { + ...style, + // Suppress the iOS Safari long-press preview/callout on the room link; long-press opens the menu instead. + WebkitTouchCallout: 'none' as const, + // Inset + rounded hover/selected highlight (Slack-style): margins keep it off the sidebar edges. + // Rooms are indented (content ~24px from the edge) so they read as nested under the category header. + marginInline: '0.5rem', + marginBlock: '1px', + paddingInlineStart: '1.5rem', + paddingInlineEnd: 'calc(0.5rem - 1px)', + borderRadius: 'var(--rcx-border-radius-medium, 0.25rem)', + ...(isDragging || isFadedOut ? { opacity: isDragging ? 0.5 : 0.4 } : {}), + // During drag-over, only tint the background — keep the inset margins and rounding so the drop area stays + // rounded and the row's height doesn't change. + ...(isDragOver ? { backgroundColor: 'var(--rcx-color-surface-hover)' } : {}), + }; + + // Long-press (iOS) / right-click opens the room menu (the kebab) instead of the native preview/context menu. + const handleContextMenu = useCallback((event: MouseEvent) => { + const trigger = event.currentTarget.querySelector('.rcx-sidebar-v2-item__menu-wrapper button'); + if (!trigger) { + return; + } + event.preventDefault(); + trigger.click(); + }, []); + const { unreadTitle, showUnread, highlightUnread: highlighted } = useUnreadDisplay(room); const icon = ( @@ -65,16 +106,21 @@ const SidebarItemWithData = ({ room, id, style, t, videoConfActions }: RoomListR } room={room} actions={actions} + menu={} + onContextMenu={handleContextMenu} + {...dragProps} + {...dropProps} {...buttonProps} /> ); @@ -87,9 +133,8 @@ function safeDateNotEqualCheck(a: Date | string | undefined, b: Date | string | return new Date(a).toISOString() !== new Date(b).toISOString(); } -const keys: (keyof RoomListRowProps)[] = ['id', 'style', 't', 'videoConfActions']; +const keys: (keyof RoomListRowProps)[] = ['id', 'style', 't', 'videoConfActions', 'groupKey', 'isCustomCategory']; -// eslint-disable-next-line react/no-multi-comp export default memo(SidebarItemWithData, (prevProps, nextProps) => { if (keys.some((key) => prevProps[key] !== nextProps[key])) { return false; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/CategoryDnDContext.tsx b/apps/meteor/client/views/navigation/sidebar/categories/CategoryDnDContext.tsx new file mode 100644 index 0000000000000..d9c3d7f6d727e --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/CategoryDnDContext.tsx @@ -0,0 +1,217 @@ +import type { DragEvent, ReactNode } from 'react'; +import { createContext, useContext, useMemo, useRef, useState } from 'react'; + +import type { MovableRoom } from '../../hooks/useCustomCategories'; +import { useCustomCategories } from '../../hooks/useCustomCategories'; + +type DraggingRoom = MovableRoom & { + /** The room's current group (custom category id or system key). */ + fromGroup?: string; + /** The system group the room returns to when removed from a custom category. */ + nativeKey?: string; +}; + +type CategoryDnDContextValue = { + draggingRoom: DraggingRoom | null; + dragOverGroup: string | null; + startDrag: (room: DraggingRoom) => void; + endDrag: () => void; + setDragOverGroup: (key: string | null) => void; + /** Debounced clear, so moving between rows of the same group doesn't flicker. */ + clearDragOverGroup: () => void; + dropOnGroup: (groupKey: string, isCustom: boolean) => void; +}; + +const CategoryDnDContext = createContext(undefined); + +export const CategoryDnDProvider = ({ children }: { children: ReactNode }) => { + const { moveRoom, removeRoom } = useCustomCategories(); + const [draggingRoom, setDraggingRoom] = useState(null); + const [dragOverGroup, setDragOverGroupState] = useState(null); + const clearTimer = useRef | undefined>(undefined); + + const value = useMemo(() => { + const cancelClear = () => { + if (clearTimer.current) { + clearTimeout(clearTimer.current); + clearTimer.current = undefined; + } + }; + + return { + draggingRoom, + dragOverGroup, + startDrag: (room) => setDraggingRoom(room), + endDrag: () => { + cancelClear(); + setDraggingRoom(null); + setDragOverGroupState(null); + }, + setDragOverGroup: (key) => { + cancelClear(); + setDragOverGroupState(key); + }, + clearDragOverGroup: () => { + cancelClear(); + clearTimer.current = setTimeout(() => setDragOverGroupState(null), 60); + }, + dropOnGroup: (groupKey, isCustom) => { + cancelClear(); + if (draggingRoom && draggingRoom.fromGroup !== groupKey) { + const room = { rid: draggingRoom.rid, name: draggingRoom.name, isFavorite: draggingRoom.isFavorite }; + if (isCustom) { + void moveRoom(room, groupKey); + } else if (draggingRoom.nativeKey === groupKey) { + // Dropping on the room's native system category removes it from its custom category. + void removeRoom(room); + } + } + setDraggingRoom(null); + setDragOverGroupState(null); + }, + }; + }, [draggingRoom, dragOverGroup, moveRoom, removeRoom]); + + return {children}; +}; + +const useCategoryDnD = () => useContext(CategoryDnDContext); + +/** The group key currently being dragged over (always an accepting group), or null. */ +export const useDragOverGroup = (): string | null => useCategoryDnD()?.dragOverGroup ?? null; + +// A 1×1 transparent GIF used to suppress the browser's native drag image. +const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +/** + * Renders a tilted "picked up card" that follows the cursor for the whole drag, and returns a cleanup. + * + * We can't tilt via `setDragImage`: Chrome rasterizes that bitmap *without* applying CSS transforms, so + * the rotation is silently dropped. Instead we hide the native drag image and animate our own live DOM + * node — appended inside the sidebar so it inherits the sidebar's theme variables (correct colors). + */ +const createDragGhost = (node: HTMLElement, startX: number, startY: number): (() => void) => { + const { width } = node.getBoundingClientRect(); + const ghost = node.cloneNode(true) as HTMLElement; + const transformAt = (x: number, y: number) => `translate3d(${x - 16}px, ${y - 12}px, 0) rotate(1.25deg)`; + + Object.assign(ghost.style, { + position: 'fixed', + insetBlockStart: '0', + insetInlineStart: '0', + width: `${width}px`, + margin: '0', + zIndex: '9999', + opacity: '0.95', + pointerEvents: 'none', + transform: transformAt(startX, startY), + borderRadius: '4px', + border: '2px solid var(--rcx-color-stroke-highlight, #095ad2)', + boxShadow: '0 8px 16px rgba(0, 0, 0, 0.3)', + backgroundColor: 'var(--rcx-sidebar-color-surface-default, var(--rcx-color-surface-light, #fff))', + } satisfies Partial); + + const container = node.closest('.rcx-sidebar') ?? document.body; + container.appendChild(ghost); + + const move = (event: globalThis.DragEvent) => { + // The browser fires a final drag event with 0,0 coordinates as the drag ends — ignore it so the + // ghost doesn't jump to the corner on its way out. + if (event.clientX === 0 && event.clientY === 0) { + return; + } + ghost.style.transform = transformAt(event.clientX, event.clientY); + }; + document.addEventListener('dragover', move); + + return () => { + document.removeEventListener('dragover', move); + ghost.remove(); + }; +}; + +/** Drag handle props for a sidebar room row. */ +export const useRoomDrag = (room: DraggingRoom) => { + const dnd = useCategoryDnD(); + const cleanupGhost = useRef<(() => void) | undefined>(undefined); + + const isDragging = dnd?.draggingRoom?.rid === room.rid; + + if (!dnd) { + return { isDragging: false }; + } + + return { + isDragging, + draggable: true, + onDragStart: (event: DragEvent) => { + // Room rows are links; the browser would otherwise attach the room URL + // (text/uri-list) to the drag, triggering Chrome's "open link / split view" drop zones. + event.dataTransfer.clearData(); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('application/x-rocketchat-room', room.rid); + + // Hide the native drag image; createDragGhost renders our own tilted, cursor-following ghost. + const transparent = new Image(); + transparent.src = TRANSPARENT_PIXEL; + event.dataTransfer.setDragImage(transparent, 0, 0); + + cleanupGhost.current = createDragGhost(event.currentTarget as HTMLElement, event.clientX, event.clientY); + + dnd.startDrag(room); + }, + onDragEnd: () => { + cleanupGhost.current?.(); + cleanupGhost.current = undefined; + dnd.endDrag(); + }, + }; +}; + +/** + * Drop-target state for a sidebar group while a room is being dragged. + * - Custom categories accept any room not already in them. + * - A system category accepts a room only when it is that room's native category (drop = return to native). + * - Non-accepting system categories are faded out to signal they don't accept the drop. + */ +export const useGroupDrop = (groupKey: string | undefined, isCustom: boolean) => { + const dnd = useCategoryDnD(); + const dragging = dnd?.draggingRoom ?? null; + + const accepts = (() => { + if (!dragging || !groupKey || dragging.fromGroup === groupKey) { + return false; + } + return isCustom || dragging.nativeKey === groupKey; + })(); + + const isDragOver = accepts && dnd?.dragOverGroup === groupKey; + const isFadedOut = Boolean(dragging) && !isCustom && Boolean(groupKey) && !accepts; + + if (!dnd || !accepts || !groupKey) { + return { isDragOver: false, isFadedOut, dropProps: {} }; + } + + return { + isDragOver, + isFadedOut, + dropProps: { + onDragEnter: (event: DragEvent) => { + event.preventDefault(); + dnd.setDragOverGroup(groupKey); + }, + onDragOver: (event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + // Always re-assert: this cancels a pending clear scheduled by leaving a sibling row of the + // same group, so moving within a section doesn't flicker. Setting the same value is a no-op render. + dnd.setDragOverGroup(groupKey); + }, + onDragLeave: () => dnd.clearDragOverGroup(), + onDrop: (event: DragEvent) => { + event.preventDefault(); + dnd.dropOnGroup(groupKey, isCustom); + }, + }, + }; +}; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/CategoryDropHighlight.tsx b/apps/meteor/client/views/navigation/sidebar/categories/CategoryDropHighlight.tsx new file mode 100644 index 0000000000000..a8f65abd34db1 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/CategoryDropHighlight.tsx @@ -0,0 +1,67 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { RefObject } from 'react'; +import { useLayoutEffect, useState } from 'react'; + +import { useDragOverGroup } from './CategoryDnDContext'; + +type HighlightRect = { top: number; height: number }; + +/** + * A single rounded highlight rendered *behind* a category while a room is dragged over it, so the whole + * category (its header + rooms, or the "drag rooms here" placeholder) reads as one continuous drop area + * instead of separate per-row pills. It spans the union of the group's elements — each tagged with + * `data-drop-group` — measured relative to the sidebar list container, and sits behind the (transparent) + * rows so it fills the inset gaps between them while the rows' own tint covers the rest. + */ +const CategoryDropHighlight = ({ containerRef }: { containerRef: RefObject }) => { + const dragOverGroup = useDragOverGroup(); + const [rect, setRect] = useState(null); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container || !dragOverGroup) { + setRect(null); + return; + } + + const elements = container.querySelectorAll(`[data-drop-group="${CSS.escape(dragOverGroup)}"]`); + if (!elements.length) { + setRect(null); + return; + } + + const base = container.getBoundingClientRect(); + let top = Infinity; + let bottom = -Infinity; + elements.forEach((element) => { + const box = element.getBoundingClientRect(); + top = Math.min(top, box.top); + bottom = Math.max(bottom, box.bottom); + }); + setRect({ top: top - base.top, height: bottom - top }); + }, [dragOverGroup, containerRef]); + + if (!rect) { + return null; + } + + return ( + + ); +}; + +export default CategoryDropHighlight; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/CategoryEmptyPlaceholder.tsx b/apps/meteor/client/views/navigation/sidebar/categories/CategoryEmptyPlaceholder.tsx new file mode 100644 index 0000000000000..30843d82eeb44 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/CategoryEmptyPlaceholder.tsx @@ -0,0 +1,47 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import { useGroupDrop } from './CategoryDnDContext'; + +// The placeholder mimics an empty room row so the drop target reads as a ghost menu item rather than a +// centered banner: it follows the active view mode's height and left-aligns the hint at the same indent as +// the room rows' leading icon/avatar. +const ITEM_HEIGHT = { extended: 44, medium: 36, condensed: 28 } as const; + +/** Placeholder + drop target shown inside an empty custom category — the whole row is the drop zone. */ +const CategoryEmptyPlaceholder = ({ categoryId }: { categoryId: string }) => { + const { t } = useTranslation(); + const { isDragOver, dropProps } = useGroupDrop(categoryId, true); + + const viewMode = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode') ?? 'extended'; + + return ( + + {t('Drag_rooms_here')} + + ); +}; + +export default CategoryEmptyPlaceholder; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/CategoryFormModal.tsx b/apps/meteor/client/views/navigation/sidebar/categories/CategoryFormModal.tsx new file mode 100644 index 0000000000000..8fa3120abdf61 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/CategoryFormModal.tsx @@ -0,0 +1,104 @@ +import { Box, Field, FieldError, FieldGroup, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEffect, useId, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import EmojiIconPicker from './EmojiIconPicker'; +import type { MovableRoom } from '../../hooks/useCustomCategories'; +import { MAX_CATEGORY_NAME_LENGTH, useCustomCategories } from '../../hooks/useCustomCategories'; + +type CategoryFormModalProps = { + /** When provided, the modal works as "Create and move" (flow D): it creates the category and moves this room into it. */ + room?: MovableRoom; + onClose: () => void; +}; + +const CategoryFormModal = ({ room, onClose }: CategoryFormModalProps) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const { createCategory, createCategoryAndMoveRoom, validateName } = useCustomCategories(); + const nameField = useId(); + const [icon, setIcon] = useState(undefined); + + const { + handleSubmit, + control, + setError, + setFocus, + formState: { errors }, + } = useForm({ defaultValues: { name: '' } }); + + useEffect(() => { + setFocus('name'); + }, [setFocus]); + + const handleConfirm = async ({ name }: { name: string }) => { + const error = validateName(name); + if (error) { + setError( + 'name', + { message: error === 'empty' ? t('Please_enter_a_category_name') : t('A_category_with_this_name_already_exists') }, + { shouldFocus: true }, + ); + return; + } + + try { + if (room) { + await createCategoryAndMoveRoom(name, room, icon); + } else { + await createCategory(name, icon); + dispatchToastMessage({ type: 'success', message: t('Category_created') }); + } + onClose(); + } catch (e) { + dispatchToastMessage({ type: 'error', message: e }); + } + }; + + const submit = handleSubmit(handleConfirm); + + return ( + void submit(e)} {...props} />} + > + + {room ? t('Move__roomName__to', { roomName: room.name }) : t('Categories_are_private_custom_groupings_of_rooms')} + + + + + {t('Name')} + + + setIcon(undefined)} /> + ( + + )} + /> + + {errors.name && {errors.name.message}} + + + + ); +}; + +export default CategoryFormModal; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/CategoryLabel.tsx b/apps/meteor/client/views/navigation/sidebar/categories/CategoryLabel.tsx new file mode 100644 index 0000000000000..500e622a59a95 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/CategoryLabel.tsx @@ -0,0 +1,94 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ReactElement } from 'react'; + +import Emoji from '../../../../components/Emoji'; + +// A fixed-size leading slot that holds both the group icon and the collapse chevron as overlaid layers. +// The slot never changes size, so swapping icon↔chevron on hover causes no layout shift (no 1px jump). +const slotClass = css` + position: relative; + display: inline-flex; + flex-shrink: 0; + inline-size: 1.25rem; + block-size: 1.25rem; + margin-inline-end: 0.25rem; + vertical-align: middle; +`; + +const iconLayerClass = css` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + + .rcx-sidebar-v2-collapse-group__bar:hover & { + display: none; + } +`; + +const chevronLayerClass = css` + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + + .rcx-sidebar-v2-collapse-group__bar:hover & { + display: flex; + } +`; + +/** + * A collapser header label: a leading icon followed by the name. The icon is the category emoji when set, + * otherwise the given fuselage icon (folder for a custom category, the type icon for a system group). The + * icon and the collapse chevron share one fixed slot — icon by default, chevron on hover — so custom and + * system groups align identically and the title doesn't shift when hovered. + */ +const CategoryLabel = ({ + emoji, + iconName, + name, + collapsed, + unread = false, +}: { + emoji?: string; + iconName: IconName; + name: string; + collapsed: boolean; + /** Match the title to the sidebar room items: regular weight + muted color normally, and the unread item's + * heavier weight + strong color when the group hides unread rooms (collapsed with "Show unreads" off). */ + unread?: boolean; +}): ReactElement => ( + <> + + + {emoji ? ( + // Emojis read visually larger than a line icon at the same box size, so render them a touch + // smaller than the x20 folder/type icon to match. + + + + ) : ( + + )} + + + + + + + {name} + + +); + +export default CategoryLabel; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/CategoryMenu.tsx b/apps/meteor/client/views/navigation/sidebar/categories/CategoryMenu.tsx new file mode 100644 index 0000000000000..4675dcd7ea0b9 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/CategoryMenu.tsx @@ -0,0 +1,120 @@ +import type { ISidebarCustomCategory } from '@rocket.chat/core-typings'; +import { Box, IconButton, Option, OptionDivider, OptionInput, OptionTitle, Position, Tile, ToggleSwitch } from '@rocket.chat/fuselage'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCategoryModals } from './useCategoryModals'; +import CreateChannelModal from '../../../../navbar/NavBarPagesGroup/actions/CreateChannelModal'; +import { useCreateRoomModal } from '../../../../navbar/NavBarPagesGroup/hooks/useCreateRoomModal'; +import { useCustomCategories } from '../../hooks/useCustomCategories'; +import { useShowUnreadsGroups } from '../../hooks/useShowUnreadsGroups'; + +type CategoryMenuProps = { + /** Present for a custom category; omitted for a system/standard group. */ + category?: ISidebarCustomCategory; + /** Collapse/show-unreads identity (category id for custom, translation key for system). */ + groupKey: string; + showUnreads: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + /** Reports open/close so the collapser can keep the kebab visible while the menu is open. */ + onOpenChange?: (open: boolean) => void; +}; + +/** + * The kebab menu on a sidebar group collapser. Custom categories get the full menu + * (New channel + Manage: Rename / Delete / New category); system/standard categories get the + * reduced menu (reorder + New category). Both share the "When closed → Show unreads" toggle. + * Sidebar-agnostic: used by both the classic and the navigation sidebars. + */ +const CategoryMenu = ({ + category, + groupKey, + showUnreads, + canMoveUp, + canMoveDown, + onMoveUp, + onMoveDown, + onOpenChange, +}: CategoryMenuProps) => { + const { t } = useTranslation(); + + const { openCreate, openRename, openDelete } = useCategoryModals(); + const { toggleShowUnreads: toggleCustomShowUnreads } = useCustomCategories(); + const { toggleShowUnreads: toggleSystemShowUnreads } = useShowUnreadsGroups(); + const createChannel = useCreateRoomModal(CreateChannelModal); + + const triggerRef = useRef(null); + const [open, setOpenState] = useState(false); + const setOpen = useCallback( + (value: boolean) => { + setOpenState(value); + onOpenChange?.(value); + }, + [onOpenChange], + ); + const close = useCallback(() => setOpen(false), [setOpen]); + + useEffect(() => { + if (!open) { + return undefined; + } + const handlePointerDown = (event: globalThis.MouseEvent) => { + const node = event.target as Node | null; + const element = node instanceof Element ? node : (node?.parentElement ?? null); + if (triggerRef.current?.contains(node) || element?.closest('[role="menu"]')) { + return; + } + close(); + }; + document.addEventListener('mousedown', handlePointerDown, true); + return () => document.removeEventListener('mousedown', handlePointerDown, true); + }, [open, close]); + + const handleToggleShowUnreads = () => (category ? toggleCustomShowUnreads(category._id) : toggleSystemShowUnreads(groupKey)); + + // Run a menu action and close the menu (closing on reorder avoids the popover pointing at a + // different category once this one changes position). + const run = (action: () => void) => () => { + action(); + close(); + }; + + return ( + e.stopPropagation()}> + setOpen(!open)} /> + {open && ( + + e.key === 'Escape' && close()}> + + + )} + + ); +}; + +export default CategoryMenu; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/DeleteCategoryModal.tsx b/apps/meteor/client/views/navigation/sidebar/categories/DeleteCategoryModal.tsx new file mode 100644 index 0000000000000..1200e0b809be2 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/DeleteCategoryModal.tsx @@ -0,0 +1,37 @@ +import type { ISidebarCustomCategory } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import { useCustomCategories } from '../../hooks/useCustomCategories'; + +type DeleteCategoryModalProps = { + category: ISidebarCustomCategory; + onClose: () => void; +}; + +const DeleteCategoryModal = ({ category, onClose }: DeleteCategoryModalProps) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const { deleteCategory } = useCustomCategories(); + + const handleConfirm = async () => { + try { + await deleteCategory(category._id); + dispatchToastMessage({ type: 'success', message: t('Category__name__deleted', { name: category.name }) }); + onClose(); + } catch (e) { + dispatchToastMessage({ type: 'error', message: e }); + } + }; + + return ( + + {t('Anything_you_added_to__name__will_move_back_to_default', { name: category.name })} + {t('No_content_access_or_security_setting_will_be_lost_or_changed')} + + ); +}; + +export default DeleteCategoryModal; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/EmojiIconPicker.tsx b/apps/meteor/client/views/navigation/sidebar/categories/EmojiIconPicker.tsx new file mode 100644 index 0000000000000..1d8cafdc43e1f --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/EmojiIconPicker.tsx @@ -0,0 +1,81 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Icon, IconButton } from '@rocket.chat/fuselage'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Emoji from '../../../../components/Emoji'; +import { useEmojiPicker } from '../../../../contexts/EmojiPickerContext'; + +const buttonClass = css` + display: flex; + align-items: center; + justify-content: center; + inline-size: 2.5rem; + block-size: 2.5rem; + padding: 0; + color: var(--rcx-color-font-hint, #9ea2a8); + border: 1px solid var(--rcx-color-stroke-light, #cbced1); + border-radius: var(--rcx-border-radius-medium, 0.25rem); + background-color: var(--rcx-color-surface-light, #fff); + cursor: pointer; + + &:hover { + background-color: var(--rcx-color-surface-hover, #f2f3f5); + } +`; + +// Positions the clear badge over the top-end corner of the emoji button; `IconButton secondary` +// supplies the theme-aware (dark in dark theme) surface and icon colors. +const clearClass = css` + position: absolute; + inset-block-start: -0.375rem; + inset-inline-end: -0.375rem; + z-index: 1; + border-radius: 50%; +`; + +type EmojiIconPickerProps = { + /** The currently selected emoji name (without colons), or undefined for the default folder icon. */ + icon?: string; + onSelect: (emoji: string) => void; + /** Clears the selected emoji, reverting to the default folder icon. */ + onClear: () => void; +}; + +/** + * Slack-style emoji trigger placed before the category name input. Shows the selected emoji (or the + * default folder icon) and opens the shared emoji picker. When an emoji is set, a small clear badge + * reverts to the folder icon. + */ +const EmojiIconPicker = ({ icon, onSelect, onClear }: EmojiIconPickerProps) => { + const { t } = useTranslation(); + const buttonRef = useRef(null); + const { open } = useEmojiPicker(); + + const handleOpen = () => { + if (buttonRef.current) { + open(buttonRef.current, onSelect); + } + }; + + return ( + + + {icon ? : } + + {icon && ( + + )} + + ); +}; + +export default EmojiIconPicker; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/MoveToList.tsx b/apps/meteor/client/views/navigation/sidebar/categories/MoveToList.tsx new file mode 100644 index 0000000000000..72018e187de9e --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/MoveToList.tsx @@ -0,0 +1,68 @@ +import { Box, Option, OptionColumn, OptionDivider, OptionTitle } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import type { MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { MoveToItem } from './useRoomCategoryItems'; +import { useRoomCategoryItems } from './useRoomCategoryItems'; +import Emoji from '../../../../components/Emoji'; +import type { MovableRoom } from '../../hooks/useCustomCategories'; + +type MoveToListProps = { + room: MovableRoom; + /** Called after a target is chosen (to close the surrounding popover/submenu). */ + onSelect: () => void; +}; + +/** + * The shared "Move to" target list: a title, Favorites + custom categories (the current one shown in + * bold with a check), a divider, then "New category". Rendered identically in the sidebar room kebab + * submenu and the room-header grouping dropdown. + */ +const MoveToList = ({ room, onSelect }: MoveToListProps) => { + const { t } = useTranslation(); + + const buildCategoryItems = useRoomCategoryItems(); + const { moveToItems, removeItem } = buildCategoryItems(room); + const newCategoryItem = moveToItems.find((item) => item.id === 'newCategory'); + const targetItems = moveToItems.filter((item) => item.id !== 'newCategory'); + + const handleSelect = (item: GenericMenuItemProps) => (event: MouseEvent) => { + if (item.disabled) { + return; + } + item.onClick?.(event); + onSelect(); + }; + + const renderRow = (item: MoveToItem, selected = false) => ( + + ) : undefined + } + label={selected ? {item.content} : item.content} + onClick={handleSelect(item)} + > + {item.status ? {item.status} : null} + + ); + + return ( + <> + {t('Move_to')} + {targetItems.map((item) => renderRow(item, Boolean(item.status)))} + {(newCategoryItem || removeItem) && } + {newCategoryItem && renderRow(newCategoryItem)} + {removeItem && renderRow(removeItem)} + + ); +}; + +export default MoveToList; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/RenameCategoryModal.tsx b/apps/meteor/client/views/navigation/sidebar/categories/RenameCategoryModal.tsx new file mode 100644 index 0000000000000..b5bf1ca38c50d --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/RenameCategoryModal.tsx @@ -0,0 +1,102 @@ +import type { ISidebarCustomCategory } from '@rocket.chat/core-typings'; +import { Field, FieldError, FieldGroup, FieldLabel, FieldRow, TextInput, Box } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEffect, useId, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import EmojiIconPicker from './EmojiIconPicker'; +import { MAX_CATEGORY_NAME_LENGTH, useCustomCategories } from '../../hooks/useCustomCategories'; + +type RenameCategoryModalProps = { + category: ISidebarCustomCategory; + onClose: () => void; +}; + +const RenameCategoryModal = ({ category, onClose }: RenameCategoryModalProps) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const { renameCategory, validateName } = useCustomCategories(); + const nameField = useId(); + const [icon, setIcon] = useState(category.icon); + + const { + handleSubmit, + control, + setError, + setFocus, + formState: { errors }, + } = useForm({ defaultValues: { name: category.name } }); + + useEffect(() => { + setFocus('name'); + }, [setFocus]); + + const handleConfirm = async ({ name }: { name: string }) => { + const trimmed = name.trim(); + // An unchanged name AND emoji is a no-op that just closes the modal. + if (trimmed === category.name.trim() && icon === category.icon) { + onClose(); + return; + } + + const error = validateName(name, category._id); + if (error) { + setError( + 'name', + { message: error === 'empty' ? t('Please_enter_a_category_name') : t('A_category_with_this_name_already_exists') }, + { shouldFocus: true }, + ); + return; + } + + try { + await renameCategory(category._id, name, icon); + dispatchToastMessage({ type: 'success', message: t('Category_renamed_to__name__', { name: trimmed }) }); + onClose(); + } catch (e) { + dispatchToastMessage({ type: 'error', message: e }); + } + }; + + const submit = handleSubmit(handleConfirm); + + return ( + void submit(e)} {...props} />} + > + + + + {t('Name')} + + + setIcon(undefined)} /> + ( + + )} + /> + + {errors.name && {errors.name.message}} + + + + ); +}; + +export default RenameCategoryModal; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/RoomMenuWithCategories.tsx b/apps/meteor/client/views/navigation/sidebar/categories/RoomMenuWithCategories.tsx new file mode 100644 index 0000000000000..99e285b79c703 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/RoomMenuWithCategories.tsx @@ -0,0 +1,137 @@ +import { Box, Icon, IconButton, Option, OptionColumn, OptionDivider, OptionTitle, Position, Tile } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import type { MouseEvent } from 'react'; +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import MoveToList from './MoveToList'; +import type { MovableRoom } from '../../hooks/useCustomCategories'; + +type MenuSection = { title?: string; items: GenericMenuItemProps[] }; + +type RoomMenuWithCategoriesProps = { + /** Standard room actions (Hide, Mark read, Favorite, Leave, …). */ + sections: MenuSection[]; + room: MovableRoom; +}; + +/** + * Sidebar room kebab menu with a real cascading "Move to ▸" submenu. + * + * Fuselage's `GenericMenu` (react-aria) can't host a nested flyout, so this renders a custom + * popover (`Position` + `Tile` + `Option`). The submenu is an absolutely-positioned child of the + * same popover (a single portal) — nesting a second `Position` portal crashes on teardown. + */ +const RoomMenuWithCategories = ({ sections, room }: RoomMenuWithCategoriesProps) => { + const { t } = useTranslation(); + + const triggerRef = useRef(null); + const popoverRef = useRef(null); + + const [open, setOpen] = useState(false); + const [submenuOpen, setSubmenuOpen] = useState(false); + + const close = useCallback(() => { + setOpen(false); + setSubmenuOpen(false); + }, []); + + useEffect(() => { + if (!open) { + return undefined; + } + const handlePointerDown = (event: globalThis.MouseEvent) => { + const node = event.target as Node | null; + const element = node instanceof Element ? node : (node?.parentElement ?? null); + // Keep open when the click lands on the trigger or anywhere inside the popover/submenu (both are role="menu"). + if (triggerRef.current?.contains(node) || element?.closest('[role="menu"]')) { + return; + } + close(); + }; + document.addEventListener('mousedown', handlePointerDown, true); + return () => document.removeEventListener('mousedown', handlePointerDown, true); + }, [open, close]); + + const handleSelect = (item: GenericMenuItemProps) => (event: MouseEvent) => { + if (item.disabled) { + return; + } + item.onClick?.(event); + close(); + }; + + const renderRow = (item: GenericMenuItemProps) => ( + + ); + + // "Move to" replaces the standard Favorite action's slot (favoriting lives in the submenu now). + const moveTo = ( + setSubmenuOpen(true)}> + + {submenuOpen && ( + + + + )} + + ); + + const hasFavoriteAction = sections.some((section) => section.items.some((item) => item.id === 'toggleFavorite')); + + const renderItem = (item: GenericMenuItemProps) => { + if (item.id === 'toggleFavorite') { + return moveTo; + } + return ( + setSubmenuOpen(false)}> + {renderRow(item)} + + ); + }; + + return ( + <> + setOpen((value) => !value)} /> + {open && ( + + e.key === 'Escape' && close()} + > + {sections.map((section, index) => ( + + {index > 0 && } + {section.title ? {section.title} : null} + {section.items.map(renderItem)} + + ))} + {!hasFavoriteAction && moveTo} + + + )} + + ); +}; + +export default RoomMenuWithCategories; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/nativeCategory.ts b/apps/meteor/client/views/navigation/sidebar/categories/nativeCategory.ts new file mode 100644 index 0000000000000..54a10c63e8ce9 --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/nativeCategory.ts @@ -0,0 +1,52 @@ +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; + +type NativeKeyMap = { + teams: string; + channels: string; + direct: string; + conversations: string; + discussions?: string; +}; + +/** System group keys for the classic sidebar (`useRoomList`). */ +export const CLASSIC_NATIVE_KEYS: NativeKeyMap = { + teams: 'Teams', + discussions: 'Discussions', + channels: 'Channels', + direct: 'Direct_Messages', + conversations: 'Conversations', +}; + +/** System group keys for the navigation sidebar (`collapsibleFilters`). */ +export const NAVIGATION_NATIVE_KEYS: NativeKeyMap = { + teams: 'teams', + channels: 'channels', + direct: 'directMessages', + conversations: 'conversations', +}; + +/** + * The system group a room belongs to when it is NOT in a custom category — its "native" category. + * Mirrors the sidebar grouping rules so a room can be dragged back to where it would otherwise live. + */ +export const getNativeCategoryKey = ( + room: SubscriptionWithRoom, + { groupByType, discussionEnabled, keys }: { groupByType: boolean; discussionEnabled?: boolean; keys: NativeKeyMap }, +): string => { + if (!groupByType) { + return keys.conversations; + } + if (room.teamMain) { + return keys.teams; + } + if (discussionEnabled && room.prid && keys.discussions) { + return keys.discussions; + } + if (room.t === 'c' || room.t === 'p') { + return keys.channels; + } + if (room.t === 'd') { + return keys.direct; + } + return keys.conversations; +}; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/useCategoryModals.tsx b/apps/meteor/client/views/navigation/sidebar/categories/useCategoryModals.tsx new file mode 100644 index 0000000000000..399e0470f597b --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/useCategoryModals.tsx @@ -0,0 +1,24 @@ +import type { ISidebarCustomCategory } from '@rocket.chat/core-typings'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import CategoryFormModal from './CategoryFormModal'; +import DeleteCategoryModal from './DeleteCategoryModal'; +import RenameCategoryModal from './RenameCategoryModal'; +import type { MovableRoom } from '../../hooks/useCustomCategories'; + +/** Centralizes opening the category modals so menus, the create (+) menu and the room header share one entry point. */ +export const useCategoryModals = () => { + const setModal = useSetModal(); + + return useMemo(() => { + const onClose = () => setModal(null); + + return { + /** Flow B (no room) / flow D (with room => create and move). */ + openCreate: (room?: MovableRoom) => setModal(), + openRename: (category: ISidebarCustomCategory) => setModal(), + openDelete: (category: ISidebarCustomCategory) => setModal(), + }; + }, [setModal]); +}; diff --git a/apps/meteor/client/views/navigation/sidebar/categories/useRoomCategoryItems.tsx b/apps/meteor/client/views/navigation/sidebar/categories/useRoomCategoryItems.tsx new file mode 100644 index 0000000000000..bccbd23f47b2b --- /dev/null +++ b/apps/meteor/client/views/navigation/sidebar/categories/useRoomCategoryItems.tsx @@ -0,0 +1,64 @@ +import { Icon } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCategoryModals } from './useCategoryModals'; +import type { MovableRoom } from '../../hooks/useCustomCategories'; +import { FAVORITES_TARGET, useCustomCategories } from '../../hooks/useCustomCategories'; + +/** A "Move to" target. `emoji` (a custom category's emoji name) renders in place of the folder icon. */ +export type MoveToItem = GenericMenuItemProps & { emoji?: string }; + +/** + * Builds the "Move to" targets (Favorites, each custom category, New category) and a "Create" item for a room. + * Shared by the sidebar item context menu and the room-header grouping dropdown. + */ +export const useRoomCategoryItems = () => { + const { t } = useTranslation(); + const { categories, moveRoom, removeRoom, getRoomCategory } = useCustomCategories(); + const { openCreate } = useCategoryModals(); + + return useCallback( + (room: MovableRoom): { moveToItems: MoveToItem[]; createItem: GenericMenuItemProps; removeItem?: GenericMenuItemProps } => { + const current = getRoomCategory(room.rid); + const selected = ; + + const moveToItems: MoveToItem[] = [ + { + id: 'favorites', + icon: 'star', + content: t('Favorites'), + onClick: () => void moveRoom(room, FAVORITES_TARGET), + status: room.isFavorite ? selected : undefined, + }, + ...categories.map( + (category): MoveToItem => ({ + id: category._id, + icon: 'folder', + emoji: category.icon, + content: category.name, + onClick: () => void moveRoom(room, category._id), + status: current?._id === category._id ? selected : undefined, + }), + ), + { id: 'newCategory', icon: 'plus', content: t('New_category'), onClick: () => openCreate(room) }, + ]; + + const createItem: GenericMenuItemProps = { id: 'createCategory', icon: 'plus', content: t('Create'), onClick: () => openCreate() }; + + // Shown only when the room currently belongs to a custom category or Favorites — removes it back to its system group. + const currentName = current?.name ?? (room.isFavorite ? t('Favorites') : undefined); + const removeItem: GenericMenuItemProps | undefined = currentName + ? { + id: 'removeFromCategory', + content: t('Remove_from__categoryName__', { categoryName: currentName }), + onClick: () => void removeRoom(room), + } + : undefined; + + return { moveToItems, createItem, removeItem }; + }, + [t, categories, moveRoom, removeRoom, getRoomCategory, openCreate], + ); +}; diff --git a/apps/meteor/client/views/navigation/sidepanel/hooks/useChannelsChildrenList.spec.ts b/apps/meteor/client/views/navigation/sidepanel/hooks/useChannelsChildrenList.spec.ts index d083c2fe31450..dcc54acdb8f9e 100644 --- a/apps/meteor/client/views/navigation/sidepanel/hooks/useChannelsChildrenList.spec.ts +++ b/apps/meteor/client/views/navigation/sidepanel/hooks/useChannelsChildrenList.spec.ts @@ -57,6 +57,12 @@ jest.mock('../../../../stores/Subscriptions', () => { }; }); +// `RoomsNavigationContext` reads the open room via RoomManager; mock it so importing the context here +// doesn't pull the real RoomManager module graph (which eagerly loads the stores being mocked above). +jest.mock('../../../../lib/RoomManager', () => ({ + useOpenedRoom: () => undefined, +})); + it('should return all subscriptions parent of fakeChannel', async () => { const { useChannelsChildrenList } = await import('./useChannelsChildrenList'); diff --git a/apps/meteor/client/views/room/Header/RoomHeader.tsx b/apps/meteor/client/views/room/Header/RoomHeader.tsx index 3d0cb83ebbcc6..50a8517d85972 100644 --- a/apps/meteor/client/views/room/Header/RoomHeader.tsx +++ b/apps/meteor/client/views/room/Header/RoomHeader.tsx @@ -11,7 +11,7 @@ import RoomTitle from './RoomTitle'; import RoomToolbox from './RoomToolbox'; import RoomTopic from './RoomTopic'; import Encrypted from './icons/Encrypted'; -import Favorite from './icons/Favorite'; +import RoomGroupingMenu from './icons/RoomGroupingMenu'; import Translate from './icons/Translate'; export type RoomHeaderProps = { @@ -41,8 +41,8 @@ const RoomHeader = ({ room, slots = {} }: RoomHeaderProps) => { {slots?.preContent} + - {isRoomFederated(room) && } diff --git a/apps/meteor/client/views/room/Header/__snapshots__/RoomInviteHeader.spec.tsx.snap b/apps/meteor/client/views/room/Header/__snapshots__/RoomInviteHeader.spec.tsx.snap index f3237dff62d2a..a77963504d472 100644 --- a/apps/meteor/client/views/room/Header/__snapshots__/RoomInviteHeader.spec.tsx.snap +++ b/apps/meteor/client/views/room/Header/__snapshots__/RoomInviteHeader.spec.tsx.snap @@ -18,6 +18,18 @@ exports[`RoomInviteHeader renders Default without crashing 1`] = `
+
-
diff --git a/apps/meteor/client/views/room/Header/icons/Favorite.tsx b/apps/meteor/client/views/room/Header/icons/Favorite.tsx deleted file mode 100644 index 3a3b5e3c01d58..0000000000000 --- a/apps/meteor/client/views/room/Header/icons/Favorite.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; -import { useStableCallback } from '@rocket.chat/fuselage-hooks'; -import { HeaderState } from '@rocket.chat/ui-client'; -import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; -import { memo } from 'react'; - -import { useUserIsSubscribed } from '../../contexts/RoomContext'; -import { useToggleFavoriteMutation } from '../../hooks/useToggleFavoriteMutation'; - -const Favorite = ({ room: { _id, f: favorite = false, t: type, name } }: { room: IRoom & { f?: ISubscription['f'] } }) => { - const t = useTranslation(); - const subscribed = useUserIsSubscribed(); - - const isFavoritesEnabled = useSetting('Favorite_Rooms', true) && ['c', 'p', 'd', 't'].includes(type); - const { mutate: toggleFavorite } = useToggleFavoriteMutation(); - - const handleFavoriteClick = useStableCallback(() => { - if (!isFavoritesEnabled) { - return; - } - - toggleFavorite({ roomId: _id, favorite: !favorite, roomName: name || '' }); - }); - - const favoriteLabel = favorite ? `${t('Unfavorite')} ${name}` : `${t('Favorite')} ${name}`; - - if (!subscribed || !isFavoritesEnabled) { - return null; - } - - return ( - - ); -}; - -export default memo(Favorite); diff --git a/apps/meteor/client/views/room/Header/icons/RoomGroupingMenu.tsx b/apps/meteor/client/views/room/Header/icons/RoomGroupingMenu.tsx new file mode 100644 index 0000000000000..d94935dbaddb2 --- /dev/null +++ b/apps/meteor/client/views/room/Header/icons/RoomGroupingMenu.tsx @@ -0,0 +1,92 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { Box, Position, Tile } from '@rocket.chat/fuselage'; +import { HeaderToolbarAction } from '@rocket.chat/ui-client'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Emoji from '../../../../components/Emoji'; +import { useCustomCategories } from '../../../navigation/hooks/useCustomCategories'; +import MoveToList from '../../../navigation/sidebar/categories/MoveToList'; +import { useUserIsSubscribed } from '../../contexts/RoomContext'; + +/** + * Room-header grouping control. It uses the same `HeaderToolbarAction` button (and size) as the + * right-side room actions. The leading content reflects the room's current grouping: the category's + * emoji when set, otherwise filled star = favorited, folder = in a custom category, outline star = + * neither. Opens the same "Move to" list used by the sidebar room kebab submenu. + */ +const RoomGroupingMenu = ({ room }: { room: IRoom & { f?: ISubscription['f'] } }) => { + const { t } = useTranslation(); + const subscribed = useUserIsSubscribed(); + const isFavoritesEnabled = useSetting('Favorite_Rooms', true) && ['c', 'p', 'd', 't'].includes(room.t); + + const { getRoomCategory } = useCustomCategories(); + + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + + useEffect(() => { + if (!open) { + return undefined; + } + const handlePointerDown = (event: globalThis.MouseEvent) => { + const node = event.target as Node | null; + const element = node instanceof Element ? node : (node?.parentElement ?? null); + if (triggerRef.current?.contains(node) || element?.closest('[role="menu"]')) { + return; + } + close(); + }; + document.addEventListener('mousedown', handlePointerDown, true); + return () => document.removeEventListener('mousedown', handlePointerDown, true); + }, [open, close]); + + if (!subscribed || !isFavoritesEnabled) { + return null; + } + + const favorite = Boolean(room.f); + const category = getRoomCategory(room._id); + // A non-favorited room in a custom category with an emoji shows that emoji; otherwise an icon. + const emoji = !favorite && category?.icon ? category.icon : undefined; + const getGroupingIcon = () => { + if (favorite) { + return 'star-filled' as const; + } + return category ? ('folder' as const) : ('star' as const); + }; + + // `IconButton` `small` renders icons at x20, so the emoji is sized to match. + const groupingIcon = emoji ? ( + + + + ) : ( + getGroupingIcon() + ); + + return ( + <> + setOpen((value) => !value)} + /> + {open && ( + + + + + + )} + + ); +}; + +export default memo(RoomGroupingMenu); diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index af95dd26d765c..a35bdc0345833 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -1,4 +1,4 @@ -import type { ISubscription, ThemePreference } from '@rocket.chat/core-typings'; +import type { ISidebarCustomCategory, ISubscription, ThemePreference } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Subscriptions, Users } from '@rocket.chat/models'; import type { FontSize } from '@rocket.chat/rest-typings'; @@ -45,6 +45,7 @@ type UserPreferences = { sidebarViewMode: string; sidebarDisplayAvatar: boolean; sidebarGroupByType: boolean; + sidebarCustomCategories: ISidebarCustomCategory[]; muteFocusedConversations: boolean; dontAskAgainList: { action: string; label: string }[]; themeAppearence: ThemePreference; @@ -120,6 +121,7 @@ export const saveUserPreferences = async (settings: Partial, us sidebarViewMode: Match.Optional(String), sidebarDisplayAvatar: Match.Optional(Boolean), sidebarGroupByType: Match.Optional(Boolean), + sidebarCustomCategories: Match.Optional([Match.ObjectIncluding({ _id: String, name: String })]), muteFocusedConversations: Match.Optional(Boolean), themeAppearence: Match.Optional(String), fontSize: Match.Optional(String), diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index a17e56af0e652..7e7c9e2324fa4 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -30,6 +30,28 @@ export class HomeContent { return this.page.locator('main header'); } + // --- Room-header grouping control (custom categories) --- + + /** The far-left grouping control in the room header (opens the "Move to" list). */ + get headerGroupingButton(): Locator { + return this.channelHeader.getByRole('button', { name: 'Move to' }); + } + + /** The grouping icon reflects the room's state: `star` (none), `folder` (in a category), `star-filled` (favorited). */ + headerGroupingIcon(name: 'star' | 'star-filled' | 'folder'): Locator { + return this.headerGroupingButton.locator(`.rcx-icon--name-${name}`); + } + + async openHeaderGroupingMenu(): Promise { + await this.headerGroupingButton.click(); + } + + /** Picks a target (a category name, "Favorites", "New category", or "Remove from …") from the header grouping list. */ + async pickHeaderGroupingTarget(name: string | RegExp): Promise { + await this.openHeaderGroupingMenu(); + await this.page.getByRole('menuitem', { name }).click(); + } + get burgerButton(): Locator { return this.channelHeader.getByRole('button', { name: 'Open sidebar' }); } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts index 5a6a653645298..71fc742f3b402 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts @@ -132,6 +132,12 @@ export class Navbar { await this.createNewMenuItem(name).click(); } + /** Opens the "Create category" modal from the create-new (+) menu. */ + async openCreateCategory(): Promise { + await this.btnCreateNew.click(); + await this.createNewMenu.getByRole('menuitem', { name: 'Category', exact: true }).click(); + } + async logout(): Promise { await this.btnUserMenu.click(); return this.btnLogout.click(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts b/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts index 45aac6b0bdc1c..d85a345a93b9c 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts @@ -116,6 +116,89 @@ export class RoomSidebar extends Sidebar { getSidebarListItemByName(name: string): Locator { return this.channelsList.getByRole('listitem').filter({ has: this.getSidebarItemByName(name) }); } + + // --- Custom categories --- + + /** The collapser (region) of an expanded custom category or system group. */ + getCategoryCollapser(name: string): Locator { + return this.channelsList.getByRole('region', { name: `Collapse ${name}` }); + } + + /** The collapser (region) of a collapsed custom category or system group. */ + getCollapsedCategoryCollapser(name: string): Locator { + return this.channelsList.getByRole('region', { name: `Expand ${name}` }); + } + + /** The "drag rooms here" placeholder shown inside an empty custom category. */ + get dragRoomsPlaceholder(): Locator { + return this.channelsList.getByText('Drag rooms here'); + } + + /** + * The category kebab is overlaid as a sibling of the collapser region, inside the same (unnamed) wrapper, + * so it is reached via the region's parent. It only renders while the collapser is hovered. + */ + getCategoryKebab(name: string): Locator { + return this.getCategoryCollapser(name).locator('xpath=..').getByRole('button', { name: 'Options', exact: true }); + } + + async openCategoryMenu(name: string): Promise { + await this.getCategoryCollapser(name).hover(); + await this.getCategoryKebab(name).click(); + } + + async openRoomMenu(name: string): Promise { + const item = this.getSidebarItemByName(name); + await item.hover(); + await item.focus(); + await item.getByRole('button', { name: 'Options', exact: true }).click(); + } + + /** Move a room into a custom category (or to "Favorites") through the kebab "Move to ▸" submenu. */ + async moveRoomToCategory(roomName: string, categoryName: string): Promise { + await this.openRoomMenu(roomName); + await this.page.getByRole('menuitem', { name: 'Move to' }).hover(); + await this.page.getByRole('menuitem', { name: categoryName, exact: true }).click(); + } + + /** Remove a room from its current grouping (back to its system group) through the kebab submenu. */ + async removeRoomFromCategory(roomName: string, categoryName: string): Promise { + await this.openRoomMenu(roomName); + await this.page.getByRole('menuitem', { name: 'Move to' }).hover(); + await this.page.getByRole('menuitem', { name: `Remove from ${categoryName}` }).click(); + } + + /** Create a new category seeded with the given room via the kebab submenu "New category". */ + async createCategoryFromRoom(roomName: string): Promise { + await this.openRoomMenu(roomName); + await this.page.getByRole('menuitem', { name: 'Move to' }).hover(); + await this.page.getByRole('menuitem', { name: 'New category', exact: true }).click(); + } + + /** Favorite a room through the kebab "Move to ▸ Favorites" item. */ + async moveRoomToFavorites(roomName: string): Promise { + await this.openRoomMenu(roomName); + await this.page.getByRole('menuitem', { name: 'Move to' }).hover(); + await this.page.getByRole('menuitem', { name: 'Favorites', exact: true }).click(); + } + + /** Unfavorite a room through the kebab "Move to ▸ Remove from Favorites" item. */ + async removeRoomFromFavorites(roomName: string): Promise { + await this.openRoomMenu(roomName); + await this.page.getByRole('menuitem', { name: 'Move to' }).hover(); + await this.page.getByRole('menuitem', { name: 'Remove from Favorites' }).click(); + } + + /** The "Move to ▸" submenu item for a grouping target (a category, "Favorites", or "Remove from …"). */ + roomMenuMoveToItem(name: string | RegExp): Locator { + return this.page.getByRole('menuitem', { name }); + } + + /** Opens the room kebab and hovers "Move to ▸" so the submenu targets are queryable. */ + async openRoomMoveToSubmenu(roomName: string): Promise { + await this.openRoomMenu(roomName); + await this.page.getByRole('menuitem', { name: 'Move to' }).hover(); + } } export class AdminSidebar extends Sidebar { diff --git a/apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts b/apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts new file mode 100644 index 0000000000000..58e1dd3861951 --- /dev/null +++ b/apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts @@ -0,0 +1,450 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { deleteChannel, setUserPreferences } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +/** + * Custom sidebar categories — per-user groupings of sidebar rooms, persisted in the + * `sidebarCustomCategories` user preference. Exercised against the default (classic) sidebar. + * + * Covers the create (+) menu entry, category-collapser actions (custom + reduced system-group menu), + * sidebar room item actions (the "Move to ▸" kebab submenu), and the room-header grouping control. + * + * Each test starts from a clean slate (no categories, target room not favorited), reset via the API in + * `beforeEach`, so the tests are order-independent. + */ +test.describe.serial('sidebar custom categories', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let targetChannelId: string; + let originalGroupByType: boolean | undefined; + + const uniqueName = (prefix: string) => `${prefix}-${faker.string.uuid().slice(0, 8)}`; + + /** Creates a category through the create (+) menu modal and waits for its collapser to appear. */ + const createCategory = async (name: string) => { + await poHomeChannel.navbar.openCreateCategory(); + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Create category' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('textbox', { name: 'Name' }).fill(name); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await expect(poHomeChannel.sidebar.getCategoryCollapser(name)).toBeVisible(); + }; + + /** Creates a category with a chosen emoji through the create (+) modal + emoji picker. */ + const createCategoryWithEmoji = async (name: string, emojiName = 'rocket') => { + await poHomeChannel.navbar.openCreateCategory(); + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Create category' }); + await dialog.getByRole('button', { name: 'Add emoji' }).click(); + const picker = poHomeChannel.page.getByRole('dialog', { name: 'Emoji picker' }); + await expect(picker).toBeVisible(); + await picker.getByRole('textbox').first().fill(emojiName); + await poHomeChannel.page.locator(`[data-emoji="${emojiName}"]`).first().click(); + await dialog.getByRole('textbox', { name: 'Name' }).fill(name); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await expect(poHomeChannel.sidebar.getCategoryCollapser(name)).toBeVisible(); + }; + + /** True when the target room currently belongs to the named grouping (its submenu offers "Remove from …"). */ + const isRoomInGrouping = async (groupingName: string) => { + await poHomeChannel.sidebar.openRoomMoveToSubmenu(targetChannel); + const present = (await poHomeChannel.sidebar.roomMenuMoveToItem(`Remove from ${groupingName}`).count()) > 0; + await poHomeChannel.page.keyboard.press('Escape'); + return present; + }; + + test.beforeAll(async ({ api }) => { + const name = faker.string.uuid(); + const created = await (await api.post('/channels.create', { name })).json(); + targetChannel = name; + targetChannelId = created.channel._id; + + const prefs = await (await api.get('/users.getPreferences')).json(); + originalGroupByType = prefs.preferences?.sidebarGroupByType; + // Group-by-type guarantees a "Channels" system group exists for the reduced-menu tests. + await setUserPreferences(api, { sidebarGroupByType: true, sidebarCustomCategories: [] }); + }); + + test.afterAll(async ({ api }) => { + await setUserPreferences(api, { sidebarCustomCategories: [], sidebarGroupByType: originalGroupByType ?? false }); + await api.post('/rooms.favorite', { roomId: targetChannelId, favorite: false }); + await deleteChannel(api, targetChannel); + }); + + test.beforeEach(async ({ api, page }) => { + await setUserPreferences(api, { sidebarCustomCategories: [] }); + await api.post('/rooms.favorite', { roomId: targetChannelId, favorite: false }); + poHomeChannel = new HomeChannel(page); + await page.goto('/home'); + }); + + test.describe('create (+) menu', () => { + test('should expose a "Category" entry', async () => { + await poHomeChannel.navbar.btnCreateNew.click(); + await expect(poHomeChannel.navbar.createNewMenu.getByRole('menuitem', { name: 'Category', exact: true })).toBeVisible(); + }); + + test('should create a category with an empty "Drag rooms here" placeholder', async () => { + const name = uniqueName('cat'); + await createCategory(name); + await expect(poHomeChannel.sidebar.dragRoomsPlaceholder.first()).toBeVisible(); + }); + + test('should reject an empty name', async () => { + await poHomeChannel.navbar.openCreateCategory(); + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Create category' }); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(dialog.getByText('Please enter a category name')).toBeVisible(); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + }); + + test('should reject a duplicate name (case-insensitive)', async () => { + const name = uniqueName('dup'); + await createCategory(name); + + await poHomeChannel.navbar.openCreateCategory(); + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Create category' }); + await dialog.getByRole('textbox', { name: 'Name' }).fill(name.toUpperCase()); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(dialog.getByText('A category with this name already exists')).toBeVisible(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + }); + }); + + test.describe('category emoji icon', () => { + test('should show the folder icon for a category without an emoji', async () => { + const name = uniqueName('folder'); + await createCategory(name); + await expect(poHomeChannel.sidebar.getCategoryCollapser(name).locator('.rcx-icon--name-folder')).toBeVisible(); + }); + + test('should show a leading icon on system groups too (aligned with custom categories)', async () => { + // System groups carry a type icon (Channels → hashtag) so default and custom groups align. + await expect(poHomeChannel.sidebar.getCategoryCollapser('Channels').locator('.rcx-icon--name-hashtag')).toBeVisible(); + }); + + test('should let you pick an emoji shown before the category name', async ({ page }) => { + const name = uniqueName('emoji'); + await poHomeChannel.navbar.openCreateCategory(); + const dialog = page.getByRole('dialog', { name: 'Create category' }); + + await dialog.getByRole('button', { name: 'Add emoji' }).click(); + const picker = page.getByRole('dialog', { name: 'Emoji picker' }); + await expect(picker).toBeVisible(); + await picker.getByRole('textbox').first().fill('rocket'); + await page.locator('[data-emoji="rocket"]').first().click(); + + await dialog.getByRole('textbox', { name: 'Name' }).fill(name); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + + // The emoji renders before the name in the collapser, replacing the default folder icon. + const collapser = poHomeChannel.sidebar.getCategoryCollapser(name); + await expect(collapser.locator('[class*="emojione"]')).toBeVisible(); + await expect(collapser.locator('.rcx-icon--name-folder')).toHaveCount(0); + }); + + test('should let you clear a selected emoji back to the folder icon', async ({ page }) => { + await poHomeChannel.navbar.openCreateCategory(); + const dialog = page.getByRole('dialog', { name: 'Create category' }); + + await dialog.getByRole('button', { name: 'Add emoji' }).click(); + await page.getByRole('dialog', { name: 'Emoji picker' }).getByRole('textbox').first().fill('rocket'); + await page.locator('[data-emoji="rocket"]').first().click(); + + // While an emoji is set the clear badge appears; clicking it reverts to the folder icon. + const clear = dialog.getByRole('button', { name: 'Remove' }); + await expect(clear).toBeVisible(); + await clear.click(); + await expect(dialog.getByRole('button', { name: 'Add emoji' }).locator('.rcx-icon--name-folder')).toBeVisible(); + await expect(clear).toHaveCount(0); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + }); + + test('should show the category emoji in the "Move to" menu (in place of the folder)', async () => { + const name = uniqueName('menu-emoji'); + await createCategoryWithEmoji(name); + + await poHomeChannel.sidebar.openRoomMoveToSubmenu(targetChannel); + const item = poHomeChannel.sidebar.roomMenuMoveToItem(new RegExp(name)); + await expect(item.locator('[class*="emojione"]')).toBeVisible(); + await poHomeChannel.page.keyboard.press('Escape'); + }); + + test('should keep the open room visible when its category is collapsed', async () => { + const name = uniqueName('collapse'); + await createCategory(name); + await poHomeChannel.sidebar.moveRoomToCategory(targetChannel, name); + + // Open the room, then collapse the category — the (read) open room stays visible. + await poHomeChannel.sidebar.getSidebarItemByName(targetChannel).click(); + await expect(poHomeChannel.page).toHaveURL(new RegExp(targetChannel)); + await poHomeChannel.sidebar.getCategoryCollapser(name).click(); + + await expect(poHomeChannel.sidebar.getCollapsedCategoryCollapser(name)).toBeVisible(); + await expect(poHomeChannel.sidebar.getSidebarItemByName(targetChannel)).toBeVisible(); + }); + }); + + test.describe('category actions (custom category)', () => { + test('should rename a category', async () => { + const name = uniqueName('ren'); + const renamed = `${name}-renamed`; + await createCategory(name); + + await poHomeChannel.sidebar.openCategoryMenu(name); + await poHomeChannel.page.getByRole('menuitem', { name: 'Rename', exact: true }).click(); + + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Rename category' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('textbox', { name: 'Name' }).fill(renamed); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + + await expect(poHomeChannel.sidebar.getCategoryCollapser(renamed)).toBeVisible(); + await expect(poHomeChannel.sidebar.getCategoryCollapser(name)).toHaveCount(0); + }); + + test('should delete a category and return its rooms to the system group', async () => { + const name = uniqueName('del'); + await createCategory(name); + await poHomeChannel.sidebar.moveRoomToCategory(targetChannel, name); + expect(await isRoomInGrouping(name)).toBe(true); + + await poHomeChannel.sidebar.openCategoryMenu(name); + await poHomeChannel.page.getByRole('menuitem', { name: 'Delete', exact: true }).click(); + + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Delete category' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + + await expect(poHomeChannel.sidebar.getCategoryCollapser(name)).toHaveCount(0); + // The room survives and is no longer grouped (back in its system group). + await expect(poHomeChannel.sidebar.getSidebarItemByName(targetChannel)).toBeVisible(); + expect(await isRoomInGrouping(name)).toBe(false); + }); + + test('should reorder categories with Move up', async () => { + const first = uniqueName('ord-a'); + const second = uniqueName('ord-b'); + await createCategory(first); + await createCategory(second); + + await poHomeChannel.sidebar.openCategoryMenu(second); + await poHomeChannel.page.getByRole('menuitem', { name: 'Move up', exact: true }).click(); + + const regions = poHomeChannel.sidebar.channelsList.getByRole('region', { name: new RegExp(`Collapse (${first}|${second})`) }); + await expect(regions.first()).toHaveAttribute('aria-label', `Collapse ${second}`); + }); + + test('should reorder categories with Move down', async () => { + const first = uniqueName('ord-c'); + const second = uniqueName('ord-d'); + await createCategory(first); + await createCategory(second); + + await poHomeChannel.sidebar.openCategoryMenu(first); + await poHomeChannel.page.getByRole('menuitem', { name: 'Move down', exact: true }).click(); + + const regions = poHomeChannel.sidebar.channelsList.getByRole('region', { name: new RegExp(`Collapse (${first}|${second})`) }); + await expect(regions.first()).toHaveAttribute('aria-label', `Collapse ${second}`); + }); + + test('should toggle "Show unreads"', async () => { + const name = uniqueName('unr'); + await createCategory(name); + + await poHomeChannel.sidebar.openCategoryMenu(name); + const toggle = poHomeChannel.page.getByRole('menuitemcheckbox', { name: 'Show unreads' }); + await expect(toggle).toBeVisible(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await poHomeChannel.page.keyboard.press('Escape'); + }); + + test('should create another category from the category menu', async () => { + const name = uniqueName('base'); + const created = uniqueName('from-menu'); + await createCategory(name); + + await poHomeChannel.sidebar.openCategoryMenu(name); + await poHomeChannel.page.getByRole('menuitem', { name: 'New category', exact: true }).click(); + + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Create category' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('textbox', { name: 'Name' }).fill(created); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await expect(poHomeChannel.sidebar.getCategoryCollapser(created)).toBeVisible(); + }); + + test('should open the Create channel modal from "New channel"', async () => { + const name = uniqueName('nc'); + await createCategory(name); + + await poHomeChannel.sidebar.openCategoryMenu(name); + await poHomeChannel.page.getByRole('menuitem', { name: 'New channel', exact: true }).click(); + + await expect(poHomeChannel.page.getByRole('dialog', { name: 'Create channel' })).toBeVisible(); + await poHomeChannel.page.keyboard.press('Escape'); + }); + }); + + test.describe('system group actions (reduced menu)', () => { + test('should show a reduced menu without Rename / Delete / New channel', async () => { + await poHomeChannel.sidebar.openCategoryMenu('Channels'); + + await expect(poHomeChannel.page.getByRole('menuitem', { name: 'Move up', exact: true })).toBeVisible(); + await expect(poHomeChannel.page.getByRole('menuitem', { name: 'Move down', exact: true })).toBeVisible(); + await expect(poHomeChannel.page.getByRole('menuitem', { name: 'New category', exact: true })).toBeVisible(); + await expect(poHomeChannel.page.getByRole('menuitemcheckbox', { name: 'Show unreads' })).toBeVisible(); + + await expect(poHomeChannel.page.getByRole('menuitem', { name: 'Rename', exact: true })).toHaveCount(0); + await expect(poHomeChannel.page.getByRole('menuitem', { name: 'Delete', exact: true })).toHaveCount(0); + await expect(poHomeChannel.page.getByRole('menuitem', { name: 'New channel', exact: true })).toHaveCount(0); + await poHomeChannel.page.keyboard.press('Escape'); + }); + + test('should create a category from the system group menu', async () => { + const created = uniqueName('from-system'); + await poHomeChannel.sidebar.openCategoryMenu('Channels'); + await poHomeChannel.page.getByRole('menuitem', { name: 'New category', exact: true }).click(); + + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Create category' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('textbox', { name: 'Name' }).fill(created); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await expect(poHomeChannel.sidebar.getCategoryCollapser(created)).toBeVisible(); + }); + }); + + test.describe('sidebar item actions', () => { + test('should move a room into a category', async () => { + const name = uniqueName('move'); + await createCategory(name); + + await poHomeChannel.sidebar.moveRoomToCategory(targetChannel, name); + expect(await isRoomInGrouping(name)).toBe(true); + }); + + test('should move a room to Favorites and back', async () => { + await poHomeChannel.sidebar.moveRoomToFavorites(targetChannel); + expect(await isRoomInGrouping('Favorites')).toBe(true); + + await poHomeChannel.sidebar.removeRoomFromFavorites(targetChannel); + expect(await isRoomInGrouping('Favorites')).toBe(false); + }); + + test('should remove a room from a category', async () => { + const name = uniqueName('rm'); + await createCategory(name); + await poHomeChannel.sidebar.moveRoomToCategory(targetChannel, name); + expect(await isRoomInGrouping(name)).toBe(true); + + await poHomeChannel.sidebar.removeRoomFromCategory(targetChannel, name); + expect(await isRoomInGrouping(name)).toBe(false); + }); + + test('should create a category and move the room into it in one step', async () => { + const name = uniqueName('created'); + await poHomeChannel.sidebar.createCategoryFromRoom(targetChannel); + + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Create category' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('textbox', { name: 'Name' }).fill(name); + // In "create and move" mode the confirm button reads "Create and move". + await dialog.getByRole('button', { name: 'Create and move', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + + await expect(poHomeChannel.sidebar.getCategoryCollapser(name)).toBeVisible(); + expect(await isRoomInGrouping(name)).toBe(true); + }); + + test('should move a room into a category via drag-and-drop', async ({ page }) => { + const name = uniqueName('dnd'); + await createCategory(name); + + const source = poHomeChannel.sidebar.getSidebarItemByName(targetChannel); + const targetHeader = poHomeChannel.sidebar.getCategoryCollapser(name); + + const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); + await source.dispatchEvent('dragstart', { dataTransfer }); + await targetHeader.dispatchEvent('dragenter', { dataTransfer }); + await targetHeader.dispatchEvent('dragover', { dataTransfer }); + await targetHeader.dispatchEvent('drop', { dataTransfer }); + await source.dispatchEvent('dragend', { dataTransfer }); + + expect(await isRoomInGrouping(name)).toBe(true); + }); + }); + + test.describe('channel header grouping', () => { + test.beforeEach(async ({ page }) => { + await page.goto(`/channel/${targetChannel}`); + await expect(poHomeChannel.content.headerGroupingButton).toBeVisible(); + }); + + test('should show the grouping control with the default star icon', async () => { + await expect(poHomeChannel.content.headerGroupingIcon('star')).toBeVisible(); + }); + + test('should move the room into a category from the header (icon → folder)', async () => { + const name = uniqueName('hdr'); + await poHomeChannel.content.pickHeaderGroupingTarget('New category'); + + const dialog = poHomeChannel.page.getByRole('dialog', { name: 'Create category' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('textbox', { name: 'Name' }).fill(name); + await dialog.getByRole('button', { name: 'Create and move', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + + await expect(poHomeChannel.content.headerGroupingIcon('folder')).toBeVisible(); + expect(await isRoomInGrouping(name)).toBe(true); + }); + + test('should favorite the room from the header (icon → star-filled) and remove it', async () => { + await poHomeChannel.content.pickHeaderGroupingTarget('Favorites'); + await expect(poHomeChannel.content.headerGroupingIcon('star-filled')).toBeVisible(); + + await poHomeChannel.content.pickHeaderGroupingTarget('Remove from Favorites'); + await expect(poHomeChannel.content.headerGroupingIcon('star')).toBeVisible(); + }); + + test('should move the room into an existing category from the header', async () => { + const name = uniqueName('hdr-existing'); + await createCategory(name); + + await poHomeChannel.content.pickHeaderGroupingTarget(name); + await expect(poHomeChannel.content.headerGroupingIcon('folder')).toBeVisible(); + expect(await isRoomInGrouping(name)).toBe(true); + }); + + test('should show the category emoji in the header grouping control', async ({ page }) => { + const name = uniqueName('hdr-emoji'); + + // Create-and-move into a new emoji category via the header menu. + await poHomeChannel.content.openHeaderGroupingMenu(); + await page.getByRole('menuitem', { name: 'New category' }).click(); + const dialog = page.getByRole('dialog', { name: 'Create category' }); + await dialog.getByRole('button', { name: 'Add emoji' }).click(); + await page.getByRole('dialog', { name: 'Emoji picker' }).getByRole('textbox').first().fill('rocket'); + await page.locator('[data-emoji="rocket"]').first().click(); + await dialog.getByRole('textbox', { name: 'Name' }).fill(name); + await dialog.getByRole('button', { name: 'Create and move', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + + // The header trigger now shows the category emoji instead of the folder/star icon. + await expect(poHomeChannel.content.headerGroupingButton.locator('[class*="emojione"]')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts b/apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts new file mode 100644 index 0000000000000..70811d437f59a --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts @@ -0,0 +1,209 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { ISidebarCustomCategory, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { password } from '../../data/user'; +import { createUser, login, deleteUser } from '../../data/users.helper'; + +/** + * Persistence + validation of the `sidebarCustomCategories` user preference (custom sidebar categories). + * + * The feature has no dedicated endpoint — categories are stored on the user preferences and round-trip + * through `users.setPreferences` / `users.getPreferences`. The REST schema validates each entry with + * `required: ['_id', 'name']` and `additionalProperties: false`, so malformed payloads are rejected. + */ +describe('[Sidebar Custom Categories]', () => { + let testUser: IUser; + let testUserCredentials: Credentials; + + const category = (overrides: Partial = {}): ISidebarCustomCategory => ({ + _id: Random.id(), + name: `category-${Random.id()}`, + ...overrides, + }); + + before((done) => getCredentials(done)); + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + after(() => deleteUser(testUser)); + + const setCategories = (data: unknown, asCredentials: Credentials = testUserCredentials) => + request + .post(api('users.setPreferences')) + .set(asCredentials) + .send({ data: { sidebarCustomCategories: data } }); + + describe('persistence', () => { + it('should persist a minimal category (only the required _id + name)', async () => { + const categories = [category({ name: 'Projects' })]; + + await setCategories(categories) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.user.settings.preferences).to.have.property('sidebarCustomCategories').that.is.an('array').with.lengthOf(1); + }); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.preferences.sidebarCustomCategories).to.deep.equal(categories); + }); + }); + + it('should persist the optional showUnreads and rooms fields verbatim', async () => { + const categories = [ + category({ name: 'Team', showUnreads: false, rooms: ['rid-1', 'rid-2'] }), + category({ name: 'Personal', showUnreads: true, rooms: [] }), + ]; + + await setCategories(categories).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCustomCategories).to.deep.equal(categories); + }); + }); + + it('should persist the optional emoji icon', async () => { + const categories = [category({ name: 'Rocket', icon: 'rocket' })]; + + await setCategories(categories).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCustomCategories[0]).to.have.property('icon', 'rocket'); + }); + }); + + it('should preserve the array order (order is the render order)', async () => { + const categories = [category({ name: 'A' }), category({ name: 'B' }), category({ name: 'C' })]; + + await setCategories(categories).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCustomCategories.map((c: ISidebarCustomCategory) => c.name)).to.deep.equal(['A', 'B', 'C']); + }); + }); + + it('should replace the whole array on each write (read-modify-write semantics)', async () => { + await setCategories([category({ name: 'first' })]).expect(200); + const replacement = [category({ name: 'second' })]; + await setCategories(replacement).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCustomCategories).to.have.lengthOf(1); + expect(res.body.preferences.sidebarCustomCategories[0]).to.have.property('name', 'second'); + }); + }); + + it('should accept an empty array (clearing all categories)', async () => { + await setCategories([category({ name: 'temp' })]).expect(200); + await setCategories([]).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCustomCategories).to.be.an('array').with.lengthOf(0); + }); + }); + }); + + describe('validation', () => { + const expectInvalid = (res: { body: { success: boolean; errorType: string } }) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }; + + // NOTE: the shared ajv instance runs with `coerceTypes`, so a scalar type mismatch on a coercible + // field (e.g. a numeric `name`, or a numeric entry in `rooms`) is coerced to the schema type and + // accepted rather than rejected. The assertions below therefore cover the constraints coercion + // cannot satisfy: missing required fields, unknown properties, a non-array root, and a value that + // cannot be coerced to the target type. + + it('should reject an entry missing the required "name"', async () => { + await setCategories([{ _id: Random.id() }]) + .expect(400) + .expect(expectInvalid); + }); + + it('should reject an entry missing the required "_id"', async () => { + await setCategories([{ name: 'no-id' }]) + .expect(400) + .expect(expectInvalid); + }); + + it('should reject an unknown property on an entry (additionalProperties: false)', async () => { + await setCategories([{ _id: Random.id(), name: 'x', color: 'red' }]) + .expect(400) + .expect(expectInvalid); + }); + + it('should reject a non-array value', async () => { + await setCategories({ _id: Random.id(), name: 'not-an-array' }).expect(400).expect(expectInvalid); + }); + + it('should reject a "showUnreads" that cannot be coerced to a boolean', async () => { + await setCategories([{ _id: Random.id(), name: 'x', showUnreads: 'yes' }]) + .expect(400) + .expect(expectInvalid); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setPreferences')) + .send({ data: { sidebarCustomCategories: [] } }) + .expect(401); + }); + }); + + describe('permissions', () => { + it("should let an admin set another user's sidebarCustomCategories", async () => { + const categories = [category({ name: 'set-by-admin' })]; + + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ userId: testUser._id, data: { sidebarCustomCategories: categories } }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCustomCategories).to.deep.equal(categories); + }); + }); + }); +}); diff --git a/docs/features/custom-sidebar-categories.md b/docs/features/custom-sidebar-categories.md new file mode 100644 index 0000000000000..4e8c81b83d847 --- /dev/null +++ b/docs/features/custom-sidebar-categories.md @@ -0,0 +1,271 @@ +# Custom Sidebar Categories + +## Overview + +Custom categories let each user group their sidebar rooms into personal, named collections that render +**above** the system groups (Favorites, Teams, Channels, Direct messages, …). A user can create, rename, +delete and reorder categories; move rooms in and out of them via context menus or drag-and-drop; and toggle +whether each category keeps showing unread rooms while collapsed. + +Categories are **per-user** and **client-only in meaning** — they are a presentation grouping of the user's +own subscriptions. They carry **no access, security or membership semantics**: moving a room into a category +never changes who can see the room or the room itself. Assignment is **exclusive** — a room lives in exactly +one place at a time (one custom category, *or* Favorites, *or* its system group). + +> The categories UI is **not** behind a license/premium gate or a feature-preview toggle in the current +> implementation. It renders in both the classic sidebar and the navigation (feature-preview) sidebar. + +## Data Model + +A category is a small record stored on the user's preferences: + +```ts +// packages/core-typings/src/IUser.ts +export interface ISidebarCustomCategory { + _id: string; // Random.id(), generated client-side on create + name: string; // display name (trimmed; max 30 chars) + icon?: string; // emoji name (e.g. `rocket`, no colons); falls back to the folder icon + showUnreads?: boolean; // when collapsed, keep listing unread rooms (default: true) + rooms?: string[]; // rids assigned to this category +} +``` + +### Emoji icon + +A category can carry an optional emoji, chosen from the shared emoji picker in the Create / Rename modals +(`EmojiIconPicker.tsx`). The picker button sits before the name field (Slack-style) and previews the current +icon: the chosen emoji, or the **folder** icon when none is set. The stored value is the picker's emoji name +(e.g. `rocket`), rendered via the `` component. + +`CategoryLabel.tsx` renders the icon before the group name in the sidebar collapser — the emoji when set, +otherwise a fuselage icon (constrained to `x20`). The collapser's `aria-label` keeps the plain name, so the +icon is purely visual and does not affect accessible names or the `region`-name selectors used by tests. + +**System groups get an icon too**, so default and custom groups align identically: the classic `useRoomList.ts` +assigns each system group a leading icon (`SYSTEM_GROUP_ICONS`: Channels → `hashtag`, Direct messages → `at`, +Teams → `team`, Favorites → `star`, …); the navigation sidebar already carries one per group +(`sidePanelFiltersConfig`). Custom categories use `folder` (or their emoji). + +**Icon ⇄ chevron (Slack-style).** The icon and the collapse chevron share a single fixed-size slot in +`CategoryLabel`: the icon shows by default and is swapped for the chevron on header hover (both layers are +absolutely positioned inside a `1.25rem` slot, so nothing resizes and the title never shifts). The collapser's +built-in fuselage chevron (`.rcx-chevron`) is hidden via CSS; `CategoryLabel` renders its own chevron +(direction from the `collapsed` prop). The whole header stays clickable to collapse regardless of hover. + +**Inset highlight (Slack-style).** Room rows apply an inset, rounded hover/selected highlight via inline style +on the item (`marginInline` keeps it off the sidebar edges, `borderRadius` rounds it) in +`SidebarItemTemplateWithData.tsx` (classic) and `SidebarItemWithData.tsx` (navigation). Rooms are **indented** +(`paddingInlineStart: 1.5rem`) so they read as nested under the category header, whose icon stays at the outer +edge. + +The collapser header (`RoomListCollapser.tsx`, both sidebars) is a two-box structure: +- an **outer** wrapper with `paddingBlockStart` that adds transparent space **above every category** (separating + it from the previous category's items, and the first one from the sidebar header). It is padding, not margin + — virtuoso measures the rendered height, and a top margin collapses out and lets the header overlap its items. +- an **inner** box that carries the inset highlight: `marginInline` (off the edges), `border-radius` on the bar + for the hover tint and on the box for the drag-over tint, a reduced `padding-inline`/`padding-block` + + `min-height: 0` so the header is the **same height as a condensed room item** and its icon aligns with the + room avatars. The kebab is a `mini` `IconButton` so it fits that height. + +During **drag-over** the inner box rounding is dropped (`borderRadius: 0`, inset kept) and the room rows drop +their vertical gap and rounding, so the whole category (header + its rooms) highlights as one contiguous block +rather than separate pills. + +The emoji also replaces the folder icon **in the menus**: the "Move to" list (`MoveToList.tsx`, used by both +the sidebar room kebab submenu and the room-header dropdown) renders a category's emoji via the `Option` +`avatar` slot. And the **room-header grouping control** (`RoomGroupingMenu.tsx`) uses the same +`HeaderToolbarAction` button — identical size to the right-side room actions (Room info, Threads, …) — and shows +the category's emoji as its icon (falling back to filled-star / folder / star). + +**Unsetting:** while an emoji is selected, the picker button shows a small clear badge (an +`IconButton secondary`, theme-aware) that reverts the category to the default folder icon. + +The ordered list of a user's categories is the preference `sidebarCustomCategories: ISidebarCustomCategory[]`. +The array order **is** the render order (top to bottom). + +## Persistence + +All mutations are persisted server-side through the existing user-preferences endpoint — there is **no new +endpoint**: + +``` +POST /v1/users.setPreferences { data: { sidebarCustomCategories: ISidebarCustomCategory[] } } +``` + +- The REST schema (`UsersSetPreferenceParamsPOST`) validates the array with `additionalProperties: false` + and `required: ['_id', 'name']` per item, so malformed entries are rejected with `400 invalid-params`. +- The Meteor method `saveUserPreferences` mirrors the field with `Match.Optional([Match.ObjectIncluding({ _id: String, name: String })])`. +- The value round-trips back to the client through the user object (`me` / login payload → + `useUserPreference('sidebarCustomCategories')`), so changes are reactive across the user's sessions. + +Every write replaces the **whole** array (read-modify-write in the hook), which keeps ordering and exclusivity +invariants in a single atomic preference update. + +## Behaviors / Flows + +All flows live in the hook `client/views/navigation/hooks/useCustomCategories.ts`. + +| Flow | Function | Notes | +|------|----------|-------| +| Create | `createCategory(name)` | Appends `{ _id, name, showUnreads: true, rooms: [] }`. Validates name first. | +| Rename | `renameCategory(id, name)` | No-op + close when unchanged. | +| Delete | `deleteCategory(id)` | Rooms fall back to their system group (the rooms themselves are untouched). | +| Reorder | `reorderCategory(id, 'up' \| 'down')` | Swaps adjacent entries; clamped at the ends. | +| Toggle unreads | `toggleShowUnreads(id)` | Flips `showUnreads` (default treated as `true`). | +| Move room | `moveRoom(room, target)` | `target` is a category id or the `favorites` sentinel. Strips the room from every other category first (exclusive). | +| Create & move | `createCategoryAndMoveRoom(name, room)` | One persisted action: new category seeded with the room (flow D). | +| Remove room | `removeRoom(room)` | Strips from all categories + unfavorites → returns to its system group. | +| Lookup | `getRoomCategory(rid)` | The category currently containing a room, if any. | + +### Name validation + +`validateName(name, excludeId?)` returns `'empty'` (blank/whitespace) or `'duplicate'` +(case-insensitive match against another category) or `undefined`. The modals map these to +`Please_enter_a_category_name` / `A_category_with_this_name_already_exists`. Max length is +`MAX_CATEGORY_NAME_LENGTH = 30`. + +### Favorites interaction + +`Favorites` is a system group, not a custom category, but it participates in the same "Move to" target list +via the `FAVORITES_TARGET = 'favorites'` sentinel. Moving a room to Favorites favorites it +(`POST /v1/rooms.favorite` + `toggleFavoriteRoom` mutation effect) and strips it from any custom category; +moving it into a custom category unfavorites it. This keeps assignment exclusive. + +### Exclusivity in the room list + +`client/sidebar/hooks/useRoomList.ts` (classic) builds a `rid → categoryId` map first. A room assigned to a +custom category is added **only** to that category's set and `return`s before the system-group bucketing — so +it never appears twice. Custom categories are emitted first (above system groups) and **persist even when +empty** (an expanded empty category reserves one row for the "drag rooms here" placeholder). + +### Collapsed display + +When a group (custom category or system group) is collapsed, it shows only its unread rooms (when "Show +unreads" is on) **plus the currently-open room**. The open room (`useOpenedRoom()`) is always kept visible in +its group even when collapsed and not unread, so the active conversation never disappears from the sidebar. +This is applied in both `useRoomList.ts` (classic) and `getDisplayRooms` in `RoomsNavigationContext.ts` (nav). + +## The two sidebars + +The feature is implemented once and rendered in both sidebars; the category components under +`client/views/navigation/sidebar/categories/` are **sidebar-agnostic**. + +| | Classic (default) | Navigation (feature preview `secondarySidebar`) | +|---|---|---| +| Room list hook | `client/sidebar/hooks/useRoomList.ts` | `client/views/navigation/...` | +| Collapser | `client/sidebar/RoomList/RoomListCollapser.tsx` | `client/views/navigation/sidebar/RoomList/RoomListCollapser.tsx` | +| Room row | `client/sidebar/RoomList/SidebarItemTemplateWithData.tsx` | `client/views/navigation/sidebar/RoomList/SidebarItemWithData.tsx` | +| Room kebab | `client/sidebar/RoomMenu.tsx` → `RoomMenuWithCategories` | same shared component | + +Which sidebar renders is decided in +`client/views/root/MainLayout/LayoutWithSidebar.tsx` via ``. + +## Menus + +Fuselage's `GenericMenu` (react-aria) cannot host a nested flyout, so the category menus are custom popovers +built from `Position` + `Tile` (`role="menu"`) + `Option` (`role="menuitem"`). + +### Room kebab — `RoomMenuWithCategories.tsx` + +The standard room actions (Hide, Mark read, Leave, …) render as before, but the **Favorite** action's slot is +replaced by a **"Move to ▸"** item that opens a cascading submenu (`MoveToList`). The submenu is an +absolutely-positioned child of the *same* popover (one portal) — nesting a second `Position` portal crashes on +teardown. Outside-click is handled by a capturing `mousedown` listener that keeps the menu open while the +target is inside any `[role="menu"]`. + +### Move-to list — `MoveToList.tsx` (shared) + +`Move to` title → `Favorites` + each category (the current grouping shown **bold + check**) → divider → +`New category` → `Remove from {category}` (only when the room is currently grouped). Built by +`useRoomCategoryItems.tsx`. Used by both the room kebab submenu and the room-header grouping dropdown. + +### Category collapser kebab — `CategoryMenu.tsx` + +- **Custom category:** Move up / Move down / New channel / **Manage** (Rename / Delete / New category) / + divider / **When closed** → Show unreads (toggle). +- **System group:** Move up / Move down / New category / divider / When closed → Show unreads. + +Reorder actions close the menu (so the popover doesn't end up anchored to a category that just moved). The +Show-unreads row is a `role="menuitemcheckbox"` wrapping a `ToggleSwitch`. + +### Room-header grouping menu — `client/views/room/Header/icons/RoomGroupingMenu.tsx` + +A popover on the far left of the room header (icon reflects the current grouping: `star-filled` for a favorite, +`folder` for a category, `star` otherwise) that opens the same `MoveToList`. + +## Drag and drop + +Native HTML5 DnD, coordinated through `CategoryDnDContext.tsx`: + +- **Dragging a room** (`useRoomDrag`): rows are `
` links, so `dataTransfer.clearData()` is called to + drop the `text/uri-list` payload (otherwise Chrome shows "open link / split view" drop zones). The native + drag image is hidden (a transparent pixel via `setDragImage`) and a **custom tilted ghost** is rendered that + follows the cursor — Chrome rasterizes a `setDragImage` bitmap *without* CSS transforms, so the tilt only + survives on a live DOM node. The ghost is appended inside `.rcx-sidebar` so it inherits the sidebar theme. +- **Drop targets** (`useGroupDrop`): a custom category accepts any room not already in it; a system group + accepts a room **only** when it is that room's native group (drop = remove from the custom category). Native + group keys are resolved by `nativeCategory.ts` (`getNativeCategoryKey`). Non-accepting system groups fade out + while a drag is in progress. +- **Highlight:** on drag-over, the whole target block (collapser header + its rows) highlights with + `--rcx-color-surface-hover`. The header bar is kept permanently transparent (`transparentBarClass`) so the + wrapper's inline background drives the header tint in the same render as the rows — header and rows light up + together. Leaving the group reverts via a 60 ms debounced clear (avoids flicker when moving between sibling + rows). +- **Empty category:** `CategoryEmptyPlaceholder.tsx` renders a full-width "Drag rooms here" drop zone. + +## Translations + +Keys added to `packages/i18n/src/locales/en.i18n.json` (rebuild `@rocket.chat/i18n` after editing): + +| Key | English | +|-----|---------| +| `Category` | Category | +| `Create_category` | Create category | +| `Categories_are_private_custom_groupings_of_rooms` | Categories are private custom groupings of rooms. | +| `Category_created` | Category created. | +| `You_can_add_rooms_after` | You can add rooms after | +| `Please_enter_a_category_name` | Please enter a category name | +| `A_category_with_this_name_already_exists` | A category with this name already exists | +| `Rename_category` / `Delete_category` | Rename category / Delete category | +| `New_category` / `New_channel` | New category / New channel | +| `Move_to` | Move to | +| `Move__roomName__to` | Move {{roomName}} to: | +| `Create_and_move` | Create and move | +| `Remove_from__categoryName__` | Remove from {{categoryName}} | +| `__roomName__moved_to__categoryName__` | {{roomName}} moved to {{categoryName}}. | +| `__roomName__removed_from__categoryName__` | {{roomName}} removed from {{categoryName}}. | +| `Show_unreads` / `When_closed` / `Manage` | Show unreads / When closed / Manage | +| `Drag_rooms_here` | Drag rooms here | + +## REST Contract + +| Method | Endpoint | Field | +|--------|----------|-------| +| POST | `/v1/users.setPreferences` | `data.sidebarCustomCategories: ISidebarCustomCategory[]` | +| GET | `/v1/me` | returns `settings.preferences.sidebarCustomCategories` | + +## Key Files + +| Layer | File | +|-------|------| +| Type | `packages/core-typings/src/IUser.ts` (`ISidebarCustomCategory`) | +| REST typings/schema | `packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts` | +| Meteor method | `apps/meteor/server/methods/saveUserPreferences.ts` | +| Core hook | `apps/meteor/client/views/navigation/hooks/useCustomCategories.ts` | +| Show-unreads (per group) | `apps/meteor/client/views/navigation/hooks/useShowUnreadsGroups.ts` | +| System-group order | `apps/meteor/client/views/navigation/hooks/useSystemGroupsOrder.ts` | +| Room list (classic) | `apps/meteor/client/sidebar/hooks/useRoomList.ts` | +| Category components | `apps/meteor/client/views/navigation/sidebar/categories/` | +| Drag-and-drop | `apps/meteor/client/views/navigation/sidebar/categories/CategoryDnDContext.tsx` | +| Create-menu entry | `apps/meteor/client/navbar/NavBarPagesGroup/hooks/useCreateNewMenu.ts` | +| Room-header grouping | `apps/meteor/client/views/room/Header/icons/RoomGroupingMenu.tsx` | +| Collapsers | `apps/meteor/client/{sidebar,views/navigation/sidebar}/RoomList/RoomListCollapser.tsx` | +| i18n | `packages/i18n/src/locales/en.i18n.json` | + +## Tests + +| Kind | File | +|------|------| +| API (preference persistence + validation) | `apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts` | +| E2E (Playwright) | `apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts` | +| E2E page object | `apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts` (custom-category helpers) | diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index aedcdfec0d759..4197af64b5b92 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -166,6 +166,21 @@ export interface IUserSettings { calendar?: IUserCalendar; } +/** + * A per-user, custom grouping of rooms shown in the sidebar. + * Order is given by the position in the `sidebarCustomCategories` preference array. + */ +export interface ISidebarCustomCategory { + _id: string; + name: string; + /** Emoji name (e.g. `rocket`, without colons) shown before the category name. Falls back to the folder icon. */ + icon?: string; + /** When collapsed, whether unread rooms in this category stay visible. Defaults to `true`. */ + showUnreads?: boolean; + /** Room ids (`rid`) assigned to this category. A room belongs to at most one custom category. */ + rooms?: string[]; +} + export interface IUser extends IRocketChatRecord { createdAt: Date; roles: IRole['_id'][]; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index b29ec32dc1295..ba1611b0209ab 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -7281,5 +7281,32 @@ "No_changes_to_save": "No changes to save", "Avatar_preview_updated": "Avatar preview updated", "Select_message_from_user": "Select message from {{username}}", - "Select_message_from_user_with_preview": "Select message from {{username}}: {{message}}" + "Select_message_from_user_with_preview": "Select message from {{username}}: {{message}}", + "Category": "Category", + "Create_category": "Create category", + "Categories_are_private_custom_groupings_of_rooms": "Categories are private custom groupings of rooms.", + "You_can_add_rooms_after": "You can add rooms after", + "Create_and_move": "Create and move", + "Move__roomName__to": "Move {{roomName}} to:", + "Rename_category": "Rename category", + "Delete_category": "Delete category", + "New_category": "New category", + "New_channel": "New channel", + "Move_up": "Move up", + "Move_down": "Move down", + "Move_to": "Move to", + "When_closed": "When closed", + "Show_unreads": "Show unreads", + "Rename": "Rename", + "Drag_rooms_here": "Drag rooms here", + "Please_enter_a_category_name": "Please enter a category name.", + "A_category_with_this_name_already_exists": "A category with this name already exists.", + "Category_created": "Category created.", + "Category_renamed_to__name__": "Category renamed to {{name}}.", + "Category__name__deleted": "Category {{name}} deleted.", + "Anything_you_added_to__name__will_move_back_to_default": "Anything you added to {{name}} will move back to their default categories.", + "No_content_access_or_security_setting_will_be_lost_or_changed": "No content, access or security setting will be lost or changed.", + "__roomName__moved_to__categoryName__": "{{roomName}} moved to {{categoryName}}.", + "Remove_from__categoryName__": "Remove from {{categoryName}}", + "__roomName__removed_from__categoryName__": "{{roomName}} removed from {{categoryName}}." } \ No newline at end of file diff --git a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts index 18b6986f1aaec..ba296c21a129d 100644 --- a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts @@ -1,4 +1,4 @@ -import type { ThemePreference } from '@rocket.chat/core-typings'; +import type { ISidebarCustomCategory, ThemePreference } from '@rocket.chat/core-typings'; import { ajv } from '../Ajv'; @@ -39,6 +39,7 @@ export type UsersSetPreferencesParamsPOST = { sidebarViewMode?: string; sidebarDisplayAvatar?: boolean; sidebarGroupByType?: boolean; + sidebarCustomCategories?: ISidebarCustomCategory[]; muteFocusedConversations?: boolean; dontAskAgainList?: Array<{ action: string; label: string }>; featuresPreview?: { name: string; value: boolean }[]; @@ -199,6 +200,22 @@ const UsersSetPreferencesParamsPostSchema = { type: 'boolean', nullable: true, }, + sidebarCustomCategories: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + icon: { type: 'string', nullable: true }, + showUnreads: { type: 'boolean', nullable: true }, + rooms: { type: 'array', items: { type: 'string' }, nullable: true }, + }, + required: ['_id', 'name'], + additionalProperties: false, + }, + nullable: true, + }, muteFocusedConversations: { type: 'boolean', nullable: true,