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
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:
82
apps/meteor/client/sidebar/Item/Condensed.stories.tsx
Normal file
82
apps/meteor/client/sidebar/Item/Condensed.stories.tsx
Normal 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' />
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
46
apps/meteor/client/sidebar/Item/Condensed.tsx
Normal file
46
apps/meteor/client/sidebar/Item/Condensed.tsx
Normal 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);
|
||||
97
apps/meteor/client/sidebar/Item/Extended.stories.tsx
Normal file
97
apps/meteor/client/sidebar/Item/Extended.stories.tsx
Normal 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' />
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
84
apps/meteor/client/sidebar/Item/Extended.tsx
Normal file
84
apps/meteor/client/sidebar/Item/Extended.tsx
Normal 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);
|
||||
81
apps/meteor/client/sidebar/Item/Medium.stories.tsx
Normal file
81
apps/meteor/client/sidebar/Item/Medium.stories.tsx
Normal 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' />
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
45
apps/meteor/client/sidebar/Item/Medium.tsx
Normal file
45
apps/meteor/client/sidebar/Item/Medium.tsx
Normal 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);
|
||||
59
apps/meteor/client/sidebar/Item/useDeferredMenuMount.ts
Normal file
59
apps/meteor/client/sidebar/Item/useDeferredMenuMount.ts
Normal 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 };
|
||||
};
|
||||
101
apps/meteor/client/sidebar/RoomList/RoomList.tsx
Normal file
101
apps/meteor/client/sidebar/RoomList/RoomList.tsx
Normal 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;
|
||||
72
apps/meteor/client/sidebar/RoomList/RoomListCollapser.tsx
Normal file
72
apps/meteor/client/sidebar/RoomList/RoomListCollapser.tsx
Normal 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;
|
||||
56
apps/meteor/client/sidebar/RoomList/RoomListRow.tsx
Normal file
56
apps/meteor/client/sidebar/RoomList/RoomListRow.tsx
Normal 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);
|
||||
11
apps/meteor/client/sidebar/RoomList/RoomListRowWrapper.tsx
Normal file
11
apps/meteor/client/sidebar/RoomList/RoomListRowWrapper.tsx
Normal 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;
|
||||
18
apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx
Normal file
18
apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx
Normal 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;
|
||||
@@ -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;
|
||||
});
|
||||
1
apps/meteor/client/sidebar/RoomList/index.ts
Normal file
1
apps/meteor/client/sidebar/RoomList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './RoomList';
|
||||
103
apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts
Normal file
103
apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts
Normal 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 };
|
||||
};
|
||||
146
apps/meteor/client/sidebar/RoomMenu.spec.tsx
Normal file
146
apps/meteor/client/sidebar/RoomMenu.spec.tsx
Normal 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();
|
||||
});
|
||||
36
apps/meteor/client/sidebar/RoomMenu.tsx
Normal file
36
apps/meteor/client/sidebar/RoomMenu.tsx
Normal 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);
|
||||
113
apps/meteor/client/sidebar/Sidebar.stories.tsx
Normal file
113
apps/meteor/client/sidebar/Sidebar.stories.tsx
Normal 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>
|
||||
),
|
||||
],
|
||||
};
|
||||
36
apps/meteor/client/sidebar/Sidebar.tsx
Normal file
36
apps/meteor/client/sidebar/Sidebar.tsx
Normal 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);
|
||||
112
apps/meteor/client/sidebar/SidebarRegion.tsx
Normal file
112
apps/meteor/client/sidebar/SidebarRegion.tsx
Normal 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);
|
||||
78
apps/meteor/client/sidebar/badges/SidebarItemBadges.spec.tsx
Normal file
78
apps/meteor/client/sidebar/badges/SidebarItemBadges.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
26
apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx
Normal file
26
apps/meteor/client/sidebar/badges/SidebarItemBadges.tsx
Normal 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;
|
||||
26
apps/meteor/client/sidebar/badges/UnreadBadge.tsx
Normal file
26
apps/meteor/client/sidebar/badges/UnreadBadge.tsx
Normal 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;
|
||||
185
apps/meteor/client/sidebar/categories/CategoryMenu.tsx
Normal file
185
apps/meteor/client/sidebar/categories/CategoryMenu.tsx
Normal 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;
|
||||
74
apps/meteor/client/sidebar/categories/CategoryRoomMenu.tsx
Normal file
74
apps/meteor/client/sidebar/categories/CategoryRoomMenu.tsx
Normal 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);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
112
apps/meteor/client/sidebar/categories/ManageCategoryModal.tsx
Normal file
112
apps/meteor/client/sidebar/categories/ManageCategoryModal.tsx
Normal 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;
|
||||
@@ -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] : [])],
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 } }),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
7
apps/meteor/client/sidebar/footer/SidebarFooter.tsx
Normal file
7
apps/meteor/client/sidebar/footer/SidebarFooter.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import SidebarFooterDefault from './SidebarFooterDefault';
|
||||
|
||||
const SidebarFooter = () => {
|
||||
return <SidebarFooterDefault />;
|
||||
};
|
||||
|
||||
export default SidebarFooter;
|
||||
36
apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx
Normal file
36
apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx
Normal 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;
|
||||
40
apps/meteor/client/sidebar/footer/SidebarFooterWatermark.tsx
Normal file
40
apps/meteor/client/sidebar/footer/SidebarFooterWatermark.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
apps/meteor/client/sidebar/footer/index.ts
Normal file
1
apps/meteor/client/sidebar/footer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SidebarFooter';
|
||||
39
apps/meteor/client/sidebar/hooks/useAvatarTemplate.tsx
Normal file
39
apps/meteor/client/sidebar/hooks/useAvatarTemplate.tsx
Normal 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]);
|
||||
};
|
||||
125
apps/meteor/client/sidebar/hooks/useCategoryList.spec.ts
Normal file
125
apps/meteor/client/sidebar/hooks/useCategoryList.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
218
apps/meteor/client/sidebar/hooks/useCategoryList.ts
Normal file
218
apps/meteor/client/sidebar/hooks/useCategoryList.ts
Normal 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;
|
||||
};
|
||||
30
apps/meteor/client/sidebar/hooks/useCollapsedGroups.ts
Normal file
30
apps/meteor/client/sidebar/hooks/useCollapsedGroups.ts
Normal 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 };
|
||||
};
|
||||
21
apps/meteor/client/sidebar/hooks/usePreventDefault.ts
Normal file
21
apps/meteor/client/sidebar/hooks/usePreventDefault.ts
Normal 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 };
|
||||
};
|
||||
391
apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx
Normal file
391
apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx
Normal 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[]));
|
||||
});
|
||||
});
|
||||
195
apps/meteor/client/sidebar/hooks/useRoomList.ts
Normal file
195
apps/meteor/client/sidebar/hooks/useRoomList.ts
Normal 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]),
|
||||
};
|
||||
};
|
||||
21
apps/meteor/client/sidebar/hooks/useShortcutOpenMenu.ts
Normal file
21
apps/meteor/client/sidebar/hooks/useShortcutOpenMenu.ts
Normal 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]);
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
21
apps/meteor/client/sidebar/hooks/useTemplateByViewMode.ts
Normal file
21
apps/meteor/client/sidebar/hooks/useTemplateByViewMode.ts
Normal 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]);
|
||||
};
|
||||
224
apps/meteor/client/sidebar/hooks/useUnreadDisplay.spec.ts
Normal file
224
apps/meteor/client/sidebar/hooks/useUnreadDisplay.spec.ts
Normal 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);
|
||||
});
|
||||
10
apps/meteor/client/sidebar/hooks/useUnreadDisplay.ts
Normal file
10
apps/meteor/client/sidebar/hooks/useUnreadDisplay.ts
Normal 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);
|
||||
};
|
||||
1
apps/meteor/client/sidebar/index.ts
Normal file
1
apps/meteor/client/sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SidebarRegion';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
63
apps/meteor/client/sidebar/sections/BannerSection.spec.tsx
Normal file
63
apps/meteor/client/sidebar/sections/BannerSection.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
26
apps/meteor/client/sidebar/sections/BannerSection.tsx
Normal file
26
apps/meteor/client/sidebar/sections/BannerSection.tsx
Normal 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;
|
||||
85
apps/meteor/client/sidebar/sections/NowPlayingSection.tsx
Normal file
85
apps/meteor/client/sidebar/sections/NowPlayingSection.tsx
Normal 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;
|
||||
24
apps/meteor/client/sidebar/sections/SidebarCard.tsx
Normal file
24
apps/meteor/client/sidebar/sections/SidebarCard.tsx
Normal 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;
|
||||
22
apps/meteor/client/sidebar/sections/StatusDisabledBanner.tsx
Normal file
22
apps/meteor/client/sidebar/sections/StatusDisabledBanner.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user