snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
65
apps/meteor/client/hooks/menuActions/useLeaveRoom.tsx
Normal file
65
apps/meteor/client/hooks/menuActions/useLeaveRoom.tsx
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
50
apps/meteor/client/hooks/menuActions/useToggleReadAction.ts
Normal file
50
apps/meteor/client/hooks/menuActions/useToggleReadAction.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
123
apps/meteor/client/hooks/notification/useNotification.ts
Normal file
123
apps/meteor/client/hooks/notification/useNotification.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
35
apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts
Normal file
35
apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts
Normal 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]);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
112
apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts
Normal file
112
apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts
Normal 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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
65
apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx
Normal file
65
apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx
Normal 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]);
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
47
apps/meteor/client/hooks/useAirGappedRestriction.spec.ts
Normal file
47
apps/meteor/client/hooks/useAirGappedRestriction.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
19
apps/meteor/client/hooks/useAirGappedRestriction.ts
Normal file
19
apps/meteor/client/hooks/useAirGappedRestriction.ts
Normal 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];
|
||||
};
|
||||
59
apps/meteor/client/hooks/useAppActionButtons.ts
Normal file
59
apps/meteor/client/hooks/useAppActionButtons.ts
Normal 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;
|
||||
};
|
||||
285
apps/meteor/client/hooks/useAppSlashCommands.spec.ts
Normal file
285
apps/meteor/client/hooks/useAppSlashCommands.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
88
apps/meteor/client/hooks/useAppSlashCommands.ts
Normal file
88
apps/meteor/client/hooks/useAppSlashCommands.ts
Normal 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));
|
||||
};
|
||||
18
apps/meteor/client/hooks/useAppUiKitInteraction.ts
Normal file
18
apps/meteor/client/hooks/useAppUiKitInteraction.ts
Normal 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]);
|
||||
};
|
||||
354
apps/meteor/client/hooks/useApplyButtonFilters.spec.ts
Normal file
354
apps/meteor/client/hooks/useApplyButtonFilters.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
89
apps/meteor/client/hooks/useApplyButtonFilters.ts
Normal file
89
apps/meteor/client/hooks/useApplyButtonFilters.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
14
apps/meteor/client/hooks/useClipboardWithToast.ts
Normal file
14
apps/meteor/client/hooks/useClipboardWithToast.ts
Normal 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 })),
|
||||
});
|
||||
}
|
||||
42
apps/meteor/client/hooks/useCreateChannelTypePermission.ts
Normal file
42
apps/meteor/client/hooks/useCreateChannelTypePermission.ts
Normal 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]);
|
||||
};
|
||||
89
apps/meteor/client/hooks/useDecryptedMessage.spec.ts
Normal file
89
apps/meteor/client/hooks/useDecryptedMessage.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
apps/meteor/client/hooks/useDecryptedMessage.ts
Normal file
34
apps/meteor/client/hooks/useDecryptedMessage.ts
Normal 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;
|
||||
};
|
||||
173
apps/meteor/client/hooks/useDeviceLogout.spec.tsx
Normal file
173
apps/meteor/client/hooks/useDeviceLogout.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
73
apps/meteor/client/hooks/useDeviceLogout.tsx
Normal file
73
apps/meteor/client/hooks/useDeviceLogout.tsx
Normal 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]);
|
||||
};
|
||||
64
apps/meteor/client/hooks/useDownloadFromServiceWorker.ts
Normal file
64
apps/meteor/client/hooks/useDownloadFromServiceWorker.ts
Normal 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],
|
||||
),
|
||||
};
|
||||
};
|
||||
43
apps/meteor/client/hooks/useEndpointMutation.ts
Normal file
43
apps/meteor/client/hooks/useEndpointMutation.ts
Normal 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>
|
||||
>;
|
||||
}
|
||||
39
apps/meteor/client/hooks/useEndpointUploadMutation.ts
Normal file
39
apps/meteor/client/hooks/useEndpointUploadMutation.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
24
apps/meteor/client/hooks/useExperimentalEndpoint.ts
Normal file
24
apps/meteor/client/hooks/useExperimentalEndpoint.ts
Normal 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>>;
|
||||
}
|
||||
55
apps/meteor/client/hooks/useExpirationText.ts
Normal file
55
apps/meteor/client/hooks/useExpirationText.ts
Normal 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]);
|
||||
}
|
||||
12
apps/meteor/client/hooks/useExternalLink.ts
Normal file
12
apps/meteor/client/hooks/useExternalLink.ts
Normal 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');
|
||||
}, []);
|
||||
};
|
||||
31
apps/meteor/client/hooks/useFeaturePreviewEnableQuery.ts
Normal file
31
apps/meteor/client/hooks/useFeaturePreviewEnableQuery.ts
Normal 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]);
|
||||
};
|
||||
44
apps/meteor/client/hooks/useFileInput.ts
Normal file
44
apps/meteor/client/hooks/useFileInput.ts
Normal 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;
|
||||
};
|
||||
114
apps/meteor/client/hooks/useFireGlobalEvent.spec.ts
Normal file
114
apps/meteor/client/hooks/useFireGlobalEvent.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
34
apps/meteor/client/hooks/useFireGlobalEvent.ts
Normal file
34
apps/meteor/client/hooks/useFireGlobalEvent.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
39
apps/meteor/client/hooks/useFormSubmitWithDirtyCheck.ts
Normal file
39
apps/meteor/client/hooks/useFormSubmitWithDirtyCheck.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
9
apps/meteor/client/hooks/useFormatDate.ts
Normal file
9
apps/meteor/client/hooks/useFormatDate.ts
Normal 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]);
|
||||
};
|
||||
28
apps/meteor/client/hooks/useFormatDateAndTime.ts
Normal file
28
apps/meteor/client/hooks/useFormatDateAndTime.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
30
apps/meteor/client/hooks/useFormatDuration.ts
Normal file
30
apps/meteor/client/hooks/useFormatDuration.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
24
apps/meteor/client/hooks/useFormatMemorySize.ts
Normal file
24
apps/meteor/client/hooks/useFormatMemorySize.ts
Normal 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;
|
||||
26
apps/meteor/client/hooks/useFormatTime.ts
Normal file
26
apps/meteor/client/hooks/useFormatTime.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
5
apps/meteor/client/hooks/useFormattedRelativeTime.ts
Normal file
5
apps/meteor/client/hooks/useFormattedRelativeTime.ts
Normal 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]);
|
||||
7
apps/meteor/client/hooks/useHasLicenseModule.ts
Normal file
7
apps/meteor/client/hooks/useHasLicenseModule.ts
Normal 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),
|
||||
});
|
||||
92
apps/meteor/client/hooks/useHideRoomAction.tsx
Normal file
92
apps/meteor/client/hooks/useHideRoomAction.tsx
Normal 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;
|
||||
};
|
||||
23
apps/meteor/client/hooks/useHighlightedCode.ts
Normal file
23
apps/meteor/client/hooks/useHighlightedCode.ts
Normal 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]);
|
||||
}
|
||||
47
apps/meteor/client/hooks/useIdleActiveEvents.ts
Normal file
47
apps/meteor/client/hooks/useIdleActiveEvents.ts
Normal 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 });
|
||||
};
|
||||
27
apps/meteor/client/hooks/useIdleConnection.ts
Normal file
27
apps/meteor/client/hooks/useIdleConnection.ts
Normal 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);
|
||||
};
|
||||
223
apps/meteor/client/hooks/useIdleDetection.spec.ts
Normal file
223
apps/meteor/client/hooks/useIdleDetection.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/meteor/client/hooks/useIdleDetection.ts
Normal file
90
apps/meteor/client/hooks/useIdleDetection.ts
Normal 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]);
|
||||
};
|
||||
108
apps/meteor/client/hooks/useInfiniteMessageQueryUpdates.ts
Normal file
108
apps/meteor/client/hooks/useInfiniteMessageQueryUpdates.ts
Normal 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]);
|
||||
};
|
||||
6
apps/meteor/client/hooks/useIsEnterprise.ts
Normal file
6
apps/meteor/client/hooks/useIsEnterprise.ts
Normal 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) }) });
|
||||
};
|
||||
8
apps/meteor/client/hooks/useIsFederationEnabled.ts
Normal file
8
apps/meteor/client/hooks/useIsFederationEnabled.ts
Normal 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;
|
||||
};
|
||||
9
apps/meteor/client/hooks/useIsSelfHosted.ts
Normal file
9
apps/meteor/client/hooks/useIsSelfHosted.ts
Normal 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 };
|
||||
};
|
||||
34
apps/meteor/client/hooks/useJoinRoom.ts
Normal file
34
apps/meteor/client/hooks/useJoinRoom.ts
Normal 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' });
|
||||
},
|
||||
});
|
||||
};
|
||||
47
apps/meteor/client/hooks/useLdapSync.tsx
Normal file
47
apps/meteor/client/hooks/useLdapSync.tsx
Normal 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>,
|
||||
);
|
||||
};
|
||||
};
|
||||
62
apps/meteor/client/hooks/useLicenseLimitsByBehavior.ts
Normal file
62
apps/meteor/client/hooks/useLicenseLimitsByBehavior.ts
Normal 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);
|
||||
};
|
||||
42
apps/meteor/client/hooks/useLivechatInquiryStore.ts
Normal file
42
apps/meteor/client/hooks/useLivechatInquiryStore.ts
Normal 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: [] }));
|
||||
},
|
||||
}));
|
||||
8
apps/meteor/client/hooks/useLocalePercentage.ts
Normal file
8
apps/meteor/client/hooks/useLocalePercentage.ts
Normal 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);
|
||||
};
|
||||
30
apps/meteor/client/hooks/useMergedRefsV2.ts
Normal file
30
apps/meteor/client/hooks/useMergedRefsV2.ts
Normal 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);
|
||||
};
|
||||
62
apps/meteor/client/hooks/useMessageboxAppsActionButtons.ts
Normal file
62
apps/meteor/client/hooks/useMessageboxAppsActionButtons.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
10
apps/meteor/client/hooks/usePreventPropagation.ts
Normal file
10
apps/meteor/client/hooks/usePreventPropagation.ts
Normal 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;
|
||||
};
|
||||
183
apps/meteor/client/hooks/usePruneWarningMessage.spec.ts
Normal file
183
apps/meteor/client/hooks/usePruneWarningMessage.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
105
apps/meteor/client/hooks/usePruneWarningMessage.ts
Normal file
105
apps/meteor/client/hooks/usePruneWarningMessage.ts
Normal 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 });
|
||||
};
|
||||
68
apps/meteor/client/hooks/useRegistrationStatus.spec.ts
Normal file
68
apps/meteor/client/hooks/useRegistrationStatus.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
apps/meteor/client/hooks/useRegistrationStatus.ts
Normal file
31
apps/meteor/client/hooks/useRegistrationStatus.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
10
apps/meteor/client/hooks/useResizeInlineBreakpoint.ts
Normal file
10
apps/meteor/client/hooks/useResizeInlineBreakpoint.ts
Normal 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;
|
||||
};
|
||||
69
apps/meteor/client/hooks/useRoomIcon.spec.ts
Normal file
69
apps/meteor/client/hooks/useRoomIcon.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
apps/meteor/client/hooks/useRoomIcon.tsx
Normal file
56
apps/meteor/client/hooks/useRoomIcon.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
49
apps/meteor/client/hooks/useRoomInfoEndpoint.ts
Normal file
49
apps/meteor/client/hooks/useRoomInfoEndpoint.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
111
apps/meteor/client/hooks/useRoomMenuActions.ts
Normal file
111
apps/meteor/client/hooks/useRoomMenuActions.ts
Normal 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 }] : [];
|
||||
};
|
||||
24
apps/meteor/client/hooks/useRoomName.ts
Normal file
24
apps/meteor/client/hooks/useRoomName.ts
Normal 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
Reference in New Issue
Block a user