snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from 'storybook/actions';
import Condensed from './Condensed';
import * as Status from '../../components/UserStatus';
export default {
component: Condensed,
args: {
clickable: true,
title: 'John Doe',
},
decorators: [
(fn) => (
<Box maxWidth='x300' backgroundColor='dark' borderRadius='x4'>
{fn()}
</Box>
),
],
} satisfies Meta<typeof Condensed>;
const Template: StoryFn<typeof Condensed> = (args) => (
<Condensed
{...args}
titleIcon={
<Box marginInline={4}>
<Status.Online />
</Box>
}
avatar={<UserAvatar username='john.doe' size='x16' url='https://via.placeholder.com/16' />}
/>
);
export const Normal = {
render: Template,
};
export const Selected = {
render: Template,
args: {
selected: true,
},
};
export const Menu = {
render: Template,
args: {
menuOptions: {
hide: {
label: { label: 'Hide', icon: 'eye-off' },
action: action('action'),
},
read: {
label: { label: 'Mark_read', icon: 'flag' },
action: action('action'),
},
favorite: {
label: { label: 'Favorite', icon: 'star' },
action: action('action'),
},
},
},
};
export const Actions = {
render: Template,
args: {
actions: (
<>
<IconButton secondary success icon='phone' />
<IconButton secondary danger icon='circle-cross' />
<IconButton secondary info icon='trash' />
<IconButton secondary icon='phone' />
</>
),
},
};

View File

@@ -0,0 +1,46 @@
import { IconButton, SidebarItem, SidebarItemAvatarWrapper, SidebarItemMenu, SidebarItemTitle } from '@rocket.chat/fuselage';
import type { HTMLAttributes, ReactNode } from 'react';
import { memo } from 'react';
import { useDeferredMenuMount } from './useDeferredMenuMount';
export type CondensedProps = {
title: ReactNode;
titleIcon?: ReactNode;
avatar: ReactNode;
icon?: ReactNode;
actions?: ReactNode;
href?: string;
unread?: boolean;
menu?: () => ReactNode;
menuOptions?: any;
selected?: boolean;
badges?: ReactNode;
clickable?: boolean;
} & Omit<HTMLAttributes<HTMLAnchorElement>, 'is'>;
const Condensed = ({ icon, title, titleIcon, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => {
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();
return (
<SidebarItem {...props} onFocus={mountNow} onPointerEnter={requestMount}>
{avatar && <SidebarItemAvatarWrapper>{avatar}</SidebarItemAvatarWrapper>}
{icon}
<SidebarItemTitle unread={unread}>{title}</SidebarItemTitle>
{titleIcon}
{badges}
{actions}
{menu && (
<SidebarItemMenu>
{menuVisibility ? (
menu()
) : (
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-item__menu icon='kebab' onPointerDown={mountNow} />
)}
</SidebarItemMenu>
)}
</SidebarItem>
);
};
export default memo(Condensed);

View File

@@ -0,0 +1,97 @@
import { Box, IconButton, Badge } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from 'storybook/actions';
import Extended from './Extended';
import * as Status from '../../components/UserStatus';
export default {
component: Extended,
decorators: [
(fn) => (
<Box maxWidth='x300' backgroundColor='dark' borderRadius='x4'>
{fn()}
</Box>
),
],
} satisfies Meta<typeof Extended>;
const Template: StoryFn<typeof Extended> = (args) => (
<Extended
{...args}
title='John Doe'
subtitle={
<Box display='flex' flexDirection='row' width='full' alignItems='center'>
<Box flexGrow='1' withTruncatedText>
John Doe: test 123
</Box>
<Badge
{...({
style: {
backgroundColor: '#6c727a',
color: 'var(--rcx-color-surface, white)',
flexShrink: 0,
},
} as any)}
>
99
</Badge>
</Box>
}
titleIcon={
<Box marginInline={4}>
<Status.Online />
</Box>
}
avatar={<UserAvatar username='john.doe' size='x16' url='https://via.placeholder.com/16' />}
/>
);
export const Normal = {
render: Template,
};
export const Selected = {
render: Template,
args: {
selected: true,
},
};
export const Menu = {
render: Template,
args: {
menuOptions: {
hide: {
label: { label: 'Hide', icon: 'eye-off' },
action: action('action'),
},
read: {
label: { label: 'Mark_read', icon: 'flag' },
action: action('action'),
},
favorite: {
label: { label: 'Favorite', icon: 'star' },
action: action('action'),
},
},
},
};
export const Actions = {
render: Template,
args: {
actions: (
<>
<IconButton secondary success icon='phone' />
<IconButton secondary danger icon='circle-cross' />
<IconButton secondary info icon='trash' />
<IconButton secondary icon='phone' />
</>
),
},
};

View File

@@ -0,0 +1,84 @@
import {
SidebarItem,
SidebarItemAvatarWrapper,
SidebarItemCol,
SidebarItemRow,
SidebarItemTitle,
SidebarItemTimestamp,
SidebarItemContent,
SidebarItemMenu,
IconButton,
} from '@rocket.chat/fuselage';
import type { HTMLAttributes, ReactNode } from 'react';
import { memo } from 'react';
import { useDeferredMenuMount } from './useDeferredMenuMount';
import { useShortTimeAgo } from '../../hooks/useTimeAgo';
export type ExtendedProps = {
icon?: ReactNode;
title: ReactNode;
avatar?: ReactNode;
actions?: ReactNode;
href?: string;
time?: any;
menu?: () => ReactNode;
subtitle?: ReactNode;
badges?: ReactNode;
unread?: boolean;
selected?: boolean;
menuOptions?: any;
titleIcon?: ReactNode;
threadUnread?: boolean;
} & Omit<HTMLAttributes<HTMLElement>, 'is'>;
const Extended = ({
icon,
title,
avatar,
actions,
href,
time,
menu,
menuOptions: _menuOptions,
subtitle = '',
titleIcon,
badges,
threadUnread: _threadUnread,
unread,
selected,
...props
}: ExtendedProps) => {
const formatDate = useShortTimeAgo();
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();
return (
<SidebarItem level={2} href={href} selected={selected} {...props} onFocus={mountNow} onPointerEnter={requestMount}>
{avatar && <SidebarItemAvatarWrapper>{avatar}</SidebarItemAvatarWrapper>}
<SidebarItemCol>
<SidebarItemRow>
{icon}
<SidebarItemTitle unread={unread}>{title}</SidebarItemTitle>
{time && <SidebarItemTimestamp>{formatDate(time)}</SidebarItemTimestamp>}
</SidebarItemRow>
<SidebarItemRow>
<SidebarItemContent unread={unread}>{subtitle}</SidebarItemContent>
{titleIcon}
{badges}
{actions}
{menu && (
<SidebarItemMenu>
{menuVisibility ? (
menu()
) : (
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-item__menu icon='kebab' onPointerDown={mountNow} />
)}
</SidebarItemMenu>
)}
</SidebarItemRow>
</SidebarItemCol>
</SidebarItem>
);
};
export default memo(Extended);

View File

@@ -0,0 +1,81 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from 'storybook/actions';
import Medium from './Medium';
import * as Status from '../../components/UserStatus';
export default {
component: Medium,
args: {
title: 'John Doe',
},
decorators: [
(fn) => (
<Box maxWidth='x300' backgroundColor='dark' borderRadius='x4'>
{fn()}
</Box>
),
],
} satisfies Meta<typeof Medium>;
const Template: StoryFn<typeof Medium> = (args) => (
<Medium
{...args}
titleIcon={
<Box marginInline={4}>
<Status.Online />
</Box>
}
avatar={<UserAvatar username='john.doe' size='x16' url='https://via.placeholder.com/16' />}
/>
);
export const Normal = {
render: Template,
};
export const Selected = {
render: Template,
args: {
selected: true,
},
};
export const Menu = {
render: Template,
args: {
menuOptions: {
hide: {
label: { label: 'Hide', icon: 'eye-off' },
action: action('action'),
},
read: {
label: { label: 'Mark_read', icon: 'flag' },
action: action('action'),
},
favorite: {
label: { label: 'Favorite', icon: 'star' },
action: action('action'),
},
},
},
};
export const Actions = {
render: Template,
args: {
actions: (
<>
<IconButton secondary success icon='phone' />
<IconButton secondary danger icon='circle-cross' />
<IconButton secondary info icon='trash' />
<IconButton secondary icon='phone' />
</>
),
},
};

View File

@@ -0,0 +1,45 @@
import { IconButton, SidebarItem, SidebarItemAvatarWrapper, SidebarItemMenu, SidebarItemTitle } from '@rocket.chat/fuselage';
import type { HTMLAttributes, ReactNode } from 'react';
import { memo } from 'react';
import { useDeferredMenuMount } from './useDeferredMenuMount';
export type MediumProps = {
title: ReactNode;
titleIcon?: ReactNode;
avatar: ReactNode;
icon?: ReactNode;
actions?: ReactNode;
href?: string;
unread?: boolean;
menu?: () => ReactNode;
badges?: ReactNode;
selected?: boolean;
menuOptions?: any;
} & Omit<HTMLAttributes<HTMLElement>, 'is'>;
const Medium = ({ icon, title, titleIcon, avatar, actions, badges, unread, menu, ...props }: MediumProps) => {
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();
return (
<SidebarItem {...props} onFocus={mountNow} onPointerEnter={requestMount}>
<SidebarItemAvatarWrapper>{avatar}</SidebarItemAvatarWrapper>
{icon}
<SidebarItemTitle unread={unread}>{title}</SidebarItemTitle>
{titleIcon}
{badges}
{actions}
{menu && (
<SidebarItemMenu>
{menuVisibility ? (
menu()
) : (
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-item__menu icon='kebab' onPointerDown={mountNow} />
)}
</SidebarItemMenu>
)}
</SidebarItem>
);
};
export default memo(Medium);

View File

@@ -0,0 +1,59 @@
import { useCallback, useEffect, useRef, useState } from 'react';
type IdleHandle = { type: 'idle' | 'timeout'; id: number };
const schedule = (fn: () => void): IdleHandle => {
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
return { type: 'idle', id: window.requestIdleCallback(fn, { timeout: 200 }) };
}
return { type: 'timeout', id: window.setTimeout(fn, 50) };
};
const cancel = (handle: IdleHandle) => {
if (handle.type === 'idle' && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(handle.id);
return;
}
window.clearTimeout(handle.id);
};
/**
* Defers mounting the sidebar item's RoomMenu until the browser is idle. The menu's hooks
* (useUserSubscription, usePermission, useSetting, useOmnichannelPrioritiesMenu, useUserPresence)
* are not cheap to run synchronously inside the same pointerover/click task as a room navigation,
* so we let the browser finish more urgent work first.
*/
export const useDeferredMenuMount = () => {
const [mounted, setMounted] = useState(typeof window !== 'undefined' && !!window.DISABLE_ANIMATION);
const handleRef = useRef<IdleHandle | undefined>(undefined);
const requestMount = useCallback(() => {
if (mounted || handleRef.current !== undefined) {
return;
}
handleRef.current = schedule(() => {
handleRef.current = undefined;
setMounted(true);
});
}, [mounted]);
const mountNow = useCallback(() => {
if (handleRef.current !== undefined) {
cancel(handleRef.current);
handleRef.current = undefined;
}
setMounted(true);
}, []);
useEffect(
() => () => {
if (handleRef.current !== undefined) {
cancel(handleRef.current);
handleRef.current = undefined;
}
},
[],
);
return { mounted, requestMount, mountNow };
};

View File

@@ -0,0 +1,101 @@
import { Box } from '@rocket.chat/fuselage';
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
import { VirtualizedScrollbars } from '@rocket.chat/ui-client';
import { useUserPreference, useUserId } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GroupedVirtuoso } from 'react-virtuoso';
import RoomListCollapser from './RoomListCollapser';
import RoomListRow from './RoomListRow';
import RoomListRowWrapper from './RoomListRowWrapper';
import RoomListWrapper from './RoomListWrapper';
import { useOpenedRoom } from '../../lib/RoomManager';
import { useMoveCategoryPosition } from '../categories/hooks/useMoveCategoryPosition';
import { useAvatarTemplate } from '../hooks/useAvatarTemplate';
import { SIDEBAR_DYNAMIC_GROUP_KEYS } from '../hooks/useCategoryList';
import { useCollapsedGroups } from '../hooks/useCollapsedGroups';
import { usePreventDefault } from '../hooks/usePreventDefault';
import { useRoomList } from '../hooks/useRoomList';
import { useShortcutOpenMenu } from '../hooks/useShortcutOpenMenu';
import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode';
const canMoveGroup = (groups: { key: string }[], index: number, direction: 'up' | 'down'): boolean => {
if (SIDEBAR_DYNAMIC_GROUP_KEYS.includes(groups[index].key)) return false;
if (direction === 'down') return index + 1 < groups.length;
return groups.slice(0, index).some((g) => !SIDEBAR_DYNAMIC_GROUP_KEYS.includes(g.key));
};
const RoomList = () => {
const { t } = useTranslation();
const userId = useUserId();
const isAnonymous = !userId;
const { collapsedGroups, handleClick, handleKeyDown } = useCollapsedGroups();
const { groups, groupsCount, totalCount } = useRoomList({ collapsedGroups });
const moveCategory = useMoveCategoryPosition();
const avatarTemplate = useAvatarTemplate();
const sideBarItemTemplate = useTemplateByViewMode();
const { ref } = useResizeObserver<HTMLElement>({ debounceDelay: 100 });
const openedRoom = useOpenedRoom() ?? '';
const sidebarViewMode = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode') || 'extended';
const extended = sidebarViewMode === 'extended';
const itemData = useMemo(
() => ({
extended,
t,
SidebarItemTemplate: sideBarItemTemplate,
AvatarTemplate: avatarTemplate,
openedRoom,
sidebarViewMode,
isAnonymous,
userId,
}),
[avatarTemplate, extended, isAnonymous, openedRoom, sideBarItemTemplate, sidebarViewMode, t, userId],
);
const allGroupKeys = useMemo(() => groups.map((group) => group.key), [groups]);
usePreventDefault(ref);
useShortcutOpenMenu(ref);
return (
<Box position='relative' overflow='hidden' height='full' ref={ref}>
<VirtualizedScrollbars>
<GroupedVirtuoso
groupCounts={groupsCount}
groupContent={(index) => {
const group = groups[index];
const onMoveUp = () => moveCategory(allGroupKeys, group.key, 'up');
const onMoveDown = () => moveCategory(allGroupKeys, group.key, 'down');
return (
<RoomListCollapser
group={group}
canMoveUp={canMoveGroup(groups, index, 'up')}
canMoveDown={canMoveGroup(groups, index, 'down')}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
onClick={() => handleClick(group.key)}
onKeyDown={(e) => handleKeyDown(e, group.key)}
/>
);
}}
{...(totalCount > 0 && {
itemContent: (index, groupIndex) => {
const group = groups[groupIndex];
const correctedIndex = index - groupsCount.slice(0, groupIndex).reduce((acc, count) => acc + count, 0);
const item = group.rooms[correctedIndex];
return item && <RoomListRow data={itemData} item={item} />;
},
})}
components={{ Item: RoomListRowWrapper, List: RoomListWrapper }}
/>
</VirtualizedScrollbars>
</Box>
);
};
export default RoomList;

View File

@@ -0,0 +1,72 @@
import { Badge, IconButton, SidebarCollapseGroup, SidebarCollapseGroupMenu } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { HTMLAttributes, KeyboardEvent, MouseEventHandler } from 'react';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../hooks/useHasLicenseModule';
import { usePreventPropagation } from '../../hooks/usePreventPropagation';
import { useDeferredMenuMount } from '../Item/useDeferredMenuMount';
import CategoryMenu from '../categories/CategoryMenu';
import type { SidebarRoomListGroup } from '../hooks/useRoomList';
import { useUnreadDisplay } from '../hooks/useUnreadDisplay';
type RoomListCollapserProps = {
group: SidebarRoomListGroup;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onClick: MouseEventHandler<HTMLElement>;
onKeyDown: (e: KeyboardEvent) => void;
} & Omit<HTMLAttributes<HTMLElement>, 'onClick' | 'onKeyDown'>;
const RoomListCollapser = ({ group, canMoveUp, canMoveDown, onMoveUp, onMoveDown, ...props }: RoomListCollapserProps) => {
const { t } = useTranslation();
const { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
const preventPropagation = usePreventPropagation();
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();
const { unreadTitle, unreadVariant, showUnread, unreadCount } = useUnreadDisplay(group.unreadInfo);
const title = group.translateTitle ? t(group.title as TranslationKey) : group.title;
return (
<SidebarCollapseGroup
title={title}
empty={group.empty}
expanded={!group.collapsed}
badge={
showUnread ? (
<Badge variant={unreadVariant} title={unreadTitle} aria-label={unreadTitle} role='status'>
{unreadCount.total}
</Badge>
) : undefined
}
onFocus={mountNow}
onPointerEnter={requestMount}
menu={
hasLicenseModule ? (
<SidebarCollapseGroupMenu onClick={preventPropagation}>
{menuVisibility ? (
<CategoryMenu
category={group.category}
groupKey={group.key}
showUnreads={group.showUnreads}
keepUnreadsOnTop={group.keepUnreadsOnTop}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
/>
) : (
<IconButton tabIndex={-1} aria-hidden mini icon='kebab' onPointerDown={mountNow} />
)}
</SidebarCollapseGroupMenu>
) : undefined
}
aria-label={group.collapsed ? t('Expand_group', { group: title }) : t('Collapse_group', { group: title })}
{...props}
/>
);
};
export default RoomListCollapser;

View File

@@ -0,0 +1,56 @@
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useVideoConfAcceptCall, useVideoConfRejectIncomingCall, useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf';
import type { TFunction } from 'i18next';
import { memo, useMemo } from 'react';
import SidebarItemTemplateWithData from './SidebarItemTemplateWithData';
import type { useAvatarTemplate } from '../hooks/useAvatarTemplate';
import type { useTemplateByViewMode } from '../hooks/useTemplateByViewMode';
export type RoomListRowProps = {
data: {
extended: boolean;
t: TFunction;
SidebarItemTemplate: ReturnType<typeof useTemplateByViewMode>;
AvatarTemplate: ReturnType<typeof useAvatarTemplate>;
openedRoom: string;
sidebarViewMode: 'extended' | 'condensed' | 'medium';
isAnonymous: boolean;
userId?: string;
};
item: SubscriptionWithRoom;
};
const RoomListRow = ({ data, item }: RoomListRowProps) => {
const { extended, t, SidebarItemTemplate, AvatarTemplate, openedRoom, sidebarViewMode, userId } = data;
const acceptCall = useVideoConfAcceptCall();
const rejectCall = useVideoConfRejectIncomingCall();
const incomingCalls = useVideoConfIncomingCalls();
const currentCall = incomingCalls.find((call) => call.rid === item.rid);
const videoConfActions = useMemo(
() =>
currentCall && {
acceptCall: (): void => acceptCall(currentCall.callId),
rejectCall: (): void => rejectCall(currentCall.callId),
},
[acceptCall, rejectCall, currentCall],
);
return (
<SidebarItemTemplateWithData
sidebarViewMode={sidebarViewMode}
selected={item.rid === openedRoom}
t={t}
room={item}
extended={extended}
SidebarItemTemplate={SidebarItemTemplate}
AvatarTemplate={AvatarTemplate}
videoConfActions={videoConfActions}
userId={userId}
/>
);
};
export default memo(RoomListRow);

View File

@@ -0,0 +1,11 @@
import { SidebarListItem } from '@rocket.chat/fuselage';
import type { ForwardedRef, HTMLAttributes } from 'react';
import { forwardRef } from 'react';
export type RoomListRoomWrapperProps = HTMLAttributes<HTMLDivElement>;
const RoomListRoomWrapper = forwardRef(function RoomListRoomWrapper(props: RoomListRoomWrapperProps, ref: ForwardedRef<HTMLDivElement>) {
return <SidebarListItem ref={ref} {...props} />;
});
export default RoomListRoomWrapper;

View File

@@ -0,0 +1,18 @@
import { useMergedRefs } from '@rocket.chat/fuselage-hooks';
import type { ForwardedRef, HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSidebarListNavigation } from './useSidebarListNavigation';
export type RoomListWrapperProps = HTMLAttributes<HTMLDivElement>;
const RoomListWrapper = forwardRef(function RoomListWrapper(props: RoomListWrapperProps, ref: ForwardedRef<HTMLDivElement>) {
const { t } = useTranslation();
const { sidebarListRef } = useSidebarListNavigation();
const mergedRefs = useMergedRefs(ref, sidebarListRef);
return <div role='list' aria-label={t('Channels')} ref={mergedRefs} {...props} />;
});
export default RoomListWrapper;

View File

@@ -0,0 +1,227 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { Icon, SidebarAction, SidebarActions, SidebarItemIcon } from '@rocket.chat/fuselage';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useLayout } from '@rocket.chat/ui-contexts';
import type { TFunction } from 'i18next';
import type { AllHTMLAttributes, ComponentType, ReactNode } from 'react';
import { memo, useMemo } from 'react';
import { RoomIcon } from '../../components/RoomIcon';
import { useUserStatusTooltip } from '../../hooks/useUserStatusTooltip';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { getSubscriptionDraft } from '../../lib/utils/getSubscriptionDraft';
import { getUidDirectMessage } from '../../lib/utils/getUidDirectMessage';
import { isIOsDevice } from '../../lib/utils/isIOsDevice';
import { getMessagePreview } from '../../lib/utils/normalizeMessagePreview/getMessagePreview';
import { useOmnichannelPriorities } from '../../views/omnichannel/hooks/useOmnichannelPriorities';
import RoomMenu from '../RoomMenu';
import SidebarItemBadges from '../badges/SidebarItemBadges';
import type { useAvatarTemplate } from '../hooks/useAvatarTemplate';
import { useUnreadDisplay } from '../hooks/useUnreadDisplay';
type RoomListRowProps = {
extended: boolean;
t: TFunction;
SidebarItemTemplate: ComponentType<
{
icon: ReactNode;
title: ReactNode;
avatar: ReactNode;
actions: ReactNode;
href: string;
time?: Date;
menu?: () => ReactNode;
menuOptions?: unknown;
subtitle?: ReactNode;
titleIcon?: ReactNode;
badges?: ReactNode;
threadUnread?: boolean;
unread?: boolean;
selected?: boolean;
is?: string;
indent?: boolean;
} & AllHTMLAttributes<HTMLElement>
>;
AvatarTemplate: ReturnType<typeof useAvatarTemplate>;
openedRoom?: string;
// sidebarViewMode: 'extended';
isAnonymous?: boolean;
userId?: string;
room: SubscriptionWithRoom;
id?: string;
/* @deprecated */
style?: AllHTMLAttributes<HTMLElement>['style'];
selected?: boolean;
sidebarViewMode?: unknown;
videoConfActions?: {
[action: string]: () => void;
};
};
const SidebarItemTemplateWithData = ({
room,
id,
selected,
style,
extended,
SidebarItemTemplate,
AvatarTemplate,
t,
isAnonymous,
videoConfActions,
userId,
}: RoomListRowProps) => {
const { sidebar } = useLayout();
const href = roomCoordinator.getRouteLink(room.t, room) || '';
const title = roomCoordinator.getRoomName(room.t, room) || '';
const dmUserId = getUidDirectMessage(room, userId);
const dmStatusTooltipHandlers = useUserStatusTooltip(dmUserId, title);
const { unreadTitle, showUnread, unreadCount, highlightUnread: highlighted } = useUnreadDisplay(room);
const { lastMessage, unread = 0, alert, rid, t: type, cl } = room;
const icon = (
<SidebarItemIcon
highlighted={highlighted}
icon={<RoomIcon room={room} placement='sidebar' size='x20' isIncomingCall={Boolean(videoConfActions)} />}
/>
);
const titleIcon = getSubscriptionDraft(room) ? (
<Icon name='pencil' size='x12' title={room.draft ? t('Unfinished_message') : t('Unfinished_thread_message')} />
) : undefined;
const actions = useMemo(
() =>
videoConfActions && (
<SidebarActions>
<SidebarAction onClick={videoConfActions.acceptCall} mini secondary success icon='phone' />
<SidebarAction onClick={videoConfActions.rejectCall} mini secondary danger icon='phone-off' />
</SidebarActions>
),
[videoConfActions],
);
const isQueued = isOmnichannelRoom(room) && room.status === 'queued';
const { enabled: isPriorityEnabled } = useOmnichannelPriorities();
const message = extended && getMessagePreview(room, lastMessage, t);
const subtitle = message ? <span className='message-body--unstyled' dangerouslySetInnerHTML={{ __html: message }} /> : null;
return (
<SidebarItemTemplate
is='a'
id={id}
indent
data-unread={highlighted}
unread={highlighted}
selected={selected}
aria-current={selected ? 'page' : undefined}
href={href}
onClick={(): void => {
if (!selected) sidebar.toggle();
}}
aria-label={showUnread ? t('__unreadTitle__from__roomTitle__', { unreadTitle, roomTitle: title }) : title}
title={title}
time={lastMessage?.ts}
subtitle={subtitle}
icon={icon}
titleIcon={titleIcon}
style={style}
badges={<SidebarItemBadges room={room} roomTitle={title} />}
avatar={AvatarTemplate && <AvatarTemplate {...room} />}
actions={actions}
menu={
!isIOsDevice && !isAnonymous && (!isQueued || (isQueued && isPriorityEnabled))
? () => (
<RoomMenu
alert={alert}
threadUnread={unreadCount.threads > 0}
rid={rid}
unread={!!unread}
roomOpen={selected}
type={type}
cl={cl}
name={title}
hideDefaultOptions={isQueued}
/>
)
: undefined
}
{...dmStatusTooltipHandlers}
/>
);
};
function safeDateNotEqualCheck(a: Date | string | undefined, b: Date | string | undefined): boolean {
if (!a || !b) {
return a !== b;
}
return new Date(a).toISOString() !== new Date(b).toISOString();
}
const keys: (keyof RoomListRowProps)[] = [
'id',
'style',
'extended',
'selected',
'SidebarItemTemplate',
'AvatarTemplate',
't',
'sidebarViewMode',
'videoConfActions',
];
export default memo(SidebarItemTemplateWithData, (prevProps, nextProps) => {
if (keys.some((key) => prevProps[key] !== nextProps[key])) {
return false;
}
if (prevProps.room === nextProps.room) {
return true;
}
if (prevProps.room._id !== nextProps.room._id) {
return false;
}
if (prevProps.room._updatedAt?.toISOString() !== nextProps.room._updatedAt?.toISOString()) {
return false;
}
if (safeDateNotEqualCheck(prevProps.room.lastMessage?._updatedAt, nextProps.room.lastMessage?._updatedAt)) {
return false;
}
if (prevProps.room.lastMessage?.msg !== nextProps.room.lastMessage?.msg) {
return false;
}
if (prevProps.room.alert !== nextProps.room.alert) {
return false;
}
if (prevProps.room.draft !== nextProps.room.draft) {
return false;
}
if (prevProps.room.threadDrafts !== nextProps.room.threadDrafts) {
return false;
}
if (isOmnichannelRoom(prevProps.room) && isOmnichannelRoom(nextProps.room) && prevProps.room?.v?.status !== nextProps.room?.v?.status) {
return false;
}
if (prevProps.room.teamMain !== nextProps.room.teamMain) {
return false;
}
if (
isOmnichannelRoom(prevProps.room) &&
isOmnichannelRoom(nextProps.room) &&
prevProps.room.priorityWeight !== nextProps.room.priorityWeight
) {
return false;
}
return true;
});

View File

@@ -0,0 +1 @@
export { default } from './RoomList';

View File

@@ -0,0 +1,103 @@
import { useFocusManager } from '@react-aria/focus';
import { useCallback } from 'react';
const isListItem = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-item');
const isCollapseGroup = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-collapse-group__bar-button');
const isListItemMenu = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-item__menu');
/**
* Custom hook to provide the sidebar navigation by keyboard.
* @returns ref - A ref to the message list DOM element.
*/
export const useSidebarListNavigation = () => {
const sidebarListFocusManager = useFocusManager();
const sidebarListRef = useCallback(
(node: HTMLElement | null) => {
let lastItemFocused: HTMLElement | null = null;
if (!node) {
return;
}
node.addEventListener('keydown', (e) => {
if (!e.target) {
return;
}
if (!isListItem(e.target) && !isCollapseGroup(e.target)) {
return;
}
if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
sidebarListFocusManager?.focusPrevious({
accept: (node) => !isListItem(node) && !isListItemMenu(node) && !isCollapseGroup(node),
});
} else if (isListItemMenu(e.target)) {
sidebarListFocusManager?.focusNext({
accept: (node) => !isListItem(node) && !isListItemMenu(node) && !isCollapseGroup(node),
});
} else {
sidebarListFocusManager?.focusNext({
accept: (node) => !isListItem(node) && !isCollapseGroup(node),
});
}
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
if (e.key === 'ArrowUp') {
sidebarListFocusManager?.focusPrevious({ accept: (node) => isListItem(node) || isCollapseGroup(node) });
}
if (e.key === 'ArrowDown') {
sidebarListFocusManager?.focusNext({ accept: (node) => isListItem(node) || isCollapseGroup(node) });
}
lastItemFocused = document.activeElement as HTMLElement;
}
});
node.addEventListener(
'blur',
(e) => {
if (
!(e.relatedTarget as HTMLElement)?.matches(':focus-visible') ||
!(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement)
) {
return;
}
if (!e.currentTarget.contains(e.relatedTarget) && !lastItemFocused) {
lastItemFocused = e.target as HTMLElement;
}
},
{ capture: true },
);
node.addEventListener(
'focus',
(e) => {
const triggeredByKeyboard = (e.target as HTMLElement)?.matches(':focus-visible');
if (!triggeredByKeyboard || !(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement)) {
return;
}
if (lastItemFocused && !e.currentTarget.contains(e.relatedTarget) && node.contains(e.target as HTMLElement)) {
lastItemFocused?.focus();
}
},
{ capture: true },
);
},
[sidebarListFocusManager],
);
return { sidebarListRef };
};

View File

@@ -0,0 +1,146 @@
import type { RoomType } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { QueryClient } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import RoomMenu from './RoomMenu';
import { createFakeLicenseInfo, createFakeSubscription } from '../../tests/mocks/data';
jest.mock('../../client/lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomDirectives: () => ({
getUiText: () => 'leaveWarning',
}),
},
}));
jest.mock('../../client/lib/LegacyRoomManager', () => ({
LegacyRoomManager: {
close: jest.fn(),
},
}));
jest.mock('./categories/hooks/useCategoryModals', () => ({
useCategoryModals: () => ({ openCreate: jest.fn(), openManage: jest.fn(), openDelete: jest.fn() }),
}));
const defaultProps = {
rid: 'roomId',
type: 'c' as RoomType,
hideDefaultOptions: false,
placement: 'right-start',
};
const buildBase = () =>
mockAppRoot()
.withTranslations('en', 'core', {
Hide: 'Hide',
Mark_unread: 'Mark Unread',
Favorite: 'Favorite',
Favorites: 'Favorites',
Move_to: 'Move to',
New_category: 'New category',
Leave_room: 'Leave',
Remove_from__categoryName__: 'Remove from {{categoryName}}',
})
.withSetting('Favorite_Rooms', true)
.withPermission('leave-c')
.withPermission('leave-p');
const renderOptions = {
wrapper: buildBase().build(),
};
const buildEnterpriseQueryClient = () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
client.setQueryData(['licenses', 'getLicenses', undefined], {
license: createFakeLicenseInfo({ hasValidLicense: true, activeModules: ['experimental-enterprise-features'] }),
});
return client;
};
const enterpriseRenderOptions = {
wrapper: buildBase().withJohnDoe().withQueryClient(buildEnterpriseQueryClient()).build(),
};
it('should display Hide, Mark Unread, Leave and Favorite toggle for regular rooms without enterprise', async () => {
render(<RoomMenu {...defaultProps} />, renderOptions);
await userEvent.click(screen.queryByRole('button') as HTMLElement);
expect(await screen.findByRole('menuitem', { name: 'Hide' })).toBeInTheDocument();
expect(await screen.findByRole('menuitem', { name: 'Mark Unread' })).toBeInTheDocument();
expect(await screen.findByRole('menuitem', { name: 'Leave' })).toBeInTheDocument();
expect(await screen.findByRole('menuitem', { name: 'Favorite' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Move to' })).not.toBeInTheDocument();
});
it('should display Hide, Mark Unread, Leave and Move to for regular rooms with enterprise', async () => {
render(<RoomMenu {...defaultProps} />, enterpriseRenderOptions);
await userEvent.click(screen.queryByRole('button') as HTMLElement);
expect(await screen.findByRole('menuitem', { name: 'Hide' })).toBeInTheDocument();
expect(await screen.findByRole('menuitem', { name: 'Mark Unread' })).toBeInTheDocument();
expect(await screen.findByRole('menuitem', { name: 'Leave' })).toBeInTheDocument();
expect(await screen.findByRole('menuitem', { name: 'Move to' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Favorite' })).not.toBeInTheDocument();
});
it('should reveal Favorites and New category inside the "Move to" submenu for enterprise rooms', async () => {
render(<RoomMenu {...defaultProps} />, enterpriseRenderOptions);
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();
});
const enterpriseFavoriteRenderOptions = {
wrapper: buildBase()
.withJohnDoe()
.withQueryClient(buildEnterpriseQueryClient())
.withSubscription(createFakeSubscription({ rid: 'roomId', f: true, t: 'c' }))
.build(),
};
const enterpriseCategoryRenderOptions = {
wrapper: buildBase()
.withJohnDoe()
.withQueryClient(buildEnterpriseQueryClient())
.withSubscription(createFakeSubscription({ rid: 'roomId', f: false, t: 'c', category: 'cat-design' }))
.withUserPreference('sidebarCategories', [{ _id: 'cat-design', name: 'Design', showUnreads: true }])
.build(),
};
it('shows "Remove from Favorites" in the Move to submenu when the room is in favorites', async () => {
render(<RoomMenu {...defaultProps} />, enterpriseFavoriteRenderOptions);
await userEvent.click(screen.queryByRole('button') as HTMLElement);
await userEvent.hover(await screen.findByRole('menuitem', { name: 'Move to' }));
expect(await screen.findByRole('menuitem', { name: 'Remove from Favorites' })).toBeInTheDocument();
});
it('shows "Remove from Design" in the Move to submenu when the room is in a custom category', async () => {
render(<RoomMenu {...defaultProps} />, enterpriseCategoryRenderOptions);
await userEvent.click(screen.queryByRole('button') as HTMLElement);
await userEvent.hover(await screen.findByRole('menuitem', { name: 'Move to' }));
expect(await screen.findByRole('menuitem', { name: 'Remove from Design' })).toBeInTheDocument();
});
it('should display only mark unread and favorite for omnichannel rooms', async () => {
render(<RoomMenu {...defaultProps} type='l' />, renderOptions);
const menu = screen.queryByRole('button');
await userEvent.click(menu as HTMLElement);
expect(await screen.findAllByRole('menuitem')).toHaveLength(2);
expect(screen.queryByRole('menuitem', { name: 'Hide' })).not.toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Leave' })).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,36 @@
import type { RoomType } from '@rocket.chat/core-typings';
import { GenericMenu } from '@rocket.chat/ui-client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import CategoryRoomMenu from './categories/CategoryRoomMenu';
import { useHasLicenseModule } from '../hooks/useHasLicenseModule';
import { useRoomMenuActions } from '../hooks/useRoomMenuActions';
export type RoomMenuProps = {
rid: string;
unread?: boolean;
threadUnread?: boolean;
alert?: boolean;
roomOpen?: boolean;
type: RoomType;
cl?: boolean;
name?: string;
hideDefaultOptions: boolean;
};
const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '', hideDefaultOptions = false }: RoomMenuProps) => {
const { t } = useTranslation();
const { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
const isUnread = alert || unread || threadUnread;
const sections = useRoomMenuActions({ rid, type, name, isUnread, cl, roomOpen, hideDefaultOptions });
if (hasLicenseModule && !hideDefaultOptions && type !== 'l') {
return <CategoryRoomMenu rid={rid} name={name} sections={sections} />;
}
return <GenericMenu detached title={t('Options')} mini aria-keyshortcuts='alt' sections={sections} />;
};
export default memo(RoomMenu);

View File

@@ -0,0 +1,113 @@
import type { ISetting } from '@rocket.chat/core-typings';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { UserContext, SettingsContext } from '@rocket.chat/ui-contexts';
import type { StoryObj, Meta } from '@storybook/react';
import type { ObjectId } from 'mongodb';
import type { ContextType } from 'react';
import Sidebar from './SidebarRegion';
export default {
component: Sidebar,
} satisfies Meta<typeof Sidebar>;
const settings: Record<string, ISetting> = {
UI_Use_Real_Name: {
_id: 'UI_Use_Real_Name',
blocked: false,
createdAt: new Date(),
env: true,
i18nLabel: 'Use real name',
packageValue: false,
sorter: 1,
ts: new Date(),
type: 'boolean',
value: true,
public: true,
_updatedAt: new Date(),
},
};
const settingContextValue: ContextType<typeof SettingsContext> = {
hasPrivateAccess: true,
querySetting: (_id) => [() => () => undefined, () => settings[_id]],
querySettings: () => [() => () => undefined, () => []],
dispatch: async () => undefined,
};
const userPreferences: Record<string, unknown> = {
sidebarViewMode: 'medium',
sidebarDisplayAvatar: true,
sidebarGroupByType: true,
sidebarShowFavorites: true,
sidebarShowUnread: true,
sidebarSortby: 'activity',
};
const subscriptions: SubscriptionWithRoom[] = [
{
_id: '3Bysd8GrmkWBdS9RT',
open: true,
alert: true,
unread: 0,
userMentions: 0,
groupMentions: 0,
ts: new Date(),
rid: 'GENERAL',
name: 'general',
t: 'c',
u: {
_id: '5yLFEABCSoqR5vozz',
username: 'yyy',
name: 'yyy',
},
_updatedAt: new Date(),
ls: new Date(),
lr: new Date(),
tunread: [],
lowerCaseName: 'general',
lowerCaseFName: 'general',
estimatedWaitingTimeQueue: 0,
livechatData: undefined,
priorityWeight: 3,
responseBy: undefined,
usersCount: 0,
waitingResponse: undefined,
},
];
const userContextValue: ContextType<typeof UserContext> = {
userId: 'john.doe',
user: {
_id: 'john.doe',
username: 'john.doe',
name: 'John Doe',
createdAt: new Date(),
active: true,
_updatedAt: new Date(),
roles: ['admin'],
type: 'user',
},
queryPreference: <T,>(pref: string | ObjectId, defaultValue: T) => [
() => () => undefined,
() => (typeof pref === 'string' ? (userPreferences[pref] as T) : defaultValue),
],
querySubscriptions: () => [() => () => undefined, () => subscriptions],
querySubscription: () => [() => () => undefined, () => undefined],
queryRoom: () => [() => () => undefined, () => undefined],
logout: () => Promise.resolve(),
onLogout: () => () => undefined,
};
export const SidebarStory: StoryObj<typeof Sidebar> = {
render: () => <Sidebar />,
decorators: [
(fn) => (
<SettingsContext.Provider value={settingContextValue}>
<UserContext.Provider value={userContextValue}>{fn()}</UserContext.Provider>
</SettingsContext.Provider>
),
],
};

View File

@@ -0,0 +1,36 @@
import { Sidebar as FuselageSidebar } from '@rocket.chat/fuselage';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import SidebarRoomList from './RoomList';
import SidebarFooter from './footer';
import BannerSection from './sections/BannerSection';
import NowPlayingSection from './sections/NowPlayingSection';
const Sidebar = () => {
const { t } = useTranslation();
const sidebarViewMode = useUserPreference('sidebarViewMode');
const sidebarHideAvatar = !useUserPreference('sidebarDisplayAvatar');
return (
<FuselageSidebar
aria-label={t('Sidebar')}
className={[
'rcx-sidebar--main',
'sidebar-region-item',
`rcx-sidebar--${sidebarViewMode}`,
sidebarHideAvatar && 'rcx-sidebar--hide-avatar',
]
.filter(Boolean)
.join(' ')}
>
<BannerSection />
<SidebarRoomList />
<NowPlayingSection />
<SidebarFooter />
</FuselageSidebar>
);
};
export default memo(Sidebar);

View File

@@ -0,0 +1,112 @@
import { FocusScope } from '@react-aria/focus';
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useLayout } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import Sidebar from './Sidebar';
const SidebarRegion = () => {
const { sidebar } = useLayout();
const sidebarMobileClass = css`
position: absolute;
user-select: none;
transform: translate3d(-100%, 0, 0);
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-user-drag: none;
touch-action: pan-y;
will-change: transform;
.rtl & {
transform: translate3d(200%, 0, 0);
&.opened {
box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 15px 1px;
transform: translate3d(0px, 0px, 0px);
}
}
`;
const sideBarStyle = css`
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
height: 100%;
user-select: none;
transition: transform 0.3s;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
> .sidebar-region-item:not(:last-child) {
visibility: hidden;
}
&.opened {
box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 15px 1px;
transform: translate3d(0px, 0px, 0px);
}
/* // 768px to 1599px
// using em unit base 16
@media (max-width: 48em) {
width: 80%;
min-width: 80%;
} */
// 1600px to 1919px
// using em unit base 16
@media (min-width: 100em) {
width: var(--sidebar-md-width);
min-width: var(--sidebar-md-width);
}
// 1920px and up
// using em unit base 16
@media (min-width: 120em) {
width: var(--sidebar-lg-width);
min-width: var(--sidebar-lg-width);
}
`;
const sidebarWrapStyle = css`
position: absolute;
z-index: 1;
top: 0;
left: 0;
height: 100%;
user-select: none;
transition: opacity 0.3s;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
touch-action: pan-y;
-webkit-user-drag: none;
&.opened {
width: 100%;
background-color: rgb(0, 0, 0);
opacity: 0.8;
}
`;
return (
<FocusScope>
<Box
id='sidebar-region'
className={[
'rcx-sidebar',
!sidebar.isCollapsed && sidebar.shouldToggle && 'opened',
sideBarStyle,
sidebar.shouldToggle && sidebarMobileClass,
].filter(Boolean)}
>
<Sidebar />
</Box>
{sidebar.shouldToggle && (
<Box className={[sidebarWrapStyle, !sidebar.isCollapsed && 'opened'].filter(Boolean)} onClick={() => sidebar.toggle()} />
)}
</FocusScope>
);
};
export default memo(SidebarRegion);

View File

@@ -0,0 +1,78 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import SidebarItemBadges from './SidebarItemBadges';
import { createFakeSubscription } from '../../../tests/mocks/data';
jest.mock('../../views/omnichannel/components/OmnichannelBadges', () => ({
__esModule: true,
default: () => <i role='status' aria-label='OmnichannelBadges' />,
}));
describe('SidebarItemBadges', () => {
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Invited__date__: 'Invited {{date}}',
mentions_counter_one: '{{count}} mention',
mentions_counter_other: '{{count}} mentions',
__unreadTitle__from__roomTitle__: '{{unreadTitle}} from {{roomTitle}}',
})
.build();
afterEach(() => {
jest.resetAllMocks();
});
it('should render UnreadBadge when there are unread messages', () => {
render(<SidebarItemBadges room={createFakeSubscription({ unread: 1, userMentions: 1, groupMentions: 0 })} roomTitle='Test Room' />, {
wrapper: appRoot,
});
expect(screen.getByRole('status', { name: '1 mention from Test Room' })).toBeInTheDocument();
});
it('should not render UnreadBadge when there are no unread messages', () => {
render(<SidebarItemBadges room={createFakeSubscription({ unread: 0, userMentions: 0, groupMentions: 0 })} roomTitle='Test Room' />, {
wrapper: appRoot,
});
expect(screen.queryByRole('status', { name: 'Test Room' })).not.toBeInTheDocument();
});
it('should render OmnichannelBadges when the room is an omnichannel room', () => {
render(<SidebarItemBadges room={createFakeSubscription({ t: 'l' })} />, { wrapper: appRoot });
expect(screen.getByRole('status', { name: 'OmnichannelBadges' })).toBeInTheDocument();
});
it('should not render OmnichannelBadges when the room is not an omnichannel room', () => {
render(<SidebarItemBadges room={createFakeSubscription({ t: 'p' })} />, { wrapper: appRoot });
expect(screen.queryByRole('status', { name: 'OmnichannelBadges' })).not.toBeInTheDocument();
});
it('should render InvitationBadge when subscription has status INVITED', () => {
render(
<SidebarItemBadges
room={createFakeSubscription({
status: 'INVITED',
inviter: { name: 'Rocket Cat', username: 'rocket.cat', _id: 'rocket.cat' },
ts: new Date('2025-01-01T00:00:00.000Z'),
})}
/>,
{
wrapper: appRoot,
},
);
expect(screen.getByRole('status', { name: 'Invited January 1st, 2025' })).toBeInTheDocument();
});
it('should not render InvitationBadge when subscription does not have status INVITED', () => {
render(<SidebarItemBadges room={createFakeSubscription()} />, {
wrapper: appRoot,
});
expect(screen.queryByRole('status', { name: /Invited/ })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,26 @@
import { isInviteSubscription, isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import UnreadBadge from './UnreadBadge';
import InvitationBadge from '../../components/InvitationBadge';
import OmnichannelBadges from '../../views/omnichannel/components/OmnichannelBadges';
import { useUnreadDisplay } from '../hooks/useUnreadDisplay';
export type SidebarItemBadgesProps = {
room: SubscriptionWithRoom;
roomTitle?: string;
};
const SidebarItemBadges = ({ room, roomTitle }: SidebarItemBadgesProps) => {
const { unreadCount, unreadTitle, unreadVariant, showUnread } = useUnreadDisplay(room);
return (
<>
{showUnread && <UnreadBadge title={unreadTitle} roomTitle={roomTitle} variant={unreadVariant} total={unreadCount.total} />}
{isOmnichannelRoom(room) && <OmnichannelBadges room={room} />}
{isInviteSubscription(room) && <InvitationBadge marginBlockStart={2} invitationDate={room.ts} />}
</>
);
};
export default SidebarItemBadges;

View File

@@ -0,0 +1,26 @@
import { SidebarItemBadge } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type UnreadBadgeProps = {
title: string;
roomTitle?: string;
variant: 'primary' | 'warning' | 'danger' | 'secondary';
total: number;
};
const UnreadBadge = ({ title, variant, total, roomTitle }: UnreadBadgeProps) => {
const { t } = useTranslation();
return (
<SidebarItemBadge
variant={variant}
title={title}
role='status'
aria-label={t('__unreadTitle__from__roomTitle__', { unreadTitle: title, roomTitle })}
>
<span aria-hidden>{total}</span>
</SidebarItemBadge>
);
};
export default UnreadBadge;

View File

@@ -0,0 +1,185 @@
import type { ISidebarCategory } from '@rocket.chat/core-typings';
import { Menu, MenuItem, MenuItemContent, MenuItemIcon, MenuSection, MenuSubmenuTrigger, ToggleSwitch } from '@rocket.chat/fuselage';
import { useToggle } from '@rocket.chat/fuselage-hooks';
import { GenericMenuItem } from '@rocket.chat/ui-client';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useCategoryModals } from './hooks/useCategoryModals';
import { useMoveRoomCategory } from './hooks/useMoveRoomCategory';
import { useToggleUnreads } from './hooks/useToggleUnreads';
import { useCreateNewItems } from '../../navbar/NavBarPagesGroup/hooks/useCreateNewItems';
type CategoryMenuProps = {
category?: ISidebarCategory;
groupKey: string;
showUnreads: boolean;
keepUnreadsOnTop: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
};
const CategoryMenu = ({
category,
groupKey,
showUnreads,
keepUnreadsOnTop,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
}: CategoryMenuProps) => {
const { t } = useTranslation();
const [isOpen, toggleOpen] = useToggle(false);
const close = () => toggleOpen(false);
const { openManage, openDelete } = useCategoryModals();
const moveRoomCategory = useMoveRoomCategory();
const { toggleShowUnreads, toggleKeepUnreadsOnTop } = useToggleUnreads();
const sidebarShowUnread = useUserPreference<boolean>('sidebarShowUnread', false);
const disableAlwaysDisplay = Boolean(sidebarShowUnread) && groupKey !== 'Unread';
const onCreateSuccess = async (rid: string, name?: string) => {
if (!category) {
return;
}
await moveRoomCategory.mutateAsync({ room: { rid, name, isFavorite: false }, target: category._id, silent: true });
};
const rawCreateItems = useCreateNewItems({ onCreateSuccess });
const createItems = category ? rawCreateItems : [];
const handleToggleShowUnreads = async () => toggleShowUnreads(category?._id ?? groupKey);
const handleToggleKeepUnreadsOnTop = async () => toggleKeepUnreadsOnTop(category?._id ?? groupKey);
const orderItems: GenericMenuItemProps[] = [
{
id: 'move-up',
icon: 'arrow-up',
content: t('Move_up'),
disabled: !canMoveUp,
onClick: () => {
close();
onMoveUp();
},
},
{
id: 'move-down',
icon: 'arrow-down',
content: t('Move_down'),
disabled: !canMoveDown,
onClick: () => {
close();
onMoveDown();
},
},
];
const manageItems: GenericMenuItemProps[] = category
? [
{
id: 'manage',
icon: 'cog',
content: t('Manage'),
onClick: () => {
close();
openManage(category);
},
},
{
id: 'delete',
icon: 'trash',
content: t('Delete'),
onClick: () => {
close();
openDelete(category);
},
},
]
: [];
const unreadItems: GenericMenuItemProps[] = [
{
id: 'show-unreads',
icon: 'flag',
content: t('Always_display'),
onClick: handleToggleShowUnreads,
addon: <ToggleSwitch disabled={disableAlwaysDisplay} checked={!disableAlwaysDisplay && showUnreads} onChange={() => undefined} />,
disabled: disableAlwaysDisplay,
},
{
id: 'keep-unreads-on-top',
icon: 'sort-amount-down',
content: t('Keep_on_top'),
onClick: handleToggleKeepUnreadsOnTop,
addon: <ToggleSwitch disabled={sidebarShowUnread} checked={!sidebarShowUnread && keepUnreadsOnTop} onChange={() => undefined} />,
disabled: sidebarShowUnread,
},
];
const allItems = [...orderItems, ...(category ? manageItems : []), ...createItems, ...unreadItems];
const disabledKeys = allItems.filter(({ disabled }) => disabled).map(({ id }) => id);
const handleAction = (key: string | number) => {
const item = allItems.find((item) => item.id === String(key));
item?.onClick?.();
};
return (
<Menu
icon='kebab'
title={t('Options')}
mini
selectionMode='multiple'
isOpen={isOpen}
onOpenChange={toggleOpen}
{...(disabledKeys.length ? { disabledKeys } : {})}
onAction={handleAction}
>
{[
<MenuSection key='main' aria-label={t('Options')}>
<>
{orderItems.map((item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
))}
{category && createItems.length > 0 && (
<MenuSubmenuTrigger key='create-new' textValue={t('Create_new')}>
<MenuItem aria-label={t('Create_new')}>
<MenuItemIcon name='plus' />
<MenuItemContent>{t('Create_new')}</MenuItemContent>
</MenuItem>
<MenuSection items={createItems}>
{(item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
)}
</MenuSection>
</MenuSubmenuTrigger>
)}
{category &&
manageItems.map((item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
))}
</>
</MenuSection>,
<MenuSection key='unreads' title={t('Unreads')} items={unreadItems}>
{(item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
)}
</MenuSection>,
]}
</Menu>
);
};
export default CategoryMenu;

View File

@@ -0,0 +1,74 @@
import { Menu, MenuItem, MenuSection, MenuSubmenuTrigger } from '@rocket.chat/fuselage';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { GenericMenuItem, useHandleMenuAction } from '@rocket.chat/ui-client';
import { useUserSubscription } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCategoryMenuItems } from './hooks/useCategoryMenuItems';
type Section = { title: string; items: GenericMenuItemProps[] };
type CategoryRoomMenuProps = {
rid: string;
name?: string;
sections: Section[];
};
const CategoryRoomMenu = ({ rid, name = '', sections }: CategoryRoomMenuProps) => {
const { t } = useTranslation();
const subscription = useUserSubscription(rid);
const isFavorite = Boolean(subscription?.f);
const { targetItems, utilItems, allCategoryItems } = useCategoryMenuItems({ rid, name, isFavorite, categoryId: subscription?.category });
// Strip toggleFavorite — the category submenu takes over the favorites action.
const actionSections = sections
.map((section) => ({ ...section, items: section.items.filter((item) => item.id !== 'toggleFavorite') }))
.filter((section) => section.items.length > 0);
const allItems = [...actionSections.flatMap((s) => s.items), ...allCategoryItems];
const disabledKeys = allItems.filter(({ disabled }) => disabled).map(({ id }) => id);
const handleAction = useHandleMenuAction(allItems);
return (
<Menu detached title={t('Options')} mini icon='kebab' aria-keyshortcuts='alt' onAction={handleAction} disabledKeys={disabledKeys}>
{[
...actionSections.map(({ title, items }, index) => (
<MenuSection key={title || String(index)} aria-label={title || t('Options')} title={title || undefined} items={items}>
{(item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
)}
</MenuSection>
)),
<MenuSection key='category' title={t('Category')}>
<MenuSubmenuTrigger key='moveTo' textValue={t('Move_to')}>
<MenuItem aria-label={t('Move_to')}>
<GenericMenuItem icon='folder' content={t('Move_to')} id='moveTo' />
</MenuItem>
<MenuSection items={targetItems}>
{(item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
)}
</MenuSection>
{utilItems.length > 0 && (
<MenuSection items={utilItems}>
{(item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
)}
</MenuSection>
)}
</MenuSubmenuTrigger>
</MenuSection>,
]}
</Menu>
);
};
export default memo(CategoryRoomMenu);

View File

@@ -0,0 +1,78 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import CreateCategoryModal from './CreateCategoryModal';
import { useCreateCustomCategory } from './hooks/useCreateCustomCategory';
import { useValidateCategoryName } from './hooks/useValidateCategoryName';
jest.mock('./hooks/useValidateCategoryName', () => ({
useValidateCategoryName: jest.fn(),
MAX_CATEGORY_NAME_LENGTH: 30,
}));
jest.mock('./hooks/useCreateCustomCategory', () => ({
useCreateCustomCategory: jest.fn(),
}));
jest.mock('../../components/UserAndRoomAutoCompleteMultiple', () => () => null);
const mockedUseValidateCategoryName = jest.mocked(useValidateCategoryName);
const mockedUseCreateCustomCategory = jest.mocked(useCreateCustomCategory);
const wrapper = mockAppRoot()
.withTranslations('en', 'core', {
Create_category: 'Create category',
Name: 'Name',
Rooms: 'Rooms',
Create: 'Create',
Cancel: 'Cancel',
You_can_add_rooms_after: 'You can add rooms after',
Categories_are_private_custom_groupings_of_rooms: 'Categories are private custom groupings of rooms',
Please_enter_a_category_name: 'Please enter a category name',
A_category_with_this_name_already_exists: 'A category with this name already exists',
})
.build();
const mutateAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
mutateAsync.mockClear();
mockedUseValidateCategoryName.mockReturnValue(() => undefined);
mockedUseCreateCustomCategory.mockReturnValue({ mutateAsync, isPending: false } as any);
});
it('shows an error when submitting with an empty name', async () => {
mockedUseValidateCategoryName.mockReturnValue((name) => (!name.trim() ? 'Please enter a category name' : undefined));
render(<CreateCategoryModal onClose={jest.fn()} />, { wrapper });
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(await screen.findByText('Please enter a category name')).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Create category' })).toBeInTheDocument();
});
it('shows an error when submitting a duplicate name', async () => {
mockedUseValidateCategoryName.mockReturnValue((name) =>
name.trim().toLowerCase() === 'design' ? 'A category with this name already exists' : undefined,
);
render(<CreateCategoryModal onClose={jest.fn()} />, { wrapper });
await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'DESIGN');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(await screen.findByText('A category with this name already exists')).toBeInTheDocument();
});
it('calls mutateAsync on valid submission and wires onClose as settleCallback', async () => {
const onClose = jest.fn();
render(<CreateCategoryModal onClose={onClose} />, { wrapper });
await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'Design');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(mockedUseCreateCustomCategory).toHaveBeenCalledWith(expect.objectContaining({ settleCallback: onClose }));
expect(mutateAsync).toHaveBeenCalledWith({ name: 'Design', roomIds: [], movedRoom: undefined });
});

View File

@@ -0,0 +1,90 @@
import { Box } from '@rocket.chat/fuselage';
import { Field, FieldError, FieldGroup, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage-forms';
import { GenericModal } from '@rocket.chat/ui-client';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useCreateCustomCategory } from './hooks/useCreateCustomCategory';
import type { MovableRoom } from './hooks/useUserSidebarCategories';
import { useValidateCategoryName } from './hooks/useValidateCategoryName';
import UserAndRoomAutoCompleteMultiple from '../../components/UserAndRoomAutoCompleteMultiple';
type CreateCategoryModalProps = {
room?: MovableRoom;
onClose: () => void;
};
const CreateCategoryModal = ({ room, onClose }: CreateCategoryModalProps) => {
const { t } = useTranslation();
const validateName = useValidateCategoryName();
const createCategoryMutation = useCreateCustomCategory({ settleCallback: onClose });
const {
handleSubmit,
control,
setFocus,
formState: { errors },
} = useForm({
defaultValues: {
name: '',
rooms: room ? [room.rid] : [],
},
});
useEffect(() => {
setFocus('name');
}, [setFocus]);
const handleConfirm = async ({ name, rooms }: { name: string; rooms: string[] }) =>
createCategoryMutation.mutateAsync({ name, roomIds: rooms, movedRoom: room });
return (
<GenericModal
title={t('Create_category')}
variant='warning'
icon={null}
confirmText={room ? t('Create_and_move') : t('Create')}
confirmLoading={createCategoryMutation.isPending}
onCancel={onClose}
annotation={room ? undefined : t('You_can_add_rooms_after')}
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleConfirm)} {...props} />}
>
<Box marginBlockEnd={16}>
{room ? t('Move__roomName__to', { roomName: room.name }) : t('Categories_are_private_custom_groupings_of_rooms')}
</Box>
<FieldGroup>
<Field>
<FieldLabel required>{t('Name')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='name'
rules={{
validate: (name: string) => validateName(name),
}}
render={({ field }) => (
<TextInput autoComplete='off' error={errors.name?.message} aria-invalid={errors.name ? 'true' : 'false'} {...field} />
)}
/>
</FieldRow>
{errors.name && <FieldError>{errors.name.message}</FieldError>}
</Field>
<Field>
<FieldLabel>{t('Rooms')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='rooms'
render={({ field: { value, onChange } }) => (
<UserAndRoomAutoCompleteMultiple value={value} onChange={onChange} excludeTypes={['l']} allowReadOnly />
)}
/>
</FieldRow>
</Field>
</FieldGroup>
</GenericModal>
);
};
export default CreateCategoryModal;

View File

@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import GenericUpsellModal from '../../components/GenericUpsellModal';
import { getURL } from '../../lib/getURL';
export type CustomCategoryUpsellModalProps = {
onClose: () => void;
onConfirm: () => void;
};
const CustomCategoryUpsellModal = ({ onClose, onConfirm }: CustomCategoryUpsellModalProps) => {
const { t } = useTranslation();
return (
<GenericUpsellModal
tagline={t('Premium_capability')}
title={t('Custom_categories')}
subtitle={t('Custom_categories_subtitle')}
description={t('Custom_categories_description')}
img={getURL('images/custom-categories.png')}
onClose={onClose}
onConfirm={onConfirm}
onCancel={onClose}
imgHeight={256}
/>
);
};
export default CustomCategoryUpsellModal;

View File

@@ -0,0 +1,36 @@
import type { ISidebarCategory } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useCategoryRoomIds } from './hooks/useCategoryRoomIds';
import { useDeleteCategory } from './hooks/useDeleteCategory';
type DeleteCategoryModalProps = {
category: ISidebarCategory;
onClose: () => void;
};
const DeleteCategoryModal = ({ category, onClose }: DeleteCategoryModalProps) => {
const { t } = useTranslation();
const deleteCategoryMutation = useDeleteCategory({ categoryName: category.name, settleCallback: onClose });
const roomIds = useCategoryRoomIds(category._id);
const handleConfirm = () => deleteCategoryMutation.mutateAsync({ categoryId: category._id, roomIds });
return (
<GenericModal
variant='danger'
title={t('Delete_category')}
confirmText={t('Delete')}
confirmLoading={deleteCategoryMutation.isPending}
onConfirm={handleConfirm}
onCancel={onClose}
>
<Box marginBlockEnd={16}>{t('Anything_you_added_to__name__will_move_back_to_default', { name: category.name })}</Box>
<Box>{t('No_content_access_or_security_setting_will_be_lost_or_changed')}</Box>
</GenericModal>
);
};
export default DeleteCategoryModal;

View File

@@ -0,0 +1,112 @@
import type { ISidebarCategory } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { Field, FieldError, FieldGroup, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage-forms';
import { GenericModal } from '@rocket.chat/ui-client';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useEffect, useRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useCategoryRoomIds } from './hooks/useCategoryRoomIds';
import { useUpdateCategory } from './hooks/useUpdateCategory';
import { useValidateCategoryName } from './hooks/useValidateCategoryName';
import UserAndRoomAutoCompleteMultiple from '../../components/UserAndRoomAutoCompleteMultiple';
type ManageCategoryModalProps = {
category: ISidebarCategory;
onClose: () => void;
};
const ManageCategoryModal = ({ category, onClose }: ManageCategoryModalProps) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const updateCategory = useUpdateCategory();
const validateName = useValidateCategoryName();
const initialRoomsRef = useRef(useCategoryRoomIds(category._id));
const {
handleSubmit,
control,
setFocus,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
name: category.name,
rooms: initialRoomsRef.current,
},
});
useEffect(() => {
setFocus('name');
}, [setFocus]);
const handleConfirm = async ({ name, rooms }: { name: string; rooms: string[] }) => {
const trimmed = name.trim();
const nameUnchanged = trimmed === category.name.trim();
const initialSet = new Set(initialRoomsRef.current);
const newSet = new Set(rooms);
const addedRoomIds = rooms.filter((rid) => !initialSet.has(rid));
const removedRoomIds = initialRoomsRef.current.filter((rid) => !newSet.has(rid));
const roomsUnchanged = addedRoomIds.length === 0 && removedRoomIds.length === 0;
if (nameUnchanged && roomsUnchanged) {
onClose();
return;
}
try {
await updateCategory(category._id, trimmed || category.name, addedRoomIds, removedRoomIds);
dispatchToastMessage({ type: 'success', message: t('Category_saved') });
onClose();
} catch (e) {
dispatchToastMessage({ type: 'error', message: e });
}
};
return (
<GenericModal
title={t('Manage_category')}
variant='warning'
icon={null}
confirmText={t('Save')}
confirmLoading={isSubmitting}
onCancel={onClose}
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleConfirm)} {...props} />}
>
<FieldGroup>
<Field>
<FieldLabel required>{t('Name')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='name'
rules={{
validate: (name: string) => validateName(name, category._id),
}}
render={({ field }) => (
<TextInput autoComplete='off' error={errors.name?.message} aria-invalid={errors.name ? 'true' : 'false'} {...field} />
)}
/>
</FieldRow>
{errors.name && <FieldError>{errors.name.message}</FieldError>}
</Field>
<Field>
<FieldLabel>{t('Rooms')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='rooms'
render={({ field: { value, onChange } }) => (
<UserAndRoomAutoCompleteMultiple value={value} onChange={onChange} excludeTypes={['l']} allowReadOnly />
)}
/>
</FieldRow>
</Field>
</FieldGroup>
</GenericModal>
);
};
export default ManageCategoryModal;

View File

@@ -0,0 +1,38 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useRoomCategoryItems } from './useRoomCategoryItems';
type UseCategoryMenuItemsParams = {
rid: string;
name?: string;
isFavorite: boolean;
categoryId?: string;
};
type UseCategoryMenuItemsResult = {
/** Category targets — every destination except "New category". */
targetItems: GenericMenuItemProps[];
/** Utility actions: "New category" (if present) + "Remove from category" (if applicable). */
utilItems: GenericMenuItemProps[];
/** All category items combined, for building disabled-keys and action maps. */
allCategoryItems: GenericMenuItemProps[];
};
export const useCategoryMenuItems = ({
rid,
name = '',
isFavorite,
categoryId,
}: UseCategoryMenuItemsParams): UseCategoryMenuItemsResult => {
const { moveToItems, removeItem } = useRoomCategoryItems({ rid, name, isFavorite, categoryId });
const targetItems = moveToItems.filter((item) => item.id !== 'newCategory');
const newCategoryItem = moveToItems.find((item) => item.id === 'newCategory');
const utilItems = [...(newCategoryItem ? [newCategoryItem] : []), ...(removeItem ? [removeItem] : [])];
return {
targetItems,
utilItems,
allCategoryItems: [...moveToItems, ...(removeItem ? [removeItem] : [])],
};
};

View File

@@ -0,0 +1,37 @@
import type { ISidebarCategory } from '@rocket.chat/core-typings';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import type { MovableRoom } from './useUserSidebarCategories';
import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import CreateCategoryModal from '../CreateCategoryModal';
import CustomCategoryUpsellModal from '../CustomCategoryUpsellModal';
import DeleteCategoryModal from '../DeleteCategoryModal';
import ManageCategoryModal from '../ManageCategoryModal';
export const useCategoryModals = () => {
const setModal = useSetModal();
const { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasLicenseModule);
return useMemo(() => {
const onClose = () => setModal(null);
if (shouldShowUpsell) {
const handleOpenUpsellModal = () => setModal(<CustomCategoryUpsellModal onClose={onClose} onConfirm={handleManageSubscription} />);
return {
openCreate: handleOpenUpsellModal,
openManage: handleOpenUpsellModal,
openDelete: handleOpenUpsellModal,
};
}
return {
openCreate: (room?: MovableRoom) => setModal(<CreateCategoryModal room={room} onClose={onClose} />),
openManage: (category: ISidebarCategory) => setModal(<ManageCategoryModal category={category} onClose={onClose} />),
openDelete: (category: ISidebarCategory) => setModal(<DeleteCategoryModal category={category} onClose={onClose} />),
};
}, [handleManageSubscription, setModal, shouldShowUpsell]);
};

View File

@@ -0,0 +1,9 @@
import { useUserSubscriptions } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
export const useCategoryRoomIds = (categoryId: string): string[] => {
const query = useMemo(() => ({ open: { $ne: false }, category: categoryId }), [categoryId]);
const subscriptions = useUserSubscriptions(query);
return useMemo(() => subscriptions.map((sub) => sub.rid), [subscriptions]);
};

View File

@@ -0,0 +1,52 @@
import type { ISidebarCategory } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { usePersistCategoriesMutation } from './usePersistCategoriesMutation';
import { useSetCategory } from './useSetCategory';
import { useUserSidebarCategories, type MovableRoom } from './useUserSidebarCategories';
import { withDynamicFirst } from '../../hooks/useCategoryList';
import { useSidebarSectionsOrder } from '../../hooks/useSidebarSectionsOrder';
export const useCreateCustomCategory = ({ settleCallback }: { settleCallback?: () => void } = {}) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const { rawCategories } = useUserSidebarCategories();
const sidebarSectionsOrder = useSidebarSectionsOrder();
const setCategory = useSetCategory();
const { mutateAsync: persistCategories } = usePersistCategoriesMutation();
return useMutation({
mutationFn: async ({ name, roomIds = [], movedRoom }: { name: string; roomIds?: string[]; movedRoom?: MovableRoom }) => {
const category: ISidebarCategory = { _id: Random.id(), name: name.trim(), showUnreads: false };
const entryMap = new Map(rawCategories.map((e) => [e._id, e]));
entryMap.set(category._id, category);
const finalIds = withDynamicFirst([category._id, ...rawCategories.map((e) => e._id)], sidebarSectionsOrder);
await persistCategories(finalIds.map((k) => entryMap.get(k) ?? { _id: k, name: k, default: true }));
const allRoomIds = movedRoom ? [movedRoom.rid, ...roomIds.filter((id) => id !== movedRoom.rid)] : roomIds;
if (allRoomIds.length > 0) {
await setCategory(allRoomIds, category._id);
}
return { category, movedRoom };
},
onSuccess: ({ category, movedRoom }) => {
if (movedRoom?.name) {
dispatchToastMessage({
type: 'success',
message: t('__roomName__moved_to__categoryName__', { roomName: movedRoom.name, categoryName: category.name }),
});
} else {
dispatchToastMessage({ type: 'success', message: t('Category_created') });
}
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: () => {
settleCallback?.();
},
});
};

View File

@@ -0,0 +1,37 @@
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { usePersistCategoriesMutation } from './usePersistCategoriesMutation';
import { useSetCategory } from './useSetCategory';
import { useUserSidebarCategories } from './useUserSidebarCategories';
export const useDeleteCategory = ({ categoryName, settleCallback }: { categoryName: string; settleCallback?: () => void }) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const { rawCategories } = useUserSidebarCategories();
const setCategory = useSetCategory();
const persistMutation = usePersistCategoriesMutation();
return useMutation({
mutationFn: async ({ categoryId, roomIds }: { categoryId: string; roomIds: string[] }) => {
await persistMutation.mutateAsync(rawCategories.filter((entry) => entry._id !== categoryId));
if (roomIds.length > 0) {
await setCategory(roomIds, null);
}
},
onError: (error) => {
dispatchToastMessage({
type: 'error',
message: error,
});
},
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Category__name__deleted', { name: categoryName }) });
},
onSettled: () => {
settleCallback?.();
},
});
};

View File

@@ -0,0 +1,114 @@
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { renderHook, act } from '@testing-library/react';
import { useMoveCategoryPosition } from './useMoveCategoryPosition';
import { usePersistCategoriesMutation } from './usePersistCategoriesMutation';
import { useUserSidebarCategories } from './useUserSidebarCategories';
import { SIDEBAR_DYNAMIC_GROUP_KEYS } from '../../hooks/useCategoryList';
jest.mock('@rocket.chat/ui-contexts', () => ({
useUserPreference: jest.fn(),
}));
jest.mock('./usePersistCategoriesMutation', () => ({
usePersistCategoriesMutation: jest.fn(),
}));
jest.mock('./useUserSidebarCategories', () => ({
useUserSidebarCategories: jest.fn(),
}));
const mockedUseUserPreference = jest.mocked(useUserPreference);
const mockedUsePersistCategoriesMutation = jest.mocked(usePersistCategoriesMutation);
const mockedUseUserSidebarCategories = jest.mocked(useUserSidebarCategories);
const mutateAsync = jest.fn().mockResolvedValue(undefined);
const persistedIds = (): string[] => mutateAsync.mock.calls[0][0].map((c: { _id: string }) => c._id);
beforeEach(() => {
mutateAsync.mockClear();
mockedUseUserPreference.mockReturnValue(undefined); // sidebarSectionsOrder falls back to SIDEBAR_SYSTEM_GROUP_KEYS
mockedUsePersistCategoriesMutation.mockReturnValue({ mutateAsync } as any);
mockedUseUserSidebarCategories.mockReturnValue({ rawCategories: [], customCategories: [] });
});
it('moves a group down by swapping with its adjacent neighbour', async () => {
const { result } = renderHook(() => useMoveCategoryPosition());
await act(async () => {
await result.current(['Favorites', 'Channels'], 'Favorites', 'down');
});
const ids = persistedIds();
expect(ids.indexOf('Channels')).toBeLessThan(ids.indexOf('Favorites'));
});
it('moves a group up by swapping with its adjacent neighbour', async () => {
const { result } = renderHook(() => useMoveCategoryPosition());
await act(async () => {
await result.current(['Favorites', 'Channels'], 'Channels', 'up');
});
const ids = persistedIds();
expect(ids.indexOf('Channels')).toBeLessThan(ids.indexOf('Favorites'));
});
it('does nothing when the group is already at the top and moving up', async () => {
const { result } = renderHook(() => useMoveCategoryPosition());
await act(async () => {
await result.current(['Favorites', 'Channels'], 'Favorites', 'up');
});
expect(mutateAsync).not.toHaveBeenCalled();
});
it('does nothing when the group is already at the bottom and moving down', async () => {
const { result } = renderHook(() => useMoveCategoryPosition());
await act(async () => {
await result.current(['Favorites', 'Channels'], 'Channels', 'down');
});
expect(mutateAsync).not.toHaveBeenCalled();
});
it('does nothing when the key is not found in currentKeys', async () => {
const { result } = renderHook(() => useMoveCategoryPosition());
await act(async () => {
await result.current(['Favorites', 'Channels'], 'Teams', 'down');
});
expect(mutateAsync).not.toHaveBeenCalled();
});
it('always places dynamic groups before static groups in the persisted result', async () => {
const { result } = renderHook(() => useMoveCategoryPosition());
await act(async () => {
await result.current(['Favorites', 'Channels'], 'Favorites', 'down');
});
const ids = persistedIds();
const lastDynamicIdx = Math.max(...SIDEBAR_DYNAMIC_GROUP_KEYS.map((k) => ids.indexOf(k)));
const firstStaticIdx = ids.indexOf('Channels'); // first static key in result
expect(lastDynamicIdx).toBeLessThan(firstStaticIdx);
});
it('persists existing category entries with their stored metadata', async () => {
const stored = [{ _id: 'Favorites', name: 'Favorites', default: true, showUnreads: true }];
mockedUseUserSidebarCategories.mockReturnValue({ rawCategories: stored, customCategories: [] });
const { result } = renderHook(() => useMoveCategoryPosition());
await act(async () => {
await result.current(['Favorites', 'Channels'], 'Favorites', 'down');
});
const persisted: { _id: string; showUnreads?: boolean }[] = mutateAsync.mock.calls[0][0];
const favEntry = persisted.find((e) => e._id === 'Favorites');
expect(favEntry?.showUnreads).toBe(true);
});

View File

@@ -0,0 +1,28 @@
import { useCallback } from 'react';
import { usePersistCategoriesMutation } from './usePersistCategoriesMutation';
import { useUserSidebarCategories } from './useUserSidebarCategories';
import { withDynamicFirst } from '../../hooks/useCategoryList';
import { useSidebarSectionsOrder } from '../../hooks/useSidebarSectionsOrder';
export const useMoveCategoryPosition = () => {
const { rawCategories } = useUserSidebarCategories();
const sidebarSectionsOrder = useSidebarSectionsOrder();
const { mutateAsync: persistCategories } = usePersistCategoriesMutation();
return useCallback(
async (currentKeys: string[], key: string, direction: 'up' | 'down') => {
const i = currentKeys.indexOf(key);
const target = direction === 'up' ? i - 1 : i + 1;
if (i === -1 || target < 0 || target >= currentKeys.length) return;
const swappedKeys = [...currentKeys];
[swappedKeys[i], swappedKeys[target]] = [swappedKeys[target], swappedKeys[i]];
const entryMap = new Map(rawCategories.map((e) => [e._id, e]));
const finalIds = withDynamicFirst(swappedKeys, sidebarSectionsOrder);
await persistCategories(finalIds.map((k) => entryMap.get(k) ?? { _id: k, name: k, default: true }));
},
[rawCategories, sidebarSectionsOrder, persistCategories],
);
};

View File

@@ -0,0 +1,62 @@
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSetCategory } from './useSetCategory';
import type { MovableRoom } from './useUserSidebarCategories';
import { FAVORITES_TARGET, useUserSidebarCategories } from './useUserSidebarCategories';
type MoveRoomInput = {
room: MovableRoom;
target?: string;
silent?: boolean;
};
export const useMoveRoomCategory = () => {
const { t } = useTranslation();
const toggleFavoriteEndpoint = useEndpoint('POST', '/v1/rooms.favorite');
const dispatchToastMessage = useToastMessageDispatch();
const setCategory = useSetCategory();
const { customCategories } = useUserSidebarCategories();
return useMutation({
mutationFn: async ({ room, target, silent = false }: MoveRoomInput) => {
const isInTarget = target === FAVORITES_TARGET ? room.isFavorite : room.categoryId === target;
const ignoreToast = silent || !room.name;
if (!target || isInTarget) {
if (room.isFavorite) {
await toggleFavoriteEndpoint({ roomId: room.rid, favorite: false });
} else {
await setCategory([room.rid], null);
}
return { message: ignoreToast ? null : t('__roomName__removed_from_category', { roomName: room.name }) };
}
if (target === FAVORITES_TARGET) {
await toggleFavoriteEndpoint({ roomId: room.rid, favorite: true });
return {
message: ignoreToast ? null : t('__roomName__moved_to__categoryName__', { roomName: room.name, categoryName: t('Favorites') }),
};
}
const category = customCategories.find((category) => category._id === target);
if (!category) {
return null;
}
await setCategory([room.rid], target);
return {
message: ignoreToast ? null : t('__roomName__moved_to__categoryName__', { roomName: room.name, categoryName: category.name }),
};
},
onSuccess: (result) => {
if (result?.message) {
dispatchToastMessage({ type: 'success', message: result.message });
}
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
};

View File

@@ -0,0 +1,11 @@
import type { ISidebarCategory } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
export const usePersistCategoriesMutation = () => {
const saveUserPreferences = useEndpoint('POST', '/v1/users.setPreferences');
return useMutation({
mutationFn: (categories: ISidebarCategory[]) => saveUserPreferences({ data: { sidebarCategories: categories } }),
});
};

View File

@@ -0,0 +1,63 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import { useCategoryModals } from './useCategoryModals';
import { useMoveRoomCategory } from './useMoveRoomCategory';
import { useRoomCategoryItems } from './useRoomCategoryItems';
import { useUserSidebarCategories } from './useUserSidebarCategories';
jest.mock('./useUserSidebarCategories', () => ({ useUserSidebarCategories: jest.fn(), FAVORITES_TARGET: 'favorites' }));
jest.mock('./useMoveRoomCategory', () => ({ useMoveRoomCategory: jest.fn() }));
jest.mock('./useCategoryModals', () => ({ useCategoryModals: jest.fn() }));
jest.mock('../../../hooks/useHasLicenseModule', () => ({ useHasLicenseModule: () => ({ data: true }) }));
const mockedUseCustomCategories = jest.mocked(useUserSidebarCategories);
const mockedUseMoveRoomCategory = jest.mocked(useMoveRoomCategory);
const mockedUseCategoryModals = jest.mocked(useCategoryModals);
const catA = { _id: 'cat-a', name: 'Design', showUnreads: true };
const catB = { _id: 'cat-b', name: 'Engineering', showUnreads: true };
beforeEach(() => {
mockedUseCustomCategories.mockReturnValue({ customCategories: [catA, catB] } as any);
mockedUseMoveRoomCategory.mockReturnValue({ mutate: jest.fn(), mutateAsync: jest.fn() } as any);
mockedUseCategoryModals.mockReturnValue({ openCreate: jest.fn(), openManage: jest.fn(), openDelete: jest.fn() });
});
const wrapper = mockAppRoot().withSetting('Favorite_Rooms', true).build();
it('marks only Favorites as selected when the room is favorited and not in a category', () => {
const { result } = renderHook(() => useRoomCategoryItems({ rid: 'room-2', name: 'General', isFavorite: true }), { wrapper });
const { moveToItems } = result.current;
const selected = moveToItems.filter((item) => item.addon != null);
expect(selected).toHaveLength(1);
expect(selected[0].id).toBe('favorites');
});
it('marks only the matching category as selected when the room has a categoryId', () => {
const { result } = renderHook(
() => useRoomCategoryItems({ rid: 'room-1', name: 'Design Room', isFavorite: false, categoryId: 'cat-a' }),
{ wrapper },
);
const { moveToItems } = result.current;
const selected = moveToItems.filter((item) => item.addon != null);
expect(selected).toHaveLength(1);
expect(selected[0].id).toBe('cat-a');
});
it('marks only Favorites as selected when isFavorite is true even if categoryId is also set (stale state)', () => {
const { result } = renderHook(() => useRoomCategoryItems({ rid: 'room-1', name: 'Design Room', isFavorite: true, categoryId: 'cat-a' }), {
wrapper,
});
const { moveToItems } = result.current;
const selected = moveToItems.filter((item) => item.addon != null);
expect(selected).toHaveLength(1);
expect(selected[0].id).toBe('favorites');
});
it('marks nothing as selected when the room is neither favorited nor in a category', () => {
const { result } = renderHook(() => useRoomCategoryItems({ rid: 'room-2', name: 'General', isFavorite: false }), { wrapper });
const { moveToItems } = result.current;
const selected = moveToItems.filter((item) => item.addon != null);
expect(selected).toHaveLength(0);
});

View File

@@ -0,0 +1,60 @@
import { Icon } from '@rocket.chat/fuselage';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCategoryModals } from './useCategoryModals';
import { useMoveRoomCategory } from './useMoveRoomCategory';
import type { MovableRoom } from './useUserSidebarCategories';
import { FAVORITES_TARGET, useUserSidebarCategories } from './useUserSidebarCategories';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export const useRoomCategoryItems = (room: MovableRoom) => {
const { t } = useTranslation();
const { customCategories } = useUserSidebarCategories();
const { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
const moveRoomCategory = useMoveRoomCategory();
const { openCreate } = useCategoryModals();
const isFavoritesEnabled = useSetting('Favorite_Rooms', true);
return useMemo(() => {
const current = !room.isFavorite && room.categoryId ? customCategories.find((c) => c._id === room.categoryId) : undefined;
const selected = <Icon name='check' size='x16' />;
const moveToItems = [
...(isFavoritesEnabled
? [
{
id: 'favorites',
icon: 'star' as const,
content: t('Favorites'),
onClick: async () => moveRoomCategory.mutateAsync({ room, target: FAVORITES_TARGET }),
addon: room.isFavorite ? selected : undefined,
},
]
: []),
...customCategories.map((category) => ({
id: category._id,
icon: 'folder' as const,
content: category.name,
onClick: async () => moveRoomCategory.mutateAsync({ room, target: category._id }),
addon: current?._id === category._id ? selected : undefined,
})),
...(hasLicenseModule
? [{ id: 'newCategory', icon: 'plus' as const, content: t('New_category'), onClick: () => openCreate(room) }]
: []),
];
const currentName = current?.name ?? (room.isFavorite ? t('Favorites') : undefined);
const removeItem = currentName
? {
id: 'removeFromCategory',
icon: 'cross' as const,
content: t('Remove_from__categoryName__', { categoryName: currentName }),
onClick: async () => moveRoomCategory.mutateAsync({ room }),
}
: undefined;
return { moveToItems, removeItem };
}, [room, customCategories, isFavoritesEnabled, t, hasLicenseModule, moveRoomCategory, openCreate]);
};

View File

@@ -0,0 +1,9 @@
import { useCallback } from 'react';
import { useExperimentalEndpoint } from '../../../hooks/useExperimentalEndpoint';
export const useSetCategory = () => {
const setCategoryEndpoint = useExperimentalEndpoint('POST', '/experimental/rooms.setCategory');
return useCallback((roomIds: string[], category: string | null) => setCategoryEndpoint({ roomIds, category }), [setCategoryEndpoint]);
};

View File

@@ -0,0 +1,53 @@
import type { ISidebarCategory } from '@rocket.chat/core-typings';
import { useCallback } from 'react';
import { usePersistCategoriesMutation } from './usePersistCategoriesMutation';
import { useUserSidebarCategories } from './useUserSidebarCategories';
import { withDynamicFirst } from '../../hooks/useCategoryList';
import { useSidebarSectionsOrder } from '../../hooks/useSidebarSectionsOrder';
export const useToggleUnreads = () => {
const { rawCategories } = useUserSidebarCategories();
const sidebarSectionsOrder = useSidebarSectionsOrder();
const { mutateAsync: persistCategories } = usePersistCategoriesMutation();
const upsertGroupEntry = useCallback(
async (id: string, patch: Partial<ISidebarCategory>) => {
const existing = rawCategories.find((entry) => entry._id === id);
if (existing) {
await persistCategories(rawCategories.map((entry) => (entry._id === id ? { ...entry, ...patch } : entry)));
return;
}
const entryMap = new Map(rawCategories?.map((entry) => [entry._id, entry]));
entryMap.set(id, { _id: id, name: id, default: true, ...patch });
const merged = withDynamicFirst(
rawCategories.map((entry) => entry._id),
sidebarSectionsOrder,
);
await persistCategories(merged.map((key) => entryMap.get(key) ?? { _id: key, name: key, default: true }));
},
[rawCategories, sidebarSectionsOrder, persistCategories],
);
const isShowUnreads = useCallback((id: string) => rawCategories.find((entry) => entry._id === id)?.showUnreads ?? false, [rawCategories]);
const isKeepUnreadsOnTop = useCallback(
(id: string) => rawCategories.find((entry) => entry._id === id)?.keepUnreadsOnTop ?? false,
[rawCategories],
);
const toggleShowUnreads = useCallback(
(id: string) => upsertGroupEntry(id, { showUnreads: !isShowUnreads(id) }),
[upsertGroupEntry, isShowUnreads],
);
const toggleKeepUnreadsOnTop = useCallback(
(id: string) => upsertGroupEntry(id, { keepUnreadsOnTop: !isKeepUnreadsOnTop(id) }),
[upsertGroupEntry, isKeepUnreadsOnTop],
);
return { toggleShowUnreads, toggleKeepUnreadsOnTop, isShowUnreads, isKeepUnreadsOnTop };
};

View File

@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { usePersistCategoriesMutation } from './usePersistCategoriesMutation';
import { useSetCategory } from './useSetCategory';
import { useUserSidebarCategories } from './useUserSidebarCategories';
export const useUpdateCategory = () => {
const setCategory = useSetCategory();
const persistCategories = usePersistCategoriesMutation();
const { rawCategories } = useUserSidebarCategories();
return useCallback(
async (categoryId: string, name: string, addedRoomIds: string[], removedRoomIds: string[]) => {
await persistCategories.mutateAsync(
rawCategories.map((entry) => (entry._id === categoryId ? { ...entry, name: name.trim() } : entry)),
);
if (addedRoomIds.length > 0) await setCategory(addedRoomIds, categoryId);
if (removedRoomIds.length > 0) await setCategory(removedRoomIds, null);
},
[rawCategories, persistCategories, setCategory],
);
};

View File

@@ -0,0 +1,78 @@
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react';
import { useUserSidebarCategories } from './useUserSidebarCategories';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
jest.mock('@rocket.chat/ui-contexts', () => ({
useUserPreference: jest.fn(),
}));
jest.mock('../../../hooks/useHasLicenseModule', () => ({
useHasLicenseModule: jest.fn(),
}));
const mockedUseUserPreference = jest.mocked(useUserPreference);
const mockedUseHasLicenseModule = jest.mocked(useHasLicenseModule);
beforeEach(() => {
mockedUseHasLicenseModule.mockReturnValue({ data: true } as any);
});
const ids = (entries: { _id: string }[]) => entries.map(({ _id }) => _id);
it('returns an empty list when the preference is unset', () => {
mockedUseUserPreference.mockReturnValue(undefined);
const { result } = renderHook(() => useUserSidebarCategories());
expect(result.current.rawCategories).toEqual([]);
expect(result.current.customCategories).toEqual([]);
});
it('drops entries left behind by a removed system group', () => {
mockedUseUserPreference.mockReturnValue([
{ _id: 'Favorites', name: 'Favorites', default: true },
{ _id: 'Drafts', name: 'Drafts', default: true },
{ _id: 'Conversations', name: 'Conversations', default: true },
]);
const { result } = renderHook(() => useUserSidebarCategories());
expect(ids(result.current.rawCategories)).toEqual(['Favorites', 'Conversations']);
});
it('keeps custom categories and their stored metadata', () => {
mockedUseUserPreference.mockReturnValue([
{ _id: 'Drafts', name: 'Drafts', default: true },
{ _id: 'custom-xyz', name: 'Work', showUnreads: true },
]);
const { result } = renderHook(() => useUserSidebarCategories());
expect(ids(result.current.rawCategories)).toEqual(['custom-xyz']);
expect(result.current.customCategories).toEqual([{ _id: 'custom-xyz', name: 'Work', showUnreads: true }]);
});
it('only drops entries flagged as system groups, not custom ids that look like one', () => {
const entries = [{ _id: 'Favorites', name: 'My favorites' }];
mockedUseUserPreference.mockReturnValue(entries);
const { result } = renderHook(() => useUserSidebarCategories());
expect(result.current.rawCategories).toEqual(entries);
});
it('hides custom categories without the license module but keeps the raw entries', () => {
mockedUseHasLicenseModule.mockReturnValue({ data: false } as any);
mockedUseUserPreference.mockReturnValue([
{ _id: 'Favorites', name: 'Favorites', default: true },
{ _id: 'Drafts', name: 'Drafts', default: true },
{ _id: 'custom-xyz', name: 'Work' },
]);
const { result } = renderHook(() => useUserSidebarCategories());
expect(ids(result.current.rawCategories)).toEqual(['Favorites', 'custom-xyz']);
expect(result.current.customCategories).toEqual([]);
});

View File

@@ -0,0 +1,21 @@
import type { ISidebarCategory } from '@rocket.chat/core-typings';
import { isStaleSidebarCategory } from '@rocket.chat/core-typings';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type MovableRoom = { rid: string; name?: string; isFavorite?: boolean; categoryId?: string };
export const FAVORITES_TARGET = 'favorites';
export const useUserSidebarCategories = () => {
const { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
const allEntries = useUserPreference<ISidebarCategory[]>('sidebarCategories');
return useMemo(() => {
const rawCategories = (allEntries ?? []).filter((entry) => !isStaleSidebarCategory(entry));
const customCategories = rawCategories.filter((entry) => !entry.default);
return hasLicenseModule ? { rawCategories, customCategories } : { rawCategories, customCategories: [] };
}, [hasLicenseModule, allEntries]);
};

View File

@@ -0,0 +1,33 @@
import { SIDEBAR_SYSTEM_GROUP_KEYS } from '@rocket.chat/core-typings';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserSidebarCategories } from './useUserSidebarCategories';
export const MAX_CATEGORY_NAME_LENGTH = 30;
export const useValidateCategoryName = () => {
const { t } = useTranslation();
const { customCategories } = useUserSidebarCategories();
return useCallback(
(name: string, excludeId?: string): string | undefined => {
const trimmed = name.trim();
if (!trimmed) {
return t('Please_enter_a_category_name');
}
if (trimmed.length > MAX_CATEGORY_NAME_LENGTH) {
return t('Category_name_is_too_long__max__maxLength__characters', { maxLength: MAX_CATEGORY_NAME_LENGTH });
}
const normalized = trimmed.toLowerCase();
if (SIDEBAR_SYSTEM_GROUP_KEYS.some((key) => t(key).toLowerCase() === normalized)) {
return t('Category_name_conflicts_with_system_group');
}
if (customCategories.some((category) => category._id !== excludeId && category.name.trim().toLowerCase() === normalized)) {
return t('A_category_with_this_name_already_exists');
}
return undefined;
},
[customCategories, t],
);
};

View File

@@ -0,0 +1,7 @@
import SidebarFooterDefault from './SidebarFooterDefault';
const SidebarFooter = () => {
return <SidebarFooterDefault />;
};
export default SidebarFooter;

View File

@@ -0,0 +1,36 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, SidebarDivider, Palette, SidebarFooter as Footer } from '@rocket.chat/fuselage';
import { useThemeMode } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
import { SidebarFooterWatermark } from './SidebarFooterWatermark';
const SidebarFooterDefault = () => {
const theme = useThemeMode();
const logo = useSetting(theme === 'dark' ? 'Layout_Sidenav_Footer_Dark' : 'Layout_Sidenav_Footer', '').trim();
const dangerousLogo = useMemo(() => ({ __html: DOMPurify.sanitize(logo) }), [logo]);
const sidebarFooterStyle = css`
& img {
max-width: 100%;
height: 100%;
}
& a:any-link {
color: ${Palette.text['font-info']};
}
`;
return (
<Footer>
<SidebarDivider />
<Box paddingBlock={12} height='x48' width='auto' className={sidebarFooterStyle} dangerouslySetInnerHTML={dangerousLogo} />
<SidebarFooterWatermark />
</Footer>
);
};
export default SidebarFooterDefault;

View File

@@ -0,0 +1,40 @@
import { Box, SidebarFooterContent as FooterContent } from '@rocket.chat/fuselage';
import { useLicense, useLicenseName } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { links } from '../../lib/links';
export const SidebarFooterWatermark = () => {
const { t } = useTranslation();
const response = useLicense();
const licenseName = useLicenseName();
if (response.isLoading || response.isError) {
return null;
}
if (licenseName.isError || licenseName.isLoading) {
return null;
}
const license = response.data;
if (license?.activeModules.includes('hide-watermark') && !license.trial) {
return null;
}
return (
<FooterContent paddingBlockEnd={8}>
<Box is='a' href={links.rocketChat} target='_blank' rel='noopener noreferrer'>
<Box color='hint' paddingBlockEnd={4}>
{t('Powered_by_RocketChat')}
</Box>
<Box color='pure-white' paddingBlockEnd={4}>
{licenseName.data}
</Box>
</Box>
</FooterContent>
);
};

View File

@@ -0,0 +1 @@
export { default } from './SidebarFooter';

View File

@@ -0,0 +1,39 @@
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import type { ComponentType } from 'react';
import { useMemo } from 'react';
export const useAvatarTemplate = (
sidebarViewMode?: 'extended' | 'medium' | 'condensed',
sidebarDisplayAvatar?: boolean,
): null | ComponentType<SubscriptionWithRoom & { rid: string }> => {
const sidebarViewModeFromSettings = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode');
const sidebarDisplayAvatarFromSettings = useUserPreference('sidebarDisplayAvatar');
const viewMode = sidebarViewMode ?? sidebarViewModeFromSettings;
const displayAvatar = sidebarDisplayAvatar ?? sidebarDisplayAvatarFromSettings;
return useMemo(() => {
if (!displayAvatar) {
return null;
}
const size = ((): 'x36' | 'x28' | 'x20' => {
switch (viewMode) {
case 'extended':
return 'x36';
case 'medium':
return 'x28';
case 'condensed':
default:
return 'x20';
}
})();
const renderRoomAvatar: ComponentType<SubscriptionWithRoom & { rid: string }> = (room) => (
<RoomAvatar size={size} room={{ ...room, _id: room.rid || room._id, type: room.t }} />
);
return renderRoomAvatar;
}, [displayAvatar, viewMode]);
};

View File

@@ -0,0 +1,125 @@
import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react';
import { SIDEBAR_DYNAMIC_GROUP_KEYS, mergeWithSectionsOrder, useCategoryList, withDynamicFirst } from './useCategoryList';
import { useHasLicenseModule } from '../../hooks/useHasLicenseModule';
jest.mock('@rocket.chat/ui-contexts', () => ({
useSetting: jest.fn(),
useUserPreference: jest.fn(),
}));
jest.mock('../../hooks/useHasLicenseModule', () => ({
useHasLicenseModule: jest.fn(),
}));
const mockedUseSetting = jest.mocked(useSetting);
const mockedUseUserPreference = jest.mocked(useUserPreference);
const mockedUseHasLicenseModule = jest.mocked(useHasLicenseModule);
const STATIC_KEYS = ['Favorites', 'Teams', 'Discussions', 'Channels', 'Direct_Messages', 'Conversations'] as const;
const DYNAMIC_KEYS = [...SIDEBAR_DYNAMIC_GROUP_KEYS];
const ALL_KEYS = [...DYNAMIC_KEYS, ...STATIC_KEYS];
describe('mergeWithSectionsOrder', () => {
it('inserts a missing key before its first successor already in the list', () => {
const result = mergeWithSectionsOrder(['Favorites', 'Channels'], ALL_KEYS);
expect(result.indexOf('Unread')).toBeLessThan(result.indexOf('Favorites'));
});
it('does not move keys that are already present', () => {
const result = mergeWithSectionsOrder(['Channels', 'Favorites'], ALL_KEYS);
expect(result.indexOf('Channels')).toBeLessThan(result.indexOf('Favorites'));
});
it('appends a key with no successor in the list to the end', () => {
const result = mergeWithSectionsOrder(['Favorites'], ALL_KEYS);
expect(result.at(-1)).toBe('Conversations');
});
it('returns all sectionsOrder keys when explicitIds is empty', () => {
const result = mergeWithSectionsOrder([], ALL_KEYS);
expect(result).toEqual(ALL_KEYS);
});
});
describe('withDynamicFirst', () => {
it('places all dynamic groups before any static group', () => {
const result = withDynamicFirst(['Favorites', 'Channels'], ALL_KEYS);
const lastDynamicIdx = Math.max(...DYNAMIC_KEYS.map((k) => result.indexOf(k)));
const firstStaticIdx = Math.min(...STATIC_KEYS.map((k) => result.indexOf(k)));
expect(lastDynamicIdx).toBeLessThan(firstStaticIdx);
});
it('includes all dynamic keys from sectionsOrder even if absent from ids', () => {
const result = withDynamicFirst(['Favorites'], ALL_KEYS);
for (const dk of DYNAMIC_KEYS) {
expect(result).toContain(dk);
}
});
it('places a new custom category first among static groups', () => {
const customId = 'custom-xyz';
const result = withDynamicFirst([customId, 'Favorites', 'Channels'], ALL_KEYS);
for (const dk of DYNAMIC_KEYS) {
expect(result.indexOf(dk)).toBeLessThan(result.indexOf(customId));
}
expect(result.indexOf(customId)).toBeLessThan(result.indexOf('Favorites'));
expect(result.indexOf(customId)).toBeLessThan(result.indexOf('Channels'));
});
it('preserves user-defined order among static groups', () => {
const result = withDynamicFirst(['Channels', 'Favorites'], ALL_KEYS);
expect(result.indexOf('Channels')).toBeLessThan(result.indexOf('Favorites'));
});
it('corrects a dynamic group that was placed in the middle of statics', () => {
const result = withDynamicFirst(['Favorites', 'Unread', 'Channels'], ALL_KEYS);
expect(result.indexOf('Unread')).toBeLessThan(result.indexOf('Favorites'));
expect(result.indexOf('Unread')).toBeLessThan(result.indexOf('Channels'));
expect(result.indexOf('Favorites')).toBeLessThan(result.indexOf('Channels'));
});
});
describe('useCategoryList', () => {
const preferences: Record<string, unknown> = {};
let hasLicenseModule = false;
beforeEach(() => {
for (const key of Object.keys(preferences)) delete preferences[key];
Object.assign(preferences, { sidebarGroupByType: false, sidebarShowFavorites: true, sidebarShowUnread: true });
hasLicenseModule = false;
mockedUseUserPreference.mockImplementation((key: string, defaultValue?: unknown) => preferences[key] ?? defaultValue);
mockedUseSetting.mockImplementation((_key: string, defaultValue?: unknown) => defaultValue);
mockedUseHasLicenseModule.mockImplementation(() => ({ data: hasLicenseModule }) as any);
});
const categoryList = () => renderHook(() => useCategoryList(false, false)).result.current;
it('ignores a section key of a system group that no longer exists', () => {
preferences.sidebarSectionsOrder = ['Incoming_Calls', 'Unread', 'Drafts', 'Favorites', 'Conversations'];
expect(categoryList()).toEqual(['Incoming_Calls', 'Unread', 'Favorites', 'Conversations']);
});
it('ignores a stale section key with the license module enabled', () => {
hasLicenseModule = true;
preferences.sidebarSectionsOrder = ['Incoming_Calls', 'Unread', 'Drafts', 'Favorites', 'Conversations'];
expect(categoryList()).not.toContain('Drafts');
});
it('ignores a stale entry already persisted in sidebarCategories', () => {
hasLicenseModule = true;
preferences.sidebarSectionsOrder = ['Incoming_Calls', 'Unread', 'Favorites', 'Conversations'];
preferences.sidebarCategories = [
{ _id: 'Drafts', name: 'Drafts', default: true },
{ _id: 'custom-xyz', name: 'Work' },
];
const result = categoryList();
expect(result).not.toContain('Drafts');
expect(result).toContain('custom-xyz');
});
});

View File

@@ -0,0 +1,218 @@
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useSidebarSectionsOrder } from './useSidebarSectionsOrder';
import { useHasLicenseModule } from '../../hooks/useHasLicenseModule';
import { useUserSidebarCategories } from '../categories/hooks/useUserSidebarCategories';
type FilterSystemCategoriesOptions = {
showOmnichannel: boolean;
inquiriesEnabled: boolean;
sidebarGroupByType: boolean;
favoritesEnabled: boolean;
sidebarShowUnread: boolean;
isDiscussionEnabled: boolean;
};
const filterSystemCategories = (categories: readonly string[], options: FilterSystemCategoriesOptions) => {
const { showOmnichannel, inquiriesEnabled, sidebarGroupByType, favoritesEnabled, sidebarShowUnread, isDiscussionEnabled } = options;
return categories.filter((key) => {
switch (key) {
case 'Incoming_Livechats':
return showOmnichannel && inquiriesEnabled;
case 'On_Hold_Chats':
case 'Open_Livechats':
return showOmnichannel;
case 'Discussions':
return sidebarGroupByType && isDiscussionEnabled;
case 'Teams':
case 'Channels':
case 'Direct_Messages':
return sidebarGroupByType;
case 'Conversations':
return !sidebarGroupByType;
case 'Unread':
return sidebarShowUnread;
case 'Favorites':
return favoritesEnabled;
case 'Incoming_Calls':
default:
return true;
}
});
};
export const SIDEBAR_DYNAMIC_GROUP_KEYS: readonly string[] = [
'Incoming_Calls',
'Incoming_Livechats',
'Open_Livechats',
'On_Hold_Chats',
'Unread',
];
export const mergeWithSectionsOrder = (explicitIds: string[], sectionsOrder: readonly string[]): string[] => {
const merged = [...explicitIds];
for (const key of sectionsOrder) {
if (merged.includes(key)) continue;
const successorIdx = sectionsOrder
.slice(sectionsOrder.indexOf(key) + 1)
.map((k) => merged.indexOf(k))
.find((pos) => pos !== -1);
merged.splice(successorIdx ?? merged.length, 0, key);
}
return merged;
};
export const withDynamicFirst = (ids: string[], sectionsOrder: readonly string[]): string[] => {
const dynamicInOrder = sectionsOrder.filter((k) => SIDEBAR_DYNAMIC_GROUP_KEYS.includes(k));
const staticIds = ids.filter((k) => !SIDEBAR_DYNAMIC_GROUP_KEYS.includes(k));
const staticOrder = sectionsOrder.filter((k) => !SIDEBAR_DYNAMIC_GROUP_KEYS.includes(k));
return [...dynamicInOrder, ...mergeWithSectionsOrder(staticIds, staticOrder)];
};
export const filterGroupVisibility = <T>(
groups: Map<string, Set<SubscriptionWithRoom>>,
hasLicenseModule: boolean,
makeGroup: (key: string, set: Set<SubscriptionWithRoom>) => T,
): T[] => {
const filteredGroups: T[] = [];
groups.forEach((group, key) => {
if (!group) {
return;
}
if (key === 'Conversations') {
filteredGroups.push(makeGroup(key, group));
return;
}
if (!hasLicenseModule || SIDEBAR_DYNAMIC_GROUP_KEYS.includes(key)) {
if (group.size > 0) {
filteredGroups.push(makeGroup(key, group));
}
return;
}
return filteredGroups.push(makeGroup(key, group));
});
return filteredGroups;
};
const isUnreadRoom = (room: SubscriptionWithRoom): boolean =>
!room.hideUnreadStatus && Boolean(room.alert || room.unread || room.tunread?.length);
export const getRoomCategory = (
room: SubscriptionWithRoom,
{
groups,
hasIncomingCalls,
}: {
groups: Map<string, Set<SubscriptionWithRoom>>;
hasIncomingCalls: (room: SubscriptionWithRoom['rid']) => boolean;
},
): string | false => {
if (room.archived) {
return false;
}
if (hasIncomingCalls(room.rid)) {
return 'Incoming_Calls';
}
if (isUnreadRoom(room) && groups.has('Unread')) {
return 'Unread';
}
if (room.category && groups.has(room.category)) {
return room.category;
}
if (room.f && groups.has('Favorites')) {
return 'Favorites';
}
if (room.teamMain && groups.has('Teams')) {
return 'Teams';
}
if (room.prid && groups.has('Discussions')) {
return 'Discussions';
}
if (room.t === 'l' && room.onHold) {
if (!groups.has('On_Hold_Chats')) {
return false;
}
return 'On_Hold_Chats';
}
if (room.t === 'l') {
if (!groups.has('Open_Livechats')) {
return false;
}
return 'Open_Livechats';
}
if ((room.t === 'c' || room.t === 'p') && groups.has('Channels')) {
return 'Channels';
}
if (room.t === 'd' && groups.has('Direct_Messages')) {
return 'Direct_Messages';
}
return 'Conversations';
};
export const useCategoryList = (showOmnichannel: boolean, inquiriesEnabled: boolean) => {
const { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
const { rawCategories: sidebarCategories } = useUserSidebarCategories();
const sidebarSectionsOrder = useSidebarSectionsOrder();
const sidebarGroupByType = useUserPreference<boolean>('sidebarGroupByType') ?? false;
const favoritesEnabled = useUserPreference<boolean>('sidebarShowFavorites', true) ?? true;
const isDiscussionEnabled = useSetting('Discussion_enabled', true) ?? true;
const sidebarShowUnread = useUserPreference<boolean>('sidebarShowUnread', false) ?? false;
const categoryList = useMemo(() => {
if (hasLicenseModule) {
return filterSystemCategories(
mergeWithSectionsOrder(
sidebarCategories.map(({ _id }) => _id),
sidebarSectionsOrder,
),
{
showOmnichannel,
inquiriesEnabled,
sidebarGroupByType,
favoritesEnabled,
sidebarShowUnread,
isDiscussionEnabled,
},
);
}
return filterSystemCategories(sidebarSectionsOrder, {
showOmnichannel,
inquiriesEnabled,
sidebarGroupByType,
favoritesEnabled,
sidebarShowUnread,
isDiscussionEnabled,
});
}, [
sidebarCategories,
sidebarSectionsOrder,
hasLicenseModule,
showOmnichannel,
inquiriesEnabled,
sidebarGroupByType,
favoritesEnabled,
sidebarShowUnread,
isDiscussionEnabled,
]);
return categoryList;
};

View File

@@ -0,0 +1,30 @@
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import type { KeyboardEvent } from 'react';
import { useCallback } from 'react';
export const useCollapsedGroups = () => {
const [collapsedGroups, setCollapsedGroups] = useLocalStorage<string[]>('sidebarGroups', []);
const handleClick = useCallback(
(group: string) => {
if (collapsedGroups.includes(group)) {
setCollapsedGroups(collapsedGroups.filter((item) => item !== group));
} else {
setCollapsedGroups([...collapsedGroups, group]);
}
},
[collapsedGroups, setCollapsedGroups],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent, group: string) => {
if (['Enter', 'Space'].includes(event.code)) {
event.preventDefault();
handleClick(group);
}
},
[handleClick],
);
return { collapsedGroups, handleClick, handleKeyDown };
};

View File

@@ -0,0 +1,21 @@
import type { RefObject } from 'react';
import { useEffect } from 'react';
export const usePreventDefault = (ref: RefObject<Element | null>): { ref: RefObject<Element | null> } => {
// Flowrouter uses an addEventListener on the document to capture any clink link, since the react synthetic event use an addEventListener on the document too,
// it is impossible/hard to determine which one will happen before and prevent/stop propagation, so feel free to remove this effect after remove flow router :)
useEffect(() => {
const { current } = ref;
const stopPropagation: EventListener = (e) => {
if ([(e.target as HTMLElement).nodeName, (e.target as HTMLElement).parentElement?.nodeName].includes('BUTTON')) {
e.preventDefault();
}
};
current?.addEventListener('click', stopPropagation);
return (): void => current?.removeEventListener('click', stopPropagation);
}, [ref]);
return { ref };
};

View File

@@ -0,0 +1,391 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { VideoConfContext } from '@rocket.chat/ui-video-conf';
import { renderHook, waitFor } from '@testing-library/react';
import { useRoomList } from './useRoomList';
import type { SidebarRoomListGroup } from './useRoomList';
import { createFakeLicenseInfo, createFakeRoom, createFakeSubscription, createFakeUser } from '../../../tests/mocks/data';
let mockOpenedRoom: string | undefined;
jest.mock('../../lib/RoomManager', () => ({
useOpenedRoom: () => mockOpenedRoom,
}));
afterEach(() => {
mockOpenedRoom = undefined;
});
// 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,
roles: ['admin'],
type: 'user',
});
const emptyUnread = {
userMentions: 0,
groupMentions: 0,
unread: 0,
tunread: undefined,
tunreadUser: undefined,
tunreadGroup: undefined,
alert: false,
};
const unreadChannels = [
{ ...createFakeSubscription({ t: 'c', tunread: ['1'] }), ...createFakeRoom({ t: 'c' }) },
{ ...createFakeSubscription({ t: 'c', tunread: ['1'] }), ...createFakeRoom({ t: 'c' }) },
{ ...createFakeSubscription({ t: 'c', tunreadUser: ['1'] }), ...createFakeRoom({ t: 'c' }) },
{ ...createFakeSubscription({ t: 'c', tunreadUser: ['1'] }), ...createFakeRoom({ t: 'c' }) },
];
const favoriteRooms = [
{ ...createFakeSubscription({ t: 'c', f: true, ...emptyUnread }), ...createFakeRoom({ t: 'c' }) },
{ ...createFakeSubscription({ t: 'c', f: true, ...emptyUnread }), ...createFakeRoom({ t: 'c' }) },
{ ...createFakeSubscription({ t: 'c', f: true, ...emptyUnread }), ...createFakeRoom({ t: 'c' }) },
];
const teams = [
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ teamMain: true }) },
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ teamMain: true }) },
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ teamMain: true }) },
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ teamMain: true }) },
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ teamMain: true }) },
];
const discussionRooms = [
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ prid: '123' }) },
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ prid: '124' }) },
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ prid: '125' }) },
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ prid: '126' }) },
{ ...createFakeSubscription({ ...emptyUnread }), ...createFakeRoom({ prid: '127' }) },
];
const directRooms = [
{ ...createFakeSubscription({ t: 'd', ...emptyUnread }), ...createFakeRoom({ t: 'd' }) },
{ ...createFakeSubscription({ t: 'd', ...emptyUnread }), ...createFakeRoom({ t: 'd' }) },
{ ...createFakeSubscription({ t: 'd', ...emptyUnread }), ...createFakeRoom({ t: 'd' }) },
{ ...createFakeSubscription({ t: 'd', ...emptyUnread }), ...createFakeRoom({ t: 'd' }) },
];
const fakeRooms = [...unreadChannels, ...favoriteRooms, ...teams, ...discussionRooms, ...directRooms];
const emptyArr: any[] = [];
const getWrapperSettings = ({
sidebarGroupByType = false,
sidebarShowFavorites = true,
sidebarShowUnread = false,
isDiscussionEnabled = false,
isEnterprise = false,
fakeRoom = undefined,
rooms = fakeRooms as unknown as SubscriptionWithRoom[],
sidebarCategories = [],
}: {
sidebarGroupByType?: boolean;
sidebarShowFavorites?: boolean;
sidebarShowUnread?: boolean;
isDiscussionEnabled?: boolean;
isEnterprise?: boolean;
fakeRoom?: SubscriptionWithRoom;
rooms?: SubscriptionWithRoom[];
sidebarCategories?: { _id: string; name?: string; default?: boolean; showUnreads?: boolean; keepUnreadsOnTop?: boolean }[];
}) => {
const root = mockAppRoot()
.wrap((children) => (
<VideoConfContext.Provider
value={
{
queryIncomingCalls: () => [() => () => undefined, () => emptyArr],
} as any
}
>
{children}
</VideoConfContext.Provider>
))
.withUser(user)
.withSubscriptions([...rooms, fakeRoom && fakeRoom].filter(Boolean) as unknown as SubscriptionWithRoom[])
.withUserPreference('sidebarGroupByType', sidebarGroupByType)
.withUserPreference('sidebarShowFavorites', sidebarShowFavorites)
.withUserPreference('sidebarShowUnread', sidebarShowUnread)
.withUserPreference('sidebarCategories', sidebarCategories)
.withSetting('Discussion_enabled', isDiscussionEnabled);
if (isEnterprise) {
root.withEndpoint('GET', '/v1/licenses.info', () => ({
license: createFakeLicenseInfo({ hasValidLicense: true, activeModules: ['experimental-enterprise-features'] }),
}));
}
return root;
};
it('should return roomList, groupsCount and groupsList', async () => {
const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
wrapper: getWrapperSettings({}).build(),
});
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 } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
// Hide custom categories and Favorites so every room folds into a single "Conversations" group.
wrapper: getWrapperSettings({ sidebarShowFavorites: false }).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);
expect(groupsCount.reduce((a, b) => a + b, 0)).toEqual(roomList.length);
});
it('should return roomList with the subscribed rooms and the correct length', async () => {
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 } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
// Hide custom categories and Favorites so the only group is "Conversations".
wrapper: getWrapperSettings({ sidebarShowFavorites: false }).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 } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(),
});
const groupsList = groupsListOf(result.current.groups);
const teamsIndex = groupsList.indexOf('Teams');
expect(groupsList).toContain('Teams');
expect(result.current.groupsCount[teamsIndex]).toEqual(teams.length);
});
it('should group favorites into a "Favorites" group when sidebarShowFavorites is on', async () => {
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(result.current.groupsCount[favoritesIndex]).toEqual(favoriteRooms.length);
});
it('should not group favorites when sidebarShowFavorites is off', async () => {
const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
wrapper: getWrapperSettings({ sidebarShowFavorites: false, sidebarGroupByType: true }).build(),
});
expect(groupsListOf(result.current.groups)).not.toContain('Favorites');
});
it('should group unread rooms into an "Unread" group when sidebarShowUnread is on', async () => {
const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
wrapper: getWrapperSettings({ sidebarShowUnread: true, sidebarGroupByType: true }).build(),
});
const groupsList = groupsListOf(result.current.groups);
const unreadIndex = groupsList.indexOf('Unread');
expect(groupsList).toContain('Unread');
// The four seeded unread channels are pulled into the Unread group.
expect(result.current.groupsCount[unreadIndex]).toEqual(unreadChannels.length);
});
it('should not group unread rooms when sidebarShowUnread is off', async () => {
const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
wrapper: getWrapperSettings({ sidebarShowUnread: false, sidebarGroupByType: true }).build(),
});
expect(groupsListOf(result.current.groups)).not.toContain('Unread');
});
it('should return groupsList with "Discussions" if isDiscussionEnabled is enabled', async () => {
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(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(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" for Channels defaults to false — no preference entry needed.
const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), {
wrapper: getWrapperSettings({ sidebarGroupByType: true, isEnterprise: true }).build(),
});
// hasLicenseModule resolves asynchronously from the mock endpoint.
await waitFor(() => {
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 hide all rooms and show a badge when a group is collapsed in CE ("Show unreads" is always off)', async () => {
const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), {
wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(),
});
const groupsList = groupsListOf(result.current.groups);
const channelsIndex = groupsList.indexOf('Channels');
// In CE showUnreads is always off — collapsing hides everything.
expect(result.current.groupsCount[channelsIndex]).toEqual(0);
// The header badge accumulates unread data from the hidden rooms.
expect(result.current.groups[channelsIndex].unreadInfo.tunread.length).toBeGreaterThan(0);
});
it('should keep the opened room visible when its group is collapsed', async () => {
// Every seeded DM is read, so without this behaviour collapsing "Direct_Messages" would hide all of them.
mockOpenedRoom = directRooms[0].rid;
const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Direct_Messages'] }), {
wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(),
});
const groupsList = groupsListOf(result.current.groups);
const directIndex = groupsList.indexOf('Direct_Messages');
expect(result.current.groupsCount[directIndex]).toEqual(1);
expect(result.current.groups[directIndex].rooms).toEqual([directRooms[0]]);
});
it('should not count the opened room in the header badge of its collapsed group', async () => {
// The opened room stays visible with its own counters, so the collapsed header must not count it again.
mockOpenedRoom = unreadChannels[0].rid;
const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), {
wrapper: getWrapperSettings({ sidebarGroupByType: true }).build(),
});
const groupsList = groupsListOf(result.current.groups);
const channelsIndex = groupsList.indexOf('Channels');
const hiddenChannels = unreadChannels.slice(1);
const { unreadInfo } = result.current.groups[channelsIndex];
expect(result.current.groups[channelsIndex].rooms).toEqual([unreadChannels[0]]);
expect(unreadInfo.tunread).toEqual(hiddenChannels.flatMap((room) => room.tunread || []));
expect(unreadInfo.tunreadUser).toEqual(hiddenChannels.flatMap((room) => room.tunreadUser || []));
expect(unreadInfo.unread).toEqual(hiddenChannels.reduce((acc, room) => acc + room.unread, 0));
});
it('should not duplicate the opened room when a collapsed group already shows it as unread', async () => {
mockOpenedRoom = unreadChannels[0].rid;
const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), {
wrapper: getWrapperSettings({
sidebarGroupByType: true,
isEnterprise: true,
sidebarCategories: [{ _id: 'Channels', name: 'Channels', default: true, showUnreads: true }],
}).build(),
});
// hasLicenseModule resolves asynchronously from the mock endpoint.
await waitFor(() => {
const groupsList = groupsListOf(result.current.groups);
const channelsIndex = groupsList.indexOf('Channels');
expect(result.current.groupsCount[channelsIndex]).toEqual(unreadChannels.length);
});
});
it('should keep unread rooms visible (and show no header badge) when a group is collapsed and "Show unreads" is on in EE', async () => {
const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), {
wrapper: getWrapperSettings({
sidebarGroupByType: true,
isEnterprise: true,
sidebarCategories: [{ _id: 'Channels', name: 'Channels', default: true, showUnreads: true }],
}).build(),
});
// hasLicenseModule resolves asynchronously from the mock endpoint.
await waitFor(() => {
const groupsList = groupsListOf(result.current.groups);
const channelsIndex = groupsList.indexOf('Channels');
// 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(groupsListOf(result.current.groups).length);
});
it('should keep empty system categories visible (dimmed)', async () => {
// Only a single DM exists, so with group-by-type on "Channels"/"Teams" have no rooms — but must still render.
// Empty system groups are an EE-only feature.
const onlyDirect = [
{ ...createFakeSubscription({ t: 'd', ...emptyUnread }), ...createFakeRoom({ t: 'd' }) },
] as unknown as SubscriptionWithRoom[];
const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
wrapper: getWrapperSettings({ rooms: onlyDirect, sidebarGroupByType: true, isEnterprise: true }).build(),
});
await waitFor(() => expect(groupsListOf(result.current.groups)).toContain('Channels'));
const groupsList = groupsListOf(result.current.groups);
expect(groupsList).toContain('Direct_Messages');
expect(result.current.groups[groupsList.indexOf('Channels')].empty).toBe(true);
expect(result.current.groups[groupsList.indexOf('Direct_Messages')].empty).toBe(false);
});
it('should keep the Favorites category visible when it is empty', async () => {
// Empty Favorites group is an EE-only feature.
const onlyDirect = [
{ ...createFakeSubscription({ t: 'd', ...emptyUnread }), ...createFakeRoom({ t: 'd' }) },
] as unknown as SubscriptionWithRoom[];
const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
wrapper: getWrapperSettings({ rooms: onlyDirect, sidebarGroupByType: true, sidebarShowFavorites: true, isEnterprise: true }).build(),
});
await waitFor(() => expect(groupsListOf(result.current.groups)).toContain('Favorites'));
const groupsList = groupsListOf(result.current.groups);
expect(result.current.groups[groupsList.indexOf('Favorites')].empty).toBe(true);
});
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.
// Channels defaults to showUnreads=false — no preference entry needed.
const { result } = renderHook(() => useRoomList({ collapsedGroups: ['Channels'] }), {
wrapper: getWrapperSettings({ sidebarGroupByType: true, isEnterprise: true }).build(),
});
// hasLicenseModule resolves asynchronously from the mock endpoint.
await waitFor(() => {
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(unreadChannels.reduce((acc, cv) => acc + cv.groupMentions, 0));
expect(unread).toEqual(unreadChannels.reduce((acc, cv) => acc + cv.unread, 0));
expect(userMentions).toEqual(unreadChannels.reduce((acc, cv) => acc + cv.userMentions, 0));
expect(tunread).toEqual(unreadChannels.reduce((acc, cv) => [...acc, ...(cv.tunread || [])], [] as string[]));
expect(tunreadUser).toEqual(unreadChannels.reduce((acc, cv) => [...acc, ...(cv.tunreadUser || [])], [] as string[]));
});
});

View File

@@ -0,0 +1,195 @@
import type { ILivechatInquiryRecord, ISidebarCategory } from '@rocket.chat/core-typings';
import { SIDEBAR_SYSTEM_GROUP_KEYS } from '@rocket.chat/core-typings';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useUserSubscriptions } from '@rocket.chat/ui-contexts';
import { useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf';
import { useMemo } from 'react';
import { filterGroupVisibility, getRoomCategory, useCategoryList } from './useCategoryList';
import { useHasLicenseModule } from '../../hooks/useHasLicenseModule';
import { useSortQueryOptions } from '../../hooks/useSortQueryOptions';
import { useOpenedRoom } from '../../lib/RoomManager';
import { useOmnichannelEnabled } from '../../views/omnichannel/hooks/useOmnichannelEnabled';
import { useQueuedInquiries } from '../../views/omnichannel/hooks/useQueuedInquiries';
import { useToggleUnreads } from '../categories/hooks/useToggleUnreads';
import { useUserSidebarCategories } from '../categories/hooks/useUserSidebarCategories';
const query = { open: { $ne: false } };
const emptyQueue: ILivechatInquiryRecord[] = [];
type GroupUnreadInfo = {
userMentions: number;
groupMentions: number;
tunread: string[];
tunreadUser: string[];
unread: number;
};
export type SidebarRoomListGroup = {
key: string;
title: string;
translateTitle: boolean;
category?: ISidebarCategory;
showUnreads: boolean;
keepUnreadsOnTop: boolean;
collapsed: boolean;
rooms: SubscriptionWithRoom[];
unreadInfo: GroupUnreadInfo;
empty: boolean;
};
type useRoomListReturnType = {
groups: SidebarRoomListGroup[];
groupsCount: number[];
totalCount: number;
};
export 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 { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
const { customCategories } = useUserSidebarCategories();
const { isShowUnreads, isKeepUnreadsOnTop } = useToggleUnreads();
const options = useSortQueryOptions();
const rooms = useUserSubscriptions(query, options);
const inquiries = useQueuedInquiries();
const categoryList = useCategoryList(showOmnichannel, inquiries.enabled);
const incomingCalls = useVideoConfIncomingCalls();
const openedRoom = useOpenedRoom();
const queue = inquiries.enabled ? inquiries.queue : emptyQueue;
const groups = useDebouncedValue(
useMemo(() => {
const isCollapsed = (key: string) => collapsedGroups?.includes(key) ?? false;
const unfilteredGroups = new Map<string, Set<SubscriptionWithRoom>>();
categoryList.forEach((category) => {
unfilteredGroups.set(category, new Set<SubscriptionWithRoom>());
});
rooms.forEach((room) => {
const roomCategory = getRoomCategory(room, {
groups: unfilteredGroups,
hasIncomingCalls: (rid: SubscriptionWithRoom['rid']) => {
return !!incomingCalls.find((call) => call.rid === rid);
},
});
if (!roomCategory) {
return;
}
const targetGroup = unfilteredGroups.get(roomCategory);
if (!targetGroup) {
return;
}
targetGroup.add(room);
});
if (unfilteredGroups.has('Incoming_Livechats')) {
unfilteredGroups.set('Incoming_Livechats', new Set(queue) as unknown as Set<SubscriptionWithRoom>);
}
const emptyUnreadInfo = (): GroupUnreadInfo => ({ userMentions: 0, groupMentions: 0, tunread: [], tunreadUser: [], unread: 0 });
const buildUnreadInfo = (roomsToCount: SubscriptionWithRoom[]): GroupUnreadInfo =>
roomsToCount.reduce<GroupUnreadInfo>((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;
if (!room.unread && !room.tunread?.length && room.alert) {
counter.unread += 1;
}
return counter;
}, emptyUnreadInfo());
const makeGroup = (key: string, set: Set<SubscriptionWithRoom>): SidebarRoomListGroup => {
const category = customCategories.find(({ _id }) => _id === key);
const title = category ? category.name : key;
const translateTitle = SIDEBAR_SYSTEM_GROUP_KEYS.includes(key as any);
const collapsed = isCollapsed(key);
const showUnreadsForGroup = hasLicenseModule ? isShowUnreads(key) : false;
const showUnreads = category ? Boolean(category.showUnreads) : showUnreadsForGroup;
const keepUnreadsOnTopForGroup = hasLicenseModule ? isKeepUnreadsOnTop(key) : false;
const keepUnreadsOnTop = category ? Boolean(category.keepUnreadsOnTop) : keepUnreadsOnTopForGroup;
const allRooms = [...set];
// A collapsed group still shows the room currently open, so the user can locate themselves in the
// sidebar, plus its unread rooms when "Show unreads" is enabled.
const isVisibleWhileCollapsed = (room: SubscriptionWithRoom) => room.rid === openedRoom || (showUnreads && isUnreadRoom(room));
let displayRooms = collapsed ? allRooms.filter(isVisibleWhileCollapsed) : allRooms;
// "Keep unreads on top": stable-partition so unread rooms come first, each partition keeping the
// configured sort (activity / a-z) it already has from the subscription query.
if (keepUnreadsOnTop) {
displayRooms = [...displayRooms.filter(isUnreadRoom), ...displayRooms.filter((room) => !isUnreadRoom(room))];
}
return {
key,
title,
translateTitle,
category,
showUnreads,
keepUnreadsOnTop,
collapsed,
rooms: displayRooms,
// The header total badge only accounts for what the collapsed group hides. Rooms kept visible
// while collapsed — the open one, and the unread ones when "Show unreads" is on — carry their
// own counters, so counting them here as well would duplicate them.
unreadInfo: collapsed ? buildUnreadInfo(allRooms.filter((room) => !isVisibleWhileCollapsed(room))) : emptyUnreadInfo(),
empty: allRooms.length === 0,
};
};
const groups = filterGroupVisibility(unfilteredGroups, hasLicenseModule, makeGroup);
return groups;
}, [
categoryList,
rooms,
hasLicenseModule,
collapsedGroups,
incomingCalls,
queue,
customCategories,
isShowUnreads,
isKeepUnreadsOnTop,
openedRoom,
]),
50,
);
// Group ordering is applied AFTER the debounce so that "Move up / Move down"
// takes effect immediately rather than waiting for the 50 ms settling period.
const groupsCount = useMemo(() => groups.map((group) => (group.empty ? 0 : group.rooms.length)), [groups]);
return {
groups,
groupsCount,
totalCount: useMemo(() => groupsCount.reduce((acc, count) => acc + count, 0), [groupsCount]),
};
};

View File

@@ -0,0 +1,21 @@
import type { RefObject } from 'react';
import { useEffect } from 'react';
import tinykeys from 'tinykeys';
// used to open the menu option by keyboard
export const useShortcutOpenMenu = (ref: RefObject<Element | null>): void => {
useEffect(() => {
const unsubscribe = tinykeys(ref.current as HTMLElement, {
Alt: (event) => {
if (!(event.target as HTMLElement).className.includes('rcx-sidebar-item')) {
return;
}
event.preventDefault();
(event.target as HTMLElement).querySelector('button')?.click();
},
});
return (): void => {
unsubscribe();
};
}, [ref]);
};

View File

@@ -0,0 +1,51 @@
import { SIDEBAR_SYSTEM_GROUP_KEYS } from '@rocket.chat/core-typings';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react';
import { useSidebarSectionsOrder } from './useSidebarSectionsOrder';
jest.mock('@rocket.chat/ui-contexts', () => ({
useUserPreference: jest.fn(),
}));
const mockedUseUserPreference = jest.mocked(useUserPreference);
it('falls back to the system group keys when the preference is unset', () => {
mockedUseUserPreference.mockReturnValue(undefined);
const { result } = renderHook(() => useSidebarSectionsOrder());
expect(result.current).toEqual(SIDEBAR_SYSTEM_GROUP_KEYS);
});
it('drops keys of system groups that no longer exist', () => {
mockedUseUserPreference.mockReturnValue(['Unread', 'Drafts', 'Favorites', 'Conversations']);
const { result } = renderHook(() => useSidebarSectionsOrder());
expect(result.current).toEqual(['Unread', 'Favorites', 'Conversations']);
});
it('keeps the stored order of the remaining keys', () => {
mockedUseUserPreference.mockReturnValue(['Conversations', 'Drafts', 'Favorites', 'Unread']);
const { result } = renderHook(() => useSidebarSectionsOrder());
expect(result.current).toEqual(['Conversations', 'Favorites', 'Unread']);
});
it('does not treat custom category ids as section keys', () => {
mockedUseUserPreference.mockReturnValue(['Favorites', 'custom-xyz']);
const { result } = renderHook(() => useSidebarSectionsOrder());
expect(result.current).toEqual(['Favorites']);
});
it('returns an empty list when every stored key is stale', () => {
mockedUseUserPreference.mockReturnValue(['Drafts']);
const { result } = renderHook(() => useSidebarSectionsOrder());
expect(result.current).toEqual([]);
});

View File

@@ -0,0 +1,9 @@
import { SIDEBAR_SYSTEM_GROUP_KEYS, isSidebarSystemGroupKey } from '@rocket.chat/core-typings';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
export const useSidebarSectionsOrder = (): readonly string[] => {
const stored = useUserPreference<string[]>('sidebarSectionsOrder');
return useMemo(() => (stored ? stored.filter(isSidebarSystemGroupKey) : SIDEBAR_SYSTEM_GROUP_KEYS), [stored]);
};

View File

@@ -0,0 +1,21 @@
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import Condensed from '../Item/Condensed';
import Extended from '../Item/Extended';
import Medium from '../Item/Medium';
export const useTemplateByViewMode = (): typeof Condensed | typeof Extended | typeof Medium => {
const sidebarViewMode = useUserPreference('sidebarViewMode');
return useMemo(() => {
switch (sidebarViewMode) {
case 'extended':
return Extended;
case 'medium':
return Medium;
case 'condensed':
default:
return Condensed;
}
}, [sidebarViewMode]);
};

View File

@@ -0,0 +1,224 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import { useUnreadDisplay } from './useUnreadDisplay';
import { createFakeSubscription } from '../../../tests/mocks/data';
const dmUnread = createFakeSubscription({
t: 'd',
unread: 3,
userMentions: 0,
groupMentions: 0,
tunread: undefined,
tunreadUser: undefined,
});
const dmThread = createFakeSubscription({
t: 'd',
unread: 3,
userMentions: 0,
groupMentions: 0,
tunread: ['1'],
tunreadUser: undefined,
});
const alert = createFakeSubscription({
t: 'p',
unread: 0,
userMentions: 0,
groupMentions: 0,
tunread: undefined,
tunreadUser: undefined,
alert: true,
});
const mentionAndGroupMention = createFakeSubscription({
t: 'p',
unread: 2,
userMentions: 1,
groupMentions: 1,
tunread: undefined,
tunreadUser: undefined,
alert: true,
});
const groupMention = createFakeSubscription({
t: 'p',
unread: 2,
userMentions: 0,
groupMentions: 2,
tunread: undefined,
tunreadUser: undefined,
alert: true,
});
const tunread = createFakeSubscription({
t: 'p',
unread: 0,
userMentions: 0,
groupMentions: 0,
tunread: ['1'],
tunreadUser: undefined,
alert: true,
});
const tunreadUser = createFakeSubscription({
t: 'p',
unread: 1,
userMentions: 0,
groupMentions: 0,
tunread: ['1'],
tunreadUser: ['1'],
alert: true,
});
const hideUnreadStatus = createFakeSubscription({
t: 'p',
hideUnreadStatus: true,
});
const hideUnreadAndMention = createFakeSubscription({
t: 'p',
hideUnreadStatus: true,
hideMentionStatus: true,
});
const noUnread = createFakeSubscription({
t: 'p',
unread: 0,
userMentions: 0,
groupMentions: 0,
tunread: undefined,
tunreadUser: undefined,
});
const wrapper = mockAppRoot()
.withTranslations('en', 'core', {
mentions_counter_one: '{{count}} mention',
mentions_counter_other: '{{count}} mentions',
threads_counter_one: '{{count}} unread threaded message',
threads_counter_other: '{{count}} unread threaded messages',
group_mentions_counter_one: '{{count}} group mention',
group_mentions_counter_other: '{{count}} group mentions',
unread_messages_counter_one: '{{count}} unread message',
unread_messages_counter_other: '{{count}} unread messages',
})
.build();
it('should return correct unread data for [Direct message unread]', async () => {
const { result } = renderHook(() => useUnreadDisplay(dmUnread), {
wrapper,
});
expect(result.current.unreadVariant).toBe('secondary');
expect(result.current.unreadTitle).toBe('3 unread messages');
expect(result.current.unreadCount).toHaveProperty('mentions', 0);
expect(result.current.unreadCount).toHaveProperty('threads', 0);
expect(result.current.unreadCount).toHaveProperty('groupMentions', 0);
expect(result.current.unreadCount).toHaveProperty('total', 3);
});
it('should return correct unread data for [Direct message with thread unread]', async () => {
const { result } = renderHook(() => useUnreadDisplay(dmThread), {
wrapper,
});
expect(result.current.unreadVariant).toBe('primary');
expect(result.current.unreadTitle).toBe('1 unread threaded message, 3 unread messages');
expect(result.current.unreadCount).toHaveProperty('mentions', 0);
expect(result.current.unreadCount).toHaveProperty('threads', 1);
expect(result.current.unreadCount).toHaveProperty('groupMentions', 0);
expect(result.current.unreadCount).toHaveProperty('total', 4);
});
it('should return correct unread data for [Channel with unread messages alert only]', async () => {
const { result } = renderHook(() => useUnreadDisplay(alert), {
wrapper,
});
expect(result.current.highlightUnread).toBe(true);
expect(result.current.showUnread).toBe(false);
expect(result.current.unreadCount).toHaveProperty('mentions', 0);
expect(result.current.unreadCount).toHaveProperty('threads', 0);
expect(result.current.unreadCount).toHaveProperty('groupMentions', 0);
expect(result.current.unreadCount).toHaveProperty('total', 0);
});
it('should return correct unread data for [Mention and group mention]', async () => {
const { result } = renderHook(() => useUnreadDisplay(mentionAndGroupMention), {
wrapper,
});
expect(result.current.unreadVariant).toBe('danger');
expect(result.current.unreadTitle).toBe('1 mention, 1 group mention');
expect(result.current.unreadCount).toHaveProperty('mentions', 1);
expect(result.current.unreadCount).toHaveProperty('threads', 0);
expect(result.current.unreadCount).toHaveProperty('groupMentions', 1);
expect(result.current.unreadCount).toHaveProperty('total', 2);
});
it('should return correct unread data for [Group mention]', async () => {
const { result } = renderHook(() => useUnreadDisplay(groupMention), {
wrapper,
});
expect(result.current.unreadVariant).toBe('warning');
expect(result.current.unreadTitle).toBe('2 group mentions');
expect(result.current.unreadCount).toHaveProperty('mentions', 0);
expect(result.current.unreadCount).toHaveProperty('threads', 0);
expect(result.current.unreadCount).toHaveProperty('groupMentions', 2);
expect(result.current.unreadCount).toHaveProperty('total', 2);
});
it('should return correct unread data for [Thread unread]', async () => {
const { result } = renderHook(() => useUnreadDisplay(tunread), {
wrapper,
});
expect(result.current.unreadVariant).toBe('primary');
expect(result.current.unreadTitle).toBe('1 unread threaded message');
expect(result.current.unreadCount).toHaveProperty('mentions', 0);
expect(result.current.unreadCount).toHaveProperty('groupMentions', 0);
expect(result.current.unreadCount).toHaveProperty('threads', 1);
expect(result.current.unreadCount).toHaveProperty('total', 1);
});
it('should return correct unread data for [Thread and thread user mention]', async () => {
const { result } = renderHook(() => useUnreadDisplay(tunreadUser), {
wrapper,
});
expect(result.current.unreadVariant).toBe('danger');
expect(result.current.unreadTitle).toBe('1 mention, 1 unread threaded message');
expect(result.current.unreadCount).toHaveProperty('mentions', 1);
expect(result.current.unreadCount).toHaveProperty('groupMentions', 0);
expect(result.current.unreadCount).toHaveProperty('threads', 1);
expect(result.current.unreadCount).toHaveProperty('total', 2);
});
it('should not highlight unread if hideUnreadStatus is enabled', async () => {
const { result } = renderHook(() => useUnreadDisplay(hideUnreadStatus));
expect(result.current.highlightUnread).toBe(false);
expect(result.current.showUnread).toBe(true);
});
it('should not show unread if hideUnreadStatus and hideMentionStatus is enabled', async () => {
const { result } = renderHook(() => useUnreadDisplay(hideUnreadAndMention));
expect(result.current.highlightUnread).toBe(false);
expect(result.current.showUnread).toBe(false);
});
it("should not show unread if there isn't any unread message", async () => {
const { result } = renderHook(() => useUnreadDisplay(noUnread));
expect(result.current.highlightUnread).toBe(false);
expect(result.current.showUnread).toBe(false);
expect(result.current.unreadCount).toHaveProperty('mentions', 0);
expect(result.current.unreadCount).toHaveProperty('groupMentions', 0);
expect(result.current.unreadCount).toHaveProperty('threads', 0);
expect(result.current.unreadCount).toHaveProperty('total', 0);
});

View File

@@ -0,0 +1,10 @@
import { useTranslation } from 'react-i18next';
import type { UnreadData } from '../../../lib/getSubscriptionUnreadData';
import { getSubscriptionUnreadData } from '../../../lib/getSubscriptionUnreadData';
export const useUnreadDisplay = (unreadData: UnreadData) => {
const { t } = useTranslation();
return getSubscriptionUnreadData(unreadData, t);
};

View File

@@ -0,0 +1 @@
export { default } from './SidebarRegion';

View File

@@ -0,0 +1,25 @@
import { SidebarBanner } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import AirGappedRestrictionWarning from './AirGappedRestrictionWarning';
import { links } from '../../../lib/links';
export type AirGappedRestrictionSectionProps = { isRestricted: boolean; remainingDays: number };
const AirGappedRestrictionSection = ({ isRestricted, remainingDays }: AirGappedRestrictionSectionProps) => {
const { t } = useTranslation();
return (
<SidebarBanner
title={<AirGappedRestrictionWarning isRestricted={isRestricted} remainingDays={remainingDays} />}
linkText={t('Learn_more')}
linkProps={{
target: '_blank',
rel: 'noopener noreferrer',
href: links.go.airgappedRestriction,
}}
/>
);
};
export default AirGappedRestrictionSection;

View File

@@ -0,0 +1,28 @@
import { Box } from '@rocket.chat/fuselage';
import { Trans } from 'react-i18next';
export type AirGappedRestrictionWarningProps = { isRestricted: boolean; remainingDays: number };
const AirGappedRestrictionWarning = ({ isRestricted, remainingDays }: AirGappedRestrictionWarningProps) => {
if (isRestricted) {
return (
<Trans i18nKey='Airgapped_workspace_restriction'>
This air-gapped workspace is in read-only mode.{' '}
<Box fontScale='p2' is='span'>
Connect it to internet or upgraded to a premium plan to restore full functionality.
</Box>
</Trans>
);
}
return (
<Trans i18nKey='Airgapped_workspace_warning' values={{ remainingDays }}>
This air-gapped workspace will enter read-only mode in {remainingDays} days.{' '}
<Box fontScale='p2' is='span'>
Connect it to internet or upgrade to a premium plan to prevent this.
</Box>
</Trans>
);
};
export default AirGappedRestrictionWarning;

View File

@@ -0,0 +1,63 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import BannerSection from './BannerSection';
// TODO: test presence banner
describe('Sidebar -> BannerSection -> Airgapped restriction', () => {
it('Should render null if restricted and not admin', () => {
render(<BannerSection />, {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0)
.build(),
});
expect(screen.queryByText('air-gapped', { exact: false })).not.toBeInTheDocument();
});
it('Should render null if admin and not restricted or warning', () => {
render(<BannerSection />, {
wrapper: mockAppRoot().withJohnDoe().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 8).build(),
});
expect(screen.queryByText('air-gapped', { exact: false })).not.toBeInTheDocument();
});
it('Should render warning message if admin and warning phase', () => {
render(<BannerSection />, {
wrapper: mockAppRoot()
.withJohnDoe()
.withRole('admin')
.withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 7)
.build(),
});
expect(screen.getByText('will enter read-only', { exact: false })).toBeInTheDocument();
});
it('Should render restriction message if admin and restricted phase', () => {
render(<BannerSection />, {
wrapper: mockAppRoot()
.withJohnDoe()
.withRole('admin')
.withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0)
.build(),
});
expect(screen.getByText('is in read-only', { exact: false })).toBeInTheDocument();
});
it('Should render restriction message instead of another banner', () => {
render(<BannerSection />, {
wrapper: mockAppRoot()
.withJohnDoe()
.withRole('admin')
.withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0)
.withSetting('Presence_broadcast_disabled', true)
.build(),
});
expect(screen.getByText('is in read-only', { exact: false })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,26 @@
import { useSessionStorage } from '@rocket.chat/fuselage-hooks';
import { useRole, useSetting } from '@rocket.chat/ui-contexts';
import AirGappedRestrictionBanner from './AirGappedRestrictionBanner/AirGappedRestrictionBanner';
import StatusDisabledBanner from './StatusDisabledBanner';
import { useAirGappedRestriction } from '../../hooks/useAirGappedRestriction';
const BannerSection = () => {
const [isRestricted, isWarning, remainingDays] = useAirGappedRestriction();
const isAdmin = useRole('admin');
const [bannerDismissed, setBannerDismissed] = useSessionStorage('presence_cap_notifier', false);
const presenceDisabled = useSetting('Presence_broadcast_disabled', false);
if ((isWarning || isRestricted) && isAdmin) {
return <AirGappedRestrictionBanner isRestricted={isRestricted} remainingDays={remainingDays} />;
}
if (presenceDisabled && !bannerDismissed) {
return <StatusDisabledBanner onDismiss={() => setBannerDismissed(true)} />;
}
return null;
};
export default BannerSection;

View File

@@ -0,0 +1,85 @@
import { AudioPlayerControls, Box, IconButton } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import SidebarCard from './SidebarCard';
import { useFormatMemorySize } from '../../hooks/useFormatMemorySize';
import { setMessageJumpQueryStringParameter } from '../../lib/utils/setMessageJumpQueryStringParameter';
import { useMediaPlayer } from '../../providers/MediaPlayerProvider';
import { useGoToRoom } from '../../views/room/hooks/useGoToRoom';
/**
* Persistent audio card pinned to the bottom of the sidebar (design 1c).
* It drives the shared audio element, so it stays available while a track is
* loaded — including after switching or closing the room, or closing the thread
* the audio was played from.
*/
const NowPlayingSection = () => {
const { t } = useTranslation();
const { track, playing, currentTime, duration, playbackRate, toggle, seek, cyclePlaybackRate, close } = useMediaPlayer();
const goToRoom = useGoToRoom();
const formatMemorySize = useFormatMemorySize();
const displayName = useUserDisplayName({ name: track?.name, username: track?.username });
if (!track) {
return null;
}
const openConversation = async () => {
if (!track.rid) {
return;
}
await goToRoom(track.rid);
if (track.mid) {
void setMessageJumpQueryStringParameter(track.mid);
}
};
return (
<SidebarCard>
<Box display='flex' alignItems='center' justifyContent='space-between' marginBlockEnd={10} style={{ gap: 8 }}>
<Box
display='flex'
alignItems='center'
minWidth={0}
flexGrow={1}
role='button'
tabIndex={0}
onClick={() => void openConversation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
void openConversation();
}
}}
title={t('Jump_to_message')}
style={{ gap: 8, cursor: track.rid ? 'pointer' : 'default' }}
>
{track.username && <UserAvatar username={track.username} size='x24' />}
<Box minWidth={0}>
<Box fontScale='p2b' color='default' withTruncatedText>
{displayName || t('Audio')}
</Box>
<Box fontScale='micro' color='hint' withTruncatedText>
{track.size ? `${track.title} (${formatMemorySize(track.size)})` : track.title}
</Box>
</Box>
</Box>
<IconButton mini icon='cross' onClick={close} title={t('Close')} aria-label={t('Close')} />
</Box>
<AudioPlayerControls
isPlaying={playing}
currentTime={currentTime}
durationTime={duration}
playbackSpeed={playbackRate}
onTogglePlay={toggle}
onSeek={seek}
onChangePlaybackSpeed={cyclePlaybackRate}
/>
</SidebarCard>
);
};
export default NowPlayingSection;

View File

@@ -0,0 +1,24 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type SidebarCardProps = { children: ReactNode };
/**
* Reusable elevated surface for sidebar sections: a padded, rounded, subtly
* bordered container in the sidebar tint.
*/
const SidebarCard = ({ children }: SidebarCardProps) => (
<Box
margin={8}
padding={10}
borderRadius='x8'
borderWidth='default'
borderStyle='solid'
borderColor='stroke-light'
backgroundColor='surface-tint'
>
{children}
</Box>
);
export default SidebarCard;

View File

@@ -0,0 +1,22 @@
import { SidebarBanner } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useStatusDisabledModal } from '../../views/admin/customUserStatus/hooks/useStatusDisabledModal';
export type StatusDisabledSectionProps = { onDismiss: () => void };
const StatusDisabledSection = ({ onDismiss }: StatusDisabledSectionProps) => {
const { t } = useTranslation();
const handleStatusDisabledModal = useStatusDisabledModal();
return (
<SidebarBanner
title={t('User_status_temporarily_disabled')}
linkText={t('Learn_more')}
onClose={onDismiss}
linkProps={{ onClick: handleStatusDisabledModal, tabIndex: 0 }}
/>
);
};
export default StatusDisabledSection;