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,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);
};