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

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

View File

@@ -0,0 +1,5 @@
import { LegacyRoomManager } from '../../../lib/LegacyRoomManager';
export const closeAllRooms = () => {
LegacyRoomManager.closeAllRooms();
};

View File

@@ -0,0 +1,9 @@
import { config } from '../../../../../app/apple/lib/config';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
/* const Apple =*/ CustomOAuth.configureOAuthService('apple', config);
export const useAppleOAuth = () => {
// Here we would expect to handle changes in settings, updating the configuration
// accordingly, but it was not implemented yet.
};

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
import { loginServices } from '../../../../lib/loginServices';
export const useCustomOAuth = () => {
useEffect(
() =>
loginServices.onLoad((services) => {
for (const service of services) {
if (!('custom' in service && service.custom)) {
continue;
}
CustomOAuth.configureCustomOAuthService(service.service, {
serverURL: service.serverURL,
authorizePath: service.authorizePath,
scope: service.scope,
});
}
}),
[],
);
};

View File

@@ -0,0 +1,34 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
const config = {
serverURL: '',
authorizePath: '/m/oauth2/auth/',
tokenPath: '/m/oauth2/token/',
identityPath: '/m/oauth2/api/me/',
scope: 'basic',
addAutopublishFields: {
forLoggedInUser: ['services.dolphin'],
forOtherUsers: ['services.dolphin.name'],
},
accessTokenParam: 'access_token',
} as const satisfies OauthConfig;
const Dolphin = CustomOAuth.configureOAuthService('dolphin', config);
export const useDolphinOAuth = () => {
const enabled = useSetting('Accounts_OAuth_Dolphin');
const url = useSetting('Accounts_OAuth_Dolphin_URL') as string;
useEffect(() => {
if (enabled) {
Dolphin.configure({
...config,
serverURL: url,
});
}
}, [enabled, url]);
};

View File

@@ -0,0 +1,39 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
// Drupal Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/drupal
// In RocketChat -> Administration the URL needs to be http(s)://{drupal.server}/
const config = {
serverURL: '',
identityPath: '/oauth2/UserInfo',
authorizePath: '/oauth2/authorize',
tokenPath: '/oauth2/token',
scope: 'openid email profile offline_access',
tokenSentVia: 'payload',
usernameField: 'preferred_username',
mergeUsers: true,
addAutopublishFields: {
forLoggedInUser: ['services.drupal'],
forOtherUsers: ['services.drupal.name'],
},
accessTokenParam: 'access_token',
} as const satisfies OauthConfig;
const Drupal = CustomOAuth.configureOAuthService('drupal', config);
export const useDrupalOAuth = () => {
const drupalUrl = useSetting('API_Drupal_URL') as string;
useEffect(() => {
if (drupalUrl) {
Drupal.configure({
...config,
serverURL: drupalUrl,
});
}
}, [drupalUrl]);
};

View File

@@ -0,0 +1,34 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
// GitHub Enterprise Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/github_enterprise
// In RocketChat -> Administration the URL needs to be http(s)://{github.enterprise.server}/
const config = {
serverURL: '',
identityPath: '/api/v3/user',
authorizePath: '/login/oauth/authorize',
tokenPath: '/login/oauth/access_token',
addAutopublishFields: {
forLoggedInUser: ['services.github-enterprise'],
forOtherUsers: ['services.github-enterprise.username'],
},
} as const satisfies OauthConfig;
const GitHubEnterprise = CustomOAuth.configureOAuthService('github_enterprise', config);
export const useGitHubEnterpriseOAuth = () => {
const githubApiUrl = useSetting('API_GitHub_Enterprise_URL') as string;
useEffect(() => {
if (githubApiUrl) {
GitHubEnterprise.configure({
...config,
serverURL: githubApiUrl,
});
}
}, [githubApiUrl]);
};

View File

@@ -0,0 +1,34 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
const config = {
serverURL: 'https://gitlab.com',
identityPath: '/api/v4/user',
scope: 'read_user',
mergeUsers: false,
addAutopublishFields: {
forLoggedInUser: ['services.gitlab'],
forOtherUsers: ['services.gitlab.username'],
},
accessTokenParam: 'access_token',
} as const satisfies OauthConfig;
const Gitlab = CustomOAuth.configureOAuthService('gitlab', config);
export const useGitLabOAuth = () => {
const gitlabApiUrl = useSetting('API_Gitlab_URL') as string;
const gitlabIdentiry = useSetting('Accounts_OAuth_Gitlab_identity_path') as string;
const gitlabMergeUsers = useSetting('Accounts_OAuth_Gitlab_merge_users', false);
useEffect(() => {
Gitlab.configure({
...config,
...(gitlabApiUrl && { serverURL: gitlabApiUrl.trim().replace(/\/*$/, '') }),
...(gitlabIdentiry && { identityPath: gitlabIdentiry.trim() || config.identityPath }),
...(gitlabMergeUsers && { mergeUsers: true }),
});
}, [gitlabApiUrl, gitlabIdentiry, gitlabMergeUsers]);
};

View File

@@ -0,0 +1,33 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
const config: OauthConfig = {
authorizePath: 'https://github.com/login/oauth/authorize',
serverURL: 'https://github.com',
identityPath: 'https://api.github.com/user',
tokenPath: 'https://github.com/login/oauth/access_token',
scope: 'user:email',
mergeUsers: false,
addAutopublishFields: {
forLoggedInUser: ['services.github'],
forOtherUsers: ['services.github.username'],
},
accessTokenParam: 'access_token',
} as const satisfies OauthConfig;
const Github = CustomOAuth.configureOAuthService('github', config);
export const useGithubOAuth = () => {
const enabled = useSetting('Accounts_OAuth_Github');
useEffect(() => {
if (enabled) {
Github.configure({
...config,
});
}
}, [enabled]);
};

View File

@@ -0,0 +1,33 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
const config = {
serverURL: '',
tokenPath: '/index.php/apps/oauth2/api/v1/token',
tokenSentVia: 'header',
authorizePath: '/index.php/apps/oauth2/authorize',
identityPath: '/ocs/v2.php/cloud/user?format=json',
scope: 'openid',
addAutopublishFields: {
forLoggedInUser: ['services.nextcloud'],
forOtherUsers: ['services.nextcloud.name'],
},
} as const satisfies OauthConfig;
const Nextcloud = CustomOAuth.configureOAuthService('nextcloud', config);
export const useNextcloudOAuth = (): void => {
const nextcloudURL = useSetting('Accounts_OAuth_Nextcloud_URL') as string;
useEffect(() => {
if (nextcloudURL) {
Nextcloud.configure({
...config,
serverURL: nextcloudURL.trim().replace(/\/*$/, ''),
});
}
}, [nextcloudURL]);
};

View File

@@ -0,0 +1,72 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
const configDefault = {
serverURL: '',
addAutopublishFields: {
forLoggedInUser: ['services.wordpress'],
forOtherUsers: ['services.wordpress.user_login'],
},
accessTokenParam: 'access_token',
} as const satisfies OauthConfig;
const WordPress = CustomOAuth.configureOAuthService('wordpress', configDefault);
const configureServerType = (
serverType: string,
identityPath?: string,
identityTokenSentVia?: string,
tokenPath?: string,
authorizePath?: string,
scope?: string,
): OauthConfig => {
switch (serverType) {
case 'custom': {
return {
...configDefault,
...(identityPath && { identityPath }),
...(identityTokenSentVia && { identityTokenSentVia }),
...(tokenPath && { tokenPath }),
...(authorizePath && { authorizePath }),
...(scope && { scope }),
};
}
case 'wordpress-com':
return {
...configDefault,
identityPath: 'https://public-api.wordpress.com/rest/v1/me',
identityTokenSentVia: 'header',
authorizePath: 'https://public-api.wordpress.com/oauth2/authorize',
tokenPath: 'https://public-api.wordpress.com/oauth2/token',
scope: 'auth',
};
default:
return {
...configDefault,
identityPath: '/oauth/me',
};
}
};
export const useWordPressOAuth = (): void => {
const wordpressURL = useSetting('API_Wordpress_URL') as string;
const serverType = useSetting('Accounts_OAuth_Wordpress_server_type') as string;
const identityPath = useSetting('Accounts_OAuth_Wordpress_identity_path') as string;
const identityTokenSentVia = useSetting('Accounts_OAuth_Wordpress_identity_token_sent_via') as string;
const tokenPath = useSetting('Accounts_OAuth_Wordpress_token_path') as string;
const authorizePath = useSetting('Accounts_OAuth_Wordpress_authorize_path') as string;
const scope = useSetting('Accounts_OAuth_Wordpress_scope') as string;
useEffect(() => {
WordPress.configure({
...configDefault,
...configureServerType(serverType, identityPath, identityTokenSentVia, tokenPath, authorizePath, scope),
serverURL: wordpressURL,
});
}, [authorizePath, identityPath, identityTokenSentVia, scope, serverType, tokenPath, wordpressURL]);
};

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,5 @@
export const purgeAllDrafts = (): void => {
Object.keys(localStorage)
.filter((key) => key.indexOf('messagebox_') === 0)
.forEach((key) => localStorage.removeItem(key));
};

View File

@@ -0,0 +1,140 @@
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
declare global {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Window {
_paq?: [string, ...unknown[]][];
GoogleAnalyticsObject: unknown;
ga?: qa;
}
type qa = {
(...args: unknown[]): void;
l?: number;
q?: unknown[];
};
}
export const useAnalytics = (): void => {
const uid = useUserId();
const googleAnalyticsEnabled = useSetting('GoogleAnalytics_enabled', false);
const googleId = useSetting('GoogleAnalytics_ID', '');
const piwiEnabled = useSetting('PiwikAnalytics_enabled', false);
const piwikUrl = useSetting('PiwikAnalytics_url', '');
const piwikSiteId = useSetting('PiwikAnalytics_siteId', '');
const piwikPrependDomain = useSetting('PiwikAnalytics_prependDomain', '');
const piwikCookieDomain = useSetting('PiwikAnalytics_cookieDomain', '');
const piwikDomains = useSetting('PiwikAnalytics_domains', '');
const piwikAdditionalTracker = useSetting('PiwikAdditionalTrackers', '');
useEffect(() => {
if (uid) {
window._paq = window._paq || [];
window._paq.push(['setUserId', uid]);
}
}, [uid]);
useEffect(() => {
if (!googleAnalyticsEnabled || !googleId) {
return;
}
if (googleId.startsWith('G-')) {
// Google Analytics 4
const f = document.getElementsByTagName('script')[0];
const j = document.createElement('script');
j.async = true;
j.src = `//www.googletagmanager.com/gtag/js?id=${googleId}`;
f.parentNode?.insertBefore(j, f);
// injecting the dataLayer into the windows global object
const w: Window & { dataLayer?: any } = window;
const dataLayer = w.dataLayer || [];
const gtag = (key: string, value: any) => {
dataLayer.push(key, value);
};
gtag('js', new Date());
gtag('config', googleId);
} else {
// Google Analytics 3
(function (i, s, o, g, r: 'ga', a?: any, m?: any) {
i.GoogleAnalyticsObject = r;
((i[r] =
i[r] ||
function (...args) {
((i[r] as any).q = (i[r] as any).q || []).push(args);
}),
((i[r] as any).l = new Date().getTime()));
((a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]));
a.async = 1;
a.src = g;
m.parentNode.insertBefore(a, m);
})(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga');
window.ga?.('create', googleId, 'auto');
window.ga?.('send', 'pageview');
}
}, [googleAnalyticsEnabled, googleId, uid]);
useEffect(() => {
if (!piwiEnabled || !piwikUrl) {
document.getElementById('piwik-analytics')?.remove();
window._paq = [];
return;
}
window._paq = window._paq || [];
window._paq.push(['trackPageView']);
window._paq.push(['enableLinkTracking']);
if (piwikPrependDomain) {
window._paq.push(['setDocumentTitle', `${window.location.hostname}/${document.title}`]);
}
const upperLevelDomain = `*.${window.location.hostname.split('.').slice(1).join('.')}`;
if (piwikCookieDomain) {
window._paq.push(['setCookieDomain', upperLevelDomain]);
}
if (piwikDomains) {
// array
const domainsArray = piwikDomains.split(/\n/);
const domains = [];
for (let i = 0; i < domainsArray.length; i++) {
// only push domain if it contains a non whitespace character.
if (/\S/.test(domainsArray[i])) {
domains.push(`*.${domainsArray[i].trim()}`);
}
}
window._paq.push(['setDomains', domains]);
}
(() => {
try {
if (/\S/.test(piwikAdditionalTracker)) {
// piwikAdditionalTracker is not empty or whitespace only
const addTrackers = JSON.parse(piwikAdditionalTracker);
for (let i = 0; i < addTrackers.length; i++) {
const tracker = addTrackers[i];
window._paq.push(['addTracker', `${tracker.trackerURL}js/`, tracker.siteId]);
}
}
} catch (e) {
// parsing JSON faild
console.log('Error while parsing JSON value of "piwikAdditionalTracker": ', e);
}
window._paq.push(['setTrackerUrl', `${piwikUrl}js/`]);
window._paq.push(['setSiteId', Number.parseInt(piwikSiteId)]);
const d = document;
const g = d.createElement('script');
g.setAttribute('id', 'piwik-analytics');
const s = d.getElementsByTagName('script')[0];
g.type = 'text/javascript';
g.async = true;
g.defer = true;
g.src = `${piwikUrl}js/`;
s.parentNode?.insertBefore(g, s);
})();
}, [piwiEnabled, piwikAdditionalTracker, piwikCookieDomain, piwikDomains, piwikPrependDomain, piwikSiteId, piwikUrl]);
};

View File

@@ -0,0 +1,257 @@
import { clientCallbacks } from '@rocket.chat/ui-client';
import { useRouter, useSetting, useUserId } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
function trackEvent(category: string, action: string, label?: unknown) {
const { _paq, ga } = window;
_paq?.push(['trackEvent', category, action, label]);
ga?.('send', 'event', category, action, label);
}
export const useAnalyticsEventTracking = () => {
const router = useRouter();
useEffect(
() =>
router.subscribeToRouteChange(() => {
const { _paq, ga } = window;
if (_paq) {
const http = location.protocol;
const slashes = http.concat('//');
const host = slashes.concat(location.hostname);
_paq.push(['setCustomUrl', host + router.getLocationPathname()]);
_paq.push(['trackPageView']);
}
ga?.('send', 'pageview', router.getLocationPathname());
}),
[router],
);
useEffect(() => {
clientCallbacks.add(
'loginPageStateChange',
(state) => {
trackEvent('Navigation', 'Login Page State Change', state);
},
clientCallbacks.priority.MEDIUM,
'analytics-login-state-change',
);
return () => {
clientCallbacks.remove('loginPageStateChange', 'analytics-login-state-change');
};
}, []);
const featuresMessages = useSetting('Analytics_features_messages', true);
useEffect(() => {
if (!featuresMessages) {
return;
}
clientCallbacks.add(
'afterSaveMessage',
(_message, { room }) => {
trackEvent('Message', 'Send', `${room.name} (${room._id})`);
},
clientCallbacks.priority.LOW,
'trackEvents',
);
return () => {
clientCallbacks.remove('afterSaveMessage', 'trackEvents');
};
}, [featuresMessages]);
const featuresRooms = useSetting('Analytics_features_rooms', true);
useEffect(() => {
if (!featuresRooms) {
return;
}
clientCallbacks.add(
'afterCreateChannel',
(_owner, room) => {
trackEvent('Room', 'Create', `${room.name} (${room._id})`);
},
clientCallbacks.priority.MEDIUM,
'analytics-after-create-channel',
);
clientCallbacks.add(
'roomNameChanged',
(room) => {
trackEvent('Room', 'Changed Name', `${room.name} (${room._id})`);
},
clientCallbacks.priority.MEDIUM,
'analytics-room-name-changed',
);
clientCallbacks.add(
'roomTopicChanged',
(room) => {
trackEvent('Room', 'Changed Topic', `${room.name} (${room._id})`);
},
clientCallbacks.priority.MEDIUM,
'analytics-room-topic-changed',
);
clientCallbacks.add(
'roomAnnouncementChanged',
(room) => {
trackEvent('Room', 'Changed Announcement', `${room.name} (${room._id})`);
},
clientCallbacks.priority.MEDIUM,
'analytics-room-announcement-changed',
);
clientCallbacks.add(
'roomTypeChanged',
(room) => {
trackEvent('Room', 'Changed Room Type', `${room.name} (${room._id})`);
},
clientCallbacks.priority.MEDIUM,
'analytics-room-type-changed',
);
clientCallbacks.add(
'archiveRoom',
(room) => {
trackEvent('Room', 'Archived', `${room.name} (${room._id})`);
},
clientCallbacks.priority.MEDIUM,
'analytics-archive-room',
);
clientCallbacks.add(
'unarchiveRoom',
(room) => {
trackEvent('Room', 'Unarchived', `${room.name} (${room._id})`);
},
clientCallbacks.priority.MEDIUM,
'analytics-unarchive-room',
);
clientCallbacks.add(
'roomAvatarChanged',
(room) => {
trackEvent('Room', 'Changed Avatar', `${room.name} (${room._id})`);
},
clientCallbacks.priority.MEDIUM,
'analytics-room-avatar-changed',
);
return () => {
clientCallbacks.remove('afterCreateChannel', 'analytics-after-create-channel');
clientCallbacks.remove('roomNameChanged', 'analytics-room-name-changed');
clientCallbacks.remove('roomTopicChanged', 'analytics-room-topic-changed');
clientCallbacks.remove('roomAnnouncementChanged', 'analytics-room-announcement-changed');
clientCallbacks.remove('roomTypeChanged', 'analytics-room-type-changed');
clientCallbacks.remove('archiveRoom', 'analytics-archive-room');
clientCallbacks.remove('unarchiveRoom', 'analytics-unarchive-room');
clientCallbacks.remove('roomAvatarChanged', 'analytics-room-avatar-changed');
};
}, [featuresRooms]);
const featuresUsers = useSetting('Analytics_features_users', true);
useEffect(() => {
if (!featuresUsers) {
return;
}
clientCallbacks.add(
'userRegistered',
() => {
trackEvent('User', 'Registered');
},
clientCallbacks.priority.MEDIUM,
'piwik-user-resitered',
);
clientCallbacks.add(
'usernameSet',
() => {
trackEvent('User', 'Username Set');
},
clientCallbacks.priority.MEDIUM,
'piweik-username-set',
);
clientCallbacks.add(
'userPasswordReset',
() => {
trackEvent('User', 'Reset Password');
},
clientCallbacks.priority.MEDIUM,
'piwik-user-password-reset',
);
clientCallbacks.add(
'userConfirmationEmailRequested',
() => {
trackEvent('User', 'Confirmation Email Requested');
},
clientCallbacks.priority.MEDIUM,
'piwik-user-confirmation-email-requested',
);
clientCallbacks.add(
'userForgotPasswordEmailRequested',
() => {
trackEvent('User', 'Forgot Password Email Requested');
},
clientCallbacks.priority.MEDIUM,
'piwik-user-forgot-password-email-requested',
);
clientCallbacks.add(
'userStatusManuallySet',
(status) => {
trackEvent('User', 'Status Manually Changed', status);
},
clientCallbacks.priority.MEDIUM,
'analytics-user-status-manually-set',
);
clientCallbacks.add(
'userAvatarSet',
(service) => {
trackEvent('User', 'Avatar Changed', service);
},
clientCallbacks.priority.MEDIUM,
'analytics-user-avatar-set',
);
return () => {
clientCallbacks.remove('userRegistered', 'piwik-user-resitered');
clientCallbacks.remove('usernameSet', 'piweik-username-set');
clientCallbacks.remove('userPasswordReset', 'piwik-user-password-reset');
clientCallbacks.remove('userConfirmationEmailRequested', 'piwik-user-confirmation-email-requested');
clientCallbacks.remove('userForgotPasswordEmailRequested', 'piwik-user-forgot-password-email-requested');
clientCallbacks.remove('userStatusManuallySet', 'analytics-user-status-manually-set');
clientCallbacks.remove('userAvatarSet', 'analytics-user-avatar-set');
};
}, [featuresUsers]);
const uid = useUserId();
useEffect(() => {
if (!featuresUsers) {
return;
}
const { _paq } = window;
trackEvent('User', 'Login', uid);
_paq?.push(['setUserId', uid]);
return () => {
trackEvent('User', 'Logout', uid);
};
}, [featuresUsers, uid]);
};

View File

@@ -0,0 +1,54 @@
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react';
import { useAutoupdate } from './useAutoupdate';
jest.mock('@rocket.chat/ui-contexts', () => ({
useToastMessageDispatch: jest.fn(() => jest.fn()),
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('useAutoupdate', () => {
it('should add event listener to document on mount', () => {
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
renderHook(() => useAutoupdate());
expect(addEventListenerSpy).toHaveBeenCalled();
expect(addEventListenerSpy).toHaveBeenCalledWith('client_changed', expect.any(Function));
});
it('should remove event listener on unmount', () => {
const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
const { unmount } = renderHook(() => useAutoupdate());
unmount();
expect(removeEventListenerSpy).toHaveBeenCalled();
expect(removeEventListenerSpy).toHaveBeenCalledWith('client_changed', expect.any(Function));
});
it('should call toast function when client_changed event is fired', () => {
const toastMock = jest.fn();
(useToastMessageDispatch as jest.Mock).mockImplementation(() => toastMock);
renderHook(() => useAutoupdate());
const event = new Event('client_changed');
document.dispatchEvent(event);
expect(toastMock).toHaveBeenCalledTimes(1);
expect(toastMock).toHaveBeenCalledWith({
type: 'info',
message: expect.anything(),
options: { isPersistent: true },
});
});
it('should not call toast function when client_changed event is not fired', () => {
const toastMock = jest.fn();
(useToastMessageDispatch as jest.Mock).mockImplementation(() => toastMock);
renderHook(() => useAutoupdate());
expect(toastMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,32 @@
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { AutoupdateToastMessage } from '../../../components/AutoupdateToastMessage';
export const useAutoupdate = () => {
const toast = useToastMessageDispatch();
const { t } = useTranslation();
const isDevMode = process.env.NODE_ENV === 'development';
useEffect(() => {
const fn = () => {
// To test this feature locally, comment the if statement below
if (isDevMode) {
window.location.reload();
return;
}
toast({
type: 'info',
options: { isPersistent: true },
message: <AutoupdateToastMessage />,
});
};
document.addEventListener('client_changed', fn);
return () => {
document.removeEventListener('client_changed', fn);
};
}, [isDevMode, t, toast]);
};

View File

@@ -0,0 +1,26 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { Subscriptions } from '../../../stores';
export const useClearUnreadAllMessagesMutation = (options?: Omit<UseMutationOptions<void, unknown, void, unknown>, 'mutationFn'>) => {
const readSubscription = useEndpoint('POST', '/v1/subscriptions.read');
return useMutation({
mutationFn: async () => {
const promises = Subscriptions.state
.filter((record) => record.open)
.map((subscription) => {
if (subscription.alert || subscription.unread > 0) {
return readSubscription({ rid: subscription.rid, readThreads: true });
}
return Promise.resolve();
});
await Promise.all(promises);
},
...options,
});
};

View File

@@ -0,0 +1,19 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { register } from '../../../../app/markdown/lib/hljs';
export const useCodeHighlight = (): void => {
const codeHighlight = useSetting('Message_Code_highlight');
useEffect(() => {
if (typeof codeHighlight === 'string') {
codeHighlight.split(',').forEach((language: string) => {
const trimmedLanguage = language.trim();
if (trimmedLanguage) {
register(trimmedLanguage);
}
});
}
}, [codeHighlight]);
};

View File

@@ -0,0 +1,15 @@
// TODO: remove this hook together with the Meteor webapp/DDP layer — it only
// patches Meteor.absoluteUrl's `secure` default, which has no consumers once
// Meteor.absoluteUrl is gone.
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { absoluteUrl } from '../../../lib/absoluteUrl';
export const useCorsSSLConfig = () => {
const forceSSlSetting = useSetting('Force_SSL');
useEffect(() => {
absoluteUrl.defaultOptions.secure = Boolean(forceSSlSetting);
}, [forceSSlSetting]);
};

View File

@@ -0,0 +1,19 @@
import { useAbsoluteUrl, useAssetPath } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
export const useDesktopFavicon = () => {
const absoluteUrl = useAbsoluteUrl();
useEffect(() => {
if (typeof window === 'undefined') return;
window.RocketChatDesktop?.setUrlResolver((relativePath?: string) => absoluteUrl(relativePath ?? '/'));
}, [absoluteUrl]);
const faviconUrl = useAssetPath('favicon');
useEffect(() => {
if (typeof window === 'undefined') return;
if (!faviconUrl) return;
window.RocketChatDesktop?.setFavicon(faviconUrl);
}, [faviconUrl]);
};

View File

@@ -0,0 +1,12 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
export const useDesktopTitle = () => {
const title = useSetting('Site_Name', 'Rocket.Chat');
useEffect(() => {
if (typeof window === 'undefined') return;
if (!title) return;
window.RocketChatDesktop?.setTitle(title);
}, [title]);
};

View File

@@ -0,0 +1,23 @@
import { useUser } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
/**
* Pushes the logged-in user's roles to the desktop app so it can decide which
* supportedVersions messages to show (e.g. restricting version-expiration
* warnings to admins). The desktop app falls back to its own role lookup when
* this bridge is unavailable, so `setUserRoles` is called optionally.
*/
export const useDesktopUserRoles = () => {
// A single canonical value derived from the reactive user. Logout, login and
// account switch all surface as a change to this key, so the effect re-runs
// and re-pushes only when the roles actually change — no userId branch and no
// effect cleanup (which would fire on every deps change and flicker
// role-targeted UI). Logged-out and "logged in with no roles" both resolve to
// an empty list, which is the correct signal for the desktop either way.
const rolesKey = (useUser()?.roles ?? []).join(',');
useEffect(() => {
if (typeof window === 'undefined') return;
window.RocketChatDesktop?.setUserRoles?.(rolesKey ? rolesKey.split(',') : []);
}, [rolesKey]);
};

View File

@@ -0,0 +1,50 @@
import { GenericModal, imperativeModal } from '@rocket.chat/ui-client';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useClearUnreadAllMessagesMutation } from './useClearUnreadAllMessagesMutation';
export const useEscapeKeyStroke = () => {
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const clearUnreadAllMessagesMutation = useClearUnreadAllMessagesMutation({
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onMutate: () => {
imperativeModal.close();
},
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== 'Escape' || !(event.shiftKey === true || event.ctrlKey === true)) {
return;
}
event.preventDefault();
event.stopPropagation();
imperativeModal.open({
component: GenericModal,
props: {
children: t('Are_you_sure_you_want_to_clear_all_unread_messages'),
variant: 'warning',
title: t('Clear_all_unreads_question'),
confirmText: t('Yes_clear_all'),
onClose: imperativeModal.close,
onCancel: imperativeModal.close,
onConfirm: clearUnreadAllMessagesMutation.mutate,
},
});
};
document.body.addEventListener('keydown', handleKeyDown);
return () => {
document.body.removeEventListener('keydown', handleKeyDown);
};
}, [clearUnreadAllMessagesMutation.mutate, dispatchToastMessage, t]);
};

View File

@@ -0,0 +1,29 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
export const useGoogleTagManager = () => {
const i = useSetting('GoogleTagManager_id', '');
useEffect(() => {
if (typeof i !== 'string' || i.trim() === '') {
return;
}
const w: Window & { dataLayer?: { 'gtm.start': number; 'event': string }[] } = window;
w.dataLayer = w.dataLayer || [];
w.dataLayer.push({
'gtm.start': new Date().getTime(),
'event': 'gtm.js',
});
const f = document.getElementsByTagName('script')[0];
const j = document.createElement('script');
j.async = true;
j.src = `//www.googletagmanager.com/gtm.js?id=${i}`;
f.parentNode?.insertBefore(j, f);
return () => {
f.parentNode?.removeChild(j);
};
}, [i]);
};

View File

@@ -0,0 +1,90 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useLoginWithIframe, useLoginWithToken, useSetting } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect, useState } from 'react';
type CallbackError = Error & { error?: string | number; reason?: string; details?: unknown };
export const useIframe = () => {
const [iframeLoginUrl, setIframeLoginUrl] = useState<string | undefined>(undefined);
const iframeEnabled = useSetting('Accounts_iframe_enabled', false);
const accountIframeUrl = useSetting('Accounts_iframe_url', '');
const apiUrl = useSetting('Accounts_Iframe_api_url', '');
const apiMethod = useSetting('Accounts_Iframe_api_method', '');
const iframeLogin = useLoginWithIframe();
const tokenLogin = useLoginWithToken();
const enabled = Boolean(iframeEnabled && accountIframeUrl && apiUrl && apiMethod);
const loginWithToken = useCallback(
(tokenData: string | { loginToken: string } | { token: string }, callback?: (error: Error | null | undefined) => void) => {
if (typeof tokenData === 'string') {
tokenData = {
token: tokenData,
};
}
if ('loginToken' in tokenData) {
tokenLogin(tokenData.loginToken, callback);
}
if ('token' in tokenData) {
iframeLogin(tokenData.token, callback);
}
},
[iframeLogin, tokenLogin],
);
const tryLogin = useStableCallback(async (callback?: (error: Error | null | undefined, result: unknown) => void) => {
if (!enabled) {
return;
}
let url = accountIframeUrl;
let separator = '?';
if (url.indexOf('?') > -1) {
separator = '&';
}
if (navigator.userAgent.indexOf('Electron') > -1) {
url += `${separator}client=electron`;
}
try {
const result = await fetch(apiUrl, {
method: apiMethod,
headers: undefined,
credentials: 'include',
});
if (!result.ok || result.status !== 200) {
setIframeLoginUrl(url);
callback?.(new Error(), null);
return;
}
const body = await result.json();
loginWithToken(body, async (error: CallbackError | Error | null | undefined) => {
if (error) {
setIframeLoginUrl(url);
} else {
setIframeLoginUrl(undefined);
}
callback?.(error, body);
});
} catch (error) {
setIframeLoginUrl(url);
callback?.(error instanceof Error ? error : undefined, null);
}
});
useEffect(() => {
tryLogin();
}, [tryLogin]);
return {
enabled,
tryLogin,
loginWithToken,
iframeLoginUrl,
};
};

View File

@@ -0,0 +1,137 @@
import type { UserStatus } from '@rocket.chat/core-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { type LocationPathname, UserContext, useLoginWithCustomOauth, useLoginWithToken, useSetting } from '@rocket.chat/ui-contexts';
import { useContext, useEffect } from 'react';
import { capitalize, ltrim, rtrim } from '../../../../lib/utils/stringUtils';
import { AccountBox } from '../../../lib/AccountBox';
import { baseURI } from '../../../lib/baseURI';
import { loginServices } from '../../../lib/loginServices';
import { getRootUrlPathPrefix } from '../../../lib/meteorRuntimeConfig';
import { settings } from '../../../lib/settings';
import { router } from '../../../providers/RouterProvider';
export const useIframeCommands = () => {
const iframeReceiveEnabled = useSetting('Iframe_Integration_receive_enable');
const iframeReceiveOrigin = useSetting('Iframe_Integration_receive_origin', '*');
const loginWithToken = useLoginWithToken();
const loginWithCustomOauth = useLoginWithCustomOauth();
const { logout } = useContext(UserContext);
const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true);
useEffect(() => {
if (!iframeReceiveEnabled) {
return;
}
const commands = {
'go'(data: { path: string }) {
if (typeof data.path !== 'string' || data.path.trim().length === 0) {
return console.error('`path` not defined');
}
const newUrl = new URL(`${rtrim(baseURI, '/')}/${ltrim(data.path, '/')}`);
const newParams = Array.from(newUrl.searchParams.entries()).reduce(
(ret, [key, value]) => {
ret[key] = value;
return ret;
},
{} as Record<string, string>,
);
const newPath = newUrl.pathname.replace(new RegExp(`^${escapeRegExp(getRootUrlPathPrefix())}`), '') as LocationPathname;
router.navigate({
pathname: newPath,
search: { ...router.getSearchParameters(), ...newParams },
});
},
'set-user-status'(data: { status: UserStatus }) {
AccountBox.setStatus(data.status);
},
'call-custom-oauth-login'(data: { service: string; redirectUrl?: string | null }, event: MessageEvent) {
if (enableModernOAuthFlow) {
const url = new URL(window.location.href);
const queryParams = url.searchParams;
const loginClient = queryParams.get('loginClient');
const redirectUrl = new URL(`/oauth/${data.service}`, window.location.origin);
if (loginClient) {
redirectUrl.searchParams.set('loginClient', loginClient);
}
window.location.href = redirectUrl.toString();
return;
}
const customOAuthCallback = (response: unknown) => {
event.source?.postMessage(
{
event: 'custom-oauth-callback',
response,
},
{ targetOrigin: event.origin },
);
};
const siteUrl = `${settings.peek('Site_Url') ?? ''}/`;
if (typeof data.redirectUrl !== 'string' || !data.redirectUrl.startsWith(siteUrl)) {
data.redirectUrl = null;
}
if (window.ServiceConfiguration) {
const customOauth = loginServices.getLoginService(data.service);
if (customOauth) {
const customRedirectUri = data.redirectUrl || siteUrl;
loginWithCustomOauth(capitalize(customOauth.service, true), { redirectUrl: customRedirectUri }, customOAuthCallback);
}
}
},
'login-with-token'(data: { token: string }) {
if (typeof data.token === 'string') {
void loginWithToken(data.token, () => {
console.log('Iframe command [login-with-token]: result', data);
});
}
},
'logout'() {
void logout();
router.navigate('/home');
},
} as const;
type CommandMessage<TCommandName extends keyof typeof commands = keyof typeof commands> = {
externalCommand: TCommandName;
} & Parameters<(typeof commands)[TCommandName]>[0];
const messageListener = (event: MessageEvent<CommandMessage>) => {
if (typeof event.data !== 'object' || typeof event.data.externalCommand !== 'string') {
return;
}
if (iframeReceiveOrigin !== '*' && iframeReceiveOrigin.split(',').indexOf(event.origin) === -1) {
console.error('Origin not allowed', event.origin);
return;
}
if (!(event.data.externalCommand in commands)) {
console.error('Command not allowed', event.data.externalCommand);
return;
}
const command: (data: MessageEvent['data'], event: MessageEvent) => void = commands[event.data.externalCommand];
command(event.data, event);
};
window.addEventListener('message', messageListener);
return () => {
window.removeEventListener('message', messageListener);
};
}, [iframeReceiveEnabled, iframeReceiveOrigin, loginWithToken, loginWithCustomOauth, logout, enableModernOAuthFlow]);
};

View File

@@ -0,0 +1,64 @@
import { useUnstoreLoginToken } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useIframe } from './useIframe';
export const useIframeLoginListener = () => {
const { enabled: iframeEnabled, tryLogin, loginWithToken } = useIframe();
const unstoreLoginToken = useUnstoreLoginToken();
useEffect(() => {
if (!iframeEnabled) {
return;
}
tryLogin();
}, [iframeEnabled, tryLogin]);
useEffect(() => {
if (!iframeEnabled) {
return;
}
const messageListener = (e: MessageEvent) => {
if (!(typeof e.data === 'function' || (typeof e.data === 'object' && !!e.data))) {
return;
}
switch (e.data.event) {
case 'try-iframe-login':
tryLogin((error) => {
if (error) {
e.source?.postMessage(
{
event: 'login-error',
response: error.message,
},
{ targetOrigin: e.origin },
);
}
});
break;
case 'login-with-token':
loginWithToken(e.data, (error) => {
if (error) {
e.source?.postMessage(
{
event: 'login-error',
response: error.message,
},
{ targetOrigin: e.origin },
);
}
});
break;
}
};
window.addEventListener('message', messageListener);
return () => {
window.removeEventListener('message', messageListener);
};
}, [iframeEnabled, loginWithToken, tryLogin]);
useEffect(() => unstoreLoginToken(tryLogin), [tryLogin, unstoreLoginToken]);
};

View File

@@ -0,0 +1,43 @@
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import tinykeys from 'tinykeys';
import KeyboardShortcutsModal from '../../../navbar/NavBarSettingsToolbar/UserMenu/KeyboardShortcutsModal';
const shouldIgnoreKeyStroke = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) {
return false;
}
if (target instanceof HTMLElement && target.isContentEditable) {
return true;
}
const { tagName } = target;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
return true;
}
return target.closest('dialog[open]') !== null;
};
export const useKeyboardShortcutsHotkey = () => {
const setModal = useSetModal();
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (shouldIgnoreKeyStroke(event.target)) {
return;
}
event.preventDefault();
const handleClose = () => setModal(null);
setModal(<KeyboardShortcutsModal onClose={handleClose} />);
};
return tinykeys(window, {
'Shift+?': handler,
});
}, [setModal]);
};

View File

@@ -0,0 +1,23 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import { MultipleBusinessHoursBehavior } from '../../../lib/omnichannel/businessHours/MultipleBusinessHoursBehavior';
import { SingleBusinessHourBehavior } from '../../../lib/omnichannel/businessHours/SingleBusinessHourBehavior';
import { businessHourManager } from '../../../lib/omnichannel/businessHours/businessHourManager';
const businessHours = {
multiple: new MultipleBusinessHoursBehavior(),
single: new SingleBusinessHourBehavior(),
};
export const useLivechatEnterprise = () => {
const businessHourType = useSetting<'Single' | 'Multiple'>('Livechat_business_hour_type', 'Single');
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
useEffect(() => {
if (businessHourType && hasLicense) {
businessHourManager.registerBusinessHourBehavior(businessHours[businessHourType.toLowerCase() as 'single' | 'multiple']);
}
}, [businessHourType, hasLicense]);
};

View File

@@ -0,0 +1,234 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { useConnectionStatus } from '@rocket.chat/ui-contexts';
import { renderHook, waitFor } from '@testing-library/react';
import { useLoadMissedMessages } from './useLoadMissedMessages';
import { upsertMessageBulk } from '../../../lib/RoomHistoryManager';
import { sdk } from '../../../lib/SDKClient';
import { Messages } from '../../../stores/Messages';
import { Subscriptions } from '../../../stores/Subscriptions';
jest.mock('@rocket.chat/ui-contexts', () => ({
useConnectionStatus: jest.fn(),
}));
jest.mock('../../../lib/LegacyRoomManager', () => ({
LegacyRoomManager: { openedRooms: { 'room-id': { rid: 'room-id' } } },
}));
jest.mock('../../../lib/RoomHistoryManager', () => ({
upsertMessageBulk: jest.fn(),
}));
jest.mock('../../../lib/SDKClient', () => ({
sdk: { rest: { get: jest.fn() } },
}));
const rid = 'room-id';
const createMessage = (_id: string, ts: Date, extra: Partial<IMessage & { temp: boolean }> = {}): IMessage =>
({
_id,
rid,
msg: _id,
ts,
_updatedAt: ts,
u: { _id: 'user-id', username: 'user' },
...extra,
}) as IMessage;
/** Mimics what the API returns: the same shape as `createMessage`, but with dates serialized. */
const createSerializedMessage = (_id: string, ts: Date, extra: Record<string, unknown> = {}) => ({
_id,
rid,
msg: _id,
ts: ts.toISOString(),
_updatedAt: ts.toISOString(),
u: { _id: 'user-id', username: 'user' },
...extra,
});
const mockSyncResponse = ({ updated = [], deleted = [] }: { updated?: unknown[]; deleted?: { _id: string }[] } = {}) => {
(sdk.rest.get as jest.Mock).mockResolvedValue({ result: { updated, deleted } });
};
/** Renders the hook while offline, then flips the connection back on — the only transition that triggers a sync. */
const renderReconnection = () => {
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: false });
const { rerender } = renderHook(() => useLoadMissedMessages());
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: true });
rerender();
};
beforeEach(() => {
jest.clearAllMocks();
Messages.state.replaceAll([]);
Subscriptions.state.replaceAll([]);
mockSyncResponse();
// stand-in for the real bulk upsert, which only stores the messages it is handed
(upsertMessageBulk as jest.Mock).mockImplementation(async ({ msgs }: { msgs: IMessage[] }) => Messages.state.storeMany(msgs));
});
describe('useLoadMissedMessages', () => {
it('should bound the sync window by the oldest and newest loaded messages', async () => {
const oldest = new Date(1_000);
const newest = new Date(3_000);
Messages.state.storeMany([createMessage('oldest', oldest), createMessage('newest', newest)]);
renderReconnection();
await waitFor(() =>
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
roomId: rid,
lastUpdate: newest.toISOString(),
fromTs: oldest.toISOString(),
}),
);
});
it('should leave hidden and temp messages out of the sync window', async () => {
const oldest = new Date(2_000);
const newest = new Date(3_000);
Messages.state.storeMany([
createMessage('hidden', new Date(1_000), { _hidden: true }),
createMessage('oldest', oldest),
createMessage('newest', newest),
createMessage('temp', new Date(4_000), { temp: true }),
]);
renderReconnection();
await waitFor(() =>
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
roomId: rid,
lastUpdate: newest.toISOString(),
fromTs: oldest.toISOString(),
}),
);
});
it('should leave messages from other rooms out of the sync window', async () => {
const oldest = new Date(2_000);
const newest = new Date(3_000);
Messages.state.storeMany([
createMessage('other-older', new Date(1_000), { rid: 'other-room-id' }),
createMessage('oldest', oldest),
createMessage('newest', newest),
createMessage('other-newer', new Date(4_000), { rid: 'other-room-id' }),
]);
renderReconnection();
await waitFor(() =>
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
roomId: rid,
lastUpdate: newest.toISOString(),
fromTs: oldest.toISOString(),
}),
);
});
it('should not sync a room holding only hidden and temp messages', async () => {
Messages.state.storeMany([
createMessage('hidden', new Date(1_000), { _hidden: true }),
createMessage('temp', new Date(2_000), { temp: true }),
]);
renderReconnection();
await waitFor(() => expect(sdk.rest.get).not.toHaveBeenCalled());
});
it('should not sync while the connection stays online', async () => {
Messages.state.store(createMessage('message', new Date(1_000)));
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: true });
const { rerender } = renderHook(() => useLoadMissedMessages());
rerender();
await waitFor(() => expect(sdk.rest.get).not.toHaveBeenCalled());
});
it('should merge the messages reported as updated into the store', async () => {
const ts = new Date(1_000);
const editedAt = new Date(2_000);
Messages.state.storeMany([createMessage('edited', ts), createMessage('untouched', ts)]);
mockSyncResponse({
updated: [
createSerializedMessage('edited', ts, { msg: 'edited text', editedAt: editedAt.toISOString() }),
createSerializedMessage('added', editedAt),
],
});
renderReconnection();
await waitFor(() => expect(Messages.state.get('edited')).toHaveProperty('msg', 'edited text'));
expect(Messages.state.get('edited')).toHaveProperty('editedAt', editedAt);
expect(Messages.state.get('added')).toHaveProperty('ts', editedAt);
expect(Messages.state.get('untouched')).toHaveProperty('msg', 'untouched');
});
it('should deserialize the dates of the messages reported as updated', async () => {
const ts = new Date(1_000);
const editedAt = new Date(2_000);
Messages.state.store(createMessage('edited', ts));
mockSyncResponse({ updated: [createSerializedMessage('edited', ts, { editedAt: editedAt.toISOString() })] });
renderReconnection();
await waitFor(() => expect(upsertMessageBulk).toHaveBeenCalled());
expect(upsertMessageBulk).toHaveBeenCalledWith(
expect.objectContaining({
msgs: [expect.objectContaining({ _id: 'edited', ts, _updatedAt: ts, editedAt })],
}),
);
});
it('should pass the room subscription along with the updated messages', async () => {
const ts = new Date(1_000);
const subscription = { _id: 'subscription-id', rid } as Parameters<typeof Subscriptions.state.store>[0];
Messages.state.store(createMessage('edited', ts));
Subscriptions.state.storeMany([subscription, { _id: 'other-subscription-id', rid: 'other-room-id' } as typeof subscription]);
mockSyncResponse({ updated: [createSerializedMessage('edited', ts)] });
renderReconnection();
await waitFor(() => expect(upsertMessageBulk).toHaveBeenCalledWith(expect.objectContaining({ subscription })));
});
it('should remove the messages reported as deleted', async () => {
Messages.state.storeMany([createMessage('kept', new Date(1_000)), createMessage('dropped', new Date(2_000))]);
mockSyncResponse({ deleted: [{ _id: 'dropped' }] });
renderReconnection();
await waitFor(() => expect(Messages.state.get('dropped')).toBeUndefined());
expect(Messages.state.get('kept')).toBeDefined();
});
it('should clear "tmid" from replies whose thread parent was deleted', async () => {
Messages.state.storeMany([createMessage('parent', new Date(1_000)), createMessage('reply', new Date(2_000), { tmid: 'parent' })]);
mockSyncResponse({ deleted: [{ _id: 'parent' }] });
renderReconnection();
await waitFor(() => expect(Messages.state.get('parent')).toBeUndefined());
expect(Messages.state.get('reply')).toBeDefined();
expect(Messages.state.get('reply')).not.toHaveProperty('tmid');
});
it('should keep "tmid" on replies whose thread parent survived', async () => {
Messages.state.storeMany([
createMessage('parent', new Date(1_000)),
createMessage('reply', new Date(2_000), { tmid: 'parent' }),
createMessage('dropped', new Date(3_000)),
]);
mockSyncResponse({ deleted: [{ _id: 'dropped' }] });
renderReconnection();
await waitFor(() => expect(Messages.state.get('dropped')).toBeUndefined());
expect(Messages.state.get('reply')).toHaveProperty('tmid', 'parent');
});
});

View File

@@ -0,0 +1,82 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { useConnectionStatus } from '@rocket.chat/ui-contexts';
import { useEffect, useRef } from 'react';
import { LegacyRoomManager } from '../../../lib/LegacyRoomManager';
import { upsertMessageBulk } from '../../../lib/RoomHistoryManager';
import { sdk } from '../../../lib/SDKClient';
import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi';
import { Messages } from '../../../stores/Messages';
import { Subscriptions } from '../../../stores/Subscriptions';
/**
* Reconciles a room with the server after the connection drops.
*
* `lastUpdate` is the newest loaded message's timestamp, so anything created or edited since then comes
* back. `fromTs` is the oldest loaded one, which bounds the query to the window the client actually holds
* — without it the server has no usable index bounds and scans every message in the room. The trade-off
* is that edits to messages older than `fromTs` go unreported, which is fine here: those messages are not
* loaded, so they are fetched fresh if the user scrolls back to them.
*
* @param rid - Room ID
*/
const loadMissedMessages = async (rid: IRoom['_id']): Promise<void> => {
const isLoaded = (record: IMessage & { temp?: boolean }) => record.rid === rid && record._hidden !== true && !record.temp;
const newestLoaded = Messages.state.findFirst(isLoaded, (a, b) => b.ts.getTime() - a.ts.getTime());
const oldestLoaded = Messages.state.findFirst(isLoaded, (a, b) => a.ts.getTime() - b.ts.getTime());
if (!newestLoaded || !oldestLoaded) {
return;
}
try {
const { result } = await sdk.rest.get('/v1/chat.syncMessages', {
roomId: rid,
lastUpdate: newestLoaded.ts.toISOString(),
fromTs: oldestLoaded.ts.toISOString(),
});
const subscription = Subscriptions.state.find((record) => record.rid === rid);
await upsertMessageBulk({ msgs: result.updated.map((msg) => mapMessageFromApi(msg)), subscription });
const deletedIds = new Set<IMessage['_id']>();
for (const { _id } of result.deleted) {
Messages.state.delete(_id);
deletedIds.add(_id);
}
if (deletedIds.size > 0) {
// remove thread reference from deleted messages
Messages.state.update(
(record) => record.tmid !== undefined && deletedIds.has(record.tmid),
({ tmid: _, ...record }) => record,
);
}
} catch (error) {
console.error('Error loading missed messages:', error);
}
};
/**
* React hook that loads missed messages when connection is restored
*/
export const useLoadMissedMessages = (): void => {
const { connected } = useConnectionStatus();
const connectionWasOnlineRef = useRef(connected);
useEffect(() => {
if (connected === true && connectionWasOnlineRef.current === false && LegacyRoomManager.openedRooms) {
Object.keys(LegacyRoomManager.openedRooms).forEach((key) => {
const value = LegacyRoomManager.openedRooms[key];
if (value.rid) {
loadMissedMessages(value.rid);
}
});
}
connectionWasOnlineRef.current = connected;
}, [connected]);
};

View File

@@ -0,0 +1,20 @@
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { RoomsCachedStore, SubscriptionsCachedStore } from '../../../cachedStores';
export const useLoadRoomForAllowedAnonymousRead = () => {
const userId = useUserId();
const accountsAllowAnonymousRead = useSetting('Accounts_AllowAnonymousRead');
useEffect(() => {
if (!userId && accountsAllowAnonymousRead === true) {
RoomsCachedStore.init();
SubscriptionsCachedStore.setReady(true);
return () => {
RoomsCachedStore.setReady(false);
SubscriptionsCachedStore.setReady(false);
};
}
}, [accountsAllowAnonymousRead, userId]);
};

View File

@@ -0,0 +1,30 @@
import { useRouter, useSearchParameter } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL';
export const useLoginOtherClients = () => {
const router = useRouter();
const resumeToken = useSearchParameter('resumeToken');
const loginClient = useSearchParameter('loginClient');
const userId = useSearchParameter('userId');
useEffect(() => {
if (!resumeToken || !userId) {
return;
}
if (loginClient !== 'desktop' && loginClient !== 'mobile') {
return;
}
const loginURL = buildDeepLinkURL(resumeToken, userId);
window.location.href = loginURL;
const timeout = setTimeout(() => {
router.navigate('/home', { replace: true });
}, 0);
return () => clearTimeout(timeout);
}, [resumeToken, userId, loginClient, router]);
};

View File

@@ -0,0 +1,41 @@
import { useRouter, useLoginWithToken } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
export const useLoginViaQuery = () => {
const router = useRouter();
const loginWithToken = useLoginWithToken();
useEffect(() => {
const handleLogin = async () => {
const { resumeToken } = router.getSearchParameters();
if (!resumeToken) {
return;
}
try {
await loginWithToken(resumeToken);
const routeName = router.getRouteName();
if (!routeName) {
router.navigate('/home');
}
const { resumeToken: _, userId: __, ...search } = router.getSearchParameters();
router.navigate(
{
pathname: router.getLocationPathname(),
search,
},
{ replace: true },
);
} catch (error) {
console.error('Failed to login with token', error);
}
};
handleLogin();
}, [loginWithToken, router]);
};

View File

@@ -0,0 +1,13 @@
import { useUserId } from '@rocket.chat/ui-contexts';
import { PublicSettingsCachedStore, SubscriptionsCachedStore } from '../../../cachedStores';
import { useUserDataSyncReady } from '../../../lib/userData';
export const useMainReady = () => {
const uid = useUserId();
const subscriptionsReady = SubscriptionsCachedStore.useReady();
const settingsReady = PublicSettingsCachedStore.useReady();
const userDataReady = useUserDataSyncReady();
return !uid || (userDataReady && subscriptionsReady && settingsReady);
};

View File

@@ -0,0 +1,39 @@
import { useAbsoluteUrl, useLayout } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { fireGlobalEvent } from '../../../lib/utils/fireGlobalEvent';
export const useMessageLinkClicks = () => {
const absoluteUrl = useAbsoluteUrl();
const { isEmbedded: embeddedLayout } = useLayout();
useEffect(() => {
if (!embeddedLayout) {
return;
}
const handleMessageLinkClick = (event: Event) => {
const element = event.currentTarget as Element | null;
if (!element || !(element instanceof HTMLElement)) {
return;
}
if (!(element instanceof HTMLAnchorElement)) {
return;
}
if (element.origin !== absoluteUrl('').replace(/\/+$/, '') || !/msg=([a-zA-Z0-9]+)/.test(element.search)) {
return;
}
fireGlobalEvent('click-message-link', { link: element.pathname + element.search });
};
document.body.addEventListener('click', handleMessageLinkClick);
return () => {
document.body.removeEventListener('click', handleMessageLinkClick);
};
}, [absoluteUrl, embeddedLayout]);
};

View File

@@ -0,0 +1,62 @@
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { useLayoutEffect } from 'react';
import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig';
import { legacyEmojioneMap } from '../../../../app/emoji-native/lib/legacyEmojioneMap';
import { emoji } from '../../../lib/emoji';
const config = getEmojiConfig(emoji);
export const useNativeEmoji = () => {
const convertAsciiToEmoji = useUserPreference<boolean>('convertAsciiEmoji', true);
useLayoutEffect(() => {
emoji.packages.native = {
emojiCategories: config.emojiCategories as any,
emojisByCategory: config.emojisByCategory,
toneList: config.toneList,
render: config.render,
renderPicker: config.renderPicker,
sprites: config.sprites,
};
for (const [key, currentEmoji] of Object.entries(config.emojiList)) {
currentEmoji.emojiPackage = 'native';
emoji.list[key] = currentEmoji;
if (currentEmoji.shortnames) {
currentEmoji.shortnames.forEach((shortname: string) => {
emoji.list[shortname] = currentEmoji;
});
}
}
// Register legacy emojione shortcodes so old reactions and stored shortcodes resolve correctly
for (const [shortcode, unicode] of Object.entries(legacyEmojioneMap)) {
const key = `:${shortcode}:`;
if (emoji.list[key]) continue;
emoji.list[key] = {
uc_base: '',
uc_output: '',
uc_match: '',
uc_greedy: '',
shortnames: [],
category: '',
emojiPackage: 'native',
unicode,
};
}
emoji.dispatchUpdate();
}, []);
useLayoutEffect(() => {
if (!emoji.packages.native) {
return;
}
emoji.packages.native.ascii = convertAsciiToEmoji ?? true;
emoji.dispatchUpdate();
}, [convertAsciiToEmoji]);
};

View File

@@ -0,0 +1,21 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { notificationManager } from '../../../lib/notificationManager';
export const useNotificationPermission = () => {
const requestPermission = useStableCallback(async () => {
const response = await Notification.requestPermission();
notificationManager.allowed = response === 'granted';
notificationManager.emit('change');
const notifications = await navigator.permissions.query({ name: 'notifications' });
notifications.onchange = () => {
notificationManager.allowed = notifications.state === 'granted';
notificationManager.emit('change');
};
});
if ('Notification' in window) {
requestPermission();
}
};

View File

@@ -0,0 +1,46 @@
import { useEndpoint, useRouter, useSearchParameter, useLoginWithToken } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useEffect } from 'react';
import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL';
export const useOAuthLogin = () => {
const router = useRouter();
const loginCode = useSearchParameter('loginCode');
const loginClient = useSearchParameter('loginClient');
const redeemLoginCode = useEndpoint('POST', '/v1/loginCode.redeem');
const loginWithToken = useLoginWithToken();
const { mutate: redeemLoginCodeMutation } = useMutation({
mutationFn: async (loginCode: string) => {
const { loginToken, userId } = await redeemLoginCode({ code: loginCode });
if (!loginToken || !userId) {
throw new Error('Invalid response from login code redemption');
}
return { loginToken, userId };
},
onSuccess: async ({ loginToken, userId }) => {
if (loginClient === 'desktop' || loginClient === 'mobile') {
window.location.href = buildDeepLinkURL(loginToken, userId);
return;
}
await loginWithToken(loginToken);
router.navigate('/home', { replace: true });
},
onError: (error) => {
console.error('Failed to redeem login code for client redirect', error);
router.navigate('/login', { replace: true });
},
});
useEffect(() => {
if (!loginCode) {
return;
}
redeemLoginCodeMutation(loginCode);
}, [loginCode, redeemLoginCodeMutation]);
};

View File

@@ -0,0 +1,73 @@
import { escapeRegExp } from '@rocket.chat/tools';
import { type LocationPathname, useLoginWithToken, useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { ltrim, rtrim } from '../../../../lib/utils/stringUtils';
import { baseURI } from '../../../lib/baseURI';
import { getRootUrlPathPrefix } from '../../../lib/meteorRuntimeConfig';
import { router } from '../../../providers/RouterProvider';
export const useOAuthPopupCommands = () => {
const loginWithToken = useLoginWithToken();
const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true);
useEffect(() => {
if (!enableModernOAuthFlow) {
return;
}
const commands = {
//Used for the 2FA challenge and for handing the session over to the desktop/mobile clients.
'redirect'(data: { path: string }) {
if (typeof data.path !== 'string' || data.path.trim().length === 0) {
return console.error('OAuth popup command [navigate]: `path` not defined');
}
const newUrl = new URL(`${rtrim(baseURI, '/')}/${ltrim(data.path, '/')}`);
const newParams = Object.fromEntries(newUrl.searchParams.entries());
const newPath = newUrl.pathname.replace(new RegExp(`^${escapeRegExp(getRootUrlPathPrefix())}`), '') as LocationPathname;
router.navigate({
pathname: newPath,
search: { ...router.getSearchParameters(), ...newParams },
});
},
'oauth-login-with-token'(data: { token: string }) {
if (typeof data.token !== 'string') {
return console.error('OAuth popup command [login]: `token` not defined');
}
void loginWithToken(data.token);
},
} as const;
const messageListener = (event: MessageEvent) => {
//The popup is same-origin, anything else is not ours to act on.
if (event.origin !== window.location.origin) {
return;
}
if (typeof event.data !== 'object' || event.data === null) {
return;
}
const { oauthPopupCommand } = event.data as { oauthPopupCommand?: unknown };
if (typeof oauthPopupCommand !== 'string' || !Object.hasOwn(commands, oauthPopupCommand)) {
return;
}
const command: (data: MessageEvent['data']) => void = commands[oauthPopupCommand as keyof typeof commands];
command(event.data);
};
window.addEventListener('message', messageListener);
return () => {
window.removeEventListener('message', messageListener);
};
}, [loginWithToken, enableModernOAuthFlow]);
};

View File

@@ -0,0 +1,17 @@
import { useRole, useRouter, useSetting, useUserId } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
export const useRedirectToSetupWizard = (): void => {
const userId = useUserId();
const setupWizardState = useSetting('Show_Setup_Wizard');
const router = useRouter();
const isAdmin = useRole('admin');
const isWizardInProgress = userId && isAdmin && setupWizardState === 'in_progress';
const mustRedirect = (!userId && setupWizardState === 'pending') || isWizardInProgress;
useEffect(() => {
if (mustRedirect) {
router.navigate('/setup-wizard');
}
}, [mustRedirect, router]);
};

View File

@@ -0,0 +1,16 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { setRootUrl } from '../../../lib/meteorRuntimeConfig';
export const useSettingsOnLoadSiteUrl = () => {
const siteUrl = useSetting('Site_Url') as string;
useEffect(() => {
const value = siteUrl;
if (value == null || value.trim() === '') {
return;
}
setRootUrl(value);
}, [siteUrl]);
};

View File

@@ -0,0 +1,41 @@
import { useRouter, useSearchParameter, useUserId } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL';
import { readStoredLoginToken } from '../../../lib/sdk/ddpSdk';
export const useShareSessionWithOtherClients = () => {
const router = useRouter();
const userId = useUserId();
const resumeToken = useSearchParameter('resumeToken');
const loginClient = useSearchParameter('loginClient');
useEffect(() => {
if (!userId) {
return;
}
const loginToken = readStoredLoginToken();
if (!loginToken) {
return;
}
if (resumeToken) {
return;
}
if (loginClient !== 'desktop' && loginClient !== 'mobile') {
return;
}
const loginURL = buildDeepLinkURL(loginToken, userId);
window.location.href = loginURL;
const timeout = setTimeout(() => {
router.navigate('/home', { replace: true });
}, 100);
return () => clearTimeout(timeout);
}, [resumeToken, loginClient, router, userId]);
};

View File

@@ -0,0 +1,11 @@
import { useEffect } from 'react';
import { useFireGlobalEvent } from '../../../hooks/useFireGlobalEvent';
export const useStartupEvent = () => {
const { mutate: fireStartupEvent } = useFireGlobalEvent('startup', 'global');
useEffect(() => {
fireStartupEvent(true);
}, [fireStartupEvent]);
};

View File

@@ -0,0 +1,14 @@
import { useSession } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
export const useUnreadMessages = (): string | undefined => {
const { t } = useTranslation();
const unreadMessages = useSession('unread') as number | '' | '999+' | '•';
// TODO: remove this when we have a better way to handle this
if (typeof unreadMessages !== 'number') {
return undefined;
}
return t('unread_messages_counter', { count: unreadMessages });
};