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,70 @@
/**
* This class implements logger.
* @remarks
*/
enum LogLevel {
fatal,
error,
warn,
info,
debug,
verbose,
}
export class ClientLogger {
module: string | undefined;
logLevel: LogLevel | undefined;
constructor(module: string, _level: LogLevel = LogLevel.info) {
this.logLevel = _level;
this.module = module;
}
private writeLog(level: LogLevel, log: any): void {
const logLine = `${new Date().toISOString()} ${LogLevel[level]} ${this.module}::${log}`;
if (this.logLevel && this.logLevel < level) {
return;
}
switch (level) {
case LogLevel.warn:
console.warn(logLine);
break;
case LogLevel.error:
case LogLevel.fatal:
console.error(logLine);
break;
default: {
console.log(logLine);
}
}
}
verbose(...args: any[]): void {
this.writeLog(LogLevel.verbose, args);
}
debug(...args: any[]): void {
this.writeLog(LogLevel.debug, args);
}
info(...args: any[]): void {
this.writeLog(LogLevel.info, args);
}
warn(...args: any[]): void {
this.writeLog(LogLevel.warn, args);
}
error(...args: any[]): void {
this.writeLog(LogLevel.error, args);
}
fatal(...args: any[]): void {
this.writeLog(LogLevel.fatal, args);
}
setLogLevel(level: LogLevel): void {
this.logLevel = level;
}
}

View File

@@ -0,0 +1,12 @@
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import type { LicenseAppSources } from '@rocket.chat/core-typings';
/**
* There have been reports of apps not being correctly migrated from versions prior to 6.0
*
* This function is a workaround to get the installation source of an app from the app storage item
* even if the installationSource property is not set.
*/
export function getInstallationSourceFromAppStorageItem(item: IAppStorageItem): LicenseAppSources {
return item.installationSource || ('marketplaceInfo' in item ? 'marketplace' : 'private');
}

View File

@@ -0,0 +1,5 @@
export const NOTIFICATION_ATTACHMENT_COLOR = '#FD745E';
export const MAX_MULTIPLE_UPLOADED_FILES = 10;
export const MAX_CUSTOM_SOUND_SIZE_BYTES = 5242880;
export const CUSTOM_SOUND_ALLOWED_MIME_TYPES = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav'];
export const MESSAGE_MAX_PARSE_LENGTH_DEFAULT = 0;

View File

@@ -0,0 +1,20 @@
import { isTranslatedMessage, getUserDisplayName } from '@rocket.chat/core-typings';
import type { ITranslatedMessage, IMessage } from '@rocket.chat/core-typings';
export function createQuoteAttachment(
message: IMessage | ITranslatedMessage,
messageLink: string,
useRealName: boolean,
userAvatarUrl: string,
) {
return {
text: message.msg,
...(message.md && { md: message.md }),
...(isTranslatedMessage(message) && { translations: message?.translations }),
message_link: messageLink,
author_name: message.alias || getUserDisplayName(message.u.name, message.u.username, useRealName),
author_icon: userAvatarUrl,
attachments: message.attachments || [],
ts: message.ts,
};
}

View File

@@ -0,0 +1,5 @@
import { CloudWorkspaceError } from './CloudWorkspaceError';
export class CloudOfflineLicenseError extends CloudWorkspaceError {
override name = CloudOfflineLicenseError.name;
}

View File

@@ -0,0 +1,5 @@
import { CloudWorkspaceError } from './CloudWorkspaceError';
export class CloudWorkspaceAccessError extends CloudWorkspaceError {
override name = CloudWorkspaceAccessError.name;
}

View File

@@ -0,0 +1,5 @@
import { CloudWorkspaceError } from './CloudWorkspaceError';
export class CloudWorkspaceConnectionError extends CloudWorkspaceError {
override name = CloudWorkspaceConnectionError.name;
}

View File

@@ -0,0 +1,3 @@
export class CloudWorkspaceError extends Error {
override name = CloudWorkspaceError.name;
}

View File

@@ -0,0 +1,5 @@
import { CloudWorkspaceError } from './CloudWorkspaceError';
export class CloudWorkspaceLicenseError extends CloudWorkspaceError {
override name = CloudWorkspaceLicenseError.name;
}

View File

@@ -0,0 +1,5 @@
import { CloudWorkspaceError } from './CloudWorkspaceError';
export class CloudWorkspaceRegistrationError extends CloudWorkspaceError {
override name = CloudWorkspaceRegistrationError.name;
}

View File

@@ -0,0 +1,3 @@
export class InvalidCloudAnnouncementInteractionError extends Error {
override name = InvalidCloudAnnouncementInteractionError.name;
}

View File

@@ -0,0 +1,3 @@
export class InvalidCoreAppInteractionError extends Error {
override name = InvalidCoreAppInteractionError.name;
}

View File

@@ -0,0 +1,12 @@
export function getCronAdvancedTimerFromPrecisionSetting(precision: '0' | '1' | '2' | '3'): string {
switch (precision) {
case '0':
return '*/30 * * * *'; // 30 minutes
case '1':
return '0 * * * *'; // hour
case '2':
return '0 */6 * * *'; // 6 hours
case '3':
return '0 0 * * *'; // day
}
}

View File

@@ -0,0 +1,99 @@
import type { Locale } from 'date-fns';
import { enUS } from 'date-fns/locale';
type LocaleLoader = () => Promise<Locale>;
// Maps Rocket.Chat language codes (from packages/i18n) to date-fns locale loaders.
// Codes without a direct date-fns counterpart fall back to the closest match, or
// to enUS when no reasonable counterpart exists.
const localeLoaders: Record<string, LocaleLoader> = {
'af': async () => (await import('date-fns/locale/af')).af,
'ar': async () => (await import('date-fns/locale/ar')).ar,
'az': async () => (await import('date-fns/locale/az')).az,
'be-BY': async () => (await import('date-fns/locale/be')).be,
'bg': async () => (await import('date-fns/locale/bg')).bg,
'bn-BD': async () => (await import('date-fns/locale/bn')).bn,
'bn-IN': async () => (await import('date-fns/locale/bn')).bn,
'bs': async () => (await import('date-fns/locale/bs')).bs,
'ca': async () => (await import('date-fns/locale/ca')).ca,
'cs': async () => (await import('date-fns/locale/cs')).cs,
'cy': async () => (await import('date-fns/locale/cy')).cy,
'da': async () => (await import('date-fns/locale/da')).da,
'de': async () => (await import('date-fns/locale/de')).de,
'de-AT': async () => (await import('date-fns/locale/de-AT')).deAT,
'de-IN': async () => (await import('date-fns/locale/de')).de,
'el': async () => (await import('date-fns/locale/el')).el,
'en': async () => (await import('date-fns/locale/en-US')).enUS,
'eo': async () => (await import('date-fns/locale/eo')).eo,
'es': async () => (await import('date-fns/locale/es')).es,
'et': async () => (await import('date-fns/locale/et')).et,
'eu': async () => (await import('date-fns/locale/eu')).eu,
'fa': async () => (await import('date-fns/locale/fa-IR')).faIR,
'fi': async () => (await import('date-fns/locale/fi')).fi,
'fr': async () => (await import('date-fns/locale/fr')).fr,
'gl': async () => (await import('date-fns/locale/gl')).gl,
'he': async () => (await import('date-fns/locale/he')).he,
'hi': async () => (await import('date-fns/locale/hi')).hi,
'hi-IN': async () => (await import('date-fns/locale/hi')).hi,
'hr': async () => (await import('date-fns/locale/hr')).hr,
'hu': async () => (await import('date-fns/locale/hu')).hu,
'id': async () => (await import('date-fns/locale/id')).id,
'it': async () => (await import('date-fns/locale/it')).it,
'ja': async () => (await import('date-fns/locale/ja')).ja,
'ka-GE': async () => (await import('date-fns/locale/ka')).ka,
'km': async () => (await import('date-fns/locale/km')).km,
'ko': async () => (await import('date-fns/locale/ko')).ko,
'ku': async () => (await import('date-fns/locale/ckb')).ckb,
'lt': async () => (await import('date-fns/locale/lt')).lt,
'lv': async () => (await import('date-fns/locale/lv')).lv,
'mn': async () => (await import('date-fns/locale/mn')).mn,
'ms-MY': async () => (await import('date-fns/locale/ms')).ms,
'nb': async () => (await import('date-fns/locale/nb')).nb,
'nl': async () => (await import('date-fns/locale/nl')).nl,
'nn': async () => (await import('date-fns/locale/nn')).nn,
'pl': async () => (await import('date-fns/locale/pl')).pl,
'pt': async () => (await import('date-fns/locale/pt')).pt,
'pt-BR': async () => (await import('date-fns/locale/pt-BR')).ptBR,
'ro': async () => (await import('date-fns/locale/ro')).ro,
'ru': async () => (await import('date-fns/locale/ru')).ru,
'se': async () => (await import('date-fns/locale/se')).se,
'sk': async () => (await import('date-fns/locale/sk')).sk,
'sk-SK': async () => (await import('date-fns/locale/sk')).sk,
'sl-SI': async () => (await import('date-fns/locale/sl')).sl,
'sq': async () => (await import('date-fns/locale/sq')).sq,
'sr': async () => (await import('date-fns/locale/sr')).sr,
'sv': async () => (await import('date-fns/locale/sv')).sv,
'ta-IN': async () => (await import('date-fns/locale/ta')).ta,
'th-TH': async () => (await import('date-fns/locale/th')).th,
'tr': async () => (await import('date-fns/locale/tr')).tr,
'ug': async () => (await import('date-fns/locale/ug')).ug,
'uk': async () => (await import('date-fns/locale/uk')).uk,
'vi-VN': async () => (await import('date-fns/locale/vi')).vi,
'zh': async () => (await import('date-fns/locale/zh-CN')).zhCN,
'zh-HK': async () => (await import('date-fns/locale/zh-HK')).zhHK,
'zh-TW': async () => (await import('date-fns/locale/zh-TW')).zhTW,
};
const resolveLoader = (language: string): LocaleLoader | undefined => {
if (localeLoaders[language]) {
return localeLoaders[language];
}
const base = language.split('-').shift();
if (base && localeLoaders[base]) {
return localeLoaders[base];
}
return undefined;
};
export const getDateFnsLocale = async (language: string): Promise<Locale> => {
const loader = resolveLoader(language);
if (!loader) {
return enUS;
}
try {
return await loader();
} catch (error) {
console.error('Error loading date-fns locale:', error);
return enUS;
}
};

View File

@@ -0,0 +1,14 @@
const extensionsMap: Record<string, string> = {
'image/apng': 'apng',
'image/avif': 'avif',
'image/gif': 'gif',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/svg+xml': 'svg',
'image/webp': 'webp',
'image/bmp': 'bmp',
'image/x-icon': 'ico',
'image/tiff': 'tif',
};
export const getImageExtensionFromMime = (mime: string): string | undefined => extensionsMap[mime];

View File

@@ -0,0 +1,6 @@
import { MESSAGE_MAX_PARSE_LENGTH_DEFAULT } from './constants';
export function getMessageMaxParseLength(): number {
const parsed = Number.parseInt(process.env.MESSAGE_MAX_PARSE_LENGTH ?? '', 10);
return Number.isFinite(parsed) ? parsed : MESSAGE_MAX_PARSE_LENGTH_DEFAULT;
}

View File

@@ -0,0 +1,2 @@
export const getMessageUrlRegex = (): RegExp =>
/([A-Za-z]{3,9}):\/\/([-;:&=\+\$,\w]+@{1})?([-A-Za-z0-9\.]+)+:?(\d+)?((\/[-\+=!:~%\/\.@\,\w]*)?\??([-\+=&!:;%@\/\.\,\w]+)?(?:#([^\s\)]+))?)?/g;

View File

@@ -0,0 +1,64 @@
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import type { TFunction } from 'i18next';
const getUnreadTitle = (
{
mentions,
threads,
groupMentions,
total,
}: {
mentions: number;
threads: number;
groupMentions: number;
total: number;
},
t: TFunction,
) => {
const title = [] as string[];
if (mentions) {
title.push(t('mentions_counter', { count: mentions }));
}
if (threads) {
title.push(t('threads_counter', { count: threads }));
}
if (groupMentions) {
title.push(t('group_mentions_counter', { count: groupMentions }));
}
const count = total - mentions - groupMentions - threads;
if (count > 0) {
title.push(t('unread_messages_counter', { count }));
}
return title.join(', ');
};
export type UnreadData = Pick<
SubscriptionWithRoom,
'alert' | 'userMentions' | 'unread' | 'tunread' | 'tunreadUser' | 'groupMentions' | 'hideMentionStatus' | 'hideUnreadStatus'
>;
export const getSubscriptionUnreadData = (
{ userMentions, tunreadUser, tunread, unread, groupMentions, hideMentionStatus, hideUnreadStatus, alert }: UnreadData,
t: TFunction,
) => {
const unreadCount = {
mentions: userMentions + (tunreadUser?.length || 0),
threads: tunread?.length || 0,
groupMentions,
total: unread + (tunread?.length || 0),
};
const unreadTitle = getUnreadTitle(unreadCount, t);
const unreadVariant: 'primary' | 'warning' | 'danger' | 'secondary' =
(unreadCount.mentions && 'danger') || (unreadCount.threads && 'primary') || (unreadCount.groupMentions && 'warning') || 'secondary';
const showUnread =
(!hideUnreadStatus || (!hideMentionStatus && (Boolean(unreadCount.mentions) || Boolean(unreadCount.groupMentions)))) &&
Boolean(unreadCount.total);
const highlightUnread = Boolean(!hideUnreadStatus && (alert || unread));
return { unreadTitle, unreadVariant, showUnread, unreadCount, highlightUnread };
};

View File

@@ -0,0 +1,20 @@
import type { IUser } from '@rocket.chat/core-typings';
import { normalizeUsername } from './utils/normalizeUsername';
/*
In contrary to getUserDisplayName, this function returns an array of strings, containing name & username in the order they're supposed to be displayed.
*/
export const getUserDisplayNames = (
name: IUser['name'],
username: IUser['username'],
useRealName: boolean,
): [nameOrUsername: string, username?: string] => {
if (!username) {
throw new Error('Username is required');
}
const shouldUseName = !!(useRealName && name);
const normalizedUsername = normalizeUsername(username);
return shouldUseName ? [name, normalizedUsername] : [normalizedUsername];
};

View File

@@ -0,0 +1,4 @@
import type { IUser } from '@rocket.chat/core-typings';
export const getUserEmailAddress = (user: Pick<IUser, 'emails'>): string | undefined =>
Array.isArray(user.emails) ? user.emails.find(({ address }) => !!address)?.address : undefined;

View File

@@ -0,0 +1,132 @@
import type { Serialized, ILivechatContact } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { mapLivechatContactConflicts } from './mapLivechatContactConflicts';
const sampleContact: Serialized<ILivechatContact> = {
_id: 'any',
name: 'Contact Name',
createdAt: '',
_updatedAt: '',
channels: [],
};
describe('Map Livechat Contact Conflicts', () => {
it('should return an empty object when the contact has no conflicts', () => {
expect(mapLivechatContactConflicts({ ...sampleContact })).to.be.equal({});
});
it('should group conflicts of the same field in a single atribute', () => {
expect(
mapLivechatContactConflicts({
...sampleContact,
name: '',
conflictingFields: [
{
field: 'name',
value: 'First Name',
},
{
field: 'name',
value: 'Second Name',
},
],
}),
).to.be.deep.equal({
name: {
name: 'name',
label: 'Name',
values: ['First Name', 'Second Name'],
},
});
});
it('should include the current value from the contact on the list of values of the conflict', () => {
expect(
mapLivechatContactConflicts({
...sampleContact,
name: 'Current Name',
conflictingFields: [
{
field: 'name',
value: 'First Name',
},
{
field: 'name',
value: 'Second Name',
},
],
}),
).to.be.deep.equal({
name: {
name: 'name',
label: 'Name',
values: ['First Name', 'Second Name', 'Current Name'],
},
});
});
it('should have a separate attribute for each conflicting field', () => {
expect(
mapLivechatContactConflicts({
...sampleContact,
conflictingFields: [
{
field: 'name',
value: 'First Value',
},
{
field: 'name',
value: 'Second Value',
},
{
field: 'manager',
value: '1',
},
{
field: 'manager',
value: '2',
},
],
}),
).to.be.deep.equal({
name: {
name: 'name',
label: 'Name',
values: ['First Name', 'Second Name', 'Contact Name'],
},
contactManager: {
name: 'contactManager',
label: 'Manager',
values: ['1', '2'],
},
});
});
it('should map conflicts on custom fields too', () => {
expect(
mapLivechatContactConflicts(
{
...sampleContact,
conflictingFields: [
{
field: 'customFields.fullName',
value: 'Full Name 1',
},
{
field: 'customFields.fullName',
value: 'Full Name 2',
},
],
},
[{ name: 'fullName', type: 'text', label: 'Full Name' }],
),
).to.be.deep.equal({
fullName: {
name: 'fullName',
label: 'Full Name',
values: ['Full Name 1', 'Full Name 2'],
},
});
});
});

View File

@@ -0,0 +1,46 @@
import type { CustomFieldMetadata, ILivechatContact, Serialized } from '@rocket.chat/core-typings';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
const fieldNameMap: { [key: string]: TranslationKey } = {
name: 'Name',
contactManager: 'Contact_Manager',
};
type MappedContactConflicts = Record<string, { name: string; label: string; values: string[] }>;
export function mapLivechatContactConflicts(
contact: Serialized<ILivechatContact>,
metadata: CustomFieldMetadata[] = [],
): MappedContactConflicts {
if (!contact.conflictingFields?.length) {
return {};
}
const conflicts = contact.conflictingFields.reduce((acc, current) => {
const fieldName = current.field === 'manager' ? 'contactManager' : current.field.replace('customFields.', '');
if (acc[fieldName]) {
acc[fieldName].values.push(current.value);
} else {
acc[fieldName] = {
name: fieldName,
label:
(current.field.startsWith('customFields.') && metadata.find(({ name }) => name === fieldName)?.label) || fieldNameMap[fieldName],
values: [current.value],
};
}
return acc;
}, {} as MappedContactConflicts);
// If there's a name conflict, add the current name to the conflict values as well
if (conflicts.name?.values.length && contact.name) {
conflicts.name.values.push(contact.name);
}
// If there's a manager conflict, add the current manager to the conflict values as well
if (conflicts.contactManager?.values.length && contact.contactManager) {
conflicts.contactManager.values.push(contact.contactManager);
}
return conflicts;
}

View File

@@ -0,0 +1,22 @@
import { OAuth } from 'meteor/oauth';
import { SystemLogger } from '../server/lib/logger/system';
const { _redirectUri } = OAuth;
OAuth._redirectUri = (serviceName, config, params, absoluteUrlOptions) => {
const ret = _redirectUri(serviceName, config, params, absoluteUrlOptions);
// DEPRECATED: Remove in v5.0.0
// Meteor 2.3 removed ?close from redirect uri so we need to add it back to not break old oauth clients
// https://github.com/meteor/meteor/commit/b5b7306bedc3e8eb241e64efb1e281925aa75dd3#diff-59244f4e0176cb1beed2e287924e97dc7ae2c0cc51494ce121a85d8937d116a5L11
if (!config?.loginStyle && !ret.includes('close')) {
SystemLogger.warn(
`Automatically added ?close to 'redirect_uri' for ${serviceName}, this behavior will be removed in v5.0.0.\n` +
"Please update your OAuth config to accept both with and without ?close as the 'redirect_uri'.",
);
return `${ret + (ret.includes('?') ? '&' : '?')}close`;
}
return ret;
};

View File

@@ -0,0 +1,131 @@
export const subscriptionFields = {
t: 1,
ts: 1,
ls: 1,
lr: 1,
name: 1,
fname: 1,
rid: 1,
code: 1,
f: 1,
u: 1,
category: 1,
open: 1,
alert: 1,
roles: 1,
unread: 1,
prid: 1,
customFields: 1,
userMentions: 1,
groupMentions: 1,
archived: 1,
audioNotificationValue: 1,
desktopNotifications: 1,
mobilePushNotifications: 1,
emailNotifications: 1,
desktopPrefOrigin: 1,
mobilePrefOrigin: 1,
emailPrefOrigin: 1,
unreadAlert: 1,
_updatedAt: 1,
blocked: 1,
blocker: 1,
autoTranslate: 1,
autoTranslateLanguage: 1,
disableNotifications: 1,
hideUnreadStatus: 1,
hideMentionStatus: 1,
muteGroupMentions: 1,
ignored: 1,
E2EKey: 1,
E2ESuggestedKey: 1,
oldRoomKeys: 1,
draft: 1,
threadDrafts: 1,
tunread: 1,
tunreadGroup: 1,
tunreadUser: 1,
status: 1,
inviter: 1,
// Omnichannel fields
department: 1,
v: 1,
onHold: 1,
} as const;
export const roomFields = {
_id: 1,
name: 1,
fname: 1,
t: 1,
cl: 1,
u: 1,
lm: 1,
// usernames: 1,
teamId: 1,
teamMain: 1,
topic: 1,
announcement: 1,
announcementDetails: 1,
muted: 1,
unmuted: 1,
_updatedAt: 1,
archived: 1,
description: 1,
default: 1,
customFields: 1,
lastMessage: 1,
retention: 1,
prid: 1,
avatarETag: 1,
usersCount: 1,
msgs: 1,
// @TODO create an API to register this fields based on room type
tags: 1,
sms: 1,
code: 1,
joinCodeRequired: 1,
open: 1,
label: 1,
ro: 1,
reactWhenReadOnly: 1,
sysMes: 1,
sentiment: 1,
broadcast: 1,
encrypted: 1,
e2eKeyId: 1,
// Omnichannel fields
livechatData: 1,
priorityId: 1,
priorityWeight: 1,
slaId: 1,
estimatedWaitingTimeQueue: 1,
v: 1,
contactId: 1,
verified: 1,
departmentId: 1,
servedBy: 1,
source: 1,
transcriptRequest: 1,
onHold: 1,
metrics: 1,
ts: 1,
waitingResponse: 1,
queuedAt: 1,
// Federation fields
federated: 1,
federation: 1,
// fields used by DMs
usernames: 1,
uids: 1,
usersWaitingForE2EKeys: 1,
// ABAC fields
abacAttributes: 1,
} as const;

View File

@@ -0,0 +1,6 @@
const unrenderableImageTypes = ['image/tiff', 'image/x-motion-jpeg', 'image/avs-video', 'image/pic'];
export const isRenderableImageType = (contentType?: string | null): boolean => {
const type = (contentType || '').split(';')[0].trim().toLowerCase();
return type.startsWith('image/') && !unrenderableImageTypes.includes(type);
};

View File

@@ -0,0 +1,18 @@
import type { IRole } from '@rocket.chat/core-typings';
import { ROOM_ROLE_PRIORITY_MAP } from '@rocket.chat/core-typings';
/**
* Retrieves the role priority for a given role.
* @param role The role ID.
* @returns The priority of the role.
*/
export const getRoomRolePriorityForRole = (role: IRole['_id']): number =>
ROOM_ROLE_PRIORITY_MAP[role as keyof typeof ROOM_ROLE_PRIORITY_MAP] ?? ROOM_ROLE_PRIORITY_MAP.default;
/**
* Calculates the minimum role priority from a list of roles.
* @param roles The array of role IDs.
* @returns The minimum role priority.
*/
export const calculateRoomRolePriorityFromRoles = (roles: IRole['_id'][]): number =>
roles.reduce((currentMin, role) => Math.min(currentMin, getRoomRolePriorityForRole(role)), ROOM_ROLE_PRIORITY_MAP.default);

View File

@@ -0,0 +1,51 @@
import type { IRole } from '@rocket.chat/core-typings';
type PriorityRoleName =
'custom-role' | 'admin' | 'livechat-manager' | 'livechat-monitor' | 'livechat-agent' | 'user' | 'app' | 'bot' | 'guest' | 'anonymous';
const order = [
'admin',
'livechat-manager',
'livechat-monitor',
'livechat-agent',
'custom-role',
'user',
'app',
'bot',
'guest',
'anonymous',
] as const;
const rolesToConsiderAsUser = ['auditor', 'auditor-log'];
export function getMostImportantRole(roles: IRole['_id'][] = []): 'no-role' | PriorityRoleName {
if (!roles.length) {
return 'no-role';
}
roles = roles.map((r) => (rolesToConsiderAsUser.includes(r) ? 'user' : r));
if (roles.length === 1) {
if (!(order as readonly string[]).includes(roles[0])) {
return 'custom-role';
}
return roles[0] as PriorityRoleName;
}
const newRoles: PriorityRoleName[] = [];
for (const role of roles) {
if ((order as readonly string[]).includes(role)) {
newRoles.push(role as PriorityRoleName);
} else if (!newRoles.includes('custom-role')) {
newRoles.push('custom-role');
}
}
for (const item of order) {
if (newRoles.includes(item)) {
return item;
}
}
return 'no-role';
}

View File

@@ -0,0 +1,3 @@
import type { IRole } from '@rocket.chat/core-typings';
export const isValidRoleScope = (scope: IRole['scope']): boolean => ['Users', 'Subscriptions'].includes(scope);

View File

@@ -0,0 +1,77 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://rocket.chat/schemas/room-toolbox-layout/v1.json",
"title": "Rocket.Chat Room Toolbox Layout configuration",
"description": "A list of entries, each declaring the toolbox layout for the room types it claims. Room types left out keep the default layout.",
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/entry" },
"allOf": [
{
"$comment": "A room type may be claimed by at most one entry; minContains 0 keeps entries that do not claim it valid.",
"contains": { "$ref": "#/$defs/entryClaimingPublicChannel" },
"minContains": 0,
"maxContains": 1
},
{
"contains": { "$ref": "#/$defs/entryClaimingPrivateChannel" },
"minContains": 0,
"maxContains": 1
},
{
"contains": { "$ref": "#/$defs/entryClaimingDirectMessage" },
"minContains": 0,
"maxContains": 1
}
],
"$defs": {
"roomType": { "enum": ["c", "p", "d"] },
"entry": {
"type": "object",
"required": ["roomType"],
"additionalProperties": false,
"properties": {
"roomType": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/$defs/roomType" }
},
"maxVisibleNormal": {
"type": "integer",
"minimum": 0,
"description": "How many non-featured actions stay in the toolbox before overflowing into the kebab menu"
},
"items": {
"type": "array",
"items": { "$ref": "#/$defs/item" }
}
}
},
"item": {
"type": "object",
"required": ["id"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"featured": { "type": "boolean" },
"order": { "type": "integer" }
}
},
"entryClaimingPublicChannel": {
"type": "object",
"required": ["roomType"],
"properties": { "roomType": { "type": "array", "contains": { "const": "c" } } }
},
"entryClaimingPrivateChannel": {
"type": "object",
"required": ["roomType"],
"properties": { "roomType": { "type": "array", "contains": { "const": "p" } } }
},
"entryClaimingDirectMessage": {
"type": "object",
"required": ["roomType"],
"properties": { "roomType": { "type": "array", "contains": { "const": "d" } } }
}
}
}

View File

@@ -0,0 +1,34 @@
import type { IRoom } from '@rocket.chat/core-typings';
export const adminFields: Partial<Record<keyof IRoom, 1>> = {
_id: 1,
prid: 1,
fname: 1,
name: 1,
t: 1,
cl: 1,
u: 1,
usernames: 1,
ts: 1,
usersCount: 1,
muted: 1,
unmuted: 1,
ro: 1,
reactWhenReadOnly: 1,
default: 1,
favorite: 1,
featured: 1,
topic: 1,
msgs: 1,
archived: 1,
teamId: 1,
teamMain: 1,
announcement: 1,
description: 1,
customFields: 1,
broadcast: 1,
uids: 1,
avatarETag: 1,
federated: 1,
abacAttributes: 1,
} as const;

View File

@@ -0,0 +1,106 @@
import type { RoomType } from '@rocket.chat/core-typings';
import type { LocationPathname } from '@rocket.chat/ui-contexts';
import type {
IRoomTypeConfig,
IRoomTypeClientDirectives,
IRoomTypeServerDirectives,
RoomIdentification,
} from '../../definition/IRoomTypeConfig';
export abstract class RoomCoordinator {
protected roomTypes: Record<string, { config: IRoomTypeConfig; directives: IRoomTypeClientDirectives | IRoomTypeServerDirectives }> = {};
protected validateRoomConfig(roomConfig: IRoomTypeConfig): void {
const { identifier } = roomConfig;
if (typeof identifier !== 'string' || identifier.length === 0) {
throw new Error('The identifier must be a string.');
}
}
protected addRoomType(roomConfig: IRoomTypeConfig, directives: IRoomTypeClientDirectives | IRoomTypeServerDirectives): void {
this.validateRoomConfig(roomConfig);
if (this.roomTypes[roomConfig.identifier]) {
return;
}
this.roomTypes[roomConfig.identifier] = { config: roomConfig, directives };
}
protected getRoomTypeConfig(identifier: RoomType): IRoomTypeConfig & Pick<Required<IRoomTypeConfig>, 'route'>;
protected getRoomTypeConfig(identifier: string): IRoomTypeConfig | undefined;
protected getRoomTypeConfig(identifier: string): IRoomTypeConfig | undefined {
if (!this.roomTypes[identifier]) {
throw new Error(`Room type with identifier ${identifier} does not exist.`);
}
return this.roomTypes[identifier].config;
}
public getRouteLink(roomType: string, subData: RoomIdentification): LocationPathname | false {
const config = this.getRoomTypeConfig(roomType);
if (!config?.route) {
return false;
}
const routeData = this.getRouteData(roomType, subData);
if (!routeData) {
return false;
}
if (!config.route.path) {
console.warn(`The route for the room type ${roomType} does not have a path`);
return false;
}
const pathDef = config.route.path;
const fields = routeData;
const regExp = /(:[\w\(\)\\\+\*\.\?]+)+/g;
let path = pathDef.replace(regExp, (key) => {
const firstRegexpChar = key.indexOf('(');
// get the content behind : and (\\d+/)
key = key.substring(1, firstRegexpChar > 0 ? firstRegexpChar : undefined);
// remove +?*
key = key.replace(/[\+\*\?]+/g, '');
return fields[key] || '';
});
path = path.replace(/\/\/+/g, '/'); // Replace multiple slashes with single slash
// remove trailing slash
// but keep the root slash if it's the only one
path = path.match(/^\/{1}$/) ? path : path.replace(/\/$/, '');
return path as LocationPathname;
}
protected getRouteData(roomType: string, subData: RoomIdentification): Record<string, string> | false {
if (!subData.rid && (subData as Record<string, string>)._id) {
console.warn('Deprecated: RoomCoordinator.getRouteData received a room object');
subData.rid = (subData as Record<string, string>)._id;
}
const config = this.getRoomTypeConfig(roomType);
if (!config) {
return false;
}
let routeData = {};
if (config.route?.link) {
routeData = config.route.link(subData);
} else if (subData?.name) {
routeData = {
rid: subData.rid,
name: subData.name,
};
}
return routeData;
}
}

View File

@@ -0,0 +1,8 @@
import type { IRoomTypeConfig } from '../../../definition/IRoomTypeConfig';
import type { RoomCoordinator } from '../coordinator';
export function getConversationRoomType(_coordinator: RoomCoordinator): IRoomTypeConfig {
return {
identifier: 'merged',
};
}

View File

@@ -0,0 +1,24 @@
import type { IRoomTypeConfig } from '../../../definition/IRoomTypeConfig';
import type { RoomCoordinator } from '../coordinator';
declare module '@rocket.chat/ui-contexts' {
export interface IRouterPaths {
direct: {
pathname: `/direct/:rid${`/${string}` | ''}${`/${string}` | ''}`;
pattern: '/direct/:rid/:tab?/:context?';
};
}
}
export function getDirectMessageRoomType(_coordinator: RoomCoordinator): IRoomTypeConfig {
return {
identifier: 'd',
route: {
name: 'direct',
path: '/direct/:rid/:tab?/:context?',
link(sub): Record<string, string> {
return { rid: sub.rid || sub.name || '' };
},
},
};
}

View File

@@ -0,0 +1,8 @@
import type { IRoomTypeConfig } from '../../../definition/IRoomTypeConfig';
import type { RoomCoordinator } from '../coordinator';
export function getFavoriteRoomType(_coordinator: RoomCoordinator): IRoomTypeConfig {
return {
identifier: 'f',
};
}

View File

@@ -0,0 +1,24 @@
import type { IRoomTypeConfig } from '../../../definition/IRoomTypeConfig';
import type { RoomCoordinator } from '../coordinator';
declare module '@rocket.chat/ui-contexts' {
export interface IRouterPaths {
live: {
pathname: `/live/${string}${`/${string}` | ''}${`/${string}` | ''}`;
pattern: '/live/:id/:tab?/:context?';
};
}
}
export function getLivechatRoomType(_coordinator: RoomCoordinator): IRoomTypeConfig {
return {
identifier: 'l',
route: {
name: 'live',
path: '/live/:id/:tab?/:context?',
link({ rid, tab }): Record<string, string> {
return { id: rid || '', tab: tab ?? 'room-info' };
},
},
};
}

View File

@@ -0,0 +1,21 @@
import type { IRoomTypeConfig } from '../../../definition/IRoomTypeConfig';
import type { RoomCoordinator } from '../coordinator';
declare module '@rocket.chat/ui-contexts' {
export interface IRouterPaths {
group: {
pathname: `/group/${string}${`/${string}` | ''}${`/${string}` | ''}`;
pattern: '/group/:name/:tab?/:context?';
};
}
}
export function getPrivateRoomType(_coordinator: RoomCoordinator): IRoomTypeConfig {
return {
identifier: 'p',
route: {
name: 'group',
path: '/group/:name/:tab?/:context?',
},
};
}

View File

@@ -0,0 +1,21 @@
import type { IRoomTypeConfig } from '../../../definition/IRoomTypeConfig';
import type { RoomCoordinator } from '../coordinator';
declare module '@rocket.chat/ui-contexts' {
export interface IRouterPaths {
channel: {
pathname: `/channel/${string}${`/${string}` | ''}${`/${string}` | ''}`;
pattern: '/channel/:name/:tab?/:context?';
};
}
}
export function getPublicRoomType(_coordinator: RoomCoordinator): IRoomTypeConfig {
return {
identifier: 'c',
route: {
name: 'channel',
path: '/channel/:name/:tab?/:context?',
},
};
}

View File

@@ -0,0 +1,8 @@
import type { IRoomTypeConfig } from '../../../definition/IRoomTypeConfig';
import type { RoomCoordinator } from '../coordinator';
export function getUnreadRoomType(_coordinator: RoomCoordinator): IRoomTypeConfig {
return {
identifier: 'unread',
};
}

View File

@@ -0,0 +1,24 @@
type Transform<T, U extends T | Promise<Awaited<T>>> = (x: T) => U;
type TransformChain<T, U extends T | Promise<Awaited<T>>> = {
(x: T): U;
use(transform: Transform<T, U>): () => void;
};
export const createAsyncTransformChain = <T>(
...transforms: Transform<T, Promise<Awaited<T>>>[]
): TransformChain<T, Promise<Awaited<T>>> => {
let chain = transforms;
const call = (x: T): Promise<Awaited<T>> => chain.reduce((x, transform) => x.then(transform), Promise.resolve(x));
const use = (transform: Transform<T, Promise<Awaited<T>>>): (() => void) => {
chain.push(transform);
return (): void => {
chain = chain.filter((t) => t !== transform);
};
};
return Object.assign(call, { use });
};

View File

@@ -0,0 +1,4 @@
export const addMinutesToADate = (date: Date, minutes: number): Date => {
const minutesInMs = minutes * 60 * 1000;
return new Date(date.getTime() + minutesInMs);
};

View File

@@ -0,0 +1,4 @@
export function ensureArray<T>(param: T | Array<T>): Array<T> {
const emptyArray: Array<T> = [];
return emptyArray.concat(param);
}

View File

@@ -0,0 +1,7 @@
export const closeUnclosedCodeBlock = (text: string): string => {
const backtickCount = (text.match(/```/g) || []).length;
if (backtickCount % 2 !== 0) {
return `${text}\n\`\`\``;
}
return text;
};

View File

@@ -0,0 +1,5 @@
export const compareByRanking =
<T>(rank: (x: T) => number) =>
(a: T, b: T) => {
return rank(a) - rank(b);
};

View File

@@ -0,0 +1,3 @@
export const exhaustiveCheck = (_: never): never => {
throw new Error('Exhaustive check failed');
};

View File

@@ -0,0 +1,13 @@
import { compile } from 'path-to-regexp';
type PathParams<TPath extends string> = TPath extends `${string}:${infer TParam}/${infer TRest}`
? (TParam extends `${infer U}?` ? U : TParam) | PathParams<TRest>
: TPath extends `${string}:${infer TParam}`
? TParam extends `${infer U}?`
? U
: TParam
: never;
export function generatePath<TPath extends string>(path: TPath, params?: Partial<Record<PathParams<TPath>, string>>): string {
return compile(path, { encode: encodeURIComponent })(params);
}

View File

@@ -0,0 +1,13 @@
export const getFileExtension = (fileName?: string): string => {
if (!fileName) {
return 'file';
}
const arr = fileName.split('.');
if (arr.length < 2 || (arr[0] === '' && arr.length === 2)) {
return 'file';
}
return arr.pop()?.toLocaleUpperCase() || 'file';
};

View File

@@ -0,0 +1,69 @@
export const withThrottling =
({ wait, leading, trailing }: { wait: number; leading?: boolean; trailing?: boolean }) =>
<TFunction extends (...args: any[]) => any>(fn: TFunction) => {
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
let result: ReturnType<TFunction>;
let previous = 0;
function throttled(this: ThisParameterType<TFunction>, ...args: Parameters<TFunction>) {
const now = Date.now();
if (!previous && leading === false) previous = now;
const remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) {
if (timer) clearTimeout(timer);
timer = undefined;
previous = now;
result = fn.apply(this, args);
} else if (!timer && trailing !== false) {
const later = () => {
previous = leading === false ? 0 : Date.now();
result = fn.apply(this, args);
timer = undefined;
};
timer = setTimeout(later, remaining);
}
return result;
}
const cancel = () => {
if (timer) clearTimeout(timer);
previous = 0;
timer = undefined;
};
return Object.assign(throttled, { cancel });
};
export const withDebouncing =
({ wait, immediate }: { wait: number; immediate?: boolean }) =>
<TFunction extends (...args: any[]) => any>(fn: TFunction) => {
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
let result: ReturnType<TFunction>;
let previous: number;
function debounced(this: ThisParameterType<TFunction>, ...args: Parameters<TFunction>) {
previous = Date.now();
if (!timer) {
const later = () => {
const passed = Date.now() - previous;
if (wait > passed) {
timer = setTimeout(later, wait - passed);
} else {
timer = undefined;
if (!immediate) result = fn.apply(this, args);
}
};
timer = setTimeout(later, wait);
if (immediate) result = fn.apply(this, args);
}
return result;
}
const cancel = () => {
clearTimeout(timer);
timer = undefined;
};
return Object.assign(debounced, { cancel });
};

View File

@@ -0,0 +1,25 @@
import { isJSON } from './isJSON';
describe('isJSON', () => {
it('returns true for any successfully parsed JSON', () => {
expect(isJSON('{"a":1}')).toBe(true);
expect(isJSON('{}')).toBe(true);
expect(isJSON('[1,2]')).toBe(true);
expect(isJSON('"hello"')).toBe(true);
expect(isJSON('true')).toBe(true);
expect(isJSON('42')).toBe(true);
});
it('returns true for valid but falsy JSON values', () => {
expect(isJSON('false')).toBe(true);
expect(isJSON('null')).toBe(true);
expect(isJSON('0')).toBe(true);
expect(isJSON('""')).toBe(true);
});
it('returns false for malformed JSON', () => {
expect(isJSON('{ not json }')).toBe(false);
expect(isJSON('{"a":')).toBe(false);
expect(isJSON('')).toBe(false);
});
});

View File

@@ -0,0 +1,8 @@
export const isJSON = (value: string): boolean => {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
};

View File

@@ -0,0 +1,7 @@
/**
* @deprecated use isObject from tools package instead
*/
export function isObject(obj: unknown): obj is object {
const type = typeof obj;
return type === 'function' || (type === 'object' && !!obj);
}

View File

@@ -0,0 +1,3 @@
export function isPlainObject(value: unknown): value is Record<string | symbol, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

View File

@@ -0,0 +1 @@
export const isRelativeURL = (str: string): boolean => /^[^\/]+\/[^\/].*$|^\/[^\/].*$/gim.test(str);

View File

@@ -0,0 +1,8 @@
/**
* Strips a leading '@' from a username if present.
*
* Federated usernames (e.g. @john.doe:matrix.org) are stored with a leading '@',
* while local usernames are not. This utility normalises both forms so callers
* that render `@{username}` don't accidentally produce `@@john.doe:matrix.org`.
*/
export const normalizeUsername = (username: string): string => (username.startsWith('@') ? username.slice(1) : username);

View File

@@ -0,0 +1,7 @@
export function omit<TObject, TKey extends keyof TObject>(obj: TObject, ...keys: TKey[]): Omit<TObject, TKey> {
const result = { ...obj };
keys.forEach((key) => {
delete result[key];
});
return result;
}

View File

@@ -0,0 +1,9 @@
export function parseCSV(csv: string, removeEmptyItems = true): string[] {
const items = csv.split(',').map((item) => item.trim());
if (removeEmptyItems) {
return items.filter(Boolean);
}
return items;
}

View File

@@ -0,0 +1,64 @@
function split(parameters: string, ignoreExtraUnquotedSpaces = true): string[] {
// Replace \n\r with a single \n
const line = parameters.replace(/\n\r/gm, '\n');
const items = line.split(/[ \n\r\t]/);
if (ignoreExtraUnquotedSpaces) {
return items.filter(Boolean);
}
return items;
}
export function parseParameters(parameters: string, ignoreExtraUnquotedSpaces = true): string[] {
if (!parameters.trim()) {
return [];
}
const match = parameters.match(/((["'])(?:(?=(\\?))\3.)*?\2)/gs);
let line = parameters;
if (!match) {
return split(line, ignoreExtraUnquotedSpaces);
}
match.forEach((item) => {
const newItem = item
// Replace start quote with SSA character
.replace(/(^['"])/g, '\u0086')
// Replace end quote with ESA character
.replace(/['"]$/g, '\u0087')
// Replace spaces inside the quotes with a Punctuation Space character
.replace(/ /g, '\u2008')
// Replace new lines inside the quotes with a PLD character
.replace(/\n/gm, '\u008B')
// Replace carriage returns inside the quotes with a PLU character
.replace(/\r/gm, '\u008C')
// Replace tabs inside the quotes with a VTS character
.replace(/\t/g, '\u008A');
line = line.replace(item, newItem);
});
// If two quoted parameters are not separated by a space, add one automatically
line = line.replace(/\u0087\u0086/g, '\u0087 \u0086');
const items = split(line, ignoreExtraUnquotedSpaces);
return items.map((item) =>
item
// Convert back the spaces from inside quotes
.replace(/\u2008/g, ' ')
// Convert back the new lines from inside quotes
.replace(/\u008B/g, '\n')
// Convert back the carriage returns from inside quotes
.replace(/\u008C/g, '\r')
// Convert back the tabs from inside quotes
.replace(/\u008A/g, '\t')
// Remove SSA and ESA characters
.replace(/[\u0086\u0087]/g, '')
// Unescape quotes
.replace(/\\\"/g, '"'),
);
}

View File

@@ -0,0 +1,5 @@
// default email engines - like gmail - will render texts with dots as an anchor tag.
// If we can, we should avoid that.
export const safeHtmlDots = (text: string): string => {
return text.replace(/\./g, '&#8228');
};

View File

@@ -0,0 +1,14 @@
/**
* @todo check alternatives for date/time formatting
*/
export const secondsToHHMMSS = (sec: number | string): string => {
if (typeof sec !== 'number') {
sec = parseFloat(sec);
}
const hours = Math.floor(sec / 3600);
const minutes = Math.floor((sec - hours * 3600) / 60);
const seconds = Math.round(sec - hours * 3600 - minutes * 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};

View File

@@ -0,0 +1,2 @@
// Promisified sleep function
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

View File

@@ -0,0 +1,113 @@
import { escapeRegExp } from '@rocket.chat/tools';
import DOMPurify from 'dompurify';
export function truncate(str: string, length: number): string {
return str.length > length ? `${str.slice(0, length - 3)}...` : str;
}
function makeString(object: unknown): string {
if (!object) return '';
return String(object);
}
function defaultToWhiteSpace(characters: unknown): string {
if (!characters) return '\\s';
if (typeof characters === 'object' && 'source' in characters) return (characters as { source: string }).source;
return `[${escapeRegExp(makeString(characters))}]`;
}
const nativeTrim = String.prototype.trim;
const nativeTrimLeft = String.prototype.trimLeft;
const nativeTrimRight = String.prototype.trimRight;
export function trim(_str: unknown, _characters?: unknown): string {
const str = makeString(_str);
if (!_characters && nativeTrim) return nativeTrim.call(str);
const characters = defaultToWhiteSpace(_characters);
return str.replace(new RegExp(`^${characters}+|${characters}+$`, 'g'), '');
}
export function ltrim(_str: unknown, _characters: unknown): string {
const str = makeString(_str);
if (!_characters && nativeTrimLeft) return nativeTrimLeft.call(str);
const characters = defaultToWhiteSpace(_characters);
return str.replace(new RegExp(`^${characters}+`), '');
}
export function rtrim(_str: unknown, _characters: unknown): string {
const str = makeString(_str);
if (!_characters && nativeTrimRight) return nativeTrimRight.call(str);
const characters = defaultToWhiteSpace(_characters);
return str.replace(new RegExp(`${characters}+$`), '');
}
export function capitalize(_str: unknown, lowercaseRest: boolean): string {
const str = makeString(_str);
const remainingChars = !lowercaseRest ? str.slice(1) : str.slice(1).toLowerCase();
return str.charAt(0).toUpperCase() + remainingChars;
}
export function stripTags(str: unknown): string {
return DOMPurify.sanitize(makeString(str)).replace(/<\/?[^>]+>/g, '');
}
export function strLeft(_str: unknown, _sep: unknown): string {
const str = makeString(_str);
const sep = makeString(_sep);
const pos = !sep ? -1 : str.indexOf(sep);
return ~pos ? str.slice(0, pos) : str;
}
export function strRight(_str: unknown, _sep: unknown): string {
const str = makeString(_str);
const sep = makeString(_sep);
const pos = !sep ? -1 : str.indexOf(sep);
return ~pos ? str.slice(pos + sep.length, str.length) : str;
}
export function strRightBack(_str: unknown, _sep: unknown): string {
const str = makeString(_str);
const sep = makeString(_sep);
const pos = !sep ? -1 : str.lastIndexOf(sep);
return ~pos ? str.slice(pos + sep.length, str.length) : str;
}
export function numberFormat(_number: number, dec: number, dsep?: string, tsep?: string): string {
if (isNaN(_number) || _number === null || _number === undefined) return '';
const number = _number.toFixed(~~dec);
tsep = typeof tsep === 'string' ? tsep : ',';
const parts = number.split('.');
const fnums = parts[0];
const decimals = parts[1] ? (dsep || '.') + parts[1] : '';
return fnums.replace(/(\d)(?=(?:\d{3})+$)/g, `$1${tsep}`) + decimals;
}
export function pad(_str: unknown, _length: number, padStr?: string, type: 'right' | 'left' | 'both' = 'right') {
const str = makeString(_str);
const length = ~~_length;
let padlen = 0;
if (!padStr) padStr = ' ';
else if (padStr.length > 1) padStr = padStr.charAt(0);
switch (type) {
case 'right':
padlen = length - str.length;
return str + padStr.repeat(padlen);
case 'both':
padlen = length - str.length;
return padStr.repeat(Math.ceil(padlen / 2)) + str + padStr.repeat(Math.floor(padlen / 2));
default: // 'left'
padlen = length - str.length;
return padStr.repeat(padlen) + str;
}
}
export function lrpad(str: unknown, length: number, padStr?: string): string {
return pad(str, length, padStr, 'both');
}

View File

@@ -0,0 +1,20 @@
import { throttle } from 'underscore';
export function throttledCounter(fn: (counter: number) => unknown, wait: number) {
let counter = 0;
const throttledFn = throttle(
() => {
fn(counter);
counter = 0;
},
wait,
{ leading: false },
);
return () => {
counter++;
throttledFn();
};
}

View File

@@ -0,0 +1,5 @@
export const availabilityErrors = {
NOT_CONFIGURED: 'video-conf-provider-not-configured',
NOT_ACTIVE: 'no-active-video-conf-provider',
NO_APP: 'no-videoconf-provider-app',
};