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:
26
apps/meteor/client/providers/ActionManagerProvider.tsx
Normal file
26
apps/meteor/client/providers/ActionManagerProvider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ActionManagerContext, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useAppActionButtons } from '../hooks/useAppActionButtons';
|
||||
import { useAppSlashCommands } from '../hooks/useAppSlashCommands';
|
||||
import { useAppUiKitInteraction } from '../hooks/useAppUiKitInteraction';
|
||||
import { useTranslationsForApps } from '../hooks/useTranslationsForApps';
|
||||
import { ActionManager } from '../lib/ActionManager';
|
||||
import { useInstance } from '../views/room/providers/hooks/useInstance';
|
||||
|
||||
export type ActionManagerProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const ActionManagerProvider = ({ children }: ActionManagerProviderProps) => {
|
||||
const router = useRouter();
|
||||
const actionManager = useInstance(() => [new ActionManager(router)], [router]);
|
||||
useTranslationsForApps();
|
||||
useAppActionButtons();
|
||||
useAppSlashCommands();
|
||||
useAppUiKitInteraction(actionManager.handleServerInteraction.bind(actionManager));
|
||||
|
||||
return <ActionManagerContext.Provider value={actionManager}>{children}</ActionManagerContext.Provider>;
|
||||
};
|
||||
|
||||
export default ActionManagerProvider;
|
||||
14
apps/meteor/client/providers/AppsProvider.tsx
Normal file
14
apps/meteor/client/providers/AppsProvider.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { AppClientOrchestratorInstance } from '../apps/orchestrator';
|
||||
import { AppsContext } from '../contexts/AppsContext';
|
||||
|
||||
export type AppsProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const AppsProvider = ({ children }: AppsProviderProps) => {
|
||||
return <AppsContext.Provider value={AppClientOrchestratorInstance}>{children}</AppsContext.Provider>;
|
||||
};
|
||||
|
||||
export default AppsProvider;
|
||||
38
apps/meteor/client/providers/AttachmentProvider.tsx
Normal file
38
apps/meteor/client/providers/AttachmentProvider.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { usePrefersReducedData } from '@rocket.chat/fuselage-hooks';
|
||||
import type { AttachmentContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { AttachmentContext, useLayout, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { getURL } from '../lib/getURL';
|
||||
|
||||
export type AttachmentProviderProps = {
|
||||
children?: ReactNode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
const AttachmentProvider = ({ children, width = 360, height = 360 }: AttachmentProviderProps) => {
|
||||
const { isMobile } = useLayout();
|
||||
const reducedData = usePrefersReducedData();
|
||||
const collapsedByDefault = !!useUserPreference<boolean>('collapseMediaByDefault');
|
||||
const autoLoadEmbedMedias = !!useUserPreference<boolean>('autoImageLoad');
|
||||
const saveMobileBandwidth = !!useUserPreference<boolean>('saveMobileBandwidth');
|
||||
|
||||
const contextValue: AttachmentContextValue = useMemo(
|
||||
() => ({
|
||||
getURL: (url: string): string => getURL(url, { full: true }),
|
||||
collapsedByDefault,
|
||||
autoLoadEmbedMedias: !reducedData && autoLoadEmbedMedias && (!saveMobileBandwidth || !isMobile),
|
||||
dimensions: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
}),
|
||||
[collapsedByDefault, reducedData, autoLoadEmbedMedias, saveMobileBandwidth, isMobile, width, height],
|
||||
);
|
||||
|
||||
return <AttachmentContext.Provider value={contextValue}>{children}</AttachmentContext.Provider>;
|
||||
};
|
||||
|
||||
export default AttachmentProvider;
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { LoginServiceConfiguration } from '@rocket.chat/core-typings';
|
||||
import { capitalize } from '@rocket.chat/tools';
|
||||
import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { ContextType, ReactNode } from 'react';
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning';
|
||||
import { capitalize as capitalizeService } from '../../../lib/utils/stringUtils';
|
||||
import { loginServices } from '../../lib/loginServices';
|
||||
import { getDdpSdk } from '../../lib/sdk/ddpSdk';
|
||||
import { STORAGE_KEYS, getStoredItem, removeStoredItem } from '../../lib/sdk/storage';
|
||||
|
||||
export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never;
|
||||
|
||||
export type AuthenticationProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const callLoginMethod = (
|
||||
options: { loginToken?: string; token?: string; iframe?: boolean },
|
||||
userCallback: ((err?: any) => void) | undefined,
|
||||
) => {
|
||||
Accounts.callLoginMethod({
|
||||
methodArguments: [options],
|
||||
userCallback,
|
||||
});
|
||||
};
|
||||
|
||||
// Bridge Accounts.loggingIn() — Meteor's Tracker-reactive flag — into a
|
||||
// non-reactive subscribe/getSnapshot pair for useSyncExternalStore. We hook
|
||||
// `_setLoggingIn` (Meteor's internal flip, also accessed in
|
||||
// apps/meteor/client/meteor/overrides/killMeteorStream.ts) to fan out
|
||||
// transitions without entering a Tracker computation.
|
||||
const loggingInListeners = new Set<() => void>();
|
||||
let loggingInBridgeInstalled = false;
|
||||
const installLoggingInBridge = (): void => {
|
||||
if (loggingInBridgeInstalled) return;
|
||||
loggingInBridgeInstalled = true;
|
||||
const wrap = Accounts as unknown as { _setLoggingIn?: (v: boolean) => void };
|
||||
const original = wrap._setLoggingIn;
|
||||
if (typeof original !== 'function') return;
|
||||
wrap._setLoggingIn = function (this: typeof Accounts, v: boolean) {
|
||||
original.call(this, v);
|
||||
loggingInListeners.forEach((cb) => cb());
|
||||
};
|
||||
};
|
||||
|
||||
const subscribeLoggingIn = (cb: () => void): (() => void) => {
|
||||
installLoggingInBridge();
|
||||
loggingInListeners.add(cb);
|
||||
return () => {
|
||||
loggingInListeners.delete(cb);
|
||||
};
|
||||
};
|
||||
|
||||
const getLoggingInSnapshot = (): boolean => Accounts.loggingIn();
|
||||
|
||||
const AuthenticationProvider = ({ children }: AuthenticationProviderProps) => {
|
||||
const isLdapEnabled = useSetting('LDAP_Enable', false);
|
||||
const isCrowdEnabled = useSetting('CROWD_Enable', false);
|
||||
|
||||
const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword';
|
||||
|
||||
useLDAPAndCrowdCollisionWarning();
|
||||
|
||||
const isLoggingIn = useSyncExternalStore(subscribeLoggingIn, getLoggingInSnapshot);
|
||||
|
||||
const contextValue = useMemo(
|
||||
(): ContextType<typeof AuthenticationContext> => ({
|
||||
isLoggingIn,
|
||||
loginWithToken: (token: string, callback): Promise<void> =>
|
||||
new Promise((resolve, reject) =>
|
||||
Meteor.loginWithToken(token, (err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
callback?.(err);
|
||||
return reject(err);
|
||||
}
|
||||
resolve(undefined);
|
||||
}),
|
||||
),
|
||||
loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
Meteor[loginMethod](user, password, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
loginWithService: <T extends LoginServiceConfiguration>(serviceConfig: T): (() => Promise<true>) => {
|
||||
const loginMethods: Record<string, string | undefined> = {
|
||||
'meteor-developer': 'MeteorDeveloperAccount',
|
||||
};
|
||||
|
||||
const { service: serviceName } = serviceConfig;
|
||||
const clientConfig = ('clientConfig' in serviceConfig && serviceConfig.clientConfig) || {};
|
||||
|
||||
const loginWithService = `loginWith${loginMethods[serviceName] || capitalize(String(serviceName || ''))}`;
|
||||
|
||||
const method: (config: unknown, cb: (error: any) => void) => Promise<true> = (Meteor as any)[loginWithService];
|
||||
|
||||
if (!method) {
|
||||
return () => Promise.reject(new Error('Login method not found'));
|
||||
}
|
||||
|
||||
return () =>
|
||||
new Promise((resolve, reject) => {
|
||||
method(clientConfig, (error: any): void => {
|
||||
if (!error) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
loginWithCustomOauth: (service: string, options: { redirectUrl: string }, callback) => {
|
||||
const methodName = `loginWith${capitalizeService(service, true)}`;
|
||||
const method = (Meteor as any)[methodName] as
|
||||
((options: { redirectUrl: string }, cb?: (response: unknown) => void) => void) | undefined;
|
||||
if (!method) {
|
||||
return;
|
||||
}
|
||||
method.call(Meteor, options, callback);
|
||||
},
|
||||
loginWithIframe: (token: string, callback) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
callLoginMethod({ iframe: true, token }, (error) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
callback?.(error);
|
||||
return reject(error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
loginWithTokenRoute: (token: string, callback) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
callLoginMethod({ token }, (error) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
callback?.(error);
|
||||
return reject(error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
getLoginToken: () => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN),
|
||||
wipeLocalAuth: () => {
|
||||
removeStoredItem(STORAGE_KEYS.USER_ID);
|
||||
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
|
||||
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN_EXPIRES);
|
||||
try {
|
||||
Meteor.connection.setUserId(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
unstoreLoginToken: (callback) => getDdpSdk().account.onLogout(callback),
|
||||
queryLoginServices: {
|
||||
getCurrentValue: () => loginServices.getLoginServiceButtons(),
|
||||
subscribe: (onStoreChange: () => void) => loginServices.on('changed', onStoreChange),
|
||||
},
|
||||
}),
|
||||
[isLoggingIn, loginMethod],
|
||||
);
|
||||
|
||||
return <AuthenticationContext.Provider value={contextValue}>{children}</AuthenticationContext.Provider>;
|
||||
};
|
||||
|
||||
export default AuthenticationProvider;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { LoginMethods } from '../AuthenticationProvider';
|
||||
|
||||
export function useLDAPAndCrowdCollisionWarning() {
|
||||
const isLdapEnabled = useSetting('LDAP_Enable', false);
|
||||
const isCrowdEnabled = useSetting('CROWD_Enable', false);
|
||||
|
||||
const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword';
|
||||
|
||||
useEffect(() => {
|
||||
if (isLdapEnabled && isCrowdEnabled) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
throw new Error('You can not use both LDAP and Crowd at the same time');
|
||||
}
|
||||
console.log('Both LDAP and Crowd are enabled. Please disable one of them.');
|
||||
}
|
||||
if (!Meteor[loginMethod]) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
throw new Error(`Meteor.${loginMethod} is not defined`);
|
||||
}
|
||||
console.log(`Meteor.${loginMethod} is not defined`);
|
||||
}
|
||||
}, [isLdapEnabled, isCrowdEnabled, loginMethod]);
|
||||
}
|
||||
112
apps/meteor/client/providers/AuthorizationProvider.tsx
Normal file
112
apps/meteor/client/providers/AuthorizationProvider.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { AuthorizationContext, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import type { ContextType, ReactNode } from 'react';
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { createAuthorizationFunctions } from '../../app/authorization/lib/createAuthorizationFunctions';
|
||||
import { PermissionsCachedStore } from '../cachedStores';
|
||||
import { Permissions, Roles, Subscriptions, Users } from '../stores';
|
||||
|
||||
// Only the slice of IUser that the authorization helpers actually read.
|
||||
// Snapshotting just `roles` (instead of the full user document) keeps the
|
||||
// provider from re-rendering on presence/status updates, last-login flips,
|
||||
// avatar etag changes, etc. — none of which affect any permission answer.
|
||||
type AuthorizableUser = Pick<IUser, '_id' | 'roles'>;
|
||||
|
||||
export type AuthorizationProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const noopSubscribe = (): (() => void) => () => undefined;
|
||||
|
||||
const subscribeToSubscriptions = (onStoreChange: () => void): (() => void) => Subscriptions.use.subscribe(onStoreChange);
|
||||
|
||||
const selectUserRoles = (userId: IUser['_id'] | undefined): AuthorizableUser['roles'] | undefined => {
|
||||
if (!userId) return undefined;
|
||||
return Users.use.getState().get(userId)?.roles;
|
||||
};
|
||||
|
||||
const AuthorizationProvider = ({ children }: AuthorizationProviderProps) => {
|
||||
const isReady = PermissionsCachedStore.useReady();
|
||||
|
||||
if (!isReady) {
|
||||
throw (async () => {
|
||||
PermissionsCachedStore.listen();
|
||||
await PermissionsCachedStore.init();
|
||||
})();
|
||||
}
|
||||
|
||||
const userId = useUserId();
|
||||
|
||||
// Permissions and Roles change infrequently (admin-driven or login-time only);
|
||||
// observing the whole map is cheap and re-renders propagate the new auth
|
||||
// answers through context to every consumer.
|
||||
const permissionsState = useSyncExternalStore(Permissions.use.subscribe, () => Permissions.use.getState());
|
||||
const rolesState = useSyncExternalStore(Roles.use.subscribe, () => Roles.use.getState());
|
||||
// For Users, only the current user's `roles` array is relevant for auth
|
||||
// decisions (hooks dispatch via getCurrentUserId; `userHasAllPermission` with
|
||||
// an arbitrary userId has no real callers). Subscribing to the full Users map
|
||||
// would re-render the provider on every presence update for every user. The
|
||||
// custom getSnapshot returns the same array reference until the current
|
||||
// user's roles actually change, so useSyncExternalStore short-circuits via
|
||||
// Object.is and the provider stays still through unrelated user churn.
|
||||
const currentUserRoles = useSyncExternalStore(Users.use.subscribe, () => selectUserRoles(userId));
|
||||
// Subscriptions.use is intentionally NOT observed here — it updates on every
|
||||
// incoming message, member change, and unread-count flip. Subscription-scoped
|
||||
// permission checks subscribe per-call below.
|
||||
|
||||
const auth = useMemo(
|
||||
() =>
|
||||
createAuthorizationFunctions({
|
||||
getCurrentUserId: () => userId,
|
||||
// Fast path for the only userId hook consumers ever pass; live read for
|
||||
// any other userId (only userHasAllPermission can reach this branch).
|
||||
getUserRoles: (id) => (id === userId ? currentUserRoles : Users.use.getState().get(id)?.roles),
|
||||
getPermission: (id) => permissionsState.get(id),
|
||||
getRoleScope: (id) => rolesState.get(id)?.scope,
|
||||
// Read Subscriptions live — reactivity for scoped checks is wired through
|
||||
// the per-call subscribe returned by queryPermission/queryRole below.
|
||||
hasSubscriptionRole: (rid, roleId) =>
|
||||
Subscriptions.use
|
||||
.getState()
|
||||
.find((s) => s.rid === rid)
|
||||
?.roles?.includes(roleId) ?? false,
|
||||
isReady: () => true,
|
||||
}),
|
||||
[userId, currentUserRoles, permissionsState, rolesState],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
(): ContextType<typeof AuthorizationContext> => ({
|
||||
// Callers without `scope` never touch Subscriptions (the factory short-circuits
|
||||
// at the role-scope gate). They rely on context-value identity for re-renders
|
||||
// from Users/Permissions/Roles changes — which is why subscribe is noop.
|
||||
// Callers with a `scope` (room id) DO touch Subscriptions, so we attach a
|
||||
// per-call subscribe to that store so they re-evaluate when subscriptions
|
||||
// for the relevant room flip without dragging the rest of the tree along.
|
||||
queryPermission: (permission, scope, scopeRoles) => [
|
||||
scope !== undefined ? subscribeToSubscriptions : noopSubscribe,
|
||||
() => auth.hasPermission(String(permission), scope ? String(scope) : undefined, scopeRoles),
|
||||
],
|
||||
queryAtLeastOnePermission: (permissions, scope) => [
|
||||
scope !== undefined ? subscribeToSubscriptions : noopSubscribe,
|
||||
() => auth.hasAtLeastOnePermission(permissions.map(String), scope ? String(scope) : undefined),
|
||||
],
|
||||
queryAllPermissions: (permissions, scope) => [
|
||||
scope !== undefined ? subscribeToSubscriptions : noopSubscribe,
|
||||
() => auth.hasAllPermission(permissions.map(String), scope ? String(scope) : undefined),
|
||||
],
|
||||
queryRole: (role, scope) => [
|
||||
scope !== undefined ? subscribeToSubscriptions : noopSubscribe,
|
||||
() => !!userId && auth.hasRole(userId, String(role), scope),
|
||||
],
|
||||
getRoles: () => Roles.state.records,
|
||||
subscribeToRoles: (callback) => Roles.use.subscribe(callback),
|
||||
}),
|
||||
[auth, userId],
|
||||
);
|
||||
|
||||
return <AuthorizationContext.Provider value={contextValue}>{children}</AuthorizationContext.Provider>;
|
||||
};
|
||||
|
||||
export default AuthorizationProvider;
|
||||
40
apps/meteor/client/providers/AvatarUrlProvider.tsx
Normal file
40
apps/meteor/client/providers/AvatarUrlProvider.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AvatarUrlContext } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { getURL } from '../lib/getURL';
|
||||
import { roomCoordinator } from '../lib/rooms/roomCoordinator';
|
||||
|
||||
export type AvatarUrlProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const AvatarUrlProvider = ({ children }: AvatarUrlProviderProps) => {
|
||||
const contextValue = useMemo(() => {
|
||||
function getUserPathAvatar(username: string, etag?: string | null): string;
|
||||
function getUserPathAvatar({ userId, etag }: { userId: string; etag?: string | null }): string;
|
||||
function getUserPathAvatar({ username, etag }: { username: string; etag?: string | null }): string;
|
||||
function getUserPathAvatar(...args: any): string {
|
||||
if (typeof args[0] === 'string') {
|
||||
const [username, etag] = args;
|
||||
return getURL(`/avatar/${username}${etag ? `?etag=${etag}` : ''}`);
|
||||
}
|
||||
const [params] = args;
|
||||
if ('userId' in params) {
|
||||
const { userId, etag } = params;
|
||||
return getURL(`/avatar/uid/${userId}${etag ? `?etag=${etag}` : ''}`);
|
||||
}
|
||||
const { username, etag } = params;
|
||||
return getURL(`/avatar/${username}${etag ? `?etag=${etag}` : ''}`);
|
||||
}
|
||||
return {
|
||||
getUserPathAvatar,
|
||||
getRoomPathAvatar: ({ type, ...room }: any): string =>
|
||||
roomCoordinator.getRoomDirectives(type || room.t).getAvatarPath({ username: room._id, ...room }) || '',
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <AvatarUrlContext.Provider value={contextValue}>{children}</AvatarUrlContext.Provider>;
|
||||
};
|
||||
|
||||
export default AvatarUrlProvider;
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { ICustomSound } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { CustomSoundContext, useEndpoint, useStream, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
|
||||
import { defaultSounds, getCustomSoundURL, formatVolume } from './lib';
|
||||
import { useUserSoundPreferences } from '../../hooks/useUserSoundPreferences';
|
||||
|
||||
export type CustomSoundProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => {
|
||||
const audioRefs = useRef<HTMLAudioElement[]>([]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const streamAll = useStream('notify-all');
|
||||
const getCustomSounds = useEndpoint('GET', '/v1/custom-sounds.list');
|
||||
|
||||
const newRoomNotification = useUserPreference<string>('newRoomNotification') || 'door';
|
||||
const newMessageNotification = useUserPreference<string>('newMessageNotification') || 'chime';
|
||||
const { notificationsSoundVolume, voipRingerVolume } = useUserSoundPreferences();
|
||||
|
||||
const { data: list } = useQuery({
|
||||
queryFn: async (): Promise<Omit<ICustomSound, '_updatedAt'>[]> => {
|
||||
// `/v1/custom-sounds.list` is paginated, so we page through all results to
|
||||
// load every custom sound into the provider (the legacy `listCustomSounds`
|
||||
// method returned them all at once).
|
||||
const sounds: Awaited<ReturnType<typeof getCustomSounds>>['sounds'] = [];
|
||||
let total = Infinity;
|
||||
while (sounds.length < total) {
|
||||
const page = await getCustomSounds({ count: 100, offset: sounds.length });
|
||||
total = page.total;
|
||||
sounds.push(...page.sounds);
|
||||
if (!page.sounds.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sounds.length) {
|
||||
return defaultSounds;
|
||||
}
|
||||
return [...sounds.map(({ _updatedAt: _, ...sound }) => ({ ...sound, src: getCustomSoundURL(sound) })), ...defaultSounds];
|
||||
},
|
||||
queryKey: ['listCustomSounds'],
|
||||
initialData: defaultSounds,
|
||||
});
|
||||
|
||||
const play = useStableCallback((soundId: ICustomSound['_id'], { volume = 1, loop = false } = {}) => {
|
||||
stop(soundId);
|
||||
|
||||
const item = list?.find(({ _id }) => _id === soundId);
|
||||
if (!item?.src) {
|
||||
console.error('Unable to play sound', soundId);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = new Audio(item.src);
|
||||
audio.volume = volume;
|
||||
audio.loop = loop;
|
||||
audio.id = soundId;
|
||||
audio.play();
|
||||
|
||||
audioRefs.current = [...audioRefs.current, audio];
|
||||
|
||||
return () => {
|
||||
stop(soundId);
|
||||
};
|
||||
});
|
||||
|
||||
const pause = useStableCallback((soundId: ICustomSound['_id']) => {
|
||||
const current = audioRefs.current?.find(({ id }) => id === soundId);
|
||||
if (current) {
|
||||
current.pause();
|
||||
audioRefs.current = audioRefs.current.filter(({ id }) => id !== soundId);
|
||||
}
|
||||
});
|
||||
|
||||
const stop = useStableCallback((soundId: ICustomSound['_id']) => {
|
||||
const current = audioRefs.current?.find(({ id }) => id === soundId);
|
||||
if (current) {
|
||||
current.load();
|
||||
audioRefs.current = audioRefs.current.filter(({ id }) => id !== soundId);
|
||||
}
|
||||
});
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
const notificationSounds = {
|
||||
playNewRoom: () => play(newRoomNotification, { loop: false, volume: formatVolume(notificationsSoundVolume) }),
|
||||
playNewRoomLoop: () => play(newRoomNotification, { loop: true, volume: formatVolume(notificationsSoundVolume) }),
|
||||
playNewMessage: () => play(newMessageNotification, { loop: false, volume: formatVolume(notificationsSoundVolume) }),
|
||||
playNewMessageCustom: (soundId: ICustomSound['_id']) =>
|
||||
play(soundId, { loop: false, volume: formatVolume(notificationsSoundVolume) }),
|
||||
stopNewRoom: () => stop(newRoomNotification),
|
||||
stopNewMessage: () => stop(newMessageNotification),
|
||||
};
|
||||
const voipSounds = {
|
||||
playRinger: () => play('telephone', { loop: true, volume: formatVolume(voipRingerVolume) }),
|
||||
playDialer: () => play('outbound-call-ringing', { loop: true, volume: formatVolume(voipRingerVolume) }),
|
||||
playCallEnded: () => play('call-ended', { loop: false, volume: formatVolume(voipRingerVolume) }),
|
||||
stopRinger: () => stop('telephone'),
|
||||
stopDialer: () => stop('outbound-call-ringing'),
|
||||
stopCallEnded: () => stop('call-ended'),
|
||||
stopAll: () => {
|
||||
stop('telephone');
|
||||
stop('outbound-call-ringing');
|
||||
stop('call-ended');
|
||||
},
|
||||
};
|
||||
const callSounds = {
|
||||
playRinger: () => play('ringtone', { loop: true, volume: formatVolume(voipRingerVolume) }),
|
||||
playDialer: () => play('dialtone', { loop: true, volume: formatVolume(voipRingerVolume) }),
|
||||
stopRinger: () => stop('ringtone'),
|
||||
stopDialer: () => stop('dialtone'),
|
||||
};
|
||||
return {
|
||||
list,
|
||||
notificationSounds,
|
||||
callSounds,
|
||||
voipSounds,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
};
|
||||
}, [list, newMessageNotification, newRoomNotification, notificationsSoundVolume, pause, play, stop, voipRingerVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
return streamAll('public-info', ([key]) => {
|
||||
switch (key) {
|
||||
case 'updateCustomSound':
|
||||
queryClient.invalidateQueries({ queryKey: ['listCustomSounds'] });
|
||||
break;
|
||||
case 'deleteCustomSound':
|
||||
queryClient.invalidateQueries({ queryKey: ['listCustomSounds'] });
|
||||
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, [queryClient, streamAll]);
|
||||
|
||||
return <CustomSoundContext.Provider value={contextValue}>{children}</CustomSoundContext.Provider>;
|
||||
};
|
||||
|
||||
export default CustomSoundProvider;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CustomSoundProvider';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { formatVolume } from './formatVolume';
|
||||
|
||||
describe('formatVolume', () => {
|
||||
it('returns 1 if volume is 100', () => {
|
||||
expect(formatVolume(100)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 if volume is 200', () => {
|
||||
expect(formatVolume(200)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0.5 if volume is 50', () => {
|
||||
expect(formatVolume(50)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('returns 0 if volume is -10', () => {
|
||||
expect(formatVolume(-10)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const formatVolume = (volume: number) => {
|
||||
const clamped = Math.max(0, Math.min(volume, 100));
|
||||
return Number((clamped / 100).toPrecision(2));
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ICustomSound } from '@rocket.chat/core-typings';
|
||||
|
||||
import { getURL } from '../../../lib/getURL';
|
||||
|
||||
export const getAssetUrl = (asset: string, params?: Record<string, any>) => getURL(asset, params, undefined, true);
|
||||
|
||||
export const getCustomSoundURL = (sound: Pick<ICustomSound, '_id' | 'extension'> & Pick<Partial<ICustomSound>, 'random'>) => {
|
||||
return getAssetUrl(`/custom-sounds/${sound._id}.${sound.extension}`, { _dc: sound.random || 0 });
|
||||
};
|
||||
|
||||
// Translation keys for sound names
|
||||
export const soundTranslationKeys: Record<string, string> = {
|
||||
'chime': 'Sound_Chime',
|
||||
'door': 'Sound_Door',
|
||||
'beep': 'Sound_Beep',
|
||||
'chelle': 'Sound_Chelle',
|
||||
'ding': 'Sound_Ding',
|
||||
'droplet': 'Sound_Droplet',
|
||||
'highbell': 'Sound_Highbell',
|
||||
'seasons': 'Sound_Seasons',
|
||||
'telephone': 'Sound_Telephone',
|
||||
'outbound-call-ringing': 'Sound_Outbound_Call_Ringing',
|
||||
'call-ended': 'Sound_Call_Ended',
|
||||
'dialtone': 'Sound_Dialtone',
|
||||
'ringtone': 'Sound_Ringtone',
|
||||
};
|
||||
|
||||
export const defaultSounds: Omit<ICustomSound, '_updatedAt'>[] = [
|
||||
{ _id: 'chime', name: 'Sound_Chime', extension: 'mp3', src: getAssetUrl('sounds/chime.mp3') },
|
||||
{ _id: 'door', name: 'Sound_Door', extension: 'mp3', src: getAssetUrl('sounds/door.mp3') },
|
||||
{ _id: 'beep', name: 'Sound_Beep', extension: 'mp3', src: getAssetUrl('sounds/beep.mp3') },
|
||||
{ _id: 'chelle', name: 'Sound_Chelle', extension: 'mp3', src: getAssetUrl('sounds/chelle.mp3') },
|
||||
{ _id: 'ding', name: 'Sound_Ding', extension: 'mp3', src: getAssetUrl('sounds/ding.mp3') },
|
||||
{ _id: 'droplet', name: 'Sound_Droplet', extension: 'mp3', src: getAssetUrl('sounds/droplet.mp3') },
|
||||
{ _id: 'highbell', name: 'Sound_Highbell', extension: 'mp3', src: getAssetUrl('sounds/highbell.mp3') },
|
||||
{ _id: 'seasons', name: 'Sound_Seasons', extension: 'mp3', src: getAssetUrl('sounds/seasons.mp3') },
|
||||
{ _id: 'telephone', name: 'Sound_Telephone', extension: 'mp3', src: getAssetUrl('sounds/telephone.mp3') },
|
||||
{
|
||||
_id: 'outbound-call-ringing',
|
||||
name: 'Sound_Outbound_Call_Ringing',
|
||||
extension: 'mp3',
|
||||
src: getAssetUrl('sounds/outbound-call-ringing.mp3'),
|
||||
},
|
||||
{ _id: 'call-ended', name: 'Sound_Call_Ended', extension: 'mp3', src: getAssetUrl('sounds/call-ended.mp3') },
|
||||
{ _id: 'dialtone', name: 'Sound_Dialtone', extension: 'mp3', src: getAssetUrl('sounds/dialtone.mp3') },
|
||||
{ _id: 'ringtone', name: 'Sound_Ringtone', extension: 'mp3', src: getAssetUrl('sounds/ringtone.mp3') },
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './helpers';
|
||||
export * from './formatVolume';
|
||||
163
apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx
Normal file
163
apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { Device, DeviceContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { DeviceContext } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
import { isSetSinkIdAvailable } from './lib/isSetSinkIdAvailable';
|
||||
|
||||
export type DeviceProviderProps = {
|
||||
children?: ReactNode | undefined;
|
||||
};
|
||||
|
||||
const defaultDevices = {
|
||||
audioInput: [],
|
||||
audioOutput: [],
|
||||
defaultAudioOutputDevice: {
|
||||
id: '',
|
||||
label: '',
|
||||
type: 'audiooutput',
|
||||
},
|
||||
defaultAudioInputDevice: {
|
||||
id: '',
|
||||
label: '',
|
||||
type: 'audioinput',
|
||||
},
|
||||
};
|
||||
|
||||
const devicesQueryKey = ['media-devices-list'];
|
||||
|
||||
export const DeviceProvider = ({ children }: DeviceProviderProps) => {
|
||||
const [enabled] = useState(typeof isSecureContext && isSecureContext);
|
||||
const [selectedAudioOutputDevice, setSelectedAudioOutputDevice] = useState<Device | undefined>(undefined);
|
||||
const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState<Device | undefined>(undefined);
|
||||
|
||||
const setAudioInputDevice = (device: Device): void => {
|
||||
if (!isSecureContext) {
|
||||
throw new Error('Device Changes are not available on insecure contexts');
|
||||
}
|
||||
setSelectedAudioInputDevice(device);
|
||||
};
|
||||
|
||||
const setAudioOutputDevice = useStableCallback(
|
||||
({ outputDevice, HTMLAudioElement }: { outputDevice: Device; HTMLAudioElement: HTMLAudioElement }): void => {
|
||||
if (!isSetSinkIdAvailable()) {
|
||||
throw new Error('setSinkId is not available in this browser');
|
||||
}
|
||||
if (!enabled) {
|
||||
throw new Error('Device Changes are not available on insecure contexts');
|
||||
}
|
||||
setSelectedAudioOutputDevice(outputDevice);
|
||||
HTMLAudioElement.setSinkId(outputDevice.id);
|
||||
},
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: devicesQueryKey,
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
const devices = await navigator.mediaDevices?.enumerateDevices();
|
||||
if (!devices || devices.length === 0) {
|
||||
return defaultDevices;
|
||||
}
|
||||
|
||||
const mappedDevices: Device[] = devices.map((device) => ({
|
||||
id: device.deviceId,
|
||||
label: device.label,
|
||||
type: device.kind,
|
||||
}));
|
||||
|
||||
const filteredInput = mappedDevices.filter((device) => device.type === 'audioinput');
|
||||
|
||||
const filteredOutput = mappedDevices.filter((device) => device.type === 'audiooutput');
|
||||
|
||||
const audioInput = filteredInput.length > 0 ? filteredInput : [defaultDevices.defaultAudioInputDevice];
|
||||
const audioOutput = filteredOutput.length > 0 ? filteredOutput : [defaultDevices.defaultAudioOutputDevice];
|
||||
|
||||
return {
|
||||
audioInput,
|
||||
audioOutput,
|
||||
defaultAudioOutputDevice: audioOutput[0],
|
||||
defaultAudioInputDevice: audioInput[0],
|
||||
};
|
||||
},
|
||||
initialData: defaultDevices,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: permissionStatus } = useQuery({
|
||||
queryKey: [...devicesQueryKey, 'permission-status'],
|
||||
queryFn: async () => {
|
||||
if (!navigator.permissions) {
|
||||
return;
|
||||
}
|
||||
const result = await navigator.permissions.query({ name: 'microphone' as PermissionName });
|
||||
return result;
|
||||
},
|
||||
initialData: undefined,
|
||||
placeholderData: undefined,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!permissionStatus) {
|
||||
return;
|
||||
}
|
||||
const invalidateQueries = (): void => {
|
||||
queryClient.invalidateQueries({ queryKey: devicesQueryKey });
|
||||
};
|
||||
|
||||
permissionStatus.addEventListener('change', invalidateQueries);
|
||||
|
||||
return (): void => {
|
||||
permissionStatus.removeEventListener('change', invalidateQueries);
|
||||
};
|
||||
}, [permissionStatus, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !navigator.mediaDevices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidateQuery = (): void => {
|
||||
queryClient.invalidateQueries({ queryKey: devicesQueryKey, exact: true });
|
||||
};
|
||||
|
||||
navigator.mediaDevices.addEventListener('devicechange', invalidateQuery);
|
||||
|
||||
return (): void => {
|
||||
navigator.mediaDevices.removeEventListener('devicechange', invalidateQuery);
|
||||
};
|
||||
}, [enabled, queryClient]);
|
||||
|
||||
const contextValue = useMemo((): DeviceContextValue => {
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
const { audioInput, audioOutput, defaultAudioOutputDevice, defaultAudioInputDevice } = data;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
permissionStatus,
|
||||
availableAudioOutputDevices: audioOutput,
|
||||
availableAudioInputDevices: audioInput,
|
||||
selectedAudioOutputDevice: selectedAudioOutputDevice || defaultAudioOutputDevice,
|
||||
selectedAudioInputDevice: selectedAudioInputDevice || defaultAudioInputDevice,
|
||||
setAudioOutputDevice,
|
||||
setAudioInputDevice,
|
||||
};
|
||||
}, [enabled, data, permissionStatus, selectedAudioOutputDevice, selectedAudioInputDevice, setAudioOutputDevice]);
|
||||
|
||||
return <DeviceContext.Provider value={contextValue}>{children}</DeviceContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const isSetSinkIdAvailable = (): boolean => {
|
||||
const audio = new Audio();
|
||||
return !!audio.setSinkId;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
import EmojiPickerProvider from './EmojiPickerProvider';
|
||||
import { usePreviewEmoji } from '../../contexts/EmojiPickerContext';
|
||||
|
||||
jest.mock('@rocket.chat/fuselage-hooks', () => {
|
||||
const originalModule = jest.requireActual('@rocket.chat/fuselage-hooks');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
useDebouncedState: <T,>(initialValue: T) => useState<T>(initialValue),
|
||||
useLocalStorage: <T,>(_: string, initialValue: T) => useState<T>(initialValue),
|
||||
useStableCallback: (callback: (...args: any[]) => unknown) => callback,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../lib/emoji', () => ({
|
||||
emoji: { packages: { base: { emojisByCategory: { recent: [] } } } },
|
||||
getFrequentEmoji: jest.fn(() => []),
|
||||
createEmojiListByCategorySubscription: jest.fn(() => {
|
||||
const snapshot = [[], {}];
|
||||
return [() => () => undefined, () => snapshot];
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../views/composer/EmojiPicker', () => () => null);
|
||||
jest.mock('./useUpdateCustomEmoji', () => ({ useUpdateCustomEmoji: () => undefined }));
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <EmojiPickerProvider>{children}</EmojiPickerProvider>;
|
||||
|
||||
describe('EmojiPickerProvider', () => {
|
||||
it('should keep the same preview reference when receiving the same emoji and name', () => {
|
||||
const { result } = renderHook(() => usePreviewEmoji(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handlePreview('image://smile', 'smile');
|
||||
});
|
||||
|
||||
const firstPreview = result.current.emojiToPreview;
|
||||
expect(firstPreview).toEqual({ emoji: 'image://smile', name: 'smile' });
|
||||
|
||||
act(() => {
|
||||
result.current.handlePreview('image://smile', 'smile');
|
||||
});
|
||||
|
||||
expect(result.current.emojiToPreview).toBe(firstPreview);
|
||||
|
||||
act(() => {
|
||||
result.current.handlePreview('image://rocket', 'rocket');
|
||||
});
|
||||
|
||||
expect(result.current.emojiToPreview).toEqual({ emoji: 'image://rocket', name: 'rocket' });
|
||||
expect(result.current.emojiToPreview).not.toBe(firstPreview);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useDebouncedState, useStableCallback, useLocalStorage } from '@rocket.chat/fuselage-hooks';
|
||||
import type { ReactNode, ContextType } from 'react';
|
||||
import { useState, useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useUpdateCustomEmoji } from './useUpdateCustomEmoji';
|
||||
import { EmojiPickerContext } from '../../contexts/EmojiPickerContext';
|
||||
import { emoji, getFrequentEmoji, createEmojiListByCategorySubscription } from '../../lib/emoji';
|
||||
import EmojiPicker from '../../views/composer/EmojiPicker';
|
||||
|
||||
const DEFAULT_ITEMS_LIMIT = 90;
|
||||
|
||||
// limit recent emojis to 27 (3 rows of 9)
|
||||
const RECENT_EMOJIS_LIMIT = 27;
|
||||
|
||||
export type EmojiPickerProviderProps = { children: ReactNode };
|
||||
|
||||
const EmojiPickerProvider = ({ children }: EmojiPickerProviderProps) => {
|
||||
const [emojiPicker, setEmojiPicker] = useState<ReactNode>(null);
|
||||
const [emojiToPreview, setEmojiToPreview] = useDebouncedState<{ emoji: string; name: string } | null>(null, 100);
|
||||
const [recentEmojis, setRecentEmojis] = useLocalStorage<string[]>('emoji.recent', []);
|
||||
const [frequentEmojis, setFrequentEmojis] = useLocalStorage<[string, number][]>('emoji.frequent', []);
|
||||
|
||||
const [actualTone, setActualTone] = useLocalStorage('emoji.tone', 0);
|
||||
const [currentCategory, setCurrentCategory] = useState('recent');
|
||||
|
||||
const [customItemsLimit, setCustomItemsLimit] = useState(DEFAULT_ITEMS_LIMIT);
|
||||
|
||||
const [quickReactions, _setQuickReactions] = useState<{ emoji: string; image: string }[]>(() =>
|
||||
getFrequentEmoji(frequentEmojis.map(([emoji]) => emoji)),
|
||||
);
|
||||
|
||||
const setQuickReactions = useStableCallback(() => _setQuickReactions(getFrequentEmoji(frequentEmojis.map(([emoji]) => emoji))));
|
||||
const [sub, getSnapshot] = useMemo(() => {
|
||||
return createEmojiListByCategorySubscription(customItemsLimit, actualTone, recentEmojis, setRecentEmojis, setQuickReactions);
|
||||
}, [customItemsLimit, actualTone, recentEmojis, setRecentEmojis, setQuickReactions]);
|
||||
|
||||
const [emojiListByCategory, categoriesIndexes] = useSyncExternalStore(sub, getSnapshot);
|
||||
|
||||
useUpdateCustomEmoji();
|
||||
|
||||
const addFrequentEmojis = useCallback(
|
||||
(emoji: string) => {
|
||||
const empty: [string, number][] = frequentEmojis.some(([emojiName]) => emojiName === emoji) ? [] : [[emoji, 0]];
|
||||
|
||||
const sortedFrequent = [...empty, ...frequentEmojis]
|
||||
.map(([emojiName, count]) => {
|
||||
return emojiName === emoji
|
||||
? ([emojiName, Math.min(count + 5, 100)] as [string, number])
|
||||
: ([emojiName, Math.max(count - 1, 0)] as [string, number]);
|
||||
})
|
||||
.sort(([, frequentA], [, frequentB]) => frequentB - frequentA);
|
||||
|
||||
setFrequentEmojis(sortedFrequent);
|
||||
_setQuickReactions(getFrequentEmoji(sortedFrequent.map(([emoji]) => emoji)));
|
||||
},
|
||||
[frequentEmojis, setFrequentEmojis],
|
||||
);
|
||||
|
||||
const addRecentEmoji = useCallback(
|
||||
(_emoji: string) => {
|
||||
addFrequentEmojis(_emoji);
|
||||
|
||||
const recent = recentEmojis || [];
|
||||
const pos = recent.indexOf(_emoji);
|
||||
|
||||
if (pos !== -1) {
|
||||
recent.splice(pos, 1);
|
||||
}
|
||||
|
||||
recent.unshift(_emoji);
|
||||
|
||||
recent.splice(RECENT_EMOJIS_LIMIT);
|
||||
|
||||
// If this value is not cloned, the recent list will not be updated
|
||||
setRecentEmojis([...recent]);
|
||||
emoji.packages.base.emojisByCategory.recent = recent;
|
||||
},
|
||||
[recentEmojis, setRecentEmojis, addFrequentEmojis],
|
||||
);
|
||||
|
||||
const open = useCallback((ref: Element, callback: (emoji: string) => void) => {
|
||||
return setEmojiPicker(<EmojiPicker reference={ref} onClose={() => setEmojiPicker(null)} onPickEmoji={(emoji) => callback(emoji)} />);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => setEmojiPicker(null), []);
|
||||
|
||||
const handlePreview = useCallback(
|
||||
(emoji: string, name: string) =>
|
||||
setEmojiToPreview((preview) => {
|
||||
if (preview?.emoji === emoji && preview?.name === name) {
|
||||
return preview;
|
||||
}
|
||||
return { emoji, name };
|
||||
}),
|
||||
[setEmojiToPreview],
|
||||
);
|
||||
|
||||
const handleRemovePreview = useCallback(() => setEmojiToPreview(null), [setEmojiToPreview]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
(): ContextType<typeof EmojiPickerContext> => ({
|
||||
isOpen: emojiPicker !== null,
|
||||
close,
|
||||
open,
|
||||
emojiToPreview,
|
||||
handlePreview,
|
||||
handleRemovePreview,
|
||||
addRecentEmoji,
|
||||
emojiListByCategory,
|
||||
recentEmojis,
|
||||
setRecentEmojis,
|
||||
actualTone,
|
||||
currentCategory,
|
||||
setCurrentCategory,
|
||||
categoriesIndexes,
|
||||
customItemsLimit,
|
||||
setCustomItemsLimit,
|
||||
setActualTone,
|
||||
quickReactions,
|
||||
}),
|
||||
[
|
||||
emojiPicker,
|
||||
open,
|
||||
close,
|
||||
emojiToPreview,
|
||||
addRecentEmoji,
|
||||
emojiListByCategory,
|
||||
categoriesIndexes,
|
||||
recentEmojis,
|
||||
setRecentEmojis,
|
||||
actualTone,
|
||||
currentCategory,
|
||||
setCurrentCategory,
|
||||
customItemsLimit,
|
||||
setActualTone,
|
||||
quickReactions,
|
||||
handlePreview,
|
||||
handleRemovePreview,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<EmojiPickerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{emojiPicker}
|
||||
</EmojiPickerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPickerProvider;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './EmojiPickerProvider';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useStream, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { updateEmojiCustom, deleteEmojiCustom } from '../../lib/customEmoji';
|
||||
|
||||
export const useUpdateCustomEmoji = () => {
|
||||
const notify = useStream('notify-logged');
|
||||
const uid = useUserId();
|
||||
useEffect(() => {
|
||||
if (!uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubUpdate = notify('updateEmojiCustom', (data) => updateEmojiCustom(data.emojiData));
|
||||
const unsubDelete = notify('deleteEmojiCustom', (data) => deleteEmojiCustom(data.emojiData));
|
||||
|
||||
return () => {
|
||||
unsubUpdate();
|
||||
unsubDelete();
|
||||
};
|
||||
}, [notify, uid]);
|
||||
};
|
||||
53
apps/meteor/client/providers/ImageGalleryProvider.tsx
Normal file
53
apps/meteor/client/providers/ImageGalleryProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { ImageGallery } from '../components/ImageGallery';
|
||||
import { ImageGalleryContext } from '../contexts/ImageGalleryContext';
|
||||
import ImageGalleryData from '../views/room/ImageGallery/ImageGalleryData';
|
||||
|
||||
export type ImageGalleryProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ImageGalleryProvider = ({ children }: ImageGalleryProviderProps) => {
|
||||
const [imageId, setImageId] = useState<string>();
|
||||
const [singleImageUrl, setSingleImageUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const handleImageClick = (event: Event) => {
|
||||
const target = event?.target as HTMLElement | null;
|
||||
|
||||
if (target?.closest('.rcx-attachment__details')) {
|
||||
return setSingleImageUrl(target.dataset.id);
|
||||
}
|
||||
if (target?.classList.contains('preview-image')) {
|
||||
return setSingleImageUrl(target.dataset.id);
|
||||
}
|
||||
if (target?.classList.contains('gallery-item')) {
|
||||
const id = target.closest('.gallery-item-container')?.getAttribute('data-id') || undefined;
|
||||
return setImageId(target.dataset.id || id);
|
||||
}
|
||||
if (target?.classList.contains('gallery-item-container')) {
|
||||
return setImageId(target.dataset.id);
|
||||
}
|
||||
if (target?.classList.contains('rcx-avatar__element') && target.closest('.gallery-item')) {
|
||||
const avatarTarget = target.closest('.gallery-item-container')?.getAttribute('data-id') || undefined;
|
||||
return setImageId(avatarTarget);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleImageClick);
|
||||
|
||||
return () => document.removeEventListener('click', handleImageClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ImageGalleryContext.Provider value={{ imageId: imageId || '', isOpen: !!imageId, onClose: () => setImageId(undefined) }}>
|
||||
{children}
|
||||
{!!singleImageUrl && (
|
||||
<ImageGallery images={[{ _id: singleImageUrl, url: singleImageUrl }]} onClose={() => setSingleImageUrl(undefined)} />
|
||||
)}
|
||||
{!!imageId && <ImageGalleryData />}
|
||||
</ImageGalleryContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGalleryProvider;
|
||||
116
apps/meteor/client/providers/LayoutProvider.tsx
Normal file
116
apps/meteor/client/providers/LayoutProvider.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
|
||||
import { useFeaturePreview } from '@rocket.chat/ui-client';
|
||||
import { LayoutContext, useRouter, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
|
||||
const hiddenActionsDefaultValue = {
|
||||
roomToolbox: [],
|
||||
messageToolbox: [],
|
||||
composerToolbox: [],
|
||||
userToolbox: [],
|
||||
};
|
||||
|
||||
export type LayoutProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const LayoutProvider = ({ children }: LayoutProviderProps) => {
|
||||
const showTopNavbarEmbeddedLayout = useSetting('UI_Show_top_navbar_embedded_layout', false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [displaySidePanel, setDisplaySidePanel] = useState(true);
|
||||
const [overlayed, setOverlayed] = useState(false);
|
||||
const [navBarSearchExpanded, setNavBarSearchExpanded] = useState(false);
|
||||
const breakpoints = useBreakpoints(); // ["xs", "sm", "md", "lg", "xl", xxl"]
|
||||
const [hiddenActions, setHiddenActions] = useState(hiddenActionsDefaultValue);
|
||||
const secondSidebarEnabled = useFeaturePreview('secondarySidebar');
|
||||
|
||||
const router = useRouter();
|
||||
// Once the layout is embedded, it can't be changed
|
||||
const [isEmbedded] = useState(() => router.getSearchParameters().layout === 'embedded');
|
||||
|
||||
const isMobile = !breakpoints.includes('md');
|
||||
const isTablet = !breakpoints.includes('lg');
|
||||
|
||||
const shouldToggle = secondSidebarEnabled ? isTablet || isMobile : isMobile;
|
||||
const shouldDisplaySidePanel = !isTablet || displaySidePanel;
|
||||
const defaultSidebarWidth = secondSidebarEnabled ? '220px' : '240px';
|
||||
|
||||
useEffect(() => {
|
||||
setIsCollapsed(shouldToggle);
|
||||
}, [shouldToggle]);
|
||||
|
||||
useEffect(() => {
|
||||
const eventHandler = (event: MessageEvent<any>) => {
|
||||
if (event.data?.event !== 'overrideUi') {
|
||||
return;
|
||||
}
|
||||
|
||||
setHiddenActions({ ...hiddenActionsDefaultValue, ...event.data.hideActions });
|
||||
};
|
||||
window.addEventListener('message', eventHandler);
|
||||
return () => window.removeEventListener('message', eventHandler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
isMobile,
|
||||
isTablet,
|
||||
isEmbedded,
|
||||
showTopNavbarEmbeddedLayout,
|
||||
navbar: {
|
||||
searchExpanded: navBarSearchExpanded,
|
||||
expandSearch: isMobile ? () => setNavBarSearchExpanded(true) : undefined,
|
||||
collapseSearch: isMobile ? () => setNavBarSearchExpanded(false) : undefined,
|
||||
},
|
||||
sidebar: {
|
||||
overlayed,
|
||||
setOverlayed,
|
||||
isCollapsed,
|
||||
shouldToggle,
|
||||
toggle: shouldToggle ? () => setIsCollapsed((isCollapsed) => !isCollapsed) : () => undefined,
|
||||
collapse: () => setIsCollapsed(true),
|
||||
expand: () => setIsCollapsed(false),
|
||||
close: () => (isEmbedded ? setIsCollapsed(true) : router.navigate('/home')),
|
||||
},
|
||||
sidePanel: {
|
||||
displaySidePanel: shouldDisplaySidePanel,
|
||||
closeSidePanel: () => setDisplaySidePanel(false),
|
||||
openSidePanel: () => setDisplaySidePanel(true),
|
||||
},
|
||||
size: {
|
||||
sidebar: shouldToggle ? '280px' : defaultSidebarWidth,
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
contextualBar: breakpoints.includes('sm') ? (breakpoints.includes('xl') ? '38%' : '380px') : '100%',
|
||||
},
|
||||
roomToolboxExpanded: breakpoints.includes('lg'),
|
||||
contextualBarExpanded: breakpoints.includes('sm'),
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
contextualBarPosition: breakpoints.includes('sm') ? (breakpoints.includes('lg') ? 'relative' : 'absolute') : 'fixed',
|
||||
hiddenActions,
|
||||
}),
|
||||
[
|
||||
isMobile,
|
||||
isTablet,
|
||||
isEmbedded,
|
||||
showTopNavbarEmbeddedLayout,
|
||||
navBarSearchExpanded,
|
||||
overlayed,
|
||||
isCollapsed,
|
||||
shouldToggle,
|
||||
shouldDisplaySidePanel,
|
||||
defaultSidebarWidth,
|
||||
breakpoints,
|
||||
hiddenActions,
|
||||
router,
|
||||
],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutProvider;
|
||||
20
apps/meteor/client/providers/MediaCallProvider.tsx
Normal file
20
apps/meteor/client/providers/MediaCallProvider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
import { MediaCallProvider as MediaCallProviderBase } from '@rocket.chat/ui-voip';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useHasLicenseModule } from '../hooks/useHasLicenseModule';
|
||||
|
||||
export type MediaCallProviderProps = { children: ReactNode };
|
||||
|
||||
const MediaCallProvider = ({ children }: MediaCallProviderProps) => {
|
||||
const canMakeInternalCall = usePermission('allow-internal-voice-calls');
|
||||
const canMakeExternalCall = usePermission('allow-external-voice-calls');
|
||||
|
||||
const { data: hasModule = false } = useHasLicenseModule('teams-voip');
|
||||
|
||||
const enabled = hasModule && (canMakeInternalCall || canMakeExternalCall);
|
||||
|
||||
return <MediaCallProviderBase enabled={enabled}>{children}</MediaCallProviderBase>;
|
||||
};
|
||||
|
||||
export default MediaCallProvider;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* Describes a single audio track owned by the shared player.
|
||||
* The descriptor is self-contained so the player keeps working after the message
|
||||
* (and the room) that originated it has been unmounted — the underlying `<audio>`
|
||||
* element lives in the provider and is never recreated on navigation.
|
||||
*/
|
||||
export type PersistentAudioTrack = {
|
||||
/** Stable identity of the track, e.g. `${mid}:${url}`. */
|
||||
id: string;
|
||||
/** Resolved, ready-to-play media URL. */
|
||||
url: string;
|
||||
/** MIME type of the media, when known. */
|
||||
mediaType?: string;
|
||||
/** File name shown in the player. */
|
||||
title: string;
|
||||
/** File size in bytes, when known. */
|
||||
size?: number;
|
||||
/** Room the audio was sent in (enables "jump back to conversation"). */
|
||||
rid?: string;
|
||||
/** Message the audio belongs to (enables jump-to-message). */
|
||||
mid?: string;
|
||||
/** Username of the sender (drives the avatar). */
|
||||
username?: string;
|
||||
/** Display name of the sender. */
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type MediaPlayerContextValue = {
|
||||
track: PersistentAudioTrack | null;
|
||||
playing: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
/** Loads (only if a different track) and plays the given track in the shared element. */
|
||||
play: (track: PersistentAudioTrack) => void;
|
||||
/** Toggles play/pause for the active track. No-op when no track is active. */
|
||||
toggle: () => void;
|
||||
/** Seeks the active track to `time` seconds. */
|
||||
seek: (time: number) => void;
|
||||
/** Cycles the playback rate 1x → 1.5x → 2x → 1x. */
|
||||
cyclePlaybackRate: () => void;
|
||||
/** Stops playback and clears the active track. */
|
||||
close: () => void;
|
||||
/** Whether the given track id is the one currently owned by the shared element. */
|
||||
isActive: (id: string) => boolean;
|
||||
};
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
export const MediaPlayerContext = createContext<MediaPlayerContextValue>({
|
||||
track: null,
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playbackRate: 1,
|
||||
play: noop,
|
||||
toggle: noop,
|
||||
seek: noop,
|
||||
cyclePlaybackRate: noop,
|
||||
close: noop,
|
||||
isActive: () => false,
|
||||
});
|
||||
|
||||
export const useMediaPlayer = (): MediaPlayerContextValue => useContext(MediaPlayerContext);
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMergedRefs, useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { MediaPlayerContextValue, PersistentAudioTrack } from './MediaPlayerContext';
|
||||
import { MediaPlayerContext } from './MediaPlayerContext';
|
||||
import { useReloadOnError } from '../../components/message/content/attachments/file/hooks/useReloadOnError';
|
||||
|
||||
const PLAYBACK_RATES = [1, 1.5, 2] as const;
|
||||
|
||||
export type MediaPlayerProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns the single, app-wide `<audio>` element used to play message audio
|
||||
* attachments. Because the element lives above the room layout and is never
|
||||
* recreated, both the in-message controls and the sidebar card drive the very
|
||||
* same element: switching or closing the room only swaps which UI is shown — the
|
||||
* element keeps playing with no reload, seek, or gap.
|
||||
*/
|
||||
const MediaPlayerProvider = ({ children }: MediaPlayerProviderProps) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const [track, setTrack] = useState<PersistentAudioTrack | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [playbackRate, setPlaybackRate] = useState<number>(1);
|
||||
|
||||
const trackRef = useRef<PersistentAudioTrack | null>(null);
|
||||
trackRef.current = track;
|
||||
|
||||
// Reuse the message player's signed-URL recovery on the shared element.
|
||||
const { mediaRef } = useReloadOnError(track?.url ?? '', 'audio');
|
||||
const audioCallback = useCallback((node: HTMLAudioElement | null) => {
|
||||
audioRef.current = node;
|
||||
}, []);
|
||||
const setAudioRef = useMergedRefs(audioCallback, mediaRef);
|
||||
|
||||
const play = useStableCallback((next: PersistentAudioTrack) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trackRef.current?.id !== next.id) {
|
||||
setTrack(next);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
audio.src = next.url;
|
||||
audio.load();
|
||||
}
|
||||
|
||||
audio.playbackRate = playbackRate;
|
||||
audio.play().catch((err) => console.warn('Failed to start audio playback:', err));
|
||||
});
|
||||
|
||||
const toggle = useStableCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !trackRef.current) {
|
||||
return;
|
||||
}
|
||||
if (audio.paused) {
|
||||
audio.play().catch((err) => console.warn('Failed to resume audio playback:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
});
|
||||
|
||||
const seek = useStableCallback((time: number) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
audio.currentTime = Math.max(0, Math.min(time, audio.duration || time));
|
||||
});
|
||||
|
||||
const cyclePlaybackRate = useStableCallback(() => {
|
||||
setPlaybackRate((rate) => {
|
||||
const idx = PLAYBACK_RATES.indexOf(rate as (typeof PLAYBACK_RATES)[number]);
|
||||
const nextRate = PLAYBACK_RATES[(idx + 1) % PLAYBACK_RATES.length];
|
||||
if (audioRef.current) {
|
||||
audioRef.current.playbackRate = nextRate;
|
||||
}
|
||||
return nextRate;
|
||||
});
|
||||
});
|
||||
|
||||
const close = useStableCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
}
|
||||
setTrack(null);
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
});
|
||||
|
||||
const isActive = useCallback((id: string) => trackRef.current?.id === id, []);
|
||||
|
||||
const value = useMemo<MediaPlayerContextValue>(
|
||||
() => ({ track, playing, currentTime, duration, playbackRate, play, toggle, seek, cyclePlaybackRate, close, isActive }),
|
||||
[track, playing, currentTime, duration, playbackRate, play, toggle, seek, cyclePlaybackRate, close, isActive],
|
||||
);
|
||||
|
||||
return (
|
||||
<MediaPlayerContext.Provider value={value}>
|
||||
{children}
|
||||
<audio
|
||||
ref={setAudioRef}
|
||||
hidden
|
||||
preload='metadata'
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => close()}
|
||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
|
||||
onDurationChange={(e) => setDuration(e.currentTarget.duration || 0)}
|
||||
onRateChange={(e) => setPlaybackRate(e.currentTarget.playbackRate)}
|
||||
>
|
||||
<track kind='captions' />
|
||||
</audio>
|
||||
</MediaPlayerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaPlayerProvider;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './MediaPlayerProvider';
|
||||
export { MediaPlayerContext, useMediaPlayer } from './MediaPlayerContext';
|
||||
export type { MediaPlayerContextValue, PersistentAudioTrack } from './MediaPlayerContext';
|
||||
75
apps/meteor/client/providers/MeteorProvider.tsx
Normal file
75
apps/meteor/client/providers/MeteorProvider.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ModalProvider, TooltipProvider } from '@rocket.chat/ui-client';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import ActionManagerProvider from './ActionManagerProvider';
|
||||
import AuthenticationProvider from './AuthenticationProvider/AuthenticationProvider';
|
||||
import AuthorizationProvider from './AuthorizationProvider';
|
||||
import AvatarUrlProvider from './AvatarUrlProvider';
|
||||
import CustomSoundProvider from './CustomSoundProvider';
|
||||
import { DeviceProvider } from './DeviceProvider/DeviceProvider';
|
||||
import EmojiPickerProvider from './EmojiPickerProvider';
|
||||
import LayoutProvider from './LayoutProvider';
|
||||
import MediaCallProvider from './MediaCallProvider';
|
||||
import OmnichannelProvider from './OmnichannelProvider';
|
||||
import RouterProvider from './RouterProvider';
|
||||
import ServerProvider from './ServerProvider';
|
||||
import SessionProvider from './SessionProvider';
|
||||
import SettingsProvider from './SettingsProvider';
|
||||
import ToastMessagesProvider from './ToastMessagesProvider';
|
||||
import TranslationProvider from './TranslationProvider';
|
||||
import UserPresenceProvider from './UserPresenceProvider';
|
||||
import UserProvider from './UserProvider';
|
||||
import VideoConfProvider from './VideoConfProvider';
|
||||
import { OmnichannelRoomIconProvider } from '../components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider';
|
||||
|
||||
export type MeteorProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const MeteorProvider = ({ children }: MeteorProviderProps) => (
|
||||
<ServerProvider>
|
||||
<RouterProvider>
|
||||
<ModalProvider>
|
||||
<SettingsProvider>
|
||||
<TranslationProvider>
|
||||
<SessionProvider>
|
||||
<TooltipProvider>
|
||||
<ToastMessagesProvider>
|
||||
<AvatarUrlProvider>
|
||||
<UserProvider>
|
||||
<LayoutProvider>
|
||||
<AuthenticationProvider>
|
||||
<CustomSoundProvider>
|
||||
<DeviceProvider>
|
||||
<AuthorizationProvider>
|
||||
<EmojiPickerProvider>
|
||||
<OmnichannelRoomIconProvider>
|
||||
<UserPresenceProvider>
|
||||
<ActionManagerProvider>
|
||||
<VideoConfProvider>
|
||||
<MediaCallProvider>
|
||||
<OmnichannelProvider>{children}</OmnichannelProvider>
|
||||
</MediaCallProvider>
|
||||
</VideoConfProvider>
|
||||
</ActionManagerProvider>
|
||||
</UserPresenceProvider>
|
||||
</OmnichannelRoomIconProvider>
|
||||
</EmojiPickerProvider>
|
||||
</AuthorizationProvider>
|
||||
</DeviceProvider>
|
||||
</CustomSoundProvider>
|
||||
</AuthenticationProvider>
|
||||
</LayoutProvider>
|
||||
</UserProvider>
|
||||
</AvatarUrlProvider>
|
||||
</ToastMessagesProvider>
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
</TranslationProvider>
|
||||
</SettingsProvider>
|
||||
</ModalProvider>
|
||||
</RouterProvider>
|
||||
</ServerProvider>
|
||||
);
|
||||
|
||||
export default MeteorProvider;
|
||||
226
apps/meteor/client/providers/OmnichannelProvider.tsx
Normal file
226
apps/meteor/client/providers/OmnichannelProvider.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
type IOmnichannelAgent,
|
||||
type OmichannelRoutingConfig,
|
||||
OmnichannelSortingMechanismSettingType,
|
||||
LivechatInquiryStatus,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { useSafely } from '@rocket.chat/fuselage-hooks';
|
||||
import { createComparatorFromSort } from '@rocket.chat/mongo-adapter';
|
||||
import { useUser, useSetting, usePermission, useEndpoint, useStream, useCustomSound } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, useEffect, useMemo, memo, useRef } from 'react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
import { getOmniChatSortQuery } from '../../app/livechat/lib/inquiries';
|
||||
import { ClientLogger } from '../../lib/ClientLogger';
|
||||
import type { OmnichannelContextValue } from '../contexts/OmnichannelContext';
|
||||
import { OmnichannelContext } from '../contexts/OmnichannelContext';
|
||||
import { useHasLicenseModule } from '../hooks/useHasLicenseModule';
|
||||
import { useLivechatInquiryStore } from '../hooks/useLivechatInquiryStore';
|
||||
import { useOmnichannelContinuousSoundNotification } from '../hooks/useOmnichannelContinuousSoundNotification';
|
||||
import { useShouldPreventAction } from '../hooks/useShouldPreventAction';
|
||||
import { initializeLivechatInquiryStream } from '../lib/omnichannel/queueManager';
|
||||
|
||||
const emptyContextValue: OmnichannelContextValue = {
|
||||
inquiries: { enabled: false },
|
||||
enabled: false,
|
||||
isEnterprise: false,
|
||||
agentAvailable: false,
|
||||
showOmnichannelQueueLink: false,
|
||||
isOverMacLimit: false,
|
||||
livechatPriorities: {
|
||||
enabled: false,
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
},
|
||||
};
|
||||
|
||||
export type OmnichannelProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const OmnichannelProvider = ({ children }: OmnichannelProviderProps) => {
|
||||
const omniChannelEnabled = useSetting('Livechat_enabled', true);
|
||||
const omnichannelRouting = useSetting('Livechat_Routing_Method', 'Auto_Selection');
|
||||
const showOmnichannelQueueLink = useSetting('Livechat_show_queue_list_link', false);
|
||||
const omnichannelPoolMaxIncoming = useSetting('Livechat_guest_pool_max_number_incoming_livechats_displayed', 0);
|
||||
const omnichannelSortingMechanism = useSetting<OmnichannelSortingMechanismSettingType>(
|
||||
'Omnichannel_sorting_mechanism',
|
||||
OmnichannelSortingMechanismSettingType.Timestamp,
|
||||
);
|
||||
|
||||
const lastQueueSize = useRef(0);
|
||||
|
||||
const loggerRef = useRef(new ClientLogger('OmnichannelProvider'));
|
||||
const hasAccess = usePermission('view-l-room');
|
||||
const canViewOmnichannelQueue = usePermission('view-livechat-queue');
|
||||
const user = useUser() as IOmnichannelAgent;
|
||||
|
||||
const agentAvailable = user?.statusLivechat === 'available';
|
||||
|
||||
const getRoutingConfig = useEndpoint('GET', '/v1/livechat/config/routing');
|
||||
|
||||
const [routeConfig, setRouteConfig] = useSafely(useState<OmichannelRoutingConfig | undefined>(undefined));
|
||||
|
||||
const accessible = hasAccess && omniChannelEnabled;
|
||||
const { data: isEnterprise = false } = useHasLicenseModule('livechat-enterprise');
|
||||
|
||||
const getPriorities = useEndpoint('GET', '/v1/livechat/priorities');
|
||||
const subscribe = useStream('notify-logged');
|
||||
const queryClient = useQueryClient();
|
||||
const isPrioritiesEnabled = isEnterprise && accessible;
|
||||
const enabled = accessible && !!user && !!routeConfig;
|
||||
|
||||
const { notificationSounds } = useCustomSound();
|
||||
|
||||
const {
|
||||
data: { priorities = [] } = {},
|
||||
isLoading: isLoadingPriorities,
|
||||
isError: isErrorPriorities,
|
||||
} = useQuery({
|
||||
queryKey: ['/v1/livechat/priorities'],
|
||||
queryFn: () => getPriorities({ sort: JSON.stringify({ sortItem: 1 }) }),
|
||||
staleTime: Infinity,
|
||||
enabled: isPrioritiesEnabled,
|
||||
});
|
||||
|
||||
const isOverMacLimit = useShouldPreventAction('monthlyActiveContacts');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPrioritiesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subscribe('omnichannel.priority-changed', () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['/v1/livechat/priorities'],
|
||||
});
|
||||
});
|
||||
}, [isPrioritiesEnabled, queryClient, subscribe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = async (): Promise<void> => {
|
||||
try {
|
||||
const { config } = await getRoutingConfig();
|
||||
setRouteConfig(config);
|
||||
} catch (error) {
|
||||
loggerRef.current.error(`update() error in routeConfig ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (omnichannelRouting || !omnichannelRouting) {
|
||||
update();
|
||||
}
|
||||
}, [accessible, getRoutingConfig, omnichannelRouting, setRouteConfig]);
|
||||
|
||||
const manuallySelected =
|
||||
enabled && canViewOmnichannelQueue && !!routeConfig && routeConfig.showQueue && !routeConfig.autoAssignAgent && agentAvailable;
|
||||
|
||||
const streamNotifyUser = useStream('notify-user');
|
||||
useEffect(() => {
|
||||
if (!manuallySelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleDepartmentAgentData = (): void => {
|
||||
initializeLivechatInquiryStream(user?._id);
|
||||
};
|
||||
|
||||
initializeLivechatInquiryStream(user?._id);
|
||||
if (!user?._id) {
|
||||
return;
|
||||
}
|
||||
return streamNotifyUser(`${user._id}/departmentAgentData`, handleDepartmentAgentData);
|
||||
}, [manuallySelected, streamNotifyUser, user?._id]);
|
||||
|
||||
const queue = useLivechatInquiryStore(
|
||||
useShallow((state) => {
|
||||
if (!manuallySelected) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return state.records
|
||||
.filter((inquiry) => inquiry.status === LivechatInquiryStatus.QUEUED)
|
||||
.sort(createComparatorFromSort(getOmniChatSortQuery(omnichannelSortingMechanism)))
|
||||
.slice(...(omnichannelPoolMaxIncoming > 0 ? [0, omnichannelPoolMaxIncoming] : []));
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastQueueSize.current < (queue?.length ?? 0)) {
|
||||
notificationSounds.playNewRoom();
|
||||
}
|
||||
lastQueueSize.current = queue?.length ?? 0;
|
||||
|
||||
return () => {
|
||||
notificationSounds.stopNewRoom();
|
||||
};
|
||||
}, [notificationSounds, queue?.length]);
|
||||
|
||||
useOmnichannelContinuousSoundNotification(queue ?? []);
|
||||
|
||||
const contextValue = useMemo<OmnichannelContextValue>(() => {
|
||||
if (!enabled) {
|
||||
return emptyContextValue;
|
||||
}
|
||||
|
||||
const livechatPriorities = {
|
||||
enabled: isPrioritiesEnabled,
|
||||
data: priorities,
|
||||
isLoading: isLoadingPriorities,
|
||||
isError: isErrorPriorities,
|
||||
};
|
||||
|
||||
if (!manuallySelected) {
|
||||
return {
|
||||
...emptyContextValue,
|
||||
enabled: true,
|
||||
isEnterprise,
|
||||
agentAvailable,
|
||||
routeConfig,
|
||||
livechatPriorities,
|
||||
isOverMacLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...emptyContextValue,
|
||||
enabled: true,
|
||||
isEnterprise,
|
||||
agentAvailable,
|
||||
routeConfig,
|
||||
inquiries: queue
|
||||
? {
|
||||
enabled: true,
|
||||
queue,
|
||||
}
|
||||
: { enabled: false },
|
||||
showOmnichannelQueueLink: showOmnichannelQueueLink && !!agentAvailable,
|
||||
livechatPriorities,
|
||||
isOverMacLimit,
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
isPrioritiesEnabled,
|
||||
priorities,
|
||||
isLoadingPriorities,
|
||||
isErrorPriorities,
|
||||
manuallySelected,
|
||||
isEnterprise,
|
||||
agentAvailable,
|
||||
routeConfig,
|
||||
queue,
|
||||
showOmnichannelQueueLink,
|
||||
isOverMacLimit,
|
||||
]);
|
||||
|
||||
return <OmnichannelContext.Provider value={contextValue}>{children}</OmnichannelContext.Provider>;
|
||||
};
|
||||
|
||||
export default memo<typeof OmnichannelProvider>(OmnichannelProvider);
|
||||
21
apps/meteor/client/providers/RouterProvider.tsx
Normal file
21
apps/meteor/client/providers/RouterProvider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { RouterContext } from '@rocket.chat/ui-contexts';
|
||||
import type { RouterContextValue } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useRouterScrollToHash } from '../hooks/useRouterScrollToHash';
|
||||
import { Router } from '../router';
|
||||
|
||||
/** @deprecated consume it from the `RouterContext` instead */
|
||||
export const router: RouterContextValue = new Router();
|
||||
|
||||
export type RouterProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const RouterProvider = ({ children }: RouterProviderProps) => {
|
||||
useRouterScrollToHash(router);
|
||||
|
||||
return <RouterContext.Provider value={router}>{children}</RouterContext.Provider>;
|
||||
};
|
||||
|
||||
export default RouterProvider;
|
||||
209
apps/meteor/client/providers/ServerProvider.tsx
Normal file
209
apps/meteor/client/providers/ServerProvider.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { Serialized } from '@rocket.chat/core-typings';
|
||||
import type {
|
||||
ServerMethodName,
|
||||
ServerMethodParameters,
|
||||
ServerMethodReturn,
|
||||
StreamerCallbackArgs,
|
||||
StreamerEvents,
|
||||
StreamNames,
|
||||
StreamKeys,
|
||||
} from '@rocket.chat/ddp-client';
|
||||
import type { Method, PathFor, OperationParams, OperationResult, UrlParams, PathPattern } from '@rocket.chat/rest-typings';
|
||||
import type { UploadResult, ServerContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { ServerContext } from '@rocket.chat/ui-contexts';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { compile } from 'path-to-regexp';
|
||||
import { useMemo, useSyncExternalStore, type ReactNode } from 'react';
|
||||
|
||||
import { Info as info } from '../../app/utils/rocketchat.info';
|
||||
import { sdk } from '../lib/SDKClient';
|
||||
import { absoluteUrl } from '../lib/absoluteUrl';
|
||||
import { ensureConnectedAndAuthenticated, getDdpSdk } from '../lib/sdk/ddpSdk';
|
||||
import { isSdkTransportEnabled } from '../lib/sdk/sdkTransportEnabled';
|
||||
|
||||
const sdkTransportEnabled = isSdkTransportEnabled();
|
||||
|
||||
const callMethod = <MethodName extends ServerMethodName>(
|
||||
methodName: MethodName,
|
||||
...args: ServerMethodParameters<MethodName>
|
||||
): Promise<ServerMethodReturn<MethodName>> => sdk.call(methodName, ...(args as any)) as Promise<ServerMethodReturn<MethodName>>;
|
||||
|
||||
const callEndpoint = <TMethod extends Method, TPathPattern extends PathPattern>({
|
||||
method,
|
||||
pathPattern,
|
||||
keys,
|
||||
params,
|
||||
signal,
|
||||
}: {
|
||||
method: TMethod;
|
||||
pathPattern: TPathPattern;
|
||||
keys: UrlParams<TPathPattern>;
|
||||
params: OperationParams<TMethod, TPathPattern>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Serialized<OperationResult<TMethod, TPathPattern>>> => {
|
||||
const compiledPath = compile(pathPattern, { encode: encodeURIComponent })(keys) as any;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unnecessary-type-assertion
|
||||
return sdk.rest.get(compiledPath, params as any, { signal }) as any;
|
||||
|
||||
case 'POST':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unnecessary-type-assertion
|
||||
return sdk.rest.post(compiledPath, params as any, { signal }) as any;
|
||||
|
||||
case 'PUT':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
return sdk.rest.put(compiledPath, params as never, { signal }) as never;
|
||||
|
||||
case 'DELETE':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unnecessary-type-assertion
|
||||
return sdk.rest.delete(compiledPath, params as any, { signal }) as any;
|
||||
|
||||
default:
|
||||
throw new Error('Invalid HTTP method');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadToEndpoint = (endpoint: PathFor<'POST'>, formData: any): Promise<UploadResult> => sdk.rest.post(endpoint as any, formData);
|
||||
|
||||
const getStream =
|
||||
<N extends StreamNames>(
|
||||
streamName: N,
|
||||
_options?: {
|
||||
retransmit?: boolean | undefined;
|
||||
retransmitToSelf?: boolean | undefined;
|
||||
},
|
||||
) =>
|
||||
<K extends StreamKeys<N>>(eventName: K, callback: (...args: StreamerCallbackArgs<N, K>) => void): (() => void) =>
|
||||
sdk.stream(streamName, [eventName], callback).stop;
|
||||
|
||||
const getStreamAll =
|
||||
<N extends StreamNames>(streamName: N) =>
|
||||
(callback: (eventName: string, args: StreamerEvents[N][number]['args']) => void): (() => void) =>
|
||||
sdk.onAnyStreamEvent(streamName, callback).stop;
|
||||
|
||||
const writeStream = <N extends StreamNames, K extends StreamKeys<N>>(streamName: N, streamKey: K, ...args: StreamerCallbackArgs<N, K>) =>
|
||||
sdk.publish(streamName, [streamKey, ...args]);
|
||||
|
||||
const disconnect = sdkTransportEnabled
|
||||
? () => {
|
||||
Meteor.disconnect();
|
||||
try {
|
||||
getDdpSdk().connection.close();
|
||||
} catch {
|
||||
// no-op — DDPSDK may not be connected yet
|
||||
}
|
||||
}
|
||||
: () => Meteor.disconnect();
|
||||
|
||||
const reconnect = sdkTransportEnabled
|
||||
? () => {
|
||||
Meteor.reconnect();
|
||||
// ensureConnectedAndAuthenticated handles both 'connect' and loginWithToken,
|
||||
// so reconnecting here also re-establishes the DDPSDK session with the
|
||||
// same token Meteor resumes with.
|
||||
void ensureConnectedAndAuthenticated();
|
||||
}
|
||||
: () => Meteor.reconnect();
|
||||
|
||||
type CombinedStatus = ReturnType<typeof Meteor.status>;
|
||||
|
||||
const sdkStatusToMeteor = (sdkStatus: string, meteor: CombinedStatus): CombinedStatus => {
|
||||
const retry = { retryCount: meteor.retryCount, retryTime: meteor.retryTime };
|
||||
|
||||
switch (sdkStatus) {
|
||||
case 'connected':
|
||||
return { status: 'connected', connected: true, ...retry };
|
||||
case 'connecting':
|
||||
return { status: 'connecting', connected: false, ...retry };
|
||||
case 'reconnecting':
|
||||
return { status: 'connecting', connected: false, ...retry };
|
||||
case 'failed':
|
||||
return { status: 'failed', connected: false, ...retry };
|
||||
case 'closed':
|
||||
case 'disconnected':
|
||||
return { status: 'waiting', connected: false, ...retry };
|
||||
case 'idle':
|
||||
default:
|
||||
return { status: 'offline', connected: false, ...retry };
|
||||
}
|
||||
};
|
||||
|
||||
// With SDK transport on, combine Meteor's DDP status with DDPSDK's so the
|
||||
// ConnectionStatusBar / idle-connection hooks reflect the worst-case of both
|
||||
// transports. With the flag off, route status straight through Meteor —
|
||||
// `meteorBackedSdk` already bridges Meteor's `_stream` events into
|
||||
// `sdk.connection.on('connection')`, so the same subscription works in both
|
||||
// modes.
|
||||
const computeStatus: () => CombinedStatus = sdkTransportEnabled
|
||||
? () => sdkStatusToMeteor(getDdpSdk().connection.status, Meteor.status())
|
||||
: () => ({ ...Meteor.status() });
|
||||
|
||||
const isStatusEqual = (a: CombinedStatus, b: CombinedStatus): boolean =>
|
||||
a.status === b.status && a.connected === b.connected && a.retryCount === b.retryCount && a.retryTime === b.retryTime;
|
||||
|
||||
let cachedStatus: CombinedStatus = computeStatus();
|
||||
const statusListeners = new Set<() => void>();
|
||||
let statusBridgeStarted = false;
|
||||
|
||||
const ensureStatusBridge = (): void => {
|
||||
if (statusBridgeStarted) return;
|
||||
statusBridgeStarted = true;
|
||||
getDdpSdk().connection.on('connection', () => {
|
||||
const next = computeStatus();
|
||||
if (isStatusEqual(cachedStatus, next)) return;
|
||||
cachedStatus = next;
|
||||
statusListeners.forEach((cb) => cb());
|
||||
});
|
||||
};
|
||||
|
||||
const subscribeStatus = (cb: () => void): (() => void) => {
|
||||
ensureStatusBridge();
|
||||
// Close the race between the cachedStatus computed at module load and the
|
||||
// first event delivered through the bridge: status may have changed in
|
||||
// between (e.g. the socket connected before <ServerProvider> mounted), and
|
||||
// useSyncExternalStore would otherwise surface the stale snapshot until
|
||||
// the next external transition.
|
||||
const next = computeStatus();
|
||||
if (!isStatusEqual(cachedStatus, next)) {
|
||||
cachedStatus = next;
|
||||
cb();
|
||||
}
|
||||
statusListeners.add(cb);
|
||||
return () => {
|
||||
statusListeners.delete(cb);
|
||||
};
|
||||
};
|
||||
|
||||
const getStatusSnapshot = (): CombinedStatus => cachedStatus;
|
||||
|
||||
export type ServerProviderProps = { children?: ReactNode };
|
||||
|
||||
const ServerProvider = ({ children }: ServerProviderProps) => {
|
||||
const { connected, status, retryCount, retryTime } = useSyncExternalStore(subscribeStatus, getStatusSnapshot);
|
||||
|
||||
const value = useMemo(
|
||||
(): ServerContextValue => ({
|
||||
connected,
|
||||
status,
|
||||
retryCount,
|
||||
retryTime,
|
||||
info,
|
||||
absoluteUrl,
|
||||
callMethod,
|
||||
callEndpoint,
|
||||
uploadToEndpoint,
|
||||
getStream,
|
||||
getStreamAll,
|
||||
writeStream,
|
||||
disconnect,
|
||||
reconnect,
|
||||
}),
|
||||
[connected, retryCount, retryTime, status],
|
||||
);
|
||||
|
||||
return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>;
|
||||
};
|
||||
|
||||
export default ServerProvider;
|
||||
30
apps/meteor/client/providers/SessionProvider.tsx
Normal file
30
apps/meteor/client/providers/SessionProvider.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { SessionContext } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const store = new Map<string, unknown>();
|
||||
const emitter = new Emitter();
|
||||
|
||||
const contextValue = {
|
||||
query: (name: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => unknown] => [
|
||||
(onStoreChange) => emitter.on(name, onStoreChange),
|
||||
() => store.get(name),
|
||||
],
|
||||
dispatch: (name: string, value: unknown): void => {
|
||||
if (store.has(name) && store.get(name) === value) {
|
||||
return;
|
||||
}
|
||||
store.set(name, value);
|
||||
emitter.emit(name);
|
||||
},
|
||||
};
|
||||
|
||||
export type SessionProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const SessionProvider = ({ children }: SessionProviderProps) => (
|
||||
<SessionContext.Provider value={contextValue}>{children}</SessionContext.Provider>
|
||||
);
|
||||
|
||||
export default SessionProvider;
|
||||
159
apps/meteor/client/providers/SettingsProvider.tsx
Normal file
159
apps/meteor/client/providers/SettingsProvider.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { isSettingCode } from '@rocket.chat/core-typings';
|
||||
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import type { SettingsContextQuery, SettingsContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { SettingsContext, useAtLeastOnePermission, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { PublicSettingsCachedStore, PrivateSettingsCachedStore } from '../cachedStores';
|
||||
import { useShowSettingAlerts } from '../hooks/useShowSettingAlerts';
|
||||
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
|
||||
import { applyQueryOptions } from '../lib/cachedStores/applyQueryOptions';
|
||||
import { getCodeSettingError } from '../lib/utils/getCodeSettingError';
|
||||
|
||||
const settingsManagementPermissions = ['view-privileged-setting', 'edit-privileged-setting', 'manage-selected-settings'];
|
||||
|
||||
export type SettingsProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const SettingsProvider = ({ children }: SettingsProviderProps) => {
|
||||
const canManageSettings = useAtLeastOnePermission(settingsManagementPermissions);
|
||||
|
||||
const cachedCollection = canManageSettings ? PrivateSettingsCachedStore : PublicSettingsCachedStore;
|
||||
|
||||
const isLoading = !cachedCollection.useReady();
|
||||
|
||||
if (isLoading) {
|
||||
throw (async () => {
|
||||
if (cachedCollection instanceof PrivateCachedStore) {
|
||||
cachedCollection.listen();
|
||||
}
|
||||
|
||||
await cachedCollection.init();
|
||||
})();
|
||||
}
|
||||
|
||||
const querySetting = useMemo(
|
||||
() =>
|
||||
(_id: ISetting['_id']): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting | undefined] => {
|
||||
let snapshot = cachedCollection.store.getState().get(_id);
|
||||
|
||||
const subscribe = (onStoreChange: () => void) =>
|
||||
cachedCollection.store.subscribe(() => {
|
||||
const newSnapshot = cachedCollection.store.getState().get(_id);
|
||||
if (newSnapshot === snapshot) return;
|
||||
snapshot = newSnapshot;
|
||||
onStoreChange();
|
||||
});
|
||||
|
||||
const getSnapshot = () => snapshot;
|
||||
|
||||
return [subscribe, getSnapshot];
|
||||
},
|
||||
[cachedCollection.store],
|
||||
);
|
||||
|
||||
const querySettings = useMemo(() => {
|
||||
return (query: SettingsContextQuery): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting[]] => {
|
||||
const effectiveQuery = {
|
||||
...('_id' in query && Array.isArray(query._id) && { _id: { $in: query._id } }),
|
||||
...('_id' in query && !Array.isArray(query._id) && { _id: query._id }),
|
||||
...('group' in query && { group: query.group }),
|
||||
...('section' in query &&
|
||||
(query.section
|
||||
? { section: query.section }
|
||||
: {
|
||||
$or: [{ section: { $exists: false } }, { section: undefined }],
|
||||
})),
|
||||
};
|
||||
|
||||
const options = {
|
||||
sort: {
|
||||
section: 1,
|
||||
sorter: 1,
|
||||
i18nLabel: 1,
|
||||
},
|
||||
...('skip' in query && typeof query.skip === 'number' && { skip: query.skip }),
|
||||
...('limit' in query && typeof query.limit === 'number' && { limit: query.limit }),
|
||||
} as const;
|
||||
|
||||
const predicate = createPredicateFromFilter<ISetting>(effectiveQuery);
|
||||
let snapshot = applyQueryOptions(cachedCollection.store.getState().filter(predicate), options);
|
||||
|
||||
const subscribe = (onStoreChange: () => void) =>
|
||||
cachedCollection.store.subscribe(() => {
|
||||
const newSnapshot = applyQueryOptions(cachedCollection.store.getState().filter(predicate), options);
|
||||
if (newSnapshot === snapshot) return;
|
||||
snapshot = newSnapshot;
|
||||
onStoreChange();
|
||||
});
|
||||
|
||||
const getSnapshot = () => snapshot;
|
||||
|
||||
return [subscribe, getSnapshot];
|
||||
};
|
||||
}, [cachedCollection.store]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const showAlerts = useShowSettingAlerts();
|
||||
|
||||
const saveSettings = useEndpoint('POST', '/v1/settings');
|
||||
const dispatch = useCallback(
|
||||
async (changes: Partial<ISetting>[], onSaved?: () => void) => {
|
||||
const changedSettingIds = changes.map((s) => s._id).filter(isTruthy);
|
||||
const alerts = cachedCollection.store
|
||||
.getState()
|
||||
.filter((setting) => Boolean(setting.alert && changedSettingIds.includes(setting._id)));
|
||||
// FIXME: This is a temporary solution to invalidate queries when settings change
|
||||
changes.forEach((val) => {
|
||||
if (val._id === 'Enterprise_License') {
|
||||
queryClient.invalidateQueries({ queryKey: ['licenses'] });
|
||||
}
|
||||
});
|
||||
|
||||
const hasInvalidCodeSetting = changes.some((change) => {
|
||||
if (!change._id || change.value === undefined) {
|
||||
return false;
|
||||
}
|
||||
const setting = cachedCollection.store.getState().get(change._id);
|
||||
return setting !== undefined && isSettingCode(setting) && getCodeSettingError(setting.code, change.value) !== undefined;
|
||||
});
|
||||
|
||||
if (hasInvalidCodeSetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alerts.length) {
|
||||
const accepted = await showAlerts(alerts);
|
||||
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await saveSettings({ settings: changes as Pick<ISetting, '_id' | 'value'>[] });
|
||||
|
||||
onSaved?.();
|
||||
},
|
||||
[queryClient, saveSettings, showAlerts, cachedCollection.store],
|
||||
);
|
||||
|
||||
const contextValue = useMemo<SettingsContextValue>(
|
||||
() => ({
|
||||
hasPrivateAccess: canManageSettings,
|
||||
querySetting,
|
||||
querySettings,
|
||||
dispatch,
|
||||
}),
|
||||
[canManageSettings, querySetting, querySettings, dispatch],
|
||||
);
|
||||
|
||||
return <SettingsContext.Provider value={contextValue}>{children}</SettingsContext.Provider>;
|
||||
};
|
||||
|
||||
export default SettingsProvider;
|
||||
88
apps/meteor/client/providers/ToastMessagesProvider.tsx
Normal file
88
apps/meteor/client/providers/ToastMessagesProvider.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ToastBarProvider, useToastBarDispatch } from '@rocket.chat/fuselage-toastbar';
|
||||
import { ToastMessagesContext } from '@rocket.chat/ui-contexts';
|
||||
import type { DefaultError, Query } from '@tanstack/react-query';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { isValidElement, useEffect } from 'react';
|
||||
|
||||
import { getErrorMessage } from '../lib/errorHandling';
|
||||
import { dispatchToastMessage, subscribeToToastMessages } from '../lib/toast';
|
||||
|
||||
const contextValue = {
|
||||
dispatch: dispatchToastMessage,
|
||||
};
|
||||
|
||||
export type ToastMessageInnerProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const ToastMessageInnerProvider = ({ children }: ToastMessageInnerProviderProps) => {
|
||||
const dispatchToastBar = useToastBarDispatch();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const queryCacheInstance = queryClient.getQueryCache();
|
||||
queryCacheInstance.config.onError = (error: DefaultError, query: Query<unknown, unknown, unknown>) => {
|
||||
const meta = query?.meta;
|
||||
if (meta) {
|
||||
const { errorToastMessage, apiErrorToastMessage } = meta as {
|
||||
errorToastMessage?: string;
|
||||
apiErrorToastMessage?: boolean;
|
||||
};
|
||||
if (apiErrorToastMessage) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
} else if (errorToastMessage) {
|
||||
dispatchToastMessage({ type: 'error', message: errorToastMessage });
|
||||
}
|
||||
}
|
||||
};
|
||||
queryCacheInstance.config.onSuccess = (_, query: Query<unknown, unknown, unknown>) => {
|
||||
const meta = query?.meta;
|
||||
if (meta) {
|
||||
const { successToastMessage } = meta as { successToastMessage?: string };
|
||||
if (successToastMessage) {
|
||||
dispatchToastMessage({ type: 'success', message: successToastMessage });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeToToastMessages(({ type, message, title = '', options }) => {
|
||||
if (type === 'error' && typeof message === 'object') {
|
||||
dispatchToastBar({ type, title, message: getErrorMessage(message), ...options });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message !== 'string' && message instanceof Error) {
|
||||
message = `[${message.name}] ${message.message}`;
|
||||
}
|
||||
|
||||
if (type === 'warning') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidElement(message)) {
|
||||
dispatchToastBar({ type, title, message, ...options });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchToastBar({ type, title, message: String(message), ...options });
|
||||
}),
|
||||
[dispatchToastBar],
|
||||
);
|
||||
|
||||
return <ToastMessagesContext.Provider value={contextValue}>{children}</ToastMessagesContext.Provider>;
|
||||
};
|
||||
|
||||
export type ToastMessagesProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const ToastMessagesProvider = ({ children }: ToastMessagesProviderProps) => (
|
||||
<ToastBarProvider>
|
||||
<ToastMessageInnerProvider>{children}</ToastMessageInnerProvider>
|
||||
</ToastBarProvider>
|
||||
);
|
||||
|
||||
export default ToastMessagesProvider;
|
||||
274
apps/meteor/client/providers/TranslationProvider.tsx
Normal file
274
apps/meteor/client/providers/TranslationProvider.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
addSprinfToI18n,
|
||||
extractTranslationKeys,
|
||||
applyCustomTranslations,
|
||||
availableTranslationNamespaces,
|
||||
defaultTranslationNamespace,
|
||||
extractTranslationNamespaces,
|
||||
} from '@rocket.chat/i18n';
|
||||
import languages from '@rocket.chat/i18n/dist/languages';
|
||||
import en from '@rocket.chat/i18n/dist/resources/en.i18n.json';
|
||||
import { capitalize, normalizeLanguage } from '@rocket.chat/tools';
|
||||
import type { TranslationContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { useSetting, TranslationContext } from '@rocket.chat/ui-contexts';
|
||||
import type i18next from 'i18next';
|
||||
import I18NextHttpBackend from 'i18next-http-backend';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { I18nextProvider, initReactI18next, useTranslation } from 'react-i18next';
|
||||
|
||||
import { i18n } from '../../app/utils/lib/i18n';
|
||||
import { AppClientOrchestratorInstance } from '../apps/orchestrator';
|
||||
import { getURL } from '../lib/getURL';
|
||||
import { onLoggedIn } from '../lib/loggedIn';
|
||||
import { isRTLScriptLanguage } from '../lib/utils/isRTLScriptLanguage';
|
||||
import { setDateFnsLocale } from '../lib/utils/setDateFnsLocale';
|
||||
|
||||
i18n.use(I18NextHttpBackend).use(initReactI18next);
|
||||
|
||||
const useCustomTranslations = (i18n: typeof i18next) => {
|
||||
const customTranslations = useSetting('Custom_Translations');
|
||||
|
||||
const parsedCustomTranslations = useMemo((): Record<string, Record<string, string>> | undefined => {
|
||||
if (!customTranslations || typeof customTranslations !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(customTranslations);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return undefined;
|
||||
}
|
||||
}, [customTranslations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parsedCustomTranslations) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyCustomTranslations(i18n, parsedCustomTranslations);
|
||||
|
||||
const handleLanguageChanged = (): void => {
|
||||
applyCustomTranslations(i18n, parsedCustomTranslations);
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
};
|
||||
}, [i18n, parsedCustomTranslations]);
|
||||
};
|
||||
|
||||
const localeCache = new Map<string, Promise<string>>();
|
||||
let isI18nInitialized = false;
|
||||
|
||||
const useI18next = (lng: string): typeof i18next => {
|
||||
// i18n.init is async, so there's a chance a race condition happens and it is initialized twice
|
||||
// This breaks translations because it loads `lng` in the first init but not the second.
|
||||
if (!isI18nInitialized) {
|
||||
isI18nInitialized = true;
|
||||
i18n.init({
|
||||
lng,
|
||||
fallbackLng: 'en',
|
||||
ns: availableTranslationNamespaces,
|
||||
defaultNS: defaultTranslationNamespace,
|
||||
nsSeparator: '.',
|
||||
resources: {
|
||||
en: extractTranslationNamespaces(en),
|
||||
},
|
||||
partialBundledLanguages: true,
|
||||
backend: {
|
||||
loadPath: 'i18n/{{lng}}.json',
|
||||
parse: (data: string, _lngs?: string | string[], namespaces: string | string[] = []) =>
|
||||
extractTranslationKeys(JSON.parse(data), namespaces),
|
||||
request: (_options: unknown, url: string, _payload: unknown, callback: (error: unknown, data: unknown) => void) => {
|
||||
const params = url.split('/');
|
||||
|
||||
const lng = params[params.length - 1];
|
||||
|
||||
let promise = localeCache.get(lng);
|
||||
|
||||
if (!promise) {
|
||||
promise = fetch(getURL(url)).then((res) => res.text());
|
||||
localeCache.set(lng, promise);
|
||||
}
|
||||
|
||||
promise.then(
|
||||
(res) => callback(null, { data: res, status: 200 }),
|
||||
() => callback(null, { data: '', status: 500 }),
|
||||
);
|
||||
},
|
||||
},
|
||||
react: {
|
||||
useSuspense: true,
|
||||
bindI18n: 'languageChanged loaded',
|
||||
bindI18nStore: 'added removed',
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
// In some cases, the language will require a word to be in a different position than the default
|
||||
// This enables the capitalization of words that are moved to the start of the sentence directly in the translation file
|
||||
i18n.on('initialized', () => {
|
||||
i18n.services.formatter?.add('capitalize', (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
return capitalize(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(lng);
|
||||
}, [lng]);
|
||||
|
||||
return i18n;
|
||||
};
|
||||
|
||||
const useAutoLanguage = () => {
|
||||
const serverLanguage = useSetting('Language', '');
|
||||
const browserLanguage = normalizeLanguage(window.navigator.userLanguage ?? window.navigator.language);
|
||||
const defaultUserLanguage = browserLanguage || serverLanguage || 'en';
|
||||
|
||||
// if the language is supported, if not remove the region
|
||||
const suggestedLanguage = languages.includes(defaultUserLanguage)
|
||||
? defaultUserLanguage
|
||||
: (defaultUserLanguage.split('-').shift() ?? 'en');
|
||||
|
||||
// usually that value is set based on the user's config language
|
||||
const [language] = useLocalStorage('userLanguage', suggestedLanguage);
|
||||
|
||||
document.documentElement.classList[isRTLScriptLanguage(language) ? 'add' : 'remove']('rtl');
|
||||
document.documentElement.setAttribute('dir', isRTLScriptLanguage(language) ? 'rtl' : 'ltr');
|
||||
document.documentElement.lang = language;
|
||||
|
||||
// if user has no language set, we should set it to the default language
|
||||
return language || suggestedLanguage;
|
||||
};
|
||||
|
||||
const getNorthernSamiDisplayName = (lng: string) => {
|
||||
/*
|
||||
** Intl.DisplayName not returning Northern Sami
|
||||
** for `se` language code in Chrome Version 134.0.6998.89
|
||||
** which is the proper name based on the Unicode Common Locale Data Repository (CLDR)
|
||||
*/
|
||||
const languageDisplayNames: { [key: string]: string } = {
|
||||
se: 'davvisámegiella',
|
||||
sv: 'nordsamiska',
|
||||
ru: 'северносаамский',
|
||||
no: 'nordsamisk',
|
||||
fi: 'pohjoissaame',
|
||||
};
|
||||
|
||||
return languageDisplayNames[lng] || 'Northern Sami';
|
||||
};
|
||||
|
||||
const getLanguageName = (code: string, lng: string): string => {
|
||||
try {
|
||||
const lang = new Intl.DisplayNames([lng], { type: 'language' });
|
||||
|
||||
if (code === 'se' && lang.of(code) === 'se') {
|
||||
return getNorthernSamiDisplayName(lng);
|
||||
}
|
||||
|
||||
return lang.of(code) ?? code;
|
||||
} catch (e) {
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
export type TranslationProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const TranslationProvider = ({ children }: TranslationProviderProps) => {
|
||||
const language = useAutoLanguage();
|
||||
const i18nextInstance = useI18next(language);
|
||||
useCustomTranslations(i18nextInstance);
|
||||
|
||||
const availableLanguages = useMemo(
|
||||
() => [
|
||||
{
|
||||
en: 'Default',
|
||||
name: i18nextInstance.t('Default'),
|
||||
ogName: i18nextInstance.t('Default'),
|
||||
key: '',
|
||||
},
|
||||
...[...new Set([...i18nextInstance.languages, ...languages])]
|
||||
.map((key) => ({
|
||||
en: key,
|
||||
name: getLanguageName(key, language),
|
||||
ogName: getLanguageName(key, key),
|
||||
key,
|
||||
}))
|
||||
.sort(({ name: nameA }, { name: nameB }) => nameA.localeCompare(nameB)),
|
||||
],
|
||||
[language, i18nextInstance],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDateFnsLocale(language);
|
||||
}, [language]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onLoggedIn(() => {
|
||||
AppClientOrchestratorInstance.getAppClientManager().initialize();
|
||||
AppClientOrchestratorInstance.load();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18nextInstance}>
|
||||
<TranslationProviderInner availableLanguages={availableLanguages}>{children}</TranslationProviderInner>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* I was forced to create this component to keep the api useTranslation from rocketchat
|
||||
* rocketchat useTranslation invalidates the provider content, triggering all the places that use it
|
||||
* i18next triggers a re-render inside useTranslation, since now we are using 100% of the i18next
|
||||
* the only way to invalidate after changing the language in a safe way is using the useTranslation from i8next
|
||||
* and invalidating the provider content
|
||||
*/
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const TranslationProviderInner = ({
|
||||
children,
|
||||
availableLanguages,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
availableLanguages: {
|
||||
en: string;
|
||||
name: string;
|
||||
ogName: string;
|
||||
key: string;
|
||||
}[];
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const value: TranslationContextValue = useMemo(
|
||||
() => ({
|
||||
language: i18n.language,
|
||||
languages: availableLanguages,
|
||||
loadLanguage: async (language: string) => {
|
||||
i18n.changeLanguage(language);
|
||||
},
|
||||
translate: Object.assign(addSprinfToI18n(t), {
|
||||
has: ((key, options) => key && i18n.exists(key, options)) as TranslationContextValue['translate']['has'],
|
||||
}),
|
||||
}),
|
||||
[availableLanguages, i18n, t],
|
||||
);
|
||||
|
||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>;
|
||||
};
|
||||
|
||||
export default TranslationProvider;
|
||||
53
apps/meteor/client/providers/UserPresenceProvider.tsx
Normal file
53
apps/meteor/client/providers/UserPresenceProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { UserPresenceContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { useSetting, UserPresenceContext } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
|
||||
import { useUserPresenceListener } from '../hooks/useUserPresenceListener';
|
||||
import { Presence } from '../lib/presence';
|
||||
import { UserPresence } from '../lib/userPresence';
|
||||
|
||||
export const userPresence = new UserPresence();
|
||||
|
||||
export type UserPresenceProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const UserPresenceProvider = ({ children }: UserPresenceProviderProps) => {
|
||||
userPresence.use();
|
||||
|
||||
useUserPresenceListener();
|
||||
|
||||
const usePresenceDisabled = useSetting('Presence_broadcast_disabled', false);
|
||||
|
||||
useEffect(() => {
|
||||
Presence.setStatus(usePresenceDisabled ? 'disabled' : 'enabled');
|
||||
}, [usePresenceDisabled]);
|
||||
|
||||
const contextValue: UserPresenceContextValue = useMemo(
|
||||
() => ({
|
||||
queryUserData: (uid) => {
|
||||
if (!uid) {
|
||||
return { get: () => undefined, subscribe: () => () => undefined };
|
||||
}
|
||||
|
||||
const subscribe = (callback: () => void) => {
|
||||
Presence.listen(uid, callback);
|
||||
|
||||
return () => {
|
||||
Presence.stop(uid, callback);
|
||||
};
|
||||
};
|
||||
|
||||
const get = () => Presence.store.get(uid);
|
||||
|
||||
return { subscribe, get };
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return <UserPresenceContext.Provider value={contextValue}>{children}</UserPresenceContext.Provider>;
|
||||
};
|
||||
|
||||
export default UserPresenceProvider;
|
||||
206
apps/meteor/client/providers/UserProvider/UserProvider.tsx
Normal file
206
apps/meteor/client/providers/UserProvider/UserProvider.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
|
||||
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
|
||||
import type { FindOptions, SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
import { UserContext, useRouteParameter, useSearchParameter } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { Filter, ObjectId } from 'mongodb';
|
||||
import type { ContextType, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import type { StoreApi, UseBoundStore } from 'zustand';
|
||||
|
||||
import { useClearRemovedRoomsHistory } from './hooks/useClearRemovedRoomsHistory';
|
||||
import { useDeleteUser } from './hooks/useDeleteUser';
|
||||
import { useEmailVerificationWarning } from './hooks/useEmailVerificationWarning';
|
||||
import { useReloadAfterLogin } from './hooks/useReloadAfterLogin';
|
||||
import { useUpdateAvatar } from './hooks/useUpdateAvatar';
|
||||
import { useIdleConnection } from '../../hooks/useIdleConnection';
|
||||
import type { IDocumentMapStore } from '../../lib/cachedStores/DocumentMapStore';
|
||||
import { applyQueryOptions } from '../../lib/cachedStores/applyQueryOptions';
|
||||
import { getDdpSdk } from '../../lib/sdk/ddpSdk';
|
||||
import { settings } from '../../lib/settings';
|
||||
import { userIdStore } from '../../lib/user';
|
||||
import { Users, Rooms, Subscriptions } from '../../stores';
|
||||
import { useSamlInviteToken } from '../../views/invite/hooks/useSamlInviteToken';
|
||||
|
||||
export type UserProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// Local logout broadcaster — `onLogout(cb)` consumers (e.g. e2ee cleanup) still
|
||||
// subscribe to this. The post-logout side effects that used to require a
|
||||
// `sdk.call('logoutCleanUp')` round-trip (afterLogoutCleanUpCallback +
|
||||
// Apps.IPostUserLoggedOut) now fire server-side via `Accounts.onLogout` and
|
||||
// `POST /v1/users.logout`, so this emitter is purely client-side fan-out.
|
||||
const ee = new Emitter();
|
||||
getDdpSdk().account.onLogout(() => ee.emit('logout'));
|
||||
|
||||
const queryRoom = (
|
||||
query: Filter<Pick<IRoom, '_id'>>,
|
||||
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => IRoom | undefined] => {
|
||||
const predicate = createPredicateFromFilter(query);
|
||||
let snapshot = Rooms.state.find(predicate);
|
||||
|
||||
const subscribe = (onStoreChange: () => void) =>
|
||||
Rooms.use.subscribe(() => {
|
||||
const newSnapshot = Rooms.state.find(predicate);
|
||||
if (newSnapshot === snapshot) return;
|
||||
snapshot = newSnapshot;
|
||||
onStoreChange();
|
||||
});
|
||||
|
||||
const getSnapshot = () => snapshot;
|
||||
|
||||
return [subscribe, getSnapshot];
|
||||
};
|
||||
|
||||
const UserProvider = ({ children }: UserProviderProps) => {
|
||||
const userId = userIdStore();
|
||||
|
||||
const user = Users.use((state) => {
|
||||
if (!userId) return null;
|
||||
return state.get(userId) ?? null;
|
||||
});
|
||||
|
||||
const previousUserId = useRef(userId);
|
||||
const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', '');
|
||||
const [preferedLanguage, setPreferedLanguage] = useLocalStorage('preferedLanguage', '');
|
||||
const [, setSamlInviteToken] = useSamlInviteToken();
|
||||
const samlCredentialToken = useSearchParameter('saml_idp_credentialToken');
|
||||
const inviteTokenHash = useRouteParameter('hash');
|
||||
|
||||
useEmailVerificationWarning(user ?? undefined);
|
||||
useClearRemovedRoomsHistory(userId);
|
||||
|
||||
useDeleteUser();
|
||||
useUpdateAvatar();
|
||||
useIdleConnection(userId);
|
||||
useReloadAfterLogin(user);
|
||||
|
||||
const querySubscriptions = useMemo(() => {
|
||||
const createSubscriptionFactory =
|
||||
<T extends SubscriptionWithRoom | IRoom>(store: UseBoundStore<StoreApi<IDocumentMapStore<T>>>) =>
|
||||
(
|
||||
query: object,
|
||||
options: FindOptions = {},
|
||||
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => SubscriptionWithRoom[]] => {
|
||||
const predicate = createPredicateFromFilter<T>(query);
|
||||
let snapshot = applyQueryOptions(store.getState().filter(predicate), options);
|
||||
|
||||
const subscribe = (onStoreChange: () => void) =>
|
||||
store.subscribe(() => {
|
||||
const newSnapshot = applyQueryOptions(store.getState().filter(predicate), options);
|
||||
if (newSnapshot === snapshot) return;
|
||||
snapshot = newSnapshot;
|
||||
onStoreChange();
|
||||
});
|
||||
|
||||
// TODO: this type assertion is completely wrong; however, the `useUserSubscriptions` hook might be deleted in
|
||||
// the future, so we can live with it for now
|
||||
const getSnapshot = () => snapshot as SubscriptionWithRoom[];
|
||||
|
||||
return [subscribe, getSnapshot];
|
||||
};
|
||||
|
||||
return userId ? createSubscriptionFactory(Subscriptions.use) : createSubscriptionFactory(Rooms.use);
|
||||
}, [userId]);
|
||||
|
||||
const querySubscription = useMemo(() => {
|
||||
return (query: object): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => SubscriptionWithRoom] => {
|
||||
const predicate = createPredicateFromFilter<SubscriptionWithRoom>(query);
|
||||
let snapshot = Subscriptions.use.getState().find(predicate);
|
||||
|
||||
const subscribe = (onStoreChange: () => void) =>
|
||||
Subscriptions.use.subscribe(() => {
|
||||
const newSnapshot = Subscriptions.use.getState().find(predicate);
|
||||
if (newSnapshot === snapshot) return;
|
||||
snapshot = newSnapshot;
|
||||
onStoreChange();
|
||||
});
|
||||
|
||||
// TODO: this type assertion is completely wrong; however, the `useUserSubscriptions` hook might be deleted in
|
||||
// the future, so we can live with it for now
|
||||
const getSnapshot = () => snapshot as SubscriptionWithRoom;
|
||||
|
||||
return [subscribe, getSnapshot];
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
(): ContextType<typeof UserContext> => ({
|
||||
userId,
|
||||
user,
|
||||
queryPreference: <T,>(
|
||||
key: string | ObjectId,
|
||||
defaultValue?: T,
|
||||
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T | undefined] => {
|
||||
const effectiveKey = String(key);
|
||||
|
||||
const subscribe = (onStoreChange: () => void): (() => void) => {
|
||||
const unsubUsers = Users.use.subscribe(onStoreChange);
|
||||
const unsubSettings = settings.observe(`Accounts_Default_User_Preferences_${effectiveKey}`, onStoreChange);
|
||||
return () => {
|
||||
unsubUsers();
|
||||
unsubSettings();
|
||||
};
|
||||
};
|
||||
|
||||
const getSnapshot = (): T | undefined => {
|
||||
return (
|
||||
(user?.settings?.preferences?.[effectiveKey] as T | undefined) ??
|
||||
defaultValue ??
|
||||
settings.peek(`Accounts_Default_User_Preferences_${effectiveKey}`)
|
||||
);
|
||||
};
|
||||
return [subscribe, getSnapshot];
|
||||
},
|
||||
querySubscription,
|
||||
queryRoom,
|
||||
querySubscriptions,
|
||||
logout: async () => Meteor.logout(),
|
||||
onLogout: (cb) => {
|
||||
return ee.on('logout', cb);
|
||||
},
|
||||
}),
|
||||
[userId, user, querySubscription, querySubscriptions],
|
||||
);
|
||||
|
||||
// Mirror local preference changes into the live userLanguage state without hitting the server.
|
||||
useEffect(() => {
|
||||
if (preferedLanguage === userLanguage) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserLanguage(preferedLanguage);
|
||||
}, [preferedLanguage, setUserLanguage, userLanguage]);
|
||||
|
||||
// When the server reports a new language, overwrite both storage keys so every tab stays aligned.
|
||||
useEffect(() => {
|
||||
if (user?.language !== undefined && user.language !== userLanguage) {
|
||||
setUserLanguage(user.language);
|
||||
setPreferedLanguage(user.language);
|
||||
}
|
||||
}, [setPreferedLanguage, setUserLanguage, user?.language, userLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!samlCredentialToken && !inviteTokenHash) {
|
||||
setSamlInviteToken(null);
|
||||
}
|
||||
}, [inviteTokenHash, samlCredentialToken, setSamlInviteToken]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (previousUserId.current && previousUserId.current !== userId) {
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
previousUserId.current = userId;
|
||||
}, [queryClient, userId]);
|
||||
|
||||
return <UserContext.Provider value={contextValue}>{children}</UserContext.Provider>;
|
||||
};
|
||||
|
||||
export default UserProvider;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { useStream } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { RoomHistoryManager } from '../../../lib/RoomHistoryManager';
|
||||
|
||||
export const useClearRemovedRoomsHistory = (userId: IUser['_id'] | undefined) => {
|
||||
const subscribeToNotifyUser = useStream('notify-user');
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subscribeToNotifyUser(`${userId}/subscriptions-changed`, (event, data) => {
|
||||
if (data.t !== 'l' && event === 'removed' && data.rid) {
|
||||
RoomHistoryManager.clear(data.rid);
|
||||
}
|
||||
});
|
||||
}, [userId, subscribeToNotifyUser]);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useStream, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Messages } from '../../../stores';
|
||||
|
||||
export const useDeleteUser = () => {
|
||||
const notify = useStream('notify-logged');
|
||||
|
||||
const uid = useUserId();
|
||||
|
||||
const updateMessages = Messages.use((state) => state.update);
|
||||
const removeMessages = Messages.use((state) => state.remove);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uid) {
|
||||
return;
|
||||
}
|
||||
return notify('Users:Deleted', ({ userId, messageErasureType, replaceByUser }) => {
|
||||
if (messageErasureType === 'Unlink' && replaceByUser) {
|
||||
return updateMessages(
|
||||
(record) => record.u._id === userId,
|
||||
(record) => ({
|
||||
...record,
|
||||
alias: replaceByUser.alias,
|
||||
u: { ...record.u, _id: replaceByUser._id, username: replaceByUser.username ?? record.u.username, name: undefined },
|
||||
}),
|
||||
);
|
||||
}
|
||||
removeMessages((record) => record.u._id === userId);
|
||||
});
|
||||
}, [notify, removeMessages, uid, updateMessages]);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useEmailVerificationWarning(user: IUser | undefined) {
|
||||
const emailVerificationEnabled = useSetting('Accounts_EmailVerification', false);
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mainEmail = user?.emails?.[0];
|
||||
const warnedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { current: warned } = warnedRef;
|
||||
if (mainEmail && mainEmail.verified !== true && emailVerificationEnabled && !warned) {
|
||||
dispatchToastMessage({
|
||||
type: 'warning',
|
||||
message: t('core.You_have_not_verified_your_email'),
|
||||
});
|
||||
warnedRef.current = true;
|
||||
}
|
||||
}, [mainEmail, emailVerificationEnabled, dispatchToastMessage, t]);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { LegacyRoomManager } from '../../../lib/LegacyRoomManager';
|
||||
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
|
||||
|
||||
export const useReloadAfterLogin = (user: IUser | null) => {
|
||||
const usernameRef = useRef<string>(undefined);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const isNewUser = !usernameRef.current && user?.username;
|
||||
const usernameChanged = user?.username && usernameRef.current !== user.username;
|
||||
|
||||
if (isNewUser || usernameChanged) {
|
||||
usernameRef.current = user.username;
|
||||
|
||||
LegacyRoomManager.closeAllRooms();
|
||||
|
||||
const routeName = router.getRouteName();
|
||||
if (!routeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roomType = roomCoordinator.getRouteNameIdentifier(routeName);
|
||||
if (roomType) {
|
||||
router.navigate({
|
||||
name: routeName,
|
||||
params: router.getRouteParameters(),
|
||||
search: router.getSearchParameters(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Purposely not cleaning up usernameRef - it needs to be persistent when logging out/in
|
||||
}, [router, user?.username]);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useUserId, useStream } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Users } from '../../../stores';
|
||||
|
||||
export const useUpdateAvatar = () => {
|
||||
const notify = useStream('notify-logged');
|
||||
const uid = useUserId();
|
||||
useEffect(() => {
|
||||
if (!uid) {
|
||||
return;
|
||||
}
|
||||
return notify('updateAvatar', (data) => {
|
||||
if ('username' in data) {
|
||||
const { username, etag } = data;
|
||||
username &&
|
||||
Users.state.update(
|
||||
(record) => record.username === username,
|
||||
(record) => ({ ...record, avatarETag: etag }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [notify, uid]);
|
||||
};
|
||||
1
apps/meteor/client/providers/UserProvider/index.ts
Normal file
1
apps/meteor/client/providers/UserProvider/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './UserProvider';
|
||||
74
apps/meteor/client/providers/VideoConfProvider.tsx
Normal file
74
apps/meteor/client/providers/VideoConfProvider.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { VideoConfPopupPayload, VideoConfContextValue } from '@rocket.chat/ui-video-conf';
|
||||
import { VideoConfContext } from '@rocket.chat/ui-video-conf';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { VideoConfManager } from '../lib/VideoConfManager';
|
||||
import VideoConfPopups from '../views/room/contextualBar/VideoConference/VideoConfPopups';
|
||||
import { useVideoConfOpenCall } from '../views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
|
||||
|
||||
export type VideoConfContextProviderProps = { children: ReactNode };
|
||||
|
||||
const VideoConfContextProvider = ({ children }: VideoConfContextProviderProps) => {
|
||||
const [outgoing, setOutgoing] = useState<VideoConfPopupPayload | undefined>();
|
||||
const handleOpenCall = useVideoConfOpenCall();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const { t } = useTranslation();
|
||||
const logLevel = useSetting<number>('Log_Level', 0);
|
||||
|
||||
useEffect(() => VideoConfManager.setLogLevel(logLevel), [logLevel]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
VideoConfManager.on('call/join', (props) => {
|
||||
handleOpenCall(props.url, props.providerName);
|
||||
}),
|
||||
[handleOpenCall],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
VideoConfManager.on('error', (props) => {
|
||||
const message = t(props.error?.startsWith('error-') ? props.error : 'error-videoconf-unexpected');
|
||||
dispatchToastMessage({ type: 'error', message });
|
||||
}),
|
||||
[dispatchToastMessage, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
VideoConfManager.on('direct/stopped', () => setOutgoing(undefined));
|
||||
VideoConfManager.on('calling/ended', () => setOutgoing(undefined));
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo<VideoConfContextValue>(
|
||||
() => ({
|
||||
dispatchOutgoing: (option) => setOutgoing({ ...option, id: option.rid }),
|
||||
dismissOutgoing: () => setOutgoing(undefined),
|
||||
startCall: (rid, confTitle) => VideoConfManager.startCall(rid, confTitle),
|
||||
acceptCall: (callId) => VideoConfManager.acceptIncomingCall(callId),
|
||||
joinCall: (callId) => VideoConfManager.joinCall(callId),
|
||||
dismissCall: (callId) => VideoConfManager.dismissIncomingCall(callId),
|
||||
rejectIncomingCall: (callId) => VideoConfManager.rejectIncomingCall(callId),
|
||||
abortCall: () => VideoConfManager.abortCall(),
|
||||
setPreferences: (prefs) => VideoConfManager.setPreferences(prefs),
|
||||
loadCapabilities: () => VideoConfManager.loadCapabilities(),
|
||||
queryIncomingCalls: () => [(cb) => VideoConfManager.on('incoming/changed', cb), () => VideoConfManager.getIncomingDirectCalls()],
|
||||
queryRinging: () => [(cb) => VideoConfManager.on('ringing/changed', cb), () => VideoConfManager.isRinging()],
|
||||
queryCalling: () => [(cb) => VideoConfManager.on('calling/changed', cb), () => VideoConfManager.isCalling()],
|
||||
queryCapabilities: () => [(cb) => VideoConfManager.on('capabilities/changed', cb), () => VideoConfManager.capabilities],
|
||||
queryPreferences: () => [(cb) => VideoConfManager.on('preference/changed', cb), () => VideoConfManager.preferences],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<VideoConfContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<VideoConfPopups>{outgoing}</VideoConfPopups>
|
||||
</VideoConfContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoConfContextProvider;
|
||||
Reference in New Issue
Block a user