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:
5
apps/meteor/client/views/root/hooks/closeAllRooms.ts
Normal file
5
apps/meteor/client/views/root/hooks/closeAllRooms.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LegacyRoomManager } from '../../../lib/LegacyRoomManager';
|
||||
|
||||
export const closeAllRooms = () => {
|
||||
LegacyRoomManager.closeAllRooms();
|
||||
};
|
||||
@@ -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.
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
5
apps/meteor/client/views/root/hooks/purgeAllDrafts.ts
Normal file
5
apps/meteor/client/views/root/hooks/purgeAllDrafts.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const purgeAllDrafts = (): void => {
|
||||
Object.keys(localStorage)
|
||||
.filter((key) => key.indexOf('messagebox_') === 0)
|
||||
.forEach((key) => localStorage.removeItem(key));
|
||||
};
|
||||
140
apps/meteor/client/views/root/hooks/useAnalytics.ts
Normal file
140
apps/meteor/client/views/root/hooks/useAnalytics.ts
Normal 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]);
|
||||
};
|
||||
257
apps/meteor/client/views/root/hooks/useAnalyticsEventTracking.ts
Normal file
257
apps/meteor/client/views/root/hooks/useAnalyticsEventTracking.ts
Normal 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]);
|
||||
};
|
||||
54
apps/meteor/client/views/root/hooks/useAutoupdate.spec.ts
Normal file
54
apps/meteor/client/views/root/hooks/useAutoupdate.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
32
apps/meteor/client/views/root/hooks/useAutoupdate.tsx
Normal file
32
apps/meteor/client/views/root/hooks/useAutoupdate.tsx
Normal 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]);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
19
apps/meteor/client/views/root/hooks/useCodeHighlight.ts
Normal file
19
apps/meteor/client/views/root/hooks/useCodeHighlight.ts
Normal 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]);
|
||||
};
|
||||
15
apps/meteor/client/views/root/hooks/useCorsSSLConfig.ts
Normal file
15
apps/meteor/client/views/root/hooks/useCorsSSLConfig.ts
Normal 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]);
|
||||
};
|
||||
19
apps/meteor/client/views/root/hooks/useDesktopFavicon.ts
Normal file
19
apps/meteor/client/views/root/hooks/useDesktopFavicon.ts
Normal 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]);
|
||||
};
|
||||
12
apps/meteor/client/views/root/hooks/useDesktopTitle.ts
Normal file
12
apps/meteor/client/views/root/hooks/useDesktopTitle.ts
Normal 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]);
|
||||
};
|
||||
23
apps/meteor/client/views/root/hooks/useDesktopUserRoles.ts
Normal file
23
apps/meteor/client/views/root/hooks/useDesktopUserRoles.ts
Normal 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]);
|
||||
};
|
||||
50
apps/meteor/client/views/root/hooks/useEscapeKeyStroke.ts
Normal file
50
apps/meteor/client/views/root/hooks/useEscapeKeyStroke.ts
Normal 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]);
|
||||
};
|
||||
29
apps/meteor/client/views/root/hooks/useGoogleTagManager.ts
Normal file
29
apps/meteor/client/views/root/hooks/useGoogleTagManager.ts
Normal 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]);
|
||||
};
|
||||
90
apps/meteor/client/views/root/hooks/useIframe.ts
Normal file
90
apps/meteor/client/views/root/hooks/useIframe.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
137
apps/meteor/client/views/root/hooks/useIframeCommands.ts
Normal file
137
apps/meteor/client/views/root/hooks/useIframeCommands.ts
Normal 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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
23
apps/meteor/client/views/root/hooks/useLivechatEnterprise.ts
Normal file
23
apps/meteor/client/views/root/hooks/useLivechatEnterprise.ts
Normal 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]);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
82
apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts
Normal file
82
apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts
Normal 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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
30
apps/meteor/client/views/root/hooks/useLoginOtherClients.ts
Normal file
30
apps/meteor/client/views/root/hooks/useLoginOtherClients.ts
Normal 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]);
|
||||
};
|
||||
41
apps/meteor/client/views/root/hooks/useLoginViaQuery.ts
Normal file
41
apps/meteor/client/views/root/hooks/useLoginViaQuery.ts
Normal 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]);
|
||||
};
|
||||
13
apps/meteor/client/views/root/hooks/useMainReady.ts
Normal file
13
apps/meteor/client/views/root/hooks/useMainReady.ts
Normal 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);
|
||||
};
|
||||
39
apps/meteor/client/views/root/hooks/useMessageLinkClicks.ts
Normal file
39
apps/meteor/client/views/root/hooks/useMessageLinkClicks.ts
Normal 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]);
|
||||
};
|
||||
62
apps/meteor/client/views/root/hooks/useNativeEmoji.ts
Normal file
62
apps/meteor/client/views/root/hooks/useNativeEmoji.ts
Normal 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]);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
46
apps/meteor/client/views/root/hooks/useOAuthLogin.ts
Normal file
46
apps/meteor/client/views/root/hooks/useOAuthLogin.ts
Normal 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]);
|
||||
};
|
||||
73
apps/meteor/client/views/root/hooks/useOAuthPopupCommands.ts
Normal file
73
apps/meteor/client/views/root/hooks/useOAuthPopupCommands.ts
Normal 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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
11
apps/meteor/client/views/root/hooks/useStartupEvent.ts
Normal file
11
apps/meteor/client/views/root/hooks/useStartupEvent.ts
Normal 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]);
|
||||
};
|
||||
14
apps/meteor/client/views/root/hooks/useUnreadMessages.ts
Normal file
14
apps/meteor/client/views/root/hooks/useUnreadMessages.ts
Normal 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 });
|
||||
};
|
||||
Reference in New Issue
Block a user