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,65 @@
import type { RoomType } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useEndpoint, useRouter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { UiTextContext } from '../../../definition/IRoomTypeConfig';
import WarningModal from '../../components/WarningModal';
import { LegacyRoomManager } from '../../lib/LegacyRoomManager';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
const leaveEndpoints = {
p: '/v1/groups.leave',
c: '/v1/channels.leave',
d: '/v1/im.leave',
v: '/v1/channels.leave',
l: '/v1/groups.leave',
} as const;
type LeaveRoomProps = {
rid: string;
type: RoomType;
name: string;
roomOpen?: boolean;
};
// TODO: this menu action should consider team leaving
export const useLeaveRoomAction = ({ rid, type, name, roomOpen }: LeaveRoomProps) => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const router = useRouter();
const leaveRoom = useEndpoint('POST', leaveEndpoints[type]);
const handleLeave = useStableCallback(() => {
const leave = async (): Promise<void> => {
try {
await leaveRoom({ roomId: rid });
if (roomOpen) {
router.navigate('/home');
}
LegacyRoomManager.close(rid);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.LEAVE_WARNING);
setModal(
<WarningModal
text={t(warnText as TranslationKey, { roomName: name })}
confirmText={t('Leave_room')}
close={() => setModal(null)}
cancelText={t('Cancel')}
confirm={leave}
/>,
);
});
return handleLeave;
};

View File

@@ -0,0 +1,18 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
export const useToggleFavoriteAction = ({ rid, isFavorite }: { rid: IRoom['_id']; isFavorite: boolean }) => {
const toggleFavorite = useEndpoint('POST', '/v1/rooms.favorite');
const dispatchToastMessage = useToastMessageDispatch();
const handleToggleFavorite = useStableCallback(async () => {
try {
await toggleFavorite({ roomId: rid, favorite: !isFavorite });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
return handleToggleFavorite;
};

View File

@@ -0,0 +1,30 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
type useToggleNotificationActionProps = {
rid: IRoom['_id'];
isNotificationEnabled: boolean;
roomName: string;
};
export const useToggleNotificationAction = ({ rid, isNotificationEnabled, roomName }: useToggleNotificationActionProps) => {
const toggleNotification = useEndpoint('POST', '/v1/rooms.saveNotification');
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const handleToggleNotification = useStableCallback(async () => {
try {
await toggleNotification({ roomId: rid, notifications: { disableNotifications: isNotificationEnabled ? '1' : '0' } });
dispatchToastMessage({
type: 'success',
message: t(isNotificationEnabled ? 'Room_notifications_off' : 'Room_notifications_on', { roomName }),
});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
return handleToggleNotification;
};

View File

@@ -0,0 +1,50 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useMarkAsUnreadMutation } from '../../components/message/hooks/useMarkAsUnreadMutation';
import { LegacyRoomManager } from '../../lib/LegacyRoomManager';
type ToggleReadActionProps = {
rid: string;
isUnread?: boolean;
subscription?: ISubscription;
};
export const useToggleReadAction = ({ rid, isUnread, subscription }: ToggleReadActionProps) => {
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const router = useRouter();
const readMessages = useEndpoint('POST', '/v1/subscriptions.read');
const unreadMessages = useMarkAsUnreadMutation();
const handleToggleRead = useStableCallback(async () => {
try {
queryClient.invalidateQueries({
queryKey: ['sidebar/search/spotlight'],
});
if (isUnread) {
await readMessages({ rid, readThreads: true });
return;
}
if (subscription == null) {
return;
}
LegacyRoomManager.close(subscription.t + subscription.name);
router.navigate('/home');
await unreadMessages.mutateAsync({ roomId: rid, subscription });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
return handleToggleRead;
};

View File

@@ -0,0 +1,43 @@
import type { INotificationDesktop } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useUser } from '@rocket.chat/ui-contexts';
import { useNotification } from './useNotification';
import { RoomManager } from '../../lib/RoomManager';
import { e2e } from '../../lib/e2ee';
import { getAvatarAsPng } from '../../lib/utils/getAvatarAsPng';
export const useDesktopNotification = () => {
const user = useUser();
const notify = useNotification();
const notifyDesktop = useStableCallback(async (notification: INotificationDesktop) => {
if (
notification.payload.rid === RoomManager.opened &&
(typeof window.document.hasFocus === 'function' ? window.document.hasFocus() : undefined)
) {
return;
}
if (user?.status === 'busy') {
return;
}
const { message } = notification.payload;
if (message.t === 'e2e' && message.content) {
const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid);
if (e2eRoom) {
const decrypted = await e2eRoom.decrypt(message.content);
// TODO(@cardoso): review backward compatibility
notification.text = decrypted.msg ?? '';
}
}
return getAvatarAsPng(notification.payload.sender?.username, (avatarAsPng) => {
notification.icon = avatarAsPng;
return notify(notification);
});
});
return notifyDesktop;
};

View File

@@ -0,0 +1,20 @@
import type { AtLeast, ISubscription } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useCustomSound } from '@rocket.chat/ui-contexts';
export const useNewMessageNotification = () => {
const { notificationSounds } = useCustomSound();
const notifyNewMessage = useStableCallback((sub: AtLeast<ISubscription, 'rid'>) => {
if (!sub || sub.audioNotificationValue === 'none') {
return;
}
if (sub?.audioNotificationValue) {
return notificationSounds.playNewMessageCustom(sub.audioNotificationValue);
}
notificationSounds.playNewMessage();
});
return notifyNewMessage;
};

View File

@@ -0,0 +1,123 @@
import type { INotificationDesktop } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { Random } from '@rocket.chat/random';
import { useRouter, useUserPreference } from '@rocket.chat/ui-contexts';
import { useNotificationAllowed } from './useNotificationAllowed';
import { stripTags } from '../../../lib/utils/stringUtils';
import { sdk } from '../../lib/SDKClient';
import { getUserAvatarURL } from '../../lib/getUserAvatarURL';
import { onClientMessageReceived } from '../../lib/onClientMessageReceived';
export const useNotification = () => {
const requireInteraction = useUserPreference('desktopNotificationRequireInteraction');
const router = useRouter();
const notificationAllowed = useNotificationAllowed();
const notify = useStableCallback(async (notification: INotificationDesktop) => {
if (!notificationAllowed) {
return;
}
if (!notification.payload) {
return;
}
const { rid, name: roomName, _id: msgId } = notification.payload;
if (!rid) {
return;
}
const message = await onClientMessageReceived({
rid,
msg: notification.text,
notification: true,
} as any);
const n = new Notification(notification.title, {
icon: notification.icon || getUserAvatarURL(notification.payload.sender?.username),
body: stripTags(message?.msg),
tag: msgId,
canReply: true,
silent: true,
requireInteraction,
} as NotificationOptions & {
canReply?: boolean;
});
const notificationDuration = !requireInteraction ? (notification.duration ?? 0) - 0 || 10 : -1;
if (notificationDuration > 0) {
setTimeout(() => n.close(), notificationDuration * 1000);
}
if (n.addEventListener) {
n.addEventListener(
'reply',
({ response }) =>
void sdk.rest.post('/v1/chat.sendMessage', {
message: {
_id: Random.id(),
rid,
msg: response,
},
}),
);
}
n.onclick = () => {
n.close();
window.focus();
if (!msgId || !rid || !roomName) {
return;
}
switch (notification.payload?.type) {
case 'd':
router.navigate({
pattern: '/direct/:rid/:tab?/:context?',
params: {
rid,
...(notification.payload.tmid && {
tab: 'thread',
context: notification.payload.tmid,
}),
},
search: { ...router.getSearchParameters(), jump: msgId },
});
break;
case 'c':
return router.navigate({
pattern: '/channel/:name/:tab?/:context?',
params: {
name: roomName,
...(notification.payload.tmid && {
tab: 'thread',
context: notification.payload.tmid,
}),
},
search: { ...router.getSearchParameters(), jump: msgId },
});
case 'p':
return router.navigate({
pattern: '/group/:name/:tab?/:context?',
params: {
name: roomName,
...(notification.payload.tmid && {
tab: 'thread',
context: notification.payload.tmid,
}),
},
search: { ...router.getSearchParameters(), jump: msgId },
});
case 'l':
return router.navigate({
pattern: '/live/:id/:tab?/:context?',
params: {
id: rid,
tab: 'room-info',
},
search: { ...router.getSearchParameters(), jump: msgId },
});
}
};
});
return notify;
};

View File

@@ -0,0 +1,19 @@
import { useCallback, useSyncExternalStore } from 'react';
import { notificationManager } from '../../lib/notificationManager';
export const useNotificationAllowed = (): boolean => {
const allowed = useSyncExternalStore(
useCallback(
(callback): (() => void) =>
notificationManager.on('change', () => {
notificationManager.allowed = Notification.permission === 'granted';
callback();
}),
[],
),
(): boolean => notificationManager.allowed,
);
return allowed;
};

View File

@@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { QuickActionsEnum, type QuickActionsActionConfig } from '../../views/room/lib/quickActions';
export const useChatForwardQuickAction = (): QuickActionsActionConfig => {
return useMemo(
() => ({
groups: ['live'],
id: QuickActionsEnum.ChatForward,
title: 'Forward_chat',
icon: 'balloon-arrow-top-right',
order: 2,
}),
[],
);
};

View File

@@ -0,0 +1,17 @@
import { useMemo } from 'react';
import { QuickActionsEnum, type QuickActionsActionConfig } from '../../views/room/lib/quickActions';
export const useCloseChatQuickAction = (): QuickActionsActionConfig => {
return useMemo(
() => ({
groups: ['live'],
id: QuickActionsEnum.CloseChat,
title: 'End_conversation',
icon: 'balloon-close-top-right',
order: 5,
color: 'danger',
}),
[],
);
};

View File

@@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { QuickActionsEnum, type QuickActionsActionConfig } from '../../views/room/lib/quickActions';
export const useMoveQueueQuickAction = (): QuickActionsActionConfig => {
return useMemo(
() => ({
groups: ['live'],
id: QuickActionsEnum.MoveQueue,
title: 'Move_queue',
icon: 'burger-arrow-left',
order: 1,
}),
[],
);
};

View File

@@ -0,0 +1,22 @@
import { useMemo } from 'react';
import { QuickActionsEnum, type QuickActionsActionConfig } from '../../views/room/lib/quickActions';
import { useHasLicenseModule } from '../useHasLicenseModule';
export const useOnHoldChatQuickAction = (): QuickActionsActionConfig | undefined => {
const { data: licensed = false } = useHasLicenseModule('livechat-enterprise');
return useMemo(() => {
if (!licensed) {
return undefined;
}
return {
groups: ['live'],
id: QuickActionsEnum.OnHoldChat,
title: 'Omnichannel_onHold_Chat',
icon: 'pause-unfilled',
order: 4,
};
}, [licensed]);
};

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react';
import type { QuickActionsActionConfig } from '../../views/room/lib/quickActions';
import { QuickActionsEnum } from '../../views/room/lib/quickActions';
export const useTranscriptQuickAction = (): QuickActionsActionConfig => {
return useMemo(
() => ({
groups: ['live'],
id: QuickActionsEnum.Transcript,
title: 'Send_transcript',
icon: 'mail-arrow-top-right',
order: 3,
options: [
{ label: 'Send_via_email', id: QuickActionsEnum.TranscriptEmail },
{
label: 'Export_as_PDF',
id: QuickActionsEnum.TranscriptPDF,
validate: (room) => ({
tooltip: 'Export_enabled_at_the_end_of_the_conversation',
value: !room?.open,
}),
},
],
}),
[],
);
};

View File

@@ -0,0 +1,82 @@
import { Box } from '@rocket.chat/fuselage';
import { GenericMenu, HeaderToolbarAction } from '@rocket.chat/ui-client';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Utilities } from '../../../ee/lib/misc/Utilities';
import { UiKitTriggerTimeoutError } from '../../lib/errors/UiKitTriggerTimeoutError';
import { useUiKitActionManager } from '../../uikit/hooks/useUiKitActionManager';
import { useRoom } from '../../views/room/contexts/RoomContext';
import { useAppActionButtons } from '../useAppActionButtons';
import { useApplyButtonFilters } from '../useApplyButtonFilters';
export const useAppsRoomStarActions = () => {
const result = useAppActionButtons('roomAction');
const actionManager = useUiKitActionManager();
const applyButtonFilters = useApplyButtonFilters('ai');
const room = useRoom();
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!result.data) {
return undefined;
}
const filteredActions = result.data.filter(applyButtonFilters);
if (filteredActions.length === 0) {
return undefined;
}
return {
id: 'ai-actions',
title: 'AI_Actions',
icon: 'stars',
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
featured: true,
order: 3,
renderToolboxItem: ({ id, icon, title, disabled, className }) => (
<GenericMenu
button={<HeaderToolbarAction icon={icon} />}
key={id}
title={title}
disabled={disabled}
items={filteredActions.map((action) => ({
id: action.actionId,
icon: undefined,
title: Utilities.getI18nKeyForApp(action.labelI18n, action.appId),
content: <Box is='span'>{t(`${Utilities.getI18nKeyForApp(action.labelI18n, action.appId)}`)}</Box>,
variant: action.variant,
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
onClick: () => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
actionId: action.actionId,
rid: room._id,
payload: { context: action.context },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
type: 'apps',
}))}
className={className}
placement='bottom-start'
icon={icon}
/>
),
};
}, [actionManager, applyButtonFilters, dispatchToastMessage, result.data, room._id, t]);
};

View File

@@ -0,0 +1,27 @@
import { useSetting, usePermission } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const AutoTranslate = lazy(() => import('../../views/room/contextualBar/AutoTranslate'));
export const useAutotranslateRoomAction = () => {
const permitted = usePermission('auto-translate');
const enabled = useSetting('AutoTranslate_Enabled', false);
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!permitted || !enabled) {
return undefined;
}
return {
id: 'autotranslate',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
title: 'Auto_Translate',
icon: 'language',
tabComponent: AutoTranslate,
order: 20,
full: true,
type: 'customization',
};
}, [enabled, permitted]);
};

View File

@@ -0,0 +1,29 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { usePermission } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useRoom } from '../../views/room/contexts/RoomContext';
const BannedUsers = lazy(() => import('../../views/room/contextualBar/BannedUsers'));
export const useBannedUsersRoomAction = () => {
const room = useRoom();
const hasPermissionToBan = usePermission('ban-user', room._id);
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!hasPermissionToBan) {
return undefined;
}
return {
id: 'banned-users',
groups: ['channel', 'group', 'team'],
title: 'Banned_Users',
icon: 'ban',
tabComponent: BannedUsers,
order: 13,
type: 'moderation',
};
}, [hasPermissionToBan]);
};

View File

@@ -0,0 +1,35 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { RoomContext } from '../../views/room/contexts/RoomContext';
import { useHasLicenseModule } from '../useHasLicenseModule';
const VideoConfList = lazy(() => import('../../views/room/contextualBar/VideoConference/VideoConfList'));
export const useCallsRoomAction = () => {
const { data: licensed = false } = useHasLicenseModule('videoconference-enterprise');
const room = useContext(RoomContext)?.room;
const federated = room ? isRoomFederated(room) : false;
const { t } = useTranslation();
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!licensed) {
return undefined;
}
return {
id: 'calls',
groups: ['channel', 'group', 'team', 'direct', 'direct_multiple'],
icon: 'phone',
title: 'Calls',
...(federated && {
tooltip: t('core.Video_Call_unavailable_for_this_type_of_room'),
disabled: true,
}),
tabComponent: VideoConfList,
order: 999,
};
}, [licensed, federated, t]);
};

View File

@@ -0,0 +1,20 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const Info = lazy(() => import('../../views/room/contextualBar/Info'));
export const useChannelSettingsRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'channel-settings',
groups: ['channel', 'group'],
anonymous: true,
full: true,
title: 'Room_Info',
icon: 'info-circled',
tabComponent: Info,
order: 1,
}),
[],
);
};

View File

@@ -0,0 +1,37 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { usePermission } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoom } from '../../views/room/contexts/RoomContext';
const PruneMessages = lazy(() => import('../../views/room/contextualBar/PruneMessages'));
export const useCleanHistoryRoomAction = () => {
const room = useRoom();
const federated = isRoomFederated(room);
const permitted = usePermission('clean-channel-history', room._id);
const { t } = useTranslation();
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!permitted) {
return undefined;
}
return {
id: 'clean-history',
groups: ['channel', 'group', 'team', 'direct_multiple', 'direct'],
full: true,
title: 'Prune_Messages',
icon: 'eraser',
...(federated && {
tooltip: t('core.Clean_History_unavailable_for_federation'),
disabled: true,
}),
tabComponent: PruneMessages,
order: 250,
type: 'customization',
};
}, [federated, permitted, t]);
};

View File

@@ -0,0 +1,36 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoom } from '../../views/room/contexts/RoomContext';
const Discussions = lazy(() => import('../../views/room/contextualBar/Discussions'));
export const useDiscussionsRoomAction = () => {
const room = useRoom();
const federated = isRoomFederated(room);
const enabled = useSetting('Discussion_enabled', false);
const { t } = useTranslation();
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!enabled || !!room.prid) {
return undefined;
}
return {
id: 'discussions',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
title: 'Discussions',
icon: 'discussion',
tabComponent: Discussions,
full: true,
...(federated && {
disabled: true,
tooltip: t('core.Discussions_unavailable_for_federation'),
}),
order: 3,
};
}, [enabled, federated, room.prid, t]);
};

View File

@@ -0,0 +1,112 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { imperativeModal } from '@rocket.chat/ui-client';
import { useSetting, usePermission, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { getRoomTypeTranslation } from '../../lib/getRoomTypeTranslation';
import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext';
import { useE2EERoomState } from '../../views/room/hooks/useE2EERoomState';
import { useE2EEState } from '../../views/room/hooks/useE2EEState';
import BaseDisableE2EEModal from '../../views/room/modals/E2EEModals/BaseDisableE2EEModal';
import EnableE2EEModal from '../../views/room/modals/E2EEModals/EnableE2EEModal';
export const useE2EERoomAction = () => {
const enabled = useSetting('E2E_Enable', false);
const room = useRoom();
const subscription = useRoomSubscription();
const e2eeState = useE2EEState();
const e2eeRoomState = useE2EERoomState(room._id);
const isE2EEReady = e2eeState === 'READY' || e2eeState === 'SAVE_PASSWORD';
const readyToEncrypt = isE2EEReady || room.encrypted;
const permittedToToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id);
const permittedToEditRoom = usePermission('edit-room', room._id);
const permitted = (room.t === 'd' || (permittedToEditRoom && permittedToToggleEncryption)) && readyToEncrypt;
const federated = isRoomFederated(room);
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const isE2EERoomNotReady = () => {
if (e2eeRoomState === 'NOT_STARTED' || e2eeRoomState === 'DISABLED' || e2eeRoomState === 'ERROR' || e2eeRoomState === 'WAITING_KEYS') {
return true;
}
return false;
};
const enabledOnRoom = !!room.encrypted;
const roomType = useMemo(() => getRoomTypeTranslation(room)?.toLowerCase(), [room]);
const roomId = room._id;
const toggleE2E = useEndpoint('POST', '/v1/rooms.saveRoomSettings');
const canResetRoomKey = enabled && isE2EEReady && (room.t === 'd' || permittedToToggleEncryption) && isE2EERoomNotReady();
const action = useStableCallback(async () => {
if (enabledOnRoom) {
imperativeModal.open({
component: BaseDisableE2EEModal,
props: {
onClose: imperativeModal.close,
onConfirm: handleToogleE2E,
roomType,
roomId,
canResetRoomKey,
},
});
} else {
imperativeModal.open({
component: EnableE2EEModal,
props: {
onClose: imperativeModal.close,
onConfirm: handleToogleE2E,
roomType,
},
});
}
});
const handleToogleE2E = async () => {
const { success } = await toggleE2E({ rid: room._id, encrypted: !room.encrypted });
if (!success) {
return;
}
imperativeModal.close();
dispatchToastMessage({
type: 'success',
message: room.encrypted
? t('E2E_Encryption_disabled_for_room', { roomName: room.name })
: t('E2E_Encryption_enabled_for_room', { roomName: room.name }),
});
if (subscription?.autoTranslate) {
dispatchToastMessage({ type: 'success', message: t('AutoTranslate_Disabled_for_room', { roomName: room.name }) });
}
};
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!enabled || !permitted) {
return undefined;
}
return {
id: 'e2e',
groups: ['direct', 'direct_multiple', 'group', 'team'],
title: enabledOnRoom ? 'Disable_E2E_encryption' : 'Enable_E2E_encryption',
icon: 'key',
order: 13,
action,
type: 'organization',
...(federated && {
tooltip: t('core.E2E_unavailable_for_federation'),
disabled: true,
}),
};
}, [enabled, permitted, federated, t, enabledOnRoom, action]);
};

View File

@@ -0,0 +1,30 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useRoom } from '../../views/room/contexts/RoomContext';
const ExportMessages = lazy(() => import('../../views/room/contextualBar/ExportMessages'));
export const useExportMessagesRoomAction = () => {
const room = useRoom();
const permitted = usePermission('mail-messages', room._id);
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!permitted) {
return undefined;
}
return {
id: 'export-messages',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
anonymous: true,
title: 'Export_Messages',
icon: 'mail',
tabComponent: ExportMessages,
full: true,
order: 12,
type: 'communication',
};
}, [permitted]);
};

View File

@@ -0,0 +1,26 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useExternalComponentsQuery } from '../../apps/gameCenter/hooks/useExternalComponentsQuery';
const GameCenter = lazy(() => import('../../apps/gameCenter/GameCenter'));
export const useGameCenterRoomAction = () => {
const result = useExternalComponentsQuery();
const enabled = result.isSuccess && result.data.length > 0;
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!enabled) {
return undefined;
}
return {
id: 'game-center',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
title: 'Apps_Game_Center',
icon: 'game',
tabComponent: GameCenter,
order: -1,
};
}, [enabled]);
};

View File

@@ -0,0 +1,146 @@
import type { IUser, IRoom } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useMediaCallAction } from '@rocket.chat/ui-voip';
import { act, renderHook } from '@testing-library/react';
import { useMediaCallRoomAction } from './useMediaCallRoomAction';
import FakeRoomProvider from '../../../tests/mocks/client/FakeRoomProvider';
import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../tests/mocks/data';
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useUserAvatarPath: jest.fn((_args: any) => 'avatar-url'),
}));
jest.mock('@rocket.chat/ui-voip', () => ({
useMediaCallAction: jest.fn(),
}));
const getUserInfoMocked = jest.fn().mockResolvedValue({ user: createFakeUser({ _id: 'peer-uid', username: 'peer-username' }) });
const appRoot = (overrides: { user?: IUser | null; room?: IRoom; subscription?: SubscriptionWithRoom } = {}) => {
const {
user = createFakeUser({ _id: 'own-uid', username: 'own-username' }),
room = createFakeRoom({ uids: ['own-uid', 'peer-uid'] }),
subscription = createFakeSubscription(),
} = overrides;
const root = mockAppRoot()
.withRoom(room)
.withEndpoint('GET', '/v1/users.info', getUserInfoMocked)
.wrap((children) => (
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
{children}
</FakeRoomProvider>
));
if (user !== null) {
root.withUser(user);
}
return root.build();
};
describe('useMediaCallRoomAction', () => {
const useMediaCallActionMocked = jest.mocked(useMediaCallAction);
beforeEach(() => {
jest.clearAllMocks();
useMediaCallActionMocked.mockReturnValue({
action: jest.fn(),
title: 'Start_call',
icon: 'phone',
});
});
it('should return undefined if ownUserId is not defined', () => {
const { result } = renderHook(() => useMediaCallRoomAction(), {
wrapper: appRoot({ user: null }),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if there are no other users in the room', () => {
const fakeRoom = createFakeRoom({ uids: ['own-uid'] });
const { result } = renderHook(() => useMediaCallRoomAction(), {
wrapper: appRoot({ room: fakeRoom }),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if there are more than one other user (Group DM)', () => {
const fakeRoom = createFakeRoom({ uids: ['own-uid', 'peer-uid-1', 'peer-uid-2'] });
const { result } = renderHook(() => useMediaCallRoomAction(), {
wrapper: appRoot({ room: fakeRoom }),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if callAction is undefined', () => {
useMediaCallActionMocked.mockReturnValue(undefined);
const { result } = renderHook(() => useMediaCallRoomAction(), {
wrapper: appRoot(),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if subscription is blocked', () => {
const fakeBlockedSubscription = createFakeSubscription({ blocker: false, blocked: true });
const { result } = renderHook(() => useMediaCallRoomAction(), {
wrapper: appRoot({ subscription: fakeBlockedSubscription }),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if subscription is blocker', () => {
const fakeBlockedSubscription = createFakeSubscription({ blocked: false, blocker: true });
const { result } = renderHook(() => useMediaCallRoomAction(), {
wrapper: appRoot({ subscription: fakeBlockedSubscription }),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if room is federated', () => {
const fakeFederatedRoom = createFakeRoom({ uids: ['own-uid', 'peer-uid'], federated: true });
const { result } = renderHook(() => useMediaCallRoomAction(), {
wrapper: appRoot({ room: fakeFederatedRoom }),
});
expect(result.current).toBeUndefined();
});
it('should return the action config if all conditions are met', () => {
const actionMock = jest.fn();
useMediaCallActionMocked.mockReturnValue({
action: actionMock,
title: 'Start_call',
icon: 'phone',
});
const { result } = renderHook(() => useMediaCallRoomAction(), {
wrapper: appRoot(),
});
expect(result.current).toEqual({
id: 'start-voice-call',
title: 'Start_call',
icon: 'phone',
featured: true,
action: expect.any(Function),
groups: ['direct'],
});
// Test the action trigger
act(() => result.current?.action?.());
expect(actionMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,73 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { useUserAvatarPath, useUserId } from '@rocket.chat/ui-contexts';
import type { TranslationKey, RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import type { PeerInfo } from '@rocket.chat/ui-voip';
import { useMediaCallAction } from '@rocket.chat/ui-voip';
import { useMemo } from 'react';
import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext';
import { useUserInfoQuery } from '../useUserInfoQuery';
const getPeerId = (uids: string[], ownUserId: string | undefined) => {
if (!ownUserId) {
return undefined;
}
const otherIds = uids.filter((uid) => uid !== ownUserId);
// If no id, it's an one user DM. If more than one, it's a group dm. Both are not supported as of now.
if (otherIds.length === 0 || otherIds.length > 1) {
return undefined;
}
return otherIds[0];
};
export const useMediaCallRoomAction = () => {
const room = useRoom();
const { uids = [] } = room;
const subscription = useRoomSubscription();
const ownUserId = useUserId();
const federated = isRoomFederated(room);
const getAvatarUrl = useUserAvatarPath();
const peerId = getPeerId(uids, ownUserId);
const { data } = useUserInfoQuery({ userId: peerId as string }, { enabled: !!peerId });
const peerInfo = useMemo<PeerInfo | undefined>(() => {
if (!data?.user?._id) {
return undefined;
}
return {
userId: data.user._id,
displayName: data.user.name || data.user.username || '',
avatarUrl: data.user.username
? getAvatarUrl({ username: data.user.username, etag: data.user.avatarETag })
: getAvatarUrl({ userId: data.user._id }),
};
}, [data, getAvatarUrl]);
const callAction = useMediaCallAction(peerInfo);
const blocked = subscription?.blocked || subscription?.blocker;
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!peerId || !callAction || blocked || federated) {
return undefined;
}
const { action, title, icon } = callAction;
return {
id: 'start-voice-call',
title: title as TranslationKey,
icon,
featured: true,
action: () => action(),
groups: ['direct'] as const,
};
}, [peerId, callAction, blocked, federated]);
};

View File

@@ -0,0 +1,36 @@
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import { usePermission } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { MemberListRouter } from '../../views/room';
import { useRoom } from '../../views/room/contexts/RoomContext';
export const useMembersListRoomAction = () => {
const room = useRoom();
const broadcast = !!room.broadcast;
const team = !!room.teamMain;
const permittedToViewBroadcastMemberList = usePermission('view-broadcast-member-list', room._id);
const isFederated = room && isRoomFederated(room);
const isFederationBlocked = isFederated && !isRoomNativeFederated(room);
return useMemo((): RoomToolboxActionConfig | undefined => {
if (broadcast && !permittedToViewBroadcastMemberList) {
return undefined;
}
if (isFederationBlocked) {
return undefined;
}
return {
id: 'members-list',
groups: ['channel', 'group', 'team'],
title: team ? 'Teams_members' : 'Members',
icon: 'members',
tabComponent: MemberListRouter,
order: 7,
};
}, [broadcast, permittedToViewBroadcastMemberList, team, isFederationBlocked]);
};

View File

@@ -0,0 +1,19 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const MentionsTab = lazy(() => import('../../views/room/contextualBar/MentionsTab'));
export const useMentionsRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'mentions',
groups: ['channel', 'group', 'team'],
title: 'Mentions',
icon: 'at',
tabComponent: MentionsTab,
order: 6,
type: 'organization',
}),
[],
);
};

View File

@@ -0,0 +1,24 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const OutlookEventsRoute = lazy(() => import('../../views/outlookCalendar/OutlookEventsRoute'));
export const useOutlookCalenderRoomAction = () => {
const enabled = useSetting('Outlook_Calendar_Enabled', false);
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!enabled) {
return undefined;
}
return {
id: 'outlookCalendar',
groups: ['channel', 'group', 'team'],
icon: 'calendar',
title: 'Outlook_calendar',
tabComponent: OutlookEventsRoute,
order: 999,
};
}, [enabled]);
};

View File

@@ -0,0 +1,36 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoom } from '../../views/room/contexts/RoomContext';
const PinnedMessagesTab = lazy(() => import('../../views/room/contextualBar/PinnedMessagesTab'));
export const usePinnedMessagesRoomAction = () => {
const room = useRoom();
const federated = isRoomFederated(room);
const enabled = useSetting('Message_AllowPinning', false);
const { t } = useTranslation();
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!enabled) {
return undefined;
}
return {
id: 'pinned-messages',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
title: 'Pinned_Messages',
icon: 'pin',
tabComponent: PinnedMessagesTab,
...(federated && {
tooltip: t('core.Pinned_messages_unavailable_for_federation'),
disabled: true,
}),
order: 9,
type: 'organization',
};
}, [enabled, federated, t]);
};

View File

@@ -0,0 +1,27 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useRoomSubscription } from '../../views/room/contexts/RoomContext';
const NotificationPreferences = lazy(() => import('../../views/room/contextualBar/NotificationPreferences'));
export const usePushNotificationsRoomAction = () => {
const subscription = useRoomSubscription();
const capable = !!subscription;
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!capable) {
return undefined;
}
return {
id: 'push-notifications',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
title: 'Notifications_Preferences',
icon: 'bell',
tabComponent: NotificationPreferences,
order: 11,
type: 'customization',
};
}, [capable]);
};

View File

@@ -0,0 +1,18 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const MessageSearchTab = lazy(() => import('../../views/room/contextualBar/MessageSearchTab'));
export const useRocketSearchRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'rocket-search',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
title: 'Search_Messages',
icon: 'magnifier',
tabComponent: MessageSearchTab,
order: 5,
}),
[],
);
};

View File

@@ -0,0 +1,19 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const StarredMessagesTab = lazy(() => import('../../views/room/contextualBar/StarredMessagesTab'));
export const useStarredMessagesRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'starred-messages',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
title: 'Starred_Messages',
icon: 'star',
tabComponent: StarredMessagesTab,
order: 10,
type: 'organization',
}),
[],
);
};

View File

@@ -0,0 +1,20 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const TeamsChannels = lazy(() => import('../../views/teams/contextualBar/channels'));
export const useTeamChannelsRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'team-channels',
groups: ['team'],
anonymous: true,
full: true,
title: 'Team_Channels',
icon: 'hash',
tabComponent: TeamsChannels,
order: 2,
}),
[],
);
};

View File

@@ -0,0 +1,20 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const TeamsInfoWithData = lazy(() => import('../../views/teams/contextualBar/info/TeamsInfoWithData'));
export const useTeamInfoRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'team-info',
groups: ['team'],
anonymous: true,
full: true,
title: 'Teams_Info',
icon: 'info-circled',
tabComponent: TeamsInfoWithData,
order: 1,
}),
[],
);
};

View File

@@ -0,0 +1,65 @@
import type { BadgeProps } from '@rocket.chat/fuselage';
import { HeaderToolbarAction, HeaderToolbarActionBadge } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoomSubscription } from '../../views/room/contexts/RoomContext';
const getVariant = (tunreadUser: number, tunreadGroup: number): BadgeProps['variant'] => {
if (tunreadUser > 0) {
return 'danger';
}
if (tunreadGroup > 0) {
return 'warning';
}
return 'primary';
};
const Threads = lazy(() => import('../../views/room/contextualBar/Threads'));
export const useThreadRoomAction = () => {
const enabled = useSetting('Threads_enabled', false);
const subscription = useRoomSubscription();
const tunread = subscription?.tunread?.length ?? 0;
const tunreadUser = subscription?.tunreadUser?.length ?? 0;
const tunreadGroup = subscription?.tunreadGroup?.length ?? 0;
const unread = tunread > 99 ? '99+' : tunread;
const variant = getVariant(tunreadUser, tunreadGroup);
const { t } = useTranslation();
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!enabled) {
return undefined;
}
return {
id: 'thread',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
full: true,
title: 'Threads',
icon: 'thread',
tabComponent: Threads,
order: 2,
renderToolboxItem: ({ id, className, icon, title, toolbox: { tab }, disabled, action, tooltip }) => (
<HeaderToolbarAction
key={id}
className={className}
id={id}
icon={icon}
title={t(title)}
pressed={id === tab?.id}
onClick={action}
disabled={disabled}
tooltip={tooltip}
>
{!!unread && <HeaderToolbarActionBadge variant={variant}>{unread}</HeaderToolbarActionBadge>}
</HeaderToolbarAction>
),
};
}, [enabled, t, unread, variant]);
};

View File

@@ -0,0 +1,18 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const RoomFiles = lazy(() => import('../../views/room/contextualBar/RoomFiles'));
export const useUploadedFilesListRoomAction = () => {
return useMemo((): RoomToolboxActionConfig => {
return {
id: 'uploaded-files-list',
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
title: 'Files',
icon: 'clip',
tabComponent: RoomFiles,
order: 8,
type: 'organization',
};
}, []);
};

View File

@@ -0,0 +1,18 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { MemberListRouter } from '../../views/room';
export const useUserInfoGroupRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'user-info-group',
groups: ['direct_multiple'],
title: 'Members',
icon: 'members',
tabComponent: MemberListRouter,
order: 1,
}),
[],
);
};

View File

@@ -0,0 +1,18 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { MemberListRouter } from '../../views/room';
export const useUserInfoRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'user-info',
groups: ['direct'],
title: 'User_Info',
icon: 'user',
tabComponent: MemberListRouter,
order: 1,
}),
[],
);
};

View File

@@ -0,0 +1,86 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { useStableCallback, useStableArray } from '@rocket.chat/fuselage-hooks';
import { usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import {
useVideoConfDispatchOutgoing,
useVideoConfIsCalling,
useVideoConfIsRinging,
useVideoConfLoadCapabilities,
} from '@rocket.chat/ui-video-conf';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoom } from '../../views/room/contexts/RoomContext';
import { useVideoConfWarning } from '../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning';
export const useVideoCallRoomAction = () => {
const { t } = useTranslation();
const room = useRoom();
const user = useUser();
const federated = isRoomFederated(room);
const ownUser = room.uids?.length === 1 || false;
const permittedToPostReadonly = usePermission('post-readonly', room._id);
const permittedToCallManagement = usePermission('call-management', room._id);
const dispatchWarning = useVideoConfWarning();
const dispatchPopup = useVideoConfDispatchOutgoing();
const loadCapabilities = useVideoConfLoadCapabilities();
const isCalling = useVideoConfIsCalling();
const isRinging = useVideoConfIsRinging();
const enabledForDMs = useSetting('VideoConf_Enable_DMs', true);
const enabledForChannels = useSetting('VideoConf_Enable_Channels', true);
const enabledForTeams = useSetting('VideoConf_Enable_Teams', true);
const enabledForGroups = useSetting('VideoConf_Enable_Groups', true);
const enabledForLiveChat = useSetting('Omnichannel_call_provider', 'default-provider') === 'default-provider';
const groups = useStableArray(
[
enabledForDMs && 'direct',
enabledForDMs && 'direct_multiple',
enabledForGroups && 'group',
enabledForLiveChat && 'live',
enabledForTeams && 'team',
enabledForChannels && 'channel',
].filter((group): group is RoomToolboxActionConfig['groups'][number] => !!group),
);
const visible = groups.length > 0;
const allowed = visible && permittedToCallManagement && (!user?.username || !room.muted?.includes(user.username)) && !ownUser;
const disabled = federated || (!!room.ro && !permittedToPostReadonly) || room.archived;
const tooltip = disabled ? t('core.Video_Call_unavailable_for_this_type_of_room') : undefined;
const handleOpenVideoConf = useStableCallback(async () => {
if (isCalling || isRinging) {
return;
}
try {
await loadCapabilities();
dispatchPopup({ rid: room._id });
} catch (error: any) {
dispatchWarning(error.error);
}
});
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!allowed) {
return undefined;
}
return {
id: 'start-video-call',
title: 'Video_call',
icon: 'video',
featured: true,
action: handleOpenVideoConf,
order: 1,
groups,
disabled,
tooltip,
};
}, [allowed, groups, disabled, handleOpenVideoConf, tooltip]);
};

View File

@@ -0,0 +1,47 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import { useAirGappedRestriction } from './useAirGappedRestriction';
// [restricted, warning, remainingDays]
describe('useAirGappedRestriction hook', () => {
it('should return [false, false, -1] if setting value is not a number', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1).build(),
});
expect(result.current).toEqual([false, false, -1]);
});
it('should return [false, false, -1] if user has a license (remaining days is a negative value)', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1).build(),
});
expect(result.current).toEqual([false, false, -1]);
});
it('should return [false, false, 8] if not on warning or restriction phase', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 8).build(),
});
expect(result.current).toEqual([false, false, 8]);
});
it('should return [true, false, 7] if on warning phase', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 7).build(),
});
expect(result.current).toEqual([false, true, 7]);
});
it('should return [true, false, 0] if on restriction phase', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0).build(),
});
expect(result.current).toEqual([true, false, 0]);
});
});

View File

@@ -0,0 +1,19 @@
import { useSetting } from '@rocket.chat/ui-contexts';
export const useAirGappedRestriction = (): [isRestrictionPhase: boolean, isWarningPhase: boolean, remainingDays: number] => {
const airGappedRestrictionRemainingDays = useSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days');
if (typeof airGappedRestrictionRemainingDays !== 'number') {
return [false, false, -1];
}
// If this value is negative, the user has a license with valid module
if (airGappedRestrictionRemainingDays < 0) {
return [false, false, airGappedRestrictionRemainingDays];
}
const isRestrictionPhase = airGappedRestrictionRemainingDays === 0;
const isWarningPhase = !isRestrictionPhase && airGappedRestrictionRemainingDays <= 7;
return [isRestrictionPhase, isWarningPhase, airGappedRestrictionRemainingDays];
};

View File

@@ -0,0 +1,59 @@
import type { IUIActionButton, UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { useConnectionStatus, useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
export const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${appId}/${actionId}`;
export const useAppActionButtons = <TContext extends `${UIActionButtonContext}`>(context?: TContext) => {
const queryClient = useQueryClient();
const apps = useStream('apps');
const uid = useUserId();
const { status } = useConnectionStatus();
const getActionButtons = useEndpoint('GET', '/apps/actionButtons');
const result = useQuery({
queryKey: ['apps', 'actionButtons', status],
enabled: status === 'connected',
queryFn: () => getActionButtons(),
...(context && {
select: (data: IUIActionButton[]) =>
data.filter(
(
button,
): button is IUIActionButton & {
context: UIActionButtonContext extends infer X ? (X extends TContext ? X : never) : never;
} => button.context === context,
),
}),
staleTime: Infinity,
});
const invalidate = useDebouncedCallback(
() => {
queryClient.invalidateQueries({
queryKey: ['apps', 'actionButtons'],
});
},
100,
[],
);
useEffect(() => {
if (!uid) {
return;
}
return apps('apps', ([key]) => {
if (['actions/changed'].includes(key)) {
invalidate();
}
});
}, [uid, apps, invalidate]);
return result;
};

View File

@@ -0,0 +1,285 @@
import type { SlashCommand } from '@rocket.chat/core-typings';
import { mockAppRoot, type StreamControllerRef } from '@rocket.chat/mock-providers';
import { QueryClient } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { useAppSlashCommands } from './useAppSlashCommands';
import { appsQueryKeys } from '../lib/queryKeys';
import { slashCommands } from '../lib/slashCommand';
const mockSlashCommands: SlashCommand[] = [
{
command: '/test',
description: 'Test command',
params: 'param1 param2',
clientOnly: false,
providesPreview: false,
appId: 'test-app-1',
permission: undefined,
},
{
command: '/weather',
description: 'Get weather information',
params: 'city',
clientOnly: false,
providesPreview: true,
appId: 'weather-app',
permission: undefined,
},
];
const mockApiResponse = {
commands: mockSlashCommands,
total: mockSlashCommands.length,
appsLoaded: true,
};
describe('useAppSlashCommands', () => {
let mockGetSlashCommands: jest.Mock;
let queryClient: QueryClient;
beforeEach(() => {
mockGetSlashCommands = jest.fn().mockResolvedValue(mockApiResponse);
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
jest.spyOn(queryClient, 'invalidateQueries');
slashCommands.commands = {};
});
afterEach(() => {
jest.clearAllMocks();
});
it('should not fetch data when user ID is not available', () => {
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).build(),
});
expect(mockGetSlashCommands).not.toHaveBeenCalled();
expect(slashCommands.commands).toEqual({});
});
it('should fetch slash commands when user ID is available', async () => {
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).withJohnDoe().build(),
});
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});
});
it('should handle command/removed event by invalidating queries', async () => {
const streamRef: StreamControllerRef<'apps'> = {};
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withQueryClient(queryClient)
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});
expect(streamRef.controller).toBeDefined();
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});
streamRef.controller?.emit('apps', [['command/removed', ['/test']]]);
expect(slashCommands.commands['/test']).toBeUndefined();
await waitFor(() => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: appsQueryKeys.slashCommands() }));
});
});
it('should handle command/disabled event by invalidating queries', async () => {
const streamRef: StreamControllerRef<'apps'> = {};
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withQueryClient(queryClient)
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});
expect(streamRef.controller).toBeDefined();
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});
streamRef.controller?.emit('apps', [['command/disabled', ['/test']]]);
expect(slashCommands.commands['/test']).toBeUndefined();
await waitFor(() => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: appsQueryKeys.slashCommands() }));
});
});
it('should handle command/added event by invalidating queries', async () => {
const streamRef: StreamControllerRef<'apps'> = {};
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});
expect(streamRef.controller).toBeDefined();
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});
mockGetSlashCommands.mockResolvedValue({
commands: [
...mockSlashCommands,
{
command: '/newcommand',
description: 'New command',
params: 'param1 param2',
clientOnly: false,
},
],
total: mockSlashCommands.length + 1,
appsLoaded: true,
});
streamRef.controller?.emit('apps', [['command/added', ['/newcommand']]]);
await waitFor(() => {
expect(slashCommands.commands['/newcommand']).toBeDefined();
});
expect(slashCommands.commands['/test']).toBeDefined();
expect(slashCommands.commands['/weather']).toBeDefined();
});
it('should handle command/updated event by invalidating queries', async () => {
const streamRef: StreamControllerRef<'apps'> = {};
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withQueryClient(queryClient)
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});
expect(streamRef.controller).toBeDefined();
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});
streamRef.controller?.emit('apps', [['command/updated', ['/test']]]);
await waitFor(() => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: appsQueryKeys.slashCommands() }));
});
expect(slashCommands.commands['/test']).toBeDefined();
expect(slashCommands.commands['/weather']).toBeDefined();
});
it('should ignore events that do not start with command/', async () => {
const streamRef: StreamControllerRef<'apps'> = {};
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withQueryClient(queryClient)
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});
expect(streamRef.controller).toBeDefined();
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});
// @ts-expect-error - testing invalid event
streamRef.controller?.emit('apps', [['some/random/event', ['/test']]]);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
});
it('should not set up stream listener when user ID is not available', () => {
const streamRef: StreamControllerRef<'apps'> = {};
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withStream('apps', streamRef).build(),
});
expect(streamRef.controller).toBeDefined();
expect(streamRef.controller?.has('apps')).toBe(false);
});
it('should fetch all commands in batches if total exceeds count', async () => {
const largeMockCommands: SlashCommand[] = Array.from({ length: 120 }, (_, i) => ({
command: `/command${i + 1}`,
description: `Description for command ${i + 1}`,
params: '',
clientOnly: false,
providesPreview: false,
appId: `app-${i + 1}`,
permission: undefined,
}));
mockGetSlashCommands.mockImplementation(({ offset, count }) => {
return Promise.resolve({
commands: largeMockCommands.slice(offset, offset + count),
total: largeMockCommands.length,
appsLoaded: true,
});
});
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withJohnDoe().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).build(),
});
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(largeMockCommands.length);
});
});
it('should not load commands when apps are not loaded', async () => {
mockGetSlashCommands.mockResolvedValue({
commands: [],
total: 0,
appsLoaded: false,
});
expect(Object.keys(slashCommands.commands)).toHaveLength(0);
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withJohnDoe().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).build(),
});
await waitFor(() => {
expect(mockGetSlashCommands).toHaveBeenCalled();
});
expect(Object.keys(slashCommands.commands)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,88 @@
import type { SlashCommand } from '@rocket.chat/core-typings';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts';
import { useQueryClient, useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { appsQueryKeys } from '../lib/queryKeys';
import { slashCommands } from '../lib/slashCommand';
type SlashCommandBasicInfo = Pick<SlashCommand, 'clientOnly' | 'command' | 'description' | 'params' | 'providesPreview' | 'appId'>;
export const useAppSlashCommands = () => {
const queryClient = useQueryClient();
const apps = useStream('apps');
const uid = useUserId();
const invalidate = useDebouncedCallback(
() => {
queryClient.invalidateQueries({
queryKey: appsQueryKeys.slashCommands(),
});
},
100,
[],
);
useEffect(() => {
if (!uid) {
return;
}
return apps('apps', ([key, [command]]) => {
if (!key.startsWith('command/')) {
return;
}
if (['command/removed', 'command/disabled'].includes(key) && typeof command === 'string') {
delete slashCommands.commands[command];
}
invalidate();
});
}, [apps, uid, invalidate]);
const getSlashCommands = useEndpoint('GET', '/v1/commands.list');
const { data } = useQuery({
queryKey: appsQueryKeys.slashCommands(),
enabled: !!uid,
structuralSharing: false,
retry: true,
// Add a bit of randomness to avoid thundering herd problem
retryDelay: (attemptIndex) => Math.min(500 * Math.random() * 10 * 2 ** attemptIndex, 30000),
queryFn: async () => {
const fetchBatch = async (currentOffset: number, accumulator: SlashCommandBasicInfo[] = []): Promise<SlashCommandBasicInfo[]> => {
const count = 50;
const { commands, appsLoaded, total } = await getSlashCommands({ offset: currentOffset, count });
if (!appsLoaded) {
throw new Error('Apps not loaded, retry later');
}
const newAccumulator = [...accumulator, ...commands];
if (newAccumulator.length < total) {
return fetchBatch(currentOffset + count, newAccumulator);
}
return newAccumulator;
};
return fetchBatch(0);
},
});
/**
* We're deliberately not using `useEffect` here because we want the forEach to run on every call
*
* What we considered:
*
* 1. Slash command list is really small (< 100 items)
* 2. `slashCommands.add` is idempotent
* 3. `slashCommands.add` doesn't trigger re-renders
*
* @TODO the `slashCommands` singleton should be refactored to fit the React data flow
*/
data?.forEach((command) => slashCommands.add(command));
};

View File

@@ -0,0 +1,18 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useStream, useUserId } from '@rocket.chat/ui-contexts';
import type * as UiKit from '@rocket.chat/ui-kit';
import { useEffect } from 'react';
export const useAppUiKitInteraction = (handleServerInteraction: (interaction: UiKit.ServerInteraction) => void) => {
const notifyUser = useStream('notify-user');
const uid = useUserId();
const handle = useStableCallback(handleServerInteraction);
useEffect(() => {
if (!uid) {
return;
}
return notifyUser(`${uid}/uiInteraction`, handle);
}, [notifyUser, uid, handle]);
};

View File

@@ -0,0 +1,354 @@
import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui';
import { UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui';
import type { IRoom } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { createElement } from 'react';
import { useApplyButtonAuthFilter, useApplyButtonFilters } from './useApplyButtonFilters';
import FakeRoomProvider from '../../tests/mocks/client/FakeRoomProvider';
import { createFakeRoom } from '../../tests/mocks/data';
const buttonRequiringRole = (role: string): IUIActionButton => ({
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.ROOM_ACTION,
when: {
hasOneRole: [role],
},
});
describe('useApplyButtonAuthFilter', () => {
describe('Role-based filtering', () => {
it('should filter button when user does not have required role (hasAllRoles)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasAllRoles: ['admin'],
},
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.build(),
});
expect(result.current(button)).toBe(false);
});
it('should show button when user has required role (hasAllRoles)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasAllRoles: ['admin'],
},
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRole('admin').build(),
});
expect(result.current(button)).toBe(true);
});
it('should filter button when user does not have any required role (hasOneRole)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasOneRole: ['admin', 'moderator'],
},
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.build(),
});
expect(result.current(button)).toBe(false);
});
it('should show button when user has at least one of the required roles (hasOneRole)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasOneRole: ['admin', 'moderator'],
},
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRole('moderator').build(),
});
expect(result.current(button)).toBe(true);
});
it('should show button when no role filter is specified', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.build(),
});
expect(result.current(button)).toBe(true);
});
it('should filter button when user is not logged in and role is required (hasAllRoles)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasAllRoles: ['admin'],
},
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withAnonymous().build(),
});
expect(result.current(button)).toBe(false);
});
});
describe('Custom role name resolution', () => {
// A custom role gets a random id, so an app can only know its name.
const customRole = { _id: 'aBcDeF1234567890x', name: 'Support Agent' };
const buttonRequiring = (role: string): IUIActionButton => ({
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasOneRole: [role],
},
});
it('should show button when the user holds the role given by name', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(),
});
expect(result.current(buttonRequiring(customRole.name))).toBe(true);
});
it('should filter button when the user does not hold the role given by name', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.withRoleDefinition(customRole)
.build(),
});
expect(result.current(buttonRequiring(customRole.name))).toBe(false);
});
it('should still show button when the role is given by id', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(),
});
expect(result.current(buttonRequiring(customRole._id))).toBe(true);
});
it('should filter button when the role name is unknown', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(),
});
expect(result.current(buttonRequiring('No Such Role'))).toBe(false);
});
it('should prefer the id over a role whose name collides with it', () => {
// `decoy.name` equals `customRole._id`. The user holds only the decoy, so
// resolving by name would wrongly show the button.
const decoy = { _id: 'zZyYxXw9876543210', name: customRole._id };
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.withRoleDefinition(customRole)
.withRoleDefinition(decoy)
.withRole(decoy._id)
.build(),
});
expect(result.current(buttonRequiring(customRole._id))).toBe(false);
});
});
describe('Permission-based filtering', () => {
it('should filter button when user does not have required permission (hasAllPermissions)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasAllPermissions: ['manage-apps'],
},
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().build(),
});
expect(result.current(button)).toBe(false);
});
it('should show button when user has required permission (hasAllPermissions)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasAllPermissions: ['manage-apps'],
},
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withPermission('manage-apps').build(),
});
expect(result.current(button)).toBe(true);
});
it('should show button when user has at least one of the required permissions (hasOnePermission)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasOnePermission: ['manage-apps', 'manage-users'],
},
};
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withPermission('manage-apps').build(),
});
expect(result.current(button)).toBe(true);
});
});
describe('Combined filters', () => {
it('should apply both role and permission filters (AND logic)', () => {
const button: IUIActionButton = {
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasAllRoles: ['admin'],
hasAllPermissions: ['manage-apps'],
},
};
const { result: result1 } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.build(),
});
expect(result1.current(button)).toBe(false);
const { result: result2 } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.withPermission('manage-apps')
.build(),
});
expect(result2.current(button)).toBe(false);
const { result: result3 } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRole('admin').withPermission('manage-apps').build(),
});
expect(result3.current(button)).toBe(true);
});
});
describe('Room-scoped roles', () => {
// `owner`, `moderator` and `leader` are scoped to `Subscriptions`: the user holds them
// in one room, so the check only passes when it is given that room.
const room = createFakeRoom({ _id: 'room-with-role' });
it('should show button when the user holds the role in the given room', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleScoped('owner', room._id).build(),
});
expect(result.current(buttonRequiringRole('owner'), room)).toBe(true);
});
it('should filter button when no room is given', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleScoped('owner', room._id).build(),
});
expect(result.current(buttonRequiringRole('owner'))).toBe(false);
});
it('should filter button when the user holds the role in another room', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleScoped('owner', 'another-room').build(),
});
expect(result.current(buttonRequiringRole('owner'), room)).toBe(false);
});
});
});
describe('useApplyButtonFilters', () => {
const rid: IRoom['_id'] = 'room-with-role';
const withRoomContext = (children: ReactNode) => createElement(FakeRoomProvider, { roomOverrides: { _id: rid, t: 'c' } }, children);
it('should scope the role filter to the room in context', () => {
const { result } = renderHook(() => useApplyButtonFilters(), {
wrapper: mockAppRoot().withJohnDoe().withRoleScoped('owner', rid).wrap(withRoomContext).build(),
});
expect(result.current(buttonRequiringRole('owner'))).toBe(true);
});
it('should filter button when the user holds the role in another room', () => {
const { result } = renderHook(() => useApplyButtonFilters(), {
wrapper: mockAppRoot().withJohnDoe().withRoleScoped('owner', 'another-room').wrap(withRoomContext).build(),
});
expect(result.current(buttonRequiringRole('owner'))).toBe(false);
});
it('should still match a workspace-wide role in a room', () => {
const { result } = renderHook(() => useApplyButtonFilters(), {
wrapper: mockAppRoot().withJohnDoe().withRole('admin').wrap(withRoomContext).build(),
});
expect(result.current(buttonRequiringRole('admin'))).toBe(true);
});
});

View File

@@ -0,0 +1,89 @@
import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui';
import { RoomTypeFilter } from '@rocket.chat/apps-engine/definition/ui';
import type { IRoom } from '@rocket.chat/core-typings';
import {
isDirectMessageRoom,
isMultipleDirectMessageRoom,
isOmnichannelRoom,
isPrivateDiscussion,
isPrivateTeamRoom,
isPublicDiscussion,
isPublicTeamRoom,
} from '@rocket.chat/core-typings';
import { AuthorizationContext, useRoleIdResolver, useUserId } from '@rocket.chat/ui-contexts';
import { useCallback, useContext } from 'react';
import { useRoom } from '../views/room/contexts/RoomContext';
const enumToFilter: { [k in RoomTypeFilter]: (room: IRoom) => boolean } = {
[RoomTypeFilter.PUBLIC_CHANNEL]: (room) => room.t === 'c',
[RoomTypeFilter.PRIVATE_CHANNEL]: (room) => room.t === 'p',
[RoomTypeFilter.PUBLIC_TEAM]: isPublicTeamRoom,
[RoomTypeFilter.PRIVATE_TEAM]: isPrivateTeamRoom,
[RoomTypeFilter.PUBLIC_DISCUSSION]: isPublicDiscussion,
[RoomTypeFilter.PRIVATE_DISCUSSION]: isPrivateDiscussion,
[RoomTypeFilter.DIRECT]: isDirectMessageRoom,
[RoomTypeFilter.DIRECT_MULTIPLE]: isMultipleDirectMessageRoom,
[RoomTypeFilter.LIVE_CHAT]: isOmnichannelRoom,
};
const applyRoomFilter = (button: IUIActionButton, room: IRoom): boolean => {
const { roomTypes } = button.when || {};
return !roomTypes || roomTypes.some((filter): boolean => enumToFilter[filter]?.(room));
};
const applyCategoryFilter = (button: IUIActionButton, category: string): boolean => {
const { category: buttonCategory } = button;
if (category === 'default') {
return !buttonCategory || buttonCategory === 'default';
}
return buttonCategory === category;
};
export const useApplyButtonFilters = (category = 'default'): ((button: IUIActionButton) => boolean) => {
const room = useRoom();
if (!room) {
throw new Error('useApplyButtonFilters must be used inside a room context');
}
const applyAuthFilter = useApplyButtonAuthFilter();
return useCallback(
// The room is the scope of the role check: without it a role scoped to
// `Subscriptions` — `owner`, `moderator`, `leader`, or a custom one — can never match.
(button: IUIActionButton) => applyAuthFilter(button, room) && applyRoomFilter(button, room) && applyCategoryFilter(button, category),
[applyAuthFilter, category, room],
);
};
/**
* Applies the `when` role and permission filters of an action button.
*
* `room` scopes the checks. A role scoped to `Subscriptions` is granted per room, so a
* check for one only passes when it carries the room the button is rendered in; pass the
* room on every room-bound surface. Surfaces with no room of their own — the user
* dropdown, for instance — can only match roles scoped to `Users`.
*/
export const useApplyButtonAuthFilter = (): ((button: IUIActionButton, room?: IRoom) => boolean) => {
const uid = useUserId();
const { queryAllPermissions, queryAtLeastOnePermission, queryRole } = useContext(AuthorizationContext);
// An app knows the name of a custom role, not the random id the workspace gave it,
// so accept either form in the role filters.
const resolveRoleId = useRoleIdResolver();
return useCallback(
(button: IUIActionButton, room?: IRoom) => {
const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {};
const hasAllPermissionsResult = hasAllPermissions ? queryAllPermissions(hasAllPermissions)[1]() : true;
const hasOnePermissionResult = hasOnePermission ? queryAtLeastOnePermission(hasOnePermission)[1]() : true;
const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(resolveRoleId(role), room?._id)[1]()) : true;
const hasOneRoleResult = hasOneRole ? !!uid && hasOneRole.some((role) => queryRole(resolveRoleId(role), room?._id)[1]()) : true;
return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult;
},
[queryAllPermissions, queryAtLeastOnePermission, queryRole, resolveRoleId, uid],
);
};

View File

@@ -0,0 +1,14 @@
import type { UseClipboardReturn } from '@rocket.chat/fuselage-hooks';
import { useClipboard, useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
export default function useClipboardWithToast(text: string): UseClipboardReturn {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
return useClipboard(text, {
onCopySuccess: useStableCallback(() => dispatchToastMessage({ type: 'success', message: t('Copied') })),
onCopyError: useStableCallback((e?: Error) => dispatchToastMessage({ type: 'error', message: e })),
});
}

View File

@@ -0,0 +1,42 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
/**
* Determines if a user's permissions restrict them to creating only one type of channel.
*
* This hook checks a user's permissions for creating public and private channels,
* either globally or within a specific team. It returns a string indicating the
* single channel type they can create, or `false` if they can create both or neither.
*
* @param {string} [teamRoomId] The optional ID of the main team room to check for team-specific permissions.
* @returns {'c' | 'p' | false} A string ('c' or 'p') if the user can only create one channel type, otherwise `false`.
*/
export const useCreateChannelTypePermission = (teamRoomId?: IRoom['_id']) => {
const canCreateChannel = usePermission('create-c');
const canCreatePrivateChannel = usePermission('create-p');
const canCreateTeamChannel = usePermission('create-team-channel', teamRoomId);
const canCreateTeamGroup = usePermission('create-team-group', teamRoomId);
return useMemo(() => {
if (teamRoomId) {
if (!canCreateTeamChannel && canCreateTeamGroup) {
return 'p';
}
if (canCreateTeamChannel && !canCreateTeamGroup) {
return 'c';
}
}
if (!canCreateChannel && canCreatePrivateChannel) {
return 'p';
}
if (canCreateChannel && !canCreatePrivateChannel) {
return 'c';
}
return false;
}, [canCreateChannel, canCreatePrivateChannel, canCreateTeamChannel, canCreateTeamGroup, teamRoomId]);
};

View File

@@ -0,0 +1,89 @@
import { isE2EEMessage } from '@rocket.chat/core-typings';
import { renderHook, waitFor } from '@testing-library/react';
import { useDecryptedMessage } from './useDecryptedMessage';
import { e2e } from '../lib/e2ee/rocketchat.e2e';
// Mock the dependencies
jest.mock('@rocket.chat/core-typings', () => ({
isE2EEMessage: jest.fn(),
}));
jest.mock('../lib/e2ee/rocketchat.e2e', () => ({
e2e: {
decryptMessage: jest.fn(),
},
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('useDecryptedMessage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the original message for non-E2EE messages', () => {
(isE2EEMessage as jest.MockedFunction<typeof isE2EEMessage>).mockReturnValue(false);
const message: any = { msg: 'Hello, world!' };
const { result } = renderHook(() => useDecryptedMessage(message));
expect(result.current).toBe('Hello, world!');
expect(e2e.decryptMessage).not.toHaveBeenCalled();
});
it('should return decrypted message for E2EE messages', async () => {
(isE2EEMessage as jest.MockedFunction<typeof isE2EEMessage>).mockReturnValue(true);
(e2e.decryptMessage as jest.Mock).mockResolvedValue({ msg: 'Decrypted message' });
const message: any = { msg: 'Encrypted message' };
const { result } = renderHook(() => useDecryptedMessage(message));
await waitFor(() => {
expect(result.current).not.toBe('E2E_message_encrypted_placeholder');
});
expect(result.current).toBe('Decrypted message');
expect(e2e.decryptMessage).toHaveBeenCalledWith(message);
});
it('should handle E2EE messages with attachments', async () => {
(isE2EEMessage as jest.MockedFunction<typeof isE2EEMessage>).mockReturnValue(true);
(e2e.decryptMessage as jest.Mock).mockResolvedValue({
attachments: [{ description: 'Attachment description' }],
});
const message: any = { msg: 'Encrypted message with attachment' };
const { result } = renderHook(() => useDecryptedMessage(message));
await waitFor(() => {
expect(result.current).toBe('Attachment description');
});
expect(result.current).toBe('Attachment description');
expect(e2e.decryptMessage).toHaveBeenCalledWith(message);
});
it('should handle E2EE messages with attachments but no description', async () => {
(isE2EEMessage as jest.MockedFunction<typeof isE2EEMessage>).mockReturnValue(true);
(e2e.decryptMessage as jest.Mock).mockResolvedValue({
attachments: [{}],
});
const message: any = { msg: 'Encrypted message with attachment' };
const { result } = renderHook(() => useDecryptedMessage(message));
await waitFor(() => {
expect(result.current).toBe('E2E_message_encrypted_placeholder');
});
await waitFor(() => {
expect(result.current).toBe('Message_with_attachment');
});
expect(e2e.decryptMessage).toHaveBeenCalledWith(message);
});
});

View File

@@ -0,0 +1,34 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isE2EEMessage } from '@rocket.chat/core-typings';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { e2e } from '../lib/e2ee/rocketchat.e2e';
export const useDecryptedMessage = (message: IMessage): string => {
const { t } = useTranslation();
const [decryptedMessage, setDecryptedMessage] = useSafely(useState(t('E2E_message_encrypted_placeholder')));
useEffect(() => {
if (!isE2EEMessage(message)) {
return;
}
e2e.decryptMessage(message).then((decryptedMsg) => {
if (decryptedMsg.msg) {
setDecryptedMessage(decryptedMsg.msg);
}
if (decryptedMsg.attachments && decryptedMsg.attachments?.length > 0) {
if (decryptedMsg.attachments[0].description) {
setDecryptedMessage(decryptedMsg.attachments[0].description);
} else {
setDecryptedMessage(t('Message_with_attachment'));
}
}
});
}, [message, t, setDecryptedMessage]);
return isE2EEMessage(message) ? decryptedMessage : message.msg;
};

View File

@@ -0,0 +1,173 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { QueryClient } from '@tanstack/react-query';
import { act, renderHook, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useDeviceLogout } from './useDeviceLogout';
const SESSION_ID = 'session-id';
type Endpoint = '/v1/sessions/logout' | '/v1/sessions/logout.me';
const setup = ({
isCurrentSession,
endpoint,
shouldFail = false,
openContextualBar = false,
}: {
isCurrentSession?: boolean;
endpoint: Endpoint;
shouldFail?: boolean;
openContextualBar?: boolean;
}) => {
const dispatchToastMessage = jest.fn();
const logout = jest.fn(() => Promise.resolve());
const navigate = jest.fn();
const logoutEndpoint = jest.fn(() => {
if (shouldFail) {
throw new Error('logout failed');
}
return { sessionId: SESSION_ID };
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const invalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
let builder = mockAppRoot()
.withQueryClient(queryClient)
.withEndpoint('POST', endpoint, logoutEndpoint)
.withRouter({ navigate })
.withToastMessageDispatch(dispatchToastMessage)
.withLogout(logout);
// `isContextualBarOpen` is derived from the `id` route param matching the session id.
if (openContextualBar) {
builder = builder.withRouteParameter('id', SESSION_ID);
}
const { result } = renderHook(() => useDeviceLogout(SESSION_ID, endpoint, isCurrentSession), { wrapper: builder.build() });
return { result, dispatchToastMessage, logout, navigate, logoutEndpoint, invalidateQueries };
};
const openModalAndConfirm = async (openLogoutModal: () => void) => {
act(() => {
openLogoutModal();
});
const confirmButton = screen.getByRole('button', { name: 'Logout_Device' });
await userEvent.click(confirmButton);
};
describe('useDeviceLogout', () => {
it('should open a confirmation modal when the returned callback is invoked', () => {
const { result } = setup({ endpoint: '/v1/sessions/logout' });
expect(screen.queryByRole('button', { name: 'Logout_Device' })).not.toBeInTheDocument();
act(() => {
result.current();
});
expect(screen.getByRole('button', { name: 'Logout_Device' })).toBeInTheDocument();
});
describe('for another session', () => {
it('should log the device out, show a success toast and close the modal on success', async () => {
const { result, dispatchToastMessage, logout, invalidateQueries } = setup({ endpoint: '/v1/sessions/logout' });
await openModalAndConfirm(result.current);
await waitFor(() => {
expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'success', message: 'Device_Logged_Out' });
});
expect(invalidateQueries).toHaveBeenCalled();
expect(logout).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Logout_Device' })).not.toBeInTheDocument();
});
});
it('should call the endpoint with the session id', async () => {
const { result, logoutEndpoint } = setup({ endpoint: '/v1/sessions/logout' });
await openModalAndConfirm(result.current);
await waitFor(() => {
expect(logoutEndpoint).toHaveBeenCalledWith({ sessionId: SESSION_ID });
});
});
it('should close the contextual bar when it is open for the logged out session', async () => {
const { result, navigate } = setup({ endpoint: '/v1/sessions/logout', openContextualBar: true });
await openModalAndConfirm(result.current);
await waitFor(() => {
expect(navigate).toHaveBeenCalledWith(expect.objectContaining({ name: 'device-management', params: {} }), expect.anything());
});
});
it('should show an error toast and close the modal on failure, without a success toast', async () => {
const { result, dispatchToastMessage, logout, invalidateQueries } = setup({
endpoint: '/v1/sessions/logout',
shouldFail: true,
});
await openModalAndConfirm(result.current);
await waitFor(() => {
expect(dispatchToastMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
});
expect(dispatchToastMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
expect(invalidateQueries).not.toHaveBeenCalled();
expect(logout).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Logout_Device' })).not.toBeInTheDocument();
});
});
});
describe('for the current session', () => {
it('should log the user out and close the modal on success, without any toast', async () => {
const { result, dispatchToastMessage, logout } = setup({
endpoint: '/v1/sessions/logout.me',
isCurrentSession: true,
});
await openModalAndConfirm(result.current);
await waitFor(() => {
expect(logout).toHaveBeenCalledTimes(1);
});
expect(dispatchToastMessage).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Logout_Device' })).not.toBeInTheDocument();
});
});
it('should still log the user out and close the modal on failure, without any toast', async () => {
const { result, dispatchToastMessage, logout } = setup({
endpoint: '/v1/sessions/logout.me',
isCurrentSession: true,
shouldFail: true,
});
await openModalAndConfirm(result.current);
await waitFor(() => {
expect(logout).toHaveBeenCalledTimes(1);
});
expect(dispatchToastMessage).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Logout_Device' })).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,73 @@
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useRoute, useRouteParameter, useEndpoint, UserContext } from '@rocket.chat/ui-contexts';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { deviceManagementQueryKeys } from '../lib/queryKeys';
export const useDeviceLogout = (
sessionId: string,
endpoint: '/v1/sessions/logout' | '/v1/sessions/logout.me',
isCurrentSession?: boolean,
): (() => void) => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const deviceManagementRouter = useRoute('device-management');
const routeId = useRouteParameter('id');
const { logout } = useContext(UserContext);
const queryClient = useQueryClient();
const logoutEndpoint = useEndpoint('POST', endpoint);
const handleCloseContextualBar = useCallback(() => deviceManagementRouter.push({}), [deviceManagementRouter]);
const isContextualBarOpen = routeId === sessionId;
const { mutate: logoutDevice } = useMutation({
mutationFn: logoutEndpoint,
onSuccess: () => {
if (isCurrentSession) return;
queryClient.invalidateQueries({ queryKey: deviceManagementQueryKeys.all });
if (isContextualBarOpen) {
handleCloseContextualBar();
}
dispatchToastMessage({ type: 'success', message: t('Device_Logged_Out') });
},
onError: (error) => {
if (isCurrentSession) return;
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: () => {
setModal(null);
if (isCurrentSession) {
logout();
}
},
throwOnError: false,
});
return useCallback(() => {
const closeModal = () => setModal(null);
const handleLogoutDevice = () => {
logoutDevice({ sessionId });
};
setModal(
<GenericModal
title={t('Logout_Device')}
variant='danger'
confirmText={t('Logout_Device')}
cancelText={t('Cancel')}
onConfirm={handleLogoutDevice}
onCancel={closeModal}
onClose={closeModal}
>
{t('Device_Logout_Text')}
</GenericModal>,
);
}, [setModal, t, logoutDevice, sessionId]);
};

View File

@@ -0,0 +1,64 @@
import { Emitter } from '@rocket.chat/emitter';
import type { TFunction } from 'i18next';
import type { MouseEvent } from 'react';
import { useId, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { downloadAs } from '../lib/download';
const ee = new Emitter<Record<string, { result: ArrayBuffer; id: string }>>();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'attachment-download-result') {
const { result } = event.data as { result: ArrayBuffer; id: string };
ee.emit(event.data.id, { result, id: event.data.id });
}
});
}
export const registerDownloadForUid = (uid: string, t: TFunction, title?: string) => {
ee.once(uid, ({ result }) => {
downloadAs({ data: [new Blob([result])] }, title ?? t('Download'));
});
};
export const forAttachmentDownload = (uid: string, href: string, controller?: ServiceWorker | null) => {
if (!controller) {
controller = navigator?.serviceWorker?.controller;
}
if (!controller) {
return;
}
controller?.postMessage({
type: 'attachment-download',
url: href,
id: uid,
});
};
export const useDownloadFromServiceWorker = (href: string, title?: string) => {
const { controller } = navigator?.serviceWorker || {};
const uid = useId();
const { t } = useTranslation();
useEffect(() => registerDownloadForUid(uid, t, title), [title, t, uid]);
return {
disabled: !controller,
onContextMenu: useCallback((e: MouseEvent) => e.preventDefault(), []),
onClick: useCallback(
(e: MouseEvent) => {
e.preventDefault();
forAttachmentDownload(uid, href, controller);
},
[href, uid, controller],
),
};
};

View File

@@ -0,0 +1,43 @@
import type { Serialized } from '@rocket.chat/core-typings';
import type { Method, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings';
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
type UseEndpointActionOptions<TMethod extends Method, TPathPattern extends PathPattern> = (undefined extends UrlParams<TPathPattern>
? {
keys?: UrlParams<TPathPattern>;
}
: {
keys: UrlParams<TPathPattern>;
}) &
Omit<
UseMutationOptions<
Serialized<OperationResult<TMethod, TPathPattern>>,
Error,
undefined extends OperationParams<TMethod, TPathPattern> ? void : OperationParams<TMethod, TPathPattern>
>,
'mutationFn'
>;
export function useEndpointMutation<TMethod extends Method, TPathPattern extends PathPattern>(
method: TMethod,
pathPattern: TPathPattern,
{ keys, ...options }: NoInfer<UseEndpointActionOptions<TMethod, TPathPattern>> = { keys: {} as UrlParams<TPathPattern> },
) {
const sendData = useEndpoint(method, pathPattern, keys as UrlParams<TPathPattern>);
const dispatchToastMessage = useToastMessageDispatch();
return useMutation({
mutationFn: sendData,
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
...options,
}) as UseMutationResult<
Serialized<OperationResult<TMethod, TPathPattern>>,
Error,
undefined extends OperationParams<TMethod, TPathPattern> ? void : OperationParams<TMethod, TPathPattern>
>;
}

View File

@@ -0,0 +1,39 @@
import type { PathFor, PathPattern } from '@rocket.chat/rest-typings';
import { useToastMessageDispatch, useUpload, type UploadResult } from '@rocket.chat/ui-contexts';
import { useMutation, type UseMutationOptions } from '@tanstack/react-query';
import { t } from 'i18next';
type UseEndpointUploadOptions<TData extends UploadResult> = Omit<UseMutationOptions<TData, Error, FormData>, 'mutationFn'>;
export const useEndpointUploadMutation = <TPathPattern extends PathPattern, TData extends UploadResult = UploadResult>(
endpoint: TPathPattern,
options?: UseEndpointUploadOptions<TData>,
) => {
const sendData = useUpload(endpoint as PathFor<'POST'>);
const dispatchToastMessage = useToastMessageDispatch();
return useMutation({
mutationFn: async (formData: FormData): Promise<TData> => {
const data = sendData(formData);
const promise = data instanceof Promise ? data : data.promise;
const result = await promise;
if (!result.success) {
if (result.status) {
throw new Error(result.status);
}
if (typeof result.error === 'string') {
throw new Error(result.error);
}
throw new Error(t('FileUpload_Error'));
}
return result as TData;
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
...options,
});
};

View File

@@ -0,0 +1,24 @@
import type { ExperimentalEndpoints } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
type ExperimentalParams<
TPath extends keyof ExperimentalEndpoints,
TMethod extends keyof ExperimentalEndpoints[TPath],
> = ExperimentalEndpoints[TPath][TMethod] extends (params: infer P) => any ? P : never;
type ExperimentalResult<
TPath extends keyof ExperimentalEndpoints,
TMethod extends keyof ExperimentalEndpoints[TPath],
> = ExperimentalEndpoints[TPath][TMethod] extends (...args: any) => infer R ? R : never;
/**
* Like `useEndpoint`, but typed against `ExperimentalEndpoints` instead of the stable `Endpoints` union.
* Delegates to `useEndpoint` at runtime (same memoisation, same SDK call) and re-narrows the return type.
*/
export function useExperimentalEndpoint<
TPath extends keyof ExperimentalEndpoints,
TMethod extends keyof ExperimentalEndpoints[TPath] & string,
>(method: TMethod, path: TPath): (params: ExperimentalParams<TPath, TMethod>) => Promise<ExperimentalResult<TPath, TMethod>> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (useEndpoint as any)(method, path) as (params: ExperimentalParams<TPath, TMethod>) => Promise<ExperimentalResult<TPath, TMethod>>;
}

View File

@@ -0,0 +1,55 @@
import { useLanguage } from '@rocket.chat/ui-contexts';
import { isSameDay } from 'date-fns';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatDate } from './useFormatDate';
import { useFormatTime } from './useFormatTime';
// Handles Date, ISO string, and EJSON { $date } (from DDP streamer which does raw JSON.parse without EJSON deserialization)
function parseExpiresAt(value?: unknown): Date | undefined {
if (!value) {
return undefined;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? undefined : value;
}
if (typeof value === 'object' && '$date' in (value as Record<string, unknown>)) {
const date = new Date((value as { $date: number }).$date);
return Number.isNaN(date.getTime()) ? undefined : date;
}
if (typeof value === 'string') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
return undefined;
}
export function useExpirationText(statusExpiresAt?: Date | string) {
const { t } = useTranslation();
const language = useLanguage();
const formatTime = useFormatTime();
const formatDate = useFormatDate();
return useMemo(() => {
const expiresAt = parseExpiresAt(statusExpiresAt);
if (!expiresAt || expiresAt.getTime() <= Date.now()) {
return undefined;
}
const now = new Date();
if (isSameDay(expiresAt, now)) {
return t('Until_time', { time: formatTime(expiresAt) });
}
if (expiresAt.getFullYear() === now.getFullYear()) {
return t('Until_date', { date: new Intl.DateTimeFormat(language, { month: 'long', day: 'numeric' }).format(expiresAt) });
}
return t('Until_date', { date: formatDate(expiresAt) });
}, [statusExpiresAt, t, language, formatTime, formatDate]);
}

View File

@@ -0,0 +1,12 @@
import { useCallback } from 'react';
import { InvalidUrlError } from '../lib/errors/InvalidUrlError';
export const useExternalLink = () => {
return useCallback((url: string | undefined) => {
if (!url) {
throw new InvalidUrlError();
}
window.open(url, '_blank', 'noopener noreferrer');
}, []);
};

View File

@@ -0,0 +1,31 @@
import type { FeaturePreviewProps } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
const handleFeaturePreviewEnableQuery = (item: FeaturePreviewProps, _: any, features: FeaturePreviewProps[]) => {
if (item.enableQuery) {
const expected = item.enableQuery.value;
const received = features.find((el) => el.name === item.enableQuery?.name)?.value;
if (expected !== received) {
item.disabled = true;
item.value = false;
} else {
item.disabled = false;
}
}
return item;
};
const groupFeaturePreview = (features: FeaturePreviewProps[]) =>
Object.entries(
features.reduce(
(result, currentValue) => {
(result[currentValue.group] = result[currentValue.group] || []).push(currentValue);
return result;
},
{} as Record<FeaturePreviewProps['group'], FeaturePreviewProps[]>,
),
);
export const useFeaturePreviewEnableQuery = (features: FeaturePreviewProps[]) => {
return useMemo(() => groupFeaturePreview(features.map(handleFeaturePreviewEnableQuery)), [features]);
};

View File

@@ -0,0 +1,44 @@
import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import { useRef, useCallback, useEffect } from 'react';
import type { AllHTMLAttributes } from 'react';
import { useChat } from '../views/room/contexts/ChatContext';
export const useFileInput = (props: AllHTMLAttributes<HTMLInputElement>) => {
const fileInputRef = useRef<HTMLInputElement>(undefined);
const chatContext = useChat();
const setupFileInput = useSafeRefCallback(
useCallback(
(composerNode: HTMLElement) => {
const fileInput = document.createElement('input');
fileInput.setAttribute('style', 'display: none;');
Object.entries(props).forEach(([key, value]) => {
fileInput.setAttribute(key, value);
});
composerNode.appendChild(fileInput);
fileInputRef.current = fileInput;
return (): void => {
fileInputRef.current = undefined;
fileInput.remove();
};
},
[props],
),
);
useEffect(() => {
const { current: composerNode } = chatContext?.composer?.composerRef || {};
if (composerNode) {
setupFileInput(composerNode);
}
return () => {
setupFileInput(null);
};
}, [setupFileInput]);
return fileInputRef;
};

View File

@@ -0,0 +1,114 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import { useFireGlobalEvent } from './useFireGlobalEvent';
import { fireGlobalEventBase } from '../lib/utils/fireGlobalEventBase';
jest.mock('../lib/utils/fireGlobalEventBase', () => ({
fireGlobalEventBase: jest.fn(() => () => undefined),
}));
const fireGlobalMock = fireGlobalEventBase as jest.MockedFunction<typeof fireGlobalEventBase>;
describe('useFireGlobalEvent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should dispatch event only once if scope is defined', async () => {
const scope = 'scope';
const { result } = renderHook(({ scope }) => useFireGlobalEvent('room-opened', scope), {
initialProps: { scope },
wrapper: mockAppRoot()
.withSetting('Iframe_Integration_send_enable', true)
.withSetting('Iframe_Integration_send_target_origin', '')
.build(),
});
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(1);
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(1);
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(1);
});
it('should dispatch event only once for each (eventName/scope)', async () => {
const { result, rerender } = renderHook(({ scope }) => useFireGlobalEvent('room-opened', scope), {
initialProps: { scope: 'scope' },
wrapper: mockAppRoot()
.withSetting('Iframe_Integration_send_enable', true)
.withSetting('Iframe_Integration_send_target_origin', '')
.build(),
});
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(1);
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(1);
rerender({ scope: 'another' });
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(2);
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(2);
});
it('should dispatch event multiple times if scope is not defined', async () => {
const { result } = renderHook(() => useFireGlobalEvent('room-opened'), {
wrapper: mockAppRoot()
.withSetting('Iframe_Integration_send_enable', true)
.withSetting('Iframe_Integration_send_target_origin', '')
.build(),
});
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(1);
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(2);
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(3);
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(4);
});
it('should pass required settings to postMessage', async () => {
const { result } = renderHook(() => useFireGlobalEvent('room-opened'), {
wrapper: mockAppRoot()
.withSetting('Iframe_Integration_send_enable', true)
.withSetting('Iframe_Integration_send_target_origin', 'origin')
.build(),
});
const postMessage = jest.fn();
fireGlobalMock.mockImplementation(() => postMessage);
result.current.mutate(null);
await waitFor(() => expect(result.current.status).toBe('success'));
expect(fireGlobalMock).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenCalledWith(true, 'origin');
});
});

View File

@@ -0,0 +1,34 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { fireGlobalEventBase } from '../lib/utils/fireGlobalEventBase';
const getScopeForEvent = (eventName: string, scope?: string) => (scope ? `${eventName}/${scope}` : eventName);
export const useFireGlobalEvent = (eventName: string, scope?: string) => {
const sendEnabled = useSetting('Iframe_Integration_send_enable');
const origin = useSetting('Iframe_Integration_send_target_origin');
const dispatchedRef = useRef({ scope: getScopeForEvent(eventName, scope), dispatched: false });
useEffect(() => {
const newScope = getScopeForEvent(eventName, scope);
if (dispatchedRef.current?.scope !== newScope) {
dispatchedRef.current = { scope: newScope, dispatched: false };
}
}, [scope, eventName]);
return useMutation({
mutationFn: async (data?: unknown) => {
if (scope && dispatchedRef.current.dispatched) {
return;
}
const postMessage = fireGlobalEventBase(eventName, data);
postMessage(sendEnabled as boolean, origin as string);
dispatchedRef.current.dispatched = true;
},
scope: scope ? { id: getScopeForEvent(eventName, scope) } : undefined,
});
};

View File

@@ -0,0 +1,39 @@
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
type UseFormSubmitOptions = {
isDirty: boolean;
noChangesMessage?: string;
};
/**
* A reusable hook for form submission that implements accessible form validation patterns.
*
* This hook wraps your form submission handler and:
* - Allows submission attempts in both create and edit modes (keeping buttons enabled for a11y)
* - Provides user-friendly feedback when trying to save an unchanged edit form
*/
export const useFormSubmitWithDirtyCheck = <TData>(
onSubmit: (data: TData) => Promise<void> | void,
{ isDirty, noChangesMessage = 'No_changes_to_save' }: UseFormSubmitOptions,
) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
return useCallback(
async (data: TData): Promise<void> => {
if (!!data && !isDirty) {
dispatchToastMessage({
type: 'info',
message: t(noChangesMessage),
});
return;
}
await onSubmit(data);
},
[isDirty, onSubmit, dispatchToastMessage, t, noChangesMessage],
);
};

View File

@@ -0,0 +1,9 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { formatDate } from '../lib/utils/dateFormat';
export const useFormatDate = () => {
const format = useSetting('Message_DateFormat', 'LL');
return useCallback((time: string | Date | number) => formatDate(time, format), [format]);
};

View File

@@ -0,0 +1,28 @@
import { useUserPreference, useSetting } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { formatDate } from '../lib/utils/dateFormat';
type UseFormatDateAndTimeParams = {
withSeconds?: boolean;
};
export const useFormatDateAndTime = ({ withSeconds }: UseFormatDateAndTimeParams = {}) => {
const clockMode = useUserPreference('clockMode');
const format = useSetting('Message_TimeAndDateFormat', 'LLL');
return useCallback(
(time: string | Date | number = new Date()) => {
switch (clockMode) {
case 1:
return formatDate(time, withSeconds ? 'MMMM D, YYYY h:mm:ss A' : 'MMMM D, YYYY h:mm A');
case 2:
return formatDate(time, withSeconds ? 'MMMM D, YYYY H:mm:ss' : 'MMMM D, YYYY H:mm');
default:
return formatDate(time, withSeconds ? 'L LTS' : format);
}
},
[clockMode, format, withSeconds],
);
};

View File

@@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useFormatDuration = () => {
const { t } = useTranslation();
return useCallback(
(duration: number) => {
const days = Math.floor(duration / 86400);
const hours = Math.floor((duration % 86400) / 3600);
const minutes = Math.floor(((duration % 86400) % 3600) / 60);
const seconds = Math.floor(((duration % 86400) % 3600) % 60);
let out = '';
if (days > 0) {
out += `${days} ${t('days')}, `;
}
if (hours > 0) {
out += `${hours} ${t('hours')}, `;
}
if (minutes > 0) {
out += `${minutes} ${t('minutes')}, `;
}
if (seconds > 0) {
out += `${seconds} ${t('seconds')}`;
}
return out;
},
[t],
);
};

View File

@@ -0,0 +1,24 @@
import { numberFormat } from '../../lib/utils/stringUtils';
const formatMemorySize = (memorySize: number): string | null => {
if (typeof memorySize !== 'number') {
return null;
}
const units = ['bytes', 'kB', 'MB', 'GB'];
let order;
for (order = 0; order < units.length - 1; ++order) {
const upperLimit = Math.pow(1024, order + 1);
if (memorySize < upperLimit) {
break;
}
}
const divider = Math.pow(1024, order);
const decimalDigits = order === 0 ? 0 : 2;
return `${numberFormat(memorySize / divider, decimalDigits)} ${units[order]}`;
};
export const useFormatMemorySize = (): ((memorySize: number) => string | null) => formatMemorySize;

View File

@@ -0,0 +1,26 @@
import { useUserPreference, useSetting } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { formatDate } from '../lib/utils/dateFormat';
const dayFormat = ['h:mm A', 'H:mm'] as const;
export const useFormatTime = () => {
const clockMode = useUserPreference<1 | 2>('clockMode');
const format = useSetting('Message_TimeFormat', 'LT');
const sameDay = clockMode !== undefined ? dayFormat[clockMode - 1] : format;
return useCallback(
(time: string | Date | number) => {
switch (clockMode) {
case 1:
case 2:
return formatDate(time, sameDay);
default:
return formatDate(time, format);
}
},
[clockMode, format, sameDay],
);
};

View File

@@ -0,0 +1,5 @@
import { useMemo } from 'react';
import { formatDurationMs } from '../lib/utils/dateFormat';
export const useFormattedRelativeTime = (timeMs: number): string => useMemo(() => formatDurationMs(timeMs), [timeMs]);

View File

@@ -0,0 +1,7 @@
import type { LicenseModule } from '@rocket.chat/core-typings';
import { useLicenseBase } from '@rocket.chat/ui-client';
export const useHasLicenseModule = (licenseName: LicenseModule | undefined) =>
useLicenseBase({
select: (data) => !!licenseName && data.license.activeModules.includes(licenseName),
});

View File

@@ -0,0 +1,92 @@
import type { RoomType } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModalDoNotAskAgain, useDontAskAgain } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useEndpoint, useSetModal, useToastMessageDispatch, useRouter, useUserId } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { UiTextContext } from '../../definition/IRoomTypeConfig';
import { updateSubscription } from '../lib/mutationEffects/updateSubscription';
import { roomCoordinator } from '../lib/rooms/roomCoordinator';
type HideRoomProps = {
rid: string;
type: RoomType;
name: string;
};
type HideRoomOptions = {
redirect?: boolean;
};
const CLOSE_ENDPOINTS_BY_ROOM_TYPE = {
p: '/v1/groups.close', // private
c: '/v1/channels.close', // channel
d: '/v1/im.close', // direct message
l: '/v1/channels.close', // livechat
} as const;
export const useHideRoomAction = ({ rid: roomId, type, name }: HideRoomProps, { redirect = true }: HideRoomOptions = {}) => {
const { t } = useTranslation();
const setModal = useSetModal();
const closeModal = useStableCallback(() => setModal());
const dispatchToastMessage = useToastMessageDispatch();
const dontAskHideRoom = useDontAskAgain('hideRoom');
const router = useRouter();
const userId = useUserId();
const hideRoomEndpoint = useEndpoint('POST', CLOSE_ENDPOINTS_BY_ROOM_TYPE[type]);
const hideRoom = useMutation({
mutationFn: () => hideRoomEndpoint({ roomId }),
onMutate: async () => {
closeModal();
if (userId) {
return updateSubscription(roomId, userId, { alert: false, open: false });
}
},
onSuccess: () => {
if (redirect) {
router.navigate('/home');
}
},
onError: async (error, _, rollbackDocument) => {
dispatchToastMessage({ type: 'error', message: error });
if (userId && rollbackDocument) {
const { alert, open } = rollbackDocument;
updateSubscription(roomId, userId, { alert, open });
}
},
});
const handleHide = useStableCallback(async () => {
const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.HIDE_WARNING);
if (dontAskHideRoom) {
hideRoom.mutate();
return;
}
setModal(
<GenericModalDoNotAskAgain
variant='danger'
confirmText={t('Yes_hide_it')}
cancelText={t('Cancel')}
onClose={closeModal}
onCancel={closeModal}
onConfirm={() => hideRoom.mutate()}
dontAskAgain={{
action: 'hideRoom',
label: t('Hide_room'),
}}
>
{t(warnText as TranslationKey, { roomName: name })}
</GenericModalDoNotAskAgain>,
);
});
return handleHide;
};

View File

@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import hljs, { register } from '../../app/markdown/lib/hljs';
export function useHighlightedCode(language: string, text: string): string {
const { t } = useTranslation();
const { isPending } = useQuery({
queryKey: ['register-highlight-language', language],
queryFn: async () => {
try {
await register(language);
return true;
} catch (error) {
console.error('Not possible to register the provided language');
}
},
});
return useMemo(() => (isPending ? t('Loading') : hljs.highlight(language, text).value), [isPending, language, text, t]);
}

View File

@@ -0,0 +1,47 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEffect } from 'react';
import { useIdleDetection } from './useIdleDetection';
/**
* useIdleActiveEvents is a custom hook that triggers a callback function when the user is detected to be idle, and another callback function when the user is detected to be active.
* The idle state is determined based on the absence of certain user interactions for a specified time period.
*
* @param options - A configuration object.
* @param options.id - A unique identifier for the idle detection mechanism.
* @param options.time - The time in milliseconds to consider the user idle. Optional. Defaults to 600000 ms (10 minutes).
* @param options.awayOnWindowBlur - A boolean flag to trigger the callback when the window loses focus. Optional. Defaults to false.
* @param onIdleCallback - The callback function to be called when the user is detected to be idle.
* @param onActiveCallback - The callback function to be called when the user is detected to be active.
*
*/
export const useIdleActiveEvents = (
{
id,
time,
awayOnWindowBlur,
}: {
id: string;
time?: number;
awayOnWindowBlur?: boolean;
},
onIdleCallback: () => void,
onActiveCallback?: () => void,
) => {
const stableIdleCallback = useStableCallback(onIdleCallback);
const stableActiveCallback = useStableCallback(onActiveCallback || (() => undefined));
useEffect(() => {
document.addEventListener(`${id}_idle`, stableIdleCallback);
onActiveCallback && document.addEventListener(`${id}_active`, stableActiveCallback);
return () => {
document.removeEventListener(`${id}_idle`, stableIdleCallback);
document.removeEventListener(`${id}_active`, stableActiveCallback);
};
}, [id, onActiveCallback, stableActiveCallback, stableIdleCallback]);
useIdleDetection({ id, time, awayOnWindowBlur });
};

View File

@@ -0,0 +1,27 @@
import type { IUser } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useConnectionStatus, useSetting } from '@rocket.chat/ui-contexts';
import { useIdleActiveEvents } from './useIdleActiveEvents';
export const useIdleConnection = (uid: IUser['_id'] | undefined) => {
const { status } = useConnectionStatus();
const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead');
const { disconnect: disconnectServer, reconnect: reconnectServer } = useConnectionStatus();
const disconnect = useStableCallback(() => {
if (status !== 'offline') {
if (!uid && allowAnonymousRead !== true) {
disconnectServer();
}
}
});
const reconnect = useStableCallback(() => {
if (status === 'offline') {
reconnectServer();
}
});
useIdleActiveEvents({ id: 'useLoginPresence', time: 60 * 1000 }, disconnect, reconnect);
};

View File

@@ -0,0 +1,223 @@
import { faker } from '@faker-js/faker';
import { renderHook, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react';
import { useIdleDetection, DEFAULT_IDLE_DETECTION_OPTIONS } from './useIdleDetection';
describe('useIdleDetection', () => {
const idleCallback = jest.fn<void, [event: Event]>();
const activeCallback = jest.fn<void, [event: Event]>();
const changeCallback = jest.fn<void, [event: Event]>();
// userEvent does not trigger when using `jest.useFakeTimers()`
// because userEvent relies on timers to trigger events
// Setting delay to null ensures the interaction is triggered immediately
// removing this dependency
const user = userEvent.setup({ delay: null });
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
describe.each([
...[60_000, 30_000, 300_000, 3_000_000].flatMap((time) =>
[DEFAULT_IDLE_DETECTION_OPTIONS.id, faker.string.uuid()].flatMap((id) =>
[false, true].flatMap((awayOnWindowBlur) => ({
...DEFAULT_IDLE_DETECTION_OPTIONS,
id,
awayOnWindowBlur,
time,
})),
),
),
])('time: $time, id: $id, awayOnWindowBlur: $awayOnWindowBlur', (args) => {
const eventId = args.id;
const idleDelayMillis = args.time;
let cleanupEvents: () => void;
beforeAll(() => {
document.addEventListener(`${eventId}_idle`, idleCallback);
document.addEventListener(`${eventId}_active`, activeCallback);
document.addEventListener(`${eventId}_change`, changeCallback);
cleanupEvents = () => {
document.removeEventListener(`${eventId}_idle`, idleCallback);
document.removeEventListener(`${eventId}_active`, activeCallback);
document.removeEventListener(`${eventId}_change`, changeCallback);
};
});
afterAll(() => {
cleanupEvents();
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should not dispatch any event on initial render', async () => {
renderHook(() => useIdleDetection(args));
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
});
it('should dispatch idle event if no interaction before timeout', async () => {
renderHook(() => useIdleDetection(args));
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
act(() => {
jest.advanceTimersByTime(idleDelayMillis + 1);
});
expect(idleCallback).toHaveBeenCalledTimes(1);
expect(idleCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(idleCallback.mock.lastCall?.[0].type).toBe(`${eventId}_idle`);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);
});
if (args.awayOnWindowBlur) {
it('should dispatch idle event on window blur', async () => {
renderHook(() => useIdleDetection(args));
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
fireEvent.blur(window);
expect(idleCallback).toHaveBeenCalledTimes(1);
expect(idleCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(idleCallback.mock.lastCall?.[0].type).toBe(`${eventId}_idle`);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);
});
} else {
it('should not dispatch idle event on window blur', async () => {
renderHook(() => useIdleDetection(args));
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
fireEvent.blur(window);
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
});
}
it('should dispatch active event if idle after interaction', async () => {
renderHook(() => useIdleDetection(args));
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
// Ensure the idle event is dispatched
act(() => {
jest.advanceTimersByTime(idleDelayMillis + 1);
});
expect(idleCallback).toHaveBeenCalledTimes(1);
expect(idleCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(idleCallback.mock.lastCall?.[0].type).toBe(`${eventId}_idle`);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);
await user.click(document.body);
expect(idleCallback).toHaveBeenCalledTimes(1);
expect(activeCallback).toHaveBeenCalledTimes(1);
expect(activeCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(activeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_active`);
expect(changeCallback).toHaveBeenCalledTimes(2);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);
});
it('should not dispatch any event if active and an interaction happened before timeout', async () => {
renderHook(() => useIdleDetection(args));
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
const halfTime = idleDelayMillis / 2;
// Advance timers by half the setup time
act(() => {
jest.advanceTimersByTime(halfTime + 1);
});
await user.click(document.body);
// Advance the remainder of the time to ensure the idle event is not dispatched
act(() => {
jest.advanceTimersByTime(halfTime + 1);
});
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
});
it('should not dispatch any event if idle and no interaction happened before timeout', async () => {
renderHook(() => useIdleDetection(args));
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
act(() => {
jest.advanceTimersByTime(idleDelayMillis + 1);
});
expect(idleCallback).toHaveBeenCalledTimes(1);
expect(idleCallback.mock.lastCall?.[0]).toBeInstanceOf(Event);
expect(idleCallback.mock.lastCall?.[0].type).toBe(`${eventId}_idle`);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback.mock.lastCall?.[0]).toBeInstanceOf(CustomEvent);
expect(changeCallback.mock.lastCall?.[0].type).toBe(`${eventId}_change`);
jest.clearAllMocks();
act(() => {
jest.advanceTimersByTime(idleDelayMillis + 1);
});
expect(idleCallback).toHaveBeenCalledTimes(0);
expect(activeCallback).toHaveBeenCalledTimes(0);
expect(changeCallback).toHaveBeenCalledTimes(0);
});
});
});

View File

@@ -0,0 +1,90 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEffect, useRef } from 'react';
const events = ['mousemove', 'mousedown', 'touchend', 'touchstart', 'keypress'];
export type UseIdleDetectionOptions = {
id?: string;
time?: number;
awayOnWindowBlur?: boolean;
};
export const DEFAULT_IDLE_DETECTION_OPTIONS = Object.freeze({
id: 'useIdleDetection',
time: 600_000, // 10 minutes
awayOnWindowBlur: false,
});
/**
* A hook that detects when the user is idle.
*
* This hook listens for mousemove, mousedown, touchend, touchstart, and keypress events.
* When any of these events are triggered, the user is considered active.
* If no events are triggered for a specified period of time, the user is considered idle.
*
* @param options - An object with the following properties:
* @param options.id - A unique identifier for the idle detection mechanism. Defaults to 'useIdleDetection'.
* @param options.time - The time in milliseconds to consider the user idle. Defaults to 600000 ms (10 minutes).
* @param options.awayOnWindowBlur - A boolean flag to trigger the idle state when the window loses focus. Defaults to false.
*/
export const useIdleDetection = ({
id = DEFAULT_IDLE_DETECTION_OPTIONS.id,
time = DEFAULT_IDLE_DETECTION_OPTIONS.time,
awayOnWindowBlur = DEFAULT_IDLE_DETECTION_OPTIONS.awayOnWindowBlur,
}: UseIdleDetectionOptions = {}) => {
const idleRef = useRef(false);
const dispatchIdle = useStableCallback(() => {
if (idleRef.current) return;
document.dispatchEvent(new Event(`${id}_idle`));
document.dispatchEvent(
new CustomEvent(`${id}_change`, {
detail: { isIdle: true },
}),
);
idleRef.current = true;
});
const dispatchActive = useStableCallback(() => {
if (!idleRef.current) return;
document.dispatchEvent(new Event(`${id}_active`));
document.dispatchEvent(
new CustomEvent(`${id}_change`, {
detail: { isIdle: false },
}),
);
idleRef.current = false;
});
useEffect(() => {
let interval: ReturnType<typeof setTimeout>;
const handleIdle = () => {
dispatchActive();
clearTimeout(interval);
interval = setTimeout(() => {
dispatchIdle();
}, time);
};
handleIdle();
events.forEach((key) => document.addEventListener(key, handleIdle));
return () => {
clearTimeout(interval);
events.forEach((key) => document.removeEventListener(key, handleIdle));
};
}, [dispatchActive, dispatchIdle, time]);
useEffect(() => {
if (!awayOnWindowBlur) {
return;
}
window.addEventListener('blur', dispatchIdle);
return () => {
window.removeEventListener('blur', dispatchIdle);
};
}, [awayOnWindowBlur, dispatchIdle]);
};

View File

@@ -0,0 +1,108 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useStream, useUserId } from '@rocket.chat/ui-contexts';
import type { InfiniteData } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
export const useInfiniteMessageQueryUpdates = <T extends IMessage, TQueryKey extends readonly unknown[]>({
queryKey,
roomId,
filter,
compare = (a, b) => b.ts.getTime() - a.ts.getTime(),
}: {
queryKey: TQueryKey;
roomId: IRoom['_id'];
filter: (message: IMessage) => message is T;
compare?: (a: T, b: T) => number;
}) => {
const queryClient = useQueryClient();
const subscribeToRoomMessages = useStream('room-messages');
const subscribeToNotifyRoom = useStream('notify-room');
const getQueryKey = useStableCallback(() => queryKey);
const doFilter = useStableCallback(filter);
const doCompare = useStableCallback(compare);
const mutateQueryData = useStableCallback((mutation: (items: T[]) => void) => {
const queryData = queryClient.getQueryData<
InfiniteData<
{
items: T[];
itemCount: number;
},
number
>
>(queryKey);
const items = queryData?.pages.flatMap((page) => page.items) ?? [];
const lastPage = queryData?.pages.at(-1) ?? { items: [], itemCount: 0 };
const beforeMutationItemsLength = items.length;
mutation(items);
const afterMutationItemsLength = items.length;
const pageSize = lastPage.items.length || items.length;
const newPageCount = items.length > 0 ? Math.ceil(items.length / pageSize) : 0;
const newQueryData: typeof queryData = {
pages: Array.from({ length: newPageCount }, (_, pageIndex) => ({
items: items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
itemCount: lastPage.itemCount - (beforeMutationItemsLength - afterMutationItemsLength),
})),
pageParams: Array.from({ length: newPageCount }, (_, pageIndex) => pageIndex * pageSize),
};
queryClient.setQueryData<
InfiniteData<
{
items: T[];
itemCount: number;
},
number
>
>(queryKey, newQueryData);
});
const userId = useUserId();
useEffect(() => {
if (!userId || !roomId) {
return;
}
const unsubscribeFromRoomMessages = subscribeToRoomMessages(roomId, (message) => {
if (!doFilter(message)) return;
mutateQueryData((items) => {
const index = items.findIndex((i) => i._id === message._id);
if (index !== -1) {
items[index] = message as T;
} else {
items.push(message as T);
items.sort(doCompare);
}
});
});
const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${roomId}/deleteMessage`, ({ _id }) => {
mutateQueryData((items) => {
const index = items.findIndex((i) => i._id === _id);
if (index !== -1) {
items.splice(index, 1);
}
});
});
const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${roomId}/deleteMessageBulk`, () => {
queryClient.invalidateQueries({ queryKey: getQueryKey() });
});
return () => {
unsubscribeFromRoomMessages();
unsubscribeFromDeleteMessage();
unsubscribeFromDeleteMessageBulk();
};
}, [subscribeToRoomMessages, subscribeToNotifyRoom, userId, roomId, queryClient, getQueryKey, doFilter, doCompare, mutateQueryData]);
};

View File

@@ -0,0 +1,6 @@
import { useLicenseBase } from '@rocket.chat/ui-client';
import type { UseQueryResult } from '@tanstack/react-query';
export const useIsEnterprise = (): UseQueryResult<{ isEnterprise: boolean }> => {
return useLicenseBase({ select: (data) => ({ isEnterprise: Boolean(data?.license.hasValidLicense) }) });
};

View File

@@ -0,0 +1,8 @@
import { useSetting } from '@rocket.chat/ui-contexts';
export const useIsFederationEnabled = () => {
const matrixFederationEnabled = useSetting('Federation_Matrix_enabled', false);
const serviceFederationEnabled = useSetting('Federation_Service_Enabled', false);
const federationEnabled = matrixFederationEnabled || serviceFederationEnabled;
return federationEnabled;
};

View File

@@ -0,0 +1,9 @@
import { useStatistics } from '../views/hooks/useStatistics';
export const useIsSelfHosted = (): { isSelfHosted: boolean; isLoading: boolean } => {
const { data, isLoading } = useStatistics();
const isSelfHosted = data?.deploy?.platform !== 'rocket-cloud';
return { isSelfHosted, isLoading };
};

View File

@@ -0,0 +1,34 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { roomsQueryKeys } from '../lib/queryKeys';
type UseJoinRoomMutationFunctionProps = {
rid: IRoom['_id'];
reference: string;
type: IRoom['t'];
};
export const useJoinRoom = () => {
const queryClient = useQueryClient();
const dispatchToastMessage = useToastMessageDispatch();
const joinChannel = useEndpoint('POST', '/v1/rooms.join');
return useMutation({
mutationFn: async ({ rid, reference, type }: UseJoinRoomMutationFunctionProps) => {
await joinChannel({ roomId: rid });
return { reference, type };
},
onSuccess: (data) => {
// Prefix-match the open-room query key (roomsQueryKeys.roomReference) so the
// "not subscribed" screen refetches and flips to the joined state without a reload.
queryClient.invalidateQueries({
queryKey: [...roomsQueryKeys.all, data.reference, data.type],
});
},
onError: (error: unknown) => {
dispatchToastMessage({ message: error, type: 'error' });
},
});
};

View File

@@ -0,0 +1,47 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
export const useLdapSync = () => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const testConnection = useEndpoint('POST', '/v1/ldap.testConnection');
const syncNow = useEndpoint('POST', '/v1/ldap.syncNow');
const closeModal = useStableCallback(() => setModal(null));
const handleSyncNow = async (): Promise<void> => {
closeModal();
try {
const { message } = await syncNow();
dispatchToastMessage({ type: 'success', message: t(message as TranslationKey) });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return async (): Promise<void> => {
try {
await testConnection();
} catch {
dispatchToastMessage({ type: 'error', message: t('Connection_failed') });
return;
}
setModal(
<GenericModal
variant='info'
confirmText={t('Sync')}
cancelText={t('Cancel')}
title={t('Execute_Synchronization_Now')}
onConfirm={handleSyncNow}
onClose={closeModal}
>
{t('LDAP_Sync_Now_Description')}
</GenericModal>,
);
};
};

View File

@@ -0,0 +1,62 @@
import type { LicenseBehavior, LicenseLimitKind } from '@rocket.chat/core-typings';
import { validateWarnLimit } from '@rocket.chat/license/src/validation/validateLimit';
import { useLicense } from '@rocket.chat/ui-client';
type LicenseLimitsByBehavior = Record<LicenseBehavior, LicenseLimitKind[]>;
export const useLicenseLimitsByBehavior = () => {
const result = useLicense({ loadValues: true });
if (result.isPending || result.isError) {
return null;
}
const { license, limits } = result.data;
if (!license || !limits) {
return null;
}
const keyLimits = Object.keys(limits) as Array<keyof typeof limits>;
// Get the rule with the highest limit that applies to this key
const rules = keyLimits
.map((key) => {
const rule = license.limits[key]
?.filter((limit) => validateWarnLimit(limit.max, limits[key].value ?? 0, limit.behavior))
.reduce<{
max: number;
behavior: LicenseBehavior;
} | null>((maxLimit, currentLimit) => (!maxLimit || currentLimit.max > maxLimit.max ? currentLimit : maxLimit), null);
if (!rule) {
return undefined;
}
if (rule.max === 0) {
return undefined;
}
if (rule.max === -1) {
return undefined;
}
return [key, rule.behavior];
})
.filter(Boolean) as Array<[keyof typeof limits, LicenseBehavior]>;
if (!rules.length) {
return null;
}
// Group by behavior
return rules.reduce((acc, [key, behavior]) => {
if (!acc[behavior]) {
acc[behavior] = [];
}
acc[behavior].push(key);
return acc;
}, {} as LicenseLimitsByBehavior);
};

View File

@@ -0,0 +1,42 @@
import type { ILivechatInquiryRecord, IRoom } from '@rocket.chat/core-typings';
import { create } from 'zustand';
export type LivechatInquiryLocalRecord = ILivechatInquiryRecord & { alert?: boolean };
export const useLivechatInquiryStore = create<{
records: LivechatInquiryLocalRecord[];
add: (record: LivechatInquiryLocalRecord) => void;
merge: (record: LivechatInquiryLocalRecord) => void;
discard: (id: ILivechatInquiryRecord['_id']) => void;
discardForRoom: (rid: IRoom['_id']) => void;
discardAll: () => void;
}>()((set) => ({
records: [],
add: (record) => {
set(({ records }) => ({ records: [...records, record] }));
},
merge: (record) => {
set(({ records }) => {
const index = records.findIndex((r) => r._id === record._id);
if (index === -1) {
return { records: [...records, record] };
}
records[index] = record;
return { records: [...records] };
});
},
discard: (id) => {
set(({ records }) => ({ records: records.filter((r) => r._id !== id) }));
},
discardForRoom: (rid) => {
set(({ records }) => ({ records: records.filter((r) => r.rid !== rid) }));
},
discardAll: () => {
set(() => ({ records: [] }));
},
}));

View File

@@ -0,0 +1,8 @@
import { useLanguage } from '@rocket.chat/ui-contexts';
import { getLocalePercentage } from '../lib/getLocalePercentage';
export const useLocalePercentage = (total: number, fraction: number, decimalCount: number | undefined): string => {
const locale = useLanguage();
return getLocalePercentage(locale, total, fraction, decimalCount);
};

View File

@@ -0,0 +1,30 @@
import type { MutableRefObject, Ref, RefCallback } from 'react';
import { useCallback } from 'react';
const isRefCallback = <T>(x: unknown): x is RefCallback<T> => typeof x === 'function';
const isMutableRefObject = <T>(x: unknown): x is MutableRefObject<T> => typeof x === 'object';
export const setRef = <T>(ref: Ref<T> | undefined, refValue: T) => {
if (isRefCallback<T>(ref)) {
ref(refValue);
return;
}
if (isMutableRefObject<T>(ref)) {
ref.current = refValue;
}
};
// TODO: backport to fuselage-hooks
/**
* Merges multiple refs into a single ref callback
*
* @param refs The refs to merge.
* @returns The merged ref callback.
*/
export const useMergedRefsV2 = <T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> => {
return useCallback((refValue: T) => {
refs.forEach((ref) => setRef(ref, refValue));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, refs);
};

View File

@@ -0,0 +1,62 @@
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppActionButtons, getIdForActionButton } from './useAppActionButtons';
import { useApplyButtonFilters } from './useApplyButtonFilters';
import { Utilities } from '../../ee/lib/misc/Utilities';
import { UiKitTriggerTimeoutError } from '../lib/errors/UiKitTriggerTimeoutError';
import type { MessageBoxAction } from '../lib/messageBox';
import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager';
export const useMessageboxAppsActionButtons = () => {
const result = useAppActionButtons('messageBoxAction');
const actionManager = useUiKitActionManager();
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const applyButtonFilters = useApplyButtonFilters();
const data = useMemo(
() =>
result.data
?.filter((action) => {
return applyButtonFilters(action);
})
.map((action) => {
const item: Omit<MessageBoxAction, 'icon'> = {
id: getIdForActionButton(action),
label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId),
action: (params) => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
rid: params.rid,
tmid: params.tmid,
actionId: action.actionId,
payload: { context: action.context, message: params.chat.composer?.text ?? '' },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
};
return item;
}) ?? [],
[actionManager, applyButtonFilters, dispatchToastMessage, result.data, t],
);
return {
...result,
data,
};
};

View File

@@ -0,0 +1,28 @@
import { useCustomSound, useSetting, useUserSubscriptions } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
const query = { t: 'l', ls: { $exists: false }, open: true };
export const useOmnichannelContinuousSoundNotification = <T>(queue: T[]) => {
const userSubscriptions = useUserSubscriptions(query);
const { notificationSounds } = useCustomSound();
const playNewRoomSoundContinuously = useSetting('Livechat_continuous_sound_notification_new_livechat_room');
const hasUnreadRoom = userSubscriptions.length > 0 || queue.length > 0;
useEffect(() => {
if (!playNewRoomSoundContinuously) {
return;
}
if (!hasUnreadRoom) {
return;
}
notificationSounds.playNewRoomLoop();
return () => {
notificationSounds.stopNewRoom();
};
}, [playNewRoomSoundContinuously, userSubscriptions, hasUnreadRoom, notificationSounds]);
};

View File

@@ -0,0 +1,10 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { UIEvent } from 'react';
export const usePreventPropagation = (fn?: (e: UIEvent) => void): ((e: UIEvent) => void) => {
const preventClickPropagation = useStableCallback((e: UIEvent): void => {
e.stopPropagation();
fn?.(e);
});
return preventClickPropagation;
};

View File

@@ -0,0 +1,183 @@
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import { renderHook, waitFor } from '@testing-library/react';
import { usePruneWarningMessage } from './usePruneWarningMessage';
import { createRenteionPolicySettingsMock as createMock } from '../../tests/mocks/client/mockRetentionPolicySettings';
import { createFakeRoom } from '../../tests/mocks/data';
jest.useFakeTimers();
const getRetentionRoomProps = (props: Partial<IRoomWithRetentionPolicy['retention']> = {}) => {
return {
retention: {
enabled: true,
overrideGlobal: true,
maxAge: 30,
filesOnly: false,
excludePinned: false,
ignoreThreads: false,
...props,
},
};
};
// Warning: The dates are formated using date-fns/intlFormat, which itself uses the JS Intl API
// The resulting formatted date can change depending on node version. These tests may break or the results
// may be different when running on the browser.
beforeEach(() => {
jest.setSystemTime(new Date(2024, 5, 1, 0, 0, 0));
});
describe('usePruneWarningMessage hook', () => {
describe('Cron timer and precision', () => {
it('Should update the message after the nextRunDate has passaed', async () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
}),
});
expect(result.current).toEqual('1 minute June 1, 2024 at 12:30 AM');
await jest.advanceTimersByTimeAsync(31 * 60 * 1000);
await waitFor(() => expect(result.current).toEqual('1 minute June 1, 2024 at 1:00 AM'));
});
it('Should return the default warning with precision set to every_hour', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
precision: '1',
}),
});
expect(result.current).toEqual('1 minute June 1, 2024 at 1:00 AM');
});
it('Should return the default warning with precision set to every_six_hours', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
precision: '2',
}),
});
expect(result.current).toEqual('1 minute June 1, 2024 at 6:00 AM');
});
it('Should return the default warning with precision set to every_day', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
precision: '3',
}),
});
expect(result.current).toEqual('1 minute June 2, 2024 at 12:00 AM');
});
it('Should return the default warning with advanced precision', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
advancedPrecision: true,
advancedPrecisionCron: '0 0 1 */1 *',
}),
});
expect(result.current).toEqual('1 minute July 1, 2024 at 12:00 AM');
});
});
describe('No override', () => {
it('Should return the default warning', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
}),
});
expect(result.current).toEqual('1 minute June 1, 2024 at 12:30 AM');
});
it('Should return the unpinned messages warning', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
doNotPrunePinned: true,
}),
});
expect(result.current).toEqual('Unpinned 1 minute June 1, 2024 at 12:30 AM');
});
it('Should return the files only warning', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
filesOnly: true,
}),
});
expect(result.current).toEqual('FilesOnly 1 minute June 1, 2024 at 12:30 AM');
});
it('Should return the unpinned files only warning', () => {
const fakeRoom = createFakeRoom({ t: 'c' });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock({
appliesToChannels: true,
TTLChannels: 60000,
filesOnly: true,
doNotPrunePinned: true,
}),
});
expect(result.current).toEqual('UnpinnedFilesOnly 1 minute June 1, 2024 at 12:30 AM');
});
});
describe('Overriden', () => {
it('Should return the default warning', () => {
const fakeRoom = createFakeRoom({ t: 'p', ...getRetentionRoomProps() });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock(),
});
expect(result.current).toEqual('30 days June 1, 2024 at 12:30 AM');
});
it('Should return the unpinned messages warning', () => {
const fakeRoom = createFakeRoom({ t: 'p', ...getRetentionRoomProps({ excludePinned: true }) });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock(),
});
expect(result.current).toEqual('Unpinned 30 days June 1, 2024 at 12:30 AM');
});
it('Should return the files only warning', () => {
const fakeRoom = createFakeRoom({ t: 'p', ...getRetentionRoomProps({ filesOnly: true }) });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock(),
});
expect(result.current).toEqual('FilesOnly 30 days June 1, 2024 at 12:30 AM');
});
it('Should return the unpinned files only warning', () => {
const fakeRoom = createFakeRoom({ t: 'p', ...getRetentionRoomProps({ excludePinned: true, filesOnly: true }) });
const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), {
wrapper: createMock(),
});
expect(result.current).toEqual('UnpinnedFilesOnly 30 days June 1, 2024 at 12:30 AM');
});
});
});

View File

@@ -0,0 +1,105 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useSetting, useTranslation, useLanguage } from '@rocket.chat/ui-contexts';
import { sendAt } from 'cron';
import { intlFormat } from 'date-fns';
import { useEffect, useState } from 'react';
import { useFormattedRelativeTime } from './useFormattedRelativeTime';
import { getCronAdvancedTimerFromPrecisionSetting } from '../../lib/getCronAdvancedTimerFromPrecisionSetting';
import { useRetentionPolicy } from '../views/room/hooks/useRetentionPolicy';
const getMessage = ({ filesOnly, excludePinned }: { filesOnly: boolean; excludePinned: boolean }): TranslationKey => {
if (filesOnly) {
return excludePinned
? 'RetentionPolicy_RoomWarning_UnpinnedFilesOnly_NextRunDate'
: 'RetentionPolicy_RoomWarning_FilesOnly_NextRunDate';
}
return excludePinned ? 'RetentionPolicy_RoomWarning_Unpinned_NextRunDate' : 'RetentionPolicy_RoomWarning_NextRunDate';
};
type CronPrecisionSetting = '0' | '1' | '2' | '3';
const getNextRunDate = ({
enableAdvancedCronTimer,
cronPrecision,
advancedCronTimer,
}: {
enableAdvancedCronTimer: boolean;
cronPrecision: CronPrecisionSetting;
advancedCronTimer: string;
}) => {
if (enableAdvancedCronTimer) {
return sendAt(advancedCronTimer);
}
return sendAt(getCronAdvancedTimerFromPrecisionSetting(cronPrecision));
};
const useNextRunDate = ({
enableAdvancedCronTimer,
advancedCronTimer,
cronPrecision,
}: {
enableAdvancedCronTimer: boolean;
cronPrecision: CronPrecisionSetting;
advancedCronTimer: string;
}) => {
const [nextRunDate, setNextRunDate] = useSafely(useState(getNextRunDate({ enableAdvancedCronTimer, advancedCronTimer, cronPrecision })));
const lang = useLanguage();
useEffect(() => {
const timeoutBetweenRunAndNow = nextRunDate.valueOf() - Date.now();
const timeout = setTimeout(
() => setNextRunDate(getNextRunDate({ enableAdvancedCronTimer, advancedCronTimer, cronPrecision })),
timeoutBetweenRunAndNow,
);
return () => clearTimeout(timeout);
}, [advancedCronTimer, cronPrecision, enableAdvancedCronTimer, nextRunDate, setNextRunDate]);
return intlFormat(
new Date(nextRunDate.valueOf()),
{
localeMatcher: 'best fit',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
},
{
locale: lang,
},
);
};
export const usePruneWarningMessage = (room: IRoom) => {
const retention = useRetentionPolicy(room);
if (!retention) {
throw new Error('usePruneWarningMessage - No room provided');
}
const { maxAge, filesOnly, excludePinned } = retention;
const cronPrecision = useSetting<CronPrecisionSetting>('RetentionPolicy_Precision', '0');
const t = useTranslation();
const enableAdvancedCronTimer = useSetting('RetentionPolicy_Advanced_Precision', false);
const advancedCronTimer = useSetting('RetentionPolicy_Advanced_Precision_Cron', '*/30 * * * *');
const message = getMessage({ filesOnly, excludePinned });
const nextRunDate = useNextRunDate({
enableAdvancedCronTimer,
advancedCronTimer,
cronPrecision,
});
const maxAgeFormatted = useFormattedRelativeTime(maxAge);
return t(message, { maxAge: maxAgeFormatted, nextRunDate });
};

View File

@@ -0,0 +1,68 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import { useRegistrationStatus } from './useRegistrationStatus';
describe('useRegistrationStatus', () => {
it('should not call API and return error state when user does not have manage-cloud permission', async () => {
const mockGetRegistrationStatus = jest.fn();
const { result } = renderHook(() => useRegistrationStatus(), {
wrapper: mockAppRoot().withEndpoint('GET', '/v1/cloud.registrationStatus', mockGetRegistrationStatus).withJohnDoe().build(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.canViewRegistrationStatus).toBe(false);
expect(result.current.isRegistered).toBeFalsy();
expect(mockGetRegistrationStatus).not.toHaveBeenCalled();
});
it('should call API and return isRegistered as true and canViewRegistrationStatus as true when workspace is registered and user has manage-cloud permission', async () => {
const mockGetRegistrationStatus = jest.fn().mockResolvedValue({
registrationStatus: {
workspaceRegistered: true,
},
});
const { result } = renderHook(() => useRegistrationStatus(), {
wrapper: mockAppRoot()
.withEndpoint('GET', '/v1/cloud.registrationStatus', mockGetRegistrationStatus)
.withPermission('manage-cloud')
.withJohnDoe()
.build(),
});
await waitFor(() => {
expect(mockGetRegistrationStatus).toHaveBeenCalled();
});
expect(result.current.isRegistered).toBe(true);
expect(result.current.canViewRegistrationStatus).toBe(true);
});
it('should call API, return isRegistered as false and canViewRegistrationStatus as true when workspace is not registered and user has manage-cloud permission', async () => {
const mockGetRegistrationStatus = jest.fn().mockResolvedValue({
registrationStatus: {
workspaceRegistered: false,
},
});
const { result } = renderHook(() => useRegistrationStatus(), {
wrapper: mockAppRoot()
.withEndpoint('GET', '/v1/cloud.registrationStatus', mockGetRegistrationStatus)
.withPermission('manage-cloud')
.withJohnDoe()
.build(),
});
await waitFor(() => {
expect(mockGetRegistrationStatus).toHaveBeenCalled();
});
expect(result.current.isRegistered).toBe(false);
expect(result.current.canViewRegistrationStatus).toBe(true);
});
});

View File

@@ -0,0 +1,31 @@
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
type useRegistrationStatusReturnType = {
canViewRegistrationStatus: boolean;
isRegistered?: boolean;
} & UseQueryResult<OperationResult<'GET', '/v1/cloud.registrationStatus'>>;
export const useRegistrationStatus = (): useRegistrationStatusReturnType => {
const getRegistrationStatus = useEndpoint('GET', '/v1/cloud.registrationStatus');
const canViewRegistrationStatus = usePermission('manage-cloud');
const queryResult = useQuery({
queryKey: ['getRegistrationStatus'],
queryFn: () => {
if (!canViewRegistrationStatus) {
throw new Error('unauthorized api call');
}
return getRegistrationStatus();
},
staleTime: Infinity,
});
return {
canViewRegistrationStatus,
isRegistered: !queryResult.isPending && queryResult.data?.registrationStatus?.workspaceRegistered,
...queryResult,
};
};

View File

@@ -0,0 +1,10 @@
import { useResizeObserver, useStableArray } from '@rocket.chat/fuselage-hooks';
import { useMemo } from 'react';
export const useResizeInlineBreakpoint = <TElement extends Element>(sizes: number[] = [], debounceDelay = 0) => {
const { ref, borderBoxSize } = useResizeObserver<TElement>({ debounceDelay });
const inlineSize = borderBoxSize ? borderBoxSize.inlineSize : 0;
const stableSizes = useStableArray(sizes);
const newSizes = useMemo(() => stableSizes.map((current) => (inlineSize ? inlineSize > current : true)), [inlineSize, stableSizes]);
return [ref, ...newSizes] as const;
};

View File

@@ -0,0 +1,69 @@
import { useRoomIcon } from './useRoomIcon';
import { createFakeRoom } from '../../tests/mocks/data';
const mockRooms = {
public: createFakeRoom({
t: 'c',
name: 'public',
teamMain: false,
}),
private: createFakeRoom({
t: 'p',
name: 'private',
teamMain: false,
}),
team: createFakeRoom({
t: 'c',
name: 'team',
teamMain: true,
}),
direct: createFakeRoom({
t: 'd',
name: 'direct',
teamMain: false,
}),
directMultiple: createFakeRoom({
t: 'd',
name: 'direct-multiple',
teamMain: false,
uids: ['user1', 'user2', 'user3'],
}),
federated: createFakeRoom({
t: 'c',
name: 'federated',
teamMain: false,
federated: true,
}),
abacRoom: createFakeRoom({
t: 'c',
name: 'abac-room',
teamMain: false,
abacAttributes: [{ key: 'department', values: ['engineering'] }],
}),
abacTeamRoom: createFakeRoom({
t: 'c',
name: 'abac-team-room',
teamMain: true,
abacAttributes: [{ key: 'team', values: ['core'] }],
}),
};
const expectedResults = {
public: { name: 'hash' },
private: { name: 'hashtag-lock' },
team: { name: 'team' },
federated: { name: 'globe' },
abacRoom: { name: 'hash-shield' },
abacTeamRoom: { name: 'team-shield' },
direct: { name: 'at' },
directMultiple: { name: 'balloon' },
};
describe('useRoomIcon', () => {
it('should return the correct icon for each room', () => {
Object.entries(mockRooms).forEach(([key, room]) => {
const result = useRoomIcon(room);
expect(result).toEqual(expectedResults[key as keyof typeof expectedResults]);
});
});
});

View File

@@ -0,0 +1,56 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated, isDirectMessageRoom } from '@rocket.chat/core-typings';
import type { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactElement } from 'react';
import { ReactiveUserStatus } from '../components/UserStatus';
export const useRoomIcon = (
room: Pick<IRoom, 't' | 'prid' | 'teamMain' | 'uids' | 'u' | 'abacAttributes'>,
): ComponentProps<typeof Icon> | ReactElement<any> | null => {
if (room.abacAttributes) {
if (room.teamMain) {
return { name: 'team-shield' };
}
return { name: 'hash-shield' };
}
if (isRoomFederated(room)) {
return { name: 'globe' };
}
if (room.prid) {
return { name: 'baloons' };
}
if (room.teamMain) {
return { name: room.t === 'p' ? 'team-lock' : 'team' };
}
if (isDirectMessageRoom(room)) {
if (room.uids && room.uids.length > 2) {
return { name: 'balloon' };
}
if (room.uids && room.uids.length > 0) {
const uid = room.uids.find((uid) => uid !== room?.u?._id) || room?.u?._id;
if (!uid) {
return null;
}
return <ReactiveUserStatus uid={uid} />;
}
return { name: 'at' };
}
switch (room.t) {
case 'p':
return { name: 'hashtag-lock' };
case 'c':
return { name: 'hash' };
default:
return null;
}
};

View File

@@ -0,0 +1,49 @@
import type { IRoom, ITeam, Serialized } from '@rocket.chat/core-typings';
import { useEndpoint, useUserId } from '@rocket.chat/ui-contexts';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { minutesToMilliseconds } from 'date-fns';
import { roomsQueryKeys } from '../lib/queryKeys';
type UseRoomInfoEndpointOptions<
TData = Serialized<{
room: IRoom | undefined;
parent?: Pick<IRoom, '_id' | 'name' | 'fname' | 't' | 'prid' | 'u'>;
team?: Pick<ITeam, 'name' | 'roomId' | 'type' | '_id'>;
}>,
> = Omit<
UseQueryOptions<
Serialized<{
room: IRoom | undefined;
parent?: Pick<IRoom, '_id' | 'name' | 'fname' | 't' | 'prid' | 'u'>;
team?: Pick<ITeam, 'name' | 'roomId' | 'type' | '_id'>;
}>,
{ success: boolean; error: string },
TData,
ReturnType<typeof roomsQueryKeys.info>
>,
'queryKey' | 'queryFn'
>;
export const useRoomInfoEndpoint = <
TData = Serialized<{
room: IRoom | undefined;
parent?: Pick<IRoom, '_id' | 'name' | 'fname' | 't' | 'prid' | 'u'>;
team?: Pick<ITeam, 'name' | 'roomId' | 'type' | '_id'>;
}>,
>(
rid: IRoom['_id'],
options?: UseRoomInfoEndpointOptions<TData>,
) => {
const getRoomInfo = useEndpoint('GET', '/v1/rooms.info');
const uid = useUserId();
return useQuery({
queryKey: roomsQueryKeys.info(rid),
queryFn: () => getRoomInfo({ roomId: rid }),
gcTime: minutesToMilliseconds(15),
retry: (count, error: { success: boolean; error: string }) => count <= 2 && error.error !== 'not-allowed',
enabled: !!uid,
...options,
});
};

View File

@@ -0,0 +1,111 @@
import type { RoomType } from '@rocket.chat/core-typings';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { usePermission, useSetting, useUserSubscription } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLeaveRoomAction } from './menuActions/useLeaveRoom';
import { useToggleFavoriteAction } from './menuActions/useToggleFavoriteAction';
import { useToggleReadAction } from './menuActions/useToggleReadAction';
import { useHideRoomAction } from './useHideRoomAction';
import { useOmnichannelPrioritiesMenu } from '../views/omnichannel/hooks/useOmnichannelPrioritiesMenu';
type RoomMenuActionsProps = {
rid: string;
type: RoomType;
name: string;
isUnread?: boolean;
cl?: boolean;
roomOpen?: boolean;
hideDefaultOptions: boolean;
};
export const useRoomMenuActions = ({
rid,
type,
name,
isUnread,
cl,
roomOpen,
hideDefaultOptions,
}: RoomMenuActionsProps): { title: string; items: GenericMenuItemProps[] }[] => {
const { t } = useTranslation();
const subscription = useUserSubscription(rid);
const isFavorite = Boolean(subscription?.f);
const canLeaveChannel = usePermission('leave-c');
const canLeavePrivate = usePermission('leave-p');
const canFavorite = useSetting('Favorite_Rooms') as boolean;
const canLeave = ((): boolean => {
if (type === 'c' && !canLeaveChannel) {
return false;
}
if (type === 'p' && !canLeavePrivate) {
return false;
}
return !((cl != null && !cl) || ['d', 'l'].includes(type));
})();
const handleHide = useHideRoomAction({ rid, type, name }, { redirect: false });
const handleToggleFavorite = useToggleFavoriteAction({ rid, isFavorite });
const handleToggleRead = useToggleReadAction({ rid, isUnread, subscription });
const handleLeave = useLeaveRoomAction({ rid, type, name, roomOpen });
const isOmnichannelRoom = type === 'l';
const prioritiesMenu = useOmnichannelPrioritiesMenu(rid);
const menuOptions = useMemo(
() =>
!hideDefaultOptions
? ([
!isOmnichannelRoom && {
id: 'hideRoom',
icon: 'eye-off',
content: t('Hide'),
onClick: handleHide,
},
{
id: 'toggleRead',
icon: 'flag',
content: isUnread ? t('Mark_read') : t('Mark_unread'),
onClick: handleToggleRead,
},
canFavorite && {
id: 'toggleFavorite',
icon: isFavorite ? 'star-filled' : 'star',
content: isFavorite ? t('Unfavorite') : t('Favorite'),
onClick: handleToggleFavorite,
},
canLeave && {
id: 'leaveRoom',
icon: 'sign-out',
content: t('Leave_room'),
onClick: handleLeave,
},
].filter(Boolean) as GenericMenuItemProps[])
: [],
[
hideDefaultOptions,
t,
handleHide,
isUnread,
handleToggleRead,
canFavorite,
isFavorite,
handleToggleFavorite,
canLeave,
handleLeave,
isOmnichannelRoom,
],
);
if (isOmnichannelRoom && prioritiesMenu.length > 0) {
return [
...(menuOptions.length > 0 ? [{ title: '', items: menuOptions }] : []),
...(prioritiesMenu.length > 0 ? [{ title: t('Priorities'), items: prioritiesMenu }] : []),
];
}
return menuOptions.length > 0 ? [{ title: '', items: menuOptions }] : [];
};

View File

@@ -0,0 +1,24 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { useUserSubscription } from '@rocket.chat/ui-contexts';
/**
*
* Hook to get the name of the room
*
* @param room - Room object
* @returns Room name
* @public
*
*/
export const useRoomName = (room: IRoom) => {
const subscription = useUserSubscription(room._id);
const username = useUserDisplayName({ name: subscription?.fname, username: subscription?.name });
if (isDirectMessageRoom(room)) {
return username;
}
return room.fname || room.name;
};

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