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,36 @@
# How does the folder structure work in the frontend?
## The folder structure should follow rocketchat's html 'semantics' like:
```
main -> room
-> header
...
-> contextualbar
roominfo
...
-> sidebar
-> Header
-> search
-> actions
...
-> list
...
-> footer
...
```
Each folder is composed by:
| File ||
| -- | -- |
| index.jsx | |
| ContainerComponent.jsx |
| contexts | (optional) |
| providers | (optional) |
| components | (optional) |
| hooks | (optional) |
| libs | (optional) |
We strongly suggest that you start developing your components/hooks/libs inside the folder where they will be directly used. If you ever find that you will use them in more than one place, then you should "promote" the code to an upper level, where it can be commonly accessed by all others
we have being moving code from blaze to react, but sometimes we will need render under blaze enviroment, to do that use `createTemplateForComponent`

View File

@@ -0,0 +1,69 @@
import { AppsEngineUIHost } from '@rocket.chat/apps/dist/client/AppsEngineUIHost';
import type { IExternalComponentRoomInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentRoomInfo';
import type { IExternalComponentUserInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentUserInfo';
import { RoomManager } from '../lib/RoomManager';
import { sdk } from '../lib/SDKClient';
import { baseURI } from '../lib/baseURI';
import { getUserAvatarURL } from '../lib/getUserAvatarURL';
import { getUser } from '../lib/user';
import { Rooms } from '../stores';
// FIXME: replace non-null assertions with proper error handling
export class RealAppsEngineUIHost extends AppsEngineUIHost {
private _baseURL: string;
constructor() {
super();
this._baseURL = baseURI.replace(/\/$/, '');
}
private getUserAvatarUrl(username: string) {
const avatarUrl = getUserAvatarURL(username)!;
if (!avatarUrl.startsWith('http') && !avatarUrl.startsWith('data')) {
return `${this._baseURL}${avatarUrl}`;
}
return avatarUrl;
}
async getClientRoomInfo(): Promise<IExternalComponentRoomInfo> {
const room = RoomManager.opened ? Rooms.state.get(RoomManager.opened) : undefined;
if (!room) {
throw new Error('Room not found');
}
const { name: slugifiedName, _id: id } = room;
let cachedMembers: IExternalComponentUserInfo[] = [];
try {
const { members } = await sdk.rest.get('/v1/groups.members', { roomId: id });
cachedMembers = members.map(({ _id, username }): IExternalComponentUserInfo => ({
id: _id,
username: username!,
avatarUrl: this.getUserAvatarUrl(username!),
}));
} catch (error) {
console.warn(error);
}
return {
id,
slugifiedName: slugifiedName!,
members: cachedMembers,
};
}
async getClientUserInfo(): Promise<IExternalComponentUserInfo> {
const { username, _id } = getUser()!;
return {
id: _id,
username: username!,
avatarUrl: this.getUserAvatarUrl(username!) || '',
};
}
}

View File

@@ -0,0 +1,43 @@
import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useState } from 'react';
import type { MouseEvent } from 'react';
import GameCenterContainer from './GameCenterContainer';
import GameCenterList from './GameCenterList';
import { useExternalComponentsQuery } from './hooks/useExternalComponentsQuery';
import { preventSyntheticEvent } from '../../lib/utils/preventSyntheticEvent';
export type IGame = IExternalComponent;
const GameCenter = () => {
const [openedGame, setOpenedGame] = useState<IGame>();
const { closeTab } = useRoomToolbox();
const result = useExternalComponentsQuery();
const handleClose = useStableCallback(() => closeTab());
const handleBack = useStableCallback((e: MouseEvent) => {
setOpenedGame(undefined);
preventSyntheticEvent(e);
});
if (!openedGame) {
return (
<GameCenterList
data-testid='game-center-list'
handleClose={handleClose}
handleOpenGame={setOpenedGame}
games={result.data}
isLoading={result.isPending}
/>
);
}
return <GameCenterContainer data-testid='game-center-container' handleBack={handleBack} handleClose={handleClose} game={openedGame} />;
};
export default GameCenter;

View File

@@ -0,0 +1,39 @@
import { Avatar } from '@rocket.chat/fuselage';
import {
ContextualbarTitle,
ContextualbarHeader,
ContextualbarBack,
ContextualbarContent,
ContextualbarClose,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import type { IGame } from './GameCenter';
interface IGameCenterContainerProps {
handleClose: () => void;
handleBack: (e: any) => void;
game: IGame;
}
const GameCenterContainer = ({ handleClose, handleBack, game }: IGameCenterContainerProps) => {
const { t } = useTranslation();
return (
<ContextualbarDialog>
<ContextualbarHeader>
{handleBack && <ContextualbarBack onClick={handleBack} />}
<ContextualbarTitle>
<Avatar url={game.icon} /> {game.name}
</ContextualbarTitle>
{handleClose && <ContextualbarClose onClick={handleClose} />}
</ContextualbarHeader>
<ContextualbarContent paddingBlock={16}>
<iframe title={t('Apps_Game_Center')} style={{ position: 'absolute', width: '95%', height: '80%' }} src={game.url} />
</ContextualbarContent>
</ContextualbarDialog>
);
};
export default GameCenterContainer;

View File

@@ -0,0 +1,63 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { Random } from '@rocket.chat/random';
import { GenericModal } from '@rocket.chat/ui-client';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { IGame } from './GameCenter';
import UserAutoCompleteMultiple from '../../components/UserAutoCompleteMultiple';
import { useOpenedRoom } from '../../lib/RoomManager';
import { sdk } from '../../lib/SDKClient';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
type Username = Exclude<IUser['username'], undefined>;
interface IGameCenterInvitePlayersModalProps {
game: IGame;
onClose: () => void;
}
const GameCenterInvitePlayersModal = ({ game, onClose }: IGameCenterInvitePlayersModalProps) => {
const { t } = useTranslation();
const [users, setUsers] = useState<Array<Username>>([]);
const { name } = game;
const openedRoom = useOpenedRoom();
const sendInvite = async () => {
const privateGroupName = `${name.replace(/\s/g, '-')}-${Random.id(10)}`;
try {
const { group } = await sdk.rest.post('/v1/groups.create', { name: privateGroupName, members: users });
roomCoordinator.openRouteLink(group.t, { rid: group._id, name: group.name });
if (openedRoom === group._id) {
await sdk.rest.post('/v1/chat.sendMessage', {
message: {
_id: Random.id(),
rid: group._id,
msg: t('Apps_Game_Center_Play_Game_Together', { name }),
},
});
}
onClose();
} catch (err) {
console.warn(err);
}
};
return (
<>
<GenericModal onClose={onClose} onCancel={onClose} onConfirm={sendInvite} title={t('Apps_Game_Center_Invite_Friends')}>
<Box marginBlockEnd={16}>{t('Invite_Users')}</Box>
<Box marginBlockEnd={16} display='flex' justifyContent='stretch'>
<UserAutoCompleteMultiple value={users} onChange={setUsers} federated />
</Box>
</GenericModal>
</>
);
};
export default GameCenterInvitePlayersModal;

View File

@@ -0,0 +1,91 @@
import { Avatar, Icon, Table, TableBody, TableCell, TableHead, TableRow } from '@rocket.chat/fuselage';
import {
ContextualbarHeader,
ContextualbarTitle,
ContextualbarClose,
ContextualbarContent,
ContextualbarDialog,
ContextualbarSkeleton,
} from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { IGame } from './GameCenter';
import GameCenterInvitePlayersModal from './GameCenterInvitePlayersModal';
interface IGameCenterListProps {
handleClose: () => void;
handleOpenGame: (game: IGame) => void;
games?: IGame[];
isLoading: boolean;
}
const GameCenterList = ({ handleClose, handleOpenGame, games, isLoading }: IGameCenterListProps) => {
const { t } = useTranslation();
const setModal = useSetModal();
const handleInvitePlayer = useCallback(
(game: IGame) => {
const handleClose = (): void => {
setModal(null);
};
setModal(<GameCenterInvitePlayersModal onClose={handleClose} game={game} />);
},
[setModal],
);
if (isLoading) {
return <ContextualbarSkeleton />;
}
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarTitle>{t('Apps_Game_Center')}</ContextualbarTitle>
{handleClose && <ContextualbarClose onClick={handleClose} />}
</ContextualbarHeader>
<ContextualbarContent>
{games && (
<div>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('Name')}</TableCell>
<TableCell>{t('Description')}</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{games.map((game, key) => (
<TableRow key={key} action onKeyDown={() => handleOpenGame(game)} onClick={() => handleOpenGame(game)}>
<TableCell>
<Avatar url={game.icon} /> {game.name}
</TableCell>
<TableCell>{game.description}</TableCell>
<TableCell>
<Icon
onKeyDown={(e) => {
e.stopPropagation();
handleInvitePlayer(game);
}}
onClick={(e) => {
e.stopPropagation();
handleInvitePlayer(game);
}}
name='plus'
title={t('Apps_Game_Center_Invite_Friends')}
></Icon>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</ContextualbarContent>
</ContextualbarDialog>
);
};
export default memo(GameCenterList);

View File

@@ -0,0 +1,15 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useExternalComponentsQuery = () => {
const getExternalComponents = useEndpoint('GET', '/apps/externalComponents');
return useQuery({
queryKey: ['apps/external-components'],
queryFn: async ({ signal }) => {
return (await getExternalComponents(undefined, { signal })).externalComponents;
},
staleTime: 10_000,
});
};

View File

@@ -0,0 +1,184 @@
import { AppClientManager } from '@rocket.chat/apps/dist/client/AppClientManager';
import type { AppsEngineUIHost } from '@rocket.chat/apps/dist/client/AppsEngineUIHost';
import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import type { Serialized } from '@rocket.chat/core-typings';
import { RealAppsEngineUIHost } from './RealAppsEngineUIHost';
import type { IAppExternalURL } from '../definitions/IAppExternalURL';
import type { ICategory } from '../definitions/ICategory';
import { sdk } from '../lib/SDKClient';
import { hasAtLeastOnePermission } from '../lib/authorization';
import { dispatchToastMessage } from '../lib/toast';
import type { App } from '../views/marketplace/types';
const isErrorObject = (e: unknown): e is { error: string } =>
typeof e === 'object' && e !== null && 'error' in e && typeof e.error === 'string';
class AppClientOrchestrator {
private _appClientUIHost: AppsEngineUIHost;
private _manager: AppClientManager;
private _isLoaded: boolean;
constructor() {
this._appClientUIHost = new RealAppsEngineUIHost();
this._manager = new AppClientManager(this._appClientUIHost);
this._isLoaded = false;
}
public async load(): Promise<void> {
if (!this._isLoaded) {
this._isLoaded = true;
}
}
public getAppClientManager(): AppClientManager {
return this._manager;
}
public handleError(error: unknown): void {
if (hasAtLeastOnePermission(['manage-apps'])) {
dispatchToastMessage({
type: 'error',
message: error,
});
}
}
public async getInstalledApps(): Promise<App[]> {
const result = await sdk.rest.get<'/apps/installed'>('/apps/installed', { includeClusterStatus: 'true' });
if ('apps' in result) {
// TODO: chapter day: multiple results are returned, but we only need one
return result.apps;
}
throw new Error('Invalid response from API');
}
public async getAppsFromMarketplace(isAdminUser?: boolean): Promise<{ apps: App[]; error?: unknown }> {
let result: App[] = [];
try {
result = await sdk.rest.get('/apps/marketplace', { isAdminUser: isAdminUser ? isAdminUser.toString() : 'false' });
} catch (e) {
if (isErrorObject(e)) {
return { apps: [], error: e.error };
}
if (typeof e === 'string') {
return { apps: [], error: e };
}
}
if (!Array.isArray(result)) {
// TODO: chapter day: multiple results are returned, but we only need one
return { apps: [], error: 'Invalid response from API' };
}
const apps = result.map((app: App) => {
const { latest, appRequestStats, price, pricingPlans, purchaseType, isEnterpriseOnly, modifiedAt, bundledIn, requestedEndUser } = app;
return {
...latest,
appRequestStats,
price,
pricingPlans,
purchaseType,
isEnterpriseOnly,
modifiedAt,
bundledIn,
requestedEndUser,
};
});
return { apps, error: undefined };
}
public async getAppsOnBundle(bundleId: string): Promise<App[]> {
const { apps } = await sdk.rest.get(`/apps/bundles/${bundleId}/apps`);
return apps;
}
public async getApp(appId: string): Promise<App> {
const { app } = await sdk.rest.get(`/apps/${appId}` as any);
return app;
}
public async setAppSettings(appId: string, settings: ISetting[]): Promise<void> {
await sdk.rest.post(`/apps/${appId}/settings`, { settings });
}
public async installApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise<App> {
const { app } = (await sdk.rest.post<'/apps'>('/apps', {
appId,
marketplace: true,
version,
permissionsGranted,
})) as { app: App };
return app;
}
public async updateApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise<App> {
const result = await sdk.rest.post<'/apps/:id'>(`/apps/${appId}`, {
appId,
marketplace: true,
version,
permissionsGranted,
});
if ('app' in result) {
return result.app;
}
throw new Error('App not found');
}
public async buildExternalUrl(appId: string, purchaseType: 'buy' | 'subscription' = 'buy', details = false): Promise<IAppExternalURL> {
const result = await sdk.rest.get('/apps/buildExternalUrl', {
appId,
purchaseType,
details: `${details}`,
});
if ('url' in result) {
return result;
}
throw new Error('Failed to build external url');
}
public async buildExternalAppRequest(appId: string) {
const result = await sdk.rest.get('/apps/buildExternalAppRequest', {
appId,
});
if ('url' in result) {
return result;
}
throw new Error('Failed to build App Request external url');
}
public async buildIncompatibleExternalUrl(appId: string, appVersion: string, action: string): Promise<IAppExternalURL> {
const result = await sdk.rest.get('/apps/incompatibleModal', {
appId,
appVersion,
action,
});
if ('url' in result) {
return result;
}
throw new Error('Failed to build external url');
}
public async getCategories(): Promise<Serialized<ICategory[]>> {
const result = await sdk.rest.get('/apps/categories');
if (Array.isArray(result)) {
// TODO: chapter day: multiple results are returned, but we only need one
return result;
}
throw new Error('Failed to get categories');
}
}
export const AppClientOrchestratorInstance = new AppClientOrchestrator();

View File

@@ -0,0 +1,10 @@
import type { IPermission } from '@rocket.chat/core-typings';
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
import { Permissions } from '../stores';
export const PermissionsCachedStore = new PrivateCachedStore<IPermission>({
name: 'permissions',
eventType: 'notify-logged',
store: Permissions.use,
});

View File

@@ -0,0 +1,31 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { sdk } from '../lib/SDKClient';
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
import { PrivateSettings } from '../stores';
class PrivateSettingsCachedStore extends PrivateCachedStore<ISetting> {
constructor() {
super({
name: 'private-settings',
eventType: 'notify-logged',
store: PrivateSettings.use,
});
}
override setupListener() {
return sdk.stream('notify-logged', ['private-settings-changed'], async (t, setting) => {
this.log('record received', t, setting);
const { _id, ...fields } = setting;
this.store.getState().update(
(record) => record._id === _id,
(record) => ({ ...record, ...fields }),
);
this.sync();
});
}
}
const instance = new PrivateSettingsCachedStore();
export { instance as PrivateSettingsCachedStore };

View File

@@ -0,0 +1,18 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { PublicCachedStore } from '../lib/cachedStores/CachedStore';
import { PublicSettings } from '../stores';
class PublicSettingsCachedStore extends PublicCachedStore<ISetting> {
constructor() {
super({
name: 'public-settings',
eventType: 'notify-all',
store: PublicSettings.use,
});
}
}
const instance = new PublicSettingsCachedStore();
export { instance as PublicSettingsCachedStore };

View File

@@ -0,0 +1,119 @@
import type { IOmnichannelRoom, IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import { DEFAULT_SLA_CONFIG, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
import { Rooms, Subscriptions } from '../stores';
class RoomsCachedStore extends PrivateCachedStore<IRoom> {
constructor() {
super({
name: 'rooms',
eventType: 'notify-user',
store: Rooms.use,
});
}
private merge(room: IRoom, sub: SubscriptionWithRoom): SubscriptionWithRoom {
return {
...sub,
encrypted: room.encrypted,
description: room.description,
cl: room.cl,
topic: room.topic,
announcement: room.announcement,
broadcast: room.broadcast || undefined,
archived: room.archived,
avatarETag: room.avatarETag,
retention: (room as IRoomWithRetentionPolicy | undefined)?.retention,
uids: room.uids,
usernames: room.usernames,
usersCount: room.usersCount,
lastMessage: room.lastMessage,
teamId: room.teamId,
teamMain: room.teamMain,
v: (room as IOmnichannelRoom | undefined)?.v,
transcriptRequest: (room as IOmnichannelRoom | undefined)?.transcriptRequest,
servedBy: (room as IOmnichannelRoom | undefined)?.servedBy,
onHold: (room as IOmnichannelRoom | undefined)?.onHold,
tags: (room as IOmnichannelRoom | undefined)?.tags,
closedAt: (room as IOmnichannelRoom | undefined)?.closedAt,
metrics: (room as IOmnichannelRoom | undefined)?.metrics,
muted: room.muted,
waitingResponse: (room as IOmnichannelRoom | undefined)?.waitingResponse,
responseBy: (room as IOmnichannelRoom | undefined)?.responseBy,
priorityId: (room as IOmnichannelRoom | undefined)?.priorityId,
priorityWeight: (room as IOmnichannelRoom | undefined)?.priorityWeight || LivechatPriorityWeight.NOT_SPECIFIED,
estimatedWaitingTimeQueue:
(room as IOmnichannelRoom | undefined)?.estimatedWaitingTimeQueue || DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE,
slaId: (room as IOmnichannelRoom | undefined)?.slaId,
livechatData: (room as IOmnichannelRoom | undefined)?.livechatData,
departmentId: (room as IOmnichannelRoom | undefined)?.departmentId,
ts: room.ts ?? sub.ts,
source: (room as IOmnichannelRoom | undefined)?.source,
queuedAt: (room as IOmnichannelRoom | undefined)?.queuedAt,
federated: room.federated,
abacAttributes: room.abacAttributes,
...(isRoomNativeFederated(room) && {
federation: room.federation,
}),
...(() => {
const name = room.name || sub.name;
const fname = room.fname || sub.fname || name;
return {
lowerCaseName: String(!room.prid ? name : fname).toLowerCase(),
lowerCaseFName: String(fname).toLowerCase(),
};
})(),
lm: (sub.lm?.getTime() ?? -1) < (room.lm?.getTime() ?? -1) ? room.lm : sub.lm,
};
}
protected override handleLoadedFromServer(rooms: IRoom[]): void {
const indexedSubscriptions = Subscriptions.use.getState().indexBy('rid');
const subscriptionsWithRoom = rooms.flatMap((room) => {
const sub = indexedSubscriptions.get(room._id);
if (!sub) return [];
return this.merge(room, sub);
});
Subscriptions.use.getState().storeMany(subscriptionsWithRoom);
}
protected override async handleRecordEvent(action: 'removed' | 'changed', room: IRoom) {
await super.handleRecordEvent(action, room);
if (action === 'removed') return;
Subscriptions.use.getState().update(
(record) => record.rid === room._id,
(sub) => this.merge(room, sub),
);
}
protected override handleSyncEvent(action: 'removed' | 'changed', room: IRoom): void {
if (action === 'removed') return;
Subscriptions.use.getState().update(
(record) => record.rid === room._id,
(sub) => this.merge(room, sub),
);
}
protected override deserializeFromCache(record: unknown) {
const deserialized = super.deserializeFromCache(record);
if (deserialized?.lastMessage?._updatedAt) {
deserialized.lastMessage._updatedAt = new Date(deserialized.lastMessage._updatedAt);
}
return deserialized;
}
}
const instance = new RoomsCachedStore();
export { instance as RoomsCachedStore };

View File

@@ -0,0 +1,95 @@
import type { IOmnichannelRoom, IRoomWithRetentionPolicy, ISubscription } from '@rocket.chat/core-typings';
import { DEFAULT_SLA_CONFIG, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
import { Rooms, Subscriptions } from '../stores';
class SubscriptionsCachedStore extends PrivateCachedStore<SubscriptionWithRoom, ISubscription> {
constructor() {
super({
name: 'subscriptions',
eventType: 'notify-user',
store: Subscriptions.use,
});
}
protected override mapRecord(subscription: ISubscription): SubscriptionWithRoom {
const room = Rooms.use.getState().find((r) => r._id === subscription.rid);
const lastRoomUpdate = room?.lm || subscription.ts || room?.ts;
return {
...subscription,
...(() => {
const { name } = subscription;
const fname = subscription.fname || name;
return {
lowerCaseName: String(!subscription.prid ? name : fname).toLowerCase(),
lowerCaseFName: String(fname).toLowerCase(),
};
})(),
encrypted: room?.encrypted,
description: room?.description,
cl: room?.cl,
topic: room?.topic,
announcement: room?.announcement,
broadcast: room?.broadcast || undefined,
archived: room?.archived,
avatarETag: room?.avatarETag,
retention: (room as IRoomWithRetentionPolicy | undefined)?.retention,
lastMessage: room?.lastMessage,
teamId: room?.teamId,
teamMain: room?.teamMain,
uids: room?.uids,
usernames: room?.usernames,
usersCount: room?.usersCount ?? 0,
v: (room as IOmnichannelRoom | undefined)?.v,
transcriptRequest: (room as IOmnichannelRoom | undefined)?.transcriptRequest,
servedBy: (room as IOmnichannelRoom | undefined)?.servedBy,
onHold: (room as IOmnichannelRoom | undefined)?.onHold,
tags: (room as IOmnichannelRoom | undefined)?.tags,
closedAt: (room as IOmnichannelRoom | undefined)?.closedAt,
metrics: (room as IOmnichannelRoom | undefined)?.metrics,
muted: room?.muted,
waitingResponse: (room as IOmnichannelRoom | undefined)?.waitingResponse,
responseBy: (room as IOmnichannelRoom | undefined)?.responseBy,
priorityId: (room as IOmnichannelRoom | undefined)?.priorityId,
slaId: (room as IOmnichannelRoom | undefined)?.slaId,
priorityWeight: (room as IOmnichannelRoom | undefined)?.priorityWeight || LivechatPriorityWeight.NOT_SPECIFIED,
estimatedWaitingTimeQueue:
(room as IOmnichannelRoom | undefined)?.estimatedWaitingTimeQueue || DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE,
livechatData: (room as IOmnichannelRoom | undefined)?.livechatData,
departmentId: (room as IOmnichannelRoom | undefined)?.departmentId,
ts: room?.ts ?? subscription.ts,
source: (room as IOmnichannelRoom | undefined)?.source,
queuedAt: (room as IOmnichannelRoom | undefined)?.queuedAt,
federated: room?.federated,
...(room &&
isRoomNativeFederated(room) && {
federation: room.federation,
}),
lm: subscription.lr ? new Date(Math.max(subscription.lr.getTime(), lastRoomUpdate?.getTime() || 0)) : lastRoomUpdate,
};
}
async upsertSubscription(record: ISubscription): Promise<void> {
return this.handleRecordEvent('changed', record);
}
protected override deserializeFromCache(record: unknown) {
const deserialized = super.deserializeFromCache(record);
if (deserialized?.lastMessage?._updatedAt) {
deserialized.lastMessage._updatedAt = new Date(deserialized.lastMessage._updatedAt);
}
return deserialized;
}
}
const instance = new SubscriptionsCachedStore();
export { instance as SubscriptionsCachedStore };

View File

@@ -0,0 +1,5 @@
export { PermissionsCachedStore } from './PermissionsCachedStore';
export { PrivateSettingsCachedStore } from './PrivateSettingsCachedStore';
export { PublicSettingsCachedStore } from './PublicSettingsCachedStore';
export { RoomsCachedStore } from './RoomsCachedStore';
export { SubscriptionsCachedStore } from './SubscriptionsCachedStore';

View File

@@ -0,0 +1,110 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import ABACUpsellModal from './ABACUpsellModal';
import { createFakeLicenseInfo } from '../../../../tests/mocks/data';
jest.mock('../../GenericUpsellModal/hooks', () => ({
useUpsellActions: jest.fn(() => ({
shouldShowUpsell: true,
cloudWorkspaceHadTrial: false,
handleManageSubscription: jest.fn(),
handleTalkToSales: jest.fn(),
})),
}));
// Mock getURL utility
jest.mock('../../../../client/lib/getURL', () => ({
getURL: (url: string) => url,
}));
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Premium_capability: 'Premium capability',
Attribute_based_access_control: 'Attribute-Based Access Control',
Attribute_based_access_control_title: 'Automate complex access management across your entire organization',
Attribute_based_access_control_description:
'ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.',
Upgrade: 'Upgrade',
Cancel: 'Cancel',
})
.withEndpoint('GET', '/v1/licenses.info', async () => ({
license: createFakeLicenseInfo(),
}))
.build();
describe('ABACUpsellModal', () => {
const mockOnClose = jest.fn();
const mockOnConfirm = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the modal with correct content', () => {
const { baseElement } = render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
expect(baseElement).toMatchSnapshot();
});
it('should have no accessibility violations', async () => {
const { container } = render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should call onConfirm when upgrade button is clicked', async () => {
const user = userEvent.setup();
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
const upgradeButton = screen.getByRole('button', { name: 'Upgrade' });
await user.click(upgradeButton);
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled();
});
it('should call onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
it('should call onClose when close button is clicked', async () => {
const user = userEvent.setup();
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
// Look for close button (usually has aria-label or is the X button)
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
it('should handle multiple button clicks correctly', async () => {
const user = userEvent.setup();
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
const upgradeButton = screen.getByRole('button', { name: 'Upgrade' });
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
// Click upgrade first
await user.click(upgradeButton);
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
// Click cancel
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
// Total calls
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,42 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta } from '@storybook/react';
import { action } from 'storybook/actions';
import ABACUpsellModal from './ABACUpsellModal';
const meta = {
component: ABACUpsellModal,
parameters: {
layout: 'centered',
},
args: {
onClose: action('onClose'),
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot()
.withTranslations('en', 'core', {
Attribute_based_access_control: 'Attribute-Based Access Control',
Attribute_based_access_control_title: 'Automate complex access management across your entire organization',
Attribute_based_access_control_description:
'ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.',
})
.build();
return (
<AppRoot>
<Story />
</AppRoot>
);
},
],
} satisfies Meta<typeof ABACUpsellModal>;
export default meta;
export const Default = {
args: {
onClose: action('onClose'),
onConfirm: action('onConfirm'),
},
};

View File

@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { getURL } from '../../../lib/getURL';
import GenericUpsellModal from '../../GenericUpsellModal';
export type ABACUpsellModalProps = {
onClose: () => void;
onConfirm: () => void;
};
const ABACUpsellModal = ({ onClose, onConfirm }: ABACUpsellModalProps) => {
const { t } = useTranslation();
return (
<GenericUpsellModal
tagline={t('Premium_capability')}
title={t('Attribute_based_access_control')}
subtitle={t('Attribute_based_access_control_title')}
description={t('Attribute_based_access_control_description')}
img={getURL('images/abac-upsell-modal.svg')}
onClose={onClose}
onConfirm={onConfirm}
onCancel={onClose}
imgHeight={256}
/>
);
};
export default ABACUpsellModal;

View File

@@ -0,0 +1,112 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ABACUpsellModal should render the modal with correct content 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
Attribute-Based Access Control
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt=""
class="rcx-box rcx-box--full rcx-modal__hero-image rcx-css-1qfl5uy"
src="images/abac-upsell-modal.svg"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
Automate complex access management across your entire organization
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@@ -0,0 +1,53 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager';
const ActionManagerBusyState = () => {
const { t } = useTranslation();
const actionManager = useUiKitActionManager();
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!actionManager) {
return;
}
const handleBusyStateChange = ({ busy }: { busy: boolean }) => setBusy(busy);
actionManager.on('busy', handleBusyStateChange);
return () => {
actionManager.off('busy', handleBusyStateChange);
};
}, [actionManager]);
if (busy) {
return (
<Box
className={css`
transform: translateX(-50%);
pointer-events: none;
`}
position='absolute'
insetInlineStart='50%'
padding={16}
backgroundColor='tint'
color='default'
textAlign='center'
fontSize='p2'
elevation='2'
borderRadius='0 0 4px 4px'
zIndex={99999}
>
{t('Loading')}
</Box>
);
}
return null;
};
export default ActionManagerBusyState;

View File

@@ -0,0 +1,31 @@
import { PaletteStyleTag } from '@rocket.chat/fuselage';
import { useDarkMode } from '@rocket.chat/fuselage-hooks';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
export type AppLayoutThemeWrapperProps = { children: ReactNode };
const AppLayoutThemeWrapper = ({ children }: AppLayoutThemeWrapperProps) => {
const dark = useDarkMode();
/*
* Paint the root canvas so mobile browsers fill the overscroll/safe-area regions
* (and tint their toolbars) with the theme background instead of falling back to white.
*/
useEffect(() => {
document.documentElement.style.setProperty('background-color', 'var(--rcx-color-surface-tint)');
return () => {
document.documentElement.style.removeProperty('background-color');
};
}, []);
return (
<>
<PaletteStyleTag theme={dark ? 'dark' : 'light'} tagId='app-layout-palette' />
{children}
</>
);
};
export default AppLayoutThemeWrapper;

View File

@@ -0,0 +1,27 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Button } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useIdleActiveEvents } from '../hooks/useIdleActiveEvents';
export const AutoupdateToastMessage = () => {
const { t } = useTranslation();
useIdleActiveEvents({ id: 'autoupdate', awayOnWindowBlur: true }, () => {
window.location.reload();
});
return (
<Box
display='flex'
alignItems='center'
className={css`
gap: 8px;
`}
>
{t('An_update_is_available')}
<Button primary small onClick={() => window.location.reload()}>
{t('Reload_to_update')}
</Button>
</Box>
);
};

View File

@@ -0,0 +1,6 @@
import { ModalBackdrop } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export type BackdropProps = ComponentProps<typeof ModalBackdrop>;
export const Backdrop = (props: BackdropProps) => <ModalBackdrop backgroundColor='transparent' {...props} />;

View File

@@ -0,0 +1,89 @@
import { Box } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import type { ComponentPropsWithoutRef } from 'react';
import { Trans } from 'react-i18next';
export type ConfirmOwnerChangeModalProps = {
shouldChangeOwner: string[];
shouldBeRemoved: string[];
contentTitle?: string;
} & Pick<ComponentPropsWithoutRef<typeof GenericModal>, 'onConfirm' | 'onCancel' | 'confirmText'>;
const ConfirmOwnerChangeModal = ({
shouldChangeOwner,
shouldBeRemoved,
contentTitle,
confirmText,
onConfirm,
onCancel,
}: ConfirmOwnerChangeModalProps) => {
const getChangeOwnerRooms = useStableCallback(() => {
if (shouldChangeOwner.length === 0) {
return '';
}
if (shouldChangeOwner.length === 1) {
return (
<Trans
i18nKey='A_new_owner_will_be_assigned_automatically_to_the__roomName__room'
values={{ roomName: shouldChangeOwner[0] }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
);
}
if (shouldChangeOwner.length <= 5) {
return (
<Trans
i18nKey='A_new_owner_will_be_assigned_automatically_to_those__count__rooms__rooms__'
values={{ count: shouldChangeOwner.length, rooms: shouldChangeOwner.join(', ') }}
components={{ br: <br />, bold: <Box is='span' fontWeight='bold' /> }}
/>
);
}
return (
<Trans
i18nKey='A_new_owner_will_be_assigned_automatically_to__count__rooms'
values={{ count: shouldChangeOwner.length }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
);
});
const getRemovedRooms = useStableCallback(() => {
if (shouldBeRemoved.length === 0) {
return '';
}
if (shouldBeRemoved.length === 1) {
return (
<Trans
i18nKey='The_empty_room__roomName__will_be_removed_automatically'
values={{ roomName: shouldBeRemoved[0] }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
);
}
if (shouldBeRemoved.length <= 5) {
return (
<Trans
i18nKey='__count__empty_rooms_will_be_removed_automatically__rooms__'
values={{ count: shouldBeRemoved.length, rooms: shouldBeRemoved.join(', ') }}
components={{ br: <br />, bold: <Box is='span' fontWeight='bold' /> }}
/>
);
}
return <Trans i18nKey='__count__empty_rooms_will_be_removed_automatically' values={{ count: shouldBeRemoved.length }} />;
});
return (
<GenericModal variant='danger' onClose={onCancel} onCancel={onCancel} confirmText={confirmText} onConfirm={onConfirm}>
{contentTitle}
{shouldChangeOwner && <Box marginBlock={16}>{getChangeOwnerRooms()}</Box>}
{shouldBeRemoved && <Box marginBlock={16}>{getRemovedRooms()}</Box>}
</GenericModal>
);
};
export default ConfirmOwnerChangeModal;

View File

@@ -0,0 +1,97 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import CreateDiscussion from './CreateDiscussion';
import * as stories from './CreateDiscussion.stories';
import { createFakeRoom } from '../../../tests/mocks/data';
jest.mock('../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomDirectives: () => ({
getRoomName: () => 'General',
}),
},
}));
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
const room1 = createFakeRoom({ encrypted: true, fname: 'Encrypted Room 1' });
const room2 = createFakeRoom({ encrypted: false, fname: 'Unencrypted Room 2' });
const appRoot = mockAppRoot()
.withEndpoint('POST', '/v1/rooms.createDiscussion', () => ({
success: true,
discussion: {
...createFakeRoom({
_id: 'discussion-id',
t: 'p' as const,
name: 'discussion-name',
fname: 'Discussion Name',
prid: 'parent-room-id',
}),
rid: 'discussion-id',
} as any,
}))
.withEndpoint(
'GET',
'/v1/rooms.autocomplete.channelAndPrivate',
() =>
({
items: [room1, room2],
}) as any,
)
.withTranslations('en', 'core', {
Encrypted: 'Encrypted',
Discussion_first_message_title: 'Message',
Discussion_target_channel: 'Parent channel or team',
})
.build();
describe('CreateDiscussion', () => {
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />, { wrapper: appRoot });
expect(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();
});
describe('Encrypted parent room behavior', () => {
it('should disable encrypted toggle and first message field when parent room is encrypted', () => {
render(<CreateDiscussion onClose={jest.fn()} encryptedParentRoom={true} />, { wrapper: appRoot });
const encryptedToggle = screen.getByRole('checkbox', { name: 'Encrypted' });
expect(encryptedToggle).toBeDisabled();
const firstMessageField = screen.getByLabelText('Message');
expect(firstMessageField).toBeDisabled();
});
it('should disable encrypted toggle and first message field when an encrypted parent room is selected', async () => {
render(<CreateDiscussion onClose={jest.fn()} />, { wrapper: appRoot });
const parentRoomSelect = screen.getByRole('textbox', { name: 'Parent channel or team' });
await userEvent.click(parentRoomSelect);
await waitFor(() => {
expect(screen.getByText('Encrypted Room 1')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Encrypted Room 1'));
const encryptedToggle = screen.getByRole('checkbox', { name: 'Encrypted' });
expect(encryptedToggle).toBeDisabled();
const firstMessageField = screen.getByLabelText('Message');
expect(firstMessageField).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,16 @@
import type { StoryObj, Meta } from '@storybook/react';
import CreateDiscussion from './CreateDiscussion';
export default {
component: CreateDiscussion,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
args: {
onClose: () => undefined,
},
} satisfies Meta<typeof CreateDiscussion>;
export const Default: StoryObj<typeof CreateDiscussion> = {};

View File

@@ -0,0 +1,222 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { Icon, Box } from '@rocket.chat/fuselage';
import {
Field,
FieldGroup,
ToggleSwitch,
TextInput,
TextAreaInput,
FieldHint,
FieldLabel,
FieldRow,
FieldError,
} from '@rocket.chat/fuselage-forms';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useEncryptedRoomDescription } from '../../navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription';
import { useGoToRoom } from '../../views/room/hooks/useGoToRoom';
import RoomAutoComplete from '../RoomAutoComplete';
import UserAutoCompleteMultiple from '../UserAutoCompleteMultiple';
import DefaultParentRoomField from './DefaultParentRoomField';
type CreateDiscussionFormValues = {
name: string;
parentRoom: IRoom['_id'];
encrypted: boolean;
usernames: Array<IUser['username']>;
firstMessage: string;
topic: string;
};
export type CreateDiscussionProps = {
parentMessageId?: IMessage['_id'];
encryptedParentRoom?: boolean;
onClose: () => void;
onSuccess?: (rid: string) => void | Promise<void>;
defaultParentRoom?: IRoom['_id'];
nameSuggestion?: string;
};
const CreateDiscussion = ({
onClose,
onSuccess,
defaultParentRoom,
parentMessageId,
nameSuggestion,
encryptedParentRoom = false,
}: CreateDiscussionProps) => {
const t = useTranslation();
const [encryptedDisabled, setEncryptedDisabled] = useState(encryptedParentRoom);
const {
formState: { errors },
handleSubmit,
control,
watch,
setValue,
} = useForm({
defaultValues: {
name: nameSuggestion || '',
parentRoom: '',
encrypted: encryptedParentRoom,
usernames: [],
firstMessage: '',
topic: '',
},
});
const onParentRoomChange = useStableCallback((room: IRoom | undefined) => {
if (!room) {
return;
}
setValue('encrypted', room.encrypted === true);
setEncryptedDisabled(room.encrypted === true);
});
const { encrypted } = watch();
const createDiscussion = useEndpoint('POST', '/v1/rooms.createDiscussion');
const goToRoom = useGoToRoom();
const dispatchToastMessage = useToastMessageDispatch();
const createDiscussionMutation = useMutation({
mutationFn: createDiscussion,
onSuccess: ({ discussion }) => {
goToRoom(discussion._id);
void onSuccess?.(discussion._id);
onClose();
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
const handleCreate = async ({ name, parentRoom, encrypted, usernames, firstMessage, topic }: CreateDiscussionFormValues) => {
createDiscussionMutation.mutate({
prid: defaultParentRoom || parentRoom,
t_name: name,
users: usernames,
reply: encrypted ? undefined : firstMessage,
topic,
...(parentMessageId && { pmid: parentMessageId }),
});
};
const getEncryptedHint = useEncryptedRoomDescription('discussion');
return (
<GenericModal
variant='warning'
icon={null}
title={t('Discussion_title')}
onCancel={onClose}
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleCreate)} {...props} />}
confirmText={t('Create')}
cancelText={t('Cancel')}
confirmLoading={createDiscussionMutation.isPending}
>
<Box marginBlockEnd={24}>{t('Discussion_description')}</Box>
<FieldGroup>
<Field>
<FieldLabel required>{t('Discussion_target_channel')}</FieldLabel>
<FieldRow>
{defaultParentRoom && (
<Controller
control={control}
name='parentRoom'
render={() => <DefaultParentRoomField defaultParentRoom={defaultParentRoom} />}
/>
)}
{!defaultParentRoom && (
<Controller
control={control}
name='parentRoom'
rules={{ required: t('Required_field', { field: t('Discussion_target_channel') }) }}
render={({ field }) => (
<RoomAutoComplete
{...field}
error={Boolean(errors.parentRoom?.message)}
placeholder={t('Search_options')}
disabled={Boolean(defaultParentRoom)}
aria-required='true'
setSelectedRoom={onParentRoomChange}
renderRoomIcon={({ encrypted }) => (encrypted ? <Icon name='key' /> : null)}
/>
)}
/>
)}
</FieldRow>
{errors.parentRoom && <FieldError>{errors.parentRoom.message}</FieldError>}
</Field>
<Field>
<FieldLabel required>{t('Name')}</FieldLabel>
<FieldRow>
<Controller
name='name'
control={control}
rules={{ required: t('Required_field', { field: t('Name') }) }}
render={({ field }) => (
<TextInput {...field} aria-required='true' endAddon={<Icon name='baloons' size='x20' />} error={errors.name?.message} />
)}
/>
</FieldRow>
{errors.name && <FieldError>{errors.name.message}</FieldError>}
</Field>
<Field>
<FieldLabel>{t('Topic')}</FieldLabel>
<FieldRow>
<Controller name='topic' control={control} render={({ field }) => <TextInput {...field} error={errors.topic?.message} />} />
</FieldRow>
<FieldRow>
<FieldHint>{t('Displayed_next_to_name')}</FieldHint>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Members')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='usernames'
render={({ field }) => <UserAutoCompleteMultiple {...field} placeholder={t('Add_people')} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Discussion_first_message_title')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='firstMessage'
render={({ field }) => <TextAreaInput {...field} rows={5} disabled={encrypted} />}
/>
</FieldRow>
{encrypted ? (
<FieldHint>{t('Discussion_first_message_disabled_due_to_e2e')}</FieldHint>
) : (
<FieldHint>{t('First_message_hint')}</FieldHint>
)}
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Encrypted')}</FieldLabel>
<Controller
control={control}
name='encrypted'
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} disabled={encryptedDisabled} />}
/>
</FieldRow>
<FieldHint>{getEncryptedHint({ isPrivate: true, encrypted })}</FieldHint>
</Field>
</FieldGroup>
</GenericModal>
);
};
export default CreateDiscussion;

View File

@@ -0,0 +1,54 @@
import { Skeleton, Callout } from '@rocket.chat/fuselage';
import { TextInput } from '@rocket.chat/fuselage-forms';
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentPropsWithoutRef } from 'react';
import { useMemo } from 'react';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
export type DefaultParentRoomFieldProps = {
defaultParentRoom: string;
} & Omit<ComponentPropsWithoutRef<typeof TextInput>, 'defaultValue' | 'disabled'>;
const DefaultParentRoomField = ({ defaultParentRoom, ...props }: DefaultParentRoomFieldProps) => {
const t = useTranslation();
const query = useMemo(
() => ({
roomId: defaultParentRoom,
}),
[defaultParentRoom],
);
const roomsInfoEndpoint = useEndpoint('GET', '/v1/rooms.info');
const { data, isPending, isError } = useQuery({
queryKey: ['defaultParentRoomInfo', query],
queryFn: async () => roomsInfoEndpoint(query),
refetchOnWindowFocus: false,
});
if (isPending) {
return <Skeleton width='full' />;
}
if (!data?.room || isError) {
return <Callout type='danger'>{t('Error')}</Callout>;
}
return (
<TextInput
{...props}
defaultValue={roomCoordinator.getRoomName(data.room.t, {
_id: data.room._id,
fname: data.room.fname,
name: data.room.name,
prid: data.room.prid,
})}
disabled
/>
);
};
export default DefaultParentRoomField;

View File

@@ -0,0 +1,347 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CreateDiscussion renders Default without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<form
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
Discussion_title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-heem01"
>
Discussion_description
</div>
<fieldset
class="rcx-box rcx-box--full rcx-field-group"
role="group"
>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<span
class="rcx-box rcx-box--full rcx-field__label rcx-label"
id="react-aria-_r_1_-label"
>
Parent channel or team
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
>
*
</span>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-box--animated rcx-autocomplete rcx-css-t3n91h"
>
<div
class="rcx-box rcx-box--full rcx-css-6d871f"
role="group"
>
<input
aria-describedby=""
aria-invalid="false"
aria-labelledby="react-aria-_r_1_-label"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-rcil7k"
id="react-aria-_r_1_"
name="parentRoom"
placeholder="Search_options"
value=""
/>
</div>
<div
class="rcx-box rcx-box--full rcx-autocomplete__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-l5txo2"
>
</i>
</div>
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_2_-label"
>
Name
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
>
*
</span>
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
aria-describedby=""
aria-invalid="false"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
id="react-aria-_r_2_-label"
name="name"
size="1"
type="text"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-baloons rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_3_-label"
>
Topic
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<input
aria-describedby="react-aria-_r_3_-hint"
aria-invalid="false"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
id="react-aria-_r_3_-label"
name="topic"
size="1"
type="text"
value=""
/>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<span
class="rcx-box rcx-box--full rcx-field__hint"
id="react-aria-_r_3_-hint"
>
Displayed_next_to_name
</span>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<span
class="rcx-box rcx-box--full rcx-field__label rcx-label"
id="react-aria-_r_4_-label"
>
Members
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-box--animated rcx-select"
>
<div
class="rcx-box rcx-box--full rcx-css-1sr8su7"
>
<div
class="rcx-box rcx-box--full rcx-css-w398ts"
>
<input
aria-controls="react-aria-_r_4_-listbox"
aria-describedby=""
aria-expanded="false"
aria-haspopup="listbox"
aria-invalid="false"
aria-labelledby="react-aria-_r_4_-label"
autocomplete="off"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-pqpp8q"
id="react-aria-_r_4_"
name="usernames"
placeholder="Add_people"
role="combobox"
value=""
/>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-select__addon rcx-css-x7bl3q rcx-css-1bprq55"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-4pvxx3"
>
</i>
</div>
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_5_-label"
>
Message
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<textarea
aria-describedby="react-aria-_r_5_-hint"
aria-invalid="false"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-textarea rcx-input-box"
cols="1"
id="react-aria-_r_5_-label"
name="firstMessage"
rows="5"
size="1"
/>
</span>
<span
class="rcx-box rcx-box--full rcx-field__hint"
id="react-aria-_r_5_-hint"
>
First_message_hint
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<span
class="rcx-box rcx-box--full rcx-field__label rcx-label"
>
Encrypted
</span>
<label
class="rcx-box rcx-box--full rcx-toggle-switch"
>
<div
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
Encrypted
</div>
<input
aria-describedby="react-aria-_r_6_-hint"
aria-invalid="false"
class="rcx-box rcx-box--full rcx-toggle-switch__input"
id="react-aria-_r_6_-label"
name="encrypted"
type="checkbox"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-toggle-switch__fake"
/>
</label>
</span>
<span
class="rcx-box rcx-box--full rcx-field__hint"
id="react-aria-_r_6_-hint"
>
Not_available_for_this_workspace
</span>
</div>
</fieldset>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="submit"
>
<span
class="rcx-button--content"
>
Create
</span>
</button>
</div>
</div>
</form>
</dialog>
</div>
</body>
`;

View File

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

View File

@@ -0,0 +1,35 @@
import styled from '@rocket.chat/styled';
import { getEmojiClassNameAndDataTitle } from '../lib/utils/renderEmoji';
export type EmojiProps = {
emojiHandle: string; // :emoji:
className?: string;
fillContainer?: boolean;
};
const EmojiComponent = styled('span', ({ fillContainer: _fillContainer, ...props }: { fillContainer?: boolean }) => props)`
${({ fillContainer }) =>
fillContainer
? `
display: inline-block;
width: 100%;
height: 100%;
margin: 0;`
: ''}
`;
function Emoji({ emojiHandle, className = undefined, fillContainer }: EmojiProps) {
const { className: emojiClassName, image, ...props } = getEmojiClassNameAndDataTitle(emojiHandle);
return (
<EmojiComponent
className={[emojiClassName, className, image ? 'rcx-message__emoji--custom' : ''].filter(Boolean).join(' ')}
style={image?.length ? { backgroundImage: image } : undefined}
fillContainer={fillContainer}
{...props}
/>
);
}
export default Emoji;

View File

@@ -0,0 +1,43 @@
import { Box, Icon, TextInput, Margins } from '@rocket.chat/fuselage';
import { useAutoFocus, useMergedRefs } from '@rocket.chat/fuselage-hooks';
import type { ChangeEvent, ComponentPropsWithoutRef, SubmitEvent } from 'react';
import { forwardRef, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
// TODO: consider changing the type of TextInput's `onChange` to (event: ChangeEvent<HTMLInputElement>) => void
export type FilterByTextProps = Omit<ComponentPropsWithoutRef<typeof TextInput>, 'onChange'> & {
shouldAutoFocus?: boolean;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
};
const FilterByText = forwardRef<HTMLInputElement, FilterByTextProps>(function FilterByText(
{ placeholder, shouldAutoFocus = false, children, ...props },
ref,
) {
const { t } = useTranslation();
const autoFocusRef = useAutoFocus(shouldAutoFocus);
const mergedRefs = useMergedRefs(ref, autoFocusRef);
const handleFormSubmit = useCallback((event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
}, []);
return (
<Box marginBlock={16} marginInline='neg-x4' is='form' onSubmit={handleFormSubmit} display='flex' flexWrap='wrap' alignItems='center'>
<Box marginInline={4} display='flex' flexGrow={1}>
<TextInput
{...props}
placeholder={placeholder ?? t('Search')}
ref={mergedRefs}
endAddon={<Icon name='magnifier' size='x20' />}
flexGrow={2}
minWidth='x220'
aria-label={placeholder ?? t('Search')}
/>
</Box>
{children && <Margins inline={4}>{children}</Margins>}
</Box>
);
});
export default memo(FilterByText);

View File

@@ -0,0 +1,38 @@
import { Box } from '@rocket.chat/fuselage';
import { ExternalLink, GenericModal } from '@rocket.chat/ui-client';
import { Trans, useTranslation } from 'react-i18next';
import { links } from '../lib/links';
export type FingerprintChangeModalProps = {
onConfirm: () => void;
onCancel: () => void;
onClose: () => void;
};
const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintChangeModalProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
title={t('Unique_ID_change_detected')}
onConfirm={onConfirm}
onClose={onClose}
onCancel={onCancel}
confirmText={t('Configuration_update')}
cancelText={t('New_workspace')}
>
<Box is='p' marginBlockEnd={16}>
<Trans i18nKey='Unique_ID_change_detected_description' />
</Box>
<Box is='p' marginBlockEnd={16}>
<Trans
i18nKey='Unique_ID_change_detected_learn_more_link'
components={{ a: <ExternalLink to={links.go.fingerPrintChangedFaq} /> }}
/>
</Box>
</GenericModal>
);
};
export default FingerprintChangeModal;

View File

@@ -0,0 +1,43 @@
import { Box } from '@rocket.chat/fuselage';
import { ExternalLink, GenericModal } from '@rocket.chat/ui-client';
import { Trans, useTranslation } from 'react-i18next';
import { links } from '../lib/links';
export type FingerprintChangeModalConfirmationProps = {
onConfirm: () => void;
onCancel: () => void;
onClose: () => void;
newWorkspace: boolean;
};
const FingerprintChangeModalConfirmation = ({ onConfirm, onCancel, onClose, newWorkspace }: FingerprintChangeModalConfirmationProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
title={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')}
onConfirm={onConfirm}
onCancel={onCancel}
cancelText={t('Back')}
confirmText={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')}
onClose={onClose}
>
<Box is='p' marginBlockEnd={16}>
{newWorkspace ? (
<Trans i18nKey='Confirm_new_workspace_description' />
) : (
<Trans i18nKey='Confirm_configuration_update_description' />
)}
</Box>
<Box is='p' marginBlockEnd={16}>
<Trans
i18nKey='Unique_ID_change_detected_learn_more_link'
components={{ a: <ExternalLink to={links.go.fingerPrintChangedFaq} /> }}
/>
</Box>
</GenericModal>
);
};
export default FingerprintChangeModalConfirmation;

View File

@@ -0,0 +1,229 @@
import { Markup } from '@rocket.chat/gazzodown';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import GazzodownText from './GazzodownText';
import { useMessageListHighlights } from './message/list/MessageListContext';
jest.mock('@rocket.chat/ui-client', () => ({
useFeaturePreview: () => false,
}));
jest.mock('@rocket.chat/fuselage-hooks', () => ({
useLocalStorage: () => ['en', jest.fn()],
}));
jest.mock('./message/list/MessageListContext', () => ({
useMessageListHighlights: jest.fn(),
useMessageListShowRealName: jest.fn(() => false),
}));
jest.mock('../lib/utils/fireGlobalEvent', () => ({
fireGlobalEvent: jest.fn(),
}));
jest.mock('../views/room/hooks/useGoToRoom', () => ({
useGoToRoom: jest.fn(),
}));
const mockUseMessageListHighlights = useMessageListHighlights as jest.MockedFunction<typeof useMessageListHighlights>;
const wrapper = mockAppRoot().withUserPreference('useEmojis', true).withSetting('UI_Use_Real_Name', false).withJohnDoe();
const HighlightTester = ({ text }: { text: string }) => {
return (
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: text }],
},
]}
/>
);
};
describe('GazzodownText highlights', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should highlight Russian word "тест" in the middle of a sentence', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
render(
<GazzodownText>
<HighlightTester text='Это тест сообщения' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
// Expect that the highlighted element wraps exactly "тест"
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
});
it('should highlight Russian word "тест" at the beginning of a sentence', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
render(
<GazzodownText>
<HighlightTester text='Тест сообщения' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
});
it('should not highlight "тест" when it is part of a larger word', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
render(
<GazzodownText>
<HighlightTester text='Тестирование сообщения' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.queryByTitle('Highlighted_chosen_word')).not.toBeInTheDocument();
});
it('should highlight Russian word "тест" at the end of a sentence', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
render(
<GazzodownText>
<HighlightTester text='Сообщение тест' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
});
it('should highlight English word "test" regardless of its position', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is a test message' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
it('should highlight all occurrences of the highlighted word in different positions', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='test is at the beginning, in the middle test, and at the end test' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
// Expect three separate highlights.
expect(highlightedElements.length).toBe(3);
highlightedElements.forEach((el) => {
expect(el).toHaveTextContent(/^test$/i);
});
});
it('should highlight the highlighted word in a multiline text', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
const multilineText = `First line
Test line
Another line with test
in it.`;
render(
<GazzodownText>
<HighlightTester text={multilineText} />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
// At least two occurrences are expected: one for "Test" (capitalized) and one for "test"
expect(highlightedElements.length).toBeGreaterThanOrEqual(2);
// Verify that the highlighted texts match "test" (case-insensitive)
highlightedElements.forEach((el) => {
expect(el.textContent).toMatch(/^test$/i);
});
});
it('should highlight highlighted word when surrounded by colons', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is :test: inside colons' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
// The highlighted element should contain only "test"
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
it('should highlight highlighted word when colon is at the start', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is :test with colon at the start' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
// The highlighted element should contain only "test"
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
it('should highlight highlighted word when colon is at the end', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is test: with colon at end' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
// The highlighted element should contain only "test"
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
it('should highlight multiple different highlighted words in the same text', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }, { highlight: 'highlight' }] as any);
render(
<GazzodownText>
<HighlightTester text='This test message should highlight the word highlight in multiple places: test and highlight.' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
expect(highlightedElements.length).toBe(5);
const texts = highlightedElements.map((el) => el.textContent?.toLowerCase());
expect(texts).toEqual(expect.arrayContaining(['test', 'highlight']));
});
it('should highlight a word containing special characters like "-", "_", ".", "/", "=", "!", ":', () => {
// The highlight word includes special characters.
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'te-st_te.st/te=te!st:' }] as any);
const testText = 'This message contains te-st_te.st/te=te!st: as a highlighted word.';
render(
<GazzodownText>
<HighlightTester text={testText} />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^te-st_te\.st\/te=te!st:$/i);
});
it('should highlight the word in a case-insensitive manner', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'Test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is a test message' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
});

View File

@@ -0,0 +1,144 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import type { ChannelMention, UserMention } from '@rocket.chat/gazzodown';
import { MarkupInteractionContext } from '@rocket.chat/gazzodown';
import { escapeRegExp } from '@rocket.chat/tools';
import { useLayout, useRouter, useUserPreference, useUserId, useUserCard } from '@rocket.chat/ui-contexts';
import type { UIEvent, ReactNode } from 'react';
import { useCallback, memo, useMemo } from 'react';
import { normalizeUsername } from '../../lib/utils/normalizeUsername';
import { detectEmoji } from '../lib/utils/detectEmoji';
import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent';
import { useMessageListHighlights, useMessageListShowRealName } from './message/list/MessageListContext';
import { useGoToRoom } from '../views/room/hooks/useGoToRoom';
export type GazzodownTextProps = {
children: ReactNode;
mentions?: {
type?: 'user' | 'team';
_id: string;
username?: string;
name?: string;
}[];
channels?: Pick<IRoom, '_id' | 'name'>[];
searchText?: string;
};
const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTextProps) => {
const [userLanguage] = useLocalStorage('userLanguage', 'en');
const highlights = useMessageListHighlights();
const { triggerProps, openUserCard } = useUserCard();
const highlightRegex = useMemo(() => {
if (!highlights?.length) {
return;
}
// Due to unnecessary escaping in escapeRegExp, we need to remove the escape character for the following characters: - = ! :
// This is necessary because it was crashing the client due to Invalid regular expression error.
const alternatives = highlights.map(({ highlight }) => escapeRegExp(highlight).replace(/\\([-=!:])/g, '$1')).join('|');
const expression = `(?<=^|[\\p{P}\\p{Z}])(${alternatives})(?=$|[\\p{P}\\p{Z}])`;
return (): RegExp => new RegExp(expression, 'gmiu');
}, [highlights]);
const markRegex = useMemo(() => {
if (!searchText) {
return;
}
return (): RegExp => new RegExp(`(${searchText})(?![^<]*>)`, 'gi');
}, [searchText]);
const convertAsciiToEmoji = useUserPreference<boolean>('convertAsciiEmoji', true);
const useEmoji = useUserPreference<boolean>('useEmojis', true);
const useRealName = useMessageListShowRealName();
const ownUserId = useUserId();
const showMentionSymbol = Boolean(useUserPreference<boolean>('mentionsWithSymbol'));
const resolveUserMention = useCallback(
(mention: string) => {
if (mention === 'all' || mention === 'here') {
return undefined;
}
const filterUser = ({ username, type }: UserMention) => {
if (!username || type === 'team') return false;
return normalizeUsername(username) === normalizeUsername(mention);
};
const filterTeam = ({ name, type }: UserMention) => type === 'team' && name === mention;
return mentions?.find((mention) => filterUser(mention) || filterTeam(mention));
},
[mentions],
);
const onUserMentionClick = useCallback(
({ username }: UserMention) => {
if (!username) {
return;
}
return (event: UIEvent): void => {
event.stopPropagation();
openUserCard(event, username);
};
},
[openUserCard],
);
const goToRoom = useGoToRoom();
const { isEmbedded, isMobile } = useLayout();
const resolveChannelMention = useCallback((mention: string) => channels?.find(({ name }) => name === mention), [channels]);
const router = useRouter();
const onChannelMentionClick = useCallback(
({ _id: rid }: ChannelMention) =>
(event: UIEvent): void => {
if (isEmbedded) {
fireGlobalEvent('click-mention-link', {
path: router.buildRoutePath({
pattern: '/channel/:name/:tab?/:context?',
params: { name: rid },
}),
channel: rid,
});
}
event.stopPropagation();
goToRoom(rid);
},
[router, isEmbedded, goToRoom],
);
return (
<MarkupInteractionContext.Provider
value={{
detectEmoji,
highlightRegex,
markRegex,
resolveUserMention,
onUserMentionClick,
resolveChannelMention,
onChannelMentionClick,
convertAsciiToEmoji,
useEmoji,
useRealName,
isMobile,
ownUserId,
showMentionSymbol,
triggerProps,
language: userLanguage,
}}
>
{children}
</MarkupInteractionContext.Provider>
);
};
export default memo(GazzodownText);

View File

@@ -0,0 +1,33 @@
import { Card, CardTitle, CardBody, CardControls, CardHeader, FramedIcon } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import type { GenericCardButton } from './GenericCardButton';
export type GenericCardProps = {
title: string;
body: string;
buttons?: ReactElement<typeof GenericCardButton>[];
icon?: ComponentProps<typeof FramedIcon>['icon'];
type?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
} & ComponentProps<typeof Card>;
export const GenericCard = ({ title, body, buttons, icon, type, ...props }: GenericCardProps) => {
const cardId = useId();
const descriptionId = useId();
const iconType = type && {
[type]: true,
};
return (
<Card role='region' aria-labelledby={cardId} aria-describedby={descriptionId} {...props}>
<CardHeader>
{icon && <FramedIcon icon={icon} {...(type && iconType)} />}
<CardTitle id={cardId}>{title}</CardTitle>
</CardHeader>
<CardBody id={descriptionId}>{body}</CardBody>
{buttons && <CardControls>{buttons}</CardControls>}
</Card>
);
};

View File

@@ -0,0 +1,4 @@
import { Button } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export const GenericCardButton = (props: ComponentProps<typeof Button>) => <Button {...props} medium />;

View File

@@ -0,0 +1,2 @@
export * from './GenericCard';
export * from './GenericCardButton';

View File

@@ -0,0 +1,30 @@
import { Box, States, StatesIcon, StatesTitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import { useTranslation } from 'react-i18next';
export type GenericErrorProps = {
icon?: IconName;
title?: string;
buttonTitle?: string;
buttonAction?: () => void;
};
const GenericError = ({ icon = 'magnifier', title, buttonTitle, buttonAction }: GenericErrorProps) => {
const { t } = useTranslation();
return (
<Box display='flex' height='100%' flexDirection='column' justifyContent='center'>
<States>
<StatesIcon name={icon} variation='danger' />
<StatesTitle>{title || t('Something_went_wrong')}</StatesTitle>
{buttonAction && (
<StatesActions>
<StatesAction onClick={buttonAction}>{buttonTitle || t('Reload_page')}</StatesAction>
</StatesActions>
)}
</States>
</Box>
);
};
export default GenericError;

View File

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

View File

@@ -0,0 +1,30 @@
import { TextInput, Box, Icon } from '@rocket.chat/fuselage';
import type { Meta, StoryFn } from '@storybook/react';
import GenericNoResults from './GenericNoResults';
export default {
component: GenericNoResults,
parameters: {
layout: 'padded',
actions: { argTypesRegex: '^on.*' },
},
decorators: [
(fn) => <div style={{ height: '100vh', maxHeight: 300, display: 'flex', flexDirection: 'column', marginInline: 24 }}>{fn()}</div>,
],
} satisfies Meta<typeof GenericNoResults>;
const filter = (
<>
<Box marginBlock={16} is='form' display='flex' flexDirection='column'>
<TextInput flexShrink={0} placeholder='Search...' endAddon={<Icon name='magnifier' size='x20' />} />
</Box>
</>
);
export const NoResults: StoryFn<typeof GenericNoResults> = () => (
<>
{filter}
<GenericNoResults />
</>
);

View File

@@ -0,0 +1,47 @@
import { Box, States, StatesIcon, StatesLink, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import { useTranslation } from 'react-i18next';
type LinkProps = { linkText: string; linkHref: string } | { linkText?: never; linkHref?: never };
type ButtonProps = { buttonTitle: string; buttonAction: () => void } | { buttonTitle?: never; buttonAction?: never };
export type GenericNoResultsProps = {
icon?: IconName | null;
title?: string;
description?: string;
} & LinkProps &
ButtonProps;
const GenericNoResults = ({
icon = 'magnifier',
title,
description,
buttonTitle,
buttonAction,
linkHref,
linkText,
}: GenericNoResultsProps) => {
const { t } = useTranslation();
return (
<Box role='group' aria-label={t('No_results_found')} display='flex' height='100%' flexDirection='column' justifyContent='center'>
<States>
{icon && <StatesIcon name={icon} />}
<StatesTitle>{title || t('No_results_found')}</StatesTitle>
{description && <StatesSubtitle>{description}</StatesSubtitle>}
{buttonTitle && buttonAction && (
<StatesActions>
<StatesAction onClick={buttonAction}>{buttonTitle}</StatesAction>
</StatesActions>
)}
{linkText && linkHref && (
<StatesLink href={linkHref} target='_blank'>
{linkText}
</StatesLink>
)}
</States>
</Box>
);
};
export default GenericNoResults;

View File

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

View File

@@ -0,0 +1,61 @@
import { Box, ProgressBar } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { ReactNode } from 'react';
const GenericResourceUsage = ({
title,
value,
max,
percentage,
threshold = 80,
variant = percentage < threshold ? 'success' : 'danger',
subTitle,
tooltip,
...props
}: {
title: string;
subTitle?: ReactNode;
value: number;
max: number;
percentage: number;
threshold?: number;
variant?: 'warning' | 'danger' | 'success';
tooltip?: string;
}) => {
const labelId = useId();
return (
<Box
title={tooltip}
width='x180'
height='x40'
marginInline={8}
fontScale='c1'
display='flex'
flexDirection='column'
justifyContent='space-around'
{...props}
>
<Box display='flex' justifyContent='space-between'>
<Box color='default' id={labelId}>
{title}
</Box>
{subTitle && <Box color='hint'>{subTitle}</Box>}
<Box color='hint'>
{value}/{max}
</Box>
</Box>
<ProgressBar
percentage={percentage}
variant={variant}
role='progressbar'
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percentage}
/>
</Box>
);
};
export default GenericResourceUsage;

View File

@@ -0,0 +1,26 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export type GenericResourceUsageSkeletonProps = {
title?: string;
} & ComponentProps<typeof Box>;
const GenericResourceUsageSkeleton = ({ title, ...props }: GenericResourceUsageSkeletonProps) => {
return (
<Box
width='x180'
height='x40'
marginInline={8}
fontScale='c1'
display='flex'
flexDirection='column'
justifyContent='space-around'
{...props}
>
{title ? <Box color='default'>{title}</Box> : <Skeleton width='full' />}
<Skeleton width='full' />
</Box>
);
};
export default GenericResourceUsageSkeleton;

View File

@@ -0,0 +1,4 @@
import GenericResourceUsage from './GenericResourceUsage';
import GenericResourceUsageSkeleton from './GenericResourceUsageSkeleton';
export { GenericResourceUsage, GenericResourceUsageSkeleton };

View File

@@ -0,0 +1,89 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import GenericUpsellModal from './GenericUpsellModal';
import * as stories from './GenericUpsellModal.stories';
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Premium_capability: 'Premium capability',
Cancel: 'Cancel',
Upgrade: 'Upgrade',
})
.build();
describe('GenericUpsellModal', () => {
const defaultProps = {
title: 'Test Title',
img: 'test-image.png',
onClose: jest.fn(),
};
afterEach(() => {
jest.clearAllMocks();
});
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render basic properties', () => {
const props = {
...defaultProps,
subtitle: 'Test Subtitle',
description: 'Test Description',
onCancel: jest.fn(),
onConfirm: jest.fn(),
};
render(<GenericUpsellModal {...props} />, { wrapper: appRoot });
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText('Test Subtitle')).toBeInTheDocument();
expect(screen.getByText('Test Description')).toBeInTheDocument();
});
it('should render with default confirm and cancel buttons', () => {
render(<GenericUpsellModal {...defaultProps} onCancel={jest.fn()} onConfirm={jest.fn()} />, { wrapper: appRoot });
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument();
});
it('should render with default tagline', () => {
render(<GenericUpsellModal {...defaultProps} />, { wrapper: appRoot });
expect(screen.getByText('Premium capability')).toBeInTheDocument();
});
it('should render with img properties', () => {
const props = {
...defaultProps,
onCancel: jest.fn(),
onConfirm: jest.fn(),
imgWidth: 300,
imgHeight: 200,
imgAlt: 'Alternative text',
};
render(<GenericUpsellModal {...props} />, { wrapper: appRoot });
const heroImage = screen.getByRole('img');
expect(heroImage).toHaveAttribute('src', 'test-image.png');
expect(heroImage).toHaveAttribute('alt', 'Alternative text');
expect(heroImage).toHaveStyle({
width: '300px',
height: '200px',
});
});
});

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import GenericUpsellModal from '.';
const meta = {
component: GenericUpsellModal,
args: {
onClose: action('onClose'),
img: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z',
imgAlt: 'Placeholder image',
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof GenericUpsellModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: 'This is the title',
subtitle: 'This is a subtitle',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
imgAlt: 'Placeholder image',
onCancel: action('onCancel'),
onConfirm: action('onConfirm'),
},
};
export const WithCustomTagLine: Story = {
args: {
...Default.args,
tagline: 'Exclusive',
},
};
export const WithCustomButtons: Story = {
args: {
...Default.args,
cancelText: 'Learn More',
confirmText: 'Contact Sales',
},
};
export const WithAnnotation: Story = {
args: {
...Default.args,
annotation: 'This is an annotation.',
},
};
export const WithFixedWidthAndHeight: Story = {
args: {
...Default.args,
imgHeight: 600,
imgWidth: 600,
},
};

View File

@@ -0,0 +1,54 @@
import { Box, ModalHeroImage } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import type { ComponentProps, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
export type GenericUpsellModalProps = Omit<ComponentProps<typeof GenericModal>, 'variant' | 'children' | 'onClose' | 'onDismiss'> & {
subtitle?: ReactNode;
description?: ReactNode;
img: ComponentProps<typeof ModalHeroImage>['src'];
imgWidth?: ComponentProps<typeof ModalHeroImage>['width'];
imgHeight?: ComponentProps<typeof ModalHeroImage>['height'];
imgAlt?: string;
onClose: () => void;
onConfirm?: () => void;
};
const GenericUpsellModal = ({
tagline,
subtitle,
img,
imgAlt = '',
imgWidth,
imgHeight,
description,
confirmText,
icon = null,
...props
}: GenericUpsellModalProps) => {
const { t } = useTranslation();
return (
<GenericModal
{...props}
icon={icon}
tagline={tagline ?? t('Premium_capability')}
variant='upsell'
confirmText={confirmText ?? t('Upgrade')}
>
<ModalHeroImage src={img} alt={imgAlt} width={imgWidth} height={imgHeight} />
{subtitle && (
<Box is='h3' fontScale='h3'>
{subtitle}
</Box>
)}
{description && (
<Box style={{ whiteSpace: 'break-spaces' }} fontScale='p2' marginBlockStart={16}>
{description}
</Box>
)}
</GenericModal>
);
};
export default GenericUpsellModal;

View File

@@ -0,0 +1,561 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`GenericUpsellModal renders Default without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`GenericUpsellModal renders WithAnnotation without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_1_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_1_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-rzpqmp"
>
<div
class="rcx-box rcx-box--full rcx-modal__footer-annotation"
>
This is an annotation.
</div>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`GenericUpsellModal renders WithCustomButtons without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_2_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_2_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Learn More
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Contact Sales
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`GenericUpsellModal renders WithCustomTagLine without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_3_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Exclusive
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_3_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`GenericUpsellModal renders WithFixedWidthAndHeight without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_4_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_4_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image rcx-css-1k43lja"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@@ -0,0 +1 @@
export * from './useUpsellActions';

View File

@@ -0,0 +1,67 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { UseQueryResult } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { useUpsellActions } from './useUpsellActions';
import { createFakeLicenseInfo } from '../../../../tests/mocks/data';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
jest.mock('../../../hooks/useIsEnterprise', () => ({
useIsEnterprise: jest.fn(),
}));
const appRoot = mockAppRoot()
.withJohnDoe()
.withEndpoint('GET', '/v1/licenses.info', async () => ({ license: createFakeLicenseInfo({ hasValidLicense: true }) }))
.build();
describe('useUpsellActions hook', () => {
beforeEach(() => {
(useIsEnterprise as jest.Mock).mockImplementation(jest.requireActual('../../../hooks/useIsEnterprise').useIsEnterprise);
});
afterAll(() => {
jest.clearAllMocks();
});
it('should show upsell modal if not enterprise', () => {
const { result } = renderHook(() => useUpsellActions(), {
wrapper: mockAppRoot().build(),
});
expect(result.current.shouldShowUpsell).toBe(true);
expect(result.current.cloudWorkspaceHadTrial).toBe(false);
});
it('should show upsell modal if enterprise but has no license module', async () => {
const { result } = renderHook(() => useUpsellActions(false), {
wrapper: appRoot,
});
await waitFor(() => {
expect(result.current.shouldShowUpsell).toBe(true);
});
});
it('should NOT show upsell if enterprise and has license module', async () => {
const { result } = renderHook(() => useUpsellActions(true), {
wrapper: appRoot,
});
await waitFor(() => {
expect(result.current.shouldShowUpsell).toBe(false);
});
});
it('should show upsell if useIsEnterprise is undefined', async () => {
(useIsEnterprise as jest.Mock).mockReturnValue({
data: undefined,
} as UseQueryResult);
const { result } = renderHook(() => useUpsellActions(true), {
wrapper: mockAppRoot().build(),
});
expect(result.current.shouldShowUpsell).toBe(true);
});
});

View File

@@ -0,0 +1,33 @@
import { useSetModal, useSetting } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
import { links } from '../../../lib/links';
import { useCheckoutUrl } from '../../../views/admin/subscription/hooks/useCheckoutUrl';
const TALK_TO_SALES_URL = links.go.contactSales;
export const useUpsellActions = (hasLicenseModule = false) => {
const setModal = useSetModal();
const handleOpenLink = useExternalLink();
const cloudWorkspaceHadTrial = useSetting('Cloud_Workspace_Had_Trial', false);
const { data } = useIsEnterprise();
const shouldShowUpsell = !data?.isEnterprise || !hasLicenseModule;
const openExternalLink = useExternalLink();
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'upsell-modal', action: 'upgrade' });
const handleManageSubscription = useCallback(() => {
openExternalLink(manageSubscriptionUrl);
setModal(null);
}, [manageSubscriptionUrl, openExternalLink, setModal]);
const handleTalkToSales = useCallback(() => {
handleOpenLink(TALK_TO_SALES_URL);
setModal(null);
}, [handleOpenLink, setModal]);
return { shouldShowUpsell, cloudWorkspaceHadTrial, handleManageSubscription, handleTalkToSales };
};

View File

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

View File

@@ -0,0 +1,230 @@
import { FocusScope } from '@react-aria/focus';
import type { IUpload } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box, ButtonGroup, IconButton, Palette, PaletteStyleTag, Throbber, spacing } from '@rocket.chat/fuselage';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Navigation, Zoom, Keyboard, A11y } from 'swiper/modules/index.mjs';
import type { SwiperClass, SwiperRef } from 'swiper/swiper-react';
import { Swiper, SwiperSlide } from 'swiper/swiper-react.mjs';
import 'swiper/swiper.css';
import 'swiper/modules/zoom.css';
import { usePreventPropagation } from '../../hooks/usePreventPropagation';
const swiperStyle = css`
.swiper {
width: 100%;
height: 100%;
}
.swiper-container {
position: absolute;
z-index: 99;
top: 0;
overflow: hidden;
width: 100%;
height: 100%;
background-color: var(--rcx-color-surface-overlay, rgba(0, 0, 0, 0.6));
}
.swiper-slide {
padding: ${spacing('x144')} ${spacing('x60')} ${spacing('x96')};
}
.rcx-swiper-prev-button,
.rcx-swiper-next-button {
position: absolute;
z-index: 10;
top: 50%;
cursor: pointer;
}
.rcx-swiper-prev-button.swiper-button-disabled,
.rcx-swiper-next-button.swiper-button-disabled {
cursor: auto;
pointer-events: none;
opacity: 0.35;
}
.rcx-swiper-prev-button.swiper-button-hidden,
.rcx-swiper-next-button.swiper-button-hidden {
cursor: auto;
pointer-events: none;
opacity: 0;
}
.rcx-swiper-prev-button,
.swiper-rtl .rcx-swiper-next-button {
right: auto;
left: 10px;
}
.rcx-swiper-next-button,
.swiper-rtl .rcx-swiper-prev-button {
right: 10px;
left: auto;
}
.rcx-lazy-preloader {
position: absolute;
z-index: -1;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: ${Palette.text['font-pure-white']};
}
.rcx-swiper-controls {
position: absolute;
top: 0;
right: 0;
padding: 10px;
z-index: 2;
width: 100%;
display: flex;
justify-content: flex-end;
background-color: ${Palette.surface['surface-sidebar']};
}
`;
export const ImageGallery = ({
images,
onClose,
loadMore,
}: {
images: Pick<IUpload, '_id' | 'path' | 'url' | 'description'>[];
onClose: () => void;
loadMore?: () => void;
}) => {
const { t } = useTranslation();
const swiperRef = useRef<SwiperRef>(null);
const [, setSwiperInst] = useState<SwiperClass>();
const [zoomScale, setZoomScale] = useState(1);
const [gridSize, setGridSize] = useState(images.length);
const handleZoom = (ratio: number) => {
if (swiperRef.current?.swiper.zoom) {
const { scale, in: zoomIn } = swiperRef.current?.swiper.zoom;
setZoomScale(scale + ratio);
return zoomIn(scale + ratio);
}
};
const handleZoomIn = () => handleZoom(1);
const handleZoomOut = () => handleZoom(-1);
const handleResize = () => handleZoom(-(zoomScale - 1));
const preventPropagation = usePreventPropagation();
return createPortal(
<>
<PaletteStyleTag theme='dark' selector='.swiper-container.image-gallery' tagId='image-gallery-palette' />
<FocusScope contain autoFocus>
<Box role='dialog' aria-modal='true' aria-label={t('Image_gallery')} className={swiperStyle}>
<div role='presentation' className='swiper-container image-gallery' onClick={onClose}>
<ButtonGroup role='toolbar' className='rcx-swiper-controls' onClick={preventPropagation}>
{zoomScale !== 1 && (
<IconButton
name='resize'
small
icon='arrow-collapse'
title={t('Resize')}
rcx-swiper-zoom-out
secondary
onClick={handleResize}
/>
)}
<IconButton
name='zoom-out'
small
icon='h-bar'
title={t('Zoom_out')}
rcx-swiper-zoom-out
onClick={handleZoomOut}
secondary
disabled={zoomScale === 1}
/>
<IconButton name='zoom-in' small icon='plus' title={t('Zoom_in')} rcx-swiper-zoom-in secondary onClick={handleZoomIn} />
<IconButton
name='close'
small
icon='cross'
aria-label={t('Close_gallery')}
className='rcx-swiper-close-button'
secondary
onClick={onClose}
/>
</ButtonGroup>
<IconButton
icon='chevron-right'
aria-label={t('Next_image')}
className='rcx-swiper-prev-button'
secondary
onClick={preventPropagation}
/>
<IconButton
icon='chevron-left'
aria-label={t('Previous_image')}
className='rcx-swiper-next-button'
secondary
onClick={preventPropagation}
/>
<Swiper
ref={swiperRef}
navigation={{
nextEl: '.rcx-swiper-next-button',
prevEl: '.rcx-swiper-prev-button',
}}
keyboard
zoom={{ toggle: false }}
lazyPreloaderClass='rcx-lazy-preloader'
runCallbacksOnInit
onKeyPress={(_: SwiperClass, keyCode: string) => String(keyCode) === '27' && onClose()}
modules={[Navigation, Zoom, Keyboard, A11y]}
onInit={(swiper: SwiperClass) => setSwiperInst(swiper)}
onSlidesGridLengthChange={(swiper: SwiperClass) => {
swiper.slideTo(images.length - gridSize, 0);
setGridSize(images.length);
}}
onReachBeginning={loadMore}
initialSlide={images.length - 1}
>
{[...images].reverse().map(({ _id, path, url, description }) => (
<SwiperSlide key={_id}>
<div className='swiper-zoom-container'>
{/* eslint-disable-next-line
jsx-a11y/no-noninteractive-element-interactions,
jsx-a11y/click-events-have-key-events
*/}
<img
src={path || url}
loading='lazy'
alt={description || ''}
data-qa-zoom-scale={zoomScale}
onClick={preventPropagation}
/>
<div className='rcx-lazy-preloader'>
<Throbber inheritColor />
</div>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</Box>
</FocusScope>
</>,
document.body,
);
};

View File

@@ -0,0 +1,25 @@
import { css } from '@rocket.chat/css-in-js';
import { IconButton, ModalBackdrop } from '@rocket.chat/fuselage';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import GenericError from '../GenericError/GenericError';
const closeButtonStyle = css`
position: absolute;
z-index: 10;
top: 10px;
right: 10px;
`;
export const ImageGalleryError = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
return createPortal(
<ModalBackdrop display='flex' justifyContent='center' color='pure-white'>
<GenericError buttonAction={onClose} buttonTitle={t('Close')} />
<IconButton icon='cross' aria-label={t('Close_gallery')} className={closeButtonStyle} onClick={onClose} />
</ModalBackdrop>,
document.body,
);
};

View File

@@ -0,0 +1,23 @@
import { css } from '@rocket.chat/css-in-js';
import { IconButton, ModalBackdrop, Throbber } from '@rocket.chat/fuselage';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
const closeButtonStyle = css`
position: absolute;
z-index: 10;
top: 10px;
right: 10px;
`;
export const ImageGalleryLoading = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
return createPortal(
<ModalBackdrop display='flex' justifyContent='center' color='pure-white'>
<IconButton icon='cross' aria-label={t('Close_gallery')} className={closeButtonStyle} onClick={onClose} />
<Throbber inheritColor />
</ModalBackdrop>,
document.body,
);
};

View File

@@ -0,0 +1,3 @@
export * from './ImageGallery';
export * from './ImageGalleryError';
export * from './ImageGalleryLoading';

View File

@@ -0,0 +1,39 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useEffect, useRef } from 'react';
export type InfiniteListAnchorProps = {
loadMore: () => void;
} & ComponentProps<typeof Box>;
const InfiniteListAnchor = ({ loadMore, ...props }: InfiniteListAnchorProps) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const target = ref.current;
if (!target) {
return;
}
const observer = new IntersectionObserver(
(e) => {
if (e[0].isIntersecting) {
loadMore();
}
},
{
root: null,
threshold: 0.1,
},
);
observer.observe(target);
return () => observer.disconnect();
}, [loadMore]);
return <Box width={5} height={5} ref={ref} {...props} />;
};
export default InfiniteListAnchor;

View File

@@ -0,0 +1,24 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './RetentionPolicyCallout.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
jest.useFakeTimers();
jest.setSystemTime(new Date(2024, 5, 1, 0, 0, 0));
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
// We have to use real timers here because `jest-axe` is breaking otherwise
jest.useRealTimers();
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryFn } from '@storybook/react';
import RetentionPolicyCallout from './RetentionPolicyCallout';
import { createRenteionPolicySettingsMock as createMock } from '../../../tests/mocks/client/mockRetentionPolicySettings';
import { createFakeRoom } from '../../../tests/mocks/data';
export default {
component: RetentionPolicyCallout,
} satisfies Meta<typeof RetentionPolicyCallout>;
const fakeRoom = createFakeRoom();
const DefaultWrapper = createMock({ appliesToChannels: true, TTLChannels: 60000 });
export const Default: StoryFn<typeof RetentionPolicyCallout> = () => (
<DefaultWrapper>
<RetentionPolicyCallout room={fakeRoom} />
</DefaultWrapper>
);
const InvalidSettingsWrapper = createMock({
appliesToChannels: true,
TTLChannels: 60000,
advancedPrecisionCron: '* * * 12 * *',
advancedPrecision: true,
});
export const InvalidSettings: StoryFn<typeof RetentionPolicyCallout> = () => (
<InvalidSettingsWrapper>
<RetentionPolicyCallout room={fakeRoom} />
</InvalidSettingsWrapper>
);

View File

@@ -0,0 +1,23 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Callout } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { usePruneWarningMessage } from '../../hooks/usePruneWarningMessage';
import { withErrorBoundary } from '../withErrorBoundary';
export type RetentionPolicyCalloutProps = { room: IRoom };
const RetentionPolicyCallout = ({ room }: RetentionPolicyCalloutProps) => {
const message = usePruneWarningMessage(room);
const { t } = useTranslation();
return (
<Callout arial-label={t('Retention_policy_warning_callout')} role='alert' aria-live='polite' type='warning'>
<div>
<p>{message}</p>
</div>
</Callout>
);
};
export default withErrorBoundary(RetentionPolicyCallout);

View File

@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders Default without crashing 1`] = `
<body>
<div>
<section
aria-live="polite"
arial-label="Retention_policy_warning_callout"
class="rcx-box rcx-box--full rcx-callout rcx-callout--warning"
role="alert"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-warning rcx-icon rcx-callout__icon rcx-css-4pvxx3"
>
</i>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper"
>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper-content"
>
<div
class="rcx-box rcx-box--full rcx-callout__content"
>
<div>
<p>
1 minute June 1, 2024 at 12:30 AM
</p>
</div>
</div>
</div>
</div>
</section>
</div>
</body>
`;
exports[`renders InvalidSettings without crashing 1`] = `
<body>
<div>
<section
aria-live="polite"
arial-label="Retention_policy_warning_callout"
class="rcx-box rcx-box--full rcx-callout rcx-callout--warning"
role="alert"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-warning rcx-icon rcx-callout__icon rcx-css-4pvxx3"
>
</i>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper"
>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper-content"
>
<div
class="rcx-box rcx-box--full rcx-callout__content"
>
<div>
<p>
1 minute June 12, 2024 at 12:00 AM
</p>
</div>
</div>
</div>
</div>
</section>
</div>
</body>
`;

View File

@@ -0,0 +1,18 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './InvitationBadge.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,32 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta } from '@storybook/react';
import InvitationBadge from './InvitationBadge';
const meta = {
component: InvitationBadge,
parameters: {
layout: 'centered',
},
decorators: [
mockAppRoot()
.withTranslations('en', 'core', {
Invited__date__: 'Invited {{date}}',
})
.buildStoryDecorator(),
],
} satisfies Meta<typeof InvitationBadge>;
export default meta;
export const WithISOStringDate = {
args: {
invitationDate: '2025-01-01T12:00:00Z',
},
};
export const WithDateObject = {
args: {
invitationDate: new Date('2025-01-01T12:00:00Z'),
},
};

View File

@@ -0,0 +1,28 @@
import { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { useTimeAgo } from '../../hooks/useTimeAgo';
export type InvitationBadgeProps = Omit<ComponentProps<typeof Icon>, 'name' | 'color' | 'role'> & {
invitationDate: string | Date;
};
const InvitationBadge = ({ invitationDate, ...props }: InvitationBadgeProps) => {
const { t } = useTranslation();
const timeAgo = useTimeAgo();
return (
<Icon
size='x20'
{...props}
role='status'
color='info'
name='mail'
aria-hidden='false'
title={t('Invited__date__', { date: timeAgo(invitationDate) })}
/>
);
};
export default InvitationBadge;

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders WithDateObject without crashing 1`] = `
<body>
<div>
<i
aria-hidden="false"
class="rcx-box rcx-box--full rcx-icon--name-mail rcx-icon rcx-css-oq4j9d"
role="status"
title="Invited January 1st, 2025"
>
</i>
</div>
</body>
`;
exports[`renders WithISOStringDate without crashing 1`] = `
<body>
<div>
<i
aria-hidden="false"
class="rcx-box rcx-box--full rcx-icon--name-mail rcx-icon rcx-css-oq4j9d"
role="status"
title="Invited January 1st, 2025"
>
</i>
</div>
</body>
`;

View File

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

View File

@@ -0,0 +1,92 @@
import { Tile, OptionTitle, Box } from '@rocket.chat/fuselage';
import type { StoryObj, Meta, StoryFn } from '@storybook/react';
import ListItem from './Sidebar/ListItem';
export default {
component: Tile,
parameters: {
docs: {
description: {
component: '`ListItem` can be used on DropDown lists, to render an item with icon and text.',
},
},
},
} satisfies Meta<typeof Tile>;
export const ListWithIcon: StoryFn<typeof Tile> = () => {
return (
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
<Box flexShrink={1} paddingBlock={12}>
<OptionTitle>Title</OptionTitle>
<ListItem text='Item 1' icon='info' />
<ListItem text='Item 2' icon='star' />
<ListItem text='Item 3' icon='hashtag' />
<ListItem text='Item 4' icon='team' />
</Box>
</Tile>
);
};
export const NoIcon: StoryFn<typeof Tile> = () => {
return (
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
<Box flexShrink={1} paddingBlock={12}>
<OptionTitle>Title</OptionTitle>
<ListItem text='Item 1' />
<ListItem text='Item 2' />
<ListItem text='Item 3' />
<ListItem text='Item 4' />
</Box>
</Tile>
);
};
export const MixedWithGap: StoryObj<typeof Tile> = {
render: () => {
return (
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
<Box flexShrink={1} paddingBlock={12}>
<OptionTitle>Title</OptionTitle>
<ListItem text='Item 1' icon='hashtag' />
<ListItem text='Item 2' icon='team' />
<ListItem text='Item 3' gap />
<ListItem text='Item 4' icon='airplane' />
</Box>
</Tile>
);
},
parameters: {
docs: {
description: {
story:
" When using `ListItem`, you can also use the `gap` prop to add spacing to the left. If the list is mixed with items **with and without** icons, it's recommended to add the gap.",
},
},
},
};
export const MixedWithoutGap: StoryObj<typeof Tile> = {
render: () => {
return (
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
<Box flexShrink={1} paddingBlock={12}>
<OptionTitle>Title</OptionTitle>
<ListItem text='Item 1' icon='hashtag' />
<ListItem text='Item 2' icon='team' />
<ListItem text='Item 3' />
<ListItem text='Item 4' icon='airplane' />
</Box>
</Tile>
);
},
parameters: {
docs: {
description: {
story: 'Not recommended. Prefer adding the `gap` prop to the items without icons.',
},
},
},
};

View File

@@ -0,0 +1,33 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import { memo, useMemo } from 'react';
const availablePercentualWidths = [47, 68, 75, 82];
export type ListSkeletonProps = {
listCount?: number;
};
const ListSkeleton = ({ listCount = 2 }: ListSkeletonProps) => {
const widths = useMemo(
() => Array.from({ length: listCount }, (_, index) => `${availablePercentualWidths[index % availablePercentualWidths.length]}%`),
[listCount],
);
return (
<Box display='flex' height='100%' justifyContent='flex-start' flexDirection='column'>
{widths.map((width, index) => (
<Box key={index} paddingInline={24} paddingBlock={16} display='flex'>
<Box>
<Skeleton variant='rect' width={36} height={36} />
</Box>
<Box marginInlineStart={8} flexGrow={1}>
<Skeleton width='100%' />
<Skeleton width={width} />
</Box>
</Box>
))}
</Box>
);
};
export default memo(ListSkeleton);

View File

@@ -0,0 +1,13 @@
export type LoadingIndicatorProps = { variation?: 'medium' | 'large' };
const LoadingIndicator = ({ variation }: LoadingIndicatorProps) => {
return (
<div className='loading__animation'>
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
</div>
);
};
export default LoadingIndicator;

View File

@@ -0,0 +1,17 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useUTCClock } from '../hooks/useUTCClock';
export type LocalTimeProps = {
utcOffset: number;
};
const LocalTime = ({ utcOffset }: LocalTimeProps) => {
const time = useUTCClock(utcOffset);
const { t } = useTranslation();
return <>{t('Local_Time_time', { time })}</>;
};
export default memo(LocalTime);

View File

@@ -0,0 +1,614 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import dompurify from 'dompurify';
import MarkdownText, { supportedURISchemes } from './MarkdownText';
import '@testing-library/jest-dom';
import { emoji } from '../lib/emoji/lib';
import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
jest.mock('../lib/getMarkdownParserLimit');
const getMarkdownParserLimitMock = jest.mocked(getMarkdownParserLimit);
const MOCKED_BASE_URI = 'http://localhost/';
// Mock getBaseURI from @rocket.chat/ui-client to ensure consistent behavior in tests
// This will affect the isExternal function imported above.
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'), // Import and retain default behavior for other exports
getBaseURI: jest.fn(() => MOCKED_BASE_URI), // Mock getBaseURI for consistent test behavior
}));
// List of common URI schemes. This list was taken from https://en.wikipedia.org/wiki/List_of_URI_schemes
const commonUriSchemes = [
'file',
'ftp',
'http',
'https',
'mailto',
'tel',
'imap',
'irc',
'nntp',
'acap',
'icap',
'mtqp',
'wss',
'admin',
'app',
'freeplane',
'geo',
'javascript',
'jdbc',
'msteams',
'ms-access',
'ms-excel',
'ms-infopath',
'ms-powerpoint',
'ms-project',
'ms-publisher',
'ms-spd',
'ms-visio',
'ms-word',
'odbc',
'psns',
'rdar',
's3',
'shortcuts',
'slack',
'stratum',
'trueconf',
'viber',
'zoommtgzoomus',
];
const nonSupportedUriSchemes = commonUriSchemes.filter((scheme) => !supportedURISchemes.includes(scheme));
const testUris = [
'example.com',
'example.com/path',
'example.com/path/to/resource',
'localhost/home',
'localhost/path/to/',
'localhost/path/to/resource',
];
const getTestCases = (schemes: string[], uris: string[]): { scheme: string; links: string[] }[] => {
return schemes.map((scheme) => {
return {
scheme,
links: uris.map((uri) => `${scheme}://${uri}`),
};
});
};
const isExternal = (link: string): boolean => link.indexOf(MOCKED_BASE_URI) !== 0;
const normalizeHtml = (html: any) => {
return html.replace(/\s+/g, ' ').trim();
};
const markdownText = `
# Heading 1
**Paragraph text**: *Bold with one asterisk* **Bold with two asterisks** Lorem ipsum dolor sit amet, consectetur adipiscing elit.
## Heading 2
_Italic Text_: _Italic with one underscore_ __Italic with two underscores__ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
### Heading 3
Lists, Links and elements
**Unordered List**
- List Item 1
- List Item 2
- List Item 3
- List Item 4
**Ordered List**
1. List Item 1
2. List Item 2
3. List Item 3
4. List Item 4
**Links:**
[Rocket.Chat](rocket.chat)
gabriel.engel@rocket.chat
+55991999999
\`Inline code\`
\`\`\`typescript
const test = 'this is code'
\`\`\`
**Bold text within __Italics__**
*Bold text with single asterik and underscore within _Italics_*
__Italics within **Bold** text__
_Italics within *Bold* text with single underscore and asterik_
`;
it('should render html elements as expected using default parser', async () => {
const { container } = render(<MarkdownText content={markdownText} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const normalizedHtml = normalizeHtml(container.innerHTML);
expect(normalizedHtml).toContain('<h1>Heading 1</h1>');
expect(normalizedHtml).toContain(
'<strong>Paragraph text</strong>: <strong>Bold with one asterisk</strong> <strong>Bold with two asterisks</strong> Lorem ipsum dolor sit amet',
);
expect(normalizedHtml).toContain('<h2>Heading 2</h2>');
expect(normalizedHtml).toContain(
'<em>Italic Text</em>: <em>Italic with one underscore</em> <em>Italic with two underscores</em> Lorem ipsum dolor sit amet',
);
expect(normalizedHtml).toContain('<h3>Heading 3</h3>');
expect(normalizedHtml).toContain('<ul> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
expect(normalizedHtml).toContain('<ol> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
expect(normalizedHtml).toContain('<a');
expect(normalizedHtml).toContain('title=""');
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
expect(normalizedHtml).toContain('target="_blank"');
expect(normalizedHtml).toContain('>Rocket.Chat</a>');
expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
expect(normalizedHtml).toContain('gabriel.engel@rocket.chat');
expect(normalizedHtml).toContain('+55991999999');
expect(normalizedHtml).toContain('<code>Inline code</code>');
expect(normalizedHtml).toContain('<pre><code class="language-typescript">const test = \'this is code\' </code></pre>');
expect(normalizedHtml).toContain('<strong>Bold text within <em>Italics</em></strong>');
expect(normalizedHtml).toContain('<strong>Bold text with single asterik and underscore within <em>Italics</em></strong>');
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
});
it('should render html elements as expected using inline parser', async () => {
const { container } = render(<MarkdownText content={markdownText} variant='inline' />, {
wrapper: mockAppRoot().build(),
});
const normalizedHtml = normalizeHtml(container.innerHTML);
expect(normalizedHtml).toContain('# Heading 1');
expect(normalizedHtml).toContain(
'<strong>Bold with one asterisk</strong> <strong>Bold with two asterisks</strong> Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
);
expect(normalizedHtml).toContain('## Heading 2');
expect(normalizedHtml).toContain(
'<em>Italic Text</em>: <em>Italic with one underscore</em> <em>Italic with two underscores</em> Lorem ipsum dolor sit amet',
);
expect(normalizedHtml).toContain('### Heading 3');
expect(normalizedHtml).toContain('<strong>Unordered List</strong> - List Item 1 - List Item 2 - List Item 3 - List Item 4');
expect(normalizedHtml).toContain('<strong>Ordered List</strong> 1. List Item 1 2. List Item 2 3. List Item 3 4. List Item 4');
expect(normalizedHtml).toContain('<a');
expect(normalizedHtml).toContain('title=""');
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
expect(normalizedHtml).toContain('target="_blank"');
expect(normalizedHtml).toContain('>Rocket.Chat</a>');
expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
expect(normalizedHtml).toContain('target="_blank"');
expect(normalizedHtml).toContain('+55991999999');
expect(normalizedHtml).toContain('Inline code');
expect(normalizedHtml).toContain(`typescript const test = 'this is code'`);
expect(normalizedHtml).toContain('<strong>Bold text within <em>Italics</em></strong>');
expect(normalizedHtml).toContain('<strong>Bold text with single asterik and underscore within <em>Italics</em></strong>');
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
});
describe('links handling', () => {
it.each([
{
caseName: 'transform external http',
link: 'http://example.com',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'http://example.com',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform external https',
link: 'https://example.com',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'https://example.com',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform notes',
link: 'notes://example.com/path',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'notes://example.com/path',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform ftp',
link: 'ftp://example.com/file.txt',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'ftp://example.com/file.txt',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform ftps',
link: 'ftps://example.com/file.txt',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'ftps://example.com/file.txt',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform tel',
link: 'tel:+1234567890',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'tel:+1234567890',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform mailto',
link: 'mailto:test@example.com',
query: () => screen.getByRole('link', { name: 'mailto:test@example.com' }),
expectedHref: 'mailto:test@example.com',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: 'mailto:test@example.com',
},
{
caseName: 'transform sms',
link: 'sms:+1234567890?body=hello',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'sms:+1234567890?body=hello',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform cid',
link: 'cid:someimage@example.com',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'cid:someimage@example.com',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'filter relative',
link: '/channel/general',
query: () => screen.getByText('Test Link'),
expectedHref: undefined,
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform absolute',
link: 'http://localhost/another-channel',
query: () => screen.getByRole('link', { name: 'Go to: another-channel' }),
expectedHref: 'http://localhost/another-channel',
expectedRel: undefined,
expectedTarget: undefined,
expectedTitleAttribute: 'Go to: another-channel',
},
{
caseName: 'filter unknown scheme',
link: 'invalid://example.com',
query: () => screen.getByText('Test Link'),
expectedHref: undefined,
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'filter javascript',
link: "javascript:alert('XSS')",
query: () => screen.getByText('Test Link'),
expectedHref: undefined,
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
] as const)('should $caseName links', ({ link, query, expectedHref, expectedRel, expectedTarget, expectedTitleAttribute }) => {
const markdownContent = `[Test Link](${link})`;
render(<MarkdownText content={markdownContent} variant='document' />, {
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
});
const anchorElement = query();
expect(anchorElement).toBeInTheDocument();
expect(anchorElement.tagName).toBe('A');
expect(anchorElement).toHaveTextContent('Test Link');
if (expectedHref !== undefined) expect(anchorElement).toHaveAttribute('href', expectedHref);
else expect(anchorElement).not.toHaveAttribute('href');
if (expectedRel !== undefined) expect(anchorElement).toHaveAttribute('rel', expectedRel);
else expect(anchorElement).not.toHaveAttribute('rel');
if (expectedTarget !== undefined) expect(anchorElement).toHaveAttribute('target', expectedTarget);
else expect(anchorElement).not.toHaveAttribute('target');
if (expectedTitleAttribute !== undefined) expect(anchorElement).toHaveAttribute('title', expectedTitleAttribute);
else expect(anchorElement).not.toHaveAttribute('title');
});
describe('multiple links', () => {
it.each(getTestCases(supportedURISchemes, testUris))('supported scheme: $scheme', ({ scheme, links }) => {
const getTextContent = (link: string) => `Test link - ${link}`;
const markdownContent = links.map((link) => `[${getTextContent(link)}](${link})`).join('\n');
render(<MarkdownText content={markdownContent} variant='document' />, {
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
});
links.forEach((link) => {
const text = getTextContent(link);
const title = `Go to: ${link.replace(MOCKED_BASE_URI, '')}`;
const anchorElement = screen.getByText(text);
expect(anchorElement).toBeInTheDocument();
expect(anchorElement.tagName).toBe('A');
expect(anchorElement).toHaveTextContent(text);
expect(anchorElement).toHaveAttribute('href', link);
if (scheme === 'mailto') {
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
expect(anchorElement).toHaveAttribute('target', '_blank');
expect(anchorElement).toHaveAttribute('href', link);
expect(anchorElement).toHaveAttribute('title', link);
return;
}
if (isExternal(link)) {
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
expect(anchorElement).toHaveAttribute('target', '_blank');
expect(anchorElement).toHaveAttribute('title', '');
return;
}
expect(anchorElement).toHaveAttribute('title', title);
});
});
it.each(getTestCases(nonSupportedUriSchemes, testUris))('unsupported scheme: $scheme', ({ links }) => {
const getTextContent = (link: string) => `Test link - ${link}`;
const markdownContent = links.map((link) => `[${getTextContent(link)}](${link})`).join('\n');
render(<MarkdownText content={markdownContent} variant='document' />, {
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
});
links.forEach((link) => {
const text = getTextContent(link);
const anchorElement = screen.getByText(text);
expect(anchorElement).toBeInTheDocument();
expect(anchorElement.tagName).toBe('A');
expect(anchorElement).toHaveTextContent(text);
expect(anchorElement).not.toHaveAttribute('href');
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
expect(anchorElement).toHaveAttribute('target', '_blank');
expect(anchorElement).toHaveAttribute('title', '');
});
});
});
});
describe('code handling', () => {
it.each([
{
caseName: 'inline with special characters',
content: '`2 < 3 > 1 & 4 "Test"`',
expected: '<code>2 &lt; 3 &gt; 1 &amp; 4 "Test"</code>',
},
{
caseName: 'inline with encoded special characters',
content: '`< = &lt; > = &gt; & = &amp;`',
expected: '<code>&lt; = &amp;lt; &gt; = &amp;gt; &amp; = &amp;amp;</code>',
},
{
caseName: 'block with language',
content: "```typescript\nconst test = 'this is code'\n```",
expected: '<code class="language-typescript">const test = \'this is code\' </code>',
},
{
caseName: 'block without language',
content: '```\nTwo < Three > One & Four "Test"\n```',
expected: '<code>Two &lt; Three &gt; One &amp; Four "Test" </code>',
},
{
caseName: 'block with encoded special characters',
content: '```\nTwo &lt; Three &gt; One &amp; Four "Test"\n```',
expected: '<code>Two &amp;lt; Three &amp;gt; One &amp;amp; Four "Test" </code>',
},
] as const)('should render $caseName', ({ content, expected }) => {
render(<MarkdownText content={`${content}`} variant='document' />, {
wrapper: mockAppRoot().build(),
});
expect(screen.getByRole('code').outerHTML).toEqual(expected);
});
});
describe('line breaks handling', () => {
it('should convert newlines to <br> in document variant', () => {
const content = 'First Line\nSecond Line\nThird Line';
const { container } = render(<MarkdownText content={content} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const html = container.innerHTML;
expect(html).toContain('First Line<br>Second Line<br>Third Line');
});
it('should convert newlines to <br> in inline variant', () => {
const content = 'First Line\nSecond Line\nThird Line';
const { container } = render(<MarkdownText content={content} variant='inline' />, {
wrapper: mockAppRoot().build(),
});
const html = container.innerHTML;
expect(html).not.toContain('<br>');
});
it('should not convert newlines to <br> in inlineWithoutBreaks variant', () => {
const content = 'First Line\nSecond Line\nThird Line';
const { container } = render(<MarkdownText content={content} variant='inlineWithoutBreaks' />, {
wrapper: mockAppRoot().build(),
});
const html = container.innerHTML;
expect(html).not.toContain('<br>');
});
});
describe('DOMPurify hook registration', () => {
it('should register hook only once at module level', () => {
// Import the module to trigger hook registration
const addHookSpy = jest.spyOn(dompurify, 'addHook');
// Clear any previous calls from module initialization
addHookSpy.mockClear();
const { rerender, unmount } = render(<MarkdownText content='[Test Link](https://example.com)' variant='document' />, {
wrapper: mockAppRoot().build(),
});
// Hook should NOT be registered during component render (it's registered at module level)
expect(addHookSpy).toHaveBeenCalledTimes(0);
// Re-rendering with different props should not register hook again
rerender(<MarkdownText content='[Another Link](https://example.com)' variant='document' />);
expect(addHookSpy).toHaveBeenCalledTimes(0);
// Rendering another instance should not register hook again
render(<MarkdownText content='[Third Link](https://test.com)' variant='inline' />, {
wrapper: mockAppRoot().build(),
});
expect(addHookSpy).toHaveBeenCalledTimes(0);
// Unmounting should not affect the module-level hook
unmount();
expect(addHookSpy).toHaveBeenCalledTimes(0);
addHookSpy.mockRestore();
});
});
describe('parser limit handling', () => {
beforeEach(() => {
getMarkdownParserLimitMock.mockClear();
});
it('should render plain text without parsing when content exceeds the limit', () => {
getMarkdownParserLimitMock.mockReturnValue(5);
const longContent = '**this should not be bold** because it exceeds the limit';
render(<MarkdownText content={longContent} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const element = screen.getByText(longContent);
expect(element).toBeInTheDocument();
expect(element.tagName).not.toBe('STRONG');
});
it('should render parsed markdown when content is within the limit', () => {
getMarkdownParserLimitMock.mockReturnValue(Infinity);
const content = '**bold text**';
render(<MarkdownText content={content} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const boldText = screen.getByText('bold text');
expect(boldText).toBeInTheDocument();
expect(boldText.tagName).toBe('STRONG');
});
it('should render parsed markdown when content length equals the limit', () => {
const content = '**hi**';
getMarkdownParserLimitMock.mockReturnValue(content.length);
render(<MarkdownText content={content} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const parsedText = screen.getByText('hi');
expect(parsedText).toBeInTheDocument();
expect(parsedText.tagName).toBe('STRONG');
});
});
describe('emoji parsing', () => {
// Minimal stand-in for the native emoji package that turns the `:smile:`
// shortcode into the same markup the real renderer produces.
const renderSmile = (text: string): string => text.replace(/:smile:/g, '<span class="emoji" title=":smile:">😄</span>');
beforeAll(() => {
emoji.packages.native = {
emojiCategories: [],
emojisByCategory: {},
toneList: {},
render: renderSmile,
renderPicker: () => undefined,
};
});
afterAll(() => {
delete emoji.packages.native;
});
beforeEach(() => {
getMarkdownParserLimitMock.mockReturnValue(Infinity);
});
it('should render emoji shortcodes that appear in visible text', () => {
const { container } = render(<MarkdownText content='hello :smile:' variant='document' parseEmoji />, {
wrapper: mockAppRoot().build(),
});
const emojiElement = container.querySelector('.emoji');
expect(emojiElement).toBeInTheDocument();
expect(emojiElement).toHaveAttribute('title', ':smile:');
expect(emojiElement).toHaveTextContent('😄');
expect(container).not.toHaveTextContent(':smile:');
});
it('should not corrupt a link whose href contains an emoji shortcode', () => {
render(<MarkdownText content='[docs](https://example.test/:smile:)' variant='document' parseEmoji />, {
wrapper: mockAppRoot().build(),
});
const anchorElement = screen.getByText('docs');
expect(anchorElement.tagName).toBe('A');
// The shortcode inside the href must stay untouched instead of being replaced by an emoji span
expect(anchorElement).toHaveAttribute('href', 'https://example.test/:smile:');
expect(anchorElement.querySelector('.emoji')).toBeNull();
});
it('should render emoji in text while keeping a shortcode inside an href intact', () => {
const { container } = render(<MarkdownText content=':smile: [docs](https://example.test/:smile:)' variant='document' parseEmoji />, {
wrapper: mockAppRoot().build(),
});
const anchorElement = screen.getByText('docs');
expect(anchorElement).toHaveAttribute('href', 'https://example.test/:smile:');
expect(anchorElement.querySelector('.emoji')).toBeNull();
const emojiElement = container.querySelector('.emoji');
expect(emojiElement).toBeInTheDocument();
expect(emojiElement).toHaveTextContent('😄');
});
});

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/react';
import outdent from 'outdent';
import MarkdownText from './MarkdownText';
const meta = {
component: MarkdownText,
parameters: {
layout: 'padded',
controls: { hideNoControlsWarning: true },
},
} satisfies Meta<typeof MarkdownText>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
content: outdent`
# h1 Heading
## h2 Heading
### h3 Heading
#### h4 Heading
##### h5 Heading
###### h6 Heading
___
*This is bold text*
_This is italic text_
~Strikethrough~
+ Lorem ipsum dolor sit amet
+ Consectetur adipiscing elit
+ Integer molestie lorem at massa
1. Lorem ipsum dolor sit amet
2. Consectetur adipiscing elit
3. Integer molestie lorem at massa
\`rocket.chat();\`
https://rocket.chat
`,
},
};
export const Document: Story = {
args: {
content: outdent`
# Title
Paragraph text.
## Subtitle
- List item 1
- List item 2
- List item 3
\`2 < 3 > 1 & 4 "Test"\`
\`< = &lt; > = &gt; & = &amp;\`
\`\`\`
Two < Three > One & Four "Test"
\`\`\`
\`\`\`
< : &lt;
> : &gt;
& : &amp;
\`\`\`
`,
variant: 'document',
},
};
export const Inline: Story = {
args: {
content: outdent`
# Title
Paragraph text.
## Subtitle
- List item 1
- List item 2
- List item 3
`,
variant: 'inline',
},
};
export const InlineWithoutBreaks: Story = {
args: {
content: outdent`
# Title
Paragraph text.
## Subtitle
- List item 1
- List item 2
- List item 3
`,
variant: 'inlineWithoutBreaks',
},
};

View File

@@ -0,0 +1,52 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import MarkdownTextInner from './MarkdownTextInner';
import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
export { supportedURISchemes } from './MarkdownTextInner';
type MarkdownTextParams = {
content: string;
variant: 'inline' | 'inlineWithoutBreaks' | 'document';
preserveHtml: boolean;
parseEmoji: boolean;
withTruncatedText: boolean;
} & ComponentProps<typeof Box>;
export type MarkdownTextProps = Partial<MarkdownTextParams>;
const preserveLineBreaks = css`
white-space: pre-line;
`;
const MarkdownText = ({ content, withTruncatedText = false, variant, preserveHtml, parseEmoji, ...boxProps }: MarkdownTextProps) => {
if (content && content.length > getMarkdownParserLimit()) {
// `document` parses with `breaks: true`, so its line breaks have to survive the unparsed
// fallback; the inline variants collapse them on purpose, and truncation needs a single line.
const keepLineBreaks = (variant ?? 'document') === 'document' && !withTruncatedText;
return (
<Box
withTruncatedText={withTruncatedText}
{...boxProps}
className={[boxProps.className, keepLineBreaks && preserveLineBreaks].flat()}
>
{content}
</Box>
);
}
return (
<MarkdownTextInner
content={content}
withTruncatedText={withTruncatedText}
variant={variant}
preserveHtml={preserveHtml}
parseEmoji={parseEmoji}
{...boxProps}
/>
);
};
export default MarkdownText;

View File

@@ -0,0 +1,212 @@
import { Box } from '@rocket.chat/fuselage';
import { isExternal, getBaseURI } from '@rocket.chat/ui-client';
import dompurify from 'dompurify';
import { marked } from 'marked';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji';
type MarkdownTextParams = {
content: string;
variant: 'inline' | 'inlineWithoutBreaks' | 'document';
preserveHtml: boolean;
parseEmoji: boolean;
withTruncatedText: boolean;
} & ComponentProps<typeof Box>;
const documentRenderer = new marked.Renderer();
const inlineRenderer = new marked.Renderer();
const inlineWithoutBreaks = new marked.Renderer();
const walkTokens = (token: marked.Token) => {
const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/;
const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/;
if (boldPattern.test(token.raw) && token.type === 'em') {
token.type = 'strong' as 'em';
} else if (italicPattern.test(token.raw) && token.type === 'strong') {
token.type = 'em' as 'strong';
}
};
marked.use({ walkTokens });
const linkMarked = (href: string | null, _title: string | null, text: string): string => {
return `<a href="${href || ''}">${text}</a>`;
};
const paragraphMarked = (text: string): string => text;
const brMarked = (): string => ' ';
const listItemMarked = (text: string): string => {
const cleanText = text.replace(/<p.*?>|<\/p>/gi, '');
return `<li>${cleanText}</li>`;
};
const horizontalRuleMarked = (): string => '';
const codeMarked = (code: string, language: string | undefined, _isEscaped: boolean): string => {
if (language) {
return `<pre><code class="language-${language}">${code} </code></pre>`;
}
return `<pre><code>${code} </code></pre>`;
};
const codespanMarked = (code: string): string => {
return `<code>${code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')}</code>`;
};
documentRenderer.link = linkMarked;
documentRenderer.listitem = listItemMarked;
documentRenderer.code = codeMarked;
documentRenderer.codespan = codespanMarked;
inlineRenderer.link = linkMarked;
inlineRenderer.paragraph = paragraphMarked;
inlineRenderer.listitem = listItemMarked;
inlineRenderer.hr = horizontalRuleMarked;
inlineWithoutBreaks.link = linkMarked;
inlineWithoutBreaks.paragraph = paragraphMarked;
inlineWithoutBreaks.br = brMarked;
inlineWithoutBreaks.image = brMarked;
inlineWithoutBreaks.code = paragraphMarked;
inlineWithoutBreaks.codespan = paragraphMarked;
inlineWithoutBreaks.listitem = listItemMarked;
inlineWithoutBreaks.hr = horizontalRuleMarked;
const defaultOptions = {
gfm: true,
headerIds: false,
};
const options = {
...defaultOptions,
breaks: true,
renderer: documentRenderer,
};
const inlineOptions = {
...defaultOptions,
renderer: inlineRenderer,
};
const inlineWithoutBreaksOptions = {
...defaultOptions,
renderer: inlineWithoutBreaks,
};
const getRegexp = (supportedURISchemes: string[]): RegExp => {
const schemes = supportedURISchemes.join('|');
return new RegExp(`^(${schemes}):`, 'im');
};
export type MarkdownTextInnerProps = Partial<MarkdownTextParams>;
export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid'];
const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a';
// Generate a unique token at runtime to prevent enumeration attacks
// This token marks internal links that need translation
const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`;
// Register the DOMPurify hook once at module level to prevent memory leaks
// This hook will be shared by all MarkdownText component instances
dompurify.addHook('afterSanitizeAttributes', (node) => {
if (!isLinkElement(node)) {
return;
}
const href = node.getAttribute('href') || '';
const isExternalLink = isExternal(href);
const isMailto = href.startsWith('mailto:');
// Set appropriate attributes based on link type
if (isExternalLink || isMailto) {
node.setAttribute('rel', 'nofollow noopener noreferrer');
// Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat
// This attribute must be preserved to guarantee users maintain their chat context
node.setAttribute('target', '_blank');
}
// Set appropriate title based on link type
if (isMailto) {
// For mailto links, use the email address as the title for better user experience
// Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com"
node.setAttribute('title', href);
} else if (isExternalLink) {
// For external links, set an empty title to prevent tooltips
// This reduces visual clutter and lets users see the URL in the browser's status bar instead
node.setAttribute('title', '');
} else {
// For internal links, use a token that will be replaced with translated text in the component
// This allows us to use the contextualized translation function
const relativePath = href.replace(getBaseURI(), '');
node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`);
}
});
const MarkdownTextInner = ({
content,
variant = 'document',
withTruncatedText = false,
preserveHtml = false,
parseEmoji = false,
...boxProps
}: MarkdownTextInnerProps) => {
const sanitizer = dompurify.sanitize;
const { t } = useTranslation();
let markedOptions: marked.MarkedOptions;
switch (variant) {
case 'inline':
markedOptions = inlineOptions;
break;
case 'inlineWithoutBreaks':
markedOptions = inlineWithoutBreaksOptions;
break;
case 'document':
default:
markedOptions = options;
}
const __html = useMemo(() => {
const html = ((): any => {
if (content && typeof content === 'string') {
const markedHtml = /inline/.test(variant)
? marked.parseInline(new Option(content).innerHTML, markedOptions)
: marked.parse(new Option(content).innerHTML, markedOptions);
if (parseEmoji) {
// We are using the old emoji parser here. This could come
// with additional processing use, but is the workaround available right now.
// Should be replaced in the future with the new parser.
return renderMessageEmoji(markedHtml);
}
return markedHtml;
}
})();
const sanitizedHtml = preserveHtml
? html
: html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) });
// Replace internal link tokens with contextualized translations
if (sanitizedHtml && typeof sanitizedHtml === 'string') {
return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href }));
}
return sanitizedHtml;
}, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]);
return __html ? (
<Box
dangerouslySetInnerHTML={{ __html }}
withTruncatedText={withTruncatedText}
withRichContent={variant === 'inlineWithoutBreaks' ? 'inlineWithoutBreaks' : true}
{...boxProps}
/>
) : null;
};
export default MarkdownTextInner;

View File

@@ -0,0 +1,34 @@
import { Box, States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
type NotFoundProps = {
title: string;
subtitle: string;
};
const NotFoundState = ({ title, subtitle }: NotFoundProps) => {
const { t } = useTranslation();
const router = useRouter();
const handleGoHomeClick = () => {
router.navigate('/home');
};
return (
<Box display='flex' justifyContent='center' height='full'>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{title}</StatesTitle>
<StatesSubtitle>{subtitle}</StatesSubtitle>
<Box marginBlockStart={16}>
<StatesActions>
<StatesAction onClick={handleGoHomeClick}>{t('Homepage')}</StatesAction>
</StatesActions>
</Box>
</States>
</Box>
);
};
export default NotFoundState;

View File

@@ -0,0 +1,25 @@
import { Box, Button, ButtonGroup, Skeleton } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
const PageSkeleton = () => (
<Page>
<PageHeader title={<Skeleton width='x320' maxWidth='full' />}>
<ButtonGroup>
<Button disabled>
<Skeleton width='x80' />
</Button>
</ButtonGroup>
</PageHeader>
<PageContent>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
<Box is='p' color='hint' fontScale='p2'>
<Skeleton />
<Skeleton />
<Skeleton width='75%' />
</Box>
</Box>
</PageContent>
</Page>
);
export default PageSkeleton;

View File

@@ -0,0 +1,216 @@
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import * as React from 'react';
import { Children, forwardRef, isValidElement } from 'react';
import PaginatedVirtualList from './PaginatedVirtualList';
const mockVirtualizerHandle = {
scrollToIndex: jest.fn(),
scrollTo: jest.fn(),
findItemIndex: jest.fn((offset: number) => offset),
scrollOffset: 0,
scrollSize: 1000,
viewportSize: 300,
};
type MockVListProps = {
children: ReactNode;
bufferSize?: number;
onScroll?: (offset: number) => void;
as?: React.ElementType;
item?: React.ElementType;
style?: CSSProperties;
className?: string;
};
jest.mock('virtua', () => {
return {
Virtualizer: React.forwardRef(
(
{ children, bufferSize, onScroll, as: asRoot = 'div', item: asItem = 'div', style, className }: MockVListProps,
ref: React.Ref<unknown>,
) => {
React.useImperativeHandle(ref, () => mockVirtualizerHandle);
const Root = asRoot;
const Item = asItem;
const wrapped = Children.map(children, (child, index) => {
const key = isValidElement(child) && child.key != null ? String(child.key) : `row-${index}`;
return <Item key={key}>{child}</Item>;
});
return (
<Root
className={className}
data-buffer-size={bufferSize}
data-testid='virtual-list'
style={style ?? { height: '100%' }}
onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)}
>
{wrapped}
</Root>
);
},
),
};
});
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'),
CustomVirtuaScrollbars: forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function CustomVirtuaScrollbars(
{ children, ...props },
ref,
) {
// eslint-disable-next-line testing-library/no-node-access
const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children;
return (
<div ref={ref} {...props}>
{content}
</div>
);
}),
}));
const items = Array.from({ length: 10 }, (_, index) => ({ _id: `${index}` }));
type VirtualListTestItem = (typeof items)[number];
const renderVirtualList = (
props: Partial<{
items: VirtualListTestItem[];
totalCount: number;
renderItem: (item: VirtualListTestItem, index: number) => ReactNode;
overscan?: number;
onEndReached?: UseInfiniteQueryResult['fetchNextPage'];
}> = {},
) => render(<PaginatedVirtualList items={items} totalCount={20} renderItem={(item) => <div>{item._id}</div>} {...props} />);
const advanceDebouncedScroll = async () => {
await act(async () => {
await jest.advanceTimersByTimeAsync(300);
});
};
describe('PaginatedVirtualList', () => {
beforeEach(() => {
mockVirtualizerHandle.scrollOffset = 0;
mockVirtualizerHandle.scrollSize = 1000;
mockVirtualizerHandle.viewportSize = 300;
});
afterEach(() => {
jest.useRealTimers();
});
it('has no accessibility violations', async () => {
const { container } = renderVirtualList();
expect(await axe(container)).toHaveNoViolations();
});
it('calls onEndReached when scrolled near the bottom', async () => {
jest.useFakeTimers();
const onEndReached = jest.fn().mockResolvedValue(undefined);
renderVirtualList({ onEndReached });
expect(onEndReached).not.toHaveBeenCalled();
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(1);
});
it('does not call onEndReached when all items are loaded', () => {
const onEndReached = jest.fn().mockResolvedValue(undefined);
renderVirtualList({ onEndReached, totalCount: items.length });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
expect(onEndReached).not.toHaveBeenCalled();
});
it('does not call onEndReached repeatedly for the same item count', async () => {
jest.useFakeTimers();
const onEndReached = jest.fn().mockResolvedValue(undefined);
renderVirtualList({ onEndReached });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(1);
});
it('calls onEndReached after a same-size dataset reset', async () => {
jest.useFakeTimers();
const onEndReached = jest.fn().mockResolvedValue(undefined);
const { rerender } = renderVirtualList({ onEndReached });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
const resetItems = Array.from({ length: 10 }, (_, index) => ({ _id: `reset-${index}` }));
rerender(
<PaginatedVirtualList items={resetItems} totalCount={20} renderItem={(item) => <div>{item._id}</div>} onEndReached={onEndReached} />,
);
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(2);
});
it('passes overscan through to virtua buffer size', () => {
renderVirtualList({ overscan: 25 });
expect(screen.getByTestId('virtual-list')).toHaveAttribute('data-buffer-size', '25');
});
it('allows onEndReached to retry after a failed load', async () => {
jest.useFakeTimers();
const onEndReached = jest.fn().mockRejectedValue(new Error('failed to load more items'));
renderVirtualList({ onEndReached });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(2);
});
it('allows onEndReached to retry after a synchronous throw', async () => {
jest.useFakeTimers();
const onEndReached = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('failed to load more items');
})
.mockImplementation(() => undefined);
renderVirtualList({ onEndReached });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(2);
});
it('calls onEndReached when the viewport is underfilled', () => {
const onEndReached = jest.fn().mockResolvedValue(undefined);
mockVirtualizerHandle.scrollSize = 200;
renderVirtualList({ onEndReached });
expect(onEndReached).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,103 @@
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { CustomVirtuaScrollbars } from '@rocket.chat/ui-client';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef } from 'react';
import type { VirtualizerHandle } from 'virtua';
import { Virtualizer } from 'virtua';
import { VirtuaListContainer } from './VirtuaListContainer';
const NEAR_BOTTOM_THRESHOLD = -20;
const scrollViewportStyle = {
height: '100%',
width: '100%',
overflow: 'auto',
} as const;
type PaginatedVirtualListProps<T extends { _id: string }> = {
items: T[];
totalCount: number;
renderItem: (item: T, index: number) => ReactNode;
overscan?: number;
onEndReached?: UseInfiniteQueryResult['fetchNextPage'];
};
function PaginatedVirtualList<T extends { _id: string }>({
items,
totalCount,
renderItem,
overscan,
onEndReached,
}: PaginatedVirtualListProps<T>) {
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
const isEndReachedLockedRef = useRef(false);
const firstItemId = items[0]?._id ?? '';
const lastItemId = items[items.length - 1]?._id ?? '';
const checkEndReached = useCallback(
async (offset: number) => {
if (isEndReachedLockedRef.current) {
return;
}
const handle = virtualizerRef.current;
if (!handle || !onEndReached) {
return;
}
const { scrollSize, viewportSize } = handle;
if (viewportSize <= 0 || items.length >= totalCount) {
return;
}
const nearBottom = offset - scrollSize + viewportSize >= NEAR_BOTTOM_THRESHOLD;
if (!nearBottom) {
return;
}
isEndReachedLockedRef.current = true;
try {
await onEndReached();
} catch {
isEndReachedLockedRef.current = false;
}
},
[items.length, onEndReached, totalCount],
);
const handleScroll = useDebouncedCallback(
(offset: number) => {
checkEndReached(offset);
},
300,
[checkEndReached],
);
useLayoutEffect(() => {
isEndReachedLockedRef.current = false;
const handle = virtualizerRef.current;
if (!handle) {
return;
}
checkEndReached(handle.scrollOffset);
}, [checkEndReached, firstItemId, items.length, lastItemId, totalCount]);
return (
<CustomVirtuaScrollbars>
<div style={scrollViewportStyle}>
<Virtualizer ref={virtualizerRef} as={VirtuaListContainer} item='li' bufferSize={overscan} onScroll={handleScroll}>
{items.map((item, index) => (
<div key={item._id}>{renderItem(item, index)}</div>
))}
</Virtualizer>
</div>
</CustomVirtuaScrollbars>
);
}
export default PaginatedVirtualList;

View File

@@ -0,0 +1,24 @@
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
const listResetStyle = {
margin: 0,
padding: 0,
listStyle: 'none',
} as const;
export type VirtuaListContainerProps = {
children: ReactNode;
style: CSSProperties;
} & Omit<HTMLAttributes<HTMLUListElement>, 'children' | 'style'>;
export const VirtuaListContainer = forwardRef<HTMLUListElement, VirtuaListContainerProps>(function VirtuaListContainer(
{ children, style, ...props },
ref,
) {
return (
<ul {...props} ref={ref} style={{ ...listResetStyle, ...style }}>
{children}
</ul>
);
});

View File

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

View File

@@ -0,0 +1,26 @@
import { Box, Tag } from '@rocket.chat/fuselage';
import { isTruthy } from '@rocket.chat/tools';
import { useLicense } from '@rocket.chat/ui-client';
const developmentTag = process.env.NODE_ENV === 'development' ? 'Development' : null;
function PlanTag() {
const license = useLicense();
const tags = [
developmentTag && { name: developmentTag },
...(license.data?.tags ?? []),
!license.isLoading && !license.isError && !license.data?.license && { name: 'Community' },
].filter(isTruthy);
return (
<>
{tags.map(({ name }) => (
<Box marginInline={4} display='inline-block' verticalAlign='middle' key={name}>
<Tag variant='primary'>{name}</Tag>
</Box>
))}
</>
);
}
export default PlanTag;

View File

@@ -0,0 +1,8 @@
import DOMPurify from 'dompurify';
/** @deprecated */
export type RawTextProps = { children: string };
const RawText = ({ children }: RawTextProps) => <span dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(children) }} />;
export default RawText;

View File

@@ -0,0 +1,28 @@
import { VisuallyHidden } from '@react-aria/visually-hidden';
import { useTranslation } from 'react-i18next';
const ResultsLiveRegion = ({
shouldAnnounce,
itemCount,
isLoading = false,
}: {
shouldAnnounce: boolean;
itemCount: number;
isLoading?: boolean;
}) => {
const { t } = useTranslation();
const message = (() => {
if (isLoading) {
return t('Loading');
}
if (!shouldAnnounce) {
return '';
}
return itemCount === 0 ? t('No_results_found') : t('__count__result_found', { count: itemCount });
})();
return <VisuallyHidden role='status'>{message}</VisuallyHidden>;
};
export default ResultsLiveRegion;

View File

@@ -0,0 +1,107 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Option, Box } from '@rocket.chat/fuselage';
import type { AutoCompleteProps } from '@rocket.chat/fuselage';
import { AutoComplete } from '@rocket.chat/fuselage-forms';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { forwardRef, memo, useMemo, useState } from 'react';
type LabelType = { name: string; avatarETag?: string; type: IRoom['t']; encrypted?: IRoom['encrypted'] };
const generateQuery = (
term = '',
): {
selector: string;
} => ({ selector: JSON.stringify({ name: term }) });
export type RoomAutoCompleteProps = Omit<AutoCompleteProps<LabelType>, 'filter'> & {
scope?: 'admin' | 'regular';
renderRoomIcon?: (props: { encrypted: IRoom['encrypted']; type: IRoom['t'] }) => ReactNode;
setSelectedRoom?: (room: IRoom | undefined) => void;
};
const AVATAR_SIZE = 'x20';
const ROOM_AUTOCOMPLETE_PARAMS = {
admin: {
endpoint: '/v1/rooms.autocomplete.adminRooms',
key: 'roomsAutoCompleteAdmin',
},
regular: {
endpoint: '/v1/rooms.autocomplete.channelAndPrivate',
key: 'roomsAutoCompleteRegular',
},
} as const;
const RoomAutoComplete = forwardRef<HTMLInputElement, RoomAutoCompleteProps>(function RoomAutoComplete(
{ value, onChange, scope = 'regular', renderRoomIcon, setSelectedRoom, ...props },
ref,
) {
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const roomsAutoCompleteEndpoint = useEndpoint('GET', ROOM_AUTOCOMPLETE_PARAMS[scope].endpoint);
const result = useQuery({
queryKey: [ROOM_AUTOCOMPLETE_PARAMS[scope].key, filterDebounced],
queryFn: () => roomsAutoCompleteEndpoint(generateQuery(filterDebounced)),
placeholderData: keepPreviousData,
});
const options = useMemo(
() =>
result.isSuccess
? result.data.items.map(({ name, fname, _id, avatarETag, t, encrypted }) => ({
value: _id,
label: { name: fname || name, avatarETag, type: t, encrypted },
}))
: [],
[result.data?.items, result.isSuccess],
);
return (
<AutoComplete
{...props}
ref={ref}
value={value}
onChange={(val) => {
onChange(val);
if (setSelectedRoom && typeof setSelectedRoom === 'function') {
const selectedRoom = result?.data?.items.find(({ _id }) => _id === val) as unknown as IRoom;
setSelectedRoom(selectedRoom);
}
}}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { value, label } }) => (
<>
<Box margin='none' marginInline={2}>
<RoomAvatar size={AVATAR_SIZE} room={{ ...label, type: label?.type || 'c', _id: value }} />
</Box>
<Box margin='none' marginInline={2}>
{label?.name}
</Box>
{renderRoomIcon?.({ encrypted: label?.encrypted, type: label?.type })}
</>
)}
renderItem={({ value, label, ...props }) => (
<Option
{...props}
label={
<>
{label?.name}
{renderRoomIcon?.({ encrypted: label?.encrypted, type: label?.type })}
</>
}
avatar={<RoomAvatar size={AVATAR_SIZE} room={{ _id: value, ...label }} />}
/>
)}
options={options}
/>
);
});
export default memo(RoomAutoComplete);

View File

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

View File

@@ -0,0 +1,74 @@
import { AutoComplete, Option, Chip, Box, Skeleton } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
const generateQuery = (
term = '',
): {
selector: string;
} => ({ selector: JSON.stringify({ name: term }) });
type RoomAutoCompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'> & {
readOnly?: boolean;
};
const RoomAutoCompleteMultiple = ({ value, onChange, ...props }: RoomAutoCompleteProps) => {
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const autocomplete = useEndpoint('GET', '/v1/rooms.autocomplete.channelAndPrivate');
const result = useQuery({
queryKey: ['rooms.autocomplete.channelAndPrivate', filterDebounced],
queryFn: () => autocomplete(generateQuery(filterDebounced)),
placeholderData: keepPreviousData,
});
const options = useMemo(
() =>
result.isSuccess
? result.data.items.map(({ fname, name, _id, avatarETag, t }) => ({
value: _id,
label: { name: fname || name, avatarETag, type: t },
}))
: [],
[result.data?.items, result.isSuccess],
);
if (result.isPending) {
return <Skeleton />;
}
return (
<AutoComplete
{...props}
value={value}
onChange={onChange}
filter={filter}
setFilter={setFilter}
multiple
renderSelected={({ selected: { value, label }, onRemove, ...props }) => (
<Chip {...props} key={value} value={value} onClick={onRemove}>
<RoomAvatar size='x20' room={{ ...label, type: label?.type || 'c', _id: value }} />
<Box is='span' margin='none' marginInlineStart={4}>
{label?.name}
</Box>
</Chip>
)}
renderItem={({ value, label, ...props }) => (
<Option
key={value}
{...props}
label={label.name}
avatar={<RoomAvatar size='x20' room={{ ...label, type: label?.type || 'c', _id: value }} />}
/>
)}
options={options}
/>
);
};
export default memo(RoomAutoCompleteMultiple);

View File

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

View File

@@ -0,0 +1,29 @@
import type { IOmnichannelSourceFromApp } from '@rocket.chat/core-typings';
import { Icon, Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useOmnichannelRoomIcon } from './context/OmnichannelRoomIconContext';
export type OmnichannelAppSourceRoomIconProps = {
source: IOmnichannelSourceFromApp;
color: ComponentProps<typeof Box>['color'];
size: ComponentProps<typeof Icon>['size'];
placement: 'sidebar' | 'default';
};
export const OmnichannelAppSourceRoomIcon = ({ source, color, size, placement }: OmnichannelAppSourceRoomIconProps) => {
const icon = (placement === 'sidebar' && source.sidebarIcon) || source.defaultIcon;
const value = useOmnichannelRoomIcon(source.id, icon || '');
if (!value) {
return <Icon name='headset' size={size} color={color} />;
}
return (
<Box size={size} color={color}>
<Box is='svg' size={size} aria-hidden='true'>
<Box is='use' href={`#${value}`} />
</Box>
</Box>
);
};

View File

@@ -0,0 +1,23 @@
import type { IOmnichannelSource } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
const iconMap = {
widget: 'livechat',
email: 'mail',
sms: 'sms',
app: 'headset',
api: 'headset',
other: 'headset',
} as const;
export type OmnichannelCoreSourceRoomIconProps = {
source: IOmnichannelSource;
color: ComponentProps<typeof Icon>['color'];
size: ComponentProps<typeof Icon>['size'];
};
export const OmnichannelCoreSourceRoomIcon = ({ source, color, size }: OmnichannelCoreSourceRoomIconProps) => {
const icon = iconMap[source?.type || 'other'] || 'headset';
return <Icon name={icon} size={size} color={color} />;
};

View File

@@ -0,0 +1,33 @@
import type { IOmnichannelSource } from '@rocket.chat/core-typings';
import { UserStatus, isOmnichannelSourceFromApp } from '@rocket.chat/core-typings';
import type { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { OmnichannelAppSourceRoomIcon } from './OmnichannelAppSourceRoomIcon';
import { OmnichannelCoreSourceRoomIcon } from './OmnichannelCoreSourceRoomIcon';
const colors = {
busy: 'status-font-on-danger',
away: 'status-font-on-warning',
online: 'status-font-on-success',
offline: 'annotation',
disabled: 'annotation',
} as const;
export type OmnichannelRoomIconProps = {
source: IOmnichannelSource;
color?: ComponentProps<typeof Icon>['color'];
status?: UserStatus;
size: ComponentProps<typeof Icon>['size'];
placement?: 'sidebar' | 'default';
};
export const OmnichannelRoomIcon = ({ source, color, status, size = 'x16', placement = 'default' }: OmnichannelRoomIconProps) => {
const iconColor = color ?? colors[status || UserStatus.OFFLINE];
if (isOmnichannelSourceFromApp(source)) {
return <OmnichannelAppSourceRoomIcon source={source} placement={placement} color={iconColor} size={size} />;
}
return <OmnichannelCoreSourceRoomIcon source={source} color={iconColor} size={size} />;
};

View File

@@ -0,0 +1,15 @@
import { createContext, useMemo, useContext, useSyncExternalStore } from 'react';
type IOmnichannelRoomIconContext = {
queryIcon(app: string, icon: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined];
};
export const OmnichannelRoomIconContext = createContext<IOmnichannelRoomIconContext>({
queryIcon: () => [(): (() => void) => (): void => undefined, () => undefined],
});
export const useOmnichannelRoomIcon = (app: string, icon: string): string | undefined => {
const { queryIcon } = useContext(OmnichannelRoomIconContext);
const [subscribe, getSnapshot] = useMemo(() => queryIcon(app, icon), [app, queryIcon, icon]);
return useSyncExternalStore(subscribe, getSnapshot);
};

View File

@@ -0,0 +1 @@
export * from './OmnichannelRoomIcon';

View File

@@ -0,0 +1,47 @@
import { Emitter } from '@rocket.chat/emitter';
import DOMPurify from 'dompurify';
import { sdk } from '../../../../lib/SDKClient';
const OmnichannelRoomIconManager = new (class extends Emitter {
icons = new Map<string, string>();
constructor() {
super();
}
public get(appId: string, icon: string): string | undefined {
if (!appId || !icon) {
return;
}
if (this.icons.has(`${appId}-${icon}`)) {
return `${appId}-${icon}`;
}
// TODO: update the apps icons to send JSON instead of a string. This will allow us to use APIClient.get()
sdk.rest
.send(`/apps/public/${appId}/get-sidebar-icon?icon=${icon}`, 'GET')
.then((response: any) => {
response.text().then((text: string) => {
this.icons.set(
`${appId}-${icon}`,
DOMPurify.sanitize(text, {
FORBID_ATTR: ['id'],
NAMESPACE: 'http://www.w3.org/2000/svg',
USE_PROFILES: { svg: true, svgFilters: true },
})
.replace(`<svg`, `<symbol id="${appId}-${icon}"`)
.replace(`</svg>`, '</symbol>'),
);
this.emit('change');
this.emit(`${appId}-${icon}`);
});
})
.catch((error: any) => {
console.error('error from get-sidebar-icon', error);
});
}
})();
export default OmnichannelRoomIconManager;

View File

@@ -0,0 +1,81 @@
import DOMPurify from 'dompurify';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { OmnichannelRoomIconContext } from '../context/OmnichannelRoomIconContext';
import OmnichannelRoomIconManager from '../lib/OmnichannelRoomIconManager';
let icons = Array.from(OmnichannelRoomIconManager.icons.values());
export type OmnichannelRoomIconProviderProps = {
children?: ReactNode;
};
export const OmnichannelRoomIconProvider = ({ children }: OmnichannelRoomIconProviderProps) => {
const svgIcons = useSyncExternalStore(
useCallback(
(callback): (() => void) =>
OmnichannelRoomIconManager.on('change', () => {
icons = Array.from(OmnichannelRoomIconManager.icons.values());
callback();
}),
[],
),
(): string[] => icons,
);
return (
<OmnichannelRoomIconContext.Provider
value={useMemo(() => {
const extractSnapshot = (app: string, iconName: string) => OmnichannelRoomIconManager.get(app, iconName);
// We cache all the icons here, so that we can use them in the OmnichannelRoomIcon component
const snapshots = new Map<string, string | undefined>();
return {
queryIcon: (
app: string,
iconName: string,
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined] => [
(callback): (() => void) =>
OmnichannelRoomIconManager.on(`${app}-${iconName}`, () => {
snapshots.set(`${app}-${iconName}`, extractSnapshot(app, iconName));
// Then we call the callback (onStoreChange), signaling React to re-render
callback();
}),
// No problem here, because it's return value is a cached in the snapshots map on subsequent calls
() => {
let snapshot = snapshots.get(`${app}-${iconName}`);
if (!snapshot) {
snapshot = extractSnapshot(app, iconName);
snapshots.set(`${app}-${iconName}`, snapshot);
}
return snapshot;
},
],
};
}, [])}
>
{createPortal(
<svg
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
style={{ display: 'none' }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(`<svg>${svgIcons.join('')}</svg>`, {
USE_PROFILES: { svg: true, svgFilters: true },
}).slice(5, -6),
}}
/>,
document.body,
'custom-icons',
)}
{children}
</OmnichannelRoomIconContext.Provider>
);
};

View File

@@ -0,0 +1,40 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { isValidElement } from 'react';
import { OmnichannelRoomIcon } from './OmnichannelRoomIcon';
import { useRoomIcon } from '../../hooks/useRoomIcon';
export const RoomIcon = ({
room,
size = 'x16',
isIncomingCall,
placement = 'default',
}: {
room: Pick<IRoom, 't' | 'prid' | 'teamMain' | 'uids' | 'u'>;
size?: ComponentProps<typeof Icon>['size'];
isIncomingCall?: boolean;
placement?: 'sidebar' | 'default';
}) => {
const iconPropsOrReactNode = useRoomIcon(room);
if (isIncomingCall) {
return <Icon name='phone' size={size} />;
}
if (isOmnichannelRoom(room)) {
return <OmnichannelRoomIcon placement={placement} source={room.source} status={room.v?.status} size={size} />;
}
if (isValidElement<any>(iconPropsOrReactNode)) {
return iconPropsOrReactNode;
}
if (!iconPropsOrReactNode) {
return null;
}
return <Icon {...iconPropsOrReactNode} size={size} />;
};

View File

@@ -0,0 +1 @@
export * from './RoomIcon';

View File

@@ -0,0 +1,17 @@
import { Box } from '@rocket.chat/fuselage';
import { CustomScrollbars } from '@rocket.chat/ui-client';
import type { ComponentPropsWithoutRef } from 'react';
export type ContentProps = ComponentPropsWithoutRef<typeof CustomScrollbars>;
const Content = ({ children, ...props }: ContentProps) => (
<Box display='flex' flexDirection='column' flexGrow={1} flexShrink={1} overflow='hidden'>
<CustomScrollbars {...props}>
<Box display='flex' flexDirection='column' width='full' minHeight='full'>
{children}
</Box>
</CustomScrollbars>
</Box>
);
export default Content;

View File

@@ -0,0 +1,31 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
export type HeaderProps = {
children?: ReactNode;
title?: ReactNode;
onClose?: () => void;
};
const Header = ({ title, onClose, children, ...props }: HeaderProps) => {
const { t } = useTranslation();
return (
<Box display='flex' flexDirection='column' paddingBlock={16} {...props}>
{(title || onClose) && (
<Box display='flex' flexDirection='row' alignItems='center' paddingInline={24} justifyContent='space-between' flexGrow={1}>
{title && (
<Box color='default' fontScale='p2b' flexShrink={1} withTruncatedText>
{title}
</Box>
)}
{onClose && <IconButton small aria-label={t('Close')} icon='cross' onClick={onClose} />}
</Box>
)}
{children}
</Box>
);
};
export default Header;

Some files were not shown because too many files have changed in this diff Show More