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

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

View File

@@ -0,0 +1,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);

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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 };
};

View File

@@ -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']);
});
});

View File

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

View File

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

View File

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

View File

@@ -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 };
};

View File

@@ -0,0 +1,3 @@
import { lazy } from 'react';
export default lazy(() => import('./RoomToolbox'));