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:
@@ -0,0 +1,71 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useCustomEmoji } from './useCustomEmoji';
|
||||
import { emoji } from '../../../../lib/emoji';
|
||||
|
||||
jest.mock('../../../../lib/customEmoji', () => ({ customRender: jest.fn((html: string) => html) }));
|
||||
|
||||
const pristinePackages = { ...emoji.packages };
|
||||
|
||||
beforeEach(() => {
|
||||
emoji.list = {};
|
||||
emoji.packages = { ...pristinePackages };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createCustomEmoji = () => ({
|
||||
_id: 'emojiId',
|
||||
_updatedAt: new Date().toISOString(),
|
||||
name: 'my-emoji',
|
||||
aliases: ['my-alias'],
|
||||
extension: 'png',
|
||||
});
|
||||
|
||||
it('should call emoji-custom.list without a query parameter', async () => {
|
||||
const getCustomEmojis = jest.fn().mockReturnValue({ emojis: { update: [], remove: [] } });
|
||||
|
||||
renderHook(() => useCustomEmoji(), {
|
||||
wrapper: mockAppRoot().withEndpoint('GET', '/v1/emoji-custom.list', getCustomEmojis).build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getCustomEmojis).toHaveBeenCalledWith({}));
|
||||
});
|
||||
|
||||
it('should populate emoji.packages.emojiCustom with the emojis and aliases returned by the endpoint', async () => {
|
||||
const customEmoji = createCustomEmoji();
|
||||
const dispatchUpdateSpy = jest.spyOn(emoji, 'dispatchUpdate');
|
||||
|
||||
renderHook(() => useCustomEmoji(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/emoji-custom.list', () => ({ emojis: { update: [customEmoji], remove: [] } }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(emoji.packages.emojiCustom.emojisByCategory.rocket).toContain('my-emoji'));
|
||||
|
||||
expect(emoji.packages.emojiCustom.list).toEqual(expect.arrayContaining([':my-emoji:', ':my-alias:']));
|
||||
expect(emoji.list[':my-emoji:']).toMatchObject({ name: 'my-emoji', emojiPackage: 'emojiCustom' });
|
||||
expect(emoji.list[':my-alias:']).toEqual({ emojiPackage: 'emojiCustom', aliasOf: 'my-emoji' });
|
||||
expect(dispatchUpdateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset emoji.packages.emojiCustom to an empty list when the request fails', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
renderHook(() => useCustomEmoji(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/emoji-custom.list', () => {
|
||||
throw new Error('unexpected error');
|
||||
})
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(consoleErrorSpy).toHaveBeenCalledWith('Error getting custom emoji ', expect.any(Error)));
|
||||
|
||||
expect(emoji.packages.emojiCustom.list).toEqual([]);
|
||||
expect(emoji.packages.emojiCustom.emojisByCategory.rocket).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { customRender } from '../../../../lib/customEmoji';
|
||||
import { emoji } from '../../../../lib/emoji';
|
||||
|
||||
export const useCustomEmoji = () => {
|
||||
const getCustomEmojis = useEndpoint('GET', '/v1/emoji-custom.list');
|
||||
const result = useQuery({
|
||||
queryKey: ['emoji-custom.list'],
|
||||
queryFn: () => getCustomEmojis({}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
emoji.packages.emojiCustom = {
|
||||
emojiCategories: [{ key: 'rocket', i18n: 'Custom' }],
|
||||
categoryIndex: 1,
|
||||
toneList: {},
|
||||
list: [],
|
||||
_regexpSignature: null,
|
||||
_regexp: null,
|
||||
emojisByCategory: { rocket: [] },
|
||||
render: customRender,
|
||||
renderPicker: customRender,
|
||||
};
|
||||
|
||||
if (result.isError) {
|
||||
console.error('Error getting custom emoji ', result.error);
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
const {
|
||||
emojis: { update: customEmojis },
|
||||
} = result.data;
|
||||
|
||||
const addCustomEmojis = () => {
|
||||
for (const currentEmoji of customEmojis) {
|
||||
emoji.packages.emojiCustom.emojisByCategory.rocket.push(currentEmoji.name);
|
||||
emoji.packages.emojiCustom.list?.push(`:${currentEmoji.name}:`);
|
||||
emoji.list[`:${currentEmoji.name}:`] = { ...currentEmoji, emojiPackage: 'emojiCustom' } as any;
|
||||
for (const alias of currentEmoji.aliases) {
|
||||
emoji.packages.emojiCustom.list?.push(`:${alias}:`);
|
||||
emoji.list[`:${alias}:`] = {
|
||||
emojiPackage: 'emojiCustom',
|
||||
aliasOf: currentEmoji.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
emoji.dispatchUpdate();
|
||||
};
|
||||
addCustomEmojis();
|
||||
}
|
||||
}, [result.data, result.error, result.isError, result.isSuccess]);
|
||||
};
|
||||
121
apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts
Normal file
121
apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { isE2EEPinnedMessage, type IRoom, type IMessage } from '@rocket.chat/core-typings';
|
||||
import { useUserId, useSetting, useRouter, useLayout, useUser } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { MentionsParser } from '../../../../../app/mentions/lib/MentionsParser';
|
||||
import { e2e } from '../../../../lib/e2ee';
|
||||
import { onClientBeforeSendMessage } from '../../../../lib/onClientBeforeSendMessage';
|
||||
import { onClientMessageReceived } from '../../../../lib/onClientMessageReceived';
|
||||
import { Rooms } from '../../../../stores';
|
||||
import { useE2EEState } from '../../../room/hooks/useE2EEState';
|
||||
|
||||
export const useE2EEncryption = () => {
|
||||
const userId = useUserId();
|
||||
const enabled = useSetting('E2E_Enable', false);
|
||||
const router = useRouter();
|
||||
// we don't care about the reactivity of this boolean
|
||||
const adminEmbedded = useLayout().isEmbedded && router.getLocationPathname().startsWith('/admin');
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled && !adminEmbedded) {
|
||||
e2e.startClient(userId);
|
||||
} else {
|
||||
e2e.setState('DISABLED');
|
||||
e2e.closeAlert();
|
||||
}
|
||||
}, [adminEmbedded, enabled, userId]);
|
||||
|
||||
const state = useE2EEState();
|
||||
const ready = state === 'READY' || state === 'SAVE_PASSWORD';
|
||||
const listenersAttachedRef = useRef(false);
|
||||
|
||||
const mentionsEnabled = useSetting('E2E_Enabled_Mentions', true);
|
||||
const me = useUser()?.username || '';
|
||||
const pattern = useSetting('UTF8_User_Names_Validation', '[0-9a-zA-Z-_.]+');
|
||||
const useRealName = useSetting('UI_Use_Real_Name', false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (listenersAttachedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offClientMessageReceived = onClientMessageReceived.use(async (msg) => {
|
||||
const e2eRoom = await e2e.getInstanceByRoomId(msg.rid);
|
||||
if (!e2eRoom?.shouldConvertReceivedMessages()) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (isE2EEPinnedMessage(msg)) {
|
||||
return e2e.decryptPinnedMessage(msg);
|
||||
}
|
||||
|
||||
return e2e.decryptMessage(msg);
|
||||
});
|
||||
|
||||
// Encrypt messages before sending
|
||||
const offClientBeforeSendMessage = onClientBeforeSendMessage.use(async (message) => {
|
||||
const e2eRoom = await e2e.getInstanceByRoomId(message.rid);
|
||||
|
||||
if (!e2eRoom) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// e2e.getInstanceByRoomId already waits for the room to be available which means this logic needs to be
|
||||
// refactored to avoid waiting for the room again
|
||||
const subscription = await new Promise<IRoom>((resolve) => {
|
||||
const room = Rooms.state.get(message.rid);
|
||||
|
||||
if (room) resolve(room);
|
||||
|
||||
const unsubscribe = Rooms.use.subscribe((state) => {
|
||||
const room = state.get(message.rid);
|
||||
if (room) {
|
||||
unsubscribe();
|
||||
resolve(room);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
subscription.encrypted ? e2eRoom.resume() : e2eRoom.pause();
|
||||
|
||||
const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages(message);
|
||||
|
||||
if (!shouldConvertSentMessages) return message;
|
||||
|
||||
if (mentionsEnabled) {
|
||||
const mentions = new MentionsParser({
|
||||
pattern: () => pattern,
|
||||
useRealName: () => useRealName,
|
||||
me: () => me,
|
||||
});
|
||||
|
||||
const e2eMentions: IMessage['e2eMentions'] = {
|
||||
e2eUserMentions: mentions.getUserMentions(message.msg),
|
||||
e2eChannelMentions: mentions.getChannelMentions(message.msg),
|
||||
};
|
||||
|
||||
message.e2eMentions = e2eMentions;
|
||||
}
|
||||
|
||||
// Should encrypt this message.
|
||||
const encryptedMessage = await e2eRoom.encryptMessage(message);
|
||||
return encryptedMessage;
|
||||
});
|
||||
|
||||
listenersAttachedRef.current = true;
|
||||
|
||||
return () => {
|
||||
offClientMessageReceived();
|
||||
offClientBeforeSendMessage();
|
||||
listenersAttachedRef.current = false;
|
||||
};
|
||||
}, [me, mentionsEnabled, pattern, ready, useRealName]);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEndpoint, useRole, useSetModal, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect, useReducer } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FingerprintChangeModal from '../../../../components/FingerprintChangeModal';
|
||||
import FingerprintChangeModalConfirmation from '../../../../components/FingerprintChangeModalConfirmation';
|
||||
|
||||
const reducer = (
|
||||
state: { openModal: boolean; openConfirmation: boolean; newWorkspace?: boolean },
|
||||
action: { type: 'openModal' | 'openConfirmation' | 'closeModal'; newWorkspace?: boolean },
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case 'openModal':
|
||||
return { openModal: true, openConfirmation: false, newWorkspace: undefined };
|
||||
case 'openConfirmation':
|
||||
return { openModal: false, openConfirmation: true, newWorkspace: action.newWorkspace };
|
||||
case 'closeModal':
|
||||
return { openModal: false, openConfirmation: false, newWorkspace: undefined };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const useFingerprintChange = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const isAdmin = useRole('admin');
|
||||
const setModal = useSetModal();
|
||||
const deploymentFingerPrintVerified = useSetting('Deployment_FingerPrint_Verified', true);
|
||||
const fingerprintEndpoint = useEndpoint('POST', '/v1/fingerprint');
|
||||
|
||||
const [{ openConfirmation, openModal, newWorkspace }, dispatch] = useReducer(reducer, {
|
||||
openModal: false,
|
||||
openConfirmation: false,
|
||||
newWorkspace: undefined,
|
||||
});
|
||||
|
||||
const { mutate: fingerPrintMutation } = useMutation({
|
||||
mutationKey: ['settings', 'Deployment_FingerPrint_Verified'],
|
||||
mutationFn: async (setDeploymentAs: 'new-workspace' | 'updated-configuration') => {
|
||||
const result = await fingerprintEndpoint({ setDeploymentAs });
|
||||
return {
|
||||
...result,
|
||||
setDeploymentAs,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ setDeploymentAs }) => {
|
||||
if (setDeploymentAs === 'new-workspace') {
|
||||
return dispatchToastMessage({ type: 'success', message: t('New_workspace_confirmed') });
|
||||
}
|
||||
return dispatchToastMessage({ type: 'success', message: t('Configuration_update_confirmed') });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
if (deploymentFingerPrintVerified === null || deploymentFingerPrintVerified === true) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: 'openModal' });
|
||||
|
||||
return () => {
|
||||
dispatch({ type: 'closeModal' });
|
||||
};
|
||||
}, [deploymentFingerPrintVerified, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openModal && !openConfirmation) {
|
||||
setModal(null);
|
||||
}
|
||||
if (openModal) {
|
||||
setModal(
|
||||
<FingerprintChangeModal
|
||||
onConfirm={() => dispatch({ type: 'openConfirmation', newWorkspace: false })}
|
||||
onCancel={() => dispatch({ type: 'openConfirmation', newWorkspace: true })}
|
||||
onClose={() => dispatch({ type: 'closeModal' })}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (openConfirmation && newWorkspace !== undefined) {
|
||||
setModal(
|
||||
<FingerprintChangeModalConfirmation
|
||||
onConfirm={() => {
|
||||
fingerPrintMutation(newWorkspace ? 'new-workspace' : 'updated-configuration');
|
||||
dispatch({ type: 'closeModal' });
|
||||
}}
|
||||
onCancel={() => dispatch({ type: 'openModal' })}
|
||||
onClose={() => dispatch({ type: 'closeModal' })}
|
||||
newWorkspace={newWorkspace}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}, [fingerPrintMutation, setModal, openConfirmation, openModal, newWorkspace]);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { FontSize } from '@rocket.chat/rest-typings';
|
||||
import { useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { useCreateFontStyleElement } from '../../../account/accessibility/hooks/useCreateFontStyleElement';
|
||||
|
||||
export const useFontStylePreference = () => {
|
||||
const fontSize = useUserPreference<FontSize>('fontSize');
|
||||
const createFontStyleElement = useCreateFontStyleElement();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Handle the edge case where the font-size preference ceases to exist: set to 100% (default)
|
||||
createFontStyleElement(fontSize || '100%');
|
||||
}, [fontSize, createFontStyleElement]);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useSessionDispatch, useStream, useWipeLocalAuth } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { isSdkTransportEnabled } from '../../../../lib/sdk/sdkTransportEnabled';
|
||||
|
||||
const sdkTransportEnabled = isSdkTransportEnabled();
|
||||
|
||||
export const useForceLogout = (userId: string) => {
|
||||
const getNotifyUserStream = useStream('notify-user');
|
||||
const setForceLogout = useSessionDispatch('forceLogout');
|
||||
const wipeLocalAuth = useWipeLocalAuth();
|
||||
|
||||
useEffect(() => {
|
||||
setForceLogout(false);
|
||||
|
||||
const unsubscribe = getNotifyUserStream(`${userId}/force_logout`, () => {
|
||||
setForceLogout(true);
|
||||
|
||||
if (!sdkTransportEnabled) {
|
||||
// Flag off: develop's exact behaviour — only set the session flag.
|
||||
// The legacy WS-reconnect → loginWithToken-fails → accounts-base
|
||||
// chain handles the actual local cleanup.
|
||||
return;
|
||||
}
|
||||
|
||||
// With the SDK socket as the transport, that chain no longer fires
|
||||
// reliably: DDPSDK auto-retries loginWithToken on every `connected`
|
||||
// and swallows the rejection with `void`, so the navbar stays on
|
||||
// Home with stale credentials. Wipe the stored login token + userId
|
||||
// here so the router falls back to /login.
|
||||
wipeLocalAuth();
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [getNotifyUserStream, setForceLogout, userId, wipeLocalAuth]);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useOnLogout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useFireGlobalEvent } from '../../../../hooks/useFireGlobalEvent';
|
||||
import { closeAllRooms } from '../closeAllRooms';
|
||||
import { purgeAllDrafts } from '../purgeAllDrafts';
|
||||
|
||||
export const useLogoutCleanup = () => {
|
||||
const onLogout = useOnLogout();
|
||||
const logoutCustomScript = useSetting('Custom_Script_On_Logout');
|
||||
const { mutate: fireLogoutCustomScript } = useFireGlobalEvent('Custom_Script_On_Logout');
|
||||
|
||||
useEffect(() => {
|
||||
return onLogout(() => {
|
||||
purgeAllDrafts();
|
||||
closeAllRooms();
|
||||
fireLogoutCustomScript(logoutCustomScript);
|
||||
});
|
||||
}, [fireLogoutCustomScript, logoutCustomScript, onLogout]);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ICalendarNotification, IUser } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { imperativeModal } from '@rocket.chat/ui-client';
|
||||
import { useStream, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import OutlookCalendarEventModal from '../../../outlookCalendar/OutlookCalendarEventModal';
|
||||
|
||||
export const useNotificationUserCalendar = (user: IUser) => {
|
||||
const requireInteraction = useUserPreference('desktopNotificationRequireInteraction');
|
||||
const notifyUserStream = useStream('notify-user');
|
||||
|
||||
const notifyUserCalendar = useStableCallback(async (notification: ICalendarNotification) => {
|
||||
if (user.status === 'busy') {
|
||||
return;
|
||||
}
|
||||
|
||||
let body = notification.text;
|
||||
if (notification.payload?.startTimeUtc) {
|
||||
try {
|
||||
const time = new Date(notification.payload.startTimeUtc);
|
||||
const formattedTime = time.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
dayPeriod: 'narrow',
|
||||
});
|
||||
body = formattedTime;
|
||||
} catch (error) {
|
||||
console.error('Failed to format calendar notification time:', error);
|
||||
body = notification.text;
|
||||
}
|
||||
}
|
||||
|
||||
const n = new Notification(notification.title, {
|
||||
body,
|
||||
tag: notification.payload._id,
|
||||
silent: true,
|
||||
requireInteraction,
|
||||
} as NotificationOptions);
|
||||
|
||||
n.onclick = function () {
|
||||
this.close();
|
||||
window.focus();
|
||||
imperativeModal.open({
|
||||
component: OutlookCalendarEventModal,
|
||||
props: { id: notification.payload._id, onClose: imperativeModal.close, onCancel: imperativeModal.close },
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id || !user.settings?.calendar?.outlook?.Enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return notifyUserStream(`${user._id}/calendar`, notifyUserCalendar);
|
||||
}, [notifyUserCalendar, notifyUserStream, user.settings?.calendar?.outlook?.Enabled, user?._id]);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AtLeast, INotificationDesktop, ISubscription, IUser } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useEmbeddedLayout } from '@rocket.chat/ui-client';
|
||||
import { useCustomSound, useRouter, useStream, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useDesktopNotification } from '../../../../hooks/notification/useDesktopNotification';
|
||||
import { useNewMessageNotification } from '../../../../hooks/notification/useNewMessageNotification';
|
||||
import { RoomManager } from '../../../../lib/RoomManager';
|
||||
import { fireGlobalEvent } from '../../../../lib/utils/fireGlobalEvent';
|
||||
|
||||
export const useNotifyUser = (user: IUser) => {
|
||||
const router = useRouter();
|
||||
const isLayoutEmbedded = useEmbeddedLayout();
|
||||
const notifyUserStream = useStream('notify-user');
|
||||
const muteFocusedConversations = useUserPreference('muteFocusedConversations');
|
||||
const { notificationSounds } = useCustomSound();
|
||||
const newMessageNotification = useNewMessageNotification();
|
||||
const showDesktopNotification = useDesktopNotification();
|
||||
|
||||
const notifyNewRoom = useStableCallback(async (sub: AtLeast<ISubscription, 'rid'>): Promise<void> => {
|
||||
if (user.status === 'busy') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((!router.getRouteParameters().name || router.getRouteParameters().name !== sub.name) && !sub.ls && sub.alert === true) {
|
||||
notificationSounds.playNewRoom();
|
||||
}
|
||||
});
|
||||
|
||||
const notifyNewMessageAudioAndDesktop = useStableCallback((notification: INotificationDesktop) => {
|
||||
const hasFocus = document.hasFocus();
|
||||
|
||||
const openedRoomId = ['channel', 'group', 'direct'].includes(router.getRouteName() || '') ? RoomManager.opened : undefined;
|
||||
|
||||
const { rid } = notification.payload;
|
||||
const messageIsInOpenedRoom = openedRoomId === rid;
|
||||
|
||||
fireGlobalEvent('notification', {
|
||||
notification,
|
||||
fromOpenedRoom: messageIsInOpenedRoom,
|
||||
hasFocus,
|
||||
});
|
||||
|
||||
if (isLayoutEmbedded) {
|
||||
if (!hasFocus && messageIsInOpenedRoom) {
|
||||
// Play a notification sound
|
||||
newMessageNotification(notification.payload);
|
||||
showDesktopNotification(notification);
|
||||
}
|
||||
} else if (!hasFocus || !messageIsInOpenedRoom || !muteFocusedConversations) {
|
||||
// Play a notification sound
|
||||
newMessageNotification(notification.payload);
|
||||
showDesktopNotification(notification);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubNotification = notifyUserStream(`${user._id}/notification`, notifyNewMessageAudioAndDesktop);
|
||||
|
||||
const unsubSubs = notifyUserStream(`${user._id}/subscriptions-changed`, (action, sub) => {
|
||||
if (action !== 'inserted') {
|
||||
return;
|
||||
}
|
||||
|
||||
void notifyNewRoom(sub);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubNotification();
|
||||
unsubSubs();
|
||||
};
|
||||
}, [notifyNewMessageAudioAndDesktop, notifyNewRoom, notifyUserStream, router, user._id]);
|
||||
|
||||
useEffect(() => () => notificationSounds.stopNewRoom(), [notificationSounds]);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { AuthorizationUtils } from '../../../../../app/authorization/lib';
|
||||
|
||||
export const useRestrictedRoles = (): void => {
|
||||
const getLicenseInfo = useEndpoint('GET', '/v1/licenses.info');
|
||||
|
||||
const { data: isEnterprise, isSuccess } = useQuery({
|
||||
queryKey: ['isEnterprise'],
|
||||
queryFn: async () => (await getLicenseInfo({})).license.hasValidLicense,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterprise) {
|
||||
AuthorizationUtils.addRolePermissionWhiteList('guest', [
|
||||
'view-d-room',
|
||||
'view-joined-room',
|
||||
'view-p-room',
|
||||
'start-discussion',
|
||||
'mobile-upload-file',
|
||||
]);
|
||||
}
|
||||
}, [isEnterprise, isSuccess]);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useRole, useSetModal, useSetting, useSettingSetValue, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import UrlChangeModal from '../../../../components/UrlChangeModal';
|
||||
import { getRootUrl, getRootUrlPathPrefix } from '../../../../lib/meteorRuntimeConfig';
|
||||
|
||||
export const useRootUrlChange = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const isAdmin = useRole('admin');
|
||||
const setModal = useSetModal();
|
||||
const closeModal = useStableCallback(() => setModal(null));
|
||||
|
||||
const currentUrl = location.origin + getRootUrlPathPrefix();
|
||||
const siteUrl = useSetting('Site_Url', '');
|
||||
const documentDomain = useSetting('Document_Domain', '');
|
||||
const setSiteUrl = useSettingSetValue('Site_Url');
|
||||
|
||||
const {
|
||||
mutate: siteUrlMutation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useMutation({
|
||||
mutationKey: ['settings', 'Site_Url'],
|
||||
mutationFn: async (url: string) => {
|
||||
await setSiteUrl(url);
|
||||
return { url };
|
||||
},
|
||||
onSuccess: ({ url }) => dispatchToastMessage({ type: 'success', message: t('Saved_new_url_site_is__url__', { url }) }),
|
||||
onError: () => dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
if (!siteUrl) {
|
||||
return;
|
||||
}
|
||||
if (isPending || isSuccess) {
|
||||
return;
|
||||
}
|
||||
const rootUrl = getRootUrl();
|
||||
if (rootUrl && rootUrl.replace(/\/$/, '') === currentUrl) {
|
||||
return;
|
||||
}
|
||||
const onConfirm = () => {
|
||||
closeModal();
|
||||
siteUrlMutation(currentUrl);
|
||||
};
|
||||
|
||||
setModal(<UrlChangeModal onClose={closeModal} onConfirm={onConfirm} siteUrl={siteUrl} currentUrl={currentUrl} />);
|
||||
|
||||
if (documentDomain) {
|
||||
window.document.domain = documentDomain;
|
||||
}
|
||||
|
||||
return closeModal;
|
||||
}, [currentUrl, documentDomain, siteUrlMutation, siteUrl, isAdmin, isPending, isSuccess, setModal, closeModal]);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useIsLoggingIn, useLoginToken } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useStoreCookiesOnLogin = (userId: string) => {
|
||||
const isLoggingIn = useIsLoggingIn();
|
||||
const loginToken = useLoginToken();
|
||||
|
||||
useEffect(() => {
|
||||
// Check for isLoggingIn to be reactive and ensure it will process only after login finishes
|
||||
// preventing race condition setting the rc_token as null forever
|
||||
if (isLoggingIn === false) {
|
||||
const secure = location.protocol === 'https:' ? '; secure' : '';
|
||||
|
||||
document.cookie = `rc_uid=${encodeURI(userId)}; path=/${secure}`;
|
||||
document.cookie = `rc_token=${encodeURI(loginToken ?? '')}; path=/${secure}`;
|
||||
}
|
||||
}, [isLoggingIn, loginToken, userId]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRequire2faSetup } from '../../../hooks/useRequire2faSetup';
|
||||
import TwoFactorRequiredModal from '../../MainLayout/TwoFactorRequiredModal';
|
||||
|
||||
export const useTwoFactorAuthSetupCheck = () => {
|
||||
const setModal = useSetModal();
|
||||
const require2faSetup = useRequire2faSetup();
|
||||
|
||||
useEffect(() => {
|
||||
if (require2faSetup) {
|
||||
setModal(<TwoFactorRequiredModal />);
|
||||
}
|
||||
}, [setModal, require2faSetup]);
|
||||
};
|
||||
69
apps/meteor/client/views/root/hooks/loggedIn/useUnread.ts
Normal file
69
apps/meteor/client/views/root/hooks/loggedIn/useUnread.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { manageFavicon } from '@rocket.chat/favicon';
|
||||
import { useSession, useSessionDispatch, useUserPreference, useUserSubscriptions } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useFireGlobalEvent } from '../../../../hooks/useFireGlobalEvent';
|
||||
|
||||
const query = { open: { $ne: false }, hideUnreadStatus: { $ne: true }, archived: { $ne: true } };
|
||||
const options = { fields: { unread: 1, alert: 1, rid: 1, t: 1, name: 1, ls: 1, unreadAlert: 1, fname: 1, prid: 1 } };
|
||||
const updateFavicon = manageFavicon();
|
||||
|
||||
type UnreadData = { unread: number; alert: boolean | undefined; unreadAlert: string | undefined };
|
||||
|
||||
export const useUnread = () => {
|
||||
const unreadAlertEnabled = useUserPreference('unreadAlert');
|
||||
const setUnread = useSessionDispatch('unread');
|
||||
const unread = useSession('unread') as string | number | null | undefined;
|
||||
|
||||
const { mutate: fireEventUnreadChangedBySubscription } = useFireGlobalEvent('unread-changed-by-subscription');
|
||||
const { mutate: fireEventUnreadChanged } = useFireGlobalEvent('unread-changed');
|
||||
|
||||
const subscriptions = useUserSubscriptions(query, options);
|
||||
|
||||
// We keep a lightweight snapshot of the last emitted per-subscription unread state so we only
|
||||
// fire "unread-changed-by-subscription" for subscriptions whose unread-relevant fields changed.
|
||||
// Previously we emitted one global event per subscription on ANY change, which scaled O(N)
|
||||
// with the user subscription count (thousands) for every single message event, dominating CPU.
|
||||
const prevSubsRef = useRef(new Map<string, UnreadData>());
|
||||
|
||||
useEffect(() => {
|
||||
let badgeIndicator: false | '•' = false;
|
||||
let unreadCount = 0;
|
||||
const nextSnapshot = new Map<string, UnreadData>();
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const { rid, unread: unreadValue, alert, unreadAlert: subscriptionUnreadAlert } = subscription;
|
||||
const prev = prevSubsRef.current.get(rid);
|
||||
// Emit per-sub event only if something that influences unread UI changed.
|
||||
if (!prev || prev.unread !== unreadValue || prev.alert !== alert || prev.unreadAlert !== subscriptionUnreadAlert) {
|
||||
fireEventUnreadChangedBySubscription(subscription);
|
||||
}
|
||||
nextSnapshot.set(rid, { unread: unreadValue, alert, unreadAlert: subscriptionUnreadAlert });
|
||||
|
||||
if (alert || unreadValue > 0) {
|
||||
if (alert === true && subscriptionUnreadAlert !== 'nothing') {
|
||||
if (subscriptionUnreadAlert === 'all' || unreadAlertEnabled !== false) {
|
||||
badgeIndicator = '•';
|
||||
}
|
||||
}
|
||||
unreadCount += unreadValue;
|
||||
}
|
||||
}
|
||||
|
||||
prevSubsRef.current = nextSnapshot; // swap snapshot
|
||||
|
||||
if (unreadCount > 0) {
|
||||
setUnread(unreadCount > 999 ? '999+' : unreadCount);
|
||||
} else if (badgeIndicator !== false) {
|
||||
setUnread(badgeIndicator);
|
||||
} else {
|
||||
setUnread('');
|
||||
}
|
||||
|
||||
fireEventUnreadChanged(unreadCount);
|
||||
}, [setUnread, subscriptions, unreadAlertEnabled, fireEventUnreadChangedBySubscription, fireEventUnreadChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFavicon(unread);
|
||||
}, [unread]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEmbeddedLayout } from '@rocket.chat/ui-client';
|
||||
import { useConnectionStatus, useIsLoggingIn } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { VideoConfManager } from '../../../../lib/VideoConfManager';
|
||||
|
||||
export const useUpdateVideoConfUser = (userId: string) => {
|
||||
const { connected } = useConnectionStatus();
|
||||
const isLoggingIn = useIsLoggingIn();
|
||||
const embeddedLayout = useEmbeddedLayout();
|
||||
|
||||
useEffect(() => {
|
||||
// Videconf should not be available in embedded layout
|
||||
VideoConfManager.updateUser(embeddedLayout ? null : userId, isLoggingIn, connected);
|
||||
}, [userId, isLoggingIn, connected, embeddedLayout]);
|
||||
};
|
||||
Reference in New Issue
Block a user