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:
24
apps/meteor/client/views/room/BubbleDate/BubbleDate.tsx
Normal file
24
apps/meteor/client/views/room/BubbleDate/BubbleDate.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Box, Bubble } from '@rocket.chat/fuselage';
|
||||
import type { Ref } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { useFormatDate } from '../../../hooks/useFormatDate';
|
||||
import type { BubbleDateProps } from '../hooks/useDateScroll';
|
||||
|
||||
export const BubbleDate = forwardRef(function BubbleDate(
|
||||
{ bubbleDate, showBubble, bubbleDateStyle, bubbleDateClassName }: BubbleDateProps,
|
||||
ref: Ref<HTMLElement>,
|
||||
) {
|
||||
const formatDate = useFormatDate();
|
||||
return (
|
||||
<Box ref={ref} position='relative' display='flex' justifyContent='center'>
|
||||
<Box className={[bubbleDateClassName, showBubble && 'bubble-visible']} style={bubbleDateStyle}>
|
||||
{bubbleDate && (
|
||||
<Bubble small secondary>
|
||||
{formatDate(bubbleDate)}
|
||||
</Bubble>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
1
apps/meteor/client/views/room/BubbleDate/index.tsx
Normal file
1
apps/meteor/client/views/room/BubbleDate/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './BubbleDate';
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { IRoom, RoomType } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import ClassificationBanner from './ClassificationBanner';
|
||||
import type { ClassificationBannersConfig } from './lib/types';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeLicenseInfo } from '../../../../tests/mocks/data';
|
||||
|
||||
const config: ClassificationBannersConfig = {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
banner: {
|
||||
style: 'classic',
|
||||
uppercase: true,
|
||||
monospace: false,
|
||||
delimiter: ' // ',
|
||||
colorMode: 'highest',
|
||||
fallbackText: 'NO CLASSIFICATION DATA',
|
||||
fallbackColor: '#6C727A',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
id: 'classification',
|
||||
source: 'clearance.level',
|
||||
label: 'Classification level',
|
||||
showInBanner: true,
|
||||
showLabel: false,
|
||||
bannerLabel: '',
|
||||
labelSeparator: '',
|
||||
valueSeparator: '/',
|
||||
sortAlpha: false,
|
||||
groupThreshold: 0,
|
||||
multipleLabel: '',
|
||||
drivesColor: true,
|
||||
values: [
|
||||
{ source: 'TS-SCI', label: 'TOP SECRET//SCI', color: '#fce100' },
|
||||
{ source: 'TS', label: 'TOP SECRET', color: '#ff8c00' },
|
||||
{ source: 'S', label: 'SECRET', color: '#c8102e' },
|
||||
{ source: 'C', label: 'CONFIDENTIAL', color: '#0033a0' },
|
||||
{ source: 'CUI', label: 'CUI', color: '#502b85' },
|
||||
{ source: 'U', label: 'UNCLASSIFIED', color: '#007a33' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sar',
|
||||
source: 'access.programs',
|
||||
label: 'Special access programs',
|
||||
showInBanner: true,
|
||||
showLabel: true,
|
||||
bannerLabel: 'SAR',
|
||||
labelSeparator: '-',
|
||||
valueSeparator: '/',
|
||||
sortAlpha: true,
|
||||
groupThreshold: 4,
|
||||
multipleLabel: 'MULTIPLE PROGRAMS',
|
||||
drivesColor: false,
|
||||
values: [
|
||||
{ source: 'SAP-1042', label: 'APPLES', color: '#c8102e' },
|
||||
{ source: 'SAP-2271', label: 'BANANAS', color: '#ff8c00' },
|
||||
{ source: 'SAP-3380', label: 'ORANGES', color: '#0033a0' },
|
||||
{ source: 'SAP-4419', label: 'PEACHES', color: '#007a33' },
|
||||
{ source: 'SAP-5567', label: 'GRAPES', color: '#502b85' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'relto',
|
||||
source: 'dissem.relto',
|
||||
label: 'Releasable to',
|
||||
showInBanner: true,
|
||||
showLabel: true,
|
||||
bannerLabel: 'RELTO',
|
||||
labelSeparator: ' ',
|
||||
valueSeparator: '/',
|
||||
sortAlpha: false,
|
||||
groupThreshold: 0,
|
||||
multipleLabel: '',
|
||||
drivesColor: false,
|
||||
values: [
|
||||
{ source: 'USA', label: 'USA', color: '#0033a0' },
|
||||
{ source: 'FVEY', label: 'FVEY', color: '#007a33' },
|
||||
{ source: 'NATO', label: 'NATO', color: '#502b85' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const roomAttributes = [
|
||||
{ key: 'clearance.level', values: ['TS'] },
|
||||
{ key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380'] },
|
||||
{ key: 'dissem.relto', values: ['USA'] },
|
||||
];
|
||||
|
||||
const roomArgs: Partial<IRoom> = {
|
||||
_id: 'classifiedRoom',
|
||||
t: 'p' as RoomType,
|
||||
name: 'operation-nightingale',
|
||||
fname: 'operation-nightingale',
|
||||
abacAttributes: roomAttributes,
|
||||
};
|
||||
|
||||
const withBanner = (bannerConfig: ClassificationBannersConfig, room: Partial<IRoom> = roomArgs) =>
|
||||
mockAppRoot()
|
||||
.withJohnDoe()
|
||||
.withSetting('ABAC_Enabled', true)
|
||||
.withSetting('ABAC_Classification_Banners_Enabled', true)
|
||||
.withSetting('ABAC_Classification_Banners_Config', JSON.stringify(bannerConfig))
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
license: createFakeLicenseInfo({ activeModules: ['abac'] }),
|
||||
}))
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={room}>{children}</FakeRoomProvider>)
|
||||
.buildStoryDecorator();
|
||||
|
||||
export default {
|
||||
component: ClassificationBanner,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [withBanner(config)],
|
||||
} satisfies Meta<typeof ClassificationBanner>;
|
||||
|
||||
export const Classic: StoryObj<typeof ClassificationBanner> = {};
|
||||
|
||||
export const TopSecret: StoryObj<typeof ClassificationBanner> = {
|
||||
decorators: [withBanner(config, { ...roomArgs, abacAttributes: [{ key: 'clearance.level', values: ['TS-SCI'] }] })],
|
||||
};
|
||||
|
||||
export const Unclassified: StoryObj<typeof ClassificationBanner> = {
|
||||
decorators: [withBanner(config, { ...roomArgs, abacAttributes: [{ key: 'clearance.level', values: ['U'] }] })],
|
||||
};
|
||||
|
||||
export const GroupedPrograms: StoryObj<typeof ClassificationBanner> = {
|
||||
decorators: [
|
||||
withBanner(config, {
|
||||
...roomArgs,
|
||||
abacAttributes: [
|
||||
{ key: 'clearance.level', values: ['S'] },
|
||||
{ key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380', 'SAP-4419', 'SAP-5567'] },
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export const Fallback: StoryObj<typeof ClassificationBanner> = {
|
||||
decorators: [withBanner(config, { ...roomArgs, abacAttributes: [{ key: 'unmapped.attribute', values: ['whatever'] }] })],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { buildClassificationBanner, parseClassificationBannersConfig } from './lib/engine';
|
||||
import { useIsABACManagedRoom } from '../../admin/ABAC/hooks/useIsABACManagedRoom';
|
||||
import { useRoom } from '../contexts/RoomContext';
|
||||
|
||||
const ClassificationBanner = () => {
|
||||
const { t } = useTranslation();
|
||||
const room = useRoom();
|
||||
const isABACRoom = useIsABACManagedRoom(room);
|
||||
const bannersEnabled = useSetting('ABAC_Classification_Banners_Enabled', false);
|
||||
const rawConfig = useSetting('ABAC_Classification_Banners_Config', '');
|
||||
const enabled = bannersEnabled && isABACRoom;
|
||||
|
||||
const banner = useMemo(() => {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = parseClassificationBannersConfig(rawConfig);
|
||||
return config?.enabled ? buildClassificationBanner(config, room.abacAttributes ?? []) : null;
|
||||
}, [enabled, rawConfig, room.abacAttributes]);
|
||||
|
||||
if (!banner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
role='region'
|
||||
aria-live='polite'
|
||||
aria-label={t('ABAC_Room_Attributes')}
|
||||
{...(banner.monospace && { fontFamily: 'mono' })}
|
||||
backgroundColor={banner.backgroundColor}
|
||||
color={banner.color}
|
||||
textTransform={banner.uppercase ? 'uppercase' : 'none'}
|
||||
fontScale='c2'
|
||||
height='x20'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
>
|
||||
<Box is='span' withTruncatedText>
|
||||
{banner.text}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClassificationBanner;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ClassificationBanner';
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Picks a readable text color for a solid background fill.
|
||||
*
|
||||
* Computes each candidate's WCAG contrast ratio against the background (sRGB channels are
|
||||
* gamma-decoded to linear light, then weighted by how strongly the eye perceives each: 21% red,
|
||||
* 72% green, 7% blue) and keeps the higher-contrast one — so admins can pick any banner color
|
||||
* and the label stays legible.
|
||||
*/
|
||||
export const readableTextColor = (hex: string): '#1F2329' | '#FFFFFF' => {
|
||||
const luminanceOf = (color: string): number => {
|
||||
const [r, g, b] = [0, 2, 4].map((offset) => parseInt(color.replace('#', '').slice(offset, offset + 2), 16) / 255);
|
||||
const linearize = (channel: number): number => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
||||
};
|
||||
|
||||
const background = luminanceOf(hex);
|
||||
const contrastWith = (ink: string): number => {
|
||||
const text = luminanceOf(ink);
|
||||
return (Math.max(background, text) + 0.05) / (Math.min(background, text) + 0.05);
|
||||
};
|
||||
|
||||
return contrastWith('#1F2329') >= contrastWith('#FFFFFF') ? '#1F2329' : '#FFFFFF';
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { readableTextColor } from './colors';
|
||||
import { buildClassificationBanner, parseClassificationBannersConfig } from './engine';
|
||||
import type { ClassificationBannersConfig } from './types';
|
||||
|
||||
const config: ClassificationBannersConfig = {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
banner: {
|
||||
style: 'classic',
|
||||
uppercase: true,
|
||||
monospace: false,
|
||||
delimiter: ' // ',
|
||||
colorMode: 'highest',
|
||||
fallbackText: 'NO CLASSIFICATION DATA',
|
||||
fallbackColor: '#6C727A',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
id: 'classification',
|
||||
source: 'clearance.level',
|
||||
label: 'Classification level',
|
||||
showInBanner: true,
|
||||
showLabel: false,
|
||||
bannerLabel: '',
|
||||
labelSeparator: '',
|
||||
valueSeparator: '/',
|
||||
sortAlpha: false,
|
||||
groupThreshold: 0,
|
||||
multipleLabel: '',
|
||||
drivesColor: true,
|
||||
values: [
|
||||
{ source: 'TS-SCI', label: 'TOP SECRET//SCI', color: '#fce100' },
|
||||
{ source: 'TS', label: 'TOP SECRET', color: '#ff8c00' },
|
||||
{ source: 'S', label: 'SECRET', color: '#c8102e' },
|
||||
{ source: 'C', label: 'CONFIDENTIAL', color: '#0033a0' },
|
||||
{ source: 'CUI', label: 'CUI', color: '#502b85' },
|
||||
{ source: 'U', label: 'UNCLASSIFIED', color: '#007a33' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sar',
|
||||
source: 'access.programs',
|
||||
label: 'Special access programs',
|
||||
showInBanner: true,
|
||||
showLabel: true,
|
||||
bannerLabel: 'SAR',
|
||||
labelSeparator: '-',
|
||||
valueSeparator: '/',
|
||||
sortAlpha: true,
|
||||
groupThreshold: 4,
|
||||
multipleLabel: 'MULTIPLE PROGRAMS',
|
||||
drivesColor: false,
|
||||
values: [
|
||||
{ source: 'SAP-1042', label: 'APPLES', color: '#c8102e' },
|
||||
{ source: 'SAP-2271', label: 'BANANAS', color: '#ff8c00' },
|
||||
{ source: 'SAP-3380', label: 'ORANGES', color: '#0033a0' },
|
||||
{ source: 'SAP-4419', label: 'PEACHES', color: '#007a33' },
|
||||
{ source: 'SAP-5567', label: 'GRAPES', color: '#502b85' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'relto',
|
||||
source: 'dissem.relto',
|
||||
label: 'Releasable to',
|
||||
showInBanner: true,
|
||||
showLabel: true,
|
||||
bannerLabel: 'RELTO',
|
||||
labelSeparator: ' ',
|
||||
valueSeparator: '/',
|
||||
sortAlpha: false,
|
||||
groupThreshold: 0,
|
||||
multipleLabel: '',
|
||||
drivesColor: false,
|
||||
values: [
|
||||
{ source: 'USA', label: 'USA', color: '#0033a0' },
|
||||
{ source: 'FVEY', label: 'FVEY', color: '#007a33' },
|
||||
{ source: 'NATO', label: 'NATO', color: '#502b85' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dissem',
|
||||
source: 'dissem.controls',
|
||||
label: 'Dissemination controls',
|
||||
showInBanner: false,
|
||||
showLabel: false,
|
||||
bannerLabel: '',
|
||||
labelSeparator: '',
|
||||
valueSeparator: '/',
|
||||
sortAlpha: true,
|
||||
groupThreshold: 0,
|
||||
multipleLabel: '',
|
||||
drivesColor: false,
|
||||
values: [
|
||||
{ source: 'NF', label: 'NOFORN', color: '#c8102e' },
|
||||
{ source: 'OC', label: 'ORCON', color: '#ff8c00' },
|
||||
{ source: 'IM', label: 'IMCON', color: '#0033a0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildClassificationBanner', () => {
|
||||
it('assembles the default prototype output', () => {
|
||||
const banner = buildClassificationBanner(config, [
|
||||
{ key: 'clearance.level', values: ['TS'] },
|
||||
{ key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380'] },
|
||||
{ key: 'dissem.relto', values: ['USA'] },
|
||||
]);
|
||||
|
||||
expect(banner.text).toBe('TOP SECRET // SAR-APPLES/BANANAS/ORANGES // RELTO USA');
|
||||
expect(banner.segments.map((s) => s.attrId)).toEqual(['classification', 'sar', 'relto']);
|
||||
expect(banner.backgroundColor).toBe('#ff8c00');
|
||||
expect(banner.color).toBe('#1F2329');
|
||||
expect(banner).toMatchObject({ style: 'classic', uppercase: true, monospace: false });
|
||||
});
|
||||
|
||||
it('collapses to multipleLabel when value count reaches groupThreshold', () => {
|
||||
const banner = buildClassificationBanner(config, [
|
||||
{ key: 'clearance.level', values: ['TS'] },
|
||||
{ key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380', 'SAP-4419', 'SAP-5567'] },
|
||||
]);
|
||||
|
||||
expect(banner.text).toBe('TOP SECRET // SAR-MULTIPLE PROGRAMS');
|
||||
});
|
||||
|
||||
it('does not collapse below groupThreshold and sorts labels alphabetically when sortAlpha is set', () => {
|
||||
const banner = buildClassificationBanner(config, [{ key: 'access.programs', values: ['SAP-3380', 'SAP-1042'] }]);
|
||||
|
||||
expect(banner.text).toBe('SAR-APPLES/ORANGES');
|
||||
});
|
||||
|
||||
it('picks the most restrictive value color in highest mode (index 0 = highest ranking)', () => {
|
||||
const banner = buildClassificationBanner(config, [{ key: 'clearance.level', values: ['U', 'TS'] }]);
|
||||
|
||||
expect(banner.backgroundColor).toBe('#ff8c00');
|
||||
});
|
||||
|
||||
it('picks the color of the first room value that is mapped in attribute mode', () => {
|
||||
const attributeModeConfig = { ...config, banner: { ...config.banner, colorMode: 'attribute' as const } };
|
||||
|
||||
expect(buildClassificationBanner(attributeModeConfig, [{ key: 'clearance.level', values: ['U', 'TS'] }]).backgroundColor).toBe(
|
||||
'#007a33',
|
||||
);
|
||||
expect(buildClassificationBanner(attributeModeConfig, [{ key: 'clearance.level', values: ['TS', 'U'] }]).backgroundColor).toBe(
|
||||
'#ff8c00',
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes attributes with showInBanner disabled', () => {
|
||||
const banner = buildClassificationBanner(config, [
|
||||
{ key: 'clearance.level', values: ['S'] },
|
||||
{ key: 'dissem.controls', values: ['NF'] },
|
||||
]);
|
||||
|
||||
expect(banner.text).toBe('SECRET');
|
||||
});
|
||||
|
||||
it('renders unmapped values of a configured attribute as raw text, after the mapped labels', () => {
|
||||
const banner = buildClassificationBanner(config, [
|
||||
{ key: 'clearance.level', values: ['TS'] },
|
||||
{ key: 'dissem.relto', values: ['USA', 'ACGU'] },
|
||||
]);
|
||||
|
||||
expect(banner.text).toBe('TOP SECRET // RELTO USA/ACGU');
|
||||
});
|
||||
|
||||
it('sorts each group alphabetically when sortAlpha is set, keeping unmapped values last, and counts both toward groupThreshold', () => {
|
||||
expect(buildClassificationBanner(config, [{ key: 'access.programs', values: ['aaa', 'SAP-3380', 'SAP-1042'] }]).text).toBe(
|
||||
'SAR-APPLES/ORANGES/aaa',
|
||||
);
|
||||
expect(
|
||||
buildClassificationBanner(config, [{ key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380', 'cherry'] }]).text,
|
||||
).toBe('SAR-MULTIPLE PROGRAMS');
|
||||
});
|
||||
|
||||
it('does not let unmapped values drive the color', () => {
|
||||
const banner = buildClassificationBanner(config, [
|
||||
{ key: 'clearance.level', values: ['X'] },
|
||||
{ key: 'dissem.relto', values: ['USA'] },
|
||||
]);
|
||||
|
||||
expect(banner.text).toBe('X // RELTO USA');
|
||||
expect(banner.backgroundColor).toBe('#6C727A');
|
||||
});
|
||||
|
||||
it('renders the fallback banner when the room only carries attributes not present in the config', () => {
|
||||
const banner = buildClassificationBanner(config, [{ key: 'country', values: ['USA'] }]);
|
||||
|
||||
expect(banner).toMatchObject({
|
||||
text: 'NO CLASSIFICATION DATA',
|
||||
segments: [],
|
||||
backgroundColor: '#6C727A',
|
||||
color: '#FFFFFF',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readableTextColor', () => {
|
||||
it('uses dark text over light backgrounds and white text over dark backgrounds', () => {
|
||||
expect(readableTextColor('#fce100')).toBe('#1F2329');
|
||||
expect(readableTextColor('#FFFFFF')).toBe('#1F2329');
|
||||
expect(readableTextColor('#c8102e')).toBe('#FFFFFF');
|
||||
expect(readableTextColor('#0033a0')).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('picks the higher-contrast ink on mid-range backgrounds', () => {
|
||||
expect(readableTextColor('#999999')).toBe('#1F2329');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClassificationBannersConfig', () => {
|
||||
it('parses a valid config', () => {
|
||||
expect(parseClassificationBannersConfig(JSON.stringify(config))).toEqual(config);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty string (unset setting)', ''],
|
||||
['invalid JSON', 'not json'],
|
||||
])('returns null for %s', (_name, raw) => {
|
||||
expect(parseClassificationBannersConfig(raw)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
|
||||
import { readableTextColor } from './colors';
|
||||
import type {
|
||||
ClassificationBannerAttribute,
|
||||
ClassificationBannerPayload,
|
||||
ClassificationBannerSegment,
|
||||
ClassificationBannersConfig,
|
||||
} from './types';
|
||||
|
||||
export const parseClassificationBannersConfig = (raw: string): ClassificationBannersConfig | null => {
|
||||
try {
|
||||
return JSON.parse(raw) as ClassificationBannersConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildSegment = (
|
||||
attribute: ClassificationBannerAttribute,
|
||||
roomAttributes: IAbacAttributeDefinition[],
|
||||
): ClassificationBannerSegment | null => {
|
||||
const roomValues = roomAttributes.find(({ key }) => key === attribute.source)?.values ?? [];
|
||||
const mapped = attribute.values.filter(({ source }) => roomValues.includes(source));
|
||||
const unmapped = roomValues.filter((value) => !attribute.values.some(({ source }) => source === value)).map((label) => ({ label }));
|
||||
if (attribute.sortAlpha) {
|
||||
const byLabel = (a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label);
|
||||
mapped.sort(byLabel);
|
||||
unmapped.sort(byLabel);
|
||||
}
|
||||
const matched = [...mapped, ...unmapped];
|
||||
if (!matched.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body =
|
||||
attribute.groupThreshold > 0 && matched.length >= attribute.groupThreshold
|
||||
? attribute.multipleLabel
|
||||
: matched.map(({ label }) => label).join(attribute.valueSeparator);
|
||||
|
||||
return {
|
||||
attrId: attribute.id,
|
||||
text: attribute.showLabel ? `${attribute.bannerLabel}${attribute.labelSeparator}${body}` : body,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveColor = ({ attributes, banner }: ClassificationBannersConfig, roomAttributes: IAbacAttributeDefinition[]): string => {
|
||||
const driver = attributes.find(({ drivesColor }) => drivesColor) ?? attributes[0];
|
||||
const roomValues = roomAttributes.find(({ key }) => key === driver.source)?.values ?? [];
|
||||
// values are ranked most restrictive first, so in 'highest' mode the first match wins;
|
||||
// in 'attribute' mode the room's own value order decides instead
|
||||
const match =
|
||||
banner.colorMode === 'highest'
|
||||
? driver.values.find(({ source }) => roomValues.includes(source))
|
||||
: roomValues.map((value) => driver.values.find(({ source }) => source === value)).find(isTruthy);
|
||||
return match?.color ?? banner.fallbackColor;
|
||||
};
|
||||
|
||||
export const buildClassificationBanner = (
|
||||
config: ClassificationBannersConfig,
|
||||
roomAttributes: IAbacAttributeDefinition[],
|
||||
): ClassificationBannerPayload => {
|
||||
const { banner } = config;
|
||||
const base = {
|
||||
style: banner.style,
|
||||
uppercase: banner.uppercase,
|
||||
monospace: banner.monospace,
|
||||
} as const;
|
||||
|
||||
const segments = config.attributes
|
||||
.filter(({ showInBanner }) => showInBanner)
|
||||
.map((attribute) => buildSegment(attribute, roomAttributes))
|
||||
.filter(isTruthy);
|
||||
|
||||
if (!segments.length) {
|
||||
return {
|
||||
...base,
|
||||
text: banner.fallbackText,
|
||||
segments: [],
|
||||
backgroundColor: banner.fallbackColor,
|
||||
color: readableTextColor(banner.fallbackColor),
|
||||
};
|
||||
}
|
||||
|
||||
const backgroundColor = resolveColor(config, roomAttributes);
|
||||
return {
|
||||
...base,
|
||||
text: segments.map(({ text }) => text).join(banner.delimiter),
|
||||
segments,
|
||||
backgroundColor,
|
||||
color: readableTextColor(backgroundColor),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClassificationBannersConfig } from './types';
|
||||
import schema from '../../../../../../../ee/packages/abac/docs/classification-banners.schema.json';
|
||||
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const validConfig: ClassificationBannersConfig & { $schema: string } = {
|
||||
$schema: 'https://rocket.chat/schemas/classification-banners/v1.json',
|
||||
version: 1,
|
||||
enabled: true,
|
||||
banner: {
|
||||
style: 'classic',
|
||||
uppercase: true,
|
||||
monospace: false,
|
||||
delimiter: ' // ',
|
||||
colorMode: 'highest',
|
||||
fallbackText: 'NO CLASSIFICATION DATA',
|
||||
fallbackColor: '#6C727A',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
id: 'classification',
|
||||
source: 'clearance.level',
|
||||
label: 'Classification level',
|
||||
showInBanner: true,
|
||||
showLabel: false,
|
||||
bannerLabel: '',
|
||||
labelSeparator: '',
|
||||
valueSeparator: '/',
|
||||
sortAlpha: false,
|
||||
groupThreshold: 0,
|
||||
multipleLabel: '',
|
||||
drivesColor: true,
|
||||
values: [
|
||||
{ source: 'TS', label: 'TOP SECRET', color: '#ff8c00' },
|
||||
{ source: 'U', label: 'UNCLASSIFIED', color: '#007a33' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { banner } = validConfig;
|
||||
const [attribute] = validConfig.attributes;
|
||||
const [value] = attribute.values;
|
||||
|
||||
describe('classification banners JSON schema (v1 enforcement contract)', () => {
|
||||
it('accepts a complete document typed as ClassificationBannersConfig', () => {
|
||||
expect(validate(validConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wrong version', { ...validConfig, version: 2 }],
|
||||
['missing banner option (colorMode)', { ...validConfig, banner: { ...banner, colorMode: undefined } }],
|
||||
['empty delimiter', { ...validConfig, banner: { ...banner, delimiter: '' } }],
|
||||
['oversized delimiter', { ...validConfig, banner: { ...banner, delimiter: 'x'.repeat(9) } }],
|
||||
['empty fallbackText', { ...validConfig, banner: { ...banner, fallbackText: '' } }],
|
||||
['malformed fallbackColor', { ...validConfig, banner: { ...banner, fallbackColor: 'red' } }],
|
||||
['unknown banner option', { ...validConfig, banner: { ...banner, position: 'top' } }],
|
||||
['unknown top-level property', { ...validConfig, source: 'idp' }],
|
||||
['empty attributes', { ...validConfig, attributes: [] }],
|
||||
['identical duplicate attributes', { ...validConfig, attributes: [attribute, attribute] }],
|
||||
['missing attribute field (drivesColor)', { ...validConfig, attributes: [{ ...attribute, drivesColor: undefined }] }],
|
||||
['uppercase attribute id', { ...validConfig, attributes: [{ ...attribute, id: 'Classification' }] }],
|
||||
['invalid labelSeparator', { ...validConfig, attributes: [{ ...attribute, labelSeparator: '::' }] }],
|
||||
['invalid valueSeparator', { ...validConfig, attributes: [{ ...attribute, valueSeparator: '|' }] }],
|
||||
['groupThreshold of 1', { ...validConfig, attributes: [{ ...attribute, groupThreshold: 1 }] }],
|
||||
['groupThreshold above 20', { ...validConfig, attributes: [{ ...attribute, groupThreshold: 21 }] }],
|
||||
['empty values', { ...validConfig, attributes: [{ ...attribute, values: [] }] }],
|
||||
['identical duplicate values', { ...validConfig, attributes: [{ ...attribute, values: [value, value] }] }],
|
||||
['value with 3-digit color', { ...validConfig, attributes: [{ ...attribute, values: [{ ...value, color: '#fff' }] }] }],
|
||||
['value missing label', { ...validConfig, attributes: [{ ...attribute, values: [{ ...value, label: undefined }] }] }],
|
||||
])('rejects %s', (_name, config) => {
|
||||
expect(validate(config)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export type ClassificationBannerValue = {
|
||||
source: string;
|
||||
label: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type ClassificationBannerAttribute = {
|
||||
id: string;
|
||||
source: string;
|
||||
label: string;
|
||||
showInBanner: boolean;
|
||||
showLabel: boolean;
|
||||
bannerLabel: string;
|
||||
labelSeparator: string;
|
||||
valueSeparator: string;
|
||||
sortAlpha: boolean;
|
||||
groupThreshold: number;
|
||||
multipleLabel: string;
|
||||
drivesColor: boolean;
|
||||
values: ClassificationBannerValue[];
|
||||
};
|
||||
|
||||
export type ClassificationBannerStyle = 'classic';
|
||||
|
||||
export type ClassificationBannersConfig = {
|
||||
version: 1;
|
||||
enabled: boolean;
|
||||
banner: {
|
||||
style: ClassificationBannerStyle;
|
||||
uppercase: boolean;
|
||||
monospace: boolean;
|
||||
delimiter: string;
|
||||
colorMode: 'highest' | 'attribute';
|
||||
fallbackText: string;
|
||||
fallbackColor: string;
|
||||
};
|
||||
attributes: ClassificationBannerAttribute[];
|
||||
};
|
||||
|
||||
export type ClassificationBannerSegment = {
|
||||
attrId: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ClassificationBannerPayload = {
|
||||
text: string;
|
||||
segments: ClassificationBannerSegment[];
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
style: ClassificationBannerStyle;
|
||||
uppercase: boolean;
|
||||
monospace: boolean;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
States,
|
||||
StatesAction,
|
||||
StatesActions,
|
||||
StatesIcon,
|
||||
StatesLink,
|
||||
StatesSubtitle,
|
||||
StatesTitle,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { links } from '../../../lib/links';
|
||||
|
||||
const DOCS_URL = links.go.e2eeGuide;
|
||||
|
||||
export type RoomE2EENotAllowedProps = {
|
||||
title: string;
|
||||
subTitle: string;
|
||||
action?: () => void;
|
||||
btnText?: string;
|
||||
icon: IconName;
|
||||
};
|
||||
|
||||
const RoomE2EENotAllowed = ({ title, subTitle, action, btnText, icon }: RoomE2EENotAllowedProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const handleGoHomeClick = () => {
|
||||
router.navigate('/home');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display='flex' justifyContent='center' height='full'>
|
||||
<States>
|
||||
<StatesIcon name={icon} variation='primary' />
|
||||
<StatesTitle>{title}</StatesTitle>
|
||||
<StatesSubtitle>{subTitle}</StatesSubtitle>
|
||||
{action && (
|
||||
<StatesActions>
|
||||
<Button secondary={true} role='link' onClick={handleGoHomeClick}>
|
||||
{t('Back_to_home')}
|
||||
</Button>
|
||||
<StatesAction primary onClick={action} role='button'>
|
||||
{btnText}
|
||||
</StatesAction>
|
||||
</StatesActions>
|
||||
)}
|
||||
<StatesLink target='_blank' href={DOCS_URL}>
|
||||
{t('Learn_more_about_E2EE')}
|
||||
</StatesLink>
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomE2EENotAllowed;
|
||||
68
apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx
Normal file
68
apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RoomE2EENotAllowed from './RoomE2EENotAllowed';
|
||||
import { e2e } from '../../../lib/e2ee';
|
||||
import { getStoredItem, STORAGE_KEYS } from '../../../lib/sdk/storage';
|
||||
import RoomBody from '../body/RoomBody';
|
||||
import { useRoom } from '../contexts/RoomContext';
|
||||
import { useE2EERoomState } from '../hooks/useE2EERoomState';
|
||||
import { useE2EEState } from '../hooks/useE2EEState';
|
||||
|
||||
const RoomE2EESetup = () => {
|
||||
const room = useRoom();
|
||||
|
||||
const e2eeState = useE2EEState();
|
||||
const e2eRoomState = useE2EERoomState(room._id);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const randomPassword = getStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD);
|
||||
|
||||
const onSavePassword = useCallback(() => {
|
||||
if (!randomPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
e2e.openSaveE2EEPasswordModal(randomPassword);
|
||||
}, [randomPassword]);
|
||||
|
||||
const onEnterE2EEPassword = useCallback(() => e2e.decodePrivateKeyFlow(), []);
|
||||
|
||||
if (e2eeState === 'SAVE_PASSWORD') {
|
||||
return (
|
||||
<RoomE2EENotAllowed
|
||||
title={t('__roomName__is_encrypted', { roomName: room.name })}
|
||||
subTitle={t('Save_your_encryption_password_to_access')}
|
||||
icon='key'
|
||||
action={onSavePassword}
|
||||
btnText={t('Save_E2EE_password')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (e2eeState === 'ENTER_PASSWORD') {
|
||||
return (
|
||||
<RoomE2EENotAllowed
|
||||
title={t('__roomName__is_encrypted', { roomName: room.name })}
|
||||
subTitle={t('Enter_your_E2E_password_to_access')}
|
||||
icon='key'
|
||||
action={onEnterE2EEPassword}
|
||||
btnText={t('Enter_your_E2E_password')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (e2eRoomState === 'WAITING_KEYS') {
|
||||
return (
|
||||
<RoomE2EENotAllowed
|
||||
title={t('Check_back_later')}
|
||||
subTitle={t('__roomName__encryption_keys_need_to_be_updated', { roomName: room.name })}
|
||||
icon='clock'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <RoomBody />;
|
||||
};
|
||||
|
||||
export default RoomE2EESetup;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HeaderTag, HeaderTagIcon } from '@rocket.chat/ui-client';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext';
|
||||
|
||||
export type FederatedRoomOriginServerProps = {
|
||||
room: IRoomWithFederationOriginalName;
|
||||
};
|
||||
|
||||
const FederatedRoomOriginServer = ({ room }: FederatedRoomOriginServerProps) => {
|
||||
const originServerName = useMemo(() => room.federationOriginalName?.split(':')[1], [room.federationOriginalName]);
|
||||
if (!originServerName) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<HeaderTag data-qa='federated-origin-server-name'>
|
||||
<HeaderTagIcon icon={{ name: 'globe' }} />
|
||||
{originServerName}
|
||||
</HeaderTag>
|
||||
);
|
||||
};
|
||||
|
||||
export default FederatedRoomOriginServer;
|
||||
41
apps/meteor/client/views/room/Header/Header.tsx
Normal file
41
apps/meteor/client/views/room/Header/Header.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { isInviteSubscription } from '@rocket.chat/core-typings';
|
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { useLayout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { lazy, memo } from 'react';
|
||||
|
||||
const RoomInviteHeader = lazy(() => import('./RoomInviteHeader'));
|
||||
const OmnichannelRoomHeader = lazy(() => import('./Omnichannel/OmnichannelRoomHeader'));
|
||||
const RoomHeaderE2EESetup = lazy(() => import('./RoomHeaderE2EESetup'));
|
||||
const RoomHeader = lazy(() => import('./RoomHeader'));
|
||||
|
||||
export type HeaderProps = {
|
||||
room: IRoom;
|
||||
subscription?: ISubscription;
|
||||
};
|
||||
|
||||
const Header = ({ room, subscription }: HeaderProps) => {
|
||||
const { isEmbedded, showTopNavbarEmbeddedLayout } = useLayout();
|
||||
const encrypted = Boolean(room.encrypted);
|
||||
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages', false);
|
||||
const shouldDisplayE2EESetup = encrypted && !unencryptedMessagesAllowed;
|
||||
|
||||
if (isEmbedded && !showTopNavbarEmbeddedLayout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription && isInviteSubscription(subscription)) {
|
||||
return <RoomInviteHeader room={room} />;
|
||||
}
|
||||
|
||||
if (room.t === 'l') {
|
||||
return <OmnichannelRoomHeader />;
|
||||
}
|
||||
|
||||
if (shouldDisplayE2EESetup) {
|
||||
return <RoomHeaderE2EESetup room={room} />;
|
||||
}
|
||||
|
||||
return <RoomHeader room={room} />;
|
||||
};
|
||||
|
||||
export default memo(Header);
|
||||
21
apps/meteor/client/views/room/Header/HeaderIconWithRoom.tsx
Normal file
21
apps/meteor/client/views/room/Header/HeaderIconWithRoom.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { HeaderIcon } from '@rocket.chat/ui-client';
|
||||
|
||||
import { OmnichannelRoomIcon } from '../../../components/RoomIcon/OmnichannelRoomIcon';
|
||||
import { useRoomIcon } from '../../../hooks/useRoomIcon';
|
||||
|
||||
export type HeaderIconWithRoomProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const HeaderIconWithRoom = ({ room }: HeaderIconWithRoomProps) => {
|
||||
const icon = useRoomIcon(room);
|
||||
if (isOmnichannelRoom(room)) {
|
||||
return <OmnichannelRoomIcon source={room.source} status={room.v?.status} size='x20' />;
|
||||
}
|
||||
|
||||
return <HeaderIcon icon={icon} />;
|
||||
};
|
||||
|
||||
export default HeaderIconWithRoom;
|
||||
16
apps/meteor/client/views/room/Header/HeaderSkeleton.tsx
Normal file
16
apps/meteor/client/views/room/Header/HeaderSkeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from '@rocket.chat/fuselage';
|
||||
import { Header, HeaderContent, HeaderContentRow } from '@rocket.chat/ui-client';
|
||||
|
||||
const HeaderSkeleton = () => {
|
||||
return (
|
||||
<Header>
|
||||
<HeaderContent>
|
||||
<HeaderContentRow>
|
||||
<Skeleton width='25%' />
|
||||
</HeaderContentRow>
|
||||
</HeaderContent>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderSkeleton;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { HeaderToolbarAction } from '@rocket.chat/ui-client';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type BackButtonProps = { routeName?: string };
|
||||
|
||||
const BackButton = ({ routeName }: BackButtonProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const back = useStableCallback(() => {
|
||||
switch (routeName) {
|
||||
case 'omnichannel-directory':
|
||||
router.navigate({
|
||||
name: 'omnichannel-directory',
|
||||
params: {
|
||||
...router.getRouteParameters(),
|
||||
tab: 'chats',
|
||||
context: 'info',
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 'omnichannel-current-chats':
|
||||
router.navigate({
|
||||
name: 'omnichannel-current-chats',
|
||||
params: {
|
||||
...router.getRouteParameters(),
|
||||
tab: 'chats',
|
||||
context: 'info',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return <HeaderToolbarAction title={t('Back')} icon='back' onClick={back} />;
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HeaderToolbar } from '@rocket.chat/ui-client';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useOmnichannelRoom } from '../../contexts/RoomContext';
|
||||
import RoomHeader from '../RoomHeader';
|
||||
import BackButton from './BackButton';
|
||||
import OmnichannelRoomHeaderTag from './OmnichannelRoomHeaderTag';
|
||||
import QuickActions from './QuickActions';
|
||||
|
||||
const OmnichannelRoomHeader = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const previousRouteName = useSyncExternalStore(
|
||||
router.subscribeToRouteChange,
|
||||
useCallback(() => router.getPreviousRouteName(), [router]),
|
||||
);
|
||||
|
||||
const room = useOmnichannelRoom();
|
||||
|
||||
const slots = useMemo(
|
||||
() => ({
|
||||
start: (previousRouteName === 'omnichannel-directory' || previousRouteName === 'omnichannel-current-chats') && (
|
||||
<HeaderToolbar>
|
||||
<BackButton routeName={previousRouteName} />
|
||||
</HeaderToolbar>
|
||||
),
|
||||
insideContent: <OmnichannelRoomHeaderTag />,
|
||||
posContent: <QuickActions />,
|
||||
}),
|
||||
[previousRouteName],
|
||||
);
|
||||
|
||||
return <RoomHeader slots={slots} room={room} />;
|
||||
};
|
||||
|
||||
export default OmnichannelRoomHeader;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import OmnichannelVerificationTag from '../../../omnichannel/components/OmnichannelVerificationTag';
|
||||
import AdvancedContactModal from '../../../omnichannel/contactInfo/AdvancedContactModal';
|
||||
import { useOmnichannelRoom } from '../../contexts/RoomContext';
|
||||
|
||||
const OmnichannelRoomHeaderTag = () => {
|
||||
const setModal = useSetModal();
|
||||
const { verified } = useOmnichannelRoom();
|
||||
|
||||
return (
|
||||
<Box marginInlineStart={4} withTruncatedText>
|
||||
<OmnichannelVerificationTag verified={verified} onClick={() => setModal(<AdvancedContactModal onCancel={() => setModal(null)} />)} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OmnichannelRoomHeaderTag;
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { Box, Dropdown, Option } from '@rocket.chat/fuselage';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import { HeaderToolbarAction } from '@rocket.chat/ui-client';
|
||||
import { memo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDropdownVisibility } from './hooks/useDropdownVisibility';
|
||||
import type { QuickActionsActionOptions } from '../../../lib/quickActions';
|
||||
|
||||
export type QuickActionOptionsProps = {
|
||||
options: QuickActionsActionOptions;
|
||||
action: (id: string) => void;
|
||||
room: IOmnichannelRoom;
|
||||
icon: IconName;
|
||||
};
|
||||
|
||||
const QuickActionOptions = ({ options, room, action, icon, ...props }: QuickActionOptionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const reference = useRef<HTMLButtonElement>(null);
|
||||
const target = useRef(null);
|
||||
const { isVisible, toggle } = useDropdownVisibility({ reference, target });
|
||||
|
||||
const handleClick = (id: string) => (): void => {
|
||||
toggle();
|
||||
action(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderToolbarAction ref={reference} icon={icon} onClick={() => toggle()} secondary={isVisible} {...props} />
|
||||
{isVisible && (
|
||||
<Dropdown reference={reference} ref={target}>
|
||||
{options.map(({ id, label, validate }) => {
|
||||
const { value: valid = true, tooltip } = validate?.(room) || {};
|
||||
return (
|
||||
<Option key={id} onClick={handleClick(id)} disabled={!valid} title={!valid && tooltip ? t(tooltip) : undefined}>
|
||||
<Box fontScale='p2m' minWidth='180px'>
|
||||
{t(label)}
|
||||
</Box>
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(QuickActionOptions);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { HeaderToolbar, HeaderToolbarAction, HeaderToolbarDivider } from '@rocket.chat/ui-client';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import QuickActionOptions from './QuickActionOptions';
|
||||
import { useQuickActions } from './hooks/useQuickActions';
|
||||
import { useOmnichannelRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
export type QuickActionsProps = {
|
||||
className?: ComponentProps<typeof Box>['className'];
|
||||
};
|
||||
|
||||
const QuickActions = ({ className }: QuickActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const room = useOmnichannelRoom();
|
||||
const { quickActions, actionDefault } = useQuickActions();
|
||||
|
||||
return (
|
||||
<HeaderToolbar aria-label={t('Omnichannel_quick_actions')}>
|
||||
{quickActions.map(({ id, color, icon, title, action = actionDefault, options }, index) => {
|
||||
const props = {
|
||||
id,
|
||||
icon,
|
||||
color,
|
||||
title: t(title),
|
||||
className,
|
||||
index,
|
||||
primary: false,
|
||||
room,
|
||||
};
|
||||
|
||||
if (options) {
|
||||
return <QuickActionOptions options={options} key={id} action={action} {...props} />;
|
||||
}
|
||||
|
||||
return <HeaderToolbarAction key={id} onClick={() => action(id)} {...props} />;
|
||||
})}
|
||||
{quickActions.length > 0 && <HeaderToolbarDivider />}
|
||||
</HeaderToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(QuickActions);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useToggle, useOutsideClick } from '@rocket.chat/fuselage-hooks';
|
||||
import type { RefObject } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* useDropdownVisibility
|
||||
* is used to control the visibility of a dropdown
|
||||
* also checks if the user clicked outside the dropdown, but ignores if the click was on the anchor
|
||||
* @param {Object} props
|
||||
* @param {Object} props.reference - The reference where the dropdown will be attached to
|
||||
* @param {Object} props.target - The target, the dropdown itself
|
||||
* @returns {Object}
|
||||
* @returns {Boolean} isVisible - The visibility of the dropdown
|
||||
* @returns {Function} toggle - The function to toggle the dropdown
|
||||
*/
|
||||
|
||||
export const useDropdownVisibility = <T extends HTMLElement>({
|
||||
reference,
|
||||
target,
|
||||
}: {
|
||||
reference: RefObject<T | null>;
|
||||
target: RefObject<T | null>;
|
||||
}): {
|
||||
isVisible: boolean;
|
||||
toggle: (state?: boolean) => void;
|
||||
} => {
|
||||
const [isVisible, toggle] = useToggle(false);
|
||||
|
||||
useOutsideClick(
|
||||
[target, reference],
|
||||
useCallback(() => toggle(false), [toggle]),
|
||||
);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
toggle,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { roomsQueryKeys, subscriptionsQueryKeys } from '../../../../../../lib/queryKeys';
|
||||
|
||||
export const usePutChatOnHoldMutation = (
|
||||
options?: Omit<UseMutationOptions<void, Error, IRoom['_id']>, 'mutationFn'>,
|
||||
): UseMutationResult<void, Error, IRoom['_id']> => {
|
||||
const putChatOnHold = useEndpoint('POST', '/v1/livechat/room.onHold');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (rid) => {
|
||||
await putChatOnHold({ roomId: rid });
|
||||
},
|
||||
...options,
|
||||
onSuccess: async (data, rid, context) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['current-chats'] });
|
||||
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.room(rid) });
|
||||
await queryClient.invalidateQueries({ queryKey: subscriptionsQueryKeys.subscription(rid) });
|
||||
return options?.onSuccess?.(data, rid, context);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
useSetModal,
|
||||
useToastMessageDispatch,
|
||||
useUserId,
|
||||
useSetting,
|
||||
usePermission,
|
||||
useRole,
|
||||
useEndpoint,
|
||||
useTranslation,
|
||||
useRouter,
|
||||
} from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { usePutChatOnHoldMutation } from './usePutChatOnHoldMutation';
|
||||
import { useReturnChatToQueueMutation } from './useReturnChatToQueueMutation';
|
||||
import { useHasLicenseModule } from '../../../../../../hooks/useHasLicenseModule';
|
||||
import { useLivechatInquiryStore } from '../../../../../../hooks/useLivechatInquiryStore';
|
||||
import { LegacyRoomManager } from '../../../../../../lib/LegacyRoomManager';
|
||||
import { quickActionHooks } from '../../../../../../ui';
|
||||
import { useIsRoomOverMacLimit } from '../../../../../omnichannel/hooks/useIsRoomOverMacLimit';
|
||||
import { useOmnichannelRouteConfig } from '../../../../../omnichannel/hooks/useOmnichannelRouteConfig';
|
||||
import CloseChatModal from '../../../../../omnichannel/modals/CloseChatModal';
|
||||
import CloseChatModalData from '../../../../../omnichannel/modals/CloseChatModalData';
|
||||
import ForwardChatModal from '../../../../../omnichannel/modals/ForwardChatModal';
|
||||
import PlaceChatOnHoldModal from '../../../../../omnichannel/modals/PlaceChatOnHoldModal';
|
||||
import ReturnChatQueueModal from '../../../../../omnichannel/modals/ReturnChatQueueModal';
|
||||
import TranscriptModal from '../../../../../omnichannel/modals/TranscriptModal';
|
||||
import { useOmnichannelRoom } from '../../../../contexts/RoomContext';
|
||||
import type { QuickActionsActionConfig } from '../../../../lib/quickActions';
|
||||
import { QuickActionsEnum } from '../../../../lib/quickActions';
|
||||
|
||||
export const useQuickActions = (): {
|
||||
quickActions: QuickActionsActionConfig[];
|
||||
actionDefault: (actionId: string) => void;
|
||||
} => {
|
||||
const room = useOmnichannelRoom();
|
||||
const setModal = useSetModal();
|
||||
const router = useRouter();
|
||||
|
||||
const t = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const [onHoldModalActive, setOnHoldModalActive] = useState(false);
|
||||
|
||||
const visitorRoomId = room.v._id;
|
||||
const rid = room._id;
|
||||
const uid = useUserId();
|
||||
const roomLastMessage = room.lastMessage;
|
||||
|
||||
const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info');
|
||||
|
||||
const getVisitorEmail = useStableCallback(async () => {
|
||||
if (!visitorRoomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
visitor: { visitorEmails },
|
||||
} = await getVisitorInfo({ visitorId: visitorRoomId });
|
||||
|
||||
if (visitorEmails?.length && visitorEmails[0].address) {
|
||||
return visitorEmails[0].address;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onHoldModalActive && roomLastMessage?.token) {
|
||||
setModal(null);
|
||||
}
|
||||
}, [roomLastMessage, onHoldModalActive, setModal]);
|
||||
|
||||
const closeModal = useCallback(() => setModal(null), [setModal]);
|
||||
|
||||
const requestTranscript = useEndpoint('POST', '/v1/livechat/transcript/:rid', { rid });
|
||||
|
||||
const handleRequestTranscript = useCallback(
|
||||
async (email: string, subject: string) => {
|
||||
try {
|
||||
await requestTranscript({ email, subject });
|
||||
closeModal();
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t('Livechat_email_transcript_has_been_requested'),
|
||||
});
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
[closeModal, dispatchToastMessage, requestTranscript, t],
|
||||
);
|
||||
|
||||
const sendTranscriptPDF = useEndpoint('POST', '/v1/omnichannel/:rid/request-transcript', { rid });
|
||||
|
||||
const handleSendTranscriptPDF = useCallback(async () => {
|
||||
try {
|
||||
await sendTranscriptPDF();
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t('Livechat_transcript_has_been_requested'),
|
||||
});
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
}, [dispatchToastMessage, sendTranscriptPDF, t]);
|
||||
|
||||
const sendTranscript = useEndpoint('POST', '/v1/livechat/transcript');
|
||||
|
||||
const handleSendTranscript = useCallback(
|
||||
async (email: string, subject: string, token: string) => {
|
||||
try {
|
||||
await sendTranscript({ token, rid, email, subject });
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
[closeModal, dispatchToastMessage, rid, sendTranscript],
|
||||
);
|
||||
|
||||
const discardTranscript = useEndpoint('DELETE', '/v1/livechat/transcript/:rid', { rid });
|
||||
|
||||
const handleDiscardTranscript = useCallback(async () => {
|
||||
try {
|
||||
await discardTranscript();
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t('Livechat_transcript_request_has_been_canceled'),
|
||||
});
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
}, [closeModal, discardTranscript, dispatchToastMessage, t]);
|
||||
|
||||
const closeChat = useEndpoint('POST', '/v1/livechat/room.closeByUser');
|
||||
|
||||
const discardForRoom = useLivechatInquiryStore((state) => state.discardForRoom);
|
||||
|
||||
const handleClose = useCallback(
|
||||
async (
|
||||
comment?: string,
|
||||
tags?: string[],
|
||||
preferences?: { omnichannelTranscriptPDF: boolean; omnichannelTranscriptEmail: boolean },
|
||||
requestData?: { email: string; subject: string },
|
||||
) => {
|
||||
try {
|
||||
await closeChat({
|
||||
rid,
|
||||
...(comment && { comment }),
|
||||
...(tags && { tags }),
|
||||
...(preferences?.omnichannelTranscriptPDF && { generateTranscriptPdf: true }),
|
||||
...(preferences?.omnichannelTranscriptEmail && requestData
|
||||
? {
|
||||
transcriptEmail: {
|
||||
sendToVisitor: preferences?.omnichannelTranscriptEmail,
|
||||
requestData,
|
||||
},
|
||||
}
|
||||
: { transcriptEmail: { sendToVisitor: false } }),
|
||||
});
|
||||
discardForRoom(rid);
|
||||
closeModal();
|
||||
dispatchToastMessage({ type: 'success', message: t('Chat_closed_successfully') });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
[closeChat, closeModal, dispatchToastMessage, rid, t, discardForRoom],
|
||||
);
|
||||
|
||||
const returnChatToQueueMutation = useReturnChatToQueueMutation({
|
||||
onSuccess: () => {
|
||||
LegacyRoomManager.close(room.t + rid);
|
||||
router.navigate('/home');
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
onSettled: () => {
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
const putChatOnHoldMutation = usePutChatOnHoldMutation({
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Chat_On_Hold_Successfully') });
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
onSettled: () => {
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = useStableCallback(async (id: string) => {
|
||||
switch (id) {
|
||||
case QuickActionsEnum.MoveQueue:
|
||||
setModal(
|
||||
<ReturnChatQueueModal
|
||||
onMoveChat={(): void => returnChatToQueueMutation.mutate(rid)}
|
||||
onCancel={(): void => {
|
||||
closeModal();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
case QuickActionsEnum.TranscriptPDF:
|
||||
handleSendTranscriptPDF();
|
||||
break;
|
||||
case QuickActionsEnum.TranscriptEmail:
|
||||
const visitorEmail = await getVisitorEmail();
|
||||
|
||||
if (!visitorEmail) {
|
||||
dispatchToastMessage({ type: 'error', message: t('Customer_without_registered_email') });
|
||||
break;
|
||||
}
|
||||
|
||||
setModal(
|
||||
<TranscriptModal
|
||||
room={room}
|
||||
email={visitorEmail}
|
||||
onRequest={handleRequestTranscript}
|
||||
onSend={handleSendTranscript}
|
||||
onDiscard={handleDiscardTranscript}
|
||||
onCancel={closeModal}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
case QuickActionsEnum.ChatForward:
|
||||
setModal(<ForwardChatModal room={room} onCancel={closeModal} />);
|
||||
break;
|
||||
case QuickActionsEnum.CloseChat:
|
||||
const email = await getVisitorEmail();
|
||||
setModal(
|
||||
room.departmentId ? (
|
||||
<CloseChatModalData visitorEmail={email} departmentId={room.departmentId} onConfirm={handleClose} onCancel={closeModal} />
|
||||
) : (
|
||||
<CloseChatModal visitorEmail={email} onConfirm={handleClose} onCancel={closeModal} />
|
||||
),
|
||||
);
|
||||
break;
|
||||
case QuickActionsEnum.OnHoldChat:
|
||||
setModal(
|
||||
<PlaceChatOnHoldModal
|
||||
onOnHoldChat={(): void => putChatOnHoldMutation.mutate(rid)}
|
||||
onCancel={(): void => {
|
||||
closeModal();
|
||||
setOnHoldModalActive(false);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
setOnHoldModalActive(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const omnichannelRouteConfig = useOmnichannelRouteConfig();
|
||||
|
||||
const manualOnHoldAllowed = useSetting('Livechat_allow_manual_on_hold');
|
||||
|
||||
const hasManagerRole = useRole('livechat-manager');
|
||||
const hasMonitorRole = useRole('livechat-monitor');
|
||||
|
||||
const roomOpen = room?.open && (room.u?._id === uid || hasManagerRole || hasMonitorRole) && room?.lastMessage?.t !== 'livechat-close';
|
||||
const canMoveQueue = !!omnichannelRouteConfig?.returnQueue && room?.u !== undefined;
|
||||
const canForwardGuest = usePermission('transfer-livechat-guest');
|
||||
const canSendTranscriptEmail = usePermission('send-omnichannel-chat-transcript');
|
||||
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
|
||||
const canSendTranscriptPDF = usePermission('request-pdf-transcript');
|
||||
const canCloseRoom = usePermission('close-livechat-room');
|
||||
const canCloseOthersRoom = usePermission('close-others-livechat-room');
|
||||
const restrictedOnHold = useSetting('Livechat_allow_manual_on_hold_upon_agent_engagement_only');
|
||||
const canRoomBePlacedOnHold = !room.onHold && room.u;
|
||||
const canAgentPlaceOnHold = !room.lastMessage?.token;
|
||||
const canPlaceChatOnHold = Boolean(manualOnHoldAllowed && canRoomBePlacedOnHold && (!restrictedOnHold || canAgentPlaceOnHold));
|
||||
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
|
||||
|
||||
const hasPermissionButtons = (id: string): boolean => {
|
||||
switch (id) {
|
||||
case QuickActionsEnum.MoveQueue:
|
||||
return !isRoomOverMacLimit && !!roomOpen && canMoveQueue;
|
||||
case QuickActionsEnum.ChatForward:
|
||||
return !isRoomOverMacLimit && !!roomOpen && canForwardGuest;
|
||||
case QuickActionsEnum.Transcript:
|
||||
return !isRoomOverMacLimit && (canSendTranscriptEmail || (hasLicense && canSendTranscriptPDF));
|
||||
case QuickActionsEnum.TranscriptEmail:
|
||||
return !isRoomOverMacLimit && canSendTranscriptEmail;
|
||||
case QuickActionsEnum.TranscriptPDF:
|
||||
return hasLicense && !isRoomOverMacLimit && canSendTranscriptPDF;
|
||||
case QuickActionsEnum.CloseChat:
|
||||
return !!roomOpen && (canCloseRoom || canCloseOthersRoom);
|
||||
case QuickActionsEnum.OnHoldChat:
|
||||
return !!roomOpen && canPlaceChatOnHold;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const quickActions = quickActionHooks
|
||||
.map((quickActionHook) => quickActionHook())
|
||||
.filter((quickAction): quickAction is QuickActionsActionConfig => !!quickAction)
|
||||
.filter((action) => {
|
||||
const { options, id } = action;
|
||||
if (options) {
|
||||
action.options = options.filter(({ id }) => hasPermissionButtons(id));
|
||||
}
|
||||
|
||||
return hasPermissionButtons(id);
|
||||
})
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const actionDefault = useStableCallback((actionId: string) => {
|
||||
handleAction(actionId);
|
||||
});
|
||||
|
||||
return { quickActions, actionDefault };
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { roomsQueryKeys, subscriptionsQueryKeys } from '../../../../../../lib/queryKeys';
|
||||
|
||||
export const useReturnChatToQueueMutation = (
|
||||
options?: Omit<UseMutationOptions<void, Error, IRoom['_id']>, 'mutationFn'>,
|
||||
): UseMutationResult<void, Error, IRoom['_id']> => {
|
||||
const returnChatToQueue = useEndpoint('POST', '/v1/livechat/inquiries.returnAsInquiry');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (rid) => {
|
||||
await returnChatToQueue({ roomId: rid });
|
||||
},
|
||||
...options,
|
||||
onSuccess: async (data, rid, context) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['current-chats'] });
|
||||
queryClient.removeQueries({ queryKey: roomsQueryKeys.room(rid) });
|
||||
queryClient.removeQueries({ queryKey: roomsQueryKeys.info(rid) });
|
||||
queryClient.removeQueries({ queryKey: subscriptionsQueryKeys.subscription(rid) });
|
||||
return options?.onSuccess?.(data, rid, context);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export default lazy(() => import('./QuickActions'));
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import { useRoomRoute, useUserDisplayName } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ParentRoomButton from '../ParentRoomButton';
|
||||
|
||||
export type ParentDiscussionProps = {
|
||||
loading?: boolean;
|
||||
room: Pick<IRoom, '_id' | 't' | 'name' | 'fname' | 'prid' | 'u' | 'federated'>;
|
||||
};
|
||||
|
||||
const getChannelRoomName = (room: ParentDiscussionProps['room'], allowSpecialChars: boolean): string => {
|
||||
if (room.prid || isRoomFederated(room)) {
|
||||
return room.fname || '';
|
||||
}
|
||||
|
||||
return (allowSpecialChars ? room.fname || room.name : room.name) || '';
|
||||
};
|
||||
|
||||
const ParentDiscussion = ({ loading = false, room }: ParentDiscussionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const goToRoom = useRoomRoute();
|
||||
const allowSpecialChars = useSetting('UI_Allow_room_names_with_special_chars', false);
|
||||
const userDisplayName = useUserDisplayName({ name: room.fname, username: room.name });
|
||||
const roomName = room.t === 'c' || room.t === 'p' ? getChannelRoomName(room, allowSpecialChars) : (userDisplayName ?? '');
|
||||
|
||||
const handleRedirect = (): void => {
|
||||
goToRoom({ rid: room._id, t: room.t, name: room.name });
|
||||
};
|
||||
|
||||
return <ParentRoomButton loading={loading} onClick={handleRedirect} title={t('Back_to__roomName__channel', { roomName })} />;
|
||||
};
|
||||
|
||||
export default ParentDiscussion;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useUserSubscription } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import ParentDiscussion from './ParentDiscussion';
|
||||
import ParentDiscussionWithData from './ParentDiscussionWithData';
|
||||
|
||||
export type ParentDiscussionRouteProps = {
|
||||
room: Pick<IRoom, '_id' | 't' | 'name' | 'fname' | 'prid' | 'u'>;
|
||||
};
|
||||
|
||||
const ParentDiscussionRoute = ({ room }: ParentDiscussionRouteProps) => {
|
||||
const { prid } = room;
|
||||
|
||||
if (!prid) {
|
||||
throw new Error('Parent room ID is missing');
|
||||
}
|
||||
|
||||
const subscription = useUserSubscription(prid);
|
||||
const parentRoomProps = useMemo(
|
||||
() =>
|
||||
subscription
|
||||
? {
|
||||
_id: subscription.rid,
|
||||
t: subscription.t,
|
||||
name: subscription.name,
|
||||
fname: subscription.fname,
|
||||
u: subscription.u,
|
||||
federated: (subscription as any).federated as IRoom['federated'],
|
||||
}
|
||||
: undefined,
|
||||
[subscription],
|
||||
);
|
||||
|
||||
if (subscription && parentRoomProps) {
|
||||
return <ParentDiscussion room={parentRoomProps} />;
|
||||
}
|
||||
|
||||
return <ParentDiscussionWithData rid={prid} />;
|
||||
};
|
||||
|
||||
export default ParentDiscussionRoute;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
|
||||
import ParentDiscussion from './ParentDiscussion';
|
||||
import { useRoomInfoEndpoint } from '../../../../../hooks/useRoomInfoEndpoint';
|
||||
|
||||
export type ParentDiscussionWithDataProps = { rid: IRoom['_id'] };
|
||||
|
||||
const ParentDiscussionWithData = ({ rid }: ParentDiscussionWithDataProps) => {
|
||||
const { data, isPending, isError } = useRoomInfoEndpoint(rid);
|
||||
|
||||
if (isError || !data?.room) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ParentDiscussion loading={isPending} room={data.room} />;
|
||||
};
|
||||
|
||||
export default ParentDiscussionWithData;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ParentDiscussionRoute';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
|
||||
import ParentDiscussion from './ParentDiscussion';
|
||||
import ParentTeam from './ParentTeam';
|
||||
|
||||
export type ParentRoomProps = { room: IRoom };
|
||||
|
||||
const ParentRoom = ({ room }: ParentRoomProps) => {
|
||||
if (room.prid) {
|
||||
return <ParentDiscussion room={room} />;
|
||||
}
|
||||
|
||||
if (room.teamId && !room.teamMain) {
|
||||
return <ParentTeam room={room} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ParentRoom;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IconButton, Skeleton } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type ParentRoomButtonProps = Omit<ComponentProps<typeof IconButton>, 'icon'> & { loading: boolean };
|
||||
|
||||
const ParentRoomButton = ({ loading, ...props }: ParentRoomButtonProps) => {
|
||||
if (loading) {
|
||||
return <Skeleton variant='rect' size={28} />;
|
||||
}
|
||||
|
||||
return <IconButton small icon='arrow-back-up' {...props} />;
|
||||
};
|
||||
|
||||
export default ParentRoomButton;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { TeamType } from '@rocket.chat/core-typings';
|
||||
import { useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ParentRoomButton from './ParentRoomButton';
|
||||
import { useTeamInfoQuery } from '../../../../hooks/useTeamInfoQuery';
|
||||
import { useGoToRoom } from '../../hooks/useGoToRoom';
|
||||
import { useUserTeamsQuery } from '../../hooks/useUserTeamsQuery';
|
||||
|
||||
type APIErrorResult = { success: boolean; error: string };
|
||||
|
||||
export type ParentTeamProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const ParentTeam = ({ room }: ParentTeamProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { teamId } = room;
|
||||
|
||||
const userId = useUserId();
|
||||
|
||||
if (!teamId) {
|
||||
throw new Error('invalid rid');
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('invalid uid');
|
||||
}
|
||||
|
||||
const {
|
||||
data: teamInfo,
|
||||
isLoading: teamInfoLoading,
|
||||
isError: teamInfoError,
|
||||
} = useTeamInfoQuery(teamId, { retry: (_, error) => (error as unknown as APIErrorResult)?.error !== 'unauthorized' });
|
||||
|
||||
const { data: userTeams, isLoading: userTeamsLoading } = useUserTeamsQuery(userId);
|
||||
|
||||
const userBelongsToTeam = Boolean(userTeams?.find((team) => team._id === teamId)) || false;
|
||||
const isPublicTeam = teamInfo?.type === TeamType.PUBLIC;
|
||||
const shouldDisplayTeam = isPublicTeam || userBelongsToTeam;
|
||||
|
||||
const goToRoom = useGoToRoom();
|
||||
|
||||
const redirectToMainRoom = (): void => {
|
||||
const rid = teamInfo?.roomId;
|
||||
if (!rid) {
|
||||
return;
|
||||
}
|
||||
|
||||
goToRoom(rid);
|
||||
};
|
||||
|
||||
if (teamInfoError || !shouldDisplayTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ParentRoomButton
|
||||
loading={teamInfoLoading || userTeamsLoading}
|
||||
onClick={redirectToMainRoom}
|
||||
title={t('Back_to__roomName__team', { roomName: teamInfo?.name })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParentTeam;
|
||||
1
apps/meteor/client/views/room/Header/ParentRoom/index.ts
Normal file
1
apps/meteor/client/views/room/Header/ParentRoom/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ParentRoom';
|
||||
330
apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
Normal file
330
apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { LayoutContext, RoomToolboxContext } from '@rocket.chat/ui-contexts';
|
||||
import type { RoomToolboxActionConfig, LayoutContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import RoomHeader from './RoomHeader';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
const mockedRoom = createFakeRoom({ prid: undefined, name: 'general', fname: 'General' });
|
||||
const appRoot = mockAppRoot()
|
||||
.withRoom(mockedRoom)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={mockedRoom}>{children}</FakeRoomProvider>)
|
||||
.build();
|
||||
|
||||
jest.mock('../../../../client/lib/getURL', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
jest.mock('./ParentRoom', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentRoom</div>),
|
||||
}));
|
||||
|
||||
const mockUseRealRoomToolbox = { value: false };
|
||||
|
||||
jest.mock('./RoomToolbox', () => {
|
||||
const ActualRoomToolbox = jest.requireActual('./RoomToolbox/RoomToolbox').default;
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn((props) => {
|
||||
if (mockUseRealRoomToolbox.value) {
|
||||
return <ActualRoomToolbox {...props} />;
|
||||
}
|
||||
return <div>RoomToolbox</div>;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const MockTabComponent = () => <div>tab</div>;
|
||||
|
||||
const mockActions: RoomToolboxActionConfig[] = [
|
||||
{ id: 'thread', icon: 'thread', title: 'Threads' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
{ id: 'members-list', icon: 'members', title: 'Members' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
{ id: 'discussions', icon: 'discussion', title: 'Discussions' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
{ id: 'files', icon: 'clip', title: 'Files' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
{ id: 'pinned-messages', icon: 'pin', title: 'Pinned Messages' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
];
|
||||
|
||||
const layoutSetting = (...scopes: object[]) => JSON.stringify(scopes);
|
||||
|
||||
const mockLayoutConfig = {
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: true, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 2 },
|
||||
{ id: 'discussions', featured: false, order: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('RoomHeader', () => {
|
||||
describe('Toolbox', () => {
|
||||
it('should render toolbox by default', async () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{}} />, { wrapper: appRoot });
|
||||
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render toolbox if roomToolbox is null and no slots are provided', () => {
|
||||
render(
|
||||
<RoomHeader
|
||||
room={mockedRoom}
|
||||
slots={{
|
||||
toolbox: {
|
||||
hidden: true,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
expect(screen.queryByLabelText('Toolbox_room_actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render toolbox if slots.toolbox is provided', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: {} }} />, { wrapper: appRoot });
|
||||
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom toolbox content from roomToolbox prop', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: { content: <div>Custom Toolbox</div> } }} />, { wrapper: appRoot });
|
||||
expect(screen.getByText('Custom Toolbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom toolbox content from slots.toolbox.content', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: { content: <div>Slotted Toolbox</div> } }} />, { wrapper: appRoot });
|
||||
expect(screen.getByText('Slotted Toolbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Room Toolbox Layout Engine', () => {
|
||||
beforeAll(() => {
|
||||
mockUseRealRoomToolbox.value = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockUseRealRoomToolbox.value = false;
|
||||
});
|
||||
|
||||
const renderWithLayout = (
|
||||
room = mockedRoom,
|
||||
layoutContextValue?: Partial<LayoutContextValue>,
|
||||
extraSettings?: Record<string, string | boolean>,
|
||||
featuresPreview = [{ name: 'roomToolboxLayout', value: true }],
|
||||
) => {
|
||||
const mockLayoutContextValue: LayoutContextValue = {
|
||||
isEmbedded: false,
|
||||
showTopNavbarEmbeddedLayout: false,
|
||||
isTablet: false,
|
||||
isMobile: false,
|
||||
roomToolboxExpanded: true,
|
||||
navbar: { searchExpanded: false },
|
||||
sidebar: {
|
||||
overlayed: false,
|
||||
setOverlayed: () => undefined,
|
||||
isCollapsed: false,
|
||||
shouldToggle: false,
|
||||
toggle: () => undefined,
|
||||
collapse: () => undefined,
|
||||
expand: () => undefined,
|
||||
close: () => undefined,
|
||||
},
|
||||
sidePanel: {
|
||||
displaySidePanel: true,
|
||||
closeSidePanel: () => undefined,
|
||||
openSidePanel: () => undefined,
|
||||
},
|
||||
size: { sidebar: '240px', contextualBar: '380px' },
|
||||
contextualBarPosition: 'relative',
|
||||
contextualBarExpanded: false,
|
||||
hiddenActions: {
|
||||
roomToolbox: [],
|
||||
messageToolbox: [],
|
||||
composerToolbox: [],
|
||||
userToolbox: [],
|
||||
},
|
||||
...layoutContextValue,
|
||||
};
|
||||
|
||||
const mockToolboxValue = {
|
||||
actions: mockActions,
|
||||
openTab: () => undefined,
|
||||
closeTab: () => undefined,
|
||||
};
|
||||
|
||||
const allowFeaturePreview = extraSettings?.Accounts_AllowFeaturePreview ?? true;
|
||||
const layoutValue = extraSettings?.Room_Toolbox_Layout ?? layoutSetting({ roomType: [room.t], ...mockLayoutConfig });
|
||||
|
||||
const appRootWithSettings = mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', allowFeaturePreview as boolean)
|
||||
.withSetting('Room_Toolbox_Layout', layoutValue as string)
|
||||
.withUserPreference('featuresPreview', featuresPreview)
|
||||
.withRoom(room)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={room}>{children}</FakeRoomProvider>)
|
||||
.build();
|
||||
|
||||
return render(
|
||||
<LayoutContext.Provider value={mockLayoutContextValue}>
|
||||
<RoomToolboxContext.Provider value={mockToolboxValue}>
|
||||
<RoomHeader room={room} slots={{}} />
|
||||
</RoomToolboxContext.Provider>
|
||||
</LayoutContext.Provider>,
|
||||
{ wrapper: appRootWithSettings },
|
||||
);
|
||||
};
|
||||
|
||||
const getToolbarSequence = () => {
|
||||
const toolbar = within(screen.getByRole('toolbar', { name: 'Toolbox_room_actions' }));
|
||||
|
||||
return [...toolbar.getAllByRole('button'), ...toolbar.queryAllByRole('separator')]
|
||||
.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1))
|
||||
.map((element) => element.getAttribute('title') ?? 'divider');
|
||||
};
|
||||
|
||||
const roomScenarios = [
|
||||
{ type: 'c', name: 'public-channel', title: 'Public Channel' },
|
||||
{ type: 'p', name: 'private-group', title: 'Private Group' },
|
||||
{ type: 'd', name: 'direct-message', title: 'Direct Message' },
|
||||
] as const;
|
||||
|
||||
roomScenarios.forEach(({ type, name, title }) => {
|
||||
describe(`Room Type: ${title}`, () => {
|
||||
const testRoom = createFakeRoom({
|
||||
prid: undefined,
|
||||
t: type,
|
||||
name,
|
||||
fname: name,
|
||||
});
|
||||
|
||||
it('should respect custom featured and visible actions layout and send remaining normal actions to options dropdown', () => {
|
||||
renderWithLayout(testRoom);
|
||||
|
||||
expect(screen.getByTitle('Threads')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Members')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Files')).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle('Options')).toBeInTheDocument();
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Members', 'Discussions', 'Options']);
|
||||
});
|
||||
|
||||
it('should order featured and visible actions by their configured order instead of their declaration order', () => {
|
||||
renderWithLayout(testRoom, undefined, {
|
||||
Room_Toolbox_Layout: layoutSetting({
|
||||
roomType: [type],
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: true, order: 2 },
|
||||
{ id: 'pinned-messages', featured: true, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 3 },
|
||||
{ id: 'discussions', featured: false, order: 2 },
|
||||
{ id: 'files', featured: false, order: 1 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Pinned Messages', 'Threads', 'divider', 'Files', 'Discussions', 'Options']);
|
||||
expect(screen.queryByTitle('Members')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should collapse normal actions into kebab menu when roomToolboxExpanded is false (mobile viewport)', () => {
|
||||
renderWithLayout(testRoom, { roomToolboxExpanded: false });
|
||||
|
||||
expect(screen.getByTitle('Threads')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Members')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Discussions')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Files')).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle('Options')).toBeInTheDocument();
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Options']);
|
||||
});
|
||||
|
||||
describe('Soft Fallbacks', () => {
|
||||
it('should fallback to legacy behavior if feature preview is disabled', () => {
|
||||
renderWithLayout(testRoom, undefined, undefined, [{ name: 'roomToolboxLayout', value: false }]);
|
||||
|
||||
expect(screen.getByTitle('Threads')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Members')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Files')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed JSON', '{ invalid json }'],
|
||||
['a bare scope object instead of an array', JSON.stringify({ roomType: [type], ...mockLayoutConfig })],
|
||||
['the legacy layouts wrapper', JSON.stringify({ layouts: [{ roomType: [type], ...mockLayoutConfig }] })],
|
||||
['scoped to an unsupported room type', layoutSetting({ roomType: ['l'], ...mockLayoutConfig })],
|
||||
[
|
||||
'claiming the same room type twice',
|
||||
layoutSetting({ roomType: [type], ...mockLayoutConfig }, { roomType: [type], maxVisibleNormal: 1 }),
|
||||
],
|
||||
])('should fallback to legacy behavior if layout configuration is %s', (_, layoutValue) => {
|
||||
renderWithLayout(testRoom, undefined, { Room_Toolbox_Layout: layoutValue });
|
||||
|
||||
expect(screen.getByTitle('Threads')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Members')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Files')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to legacy behavior if no scope declares this room type', () => {
|
||||
const otherType = type === 'd' ? 'c' : 'd';
|
||||
renderWithLayout(testRoom, undefined, {
|
||||
Room_Toolbox_Layout: layoutSetting({ roomType: [otherType], ...mockLayoutConfig }),
|
||||
});
|
||||
|
||||
expect(screen.getByTitle('Files')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility (a11y)', () => {
|
||||
it('should not have any accessibility violations', async () => {
|
||||
const { container } = renderWithLayout(testRoom);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a single setting serving every room type', () => {
|
||||
const sharedSetting = layoutSetting(
|
||||
{
|
||||
roomType: ['c', 'p'],
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: true, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 2 },
|
||||
{ id: 'discussions', featured: false, order: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
roomType: ['d'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'files', featured: true, order: 1 }],
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['c', 'p'] as const)('should apply the channel scope to room type %s', (type) => {
|
||||
const room = createFakeRoom({ prid: undefined, t: type, name: type, fname: type });
|
||||
renderWithLayout(room, undefined, { Room_Toolbox_Layout: sharedSetting });
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Members', 'Discussions', 'Options']);
|
||||
});
|
||||
|
||||
it('should apply the direct message scope from the same setting', () => {
|
||||
const room = createFakeRoom({ prid: undefined, t: 'd', name: 'dm', fname: 'dm' });
|
||||
renderWithLayout(room, undefined, { Room_Toolbox_Layout: sharedSetting });
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Files', 'divider', 'Threads', 'Options']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
70
apps/meteor/client/views/room/Header/RoomHeader.tsx
Normal file
70
apps/meteor/client/views/room/Header/RoomHeader.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import { Header, HeaderContent, HeaderContentRow, HeaderToolbar } from '@rocket.chat/ui-client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FederatedRoomOriginServer from './FederatedRoomOriginServer';
|
||||
import ParentRoom from './ParentRoom';
|
||||
import RoomMemberStatus from './RoomMemberStatus';
|
||||
import RoomTitle from './RoomTitle';
|
||||
import RoomToolbox from './RoomToolbox';
|
||||
import RoomTopic from './RoomTopic';
|
||||
import Encrypted from './icons/Encrypted';
|
||||
import RoomGroupingMenu from './icons/RoomGroupingMenu';
|
||||
import Translate from './icons/Translate';
|
||||
|
||||
export type RoomHeaderProps = {
|
||||
room: IRoom;
|
||||
slots?: {
|
||||
start?: ReactNode;
|
||||
preContent?: ReactNode;
|
||||
insideContent?: ReactNode;
|
||||
posContent?: ReactNode;
|
||||
end?: ReactNode;
|
||||
toolbox?: {
|
||||
pre?: ReactNode;
|
||||
content?: ReactNode;
|
||||
pos?: ReactNode;
|
||||
hidden?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const RoomHeader = ({ room, slots = {} }: RoomHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Header>
|
||||
{slots?.start}
|
||||
<ParentRoom room={room} />
|
||||
{slots?.preContent}
|
||||
<HeaderContent>
|
||||
<HeaderContentRow>
|
||||
<RoomGroupingMenu room={room} />
|
||||
<RoomTitle room={room} />
|
||||
{isRoomFederated(room) && <FederatedRoomOriginServer room={room} />}
|
||||
<Encrypted room={room} />
|
||||
<Translate room={room} />
|
||||
<RoomTopic room={room} />
|
||||
<RoomMemberStatus room={room} />
|
||||
{slots?.insideContent}
|
||||
</HeaderContentRow>
|
||||
</HeaderContent>
|
||||
{slots?.posContent}
|
||||
{slots.toolbox?.hidden !== true && (
|
||||
<Suspense fallback={null}>
|
||||
<HeaderToolbar aria-label={t('Toolbox_room_actions')}>
|
||||
{slots?.toolbox?.pre}
|
||||
{slots?.toolbox?.content || <RoomToolbox />}
|
||||
{slots?.toolbox?.pos}
|
||||
</HeaderToolbar>
|
||||
</Suspense>
|
||||
)}
|
||||
{slots?.end}
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomHeader;
|
||||
30
apps/meteor/client/views/room/Header/RoomHeaderE2EESetup.tsx
Normal file
30
apps/meteor/client/views/room/Header/RoomHeaderE2EESetup.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
import RoomHeader from './RoomHeader';
|
||||
import type { RoomHeaderProps } from './RoomHeader';
|
||||
import { useE2EERoomState } from '../hooks/useE2EERoomState';
|
||||
import { useE2EEState } from '../hooks/useE2EEState';
|
||||
|
||||
const RoomToolboxE2EESetup = lazy(() => import('./RoomToolbox/RoomToolboxE2EESetup'));
|
||||
|
||||
const RoomHeaderE2EESetup = ({ room }: RoomHeaderProps) => {
|
||||
const e2eeState = useE2EEState();
|
||||
const e2eRoomState = useE2EERoomState(room._id);
|
||||
|
||||
if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === 'WAITING_KEYS') {
|
||||
return (
|
||||
<RoomHeader
|
||||
room={room}
|
||||
slots={{
|
||||
toolbox: {
|
||||
content: <RoomToolboxE2EESetup />,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <RoomHeader room={room} />;
|
||||
};
|
||||
|
||||
export default RoomHeaderE2EESetup;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import * as stories from './RoomInviteHeader.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
const appRoot = mockAppRoot().build();
|
||||
|
||||
jest.mock('../../../../client/lib/getURL', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
jest.mock('./ParentRoom', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentRoom</div>),
|
||||
}));
|
||||
|
||||
jest.mock('./RoomToolbox', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>RoomToolbox</div>),
|
||||
}));
|
||||
|
||||
describe('RoomInviteHeader', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const view = render(<Story />, { wrapper: appRoot });
|
||||
expect(view.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import RoomInviteHeader from './RoomInviteHeader';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
const mockedRoom = createFakeRoom({ name: 'rocket.cat', federated: true });
|
||||
|
||||
const meta = {
|
||||
component: RoomInviteHeader,
|
||||
args: {
|
||||
room: mockedRoom,
|
||||
},
|
||||
decorators: [(story) => <FakeRoomProvider roomOverrides={mockedRoom}>{story()}</FakeRoomProvider>],
|
||||
} satisfies Meta<typeof RoomInviteHeader>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
17
apps/meteor/client/views/room/Header/RoomInviteHeader.tsx
Normal file
17
apps/meteor/client/views/room/Header/RoomInviteHeader.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import RoomHeader from './RoomHeader';
|
||||
import type { RoomHeaderProps } from './RoomHeader';
|
||||
|
||||
const RoomInviteHeader = ({ room }: Pick<RoomHeaderProps, 'room'>) => {
|
||||
return (
|
||||
<RoomHeader
|
||||
room={room}
|
||||
slots={{
|
||||
toolbox: {
|
||||
hidden: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInviteHeader;
|
||||
122
apps/meteor/client/views/room/Header/RoomMemberStatus.spec.tsx
Normal file
122
apps/meteor/client/views/room/Header/RoomMemberStatus.spec.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import RoomMemberStatus from './RoomMemberStatus';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../tests/mocks/data';
|
||||
|
||||
const user = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const user2 = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
statusText: 'Sample Status',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const user3 = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const userWithExpiration = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
statusText: 'In a meeting',
|
||||
statusExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
status: UserStatus.BUSY,
|
||||
});
|
||||
|
||||
describe('RoomMemberStatus', () => {
|
||||
it('should render the statusText of the DM partner', () => {
|
||||
const room = createFakeRoom({ t: 'd', uids: [user._id, user2._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomMemberStatus room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withUsers([user2])
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sample Status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should expose the expiration text on the title attribute when the partner has one', () => {
|
||||
const room = createFakeRoom({ t: 'd', uids: [user._id, userWithExpiration._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomMemberStatus room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withUsers([userWithExpiration])
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
const wrapper = screen.getByText('In a meeting').closest('[title]');
|
||||
expect(wrapper).toHaveAttribute('title');
|
||||
expect(wrapper?.getAttribute('title')).not.toBe('');
|
||||
});
|
||||
|
||||
it('should not render statusText for multi-user DMs (3+ participants)', () => {
|
||||
const room = createFakeRoom({ t: 'd', uids: [user._id, user2._id, user3._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomMemberStatus room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withUsers([user2, user3])
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Sample Status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render statusText for non-DM rooms', () => {
|
||||
const room = createFakeRoom({ t: 'c' });
|
||||
const subscription = createFakeSubscription({ t: 'c', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomMemberStatus room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Sample Status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
25
apps/meteor/client/views/room/Header/RoomMemberStatus.tsx
Normal file
25
apps/meteor/client/views/room/Header/RoomMemberStatus.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useUserId, useUserPresence } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import MarkdownText from '../../../components/MarkdownText';
|
||||
import { useExpirationText } from '../../../hooks/useExpirationText';
|
||||
import { getUidDirectMessage } from '../../../lib/utils/getUidDirectMessage';
|
||||
|
||||
export type RoomMemberStatusProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const RoomMemberStatus = ({ room }: RoomMemberStatusProps): ReactElement | null => {
|
||||
const directUserId = getUidDirectMessage(room, useUserId());
|
||||
const presence = useUserPresence(directUserId);
|
||||
const expirationText = useExpirationText(presence?.statusExpiresAt);
|
||||
|
||||
if (!presence?.statusText && !presence?.statusExpiresAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownText title={expirationText} content={presence?.statusText} parseEmoji={true} variant='inline' withTruncatedText />;
|
||||
};
|
||||
|
||||
export default RoomMemberStatus;
|
||||
47
apps/meteor/client/views/room/Header/RoomTitle.tsx
Normal file
47
apps/meteor/client/views/room/Header/RoomTitle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { isTeamRoom, type IRoom } from '@rocket.chat/core-typings';
|
||||
import { useButtonPattern, useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useDocumentTitle, HeaderTitle, HeaderTitleButton } from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import HeaderIconWithRoom from './HeaderIconWithRoom';
|
||||
|
||||
export type RoomTitleProps = { room: IRoom };
|
||||
|
||||
const RoomTitle = ({ room }: RoomTitleProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useDocumentTitle(room.name, false);
|
||||
const { openTab } = useRoomToolbox();
|
||||
|
||||
const handleOpenRoomInfo = useStableCallback(() => {
|
||||
if (isTeamRoom(room)) {
|
||||
return openTab('team-info');
|
||||
}
|
||||
|
||||
switch (room.t) {
|
||||
case 'l':
|
||||
openTab('room-info');
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
(room.uids?.length ?? 0) > 2 ? openTab('user-info-group') : openTab('user-info');
|
||||
break;
|
||||
|
||||
default:
|
||||
openTab('channel-settings');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const buttonProps = useButtonPattern(handleOpenRoomInfo);
|
||||
|
||||
return (
|
||||
<HeaderTitleButton aria-label={`${room.name}${room.encrypted ? ` - ${t('encrypted')}` : ''}`} {...buttonProps} marginInlineEnd={4}>
|
||||
<HeaderIconWithRoom room={room} />
|
||||
<HeaderTitle>{room.name}</HeaderTitle>
|
||||
</HeaderTitleButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomTitle;
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericMenu, HeaderToolbarAction, HeaderToolbarDivider } from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox, type RenderToolboxItemParams, type RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useRoomToolboxActions } from './hooks/useRoomToolboxActions';
|
||||
|
||||
export type RoomToolboxProps = {
|
||||
className?: ComponentProps<typeof Box>['className'];
|
||||
};
|
||||
|
||||
const RoomToolbox = ({ className }: RoomToolboxProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toolbox = useRoomToolbox();
|
||||
const { featuredActions, hiddenActions, visibleActions } = useRoomToolboxActions(toolbox);
|
||||
|
||||
const showKebabMenu = hiddenActions.length > 0;
|
||||
|
||||
const renderDefaultToolboxItem = useStableCallback(
|
||||
({ id, className, icon, title, toolbox: { tab }, action, disabled, tooltip }: RenderToolboxItemParams) => {
|
||||
return (
|
||||
<HeaderToolbarAction
|
||||
key={id}
|
||||
className={className}
|
||||
icon={icon}
|
||||
title={t(title)}
|
||||
pressed={id === tab?.id}
|
||||
onClick={action}
|
||||
disabled={disabled}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const mapToToolboxItem = (action: RoomToolboxActionConfig) => {
|
||||
return (action.renderToolboxItem ?? renderDefaultToolboxItem)?.({
|
||||
...action,
|
||||
action: action.action ?? (() => toolbox.openTab(action.id)),
|
||||
className,
|
||||
toolbox,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{featuredActions.map(mapToToolboxItem)}
|
||||
{featuredActions.length > 0 && <HeaderToolbarDivider />}
|
||||
{visibleActions.map(mapToToolboxItem)}
|
||||
{showKebabMenu && <GenericMenu className={className} title={t('Options')} sections={hiddenActions} placement='bottom-end' />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RoomToolbox);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableArray } from '@rocket.chat/fuselage-hooks';
|
||||
import { HeaderToolbarAction } from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox, type RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { roomActionHooksForE2EESetup } from '../../../../ui';
|
||||
|
||||
export type RoomToolboxE2EESetupProps = {
|
||||
className?: ComponentProps<typeof Box>['className'];
|
||||
};
|
||||
|
||||
const RoomToolboxE2EESetup = ({ className }: RoomToolboxE2EESetupProps) => {
|
||||
const { t } = useTranslation();
|
||||
const toolbox = useRoomToolbox();
|
||||
|
||||
const { tab } = toolbox;
|
||||
|
||||
const actions = useStableArray(
|
||||
roomActionHooksForE2EESetup
|
||||
.map((roomActionHook) => roomActionHook())
|
||||
.filter((roomAction): roomAction is RoomToolboxActionConfig => !!roomAction),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{actions.map(({ id, icon, title, action, disabled, tooltip }) => (
|
||||
<HeaderToolbarAction
|
||||
key={id}
|
||||
className={className}
|
||||
id={id}
|
||||
icon={icon}
|
||||
title={t(title)}
|
||||
pressed={id === tab?.id}
|
||||
onClick={action ?? (() => toolbox.openTab(id))}
|
||||
disabled={disabled}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomToolboxE2EESetup;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
|
||||
const setting = (value: unknown) => JSON.stringify(value);
|
||||
|
||||
describe('resolveLayoutForRoomType', () => {
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['invalid JSON', '{ invalid json }'],
|
||||
['a bare entry object', setting({ roomType: ['c'] })],
|
||||
['the legacy layouts wrapper', setting({ layouts: [{ roomType: ['c'] }] })],
|
||||
['a JSON primitive', '42'],
|
||||
['null', 'null'],
|
||||
])('should return null when the setting is %s', (_, raw) => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
|
||||
const raw = setting([
|
||||
{ roomType: ['c', 'p'], maxVisibleNormal: 4, items: [{ id: 'thread', featured: true, order: 1 }] },
|
||||
{ roomType: ['d'], maxVisibleNormal: 1, items: [{ id: 'discussions', order: 1 }] },
|
||||
]);
|
||||
|
||||
it.each([
|
||||
['c', 4],
|
||||
['p', 4],
|
||||
['d', 1],
|
||||
] as const)('should resolve the entry declaring room type %s', (roomType, maxVisibleNormal) => {
|
||||
expect(resolveLayoutForRoomType(raw, roomType)).toEqual(
|
||||
expect.objectContaining({
|
||||
maxVisibleNormal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip roomType from the resolved engine config', () => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toEqual({
|
||||
maxVisibleNormal: 4,
|
||||
items: [{ id: 'thread', featured: true, order: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for a room type no entry declares', () => {
|
||||
expect(resolveLayoutForRoomType(setting([{ roomType: ['c'] }]), 'd')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for the omnichannel room type', () => {
|
||||
expect(resolveLayoutForRoomType(raw, 'l')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the setting itself is not parseable', () => {
|
||||
expect(resolveLayoutForRoomType('{ invalid json }', 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when there are no entries', () => {
|
||||
expect(resolveLayoutForRoomType(setting([]), 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when an entry declares no roomType, as the schema requires one', () => {
|
||||
expect(resolveLayoutForRoomType(setting([{ maxVisibleNormal: 2 }, { roomType: ['c'], maxVisibleNormal: 4 }]), 'c')).toBeNull();
|
||||
});
|
||||
|
||||
describe('when the value did not go through schema validation', () => {
|
||||
it.each([
|
||||
['items is not an array', setting([{ roomType: ['c'], items: {} }])],
|
||||
['items holds a null', setting([{ roomType: ['c'], items: [null] }])],
|
||||
['items holds a primitive', setting([{ roomType: ['c'], items: ['thread'] }])],
|
||||
['an item has no id', setting([{ roomType: ['c'], items: [{ order: 1 }] }])],
|
||||
['an item id is not a string', setting([{ roomType: ['c'], items: [{ id: 1 }] }])],
|
||||
['maxVisibleNormal is a string', setting([{ roomType: ['c'], maxVisibleNormal: '4' }])],
|
||||
['maxVisibleNormal is null', setting([{ roomType: ['c'], maxVisibleNormal: null }])],
|
||||
['the same room type is claimed by two entries', setting([{ roomType: ['c', 'p'] }, { roomType: ['c'] }])],
|
||||
['the same room type is repeated inside one entry', setting([{ roomType: ['c', 'c'] }])],
|
||||
['a roomType is not an array', setting([{ roomType: 'c' }])],
|
||||
['a roomType is empty', setting([{ roomType: [] }])],
|
||||
['a roomType mixes a supported and an unsupported type', setting([{ roomType: ['c', 'l'], maxVisibleNormal: 4 }])],
|
||||
['a roomType holds only an unsupported type', setting([{ roomType: ['l'] }, { roomType: ['c'] }])],
|
||||
['a roomType holds an uppercase type', setting([{ roomType: ['C', 'c'] }])],
|
||||
['a roomType holds a non-string', setting([{ roomType: [1, 'c'] }])],
|
||||
['an entry is null', setting([{ roomType: ['c'] }, null])],
|
||||
['the matching entry itself is null', setting([null, { roomType: ['d'] }])],
|
||||
['an entry is a primitive', setting([{ roomType: ['c'] }, 'nope'])],
|
||||
['an entry is an array', setting([{ roomType: ['c'] }, []])],
|
||||
['an item featured is not a boolean', setting([{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }])],
|
||||
['an item order is not a number', setting([{ roomType: ['c'], items: [{ id: 'thread', order: 'first' }] }])],
|
||||
['an item order is an object', setting([{ roomType: ['c'], items: [{ id: 'thread', order: {} }] }])],
|
||||
['an item order is null', setting([{ roomType: ['c'], items: [{ id: 'thread', order: null }] }])],
|
||||
['an item order parses to Infinity', '[{"roomType":["c"],"items":[{"id":"thread","order":1e999}]}]'],
|
||||
['the setting is an empty list', setting([])],
|
||||
['an entry carries an unknown key', setting([{ roomType: ['c'], somethingElse: true }])],
|
||||
['an item carries an unknown key', setting([{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }])],
|
||||
['an item id is empty', setting([{ roomType: ['c'], items: [{ id: '' }] }])],
|
||||
['an item order is fractional', setting([{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }])],
|
||||
['maxVisibleNormal is negative', setting([{ roomType: ['c'], maxVisibleNormal: -1 }])],
|
||||
['maxVisibleNormal is fractional', setting([{ roomType: ['c'], maxVisibleNormal: 1.5 }])],
|
||||
])('should return null so the toolbox keeps its default layout when %s', (_, raw) => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should discard the whole setting when a later entry overlaps, even if the matching one is fine', () => {
|
||||
const raw = setting([{ roomType: ['c'], maxVisibleNormal: 4 }, { roomType: ['d'] }, { roomType: ['d'] }]);
|
||||
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { RoomType } from '@rocket.chat/core-typings';
|
||||
import { isRecord } from '@rocket.chat/tools';
|
||||
|
||||
import type { RoomToolboxLayoutConfig } from './processRoomActions';
|
||||
|
||||
const ROOM_TOOLBOX_LAYOUT_ROOM_TYPES = ['c', 'p', 'd'] as const;
|
||||
|
||||
type RoomToolboxLayoutRoomType = (typeof ROOM_TOOLBOX_LAYOUT_ROOM_TYPES)[number];
|
||||
|
||||
type RoomToolboxLayoutScope = RoomToolboxLayoutConfig & {
|
||||
roomType: RoomToolboxLayoutRoomType[];
|
||||
};
|
||||
|
||||
export type RoomToolboxLayoutSetting = RoomToolboxLayoutScope[];
|
||||
|
||||
const SUPPORTED_ROOM_TYPES: ReadonlySet<unknown> = new Set(ROOM_TOOLBOX_LAYOUT_ROOM_TYPES);
|
||||
|
||||
const ENTRY_KEYS = ['roomType', 'maxVisibleNormal', 'items'];
|
||||
|
||||
const ITEM_KEYS = ['id', 'featured', 'order'];
|
||||
|
||||
// mirrors apps/meteor/lib/roomToolboxLayout/room-toolbox-layout.schema.json, which rejects the same
|
||||
// values when the setting is saved; both have to change together
|
||||
const hasOnlyKeys = (value: Record<string, unknown>, allowed: string[]): boolean =>
|
||||
Object.keys(value).every((key) => allowed.includes(key));
|
||||
|
||||
const isCount = (value: unknown): boolean => Number.isInteger(value) && (value as number) >= 0;
|
||||
|
||||
const isLayoutItem = (value: unknown): boolean =>
|
||||
isRecord(value) &&
|
||||
hasOnlyKeys(value, ITEM_KEYS) &&
|
||||
typeof value.id === 'string' &&
|
||||
value.id.length > 0 &&
|
||||
(value.featured === undefined || typeof value.featured === 'boolean') &&
|
||||
(value.order === undefined || Number.isInteger(value.order));
|
||||
|
||||
const isRoomTypeList = (value: unknown): boolean =>
|
||||
Array.isArray(value) && value.length > 0 && new Set(value).size === value.length && value.every((type) => SUPPORTED_ROOM_TYPES.has(type));
|
||||
|
||||
const isLayoutScope = (value: unknown): boolean =>
|
||||
isRecord(value) &&
|
||||
hasOnlyKeys(value, ENTRY_KEYS) &&
|
||||
isRoomTypeList(value.roomType) &&
|
||||
(value.maxVisibleNormal === undefined || isCount(value.maxVisibleNormal)) &&
|
||||
(value.items === undefined || (Array.isArray(value.items) && value.items.every(isLayoutItem)));
|
||||
|
||||
const hasOverlappingRoomTypes = (scopes: RoomToolboxLayoutSetting): boolean => {
|
||||
const claimed = new Set<RoomToolboxLayoutRoomType>();
|
||||
|
||||
for (const { roomType: roomTypes } of scopes) {
|
||||
for (const claimedType of roomTypes) {
|
||||
if (claimed.has(claimedType)) {
|
||||
return true;
|
||||
}
|
||||
claimed.add(claimedType);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const parseLayoutSetting = (raw: string): RoomToolboxLayoutSetting | null => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isLayoutScope)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as RoomToolboxLayoutSetting;
|
||||
};
|
||||
|
||||
export const resolveLayoutForRoomType = (raw: string, roomType: RoomType): RoomToolboxLayoutConfig | null => {
|
||||
const scopes = parseLayoutSetting(raw);
|
||||
if (!scopes || hasOverlappingRoomTypes(scopes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scope = scopes.find(({ roomType: roomTypes }) => roomTypes.includes(roomType as RoomToolboxLayoutRoomType));
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { maxVisibleNormal: scope.maxVisibleNormal, items: scope.items };
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import { processRoomActions } from './processRoomActions';
|
||||
import type { RoomToolboxLayoutConfig } from './processRoomActions';
|
||||
|
||||
describe('RoomToolbox Layout Engine (processRoomActions)', () => {
|
||||
it('should isolate featured items into featuredActions', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 10 },
|
||||
{ id: 'start-call', featured: true, order: 5 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
});
|
||||
it('should respect maxVisibleNormal limit and sort items by order', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'clean-history' }, { id: 'team-info' }, { id: 'members-list' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 10 },
|
||||
{ id: 'clean-history', featured: false, order: 250 },
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 7 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['team-info', 'members-list']);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('thread');
|
||||
expect(hiddenIds).toContain('clean-history');
|
||||
});
|
||||
it('should force app actions into hiddenActions grouped by section, ignoring config placement', () => {
|
||||
const actionsBase = [{ id: 'app-star-actions', type: 'apps' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'app-star-actions', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(0);
|
||||
expect(result.hiddenActions).toMatchObject([
|
||||
{
|
||||
id: 'apps',
|
||||
items: [{ id: 'app-star-actions' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should safely handle new/unknown actions not present in the layout config without dropping them', () => {
|
||||
const actionsBase = [{ id: 'legacy-action' }, { id: 'brand-new-action' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'legacy-action', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
const allRenderedIds = [
|
||||
...result.featuredActions.map((a) => a.id),
|
||||
...result.visibleActions.map((a) => a.id),
|
||||
...result.hiddenActions.flatMap((section) => section.items.map((i) => i.id)),
|
||||
];
|
||||
expect(allRenderedIds).toContain('brand-new-action');
|
||||
});
|
||||
it('should return base actions unchanged when the config has no items', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config = {
|
||||
items: [],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result).toEqual({
|
||||
featuredActions: [],
|
||||
visibleActions: actionsBase,
|
||||
hiddenActions: [],
|
||||
});
|
||||
});
|
||||
it('should return base actions unchanged when items is missing from the config object', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config: RoomToolboxLayoutConfig = {};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result).toEqual({
|
||||
featuredActions: [],
|
||||
visibleActions: actionsBase,
|
||||
hiddenActions: [],
|
||||
});
|
||||
});
|
||||
it('should fall back to default maxVisibleNormal (6) when not specified in config', () => {
|
||||
const actionsBase = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }, { id: '6' }, { id: '7' }];
|
||||
const config = {
|
||||
items: [
|
||||
{ id: '1', featured: false, order: 1 },
|
||||
{ id: '2', featured: false, order: 2 },
|
||||
{ id: '3', featured: false, order: 3 },
|
||||
{ id: '4', featured: false, order: 4 },
|
||||
{ id: '5', featured: false, order: 5 },
|
||||
{ id: '6', featured: false, order: 6 },
|
||||
{ id: '7', featured: false, order: 7 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(6);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toEqual(['7']);
|
||||
});
|
||||
it('should force app actions into hiddenActions', () => {
|
||||
const actionsBase = [{ id: 'app-x', type: 'apps' }, { id: 'thread' }];
|
||||
const result = processRoomActions(actionsBase, { maxVisibleNormal: 6 });
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread']);
|
||||
expect(result.hiddenActions).toMatchObject([{ id: 'apps', items: [{ id: 'app-x' }] }]);
|
||||
});
|
||||
it('should keep actions flagged as featured by themselves when the config does not mention them', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-video-call', featured: true }, { id: 'ai-actions', featured: true }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'thread', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['start-video-call', 'ai-actions']);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread']);
|
||||
expect(result.hiddenActions).toHaveLength(0);
|
||||
});
|
||||
it('should let the config demote an action flagged as featured by itself', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-video-call', featured: true }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'start-video-call', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions).toHaveLength(0);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['start-video-call', 'thread']);
|
||||
});
|
||||
it('should keep configured actions ahead of unconfigured ones even when the configured order is very large', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'discussions' }, { id: 'clean-history' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'clean-history', featured: false, order: 10000 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['clean-history', 'thread', 'discussions']);
|
||||
});
|
||||
it('should keep unconfigured actions in their incoming order', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'discussions' }, { id: 'clean-history' }, { id: 'mentions' }];
|
||||
const result = processRoomActions(actionsBase, { maxVisibleNormal: 6, items: [] });
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread', 'discussions', 'clean-history', 'mentions']);
|
||||
});
|
||||
it('should sort featured items with an omitted order after the ones that declare it', () => {
|
||||
const actionsBase = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'a', featured: true },
|
||||
{ id: 'b', featured: true, order: 3 },
|
||||
{ id: 'c', featured: true, order: 1 },
|
||||
{ id: 'd', featured: true, order: 2 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((x) => x.id)).toEqual(['c', 'd', 'b', 'a']);
|
||||
});
|
||||
it('should sort featuredActions by order when multiple featured items exist', () => {
|
||||
const actionsBase = [{ id: 'start-call' }, { id: 'thread' }, { id: 'search' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'start-call', featured: true, order: 20 },
|
||||
{ id: 'thread', featured: true, order: 5 },
|
||||
{ id: 'search', featured: false, order: 10 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
});
|
||||
it('should hide all normal actions when maxVisibleNormal is 0', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'search' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 0,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 1 },
|
||||
{ id: 'search', featured: false, order: 2 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(0);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toEqual(['thread', 'search']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
export type RoomToolboxLayoutItem = {
|
||||
id: string;
|
||||
featured?: boolean;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type RoomToolboxLayoutConfig = {
|
||||
maxVisibleNormal?: number;
|
||||
items?: RoomToolboxLayoutItem[];
|
||||
};
|
||||
|
||||
export type RoomToolboxBaseAction = {
|
||||
id: string;
|
||||
type?: string;
|
||||
featured?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type RoomToolboxHiddenSection = {
|
||||
id: string;
|
||||
items: RoomToolboxBaseAction[];
|
||||
};
|
||||
|
||||
const compareByOrder = (a: { order: number }, b: { order: number }): number => (a.order === b.order ? 0 : a.order - b.order);
|
||||
|
||||
const groupActionsByType = (actions: RoomToolboxBaseAction[]): RoomToolboxHiddenSection[] => {
|
||||
const sectionsMap = new Map<string, RoomToolboxHiddenSection>();
|
||||
for (const action of actions) {
|
||||
const group = action.type ?? '';
|
||||
const existing = sectionsMap.get(group);
|
||||
if (existing) {
|
||||
existing.items.push(action);
|
||||
} else {
|
||||
sectionsMap.set(group, { id: group, items: [action] });
|
||||
}
|
||||
}
|
||||
return Array.from(sectionsMap.values());
|
||||
};
|
||||
|
||||
export const processRoomActions = (actionsBase: RoomToolboxBaseAction[], config: RoomToolboxLayoutConfig) => {
|
||||
const appActions = actionsBase.filter((a) => a.type === 'apps');
|
||||
const nonAppActions = actionsBase.filter((a) => a.type !== 'apps');
|
||||
|
||||
const itemMap = new Map((config.items || []).map((item) => [item.id, item]));
|
||||
|
||||
const [featuredWithOrder, normalWithOrder] = nonAppActions.reduce<
|
||||
[{ action: RoomToolboxBaseAction; order: number }[], { action: RoomToolboxBaseAction; order: number }[]]
|
||||
>(
|
||||
(acc, action) => {
|
||||
const configItem = itemMap.get(action.id);
|
||||
const isFeatured = configItem?.featured ?? action.featured ?? false;
|
||||
const order = configItem?.order ?? Number.POSITIVE_INFINITY;
|
||||
acc[isFeatured ? 0 : 1].push({ action, order });
|
||||
return acc;
|
||||
},
|
||||
[[], []],
|
||||
);
|
||||
|
||||
featuredWithOrder.sort(compareByOrder);
|
||||
normalWithOrder.sort(compareByOrder);
|
||||
|
||||
const maxVisible = config.maxVisibleNormal ?? 6;
|
||||
const visibleActions = normalWithOrder.slice(0, maxVisible).map((n) => n.action);
|
||||
const overflowNormalActions = normalWithOrder.slice(maxVisible).map((n) => n.action);
|
||||
|
||||
const hiddenActions: RoomToolboxHiddenSection[] = [];
|
||||
|
||||
if (appActions.length > 0) {
|
||||
hiddenActions.push({ id: 'apps', items: appActions });
|
||||
}
|
||||
|
||||
hiddenActions.push(...groupActionsByType(overflowNormalActions));
|
||||
|
||||
return {
|
||||
featuredActions: featuredWithOrder.map((n) => n.action),
|
||||
visibleActions,
|
||||
hiddenActions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
import type { RoomToolboxLayoutSetting } from './parseLayoutSetting';
|
||||
import schema from '../../../../../../lib/roomToolboxLayout/room-toolbox-layout.schema.json';
|
||||
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const validSetting: RoomToolboxLayoutSetting = [
|
||||
{ roomType: ['c', 'p'], maxVisibleNormal: 3, items: [{ id: 'members-list', featured: true, order: 1 }] },
|
||||
{ roomType: ['d'], maxVisibleNormal: 1 },
|
||||
];
|
||||
|
||||
describe('room toolbox layout JSON schema', () => {
|
||||
it('accepts a document typed as RoomToolboxLayoutSetting', () => {
|
||||
expect(validate(validSetting)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an entry with only roomType', [{ roomType: ['c'] }]],
|
||||
['an entry with an empty items list', [{ roomType: ['c'], items: [] }]],
|
||||
['an item with only an id', [{ roomType: ['c'], items: [{ id: 'thread' }] }]],
|
||||
['maxVisibleNormal of zero', [{ roomType: ['c'], maxVisibleNormal: 0 }]],
|
||||
['a negative order', [{ roomType: ['c'], items: [{ id: 'thread', order: -1 }] }]],
|
||||
['entries that partition the room types', [{ roomType: ['c', 'p'] }, { roomType: ['d'] }]],
|
||||
])('accepts %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a bare entry object', { roomType: ['c'] }],
|
||||
['the legacy layouts wrapper', { layouts: [{ roomType: ['c'] }] }],
|
||||
['a JSON primitive', 42],
|
||||
['null', null],
|
||||
['an empty list', []],
|
||||
['a null entry', [null]],
|
||||
['an entry that is an array', [[]]],
|
||||
['an unknown entry key', [{ roomType: ['c'], somethingElse: true }]],
|
||||
])('rejects %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
|
||||
describe('roomType', () => {
|
||||
it.each([
|
||||
['roomType is missing', [{ items: [] }]],
|
||||
['roomType is an empty list', [{ roomType: [] }]],
|
||||
['roomType is a bare string', [{ roomType: 'c' }]],
|
||||
['roomType holds the omnichannel type', [{ roomType: ['l'] }]],
|
||||
['roomType holds an unknown type', [{ roomType: ['x'] }]],
|
||||
['roomType mixes a valid and an invalid type', [{ roomType: ['c', 'l'] }]],
|
||||
['roomType holds a non-string', [{ roomType: [1] }]],
|
||||
['roomType holds an uppercase type', [{ roomType: ['C'] }]],
|
||||
['the same room type is repeated inside one entry', [{ roomType: ['c', 'c'] }]],
|
||||
['the same room type is claimed by two entries', [{ roomType: ['c', 'p'] }, { roomType: ['c'] }]],
|
||||
['every room type is claimed twice', [{ roomType: ['c', 'p', 'd'] }, { roomType: ['c', 'p', 'd'] }]],
|
||||
])('rejects when %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('items and maxVisibleNormal', () => {
|
||||
it.each([
|
||||
['items is not an array', [{ roomType: ['c'], items: {} }]],
|
||||
['an item is null', [{ roomType: ['c'], items: [null] }]],
|
||||
['an item has no id', [{ roomType: ['c'], items: [{ order: 1 }] }]],
|
||||
['an item id is empty', [{ roomType: ['c'], items: [{ id: '' }] }]],
|
||||
['an item id is not a string', [{ roomType: ['c'], items: [{ id: {} }] }]],
|
||||
['an item featured is not a boolean', [{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }]],
|
||||
['an item order is fractional', [{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }]],
|
||||
['an item has an unknown key', [{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }]],
|
||||
['maxVisibleNormal is negative', [{ roomType: ['c'], maxVisibleNormal: -1 }]],
|
||||
['maxVisibleNormal is fractional', [{ roomType: ['c'], maxVisibleNormal: 1.5 }]],
|
||||
[
|
||||
'a later entry is invalid even if the first one is fine',
|
||||
[
|
||||
{ roomType: ['c'], items: [{ id: 'thread' }] },
|
||||
{ roomType: ['d'], items: [null] },
|
||||
],
|
||||
],
|
||||
])('rejects when %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('client validation parity with the JSON schema', () => {
|
||||
const documents: [string, unknown][] = [
|
||||
['a complete document', validSetting],
|
||||
['an entry with only roomType', [{ roomType: ['c'] }]],
|
||||
['maxVisibleNormal of zero', [{ roomType: ['c'], maxVisibleNormal: 0 }]],
|
||||
['an item with only an id', [{ roomType: ['c'], items: [{ id: 'thread' }] }]],
|
||||
['an empty list', []],
|
||||
['an entry with no roomType', [{ roomType: ['c'] }, { maxVisibleNormal: 1 }]],
|
||||
['an unsupported room type', [{ roomType: ['c', 'l'] }]],
|
||||
['a duplicated room type', [{ roomType: ['c', 'c'] }]],
|
||||
['overlapping entries', [{ roomType: ['c', 'p'] }, { roomType: ['c'] }]],
|
||||
['an unknown entry key', [{ roomType: ['c'], somethingElse: true }]],
|
||||
['an unknown item key', [{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }]],
|
||||
['an empty item id', [{ roomType: ['c'], items: [{ id: '' }] }]],
|
||||
['a fractional order', [{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }]],
|
||||
['a non-boolean featured', [{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }]],
|
||||
['a negative maxVisibleNormal', [{ roomType: ['c'], maxVisibleNormal: -1 }]],
|
||||
['a fractional maxVisibleNormal', [{ roomType: ['c'], maxVisibleNormal: 1.5 }]],
|
||||
['a null entry', [{ roomType: ['c'] }, null]],
|
||||
];
|
||||
|
||||
it.each(documents)('agrees on %s', (_name, document) => {
|
||||
const raw = JSON.stringify(document);
|
||||
// ajv coerces in place, so it gets its own copy
|
||||
const acceptedBySchema = validate(JSON.parse(raw));
|
||||
const claimsPublicChannel = (document as { roomType?: string[] }[]).some((entry) => entry?.roomType?.includes('c'));
|
||||
|
||||
expect(resolveLayoutForRoomType(raw, 'c') !== null).toBe(acceptedBySchema && claimsPublicChannel);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { LayoutContext } from '@rocket.chat/ui-contexts';
|
||||
import type { LayoutContextValue, RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useRoomToolboxActions } from './useRoomToolboxActions';
|
||||
import FakeRoomProvider from '../../../../../../tests/mocks/client/FakeRoomProvider';
|
||||
|
||||
const MockTabComponent = () => <div>tab</div>;
|
||||
|
||||
describe('useRoomToolboxActions', () => {
|
||||
it('should return an empty array if there are no actions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions: [], openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.featuredActions).toEqual([]);
|
||||
expect(result.current.hiddenActions).toEqual([]);
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return apps actions only inside hiddenActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions: appsActions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
const appsSection = result.current.hiddenActions[0];
|
||||
const appsItems = appsSection.items;
|
||||
|
||||
expect(appsSection).toBeDefined();
|
||||
expect(appsSection).toHaveProperty('id', 'apps');
|
||||
expect(appsItems).toMatchObject(appsActions);
|
||||
});
|
||||
|
||||
it('should return max of 6 items on visibleActions and the rest items inside hiddenActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.hiddenActions.length).toBeGreaterThan(0);
|
||||
expect(result.current.visibleActions.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should return featured items inside featuredActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.featuredActions).toMatchObject(actions.filter((action) => action.featured));
|
||||
});
|
||||
|
||||
describe('with roomToolboxLayout feature preview enabled', () => {
|
||||
const layoutSetting = (...scopes: object[]) => JSON.stringify(scopes);
|
||||
|
||||
const mockLayoutConfig = layoutSetting({
|
||||
roomType: ['c'],
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'thread', featured: true, order: 10 },
|
||||
{ id: 'discussions', featured: false, order: 2 },
|
||||
{ id: 'rocket-search', featured: false, order: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
it('should respect custom featured and visible ordering for public channels', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(['team-info', 'discussions']);
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('rocket-search');
|
||||
});
|
||||
|
||||
describe('with a single setting scoped by room type', () => {
|
||||
const scopedSetting = layoutSetting(
|
||||
{
|
||||
roomType: ['c', 'p'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'thread', featured: true, order: 1 }],
|
||||
},
|
||||
{
|
||||
roomType: ['d'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'discussions', featured: false, order: 1 }],
|
||||
},
|
||||
);
|
||||
|
||||
const renderForRoomType = (roomType: 'c' | 'p' | 'd' | 'l') =>
|
||||
renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', scopedSetting)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: roomType }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
it.each(['c', 'p'] as const)('should apply the scope declaring both channel types to room type %s', (roomType) => {
|
||||
const { result } = renderForRoomType(roomType);
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
});
|
||||
|
||||
it('should apply the direct message scope from the same setting', () => {
|
||||
const { result } = renderForRoomType('d');
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(['discussions']);
|
||||
});
|
||||
|
||||
it('should fall back to legacy behavior for a room type no scope declares', () => {
|
||||
const { result } = renderForRoomType('l');
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep actions flagged as featured by themselves out of the visible row and the kebab menu', () => {
|
||||
const configWithoutStartCall = layoutSetting({
|
||||
roomType: ['c'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'team-info', featured: false, order: 1 }],
|
||||
});
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', configWithoutStartCall)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).not.toContain('start-call');
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).not.toContain('start-call');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid JSON', '{ invalid json }'],
|
||||
['an empty array', '[]'],
|
||||
['a bare scope object instead of an array', JSON.stringify({ roomType: ['c'], maxVisibleNormal: 1, items: [{ id: 'team-info' }] })],
|
||||
['the legacy layouts wrapper', JSON.stringify({ layouts: [{ roomType: ['c'], maxVisibleNormal: 1 }] })],
|
||||
['non-array items', layoutSetting({ roomType: ['c'], items: {} })],
|
||||
['items with invalid item types', layoutSetting({ roomType: ['c'], items: [null] })],
|
||||
['a scope without roomType', layoutSetting({ maxVisibleNormal: 1, items: [] })],
|
||||
['an empty roomType', layoutSetting({ roomType: [], items: [] })],
|
||||
['an unsupported roomType', layoutSetting({ roomType: ['l'], items: [] })],
|
||||
['room types overlapping across scopes', layoutSetting({ roomType: ['c', 'p'] }, { roomType: ['c'] })],
|
||||
['a room type repeated inside one scope', layoutSetting({ roomType: ['c', 'c'] })],
|
||||
])('should fall back to legacy behavior if config is %s', (_, layoutConfigValue) => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', layoutConfigValue)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
|
||||
it('should fall back to legacy behavior if feature preview is disabled', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: false }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
|
||||
const collapsedLayoutContextValue: LayoutContextValue = {
|
||||
isEmbedded: false,
|
||||
showTopNavbarEmbeddedLayout: false,
|
||||
isTablet: false,
|
||||
isMobile: false,
|
||||
roomToolboxExpanded: false,
|
||||
navbar: {
|
||||
searchExpanded: false,
|
||||
},
|
||||
sidebar: {
|
||||
overlayed: false,
|
||||
setOverlayed: () => undefined,
|
||||
isCollapsed: false,
|
||||
shouldToggle: false,
|
||||
toggle: () => undefined,
|
||||
collapse: () => undefined,
|
||||
expand: () => undefined,
|
||||
close: () => undefined,
|
||||
},
|
||||
sidePanel: {
|
||||
displaySidePanel: true,
|
||||
closeSidePanel: () => undefined,
|
||||
openSidePanel: () => undefined,
|
||||
},
|
||||
size: {
|
||||
sidebar: '240px',
|
||||
contextualBar: '380px',
|
||||
},
|
||||
contextualBarPosition: 'relative',
|
||||
contextualBarExpanded: false,
|
||||
hiddenActions: {
|
||||
roomToolbox: [],
|
||||
messageToolbox: [],
|
||||
composerToolbox: [],
|
||||
userToolbox: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe('when a demoted action can not render as a menu item', () => {
|
||||
// mirrors useAppsRoomStarActions: renders itself, has no `action` and no `tabComponent`
|
||||
const renderOnlyAction = {
|
||||
id: 'ai-actions',
|
||||
title: 'AI_Actions',
|
||||
icon: 'stars',
|
||||
groups: ['channel'],
|
||||
featured: true,
|
||||
order: 3,
|
||||
renderToolboxItem: () => null,
|
||||
} as unknown as RoomToolboxActionConfig;
|
||||
|
||||
const renderWithConfig = (config: object, layoutContextValue?: LayoutContextValue) => {
|
||||
const withLayout = (children: ReactNode) =>
|
||||
layoutContextValue ? <LayoutContext.Provider value={layoutContextValue}>{children}</LayoutContext.Provider> : children;
|
||||
|
||||
return renderHook(() => useRoomToolboxActions({ actions: [...actions, renderOnlyAction], openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', layoutSetting({ roomType: ['c'], ...config }))
|
||||
.wrap((children) => withLayout(<FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>))
|
||||
.build(),
|
||||
});
|
||||
};
|
||||
|
||||
const hiddenIdsOf = (result: { hiddenActions: { items: { id?: string }[] }[] }) =>
|
||||
result.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
|
||||
it('should keep it in the visible row when it fits, honouring the configured order', () => {
|
||||
const { result } = renderWithConfig({
|
||||
maxVisibleNormal: 3,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 2 },
|
||||
{ id: 'ai-actions', featured: false, order: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id).slice(0, 2)).toEqual(['ai-actions', 'team-info']);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).not.toContain('ai-actions');
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
|
||||
it('should pull it back into the visible row when it would overflow into the kebab', () => {
|
||||
const { result } = renderWithConfig({
|
||||
maxVisibleNormal: 1,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'ai-actions', featured: false, order: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toContain('ai-actions');
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
|
||||
it('should pin it to the featured row when the toolbox is collapsed and the visible row is gone', () => {
|
||||
const { result } = renderWithConfig(
|
||||
{
|
||||
maxVisibleNormal: 1,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'ai-actions', featured: false, order: 2 },
|
||||
],
|
||||
},
|
||||
collapsedLayoutContextValue,
|
||||
);
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toContain('ai-actions');
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unexpanded layout properly (roomToolboxExpanded = false)', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => (
|
||||
<LayoutContext.Provider value={collapsedLayoutContextValue}>
|
||||
<FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>
|
||||
</LayoutContext.Provider>
|
||||
))
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('team-info');
|
||||
expect(hiddenIds).toContain('discussions');
|
||||
expect(hiddenIds).toContain('rocket-search');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const appsActions: RoomToolboxActionConfig[] = [
|
||||
{
|
||||
id: 'app1',
|
||||
title: 'app-42212581-0966-44aa-8366-b3e92aa00df4.action_button_label_files',
|
||||
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
|
||||
type: 'apps',
|
||||
icon: undefined as unknown as RoomToolboxActionConfig['icon'],
|
||||
},
|
||||
{
|
||||
id: 'app2',
|
||||
title: 'app-42212581-0966-44aa-8366-b3e92aa00df4.action_button_label_files',
|
||||
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
|
||||
type: 'apps',
|
||||
icon: undefined as unknown as RoomToolboxActionConfig['icon'],
|
||||
},
|
||||
];
|
||||
|
||||
const baseActions: RoomToolboxActionConfig[] = [
|
||||
{
|
||||
id: 'team-info',
|
||||
groups: ['team'],
|
||||
anonymous: true,
|
||||
full: true,
|
||||
title: 'Teams_Info',
|
||||
icon: 'info-circled',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'thread',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
full: true,
|
||||
title: 'Threads',
|
||||
icon: 'thread',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'team-channels',
|
||||
groups: ['team'],
|
||||
anonymous: true,
|
||||
full: true,
|
||||
title: 'Team_Channels',
|
||||
icon: 'hash',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'discussions',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Discussions',
|
||||
icon: 'discussion',
|
||||
full: true,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 'start-call',
|
||||
title: 'Call',
|
||||
icon: 'phone',
|
||||
groups: ['direct', 'direct_multiple', 'group', 'team', 'channel', 'direct'],
|
||||
disabled: false,
|
||||
full: true,
|
||||
order: 4,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'rocket-search',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
|
||||
title: 'Search_Messages',
|
||||
icon: 'magnifier',
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
id: 'mentions',
|
||||
groups: ['channel', 'group', 'team'],
|
||||
title: 'Mentions',
|
||||
icon: 'at',
|
||||
order: 6,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'members-list',
|
||||
groups: ['channel', 'group', 'team'],
|
||||
title: 'Teams_members',
|
||||
icon: 'members',
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
id: 'uploaded-files-list',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
|
||||
title: 'Files',
|
||||
icon: 'clip',
|
||||
order: 8,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'pinned-messages',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Pinned_Messages',
|
||||
icon: 'pin',
|
||||
order: 9,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'starred-messages',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Starred_Messages',
|
||||
icon: 'star',
|
||||
order: 10,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'keyboard-shortcut-list',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Keyboard_Shortcuts_Title',
|
||||
icon: 'keyboard',
|
||||
order: 99,
|
||||
type: 'customization',
|
||||
},
|
||||
{
|
||||
id: 'clean-history',
|
||||
groups: ['channel', 'group', 'team', 'direct_multiple', 'direct'],
|
||||
full: true,
|
||||
title: 'Prune_Messages',
|
||||
icon: 'eraser',
|
||||
order: 250,
|
||||
type: 'customization',
|
||||
},
|
||||
];
|
||||
|
||||
// real toolbox actions open a contextual bar or run an action; keep fixtures faithful to that
|
||||
const actions: RoomToolboxActionConfig[] = baseActions.map((action) => ({ tabComponent: MockTabComponent, ...action }));
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { useFeaturePreview } from '@rocket.chat/ui-client';
|
||||
import { useLayout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { RoomToolboxActionConfig, RoomToolboxContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
import { processRoomActions } from './processRoomActions';
|
||||
import type { RoomToolboxBaseAction } from './processRoomActions';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
type MenuSection = {
|
||||
id: string;
|
||||
title?: string;
|
||||
items: GenericMenuItemProps[];
|
||||
};
|
||||
|
||||
const canRenderAsMenuItem = (item: RoomToolboxActionConfig): boolean => Boolean(item.action ?? item.tabComponent);
|
||||
|
||||
const isSelfRendering = (item: RoomToolboxActionConfig): boolean => Boolean(item.renderToolboxItem) && !canRenderAsMenuItem(item);
|
||||
|
||||
const actionToMenuItem = (
|
||||
item: RoomToolboxBaseAction,
|
||||
openTab: RoomToolboxContextValue['openTab'],
|
||||
t: (key: string) => string,
|
||||
): GenericMenuItemProps => ({
|
||||
...(item as Record<string, unknown>),
|
||||
id: item.id,
|
||||
content: t(item.title as string),
|
||||
onClick:
|
||||
(item.action as (() => void) | undefined) ??
|
||||
((): void => {
|
||||
openTab(item.id);
|
||||
}),
|
||||
});
|
||||
|
||||
export const useRoomToolboxActions = ({ actions, openTab }: Pick<RoomToolboxContextValue, 'actions' | 'openTab'>) => {
|
||||
const { t } = useTranslation();
|
||||
const { roomToolboxExpanded } = useLayout();
|
||||
const room = useRoom();
|
||||
const isLayoutPreviewEnabled = useFeaturePreview('roomToolboxLayout');
|
||||
|
||||
const layoutSettingJSON = useSetting('Room_Toolbox_Layout', '');
|
||||
|
||||
const layoutConfig = useMemo(() => {
|
||||
if (!isLayoutPreviewEnabled) {
|
||||
return null;
|
||||
}
|
||||
return resolveLayoutForRoomType(layoutSettingJSON, room.t);
|
||||
}, [isLayoutPreviewEnabled, layoutSettingJSON, room.t]);
|
||||
|
||||
if (isLayoutPreviewEnabled && layoutConfig) {
|
||||
const { featuredActions, visibleActions: engineVisible, hiddenActions: engineSections } = processRoomActions(actions, layoutConfig);
|
||||
|
||||
const typedFeatured = featuredActions as RoomToolboxActionConfig[];
|
||||
const typedVisible = engineVisible as RoomToolboxActionConfig[];
|
||||
const overflowActions = engineSections.flatMap((section) => section.items as RoomToolboxActionConfig[]);
|
||||
|
||||
if (!roomToolboxExpanded) {
|
||||
const pinnedSelfRendering = [...typedVisible, ...overflowActions].filter(isSelfRendering);
|
||||
|
||||
const orderedOverflowActions = [...typedVisible, ...overflowActions].filter((item) => !item.disabled && canRenderAsMenuItem(item));
|
||||
|
||||
const sectionsMap = new Map<string, MenuSection>();
|
||||
for (const item of orderedOverflowActions) {
|
||||
const group = item.type ?? '';
|
||||
const menuItem = actionToMenuItem(item, openTab, t);
|
||||
const existing = sectionsMap.get(group);
|
||||
if (existing) {
|
||||
existing.items.push(menuItem);
|
||||
} else {
|
||||
sectionsMap.set(group, {
|
||||
id: group,
|
||||
title: group === 'apps' ? t('Apps') : '',
|
||||
items: [menuItem],
|
||||
});
|
||||
}
|
||||
}
|
||||
const hiddenActions = Array.from(sectionsMap.values());
|
||||
|
||||
return { featuredActions: [...typedFeatured, ...pinnedSelfRendering], visibleActions: [], hiddenActions };
|
||||
}
|
||||
|
||||
const hiddenActions = engineSections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.id === 'apps' ? t('Apps') : '',
|
||||
items: (section.items as RoomToolboxActionConfig[])
|
||||
.filter((item) => !item.disabled && canRenderAsMenuItem(item))
|
||||
.map((item) => actionToMenuItem(item, openTab, t)),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
|
||||
const rescuedFromOverflow = overflowActions.filter(isSelfRendering);
|
||||
|
||||
return { featuredActions: typedFeatured, visibleActions: [...typedVisible, ...rescuedFromOverflow], hiddenActions };
|
||||
}
|
||||
|
||||
const normalActions = actions.filter((action) => !action.featured && action.type !== 'apps');
|
||||
const featuredActions = actions.filter((action) => action.featured);
|
||||
const appsActions = actions.filter((action) => action.type === 'apps');
|
||||
const visibleActions = !roomToolboxExpanded ? [] : normalActions.slice(0, 6);
|
||||
|
||||
const hiddenActions = (!roomToolboxExpanded ? actions : [...appsActions, ...normalActions.slice(6)])
|
||||
.filter((item) => !item.disabled && !item.featured)
|
||||
.map((item) => ({
|
||||
content: t(item.title),
|
||||
onClick:
|
||||
item.action ??
|
||||
((): void => {
|
||||
openTab(item.id);
|
||||
}),
|
||||
...item,
|
||||
}))
|
||||
.reduce((acc, item) => {
|
||||
const group = item.type ? item.type : '';
|
||||
const section = acc.find((section: { id: string }) => section.id === group);
|
||||
if (section) {
|
||||
section.items.push(item);
|
||||
return acc;
|
||||
}
|
||||
|
||||
const newSection = { id: group, title: group === 'apps' ? t('Apps') : '', items: [item] };
|
||||
acc.push(newSection);
|
||||
|
||||
return acc;
|
||||
}, [] as MenuSection[]);
|
||||
|
||||
return { hiddenActions, featuredActions, visibleActions };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export default lazy(() => import('./RoomToolbox'));
|
||||
153
apps/meteor/client/views/room/Header/RoomTopic.spec.tsx
Normal file
153
apps/meteor/client/views/room/Header/RoomTopic.spec.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import RoomTopic from './RoomTopic';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../tests/mocks/data';
|
||||
|
||||
const user = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const user2 = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
statusText: 'Sample Status',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const user3 = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
describe('RoomTopic', () => {
|
||||
it('should render Add Topic when no topic is present and user can edit room', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'c', u: { _id: user._id, username: user.username, name: user.name } });
|
||||
const subscription = createFakeSubscription({ t: 'c', rid: room._id, u: room.u, roles: ['owner'] });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withPermission('edit-room')
|
||||
.withUser(user)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add_topic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render Add Topic when no topic is present and user cannot edit room', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'c' });
|
||||
const subscription = createFakeSubscription({ t: 'c', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add_topic')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render Add Topic when no statusText is present, user can edit room and room is a direct message room', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'd', uids: [user._id, user3._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withUsers([user3])
|
||||
.withPermission('edit-room')
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add_topic')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render topic when topic is present for rooms', () => {
|
||||
const room = createFakeRoom({ topic: 'Sample Topic', t: 'c' });
|
||||
const subscription = createFakeSubscription({ t: 'c', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sample Topic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the DM partner statusText (handled by RoomMemberStatus)', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'd', uids: [user._id, user2._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withPermission('edit-room')
|
||||
.withUser(user)
|
||||
.withUsers([user2])
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add_topic')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Sample Status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render Add Topic for livechat rooms', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'l' });
|
||||
const subscription = createFakeSubscription({ t: 'l', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withPermission('edit-room')
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add_topic')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
39
apps/meteor/client/views/room/Header/RoomTopic.tsx
Normal file
39
apps/meteor/client/views/room/Header/RoomTopic.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isPrivateRoom, isPublicRoom, isTeamRoom } from '@rocket.chat/core-typings';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useTranslation, useRouter } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import MarkdownText from '../../../components/MarkdownText';
|
||||
import { useCanEditRoom } from '../contextualBar/Info/hooks/useCanEditRoom';
|
||||
|
||||
export type RoomTopicProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const RoomTopic = ({ room }: RoomTopicProps) => {
|
||||
const t = useTranslation();
|
||||
const canEdit = useCanEditRoom(room);
|
||||
const router = useRouter();
|
||||
|
||||
const currentRoute = router.getLocationPathname();
|
||||
const href = isTeamRoom(room) ? `${currentRoute}/team-info` : `${currentRoute}/channel-settings`;
|
||||
|
||||
const { topic } = room;
|
||||
const canEditTopic = canEdit && (isPublicRoom(room) || isPrivateRoom(room));
|
||||
|
||||
if (!topic && !canEditTopic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!topic && canEditTopic) {
|
||||
return (
|
||||
<Box is='a' href={href}>
|
||||
{t('Add_topic')}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <MarkdownText color='default' parseEmoji={true} variant='inlineWithoutBreaks' withTruncatedText content={topic} />;
|
||||
};
|
||||
|
||||
export default RoomTopic;
|
||||
@@ -0,0 +1,64 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RoomInviteHeader renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-room-header rcx-css-12iwr9i"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-e8r6o4"
|
||||
>
|
||||
<div>
|
||||
ParentRoom
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1axz7ym"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1c6vtok"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-sdt442"
|
||||
title="Favorite rocket.cat"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-star rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
<div
|
||||
aria-label="rocket.cat"
|
||||
class="rcx-box rcx-box--full rcx-css-1lb6izd rcx-css-f2vsf1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1vxtvqh"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-globe rcx-icon rcx-css-rm4moq"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<h1
|
||||
class="rcx-box rcx-box--full rcx-css-13pxsnj"
|
||||
>
|
||||
rocket.cat
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr
|
||||
class="rcx-box rcx-box--full rcx-divider rcx-css-emj6cu"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
15
apps/meteor/client/views/room/Header/icons/Encrypted.tsx
Normal file
15
apps/meteor/client/views/room/Header/icons/Encrypted.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { HeaderState } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type EncryptedProps = { room: IRoom };
|
||||
|
||||
const Encrypted = ({ room }: EncryptedProps) => {
|
||||
const { t } = useTranslation();
|
||||
const e2eEnabled = useSetting('E2E_Enable');
|
||||
return e2eEnabled && room?.encrypted ? <HeaderState title={t('Encrypted')} icon='key' color='status-font-on-success' /> : null;
|
||||
};
|
||||
|
||||
export default memo(Encrypted);
|
||||
43
apps/meteor/client/views/room/Header/icons/Favorite.tsx
Normal file
43
apps/meteor/client/views/room/Header/icons/Favorite.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { IconButton } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useToggleFavoriteMutation } from '../../hooks/useToggleFavoriteMutation';
|
||||
|
||||
export type FavoriteProps = { room: IRoom & { f?: ISubscription['f'] } };
|
||||
|
||||
const Favorite = ({ room: { _id, f: favorite = false, t: type, name } }: FavoriteProps) => {
|
||||
const t = useTranslation();
|
||||
|
||||
const isFavoritesEnabled = useSetting('Favorite_Rooms', true) && ['c', 'p', 'd', 't'].includes(type);
|
||||
const { mutate: toggleFavorite } = useToggleFavoriteMutation();
|
||||
|
||||
const handleFavoriteClick = useStableCallback(() => {
|
||||
if (!isFavoritesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleFavorite({ roomId: _id, favorite: !favorite, roomName: name || '' });
|
||||
});
|
||||
|
||||
const favoriteLabel = favorite ? `${t('Unfavorite')} ${name}` : `${t('Favorite')} ${name}`;
|
||||
|
||||
if (!isFavoritesEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
small
|
||||
marginInlineEnd={4}
|
||||
icon={favorite ? 'star-filled' : 'star'}
|
||||
title={favoriteLabel}
|
||||
color={favorite ? 'status-font-on-warning' : undefined}
|
||||
onClick={handleFavoriteClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Favorite);
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import RoomGroupingMenu from './RoomGroupingMenu';
|
||||
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
|
||||
import { useCategoryModals } from '../../../../sidebar/categories/hooks/useCategoryModals';
|
||||
import { useMoveRoomCategory } from '../../../../sidebar/categories/hooks/useMoveRoomCategory';
|
||||
import { useUserIsSubscribed } from '../../contexts/RoomContext';
|
||||
import { useToggleFavoriteMutation } from '../../hooks/useToggleFavoriteMutation';
|
||||
|
||||
jest.mock('../../../../hooks/useHasLicenseModule', () => ({ useHasLicenseModule: jest.fn() }));
|
||||
jest.mock('../../../../sidebar/categories/hooks/useMoveRoomCategory', () => ({ useMoveRoomCategory: jest.fn() }));
|
||||
jest.mock('../../../../sidebar/categories/hooks/useCategoryModals', () => ({ useCategoryModals: jest.fn() }));
|
||||
jest.mock('../../contexts/RoomContext', () => ({ useUserIsSubscribed: jest.fn() }));
|
||||
jest.mock('../../hooks/useToggleFavoriteMutation', () => ({ useToggleFavoriteMutation: jest.fn() }));
|
||||
|
||||
const mockedUseCategoryModals = jest.mocked(useCategoryModals);
|
||||
const mockedUseMoveRoomCategory = jest.mocked(useMoveRoomCategory);
|
||||
const mockedUseHasLicenseModule = jest.mocked(useHasLicenseModule);
|
||||
const mockedUseUserIsSubscribed = jest.mocked(useUserIsSubscribed);
|
||||
const mockedUseToggleFavoriteMutation = jest.mocked(useToggleFavoriteMutation);
|
||||
|
||||
const ROOM_ID = 'test-room-id';
|
||||
|
||||
const catDesign = { _id: 'cat-design', name: 'Design', showUnreads: false };
|
||||
const catOther = { _id: 'cat-other', name: 'Engineering', showUnreads: false };
|
||||
|
||||
const makeRoom = (overrides: Partial<IRoom & { f?: boolean; category?: string }> = {}): IRoom & { f?: boolean; category?: string } =>
|
||||
({ _id: ROOM_ID, name: 'General', t: 'c', ...overrides }) as any;
|
||||
|
||||
const wrapper = mockAppRoot().withSetting('Favorite_Rooms', true).withUserPreference('sidebarCategories', [catDesign, catOther]).build();
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseUserIsSubscribed.mockReturnValue(true);
|
||||
mockedUseToggleFavoriteMutation.mockReturnValue({ mutate: jest.fn() } as any);
|
||||
mockedUseCategoryModals.mockReturnValue({ openCreate: jest.fn(), openManage: jest.fn(), openDelete: jest.fn() });
|
||||
mockedUseMoveRoomCategory.mockReturnValue({ mutate: jest.fn(), mutateAsync: jest.fn() } as any);
|
||||
mockedUseHasLicenseModule.mockReturnValue({ data: true } as any);
|
||||
});
|
||||
|
||||
it('shows Favorites in the grouping menu when the room is favorited', async () => {
|
||||
render(<RoomGroupingMenu room={makeRoom({ f: true })} />, { wrapper });
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: 'Favorites' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the matching category in the grouping menu when the room has a category subscription field', async () => {
|
||||
render(<RoomGroupingMenu room={makeRoom({ f: false, category: 'cat-design' })} />, { wrapper });
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: 'Design' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isOmnichannelRoom, type IRoom, type ISubscription } from '@rocket.chat/core-typings';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Favorite from './Favorite';
|
||||
import RoomHeaderCategoryMenu from './RoomHeaderCategoryMenu';
|
||||
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
|
||||
import { useUserIsSubscribed } from '../../contexts/RoomContext';
|
||||
|
||||
const RoomGroupingMenu = ({ room }: { room: IRoom & { f?: ISubscription['f']; category?: ISubscription['category'] } }) => {
|
||||
const subscribed = useUserIsSubscribed();
|
||||
const { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
|
||||
|
||||
if (!subscribed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasLicenseModule && !isOmnichannelRoom(room)) {
|
||||
return <RoomHeaderCategoryMenu room={room} />;
|
||||
}
|
||||
|
||||
return <Favorite room={room} />;
|
||||
};
|
||||
|
||||
export default memo(RoomGroupingMenu);
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { IconButton } from '@rocket.chat/fuselage';
|
||||
import { GenericMenu } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCategoryMenuItems } from '../../../../sidebar/categories/hooks/useCategoryMenuItems';
|
||||
|
||||
const getGroupingIcon = (favorite: boolean, category: boolean, isFavoritesEnabled: boolean): 'star-filled' | 'folder' | 'star' => {
|
||||
if (favorite) {
|
||||
return 'star-filled';
|
||||
}
|
||||
|
||||
if (category) {
|
||||
return 'folder';
|
||||
}
|
||||
|
||||
return isFavoritesEnabled ? 'star' : 'folder';
|
||||
};
|
||||
|
||||
type RoomHeaderCategoryMenuProps = {
|
||||
room: IRoom & { f?: ISubscription['f']; category?: ISubscription['category'] };
|
||||
};
|
||||
|
||||
const RoomHeaderCategoryMenu = ({ room }: RoomHeaderCategoryMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isFavoritesEnabled = useSetting('Favorite_Rooms', true);
|
||||
const favorite = Boolean(room.f);
|
||||
const category = Boolean(room.category);
|
||||
const groupingIcon = getGroupingIcon(favorite, category, isFavoritesEnabled);
|
||||
|
||||
const { targetItems, utilItems } = useCategoryMenuItems({
|
||||
rid: room._id,
|
||||
name: room.name,
|
||||
isFavorite: favorite,
|
||||
categoryId: room.category,
|
||||
});
|
||||
|
||||
const sections = [{ items: targetItems }, ...(utilItems.length ? [{ items: utilItems }] : [])];
|
||||
|
||||
return (
|
||||
<GenericMenu
|
||||
button={<IconButton small color={favorite ? 'status-font-on-warning' : undefined} icon={groupingIcon} marginInlineEnd={4} />}
|
||||
title={t('Move_to')}
|
||||
icon={groupingIcon}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomHeaderCategoryMenu;
|
||||
20
apps/meteor/client/views/room/Header/icons/Translate.tsx
Normal file
20
apps/meteor/client/views/room/Header/icons/Translate.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { HeaderState } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type TranslateProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const Translate = ({ room: { autoTranslateLanguage, autoTranslate } }: TranslateProps) => {
|
||||
const { t } = useTranslation();
|
||||
const autoTranslateEnabled = useSetting('AutoTranslate_Enabled');
|
||||
const encryptedLabel = t('Translated');
|
||||
return autoTranslateEnabled && autoTranslate && autoTranslateLanguage ? (
|
||||
<HeaderState title={encryptedLabel} icon='language' color='info' />
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default memo(Translate);
|
||||
1
apps/meteor/client/views/room/Header/index.ts
Normal file
1
apps/meteor/client/views/room/Header/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Header';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useImagesList } from './hooks/useImagesList';
|
||||
import { ImageGallery, ImageGalleryError, ImageGalleryLoading } from '../../../components/ImageGallery';
|
||||
import { ImageGalleryContext } from '../../../contexts/ImageGalleryContext';
|
||||
import { useRoom } from '../contexts/RoomContext';
|
||||
|
||||
const ImageGalleryData = () => {
|
||||
const { _id: rid } = useRoom();
|
||||
|
||||
const { imageId, onClose } = useContext(ImageGalleryContext);
|
||||
|
||||
const { isPending, isError, data: images, fetchNextPage } = useImagesList({ roomId: rid, startingFromId: imageId });
|
||||
|
||||
if (isPending) {
|
||||
return <ImageGalleryLoading onClose={onClose} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ImageGalleryError onClose={onClose} />;
|
||||
}
|
||||
|
||||
return <ImageGallery images={images} loadMore={fetchNextPage} onClose={onClose} />;
|
||||
};
|
||||
|
||||
export default ImageGalleryData;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Base64 } from '@rocket.chat/base64';
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { e2e } from '../../../../lib/e2ee/rocketchat.e2e';
|
||||
import { roomsQueryKeys } from '../../../../lib/queryKeys';
|
||||
|
||||
export const useImagesList = ({ roomId, startingFromId }: { roomId: IRoom['_id']; startingFromId?: string }) => {
|
||||
const getFiles = useEndpoint('GET', '/v1/rooms.images');
|
||||
|
||||
const count = 5;
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: roomsQueryKeys.images(roomId, { startingFromId }),
|
||||
queryFn: async ({ pageParam: offset }) => {
|
||||
const { files, total } = await getFiles({
|
||||
roomId,
|
||||
startingFromId,
|
||||
offset,
|
||||
count,
|
||||
});
|
||||
|
||||
const items = files.map((file) => ({
|
||||
...file,
|
||||
uploadedAt: file.uploadedAt ? new Date(file.uploadedAt) : undefined,
|
||||
modifiedAt: file.modifiedAt ? new Date(file.modifiedAt) : undefined,
|
||||
expiresAt: file.expiresAt ? new Date(file.expiresAt) : undefined,
|
||||
_updatedAt: new Date(file._updatedAt),
|
||||
}));
|
||||
|
||||
for await (const file of items) {
|
||||
if (file.rid && file.content) {
|
||||
const e2eRoom = await e2e.getInstanceByRoomId(file.rid);
|
||||
if (e2eRoom?.shouldConvertReceivedMessages()) {
|
||||
const decrypted = await e2e.decryptFileContent(file);
|
||||
const key = Base64.encode(
|
||||
JSON.stringify({
|
||||
...decrypted.encryption,
|
||||
name: String.fromCharCode(...new TextEncoder().encode(decrypted.name)),
|
||||
type: decrypted.type,
|
||||
}),
|
||||
);
|
||||
decrypted.path = `/file-decrypt${decrypted.path}?key=${encodeURIComponent(key)}`;
|
||||
Object.assign(file, decrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
itemCount: total,
|
||||
};
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, _, lastOffset) => {
|
||||
const nextOffset = lastOffset + count;
|
||||
if (nextOffset >= lastPage.itemCount) return undefined;
|
||||
return nextOffset;
|
||||
},
|
||||
// Remove duplicates while preserving order
|
||||
select: ({ pages }) => Array.from(new Map(pages.flatMap((page) => page.items.map((item) => [item._id, item]))).values()),
|
||||
});
|
||||
};
|
||||
35
apps/meteor/client/views/room/MemberListRouter.tsx
Normal file
35
apps/meteor/client/views/room/MemberListRouter.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { useUserId, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { useRoom } from './contexts/RoomContext';
|
||||
import RoomMembers from './contextualBar/RoomMembers';
|
||||
import UserInfo from './contextualBar/UserInfo';
|
||||
|
||||
const getUid = (room: IRoom, ownUserId: IUser['_id'] | undefined) => {
|
||||
if (room.uids?.length === 1) {
|
||||
return room.uids[0];
|
||||
}
|
||||
|
||||
const uid = room.uids?.filter((uid) => uid !== ownUserId).shift();
|
||||
|
||||
// Self DMs used to be created with the userId duplicated.
|
||||
// Sometimes rooms can have 2 equal uids, but it's a self DM.
|
||||
return uid || room.uids?.[0];
|
||||
};
|
||||
|
||||
const MemberListRouter = () => {
|
||||
const { tab, context: username } = useRoomToolbox();
|
||||
const room = useRoom();
|
||||
const { closeTab } = useRoomToolbox();
|
||||
const ownUserId = useUserId();
|
||||
|
||||
const isMembersList = tab?.id === 'members-list' || tab?.id === 'user-info-group';
|
||||
|
||||
if (isMembersList && !username) {
|
||||
return <RoomMembers rid={room._id} />;
|
||||
}
|
||||
|
||||
return <UserInfo {...(username ? { username } : { uid: getUid(room, ownUserId) })} onClose={closeTab} rid={room._id} />;
|
||||
};
|
||||
|
||||
export default MemberListRouter;
|
||||
266
apps/meteor/client/views/room/MessageList/MessageList.spec.tsx
Normal file
266
apps/meteor/client/views/room/MessageList/MessageList.spec.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { MessageListProps } from './MessageList';
|
||||
import { MessageList } from './MessageList';
|
||||
import { useMessages } from './hooks/useMessages';
|
||||
import { RoomManager } from '../../../lib/RoomManager';
|
||||
import { useFirstUnreadMessageId } from '../hooks/useFirstUnreadMessageId';
|
||||
|
||||
const mockVirtualizerHandle = {
|
||||
scrollToIndex: jest.fn(),
|
||||
scrollTo: jest.fn(),
|
||||
findItemIndex: jest.fn((offset: number) => offset),
|
||||
scrollOffset: 0,
|
||||
scrollSize: 1000,
|
||||
viewportSize: 300,
|
||||
};
|
||||
|
||||
jest.mock('virtua', () => {
|
||||
const { Children, forwardRef, useImperativeHandle } = jest.requireActual<typeof import('react')>('react');
|
||||
|
||||
return {
|
||||
// `virtua` renders a plain container and wraps every child in a `div` of its own, so the list markup
|
||||
// under test cannot rely on `ul`/`li` semantics.
|
||||
VList: forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
onScroll,
|
||||
shift: _shift,
|
||||
keepMounted: _keepMounted,
|
||||
...props
|
||||
}: { children: ReactNode; onScroll?: (offset: number) => void; shift?: boolean; keepMounted?: number[] },
|
||||
ref: any,
|
||||
) => {
|
||||
useImperativeHandle(ref, () => mockVirtualizerHandle);
|
||||
return (
|
||||
<div data-testid='message-list' onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)} {...props}>
|
||||
{Children.map(children, (child) => (child ? <div>{child}</div> : child))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@rocket.chat/fuselage-hooks', () => ({
|
||||
useDebouncedCallback: (callback: (...args: any[]) => void) => callback,
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/RoomManager', () => ({
|
||||
RoomManager: {
|
||||
getStore: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./hooks/useMessages', () => ({
|
||||
useMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks/useTryToJumpToMessage', () => jest.fn());
|
||||
|
||||
jest.mock('../hooks/useFirstUnreadMessageId', () => ({
|
||||
useFirstUnreadMessageId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../contexts/RoomContext', () => ({
|
||||
useRoomSubscription: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../contexts/ChatContext', () => ({
|
||||
useChat: jest.fn(() => ({
|
||||
readStateManager: {
|
||||
setIsUnreadMarkVisibleCallback: jest.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./MessageListItem', () => ({
|
||||
MessageListItem: ({ message }: { message: IMessage }) => (
|
||||
<div role='listitem' data-testid='message-list-item'>
|
||||
{message.msg}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('./providers/MessageListProvider', () => ({ children }: { children: ReactNode }) => <>{children}</>);
|
||||
|
||||
jest.mock('../providers/SelectedMessagesProvider', () => ({
|
||||
SelectedMessagesProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('../body/LoadingMessagesIndicator', () => () => <span>Loading</span>);
|
||||
|
||||
jest.mock('../body/RetentionPolicyWarning', () => () => <span>Retention policy warning</span>);
|
||||
|
||||
jest.mock('../body/RoomForeword/RoomForeword', () => () => <span>Room foreword</span>);
|
||||
|
||||
const createMessage = (_id: string, ts = new Date()): IMessage =>
|
||||
({
|
||||
_id,
|
||||
rid: 'room-id',
|
||||
msg: `message ${_id}`,
|
||||
ts,
|
||||
_updatedAt: ts,
|
||||
u: {
|
||||
_id: 'user-id',
|
||||
username: 'user',
|
||||
},
|
||||
}) as IMessage;
|
||||
|
||||
const defaultProps = {
|
||||
rid: 'room-id' as IRoom['_id'],
|
||||
canPreview: true,
|
||||
hasMorePreviousMessages: false,
|
||||
isLoadingMoreMessages: false,
|
||||
user: { _id: 'user-id', username: 'user' } as IUser,
|
||||
room: { _id: 'room-id', t: 'c' } as IRoom,
|
||||
retentionPolicy: undefined,
|
||||
hasMoreNextMessages: false,
|
||||
shouldJumpToBottom: false,
|
||||
setShouldJumpToBottom: jest.fn(),
|
||||
isAtBottom: { current: false },
|
||||
isJumpingToMessage: false,
|
||||
setIsJumpingToMessage: jest.fn(),
|
||||
setUnreadCount: jest.fn(),
|
||||
setLastMessageDate: jest.fn(),
|
||||
debouncedClearNewMessagesOnScroll: jest.fn(),
|
||||
handleDateScroll: jest.fn(),
|
||||
debouncedMessageRead: jest.fn(),
|
||||
setKeepAtBottom: jest.fn(),
|
||||
};
|
||||
|
||||
describe('MessageList scroll position', () => {
|
||||
let root: ReturnType<typeof mockAppRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockVirtualizerHandle.scrollToIndex.mockClear();
|
||||
mockVirtualizerHandle.scrollTo.mockClear();
|
||||
mockVirtualizerHandle.findItemIndex.mockImplementation((offset: number) => offset);
|
||||
mockVirtualizerHandle.scrollOffset = 0;
|
||||
mockVirtualizerHandle.scrollSize = 1000;
|
||||
mockVirtualizerHandle.viewportSize = 300;
|
||||
(useMessages as jest.Mock).mockReturnValue([createMessage('message-1'), createMessage('message-2')]);
|
||||
(useFirstUnreadMessageId as jest.Mock).mockReturnValue(undefined);
|
||||
root = mockAppRoot().withSetting('Message_GroupingPeriod', 300).withUserPreference('displayAvatars', true);
|
||||
});
|
||||
|
||||
it('should restore room scroll position based on store', () => {
|
||||
const store = {
|
||||
scroll: 123,
|
||||
atBottom: false,
|
||||
update: jest.fn(),
|
||||
};
|
||||
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
|
||||
|
||||
render(<MessageList {...defaultProps} />, { wrapper: root.build() });
|
||||
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
expect(mockVirtualizerHandle.scrollTo).toHaveBeenCalledWith(123);
|
||||
expect(mockVirtualizerHandle.scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should jump to bottom if atBottom is true', () => {
|
||||
const store = {
|
||||
scroll: 123,
|
||||
atBottom: true,
|
||||
update: jest.fn(),
|
||||
};
|
||||
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
|
||||
|
||||
render(<MessageList {...defaultProps} shouldJumpToBottom={true} />, { wrapper: root.build() });
|
||||
|
||||
expect(mockVirtualizerHandle.scrollToIndex).toHaveBeenCalledWith(2, { align: 'center' });
|
||||
});
|
||||
|
||||
it('should jump to bottom if unreads are present', () => {
|
||||
const store = {
|
||||
scroll: 123,
|
||||
atBottom: false,
|
||||
update: jest.fn(),
|
||||
};
|
||||
(useFirstUnreadMessageId as jest.Mock).mockReturnValue('message-1');
|
||||
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
|
||||
|
||||
render(<MessageList {...defaultProps} shouldJumpToBottom={true} />, { wrapper: root.build() });
|
||||
|
||||
expect(defaultProps.setShouldJumpToBottom).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should do nothing if no previous scroll position is stored', () => {
|
||||
const store = {
|
||||
scroll: undefined,
|
||||
atBottom: false,
|
||||
update: jest.fn(),
|
||||
};
|
||||
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
|
||||
|
||||
render(<MessageList {...defaultProps} />, { wrapper: root.build() });
|
||||
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
expect(mockVirtualizerHandle.scrollToIndex).not.toHaveBeenCalled();
|
||||
expect(mockVirtualizerHandle.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update store based on scroll position', async () => {
|
||||
const store = {
|
||||
scroll: 1,
|
||||
atBottom: false,
|
||||
update: jest.fn(),
|
||||
};
|
||||
(RoomManager.getStore as jest.Mock).mockReturnValue(store);
|
||||
mockVirtualizerHandle.scrollOffset = 50;
|
||||
|
||||
render(<MessageList {...defaultProps} />, { wrapper: root.build() });
|
||||
|
||||
fireEvent.scroll(screen.getByTestId('message-list'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.update).toHaveBeenCalledWith({ scroll: 50, atBottom: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageList accessibility', () => {
|
||||
let root: ReturnType<typeof mockAppRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useMessages as jest.Mock).mockReturnValue([createMessage('message-1'), createMessage('message-2')]);
|
||||
(useFirstUnreadMessageId as jest.Mock).mockReturnValue(undefined);
|
||||
(RoomManager.getStore as jest.Mock).mockReturnValue({ scroll: undefined, atBottom: false, update: jest.fn() });
|
||||
root = mockAppRoot().withSetting('Message_GroupingPeriod', 300).withUserPreference('displayAvatars', true);
|
||||
});
|
||||
|
||||
it('should render a labelled list exposing every message as a list item', () => {
|
||||
render(<MessageList {...defaultProps} />, { wrapper: root.build() });
|
||||
|
||||
const list = screen.getByRole('list');
|
||||
|
||||
expect(list).toHaveAccessibleName();
|
||||
// the two messages plus the foreword
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(3);
|
||||
});
|
||||
|
||||
const states: [string, Partial<MessageListProps>][] = [
|
||||
['default', {}],
|
||||
['loading previous messages', { hasMorePreviousMessages: true, isLoadingMoreMessages: true }],
|
||||
['loading next messages', { hasMoreNextMessages: true, isLoadingMoreMessages: true }],
|
||||
[
|
||||
'showing the retention policy warning',
|
||||
{ retentionPolicy: { enabled: true, isActive: true, filesOnly: false, excludePinned: false, ignoreThreads: false, maxAge: 30 } },
|
||||
],
|
||||
['without preview permission', { canPreview: false }],
|
||||
];
|
||||
|
||||
it.each(states)('should have no accessibility violations when %s', async (_state, props) => {
|
||||
const { container } = render(<MessageList {...defaultProps} {...props} />, { wrapper: root.build() });
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
330
apps/meteor/client/views/room/MessageList/MessageList.tsx
Normal file
330
apps/meteor/client/views/room/MessageList/MessageList.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { isThreadMessage } from '@rocket.chat/core-typings';
|
||||
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
import { useSearchParameter, useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { VirtualizerHandle } from 'virtua';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { MessageListItem } from './MessageListItem';
|
||||
import { useRoomSubscription } from '../contexts/RoomContext';
|
||||
import { useFirstUnreadMessageId } from '../hooks/useFirstUnreadMessageId';
|
||||
import { SelectedMessagesProvider } from '../providers/SelectedMessagesProvider';
|
||||
import { useMessages } from './hooks/useMessages';
|
||||
import useTryToJumpToMessage from './hooks/useTryToJumpToMessage';
|
||||
import { isMessageSequential } from './lib/isMessageSequential';
|
||||
import MessageListProvider from './providers/MessageListProvider';
|
||||
import { RoomManager } from '../../../lib/RoomManager';
|
||||
import LoadingMessagesIndicator from '../body/LoadingMessagesIndicator';
|
||||
import RetentionPolicyWarning from '../body/RetentionPolicyWarning';
|
||||
import RoomForeword from '../body/RoomForeword/RoomForeword';
|
||||
import { useStoreScrollPosition } from '../body/hooks/useStoreScrollPosition';
|
||||
import { useChat } from '../contexts/ChatContext';
|
||||
import type { RetentionPolicy } from '../hooks/useRetentionPolicy';
|
||||
import { useKeepMountedMessages } from './hooks/useKeepMountedMessages';
|
||||
|
||||
export type MessageListProps = {
|
||||
rid: IRoom['_id'];
|
||||
canPreview: boolean;
|
||||
hasMorePreviousMessages: boolean;
|
||||
isLoadingMoreMessages: boolean;
|
||||
user: IUser | null;
|
||||
room: IRoom;
|
||||
retentionPolicy: RetentionPolicy | undefined;
|
||||
hasMoreNextMessages: boolean;
|
||||
shouldJumpToBottom: boolean;
|
||||
isAtBottom: MutableRefObject<boolean>;
|
||||
isJumpingToMessage: boolean;
|
||||
setIsJumpingToMessage: Dispatch<SetStateAction<boolean>>;
|
||||
setUnreadCount: Dispatch<SetStateAction<number>>;
|
||||
setLastMessageDate: Dispatch<SetStateAction<Date | undefined>>;
|
||||
debouncedClearNewMessagesOnScroll: () => void;
|
||||
handleDateScroll: (topMessage: IMessage | undefined, offset: number) => void;
|
||||
setShouldJumpToBottom: Dispatch<SetStateAction<boolean>>;
|
||||
debouncedMessageRead: () => void;
|
||||
setKeepAtBottom: (keepAtBottom: () => void) => void;
|
||||
};
|
||||
|
||||
export const MessageList = function MessageList({
|
||||
rid,
|
||||
canPreview,
|
||||
hasMorePreviousMessages,
|
||||
isLoadingMoreMessages,
|
||||
user,
|
||||
room,
|
||||
retentionPolicy,
|
||||
hasMoreNextMessages,
|
||||
shouldJumpToBottom,
|
||||
setShouldJumpToBottom,
|
||||
isAtBottom,
|
||||
isJumpingToMessage,
|
||||
setIsJumpingToMessage,
|
||||
setUnreadCount,
|
||||
setLastMessageDate,
|
||||
debouncedClearNewMessagesOnScroll,
|
||||
handleDateScroll,
|
||||
debouncedMessageRead,
|
||||
setKeepAtBottom,
|
||||
}: MessageListProps) {
|
||||
// Prepend ref needed for adjusting the message list shift
|
||||
// https://inokawa.github.io/virtua/?path=/story/advanced-chat--default
|
||||
const isPrepend = useRef<boolean>(false);
|
||||
useLayoutEffect(() => {
|
||||
isPrepend.current = false;
|
||||
// FIXME: isAtBottom should be better calculated, as it does no alwas represent the correct value
|
||||
if (hasMoreNextMessages) {
|
||||
isAtBottom.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
|
||||
const lastScrollSizeRef = useRef(0);
|
||||
|
||||
const messages = useMessages({ rid });
|
||||
|
||||
const messagesLength = canPreview ? messages.length + 1 : messages.length;
|
||||
|
||||
useEffect(() => {
|
||||
setKeepAtBottom(() => {
|
||||
if (virtualizerRef.current) {
|
||||
virtualizerRef.current.scrollToIndex(messagesLength, {
|
||||
align: 'end',
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [messagesLength, setKeepAtBottom]);
|
||||
|
||||
const keepMountedMessages = useKeepMountedMessages(messages, canPreview);
|
||||
|
||||
useTryToJumpToMessage({ rid, virtualizerRef, setIsJumpingToMessage, messages });
|
||||
|
||||
const handlePrepend = useCallback(
|
||||
(offset: number) => {
|
||||
if (!isRoomInitialized.current) {
|
||||
return;
|
||||
}
|
||||
// If the offset is less than 200, it means the user is reaching the top of the list,
|
||||
// so the prepend need to be enabled for smooth scrolling,
|
||||
// if the prepend is enabled when a new message is added, the list will misalign.
|
||||
if (offset < 200) {
|
||||
isPrepend.current = true;
|
||||
}
|
||||
|
||||
const scrollSize = virtualizerRef.current?.scrollSize ?? 0;
|
||||
const viewportSize = virtualizerRef.current?.viewportSize ?? 0;
|
||||
|
||||
if (hasMoreNextMessages) {
|
||||
isAtBottom.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollSize >= viewportSize) {
|
||||
isAtBottom.current = true;
|
||||
}
|
||||
|
||||
isAtBottom.current = offset - scrollSize + viewportSize >= -60;
|
||||
if (shouldJumpToBottom && isAtBottom.current) {
|
||||
setShouldJumpToBottom(false);
|
||||
}
|
||||
},
|
||||
[isAtBottom, setShouldJumpToBottom, shouldJumpToBottom, hasMoreNextMessages],
|
||||
);
|
||||
|
||||
const isRoomInitialized = useRef<boolean>(false);
|
||||
|
||||
const firstUnreadMessageId = useFirstUnreadMessageId();
|
||||
|
||||
const messageJumpParam = useSearchParameter('msg');
|
||||
|
||||
// Scroll to bottom
|
||||
useEffect(() => {
|
||||
if (isJumpingToMessage || messageJumpParam) {
|
||||
if (!isRoomInitialized.current) {
|
||||
// Jump to message will have to load messages, thus removing the need to initialize the room here
|
||||
isRoomInitialized.current = true;
|
||||
setShouldJumpToBottom(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRoomInitialized.current) {
|
||||
const store = RoomManager.getStore(rid);
|
||||
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!store?.atBottom && store?.scroll !== undefined) {
|
||||
// When unreads are present, always scroll to bottom
|
||||
if (firstUnreadMessageId) {
|
||||
isRoomInitialized.current = true;
|
||||
setShouldJumpToBottom(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setShouldJumpToBottom(false);
|
||||
|
||||
virtualizerRef.current?.scrollTo(store?.scroll);
|
||||
isAtBottom.current = false;
|
||||
isRoomInitialized.current = true;
|
||||
return;
|
||||
}
|
||||
setShouldJumpToBottom(true);
|
||||
isRoomInitialized.current = true;
|
||||
}
|
||||
|
||||
const handle = virtualizerRef.current;
|
||||
const lastItemIndex = messages.length - 1;
|
||||
if (shouldJumpToBottom === true) {
|
||||
// When new messages arrive, this effect is triggered, but the latest message is not on the index, so it scrolls to the previous index
|
||||
// TODO: Find if there is a better way to scroll to the latest message
|
||||
handle?.scrollToIndex(lastItemIndex + 1, {
|
||||
align: 'center',
|
||||
});
|
||||
}
|
||||
// If new messages arrive and is at bottom, scroll to keep at bottom.
|
||||
if (isAtBottom.current && lastScrollSizeRef.current !== handle?.scrollSize && !isLoadingMoreMessages) {
|
||||
lastScrollSizeRef.current = handle?.scrollSize ?? 0;
|
||||
handle?.scrollToIndex(lastItemIndex + 1, {
|
||||
align: 'end',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isAtBottom,
|
||||
messages,
|
||||
shouldJumpToBottom,
|
||||
isJumpingToMessage,
|
||||
isLoadingMoreMessages,
|
||||
messageJumpParam,
|
||||
rid,
|
||||
firstUnreadMessageId,
|
||||
setShouldJumpToBottom,
|
||||
]);
|
||||
|
||||
const storeScrollPosition = useStoreScrollPosition({ rid, isAtBottom, virtualizerRef });
|
||||
|
||||
const subscription = useRoomSubscription();
|
||||
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');
|
||||
const messageGroupingPeriod = useSetting('Message_GroupingPeriod', 300);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const unreadMarkIndex = useRef<number | null>(null);
|
||||
const chat = useChat();
|
||||
|
||||
const isUnreadMarkVisible = useCallback(() => {
|
||||
if (unreadMarkIndex.current === null) {
|
||||
return false;
|
||||
}
|
||||
const handle = virtualizerRef.current;
|
||||
if (!handle) {
|
||||
return false;
|
||||
}
|
||||
const listIndexOffset = canPreview ? 1 : 0;
|
||||
const markListIndex = listIndexOffset + unreadMarkIndex.current;
|
||||
const viewStartIndex = handle.findItemIndex(handle.scrollOffset);
|
||||
const viewEndIndex = handle.findItemIndex(handle.scrollOffset + handle.viewportSize);
|
||||
const minIndex = Math.min(viewStartIndex, viewEndIndex);
|
||||
const maxIndex = Math.max(viewStartIndex, viewEndIndex);
|
||||
const isVisible = markListIndex >= minIndex && markListIndex <= maxIndex;
|
||||
return isVisible;
|
||||
}, [canPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat) {
|
||||
chat.readStateManager.setIsUnreadMarkVisibleCallback(isUnreadMarkVisible);
|
||||
}
|
||||
}, [chat, isUnreadMarkVisible]);
|
||||
|
||||
const handleTopVisibleMessage = useDebouncedCallback(
|
||||
(topMessage: IMessage | undefined) => {
|
||||
if (!topMessage) {
|
||||
setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
setLastMessageDate(topMessage.ts);
|
||||
},
|
||||
300,
|
||||
[setUnreadCount, setLastMessageDate],
|
||||
);
|
||||
|
||||
unreadMarkIndex.current = null;
|
||||
|
||||
return (
|
||||
<MessageListProvider>
|
||||
<SelectedMessagesProvider>
|
||||
<VList
|
||||
ref={virtualizerRef}
|
||||
shift={isPrepend.current === true}
|
||||
style={{ height: '100%' }}
|
||||
aria-label={t('Message_list')}
|
||||
aria-busy={isLoadingMoreMessages}
|
||||
role='list'
|
||||
className='messages-list'
|
||||
keepMounted={keepMountedMessages}
|
||||
onScroll={(offset: number) => {
|
||||
handlePrepend(offset);
|
||||
storeScrollPosition();
|
||||
debouncedClearNewMessagesOnScroll();
|
||||
|
||||
const handle = virtualizerRef.current;
|
||||
const viewportTopPadding = 21; // TODO: we should derive this value from somewhere else.
|
||||
const topMessage = handle
|
||||
? messages[handle.findItemIndex(handle.scrollOffset - viewportTopPadding) - (canPreview ? 1 : 0)]
|
||||
: undefined;
|
||||
handleTopVisibleMessage(topMessage);
|
||||
handleDateScroll(topMessage, offset);
|
||||
debouncedMessageRead();
|
||||
}}
|
||||
>
|
||||
{canPreview ? (
|
||||
<>
|
||||
{hasMorePreviousMessages ? (
|
||||
<div className='load-more' role='presentation'>
|
||||
{isLoadingMoreMessages ? <LoadingMessagesIndicator /> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div role='listitem'>
|
||||
<RoomForeword user={user} room={room} />
|
||||
{retentionPolicy?.isActive ? <RetentionPolicyWarning room={room} /> : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{messages.map((message, index, { [index - 1]: previous }) => {
|
||||
const sequential = isMessageSequential(message, previous, messageGroupingPeriod);
|
||||
const showUnreadDivider = firstUnreadMessageId === message._id;
|
||||
const system = MessageTypes.isSystemMessage(message);
|
||||
const visible = !isThreadMessage(message) && !system;
|
||||
|
||||
if (showUnreadDivider) {
|
||||
unreadMarkIndex.current = index;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={message._id}>
|
||||
<MessageListItem
|
||||
message={message}
|
||||
previous={previous}
|
||||
showUnreadDivider={showUnreadDivider}
|
||||
showUserAvatar={showUserAvatar}
|
||||
sequential={sequential}
|
||||
visible={visible}
|
||||
subscription={subscription}
|
||||
system={system}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{hasMoreNextMessages ? (
|
||||
<div className='load-more' role='presentation'>
|
||||
{isLoadingMoreMessages ? <LoadingMessagesIndicator /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</VList>
|
||||
</SelectedMessagesProvider>
|
||||
</MessageListProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction, Icon } from '@rocket.chat/fuselage';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useRoom } from '../contexts/RoomContext';
|
||||
|
||||
export type MessageListErrorBoundaryProps = { children: ReactNode };
|
||||
|
||||
const MessageListErrorBoundary = ({ children }: MessageListErrorBoundaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const room = useRoom();
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
resetKeys={[room._id]}
|
||||
fallback={
|
||||
<States>
|
||||
<StatesIcon name='circle-exclamation' variation='danger' />
|
||||
<StatesTitle>{t('Error')}</StatesTitle>
|
||||
<StatesSubtitle>{t('Error_something_went_wrong')}</StatesSubtitle>
|
||||
<StatesActions>
|
||||
<StatesAction
|
||||
onClick={(): void => {
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
<Icon name='reload' /> {t('Reload')}
|
||||
</StatesAction>
|
||||
</StatesActions>
|
||||
</States>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageListErrorBoundary;
|
||||
100
apps/meteor/client/views/room/MessageList/MessageListItem.tsx
Normal file
100
apps/meteor/client/views/room/MessageList/MessageListItem.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { isThreadMessage, type IMessage, type ISubscription } from '@rocket.chat/core-typings';
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Bubble, MessageDivider } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isMessageNewDay } from './lib/isMessageNewDay';
|
||||
import { useMessageListFormatDate } from '../../../components/message/list/MessageListContext';
|
||||
import RoomMessage from '../../../components/message/variants/RoomMessage';
|
||||
import SystemMessage from '../../../components/message/variants/SystemMessage';
|
||||
import ThreadMessagePreview from '../../../components/message/variants/ThreadMessagePreview';
|
||||
import { useDateRef } from '../providers/DateListProvider';
|
||||
|
||||
export type MessageListItemProps = {
|
||||
message: IMessage;
|
||||
previous?: IMessage;
|
||||
showUnreadDivider: boolean;
|
||||
|
||||
sequential: boolean;
|
||||
showUserAvatar: boolean;
|
||||
visible: boolean;
|
||||
subscription: ISubscription | undefined;
|
||||
system: boolean;
|
||||
};
|
||||
export const MessageListItem = ({
|
||||
message,
|
||||
previous,
|
||||
showUnreadDivider,
|
||||
sequential,
|
||||
showUserAvatar,
|
||||
visible,
|
||||
subscription,
|
||||
system,
|
||||
}: MessageListItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const formatDate = useMessageListFormatDate();
|
||||
|
||||
const ref = useDateRef();
|
||||
|
||||
const newDay = isMessageNewDay(message, previous);
|
||||
const showDivider = newDay || showUnreadDivider;
|
||||
const unread = Boolean(subscription?.tunread?.includes(message._id));
|
||||
const mention = Boolean(subscription?.tunreadUser?.includes(message._id));
|
||||
const all = Boolean(subscription?.tunreadGroup?.includes(message._id));
|
||||
const ignoredUser = Boolean(subscription?.ignored?.includes(message.u._id));
|
||||
const shouldShowAsSequential = sequential && !newDay;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDivider && (
|
||||
<Box
|
||||
ref={ref}
|
||||
data-id={message.ts}
|
||||
role='listitem'
|
||||
className={css`
|
||||
transition: opacity 0.2s ease-out;
|
||||
`}
|
||||
{...(newDay && {
|
||||
'data-time': new Date(message.ts)
|
||||
.toISOString()
|
||||
.replaceAll(/[-T:.]/g, '')
|
||||
.substring(0, 8),
|
||||
})}
|
||||
>
|
||||
<MessageDivider unreadLabel={showUnreadDivider ? t('Unread_Messages').toLowerCase() : undefined}>
|
||||
{newDay && (
|
||||
<Bubble small secondary>
|
||||
{formatDate(message.ts)}
|
||||
</Bubble>
|
||||
)}
|
||||
</MessageDivider>
|
||||
</Box>
|
||||
)}
|
||||
{visible && (
|
||||
<RoomMessage
|
||||
message={message}
|
||||
showUserAvatar={showUserAvatar}
|
||||
sequential={shouldShowAsSequential}
|
||||
unread={unread}
|
||||
mention={mention}
|
||||
all={all}
|
||||
ignoredUser={ignoredUser}
|
||||
/>
|
||||
)}
|
||||
{isThreadMessage(message) && (
|
||||
<div role='listitem'>
|
||||
<ThreadMessagePreview
|
||||
data-mid={message._id}
|
||||
data-tmid={message.tmid}
|
||||
data-unread={showUnreadDivider}
|
||||
data-sequential={sequential}
|
||||
sequential={shouldShowAsSequential}
|
||||
message={message}
|
||||
showUserAvatar={showUserAvatar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{system && <SystemMessage showUserAvatar={showUserAvatar} message={message} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
type MessageHighlightContextValue = {
|
||||
highlightMessageId?: IMessage['_id'];
|
||||
};
|
||||
|
||||
const MessageHighlightContext = createContext<MessageHighlightContextValue>({});
|
||||
|
||||
export const useIsMessageHighlight = (_id: IMessage['_id']): boolean => useContext(MessageHighlightContext).highlightMessageId === _id;
|
||||
|
||||
export default MessageHighlightContext;
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { SyntheticEvent } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useSyncExternalStore } from 'react';
|
||||
|
||||
import { selectedMessageStore } from '../../providers/SelectedMessagesProvider';
|
||||
|
||||
type SelectMessageContextValue = {
|
||||
selectedMessageStore: typeof selectedMessageStore;
|
||||
};
|
||||
|
||||
export const SelectedMessageContext = createContext({
|
||||
selectedMessageStore,
|
||||
} as SelectMessageContextValue);
|
||||
|
||||
export const useIsSelectedMessage = (mid: string, omit?: boolean): boolean => {
|
||||
const { selectedMessageStore } = useContext(SelectedMessageContext);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void): (() => void) => selectedMessageStore.on(mid, callback),
|
||||
[selectedMessageStore, mid],
|
||||
);
|
||||
|
||||
const getSnapshot = (): boolean => selectedMessageStore.isSelected(mid);
|
||||
|
||||
const isSelected = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected || omit) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedMessageStore.addAvailableMessage(mid);
|
||||
|
||||
return () => selectedMessageStore.removeAvailableMessage(mid);
|
||||
}, [mid, selectedMessageStore, isSelected, omit]);
|
||||
|
||||
return isSelected;
|
||||
};
|
||||
|
||||
export const useIsSelecting = (): boolean => {
|
||||
const { selectedMessageStore } = useContext(SelectedMessageContext);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void): (() => void) => selectedMessageStore.on('toggleIsSelecting', callback),
|
||||
[selectedMessageStore],
|
||||
);
|
||||
|
||||
const getSnapshot = (): boolean => selectedMessageStore.getIsSelecting();
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useToggleSelect = (mid: string): ((event?: SyntheticEvent<HTMLElement>) => void) => {
|
||||
const { selectedMessageStore } = useContext(SelectedMessageContext);
|
||||
|
||||
return useCallback(
|
||||
(event) => {
|
||||
event?.stopPropagation();
|
||||
selectedMessageStore.toggle(mid);
|
||||
},
|
||||
[mid, selectedMessageStore],
|
||||
);
|
||||
};
|
||||
|
||||
export const useToggleSelectAll = (): (() => void) => {
|
||||
const { selectedMessageStore } = useContext(SelectedMessageContext);
|
||||
return useCallback(() => {
|
||||
selectedMessageStore.toggleAll(Array.from(selectedMessageStore.availableMessages));
|
||||
}, [selectedMessageStore]);
|
||||
};
|
||||
|
||||
export const useClearSelection = (): (() => void) => {
|
||||
const { selectedMessageStore } = useContext(SelectedMessageContext);
|
||||
return useCallback(() => {
|
||||
selectedMessageStore.clearStore();
|
||||
}, [selectedMessageStore]);
|
||||
};
|
||||
|
||||
export const useCountSelected = (): number => {
|
||||
const { selectedMessageStore } = useContext(SelectedMessageContext);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void): (() => void) => selectedMessageStore.on('change', callback),
|
||||
[selectedMessageStore],
|
||||
);
|
||||
|
||||
const getSnapshot = (): number => selectedMessageStore.count();
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useAvailableMessagesCount = () => {
|
||||
const { selectedMessageStore } = useContext(SelectedMessageContext);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void): (() => void) => selectedMessageStore.on('change', callback),
|
||||
[selectedMessageStore],
|
||||
);
|
||||
|
||||
const getSnapshot = () => selectedMessageStore.availableMessagesCount();
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useMessageListAutoLinkDomains } from '../../../../components/message/list/MessageListContext';
|
||||
|
||||
export const useAutoLinkDomains = (): string[] => {
|
||||
const domains = useMessageListAutoLinkDomains();
|
||||
|
||||
const customDomains = useMemo(() => (domains ? domains.split(',').map((domain) => domain.trim()) : []), [domains]);
|
||||
|
||||
return useMemo(() => {
|
||||
return customDomains;
|
||||
}, [customDomains]);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { IMessage, ISubscription, ITranslatedMessage } from '@rocket.chat/core-typings';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { AutoTranslate } from '../../../../lib/autotranslate';
|
||||
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
|
||||
import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from '../lib/autoTranslate';
|
||||
import { isOwnUserMessage } from '../lib/isOwnUserMessage';
|
||||
|
||||
export type AutoTranslateOptions = {
|
||||
autoTranslateEnabled: boolean;
|
||||
autoTranslateLanguage?: string;
|
||||
showAutoTranslate: (message: IMessage & Partial<ITranslatedMessage>) => boolean;
|
||||
};
|
||||
|
||||
export const useAutoTranslate = (subscription?: ISubscription): AutoTranslateOptions => {
|
||||
const autoTranslateSettingEnabled = useSetting('AutoTranslate_Enabled', false);
|
||||
const isSubscriptionEnabled = autoTranslateSettingEnabled && subscription?.autoTranslateLanguage && subscription?.autoTranslate;
|
||||
const isLivechatRoom = useMemo(() => subscription && roomCoordinator.isLivechatRoom(subscription?.t), [subscription]);
|
||||
const autoTranslateEnabled = Boolean(isSubscriptionEnabled || isLivechatRoom);
|
||||
const autoTranslateLanguage = autoTranslateEnabled && subscription ? AutoTranslate.getLanguage(subscription.rid) : undefined;
|
||||
|
||||
const showAutoTranslate = useCallback(
|
||||
(message: IMessage): boolean => {
|
||||
if (!autoTranslateEnabled || !autoTranslateLanguage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!isOwnUserMessage(message, subscription) &&
|
||||
!(message as { autoTranslateShowInverse?: boolean }).autoTranslateShowInverse &&
|
||||
(hasTranslationLanguageInMessage(message, autoTranslateLanguage) ||
|
||||
hasTranslationLanguageInAttachments(message.attachments, autoTranslateLanguage))
|
||||
);
|
||||
},
|
||||
[subscription, autoTranslateEnabled, autoTranslateLanguage],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return { autoTranslateEnabled, autoTranslateLanguage, showAutoTranslate };
|
||||
}, [autoTranslateEnabled, autoTranslateLanguage, showAutoTranslate]);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useKatex } from './useKatex';
|
||||
|
||||
it('should return enabled true dollar syntax true and parenthesis syntax true if all settings is enabled', () => {
|
||||
const { result } = renderHook(() => useKatex(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Katex_Enabled', true)
|
||||
.withSetting('Katex_Dollar_Syntax', true)
|
||||
.withSetting('Katex_Parenthesis_Syntax', true)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.katexEnabled).toBe(true);
|
||||
expect(result.current.katexDollarSyntaxEnabled).toBe(true);
|
||||
expect(result.current.katexParenthesisSyntaxEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return enabled false dollar syntax false and parenthesis syntax false if all settings is disabled', () => {
|
||||
const { result } = renderHook(() => useKatex(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Katex_Enabled', false)
|
||||
.withSetting('Katex_Dollar_Syntax', false)
|
||||
.withSetting('Katex_Parenthesis_Syntax', false)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.katexEnabled).toBe(false);
|
||||
expect(result.current.katexDollarSyntaxEnabled).toBe(false);
|
||||
expect(result.current.katexParenthesisSyntaxEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return enabled true dollar syntax false and parenthesis syntax false if Katex_Enabled settings is enable', () => {
|
||||
const { result } = renderHook(() => useKatex(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Katex_Enabled', true)
|
||||
.withSetting('Katex_Dollar_Syntax', false)
|
||||
.withSetting('Katex_Parenthesis_Syntax', false)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.katexEnabled).toBe(true);
|
||||
expect(result.current.katexDollarSyntaxEnabled).toBe(false);
|
||||
expect(result.current.katexParenthesisSyntaxEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return enabled false dollar syntax false and parenthesis syntax false if DollarSyntaxEnabled and ParenthesisSyntaxEnabled settings is enable', () => {
|
||||
const { result } = renderHook(() => useKatex(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Katex_Enabled', false)
|
||||
.withSetting('Katex_Dollar_Syntax', true)
|
||||
.withSetting('Katex_Parenthesis_Syntax', true)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.katexEnabled).toBe(false);
|
||||
expect(result.current.katexDollarSyntaxEnabled).toBe(false);
|
||||
expect(result.current.katexParenthesisSyntaxEnabled).toBe(false);
|
||||
});
|
||||
17
apps/meteor/client/views/room/MessageList/hooks/useKatex.ts
Normal file
17
apps/meteor/client/views/room/MessageList/hooks/useKatex.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export const useKatex = (): {
|
||||
katexEnabled: boolean;
|
||||
katexDollarSyntaxEnabled: boolean;
|
||||
katexParenthesisSyntaxEnabled: boolean;
|
||||
} => {
|
||||
const katexEnabled = useSetting('Katex_Enabled', true);
|
||||
const katexDollarSyntaxEnabled = useSetting('Katex_Dollar_Syntax', false) && katexEnabled;
|
||||
const katexParenthesisSyntaxEnabled = useSetting('Katex_Parenthesis_Syntax', true) && katexEnabled;
|
||||
|
||||
return {
|
||||
katexEnabled,
|
||||
katexDollarSyntaxEnabled,
|
||||
katexParenthesisSyntaxEnabled,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useKeepAtBottom } from './useKeepAtBottom';
|
||||
|
||||
let resizeCallbacks: ResizeObserverCallback[] = [];
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
resizeCallbacks.push(cb);
|
||||
}
|
||||
|
||||
observe = jest.fn();
|
||||
|
||||
unobserve = jest.fn();
|
||||
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
// Rows are measured asynchronously after the list remounts (avatars, images, reactions),
|
||||
// so the observer fires repeatedly while the restored position is being applied.
|
||||
const settleList = (times: number) => {
|
||||
for (let i = 0; i < times; i++) {
|
||||
resizeCallbacks.forEach((cb) => cb([], {} as ResizeObserver));
|
||||
}
|
||||
};
|
||||
|
||||
const mountList = (isAtBottom: { current: boolean }) => {
|
||||
const { result } = renderHook(() => useKeepAtBottom(isAtBottom));
|
||||
|
||||
const node = document.createElement('div');
|
||||
node.appendChild(document.createElement('div'));
|
||||
result.current.keepAtBottomRef(node);
|
||||
|
||||
const scrollToEnd = jest.fn();
|
||||
result.current.setKeepAtBottom(scrollToEnd);
|
||||
|
||||
return scrollToEnd;
|
||||
};
|
||||
|
||||
describe('useKeepAtBottom', () => {
|
||||
beforeEach(() => {
|
||||
resizeCallbacks = [];
|
||||
(global as any).ResizeObserver = MockResizeObserver;
|
||||
});
|
||||
|
||||
it('does not pull the list to the latest messages when the room was left mid-history', () => {
|
||||
const scrollToEnd = mountList({ current: false });
|
||||
|
||||
settleList(3);
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the list at the bottom when the room was left at the bottom', () => {
|
||||
const scrollToEnd = mountList({ current: true });
|
||||
|
||||
settleList(3);
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
// This hook is responsible for keeping the message list at the bottom despite any size changes to the container.
|
||||
// Some examples of when this is needed are:
|
||||
// - When the user is at the bottom and a new message arrives, the message list will grow and we want to keep it at the bottom. (This one is already handled in another place, but this hook also does this job)
|
||||
// - When the user is at the bottom and the composer grows (e.g. when typing a long message), we want to keep it at the bottom.
|
||||
// - When the user is at the bottom and the window is resized, the elements might reflow.
|
||||
// - When the user is at the bottom and a thread opens, the horizontal size of the container will shrink, and the elements might reflow.
|
||||
// - When the user is at the bottom and a message is reacted to, the message will grow and shift the list.
|
||||
// - When the user is at the bottom and a video is loading, after loading the video element can change sizes and shift the list
|
||||
export const useKeepAtBottom = (isAtBottom: MutableRefObject<boolean | null>) => {
|
||||
const handleRef = useRef<(() => void) | null>(null);
|
||||
const keepAtBottomRef = useSafeRefCallback(
|
||||
useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
const sendToBottom = () => {
|
||||
if (isAtBottom.current && handleRef.current) {
|
||||
handleRef.current();
|
||||
}
|
||||
};
|
||||
const listWrapper = node.firstChild;
|
||||
const observer = new ResizeObserver(() => {
|
||||
sendToBottom();
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
if (listWrapper instanceof HTMLElement) {
|
||||
observer.observe(listWrapper);
|
||||
}
|
||||
|
||||
const sendToBottomEvent = (e: Event) => {
|
||||
if (!(e.target instanceof HTMLElement)) return;
|
||||
if (!e.target.closest('.rc-message-box')) return;
|
||||
sendToBottom();
|
||||
};
|
||||
|
||||
// Workaround for height hack in useAutoGrow
|
||||
// node.style.height = '0';
|
||||
// node.style.height = `${node.scrollHeight}px`;
|
||||
window.addEventListener('input', sendToBottomEvent);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('input', sendToBottomEvent);
|
||||
};
|
||||
},
|
||||
[isAtBottom],
|
||||
),
|
||||
);
|
||||
|
||||
const setKeepAtBottom = useCallback((handle: () => void | null) => {
|
||||
handleRef.current = handle;
|
||||
}, []);
|
||||
|
||||
return { keepAtBottomRef, setKeepAtBottom };
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
export const useKeepMountedMessages = (messages: IMessage[], canPreview: boolean = false): number[] => {
|
||||
const offset = canPreview ? 1 : 0;
|
||||
return messages.reduce<number[]>((acc, message, index) => {
|
||||
// Keep mounted anything with an embed that reloads when re-mounted: file
|
||||
// attachments (audio/video players) and URL previews (e.g. YouTube iframes).
|
||||
// Otherwise virtua recycles them on scroll-to-bottom (new message / reaction
|
||||
// growing a message) and the iframe flickers.
|
||||
//
|
||||
// Don't extend this to other message/attachment types just to preserve some local
|
||||
// UI state. Every match here stays mounted for the life of the list regardless of
|
||||
// scroll position, so it only scales for the "needs a live browser resource" case
|
||||
// above and adding more to it defeats virtualization in media-heavy channels.
|
||||
const hasUrlPreview = message.urls?.some((url) => Object.keys(url.meta ?? {}).length > 0 || !!url.headers) ?? false;
|
||||
if ((message.files?.length ?? 0) > 0 || hasUrlPreview) {
|
||||
acc.push(index + offset);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import type { Options, Root } from '@rocket.chat/message-parser';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAutoLinkDomains } from './useAutoLinkDomains';
|
||||
import { useMessageListAutoTranslate } from '../../../../components/message/list/MessageListContext';
|
||||
import { parseMessageTextToAstMarkdown } from '../../../../lib/parseMessageTextToAstMarkdown';
|
||||
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';
|
||||
|
||||
export const useMessageBody = (message: IMessage | undefined): string | Root => {
|
||||
const autoTranslateOptions = useMessageListAutoTranslate();
|
||||
const customDomains = useAutoLinkDomains();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!message) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (message.md) {
|
||||
const parseOptions: Options = {
|
||||
customDomains,
|
||||
emoticons: true,
|
||||
};
|
||||
|
||||
const messageWithMd = parseMessageTextToAstMarkdown(message, parseOptions, autoTranslateOptions);
|
||||
|
||||
return messageWithMd.md;
|
||||
}
|
||||
|
||||
if (message.msg) {
|
||||
return toPlainTextRoot(message.msg);
|
||||
}
|
||||
|
||||
if (message.attachments) {
|
||||
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description);
|
||||
|
||||
if (attachment?.description) {
|
||||
return attachment.description;
|
||||
}
|
||||
|
||||
if (attachment?.title) {
|
||||
return attachment.title;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [message, customDomains, autoTranslateOptions]);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IRoom, IMessage, MessageTypesValues } from '@rocket.chat/core-typings';
|
||||
import { useStableArray } from '@rocket.chat/fuselage-hooks';
|
||||
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
|
||||
import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
import { Messages } from '../../../../stores';
|
||||
import { useRoom } from '../../contexts/RoomContext';
|
||||
|
||||
const mergeHideSysMessages = (
|
||||
sysMesArray1: Array<MessageTypesValues>,
|
||||
sysMesArray2: Array<MessageTypesValues>,
|
||||
): Array<MessageTypesValues> => {
|
||||
return Array.from(new Set([...sysMesArray1, ...sysMesArray2]));
|
||||
};
|
||||
|
||||
export const useMessages = ({ rid }: { rid: IRoom['_id'] }): IMessage[] => {
|
||||
const showThreadsInMainChannel = useUserPreference<boolean>('showThreadsInMainChannel', false);
|
||||
const hideSysMesSetting = useSetting<MessageTypesValues[]>('Hide_System_Messages', []);
|
||||
const room = useRoom();
|
||||
const hideRoomSysMes: Array<MessageTypesValues> = Array.isArray(room.sysMes) ? room.sysMes : [];
|
||||
|
||||
const hideSysMessages = useStableArray(mergeHideSysMessages(hideSysMesSetting, hideRoomSysMes));
|
||||
|
||||
const predicate = useMemo(
|
||||
() =>
|
||||
createPredicateFromFilter<IMessage>({
|
||||
rid,
|
||||
_hidden: { $ne: true },
|
||||
t: { $nin: hideSysMessages },
|
||||
...(!showThreadsInMainChannel && {
|
||||
$or: [{ tmid: { $exists: false } }, { tshow: { $eq: true } }],
|
||||
}),
|
||||
}),
|
||||
[rid, hideSysMessages, showThreadsInMainChannel],
|
||||
);
|
||||
|
||||
return Messages.use(useShallow((state) => state.filter(predicate).sort((a, b) => a.ts.getTime() - b.ts.getTime())));
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { withDebouncing } from '../../../../../lib/utils/highOrderFunctions';
|
||||
import { sdk } from '../../../../lib/SDKClient';
|
||||
import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
|
||||
import { Messages } from '../../../../stores';
|
||||
|
||||
const MAX_IDS_PER_REQUEST = 100;
|
||||
|
||||
const findParentMessage = (() => {
|
||||
const waiting: string[] = [];
|
||||
let resolve: (resolved: IMessage[] | PromiseLike<IMessage[]>) => void;
|
||||
let pending = new Promise<IMessage[]>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
|
||||
const getMessages = withDebouncing({ wait: 500 })(async () => {
|
||||
const messageIds = [...waiting];
|
||||
waiting.length = 0;
|
||||
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < messageIds.length; i += MAX_IDS_PER_REQUEST) {
|
||||
batches.push(messageIds.slice(i, i + MAX_IDS_PER_REQUEST));
|
||||
}
|
||||
|
||||
resolve(
|
||||
Promise.all(
|
||||
batches.map((ids) =>
|
||||
sdk.rest.post('/v1/chat.getMessages', { messageIds: ids }).then(({ messages }) => messages.map((msg) => mapMessageFromApi(msg))),
|
||||
),
|
||||
).then((results) => results.flat()),
|
||||
);
|
||||
|
||||
pending = new Promise<IMessage[]>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
});
|
||||
|
||||
const get = async (tmid: IMessage['_id']) => {
|
||||
void getMessages();
|
||||
const messages = await pending;
|
||||
const message = messages.find(({ _id }) => _id === tmid);
|
||||
|
||||
if (!message) {
|
||||
throw new Error(`Message ${tmid} not found`);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
return async (tmid: IMessage['_id']) => {
|
||||
const message = Messages.state.get(tmid);
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (waiting.indexOf(tmid) === -1) {
|
||||
waiting.push(tmid);
|
||||
}
|
||||
return get(tmid);
|
||||
};
|
||||
})();
|
||||
|
||||
export const useParentMessage = (mid: IMessage['_id']): UseQueryResult<IMessage> =>
|
||||
useQuery({
|
||||
queryKey: ['parent-message', { mid }],
|
||||
queryFn: async () => findParentMessage(mid),
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { WindowVirtualizerHandle } from 'virtua';
|
||||
|
||||
import useTryToJumpToMessage from './useTryToJumpToMessage';
|
||||
import { RoomHistoryManager } from '../../../../lib/RoomHistoryManager';
|
||||
import { RoomContext } from '../../contexts/RoomContext';
|
||||
import { useGoToRoom } from '../../hooks/useGoToRoom';
|
||||
|
||||
jest.mock('../../../../lib/RoomHistoryManager', () => ({
|
||||
RoomHistoryManager: {
|
||||
getSurroundingChannelMessages: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../hooks/useGoToRoom', () => ({
|
||||
useGoToRoom: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../providers/RouterProvider', () => ({
|
||||
router: {
|
||||
getSearchParameters: jest.fn().mockReturnValue({}),
|
||||
navigate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedRoomHistoryManager = jest.mocked(RoomHistoryManager);
|
||||
const mockedGoToRoom = jest.fn().mockResolvedValue(undefined);
|
||||
const mockedSetIsJumpingToMessage = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(useGoToRoom).mockReturnValue(mockedGoToRoom);
|
||||
mockedRoomHistoryManager.getSurroundingChannelMessages.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderJumpHook = (rid: string, message: unknown) =>
|
||||
renderHook(
|
||||
() =>
|
||||
useTryToJumpToMessage({
|
||||
rid,
|
||||
virtualizerRef: { current: { scrollToIndex: jest.fn() } } as unknown as MutableRefObject<WindowVirtualizerHandle | null>,
|
||||
setIsJumpingToMessage: mockedSetIsJumpingToMessage,
|
||||
messages: [{ _id: 'another-msg' }],
|
||||
}),
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withRouter({ getSearchParameters: () => ({ msg: 'msg-1' }) })
|
||||
.withEndpoint('GET', '/v1/chat.getMessage', (() => {
|
||||
if (message instanceof Error) {
|
||||
throw message;
|
||||
}
|
||||
return { message };
|
||||
}) as any)
|
||||
.wrap((children) => (
|
||||
<RoomContext.Provider
|
||||
value={
|
||||
{
|
||||
rid,
|
||||
room: { _id: rid, t: 'c', name: 'general' },
|
||||
hasMorePreviousMessages: false,
|
||||
hasMoreNextMessages: false,
|
||||
isLoadingMoreMessages: false,
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</RoomContext.Provider>
|
||||
))
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
const message = {
|
||||
_id: 'msg-1',
|
||||
ts: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
u: { _id: 'user-1', username: 'john' },
|
||||
msg: 'Hello',
|
||||
_updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
};
|
||||
|
||||
it('should navigate to the message room when the message belongs to another room', async () => {
|
||||
renderJumpHook('room-1', { ...message, rid: 'room-2' });
|
||||
|
||||
await waitFor(() => expect(mockedGoToRoom).toHaveBeenCalledWith('room-2'));
|
||||
|
||||
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should load surrounding messages in place when the message belongs to the current room', async () => {
|
||||
renderJumpHook('room-1', { ...message, rid: 'room-1' });
|
||||
|
||||
await waitFor(() => expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalled());
|
||||
|
||||
expect(mockedGoToRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should discard the jump when the target message cannot be fetched', async () => {
|
||||
renderJumpHook('room-1', new Error('error-invalid-message'));
|
||||
|
||||
await waitFor(() => expect(mockedSetIsJumpingToMessage).toHaveBeenCalledWith(false));
|
||||
|
||||
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).not.toHaveBeenCalled();
|
||||
expect(mockedGoToRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not navigate for a cross-room thread message, as it is handled by useTryToJumpToThreadMessage', async () => {
|
||||
renderJumpHook('room-1', { ...message, rid: 'room-2', tmid: 'parent-msg-1', tshow: true });
|
||||
|
||||
await waitFor(() => expect(mockedSetIsJumpingToMessage).toHaveBeenCalledWith(true));
|
||||
|
||||
expect(mockedGoToRoom).not.toHaveBeenCalled();
|
||||
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should request surrounding messages only once while the target stays out of the list', async () => {
|
||||
const { rerender } = renderJumpHook('room-1', { ...message, rid: 'room-1' });
|
||||
|
||||
await waitFor(() => expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender();
|
||||
rerender();
|
||||
|
||||
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop jumping when the surrounding messages request fails', async () => {
|
||||
mockedRoomHistoryManager.getSurroundingChannelMessages.mockRejectedValue(new Error('too-many-requests'));
|
||||
|
||||
const { rerender } = renderJumpHook('room-1', { ...message, rid: 'room-1' });
|
||||
|
||||
await waitFor(() => expect(mockedSetIsJumpingToMessage).toHaveBeenLastCalledWith(false));
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { isThreadMainMessage, isThreadMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint, useSearchParameter } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { WindowVirtualizerHandle } from 'virtua';
|
||||
|
||||
import { RoomHistoryManager } from '../../../../lib/RoomHistoryManager';
|
||||
import { messagesQueryKeys } from '../../../../lib/queryKeys';
|
||||
import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
|
||||
import { setMessageJumpQueryStringParameter } from '../../../../lib/utils/setMessageJumpQueryStringParameter';
|
||||
import { useRoomMessages } from '../../contexts/RoomContext';
|
||||
import { useGoToRoom } from '../../hooks/useGoToRoom';
|
||||
import { clearHighlightMessage, setHighlightMessage } from '../providers/messageHighlightSubscription';
|
||||
|
||||
type UseTryToJumpToMessageProps = {
|
||||
rid: string;
|
||||
virtualizerRef: MutableRefObject<WindowVirtualizerHandle | null>;
|
||||
setIsJumpingToMessage: Dispatch<SetStateAction<boolean>>;
|
||||
messages: { _id: string }[];
|
||||
};
|
||||
|
||||
const useTryToJumpToMessage = ({ rid, virtualizerRef, setIsJumpingToMessage, messages }: UseTryToJumpToMessageProps) => {
|
||||
const messageJumpParam = useSearchParameter('msg');
|
||||
|
||||
const { isLoadingMoreMessages } = useRoomMessages();
|
||||
|
||||
const getMessage = useEndpoint('GET', '/v1/chat.getMessage');
|
||||
|
||||
const goToRoom = useGoToRoom();
|
||||
|
||||
const { data: message, isError } = useQuery({
|
||||
queryKey: messageJumpParam ? messagesQueryKeys.message(messageJumpParam) : [],
|
||||
queryFn: async () => {
|
||||
if (!messageJumpParam) return null;
|
||||
const { message } = await getMessage({ msgId: messageJumpParam });
|
||||
return mapMessageFromApi(message);
|
||||
},
|
||||
enabled: !!messageJumpParam,
|
||||
});
|
||||
|
||||
const isThreadReply = !!message && isThreadMessage(message) && !isThreadMainMessage(message) && message.tshow !== true;
|
||||
const targetId = isThreadReply ? message.tmid : messageJumpParam;
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetId || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJumpingToMessage(true);
|
||||
|
||||
if (message.rid !== rid) {
|
||||
return;
|
||||
}
|
||||
|
||||
void RoomHistoryManager.getSurroundingChannelMessages({ _id: targetId, rid })
|
||||
.catch(() => undefined)
|
||||
.finally(() => setIsJumpingToMessage(false));
|
||||
}, [targetId, rid, message, setIsJumpingToMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageJumpParam) {
|
||||
setIsJumpingToMessage(false);
|
||||
return;
|
||||
}
|
||||
if (isError) {
|
||||
setIsJumpingToMessage(false);
|
||||
setMessageJumpQueryStringParameter(null);
|
||||
return;
|
||||
}
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (!isThreadMessage(message) && !isThreadMainMessage(message) && message.rid !== rid) {
|
||||
setIsJumpingToMessage(false);
|
||||
goToRoom(message.rid);
|
||||
return;
|
||||
}
|
||||
if (!virtualizerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingMoreMessages || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = targetId ? messages.findIndex((current) => current._id === targetId) : -1;
|
||||
|
||||
if (!targetId || targetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Calculate the offset of the page, for the message to be in the center of the page
|
||||
virtualizerRef.current?.scrollToIndex(targetIndex, {
|
||||
align: 'center',
|
||||
});
|
||||
|
||||
setHighlightMessage(targetId);
|
||||
|
||||
setTimeout(() => {
|
||||
clearHighlightMessage();
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsJumpingToMessage(false);
|
||||
if (targetId === messageJumpParam) {
|
||||
setMessageJumpQueryStringParameter(null);
|
||||
}
|
||||
}, 500);
|
||||
}, [messageJumpParam, virtualizerRef, setIsJumpingToMessage, rid, messages, message, isError, isLoadingMoreMessages, targetId, goToRoom]);
|
||||
};
|
||||
|
||||
export default useTryToJumpToMessage;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import useTryToJumpToThreadMessage from './useTryToJumpToThreadMessage';
|
||||
import { RoomManager } from '../../../../lib/RoomManager';
|
||||
import { useGoToRoom } from '../../hooks/useGoToRoom';
|
||||
|
||||
jest.mock('../../hooks/useGoToRoom', () => ({
|
||||
useGoToRoom: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../lib/RoomManager', () => ({
|
||||
RoomManager: {
|
||||
opened: undefined as string | undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../../lib/rooms/roomCoordinator', () => ({
|
||||
roomCoordinator: {
|
||||
openRouteLink: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../../providers/RouterProvider', () => ({
|
||||
router: {
|
||||
getSearchParameters: jest.fn().mockReturnValue({}),
|
||||
getRouteParameters: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../../stores', () => ({
|
||||
Subscriptions: {
|
||||
state: {
|
||||
find: jest.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedUseGoToRoom = jest.mocked(useGoToRoom);
|
||||
const mockedGoToRoom = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseGoToRoom.mockReturnValue(mockedGoToRoom);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(RoomManager as { opened: string | undefined }).opened = undefined;
|
||||
});
|
||||
|
||||
describe('useTryToJumpToThreadMessage', () => {
|
||||
describe('early return when msg param is absent or jumpContext is jumpToUnread', () => {
|
||||
it('should not navigate when msg search parameter is absent', () => {
|
||||
const endpointSpy = jest.fn();
|
||||
|
||||
renderHook(() => useTryToJumpToThreadMessage(), {
|
||||
wrapper: mockAppRoot().withEndpoint('GET', '/v1/chat.getMessage', endpointSpy).build(),
|
||||
});
|
||||
|
||||
expect(endpointSpy).not.toHaveBeenCalled();
|
||||
expect(mockedGoToRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not navigate when jumpContext is jumpToUnread', async () => {
|
||||
const threadMessage = {
|
||||
_id: 'msg-1',
|
||||
rid: 'room-1',
|
||||
tmid: 'parent-msg-1',
|
||||
ts: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
u: { _id: 'user-1', username: 'john' },
|
||||
msg: 'Thread reply',
|
||||
_updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
};
|
||||
|
||||
const endpointSpy = jest.fn().mockResolvedValue({ message: threadMessage });
|
||||
|
||||
renderHook(() => useTryToJumpToThreadMessage(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withRouter({ getSearchParameters: () => ({ msg: 'msg-1', jumpContext: 'jumpToUnread' }) })
|
||||
.withEndpoint('GET', '/v1/chat.getMessage', endpointSpy)
|
||||
.withMethod('getRoomById', () => ({ _id: 'room-1', t: 'c', name: 'general' }) as any)
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endpointSpy).toHaveBeenCalledWith({ msgId: 'msg-1' });
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(mockedGoToRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when jumpContext is absent', () => {
|
||||
it('should open the thread when jumpContext is null', async () => {
|
||||
const threadMessage = {
|
||||
_id: 'msg-1',
|
||||
rid: 'room-1',
|
||||
tmid: 'parent-msg-1',
|
||||
ts: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
u: { _id: 'user-1', username: 'john' },
|
||||
msg: 'Thread reply',
|
||||
_updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
};
|
||||
|
||||
const endpointSpy = jest.fn().mockResolvedValue({ message: threadMessage });
|
||||
|
||||
renderHook(() => useTryToJumpToThreadMessage(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withRouter({ getSearchParameters: () => ({ msg: 'msg-1' }) })
|
||||
.withEndpoint('GET', '/v1/chat.getMessage', endpointSpy)
|
||||
.withMethod('getRoomById', () => ({ _id: 'room-1', t: 'c', name: 'general' }) as any)
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endpointSpy).toHaveBeenCalledWith({ msgId: 'msg-1' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGoToRoom).toHaveBeenCalledWith('room-1', {
|
||||
routeParamsOverrides: { tab: 'thread', context: 'parent-msg-1' },
|
||||
replace: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { isThreadMainMessage, isThreadMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint, useRouteParameter, useSearchParameter } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { RoomManager } from '../../../../lib/RoomManager';
|
||||
import { messagesQueryKeys } from '../../../../lib/queryKeys';
|
||||
import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
|
||||
import { useGoToRoom } from '../../hooks/useGoToRoom';
|
||||
|
||||
const useTryToJumpToThreadMessage = (): void => {
|
||||
const messageJumpParam = useSearchParameter('msg');
|
||||
const messageJumpContext = useSearchParameter('jumpContext');
|
||||
const goToRoom = useGoToRoom();
|
||||
const tab = useRouteParameter('tab');
|
||||
const context = useRouteParameter('context');
|
||||
|
||||
const getMessage = useEndpoint('GET', '/v1/chat.getMessage');
|
||||
|
||||
const { data: message } = useQuery({
|
||||
queryKey: messageJumpParam ? messagesQueryKeys.message(messageJumpParam) : [],
|
||||
queryFn: async () => {
|
||||
if (!messageJumpParam) return null;
|
||||
const { message: raw } = await getMessage({ msgId: messageJumpParam });
|
||||
return mapMessageFromApi(raw);
|
||||
},
|
||||
enabled: !!messageJumpParam,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageJumpParam || messageJumpContext === 'jumpToUnread') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isThreadMessage(message) && !isThreadMainMessage(message)) {
|
||||
return;
|
||||
}
|
||||
if (tab === 'thread' && (context === message.tmid || context === message._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await goToRoom(message.rid, {
|
||||
routeParamsOverrides: { tab: 'thread', context: message.tmid || message._id },
|
||||
replace: RoomManager.opened === message.rid,
|
||||
});
|
||||
})();
|
||||
}, [messageJumpParam, message, goToRoom, tab, context, messageJumpContext]);
|
||||
};
|
||||
|
||||
export default useTryToJumpToThreadMessage;
|
||||
1
apps/meteor/client/views/room/MessageList/index.ts
Normal file
1
apps/meteor/client/views/room/MessageList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './MessageList';
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { IMessage, MessageAttachment } from '@rocket.chat/core-typings';
|
||||
|
||||
import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from './autoTranslate';
|
||||
|
||||
describe('hasTranslationLanguageInMessage', () => {
|
||||
const testCases = [
|
||||
[{}, '', false],
|
||||
[{ translations: { en: 'bah' } }, '', false],
|
||||
[{ translations: { en: 'bah' } }, 'pt', false],
|
||||
[{ translations: { en: 'bah' } }, 'en', true],
|
||||
] as const;
|
||||
|
||||
testCases.forEach(([message, language, expectedResult]) => {
|
||||
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(message)} with ${JSON.stringify(language)}`, () => {
|
||||
const result = hasTranslationLanguageInMessage(message as unknown as IMessage, language);
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTranslationLanguageInAttachments', () => {
|
||||
const testCases = [
|
||||
[[{}], '', false],
|
||||
[undefined, '', false],
|
||||
[[{ translations: { en: 'bah' } }], '', false],
|
||||
[[{ translations: { en: 'bah' } }], 'pt', false],
|
||||
[[{ translations: { en: 'bah' } }], 'pt', false],
|
||||
[[{ translations: { en: 'bah' } }], 'en', true],
|
||||
] as const;
|
||||
|
||||
testCases.forEach(([attachment, language, expectedResult]) => {
|
||||
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(attachment)} with ${JSON.stringify(language)}`, () => {
|
||||
const result = hasTranslationLanguageInAttachments(attachment as unknown as MessageAttachment[], language);
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { IMessage, MessageAttachment } from '@rocket.chat/core-typings';
|
||||
import { isTranslatedMessageAttachment, isTranslatedMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
export const hasTranslationLanguageInMessage = (message: IMessage, language: string): boolean =>
|
||||
isTranslatedMessage(message) && Boolean(message.translations?.[language]);
|
||||
|
||||
export const hasTranslationLanguageInAttachments = (attachments: MessageAttachment[] = [], language: string): boolean =>
|
||||
isTranslatedMessageAttachment(attachments) && attachments?.some((attachment) => attachment?.translations?.[language]);
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
import { isMessageNewDay } from './isMessageNewDay';
|
||||
|
||||
const date = new Date('2021-10-27T00:00:00.000Z');
|
||||
|
||||
const baseMessage: IMessage = {
|
||||
ts: date,
|
||||
u: {
|
||||
_id: 'userId',
|
||||
name: 'userName',
|
||||
username: 'userName',
|
||||
},
|
||||
msg: 'message',
|
||||
rid: 'roomId',
|
||||
_id: 'messageId',
|
||||
_updatedAt: date,
|
||||
urls: [],
|
||||
};
|
||||
|
||||
it('should return true if the message is from a different day', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
};
|
||||
const message2 = {
|
||||
...baseMessage,
|
||||
ts: new Date('2021-10-28T00:00:00.000Z'),
|
||||
};
|
||||
expect(isMessageNewDay(message, message2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the message is from the same day', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
};
|
||||
const message2 = {
|
||||
...baseMessage,
|
||||
};
|
||||
expect(isMessageNewDay(message, message2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if there is no previous message', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
};
|
||||
expect(isMessageNewDay(message, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for different days even if the range is on second', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
ts: new Date(2022, 0, 1, 23, 59, 59, 999),
|
||||
};
|
||||
const current: IMessage = {
|
||||
...baseMessage,
|
||||
ts: new Date(2022, 0, 2, 0, 0, 0, 0),
|
||||
};
|
||||
expect(isMessageNewDay(current, previous)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { isSameDay } from 'date-fns';
|
||||
|
||||
export const isMessageNewDay = (current: IMessage, previous: IMessage | undefined): boolean =>
|
||||
!previous || !isSameDay(current.ts, previous.ts);
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
import { isMessageSequential } from './isMessageSequential';
|
||||
|
||||
const TIME_RANGE_IN_SECONDS = 300;
|
||||
|
||||
const date = new Date('2021-10-27T00:00:00.000Z');
|
||||
const baseMessage: IMessage = {
|
||||
ts: date,
|
||||
u: {
|
||||
_id: 'userId',
|
||||
name: 'userName',
|
||||
username: 'userName',
|
||||
},
|
||||
msg: 'message',
|
||||
rid: 'roomId',
|
||||
_id: 'messageId',
|
||||
_updatedAt: date,
|
||||
urls: [],
|
||||
};
|
||||
|
||||
it('should return false if no previous message', () => {
|
||||
const current: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
expect(isMessageSequential(current, undefined, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if both messages doesn't belong to the same user", () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
const current: IMessage = {
|
||||
...baseMessage,
|
||||
u: {
|
||||
_id: 'userId2',
|
||||
name: 'userName2',
|
||||
username: 'userName2',
|
||||
},
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if both messages belongs to the same user but have more than five minutes of difference', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
|
||||
const current: IMessage = {
|
||||
...previous,
|
||||
ts: new Date('2021-10-27T00:05:00.001Z'),
|
||||
};
|
||||
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
it('should return true if both messages belongs to the same user and have less than five minutes of difference', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
const current: IMessage = {
|
||||
...previous,
|
||||
ts: new Date('2021-10-27T00:04:59.999Z'),
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(true);
|
||||
});
|
||||
it('should return false if message are not groupable', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
groupable: false,
|
||||
};
|
||||
const current: IMessage = {
|
||||
...previous,
|
||||
groupable: false,
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
it('should return false if both messages are not from the same thread', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
tmid: 'threadId',
|
||||
};
|
||||
const current: IMessage = {
|
||||
...previous,
|
||||
tmid: 'threadId2',
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if both messages are from the same thread same user and bellow the time range', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
tmid: 'threadId',
|
||||
};
|
||||
const current: IMessage = {
|
||||
...previous,
|
||||
tmid: 'threadId',
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if previous message is thread message but the current is a regular one', () => {
|
||||
const previous: IMessage = {
|
||||
tmid: 'threadId',
|
||||
...baseMessage,
|
||||
};
|
||||
const current: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if message is a reply from a previous message', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
_id: 'threadId',
|
||||
};
|
||||
const current: IMessage = {
|
||||
...previous,
|
||||
tmid: 'threadId',
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(true);
|
||||
});
|
||||
it("should return false if both messages don't have the same alias", () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
alias: 'alias',
|
||||
};
|
||||
const current: IMessage = {
|
||||
...previous,
|
||||
alias: 'alias2',
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if message is from system', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
const current: IMessage = {
|
||||
...previous,
|
||||
ts: new Date('2021-10-27T00:04:59.999Z'),
|
||||
t: 'au',
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false even if messages should be sequential, but they are from a different day', () => {
|
||||
const previous: IMessage = {
|
||||
...baseMessage,
|
||||
ts: new Date(2022, 0, 1, 23, 59, 59, 999),
|
||||
};
|
||||
const current: IMessage = {
|
||||
...baseMessage,
|
||||
ts: new Date(2022, 0, 2, 0, 0, 0, 0),
|
||||
};
|
||||
expect(isMessageSequential(current, previous, TIME_RANGE_IN_SECONDS)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
import { differenceInSeconds } from 'date-fns';
|
||||
|
||||
import { isMessageNewDay } from './isMessageNewDay';
|
||||
|
||||
export const isMessageSequential = (current: IMessage, previous: IMessage | undefined, groupingRange: number): boolean => {
|
||||
if (!previous) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (MessageTypes.isSystemMessage(current) || MessageTypes.isSystemMessage(previous)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.tmid) {
|
||||
return [previous.tmid, previous._id].includes(current.tmid);
|
||||
}
|
||||
|
||||
if (previous.tmid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.groupable === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.u._id !== previous.u._id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.alias !== previous.alias) {
|
||||
return false;
|
||||
}
|
||||
return differenceInSeconds(current.ts, previous.ts) < groupingRange && !isMessageNewDay(current, previous);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
|
||||
|
||||
import { isOwnUserMessage } from './isOwnUserMessage';
|
||||
|
||||
const date = new Date('2021-10-27T00:00:00.000Z');
|
||||
|
||||
const baseMessage: IMessage = {
|
||||
ts: date,
|
||||
u: {
|
||||
_id: 'userId',
|
||||
name: 'userName',
|
||||
username: 'userName',
|
||||
},
|
||||
msg: 'message',
|
||||
rid: 'roomId',
|
||||
_id: 'messageId',
|
||||
_updatedAt: date,
|
||||
urls: [],
|
||||
};
|
||||
|
||||
it('should return true if the message is from user', () => {
|
||||
const message: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
|
||||
const subscription: ISubscription = {
|
||||
u: {
|
||||
_id: 'userId',
|
||||
},
|
||||
} as ISubscription;
|
||||
|
||||
expect(isOwnUserMessage(message, subscription)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the message is not from user', () => {
|
||||
const message: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
|
||||
const subscription: ISubscription = {
|
||||
u: {
|
||||
_id: 'otherUser',
|
||||
},
|
||||
} as ISubscription;
|
||||
|
||||
expect(isOwnUserMessage(message, subscription)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if there is no subscription', () => {
|
||||
const message: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
|
||||
expect(isOwnUserMessage(message, undefined)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
|
||||
|
||||
export const isOwnUserMessage = (message: IMessage, subscription?: ISubscription): boolean => message.u._id === subscription?.u._id;
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import type { Root } from '@rocket.chat/message-parser';
|
||||
|
||||
import { isParsedMessage } from './isParsedMessage';
|
||||
|
||||
const date = new Date('2021-10-27T00:00:00.000Z');
|
||||
|
||||
const baseMessage: IMessage = {
|
||||
ts: date,
|
||||
u: {
|
||||
_id: 'userId',
|
||||
name: 'userName',
|
||||
username: 'userName',
|
||||
},
|
||||
msg: 'message',
|
||||
md: [
|
||||
{
|
||||
type: 'PARAGRAPH',
|
||||
value: [
|
||||
{
|
||||
type: 'PLAIN_TEXT',
|
||||
value: 'message',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rid: 'roomId',
|
||||
_id: 'messageId',
|
||||
_updatedAt: date,
|
||||
urls: [],
|
||||
};
|
||||
|
||||
it('should return true if the message parsed', () => {
|
||||
const message: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
|
||||
expect(isParsedMessage(message.md as Root)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the message is not parsed', () => {
|
||||
const message: IMessage = {
|
||||
...baseMessage,
|
||||
};
|
||||
|
||||
expect(isParsedMessage(message.msg as string)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Root } from '@rocket.chat/message-parser';
|
||||
|
||||
export const isParsedMessage = (text: string | Root): text is Root => Array.isArray(text) && text.length > 0;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isValidLink } from './isValidLink';
|
||||
|
||||
describe('isValidLink', () => {
|
||||
const testCases = [
|
||||
['/', false],
|
||||
['test', false],
|
||||
['test/test', false],
|
||||
['.', false],
|
||||
['./test', false],
|
||||
['https://rocket.chat', true],
|
||||
['rocket.chat', false],
|
||||
['data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAAsBAAEAAAICTAEAOw==', true],
|
||||
] as const;
|
||||
|
||||
testCases.forEach(([parameter, expectedResult]) => {
|
||||
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => {
|
||||
const result = isValidLink(parameter);
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const isValidLink = (link: string): boolean => {
|
||||
try {
|
||||
return Boolean(new URL(link));
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ContextType, ReactNode } from 'react';
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import * as messageHighlightSubscription from './messageHighlightSubscription';
|
||||
import MessageHighlightContext from '../contexts/MessageHighlightContext';
|
||||
|
||||
export type MessageHighlightProviderProps = { children: ReactNode };
|
||||
|
||||
const MessageHighlightProvider = ({ children }: MessageHighlightProviderProps) => {
|
||||
const highlightMessageId = useSyncExternalStore(messageHighlightSubscription.subscribe, messageHighlightSubscription.getSnapshot);
|
||||
|
||||
const contextValue = useMemo<ContextType<typeof MessageHighlightContext>>(
|
||||
() => ({
|
||||
highlightMessageId,
|
||||
}),
|
||||
[highlightMessageId],
|
||||
);
|
||||
|
||||
return <MessageHighlightContext.Provider value={contextValue}>{children}</MessageHighlightContext.Provider>;
|
||||
};
|
||||
|
||||
export default MessageHighlightProvider;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user