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

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

View File

@@ -0,0 +1,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;

View 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;

View 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;

View File

@@ -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;

View File

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

View 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;

View 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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from './CustomSoundProvider';

View File

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

View File

@@ -0,0 +1,4 @@
export const formatVolume = (volume: number) => {
const clamped = Math.max(0, Math.min(volume, 100));
return Number((clamped / 100).toPrecision(2));
};

View File

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

View File

@@ -0,0 +1,2 @@
export * from './helpers';
export * from './formatVolume';

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

View File

@@ -0,0 +1,4 @@
export const isSetSinkIdAvailable = (): boolean => {
const audio = new Audio();
return !!audio.setSinkId;
};

View File

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

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from './EmojiPickerProvider';

View File

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

View 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;

View 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;

View 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;

View File

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

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
export { default } from './MediaPlayerProvider';
export { MediaPlayerContext, useMediaPlayer } from './MediaPlayerContext';
export type { MediaPlayerContextValue, PersistentAudioTrack } from './MediaPlayerContext';

View 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;

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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default } from './UserProvider';

View 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;