snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
16
apps/meteor/client/lib/utils/callWithErrorHandling.ts
Normal file
16
apps/meteor/client/lib/utils/callWithErrorHandling.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ServerMethodName, ServerMethodParameters, ServerMethodReturn } from '@rocket.chat/ddp-client';
|
||||
|
||||
import { sdk } from '../SDKClient';
|
||||
import { dispatchToastMessage } from '../toast';
|
||||
|
||||
export const callWithErrorHandling = async <M extends ServerMethodName>(
|
||||
method: M,
|
||||
...params: ServerMethodParameters<M>
|
||||
): Promise<ServerMethodReturn<M>> => {
|
||||
try {
|
||||
return await sdk.call(method, ...params);
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
36
apps/meteor/client/lib/utils/closeUnclosedCodeBlock.spec.ts
Normal file
36
apps/meteor/client/lib/utils/closeUnclosedCodeBlock.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { closeUnclosedCodeBlock } from '../../../lib/utils/closeUnclosedCodeBlock';
|
||||
|
||||
describe('closeUnclosedCodeBlock', () => {
|
||||
it('should return the text unchanged if there are no backticks', () => {
|
||||
expect(closeUnclosedCodeBlock('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should return the text unchanged if code block is already closed', () => {
|
||||
const text = '```\ncode\n```';
|
||||
expect(closeUnclosedCodeBlock(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('should append closing backticks when code block is unclosed', () => {
|
||||
const text = '```\ncode';
|
||||
expect(closeUnclosedCodeBlock(text)).toBe('```\ncode\n```');
|
||||
});
|
||||
|
||||
it('should handle code block with language specifier unclosed', () => {
|
||||
const text = '```javascript\nconst x = 1;';
|
||||
expect(closeUnclosedCodeBlock(text)).toBe('```javascript\nconst x = 1;\n```');
|
||||
});
|
||||
|
||||
it('should return unchanged text when there are multiple closed code blocks', () => {
|
||||
const text = '```\ncode1\n```\ntext\n```\ncode2\n```';
|
||||
expect(closeUnclosedCodeBlock(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('should close unclosed block when there is one closed block followed by an unclosed block', () => {
|
||||
const text = '```\ncode1\n```\ntext\n```\ncode2';
|
||||
expect(closeUnclosedCodeBlock(text)).toBe('```\ncode1\n```\ntext\n```\ncode2\n```');
|
||||
});
|
||||
|
||||
it('should return the text unchanged if it is empty', () => {
|
||||
expect(closeUnclosedCodeBlock('')).toBe('');
|
||||
});
|
||||
});
|
||||
172
apps/meteor/client/lib/utils/dateFormat.spec.ts
Normal file
172
apps/meteor/client/lib/utils/dateFormat.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @jest-environment <rootDir>/tests/environments/timezone.ts
|
||||
* @jest-environment-options {"timezone": "America/Argentina/Buenos_Aires"}
|
||||
*/
|
||||
import { formatDate, momentFormatToDateFns, toDate } from './dateFormat';
|
||||
|
||||
describe('momentFormatToDateFns', () => {
|
||||
it('maps locale tokens', () => {
|
||||
expect(momentFormatToDateFns('L')).toBe('P');
|
||||
expect(momentFormatToDateFns('LT')).toBe('p');
|
||||
expect(momentFormatToDateFns('LTS')).toBe('pp');
|
||||
expect(momentFormatToDateFns('LL')).toBe('PPP');
|
||||
expect(momentFormatToDateFns('LLL')).toBe('PPP p');
|
||||
expect(momentFormatToDateFns('LLLL')).toBe('EEEE, PPP p');
|
||||
});
|
||||
|
||||
it('maps common tokens', () => {
|
||||
expect(momentFormatToDateFns('YYYY-MM-DD HH:mm:ss')).toBe('yyyy-MM-dd HH:mm:ss');
|
||||
expect(momentFormatToDateFns('MMMM Do YYYY, h:mm:ss a')).toBe('MMMM do yyyy, h:mm:ss aaa');
|
||||
});
|
||||
|
||||
it('preserves AM/PM casing (Moment A = uppercase, a = lowercase)', () => {
|
||||
// date-fns `a` is always uppercase; `aaa` is lowercase. So Moment `a` → `aaa`.
|
||||
expect(momentFormatToDateFns('A')).toBe('a');
|
||||
expect(momentFormatToDateFns('a')).toBe('aaa');
|
||||
});
|
||||
|
||||
it('translates moment [literal] escape to date-fns single-quoted literal', () => {
|
||||
expect(momentFormatToDateFns('[Today at] LT')).toBe("'Today at' p");
|
||||
expect(momentFormatToDateFns('[Session started at] HH:mm [on] LL')).toBe("'Session started at' HH:mm 'on' PPP");
|
||||
});
|
||||
|
||||
it("escapes embedded single quotes inside literals as ''", () => {
|
||||
expect(momentFormatToDateFns("[it's] LT")).toBe("'it''s' p");
|
||||
});
|
||||
|
||||
it('drops empty literal blocks since date-fns has no empty-string syntax', () => {
|
||||
// In date-fns, '' represents a literal apostrophe, not an empty string.
|
||||
expect(momentFormatToDateFns('[] LT')).toBe(' p');
|
||||
});
|
||||
|
||||
it('quotes letters that are not Moment tokens (T in ISO 8601 separator)', () => {
|
||||
// In Moment, T is a literal; in date-fns T = ms timestamp. Must quote.
|
||||
expect(momentFormatToDateFns('YYYY-MM-DDTHH:mm:ss')).toBe("yyyy-MM-dd'T'HH:mm:ss");
|
||||
});
|
||||
|
||||
it('maps day-of-year tokens (DDD, DDDo, DDDD)', () => {
|
||||
expect(momentFormatToDateFns('DDD')).toBe('D');
|
||||
expect(momentFormatToDateFns('DDDo')).toBe('Do');
|
||||
expect(momentFormatToDateFns('DDDD')).toBe('DDD');
|
||||
});
|
||||
|
||||
it('maps day-of-week ordinal (do)', () => {
|
||||
expect(momentFormatToDateFns('do')).toBe('io');
|
||||
});
|
||||
|
||||
it('maps the 6-digit padded year (YYYYYY)', () => {
|
||||
expect(momentFormatToDateFns('YYYYYY')).toBe('yyyyyy');
|
||||
});
|
||||
|
||||
it('maps timezone abbreviation tokens (z, zz)', () => {
|
||||
expect(momentFormatToDateFns('z')).toBe('zzz');
|
||||
expect(momentFormatToDateFns('zz')).toBe('zzz');
|
||||
});
|
||||
|
||||
it('maps extended fractional-second tokens (SSSS…SSSSSSSSS)', () => {
|
||||
expect(momentFormatToDateFns('SSSS')).toBe('SSSS');
|
||||
expect(momentFormatToDateFns('SSSSSSSSS')).toBe('SSSSSSSSS');
|
||||
});
|
||||
|
||||
it('maps era year (y) and era name (N…NNNNN)', () => {
|
||||
expect(momentFormatToDateFns('y')).toBe('y');
|
||||
expect(momentFormatToDateFns('N')).toBe('G');
|
||||
expect(momentFormatToDateFns('NN')).toBe('GG');
|
||||
expect(momentFormatToDateFns('NNN')).toBe('GGG');
|
||||
expect(momentFormatToDateFns('NNNN')).toBe('GGGG');
|
||||
expect(momentFormatToDateFns('NNNNN')).toBe('GGGGG');
|
||||
});
|
||||
|
||||
it('maps lowercase locale variants (l, ll, lll, llll)', () => {
|
||||
expect(momentFormatToDateFns('l')).toBe('P');
|
||||
expect(momentFormatToDateFns('ll')).toBe('PP');
|
||||
expect(momentFormatToDateFns('lll')).toBe('PP p');
|
||||
expect(momentFormatToDateFns('llll')).toBe('EEE, PP p');
|
||||
});
|
||||
|
||||
it('preserves tokens that are identical between Moment and date-fns (kk, Q, ww)', () => {
|
||||
// Regression: these used to pass through unchanged; tokenizer now must list them
|
||||
// explicitly so they aren't quoted as literals.
|
||||
expect(momentFormatToDateFns('kk:mm')).toBe('kk:mm');
|
||||
expect(momentFormatToDateFns('k:mm')).toBe('k:mm');
|
||||
expect(momentFormatToDateFns('Q')).toBe('Q');
|
||||
expect(momentFormatToDateFns('ww')).toBe('ww');
|
||||
});
|
||||
|
||||
it('maps ISO week-of-year tokens (W, WW, Wo)', () => {
|
||||
expect(momentFormatToDateFns('W')).toBe('I');
|
||||
expect(momentFormatToDateFns('WW')).toBe('II');
|
||||
expect(momentFormatToDateFns('Wo')).toBe('Io');
|
||||
});
|
||||
|
||||
it('maps week-year tokens (gg/gggg locale, GG/GGGG ISO)', () => {
|
||||
expect(momentFormatToDateFns('gg')).toBe('YY');
|
||||
expect(momentFormatToDateFns('gggg')).toBe('YYYY');
|
||||
expect(momentFormatToDateFns('GG')).toBe('RR');
|
||||
expect(momentFormatToDateFns('GGGG')).toBe('RRRR');
|
||||
});
|
||||
|
||||
it('maps Moment timezone offset tokens to date-fns equivalents', () => {
|
||||
expect(momentFormatToDateFns('Z')).toBe('xxx');
|
||||
expect(momentFormatToDateFns('ZZ')).toBe('xx');
|
||||
expect(momentFormatToDateFns('Z ZZ')).toBe('xxx xx');
|
||||
expect(momentFormatToDateFns('LT Z')).toBe('p xxx');
|
||||
expect(momentFormatToDateFns('YYYY-MM-DDTHH:mm:ssZ')).toBe("yyyy-MM-dd'T'HH:mm:ssxxx");
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
const sample = new Date('2026-04-24T20:30:45');
|
||||
|
||||
it('formats literal blocks with locale tokens without throwing', () => {
|
||||
expect(() => formatDate(sample, '[Today at] LT')).not.toThrow();
|
||||
expect(formatDate(sample, '[Today at] LT')).toMatch(/^Today at /);
|
||||
});
|
||||
|
||||
it('keeps the ISO 8601 T as a literal instead of inserting a ms timestamp', () => {
|
||||
expect(formatDate(sample, 'YYYY-MM-DDTHH:mm:ss')).toBe('2026-04-24T20:30:45');
|
||||
});
|
||||
|
||||
it('does not throw on Moment timezone tokens', () => {
|
||||
expect(() => formatDate(sample, 'LT Z')).not.toThrow();
|
||||
expect(() => formatDate(sample, 'Z ZZ')).not.toThrow();
|
||||
expect(() => formatDate(sample, 'YYYY-MM-DDTHH:mm:ssZ')).not.toThrow();
|
||||
});
|
||||
|
||||
it('falls back instead of crashing on a malformed format', () => {
|
||||
// Unterminated bracket — translator buffers but date-fns may still refuse.
|
||||
expect(() => formatDate(sample, '[unterminated')).not.toThrow();
|
||||
});
|
||||
|
||||
it('formats week-year tokens without throwing on date-fns Y warning', () => {
|
||||
// date-fns refuses Y/YY/YYYY without useAdditionalWeekYearTokens — verify the
|
||||
// option is wired through.
|
||||
expect(() => formatDate(sample, 'gggg')).not.toThrow();
|
||||
expect(formatDate(sample, 'gggg')).toMatch(/^\d{4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDate', () => {
|
||||
it('parses a bare yyyy-MM-dd string at LOCAL midnight (not UTC midnight)', () => {
|
||||
const d = toDate('2026-07-02');
|
||||
// Pre-fix `new Date('2026-07-02')` (UTC midnight) is the previous day / 21:00 here.
|
||||
expect(d.getFullYear()).toBe(2026);
|
||||
expect(d.getMonth()).toBe(6); // July (0-indexed)
|
||||
expect(d.getDate()).toBe(2);
|
||||
expect(d.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps full ISO datetimes as instants (UTC preserved)', () => {
|
||||
expect(toDate('2026-07-02T15:30:00.000Z').toISOString()).toBe('2026-07-02T15:30:00.000Z');
|
||||
});
|
||||
|
||||
it('returns Date objects unchanged', () => {
|
||||
const d = new Date();
|
||||
expect(toDate(d)).toBe(d);
|
||||
});
|
||||
|
||||
it('accepts epoch milliseconds', () => {
|
||||
const ms = Date.UTC(2026, 6, 2, 12);
|
||||
expect(toDate(ms).getTime()).toBe(ms);
|
||||
});
|
||||
});
|
||||
262
apps/meteor/client/lib/utils/dateFormat.ts
Normal file
262
apps/meteor/client/lib/utils/dateFormat.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { format, formatDistanceToNow, formatDuration, intervalToDuration, differenceInCalendarDays, parse } from 'date-fns';
|
||||
import type { Locale } from 'date-fns';
|
||||
|
||||
export type DateInput = string | Date | number;
|
||||
|
||||
const FALLBACK_FORMAT = 'PPP p'; // date-fns equivalent of moment's LLL
|
||||
|
||||
const MOMENT_TO_DATE_FNS_TOKENS: ReadonlyArray<readonly [moment: string, dateFns: string]> = (
|
||||
[
|
||||
// Locale formats
|
||||
['LLLL', 'EEEE, PPP p'],
|
||||
['LTS', 'pp'],
|
||||
['LLL', 'PPP p'],
|
||||
['LL', 'PPP'],
|
||||
['LT', 'p'],
|
||||
['L', 'P'],
|
||||
// Locale formats — short variants (Moment lowercase l = no zero-padding;
|
||||
// date-fns has no equivalent without zero-padding, so PP/P is the closest match)
|
||||
['llll', 'EEE, PP p'],
|
||||
['lll', 'PP p'],
|
||||
['ll', 'PP'],
|
||||
['l', 'P'],
|
||||
// Year
|
||||
['YYYYYY', 'yyyyyy'], // 6-digit padded; Moment includes a +/- sign that date-fns omits
|
||||
['YYYY', 'yyyy'],
|
||||
['YY', 'yy'],
|
||||
['Y', 'yyyy'],
|
||||
['y', 'y'], // Moment lowercase y = era year (always positive); equivalent to calendar year for AD dates
|
||||
// Era — Moment N/NN/NNN are abbreviated, NNNN wide, NNNNN narrow
|
||||
['NNNNN', 'GGGGG'],
|
||||
['NNNN', 'GGGG'],
|
||||
['NNN', 'GGG'],
|
||||
['NN', 'GG'],
|
||||
['N', 'G'],
|
||||
// Month
|
||||
['MMMM', 'MMMM'],
|
||||
['MMM', 'MMM'],
|
||||
['MM', 'MM'],
|
||||
['Mo', 'Mo'],
|
||||
['M', 'M'],
|
||||
// Day of month
|
||||
['Do', 'do'],
|
||||
['DD', 'dd'],
|
||||
['D', 'd'],
|
||||
// Day of year — needs `useAdditionalDayOfYearTokens` on date-fns
|
||||
['DDDD', 'DDD'],
|
||||
['DDDo', 'Do'],
|
||||
['DDD', 'D'],
|
||||
// Day of week
|
||||
['dddd', 'EEEE'],
|
||||
['ddd', 'EEE'],
|
||||
['dd', 'EEEEEE'],
|
||||
// Numeric day of week — semantics shift: Moment d/e produce 0-6 (Sun=0),
|
||||
// date-fns i/c produce 1-7. No exact equivalent exists.
|
||||
['do', 'io'],
|
||||
['d', 'i'],
|
||||
['e', 'c'],
|
||||
// ISO week of year
|
||||
['WW', 'II'],
|
||||
['Wo', 'Io'],
|
||||
['W', 'I'],
|
||||
// Week-numbering year — Moment gg/gggg = locale, GG/GGGG = ISO.
|
||||
// date-fns Y/YY/YYYY (locale) requires `useAdditionalWeekYearTokens`.
|
||||
['gggg', 'YYYY'],
|
||||
['gg', 'YY'],
|
||||
['GGGG', 'RRRR'],
|
||||
['GG', 'RR'],
|
||||
// Hour (H = 0-23, h = 1-12, k = 1-24)
|
||||
['HH', 'HH'],
|
||||
['H', 'H'],
|
||||
['hh', 'hh'],
|
||||
['h', 'h'],
|
||||
['kk', 'kk'],
|
||||
['k', 'k'],
|
||||
// Minute
|
||||
['mm', 'mm'],
|
||||
['m', 'm'],
|
||||
// Second
|
||||
['ss', 'ss'],
|
||||
['s', 's'],
|
||||
// Fractional second — JS Date only has ms precision; SSSS+ pad with zeros in both
|
||||
['SSSSSSSSS', 'SSSSSSSSS'],
|
||||
['SSSSSSSS', 'SSSSSSSS'],
|
||||
['SSSSSSS', 'SSSSSSS'],
|
||||
['SSSSSS', 'SSSSSS'],
|
||||
['SSSSS', 'SSSSS'],
|
||||
['SSSS', 'SSSS'],
|
||||
['SSS', 'SSS'],
|
||||
['SS', 'SS'],
|
||||
['S', 'S'],
|
||||
// AM/PM — date-fns `a`/`aa` are always uppercase (AM/PM); `aaa` is lowercase (am/pm)
|
||||
['A', 'a'],
|
||||
['a', 'aaa'],
|
||||
// Quarter
|
||||
['QQQQ', 'QQQQ'],
|
||||
['QQQ', 'QQQ'],
|
||||
['QQ', 'QQ'],
|
||||
['Qo', 'Qo'],
|
||||
['Q', 'Q'],
|
||||
// Week of year (locale)
|
||||
['ww', 'ww'],
|
||||
['wo', 'wo'],
|
||||
['w', 'w'],
|
||||
// ISO day of week (Moment E = 1-7 → date-fns i)
|
||||
['E', 'i'],
|
||||
// Timezone offset (Moment Z = +05:00, ZZ = +0500)
|
||||
['ZZ', 'xx'],
|
||||
['Z', 'xxx'],
|
||||
// Timezone abbreviated name (Moment z/zz = "EST"). date-fns has no
|
||||
// real abbreviation; zzz produces "GMT-3" in browsers. Closest available.
|
||||
['zz', 'zzz'],
|
||||
['z', 'zzz'],
|
||||
// Unix timestamp (Moment X = seconds, x = milliseconds)
|
||||
['X', 't'],
|
||||
['x', 'T'],
|
||||
] as Array<[moment: string, dateFns: string]>
|
||||
).sort((a, b) => b[0].length - a[0].length);
|
||||
|
||||
const LITERAL_LETTER = /[a-zA-Z]/;
|
||||
|
||||
/**
|
||||
* Translate a Moment.js format string to a date-fns format string.
|
||||
*
|
||||
* The two libraries diverge in two important ways that this function bridges:
|
||||
* 1. Moment treats unrecognized letters as literals (so `T` in `YYYY-MM-DDTHH:mm:ss`
|
||||
* prints as a literal `T`); date-fns reserves every letter as a token, so an
|
||||
* unmapped letter either produces wrong output (`T` = ms timestamp) or throws.
|
||||
* 2. Moment uses `Z`/`ZZ` for timezone offsets; date-fns has no `Z` token at all.
|
||||
*
|
||||
* The translator tokenizes left-to-right: it recognizes Moment's `[literal]` escape
|
||||
* syntax, longest-matches a known Moment token, and quotes any other letter as a
|
||||
* date-fns literal so admin-configured formats keep working after the moment→date-fns
|
||||
* migration. Used by Message_DateFormat / Message_TimeFormat / Message_TimeAndDateFormat.
|
||||
*/
|
||||
export const momentFormatToDateFns = (momentFormat: string): string => {
|
||||
let out = '';
|
||||
let literal = '';
|
||||
let i = 0;
|
||||
|
||||
const flushLiteral = () => {
|
||||
if (literal) {
|
||||
out += `'${literal.replace(/'/g, "''")}'`;
|
||||
literal = '';
|
||||
}
|
||||
};
|
||||
|
||||
while (i < momentFormat.length) {
|
||||
const ch = momentFormat[i];
|
||||
|
||||
if (ch === '[') {
|
||||
const end = momentFormat.indexOf(']', i + 1);
|
||||
if (end !== -1) {
|
||||
literal += momentFormat.slice(i + 1, end);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let matched = false;
|
||||
for (const [mom, df] of MOMENT_TO_DATE_FNS_TOKENS) {
|
||||
if (momentFormat.startsWith(mom, i)) {
|
||||
flushLiteral();
|
||||
out += df;
|
||||
i += mom.length;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
if (LITERAL_LETTER.test(ch)) {
|
||||
literal += ch;
|
||||
} else {
|
||||
flushLiteral();
|
||||
out += ch;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
flushLiteral();
|
||||
return out;
|
||||
};
|
||||
|
||||
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Normalize a date input to a Date.
|
||||
*
|
||||
* Calendar-date-only strings (`yyyy-MM-dd`, e.g. produced by an `<input type="date">`)
|
||||
* are parsed in LOCAL time, the way Moment.js did before the date-fns migration.
|
||||
* `new Date('2026-07-02')` instead parses as UTC midnight, which renders as the
|
||||
* previous day in negative-offset timezones (e.g. UTC-3 shows "Jul 1"). Full ISO
|
||||
* datetimes, numbers and Date objects keep their original instant semantics.
|
||||
*/
|
||||
export const toDate = (date: DateInput): Date => {
|
||||
if (date instanceof Date) {
|
||||
return date;
|
||||
}
|
||||
if (typeof date === 'string' && DATE_ONLY_RE.test(date)) {
|
||||
return parse(date, 'yyyy-MM-dd', new Date());
|
||||
}
|
||||
return new Date(date);
|
||||
};
|
||||
|
||||
const safeFormat = (d: Date, momentFormat: string, locale?: Locale): string => {
|
||||
const options = {
|
||||
...(locale && { locale }),
|
||||
useAdditionalWeekYearTokens: true,
|
||||
useAdditionalDayOfYearTokens: true,
|
||||
};
|
||||
try {
|
||||
return format(d, momentFormatToDateFns(momentFormat), options);
|
||||
} catch {
|
||||
return format(d, FALLBACK_FORMAT, options);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDate = (date: DateInput, formatStr: string, locale?: Locale): string => {
|
||||
return safeFormat(toDate(date), formatStr, locale);
|
||||
};
|
||||
|
||||
export const formatTimeAgo = (
|
||||
date: DateInput,
|
||||
options: {
|
||||
yesterdayLabel: string;
|
||||
sameDayFormat: string;
|
||||
lastDayFormat?: string; // if set, show "Yesterday" + format(time, lastDayFormat)
|
||||
lastWeekFormat: string;
|
||||
otherFormat: string;
|
||||
otherYearFormat: string;
|
||||
},
|
||||
locale?: Locale,
|
||||
): string => {
|
||||
const d = toDate(date);
|
||||
const now = new Date();
|
||||
const diffDays = differenceInCalendarDays(now, d);
|
||||
|
||||
if (diffDays === 0) {
|
||||
return safeFormat(d, options.sameDayFormat, locale);
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
if (options.lastDayFormat) {
|
||||
return `${options.yesterdayLabel} ${safeFormat(d, options.lastDayFormat, locale)}`;
|
||||
}
|
||||
return options.yesterdayLabel;
|
||||
}
|
||||
if (diffDays > 1 && diffDays < 7) {
|
||||
return safeFormat(d, options.lastWeekFormat, locale);
|
||||
}
|
||||
const diffYears = now.getFullYear() - d.getFullYear();
|
||||
const fmt = diffYears !== 0 ? options.otherYearFormat : options.otherFormat;
|
||||
return safeFormat(d, fmt, locale);
|
||||
};
|
||||
|
||||
export const formatFromNow = (date: DateInput, addSuffix: boolean, locale?: Locale): string => {
|
||||
return formatDistanceToNow(toDate(date), { addSuffix, locale });
|
||||
};
|
||||
|
||||
export const formatDurationMs = (timeMs: number, locale?: Locale): string => {
|
||||
const duration = intervalToDuration({ start: 0, end: timeMs });
|
||||
return formatDuration(duration, { locale });
|
||||
};
|
||||
15
apps/meteor/client/lib/utils/detectEmoji.ts
Normal file
15
apps/meteor/client/lib/utils/detectEmoji.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { emoji } from '../emoji';
|
||||
|
||||
export const detectEmoji = (text: string): { name: string; className: string; image?: string; content: string }[] => {
|
||||
const html = Object.values(emoji.packages)
|
||||
.reverse()
|
||||
.reduce((html, { render }) => render(html), text);
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return Array.from(div.querySelectorAll('span')).map((span) => ({
|
||||
name: span.title,
|
||||
className: `${span.className} ${span.style.backgroundImage ? 'rcx-message__emoji--custom' : ''}`,
|
||||
image: span.style.backgroundImage || undefined,
|
||||
content: span.innerText,
|
||||
}));
|
||||
};
|
||||
13
apps/meteor/client/lib/utils/fireGlobalEvent.ts
Normal file
13
apps/meteor/client/lib/utils/fireGlobalEvent.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { fireGlobalEventBase } from './fireGlobalEventBase';
|
||||
import { settings } from '../settings';
|
||||
|
||||
export const fireGlobalEvent = (eventName: string, detail?: unknown): void => {
|
||||
const dispatchIframeMessage = fireGlobalEventBase(eventName, detail);
|
||||
|
||||
const enabled = settings.peek('Iframe_Integration_send_enable');
|
||||
if (enabled === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchIframeMessage(enabled, settings.peek('Iframe_Integration_send_target_origin') ?? '*');
|
||||
};
|
||||
59
apps/meteor/client/lib/utils/fireGlobalEventBase.spec.ts
Normal file
59
apps/meteor/client/lib/utils/fireGlobalEventBase.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { fireGlobalEventBase } from './fireGlobalEventBase';
|
||||
|
||||
const postMessageMock = jest.fn();
|
||||
const dispatchEventMock = jest.fn();
|
||||
|
||||
const originalDispatch = window.dispatchEvent;
|
||||
const originalPostMessage = parent.postMessage;
|
||||
|
||||
beforeAll(() => {
|
||||
window.dispatchEvent = dispatchEventMock;
|
||||
parent.postMessage = postMessageMock;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
postMessageMock.mockClear();
|
||||
dispatchEventMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
window.dispatchEvent = originalDispatch;
|
||||
parent.postMessage = originalPostMessage;
|
||||
});
|
||||
|
||||
it('should dispatch event but not post message', () => {
|
||||
const detail = 'test-detail';
|
||||
const postMessage = fireGlobalEventBase('test-event', detail);
|
||||
postMessage(false, '');
|
||||
|
||||
expect(postMessageMock).not.toHaveBeenCalled();
|
||||
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const result = dispatchEventMock.mock.lastCall[0];
|
||||
expect(result).toBeInstanceOf(CustomEvent);
|
||||
expect(result.detail).toBe(detail);
|
||||
expect(result.type).toBe('test-event');
|
||||
});
|
||||
|
||||
it('should dispatch event and post message', () => {
|
||||
const detail = 'test-detail';
|
||||
const origin = 'test-origin';
|
||||
const postMessage = fireGlobalEventBase('test-event', detail);
|
||||
postMessage(true, origin);
|
||||
|
||||
expect(postMessageMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const dispatchResult = dispatchEventMock.mock.lastCall[0];
|
||||
expect(dispatchResult).toBeInstanceOf(CustomEvent);
|
||||
expect(dispatchResult.detail).toBe(detail);
|
||||
expect(dispatchResult.type).toBe('test-event');
|
||||
|
||||
const [postEventResult, originResult] = postMessageMock.mock.lastCall;
|
||||
expect(postEventResult).toBeInstanceOf(Object);
|
||||
expect(originResult).toBe(origin);
|
||||
expect(postEventResult.data).toBe(detail);
|
||||
expect(postEventResult.eventName).toBe('test-event');
|
||||
});
|
||||
18
apps/meteor/client/lib/utils/fireGlobalEventBase.ts
Normal file
18
apps/meteor/client/lib/utils/fireGlobalEventBase.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const fireGlobalEventBase = (eventName: string, detail?: unknown) => {
|
||||
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
|
||||
const dispatchMessage = (iframeSendEnabled: boolean, sendTargetOrigin: string) => {
|
||||
if (!iframeSendEnabled) {
|
||||
return;
|
||||
}
|
||||
parent.postMessage(
|
||||
{
|
||||
eventName,
|
||||
data: detail,
|
||||
},
|
||||
sendTargetOrigin,
|
||||
);
|
||||
};
|
||||
|
||||
return dispatchMessage;
|
||||
};
|
||||
14
apps/meteor/client/lib/utils/formatBytes.ts
Normal file
14
apps/meteor/client/lib/utils/formatBytes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const formatBytes = (bytes: number, decimals?: number): string => {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes';
|
||||
}
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals || 0;
|
||||
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
};
|
||||
33
apps/meteor/client/lib/utils/getAvatarAsPng.ts
Normal file
33
apps/meteor/client/lib/utils/getAvatarAsPng.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
import { getUserAvatarURL } from '../getUserAvatarURL';
|
||||
|
||||
export const getAvatarAsPng = (username: IUser['username'], cb: (dataURL: string) => void): (() => void) => {
|
||||
const image = new Image();
|
||||
|
||||
const onLoad = (): void => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('failed to get canvas context');
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0);
|
||||
try {
|
||||
return cb(canvas.toDataURL('image/png'));
|
||||
} catch (e) {
|
||||
return cb('');
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (): void => cb('');
|
||||
|
||||
image.onload = onLoad;
|
||||
image.onerror = onError;
|
||||
image.src = getUserAvatarURL(username || '') as string;
|
||||
|
||||
return onError;
|
||||
};
|
||||
30
apps/meteor/client/lib/utils/getCodeSettingError.spec.ts
Normal file
30
apps/meteor/client/lib/utils/getCodeSettingError.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getCodeSettingError } from './getCodeSettingError';
|
||||
|
||||
describe('getCodeSettingError', () => {
|
||||
it('returns the JSON error key for malformed application/json', () => {
|
||||
expect(getCodeSettingError('application/json', '{ invalid')).toBe('Invalid_JSON');
|
||||
});
|
||||
|
||||
it('returns undefined for valid application/json', () => {
|
||||
expect(getCodeSettingError('application/json', '{}')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('treats valid but falsy JSON values as valid', () => {
|
||||
expect(getCodeSettingError('application/json', 'false')).toBe(undefined);
|
||||
expect(getCodeSettingError('application/json', 'null')).toBe(undefined);
|
||||
expect(getCodeSettingError('application/json', '0')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('treats an empty string as valid', () => {
|
||||
expect(getCodeSettingError('application/json', '')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for a mime type without a validator', () => {
|
||||
expect(getCodeSettingError('text/html', '<not json>')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns undefined when code or value is not a validatable string', () => {
|
||||
expect(getCodeSettingError(undefined, '{ invalid')).toBe(undefined);
|
||||
expect(getCodeSettingError('application/json', undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
17
apps/meteor/client/lib/utils/getCodeSettingError.ts
Normal file
17
apps/meteor/client/lib/utils/getCodeSettingError.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { isJSON } from '../../../lib/utils/isJSON';
|
||||
|
||||
type CodeSettingValidator = (value: string) => TranslationKey | undefined;
|
||||
|
||||
const validatorsByMimeType: Record<string, CodeSettingValidator> = {
|
||||
'application/json': (value) => (value === '' || isJSON(value) ? undefined : 'Invalid_JSON'),
|
||||
};
|
||||
|
||||
export const getCodeSettingError = (code: string | undefined, value: unknown): TranslationKey | undefined => {
|
||||
if (typeof value !== 'string' || code === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return validatorsByMimeType[code]?.(value);
|
||||
};
|
||||
21
apps/meteor/client/lib/utils/getConfig.ts
Normal file
21
apps/meteor/client/lib/utils/getConfig.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const readLocalStorage = (key: string): string | null => {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getConfig = <T>(key: string, defaultValue?: T): string | null | T => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const storedItem = searchParams.get(key) || readLocalStorage(`rc-config-${key}`);
|
||||
|
||||
return storedItem ?? defaultValue ?? null;
|
||||
};
|
||||
|
||||
export const getNumericConfig = (key: string, defaultValue: number): number => {
|
||||
const parsed = Number(getConfig(key, defaultValue));
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||
};
|
||||
12
apps/meteor/client/lib/utils/getDateRange.ts
Normal file
12
apps/meteor/client/lib/utils/getDateRange.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { startOfDay, endOfDay } from 'date-fns';
|
||||
|
||||
export const getDateRange = (): {
|
||||
start: string;
|
||||
end: string;
|
||||
} => {
|
||||
const now = new Date();
|
||||
return {
|
||||
start: startOfDay(now).toISOString(),
|
||||
end: endOfDay(now).toISOString(),
|
||||
};
|
||||
};
|
||||
22
apps/meteor/client/lib/utils/getSubscriptionDraft.spec.ts
Normal file
22
apps/meteor/client/lib/utils/getSubscriptionDraft.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getSubscriptionDraft } from './getSubscriptionDraft';
|
||||
|
||||
it('should return undefined when there is no draft', () => {
|
||||
expect(getSubscriptionDraft({})).toBeUndefined();
|
||||
expect(getSubscriptionDraft({ threadDrafts: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the main composer draft when present', () => {
|
||||
expect(getSubscriptionDraft({ draft: 'main draft' })).toBe('main draft');
|
||||
});
|
||||
|
||||
it('should prefer the main composer draft over thread drafts', () => {
|
||||
expect(getSubscriptionDraft({ draft: 'main draft', threadDrafts: { t1: 'thread draft' } })).toBe('main draft');
|
||||
});
|
||||
|
||||
it('should fall back to a thread draft when there is no main draft', () => {
|
||||
expect(getSubscriptionDraft({ threadDrafts: { t1: 'thread draft' } })).toBe('thread draft');
|
||||
});
|
||||
|
||||
it('should return the first non-empty thread draft', () => {
|
||||
expect(getSubscriptionDraft({ threadDrafts: { t1: '', t2: 'second' } })).toBe('second');
|
||||
});
|
||||
9
apps/meteor/client/lib/utils/getSubscriptionDraft.ts
Normal file
9
apps/meteor/client/lib/utils/getSubscriptionDraft.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ISubscription } from '@rocket.chat/core-typings';
|
||||
|
||||
export const getSubscriptionDraft = (subscription: Pick<ISubscription, 'draft' | 'threadDrafts'>): string | undefined => {
|
||||
if (subscription.draft) {
|
||||
return subscription.draft;
|
||||
}
|
||||
|
||||
return subscription.threadDrafts && Object.values(subscription.threadDrafts).find(Boolean);
|
||||
};
|
||||
18
apps/meteor/client/lib/utils/getUidDirectMessage.ts
Normal file
18
apps/meteor/client/lib/utils/getUidDirectMessage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
export const getUidDirectMessage = (room: Pick<IRoom, 't' | 'uids' | 'usernames'>, uid?: IUser['_id']) => {
|
||||
if (room.t !== 'd' || !room.uids?.length || room.uids.length > 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const partner = room.uids.find((_uid) => _uid !== uid);
|
||||
if (partner) {
|
||||
return partner;
|
||||
}
|
||||
|
||||
if (room.uids.length === 1 && room.usernames?.length === 1) {
|
||||
return uid;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
4
apps/meteor/client/lib/utils/getUserEmailVerified.ts
Normal file
4
apps/meteor/client/lib/utils/getUserEmailVerified.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
export const getUserEmailVerified = (user: Pick<IUser, 'emails'>): boolean | undefined =>
|
||||
Array.isArray(user.emails) ? user.emails.find(({ verified }) => !!verified)?.verified : undefined;
|
||||
15
apps/meteor/client/lib/utils/isIE11.ts
Normal file
15
apps/meteor/client/lib/utils/isIE11.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const isIE11: boolean = ((): boolean => {
|
||||
const { userAgent } = window.navigator;
|
||||
const msieIdx = userAgent.indexOf('MSIE');
|
||||
|
||||
if (msieIdx > 0) {
|
||||
return parseInt(userAgent.substring(msieIdx + 5, userAgent.indexOf('.', msieIdx))) === 11;
|
||||
}
|
||||
|
||||
// If MSIE detection fails, check the Trident engine version
|
||||
if (navigator.userAgent.match(/Trident\/7\./)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
1
apps/meteor/client/lib/utils/isIOsDevice.ts
Normal file
1
apps/meteor/client/lib/utils/isIOsDevice.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isIOsDevice = !!navigator.platform.match(/iPhone|iPod|iPad/);
|
||||
9
apps/meteor/client/lib/utils/isLayoutEmbedded.ts
Normal file
9
apps/meteor/client/lib/utils/isLayoutEmbedded.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { router } from '../../providers/RouterProvider';
|
||||
|
||||
let embedded = false;
|
||||
|
||||
router.subscribeToRouteChange(() => {
|
||||
embedded = router.getSearchParameters().layout === 'embedded';
|
||||
});
|
||||
|
||||
export const isLayoutEmbedded = () => embedded;
|
||||
12
apps/meteor/client/lib/utils/isOverLicenseLimits.ts
Normal file
12
apps/meteor/client/lib/utils/isOverLicenseLimits.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { LicenseLimitKind } from '@rocket.chat/core-typings';
|
||||
|
||||
type Limits = Record<
|
||||
LicenseLimitKind,
|
||||
{
|
||||
max: number;
|
||||
value?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
export const isOverLicenseLimits = (limits: Limits): boolean =>
|
||||
Object.values(limits).some((limit) => limit.value !== undefined && limit.max > 0 && limit.value > limit.max);
|
||||
12
apps/meteor/client/lib/utils/isPreviewableImage.ts
Normal file
12
apps/meteor/client/lib/utils/isPreviewableImage.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const PREVIEWABLE_IMAGE_MIMES = new Set([
|
||||
'image/apng',
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export const isPreviewableImage = (mimeType: string): boolean => PREVIEWABLE_IMAGE_MIMES.has(mimeType.toLowerCase());
|
||||
26
apps/meteor/client/lib/utils/isRTLScriptLanguage.spec.ts
Normal file
26
apps/meteor/client/lib/utils/isRTLScriptLanguage.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isRTLScriptLanguage } from './isRTLScriptLanguage';
|
||||
|
||||
const testCases = [
|
||||
['en', false],
|
||||
['ar', true],
|
||||
['dv', true],
|
||||
['fa', true],
|
||||
['he', true],
|
||||
['ku', true],
|
||||
['ps', true],
|
||||
['sd', true],
|
||||
['ug', true],
|
||||
['ur', true],
|
||||
['yi', true],
|
||||
['ar', true],
|
||||
['ar-LY', true],
|
||||
['dv-MV', true],
|
||||
['', false],
|
||||
] as const;
|
||||
|
||||
testCases.forEach(([parameter, expectedResult]) => {
|
||||
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => {
|
||||
const result = isRTLScriptLanguage(parameter);
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
10
apps/meteor/client/lib/utils/isRTLScriptLanguage.ts
Normal file
10
apps/meteor/client/lib/utils/isRTLScriptLanguage.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const isRTLScriptLanguage = (lang: string): boolean => {
|
||||
const language = lang || 'en';
|
||||
const [languageCode] = language.split('-');
|
||||
|
||||
if (!languageCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ['ar', 'dv', 'fa', 'he', 'ku', 'ps', 'sd', 'ug', 'ur', 'yi'].includes(languageCode);
|
||||
};
|
||||
84
apps/meteor/client/lib/utils/isSafeAvatarUrl.spec.ts
Normal file
84
apps/meteor/client/lib/utils/isSafeAvatarUrl.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { isSafeAvatarUrl } from './isSafeAvatarUrl';
|
||||
|
||||
describe('valid URLs', () => {
|
||||
it('should accept HTTPS URLs', () => {
|
||||
expect(isSafeAvatarUrl('https://example.com/avatar.png')).toBe(true);
|
||||
expect(isSafeAvatarUrl('https://cdn.example.com/user/123/avatar.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept HTTP URLs', () => {
|
||||
expect(isSafeAvatarUrl('http://example.com/avatar.png')).toBe(true);
|
||||
expect(isSafeAvatarUrl('http://localhost:3000/avatar.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept data URLs with image MIME types', () => {
|
||||
expect(isSafeAvatarUrl('data:image/png;base64,iVBORw0KGgoAAAANSUhEUg')).toBe(true);
|
||||
expect(isSafeAvatarUrl('data:image/jpeg;base64,/9j/4AAQSkZJRg')).toBe(true);
|
||||
expect(isSafeAvatarUrl('data:image/gif;base64,R0lGODlh')).toBe(true);
|
||||
expect(isSafeAvatarUrl('data:image/webp;base64,UklGRiQA')).toBe(true);
|
||||
expect(isSafeAvatarUrl('data:image/svg+xml;base64,PHN2Zw')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive for protocols', () => {
|
||||
expect(isSafeAvatarUrl('HTTPS://example.com/avatar.png')).toBe(true);
|
||||
expect(isSafeAvatarUrl('HTTP://example.com/avatar.png')).toBe(true);
|
||||
expect(isSafeAvatarUrl('DATA:IMAGE/PNG;base64,iVBORw0KGgoAAAANSUhEUg')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid URLs', () => {
|
||||
it('should reject javascript: protocol', () => {
|
||||
expect(isSafeAvatarUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isSafeAvatarUrl('javascript:void(0)')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject file: protocol', () => {
|
||||
expect(isSafeAvatarUrl('file:///etc/passwd')).toBe(false);
|
||||
expect(isSafeAvatarUrl('file:///C:/Windows/System32')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject data: URLs with non-image MIME types', () => {
|
||||
expect(isSafeAvatarUrl('data:text/html;base64,PHNjcmlwdD4')).toBe(false);
|
||||
expect(isSafeAvatarUrl('data:text/javascript;base64,YWxlcnQoMSk')).toBe(false);
|
||||
expect(isSafeAvatarUrl('data:application/javascript;base64,YWxlcnQoMSk')).toBe(false);
|
||||
expect(isSafeAvatarUrl('data:text/plain;base64,SGVsbG8=')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject ftp: protocol', () => {
|
||||
expect(isSafeAvatarUrl('ftp://example.com/avatar.png')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject malformed URLs', () => {
|
||||
expect(isSafeAvatarUrl('not a url')).toBe(false);
|
||||
expect(isSafeAvatarUrl('htp://missing-t.com')).toBe(false);
|
||||
expect(isSafeAvatarUrl('://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty strings', () => {
|
||||
expect(isSafeAvatarUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject other dangerous protocols', () => {
|
||||
expect(isSafeAvatarUrl('vbscript:msgbox(1)')).toBe(false);
|
||||
expect(isSafeAvatarUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL edge cases', () => {
|
||||
it('should handle URLs with query parameters', () => {
|
||||
expect(isSafeAvatarUrl('https://example.com/avatar.png?size=large&format=webp')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle URLs with fragments', () => {
|
||||
expect(isSafeAvatarUrl('https://example.com/avatar.png#profile')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle URLs with authentication', () => {
|
||||
expect(isSafeAvatarUrl('https://user:pass@example.com/avatar.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle URLs with non-standard ports', () => {
|
||||
expect(isSafeAvatarUrl('http://example.com:8080/avatar.png')).toBe(true);
|
||||
expect(isSafeAvatarUrl('https://example.com:443/avatar.png')).toBe(true);
|
||||
});
|
||||
});
|
||||
17
apps/meteor/client/lib/utils/isSafeAvatarUrl.ts
Normal file
17
apps/meteor/client/lib/utils/isSafeAvatarUrl.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function isSafeAvatarUrl(urlString: string): boolean {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const protocol = url.protocol.toLowerCase();
|
||||
|
||||
if (protocol === 'http:' || protocol === 'https:') {
|
||||
return true;
|
||||
}
|
||||
if (protocol === 'data:') {
|
||||
return urlString.toLowerCase().startsWith('data:image/');
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
8
apps/meteor/client/lib/utils/isValidImageFormat.ts
Normal file
8
apps/meteor/client/lib/utils/isValidImageFormat.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const isValidImageFormat = (dataURL: string): Promise<boolean> => {
|
||||
const img = new Image();
|
||||
return new Promise((resolve) => {
|
||||
img.onload = (): void => resolve(true);
|
||||
img.onerror = (): void => resolve(false);
|
||||
img.src = dataURL;
|
||||
});
|
||||
};
|
||||
36
apps/meteor/client/lib/utils/keyCodes.ts
Normal file
36
apps/meteor/client/lib/utils/keyCodes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const keyCodes = {
|
||||
TAB: 9,
|
||||
CARRIAGE_RETURN: 10,
|
||||
NEW_LINE: 13,
|
||||
SHIFT: 16,
|
||||
CONTROL: 17,
|
||||
ALT: 18,
|
||||
PAUSE_BREAK: 19,
|
||||
CAPS_LOCK: 20,
|
||||
ESCAPE: 27,
|
||||
PAGE_DOWN: 33,
|
||||
PAGE_UP: 34,
|
||||
END: 35,
|
||||
HOME: 36,
|
||||
ARROW_LEFT: 37,
|
||||
ARROW_UP: 38,
|
||||
ARROW_RIGHT: 39,
|
||||
ARROW_DOWN: 40,
|
||||
INSERT: 45,
|
||||
SUPER: 91,
|
||||
MENU: 93,
|
||||
F1: 112,
|
||||
F2: 113,
|
||||
F3: 114,
|
||||
F4: 115,
|
||||
F5: 116,
|
||||
F6: 117,
|
||||
F7: 118,
|
||||
F8: 119,
|
||||
F9: 120,
|
||||
F10: 121,
|
||||
F11: 122,
|
||||
F12: 123,
|
||||
NUM_LOCK: 144,
|
||||
SCROLL_LOCK: 145,
|
||||
} as const;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ICustomUserStatus, Serialized } from '@rocket.chat/core-typings';
|
||||
|
||||
export const mapCustomUserStatusFromApi = ({ _updatedAt, ...status }: Serialized<ICustomUserStatus>): ICustomUserStatus => ({
|
||||
...status,
|
||||
_updatedAt: new Date(_updatedAt),
|
||||
});
|
||||
46
apps/meteor/client/lib/utils/mapMessageFromApi.spec.ts
Normal file
46
apps/meteor/client/lib/utils/mapMessageFromApi.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { mapMessageFromApi } from './mapMessageFromApi';
|
||||
|
||||
it('should convert all date fields to Date objects', () => {
|
||||
const now = new Date();
|
||||
const iso = now.toISOString();
|
||||
const input = {
|
||||
_id: 'id',
|
||||
ts: iso,
|
||||
_updatedAt: iso,
|
||||
pinnedAt: iso,
|
||||
tlm: iso,
|
||||
webRtcCallEndTs: iso,
|
||||
editedAt: iso,
|
||||
dlm: iso,
|
||||
msg: 'hi',
|
||||
u: { _id: 'u', username: 'user' },
|
||||
rid: 'r',
|
||||
attachments: [{ ts: iso, title: 'attach' }],
|
||||
};
|
||||
const result = mapMessageFromApi(input);
|
||||
expect(result.ts).toBeInstanceOf(Date);
|
||||
expect(result._updatedAt).toBeInstanceOf(Date);
|
||||
expect(result.pinnedAt).toBeInstanceOf(Date);
|
||||
expect(result.tlm).toBeInstanceOf(Date);
|
||||
expect(result.webRtcCallEndTs).toBeInstanceOf(Date);
|
||||
expect(result.editedAt).toBeInstanceOf(Date);
|
||||
expect(result.dlm).toBeInstanceOf(Date);
|
||||
expect(result.attachments?.[0].ts).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should not set missing optional dates', () => {
|
||||
const input = {
|
||||
_id: 'id',
|
||||
ts: new Date().toISOString(),
|
||||
_updatedAt: new Date().toISOString(),
|
||||
msg: 'hi',
|
||||
u: { _id: 'u', username: 'user' },
|
||||
rid: 'r',
|
||||
};
|
||||
const result = mapMessageFromApi(input);
|
||||
expect(result.pinnedAt).toBeUndefined();
|
||||
expect(result.tlm).toBeUndefined();
|
||||
expect(result.webRtcCallEndTs).toBeUndefined();
|
||||
expect(result.editedAt).toBeUndefined();
|
||||
expect(result.dlm).toBeUndefined();
|
||||
});
|
||||
31
apps/meteor/client/lib/utils/mapMessageFromApi.ts
Normal file
31
apps/meteor/client/lib/utils/mapMessageFromApi.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { IDiscussionMessage, IEditedMessage, IMessage, Serialized } from '@rocket.chat/core-typings';
|
||||
|
||||
type MappableMessage = IMessage & Partial<Pick<IEditedMessage, 'editedAt' | 'editedBy'>> & Partial<Pick<IDiscussionMessage, 'dlm'>>;
|
||||
|
||||
export const mapMessageFromApi = <T extends MappableMessage = IMessage>({
|
||||
attachments,
|
||||
tlm,
|
||||
ts,
|
||||
_updatedAt,
|
||||
pinnedAt,
|
||||
webRtcCallEndTs,
|
||||
editedAt,
|
||||
dlm,
|
||||
...message
|
||||
}: Serialized<T>): T =>
|
||||
({
|
||||
...message,
|
||||
ts: new Date(ts),
|
||||
...(tlm && { tlm: new Date(tlm) }),
|
||||
_updatedAt: new Date(_updatedAt),
|
||||
...(pinnedAt && { pinnedAt: new Date(pinnedAt) }),
|
||||
...(webRtcCallEndTs && { webRtcCallEndTs: new Date(webRtcCallEndTs) }),
|
||||
...(attachments && {
|
||||
attachments: attachments.map(({ ts, ...attachment }) => ({
|
||||
...(ts && { ts: new Date(ts) }),
|
||||
...attachment,
|
||||
})),
|
||||
}),
|
||||
...(editedAt && { editedAt: new Date(editedAt) }),
|
||||
...(dlm && { dlm: new Date(dlm) }),
|
||||
}) as unknown as T;
|
||||
7
apps/meteor/client/lib/utils/mapReadReceiptFromApi.ts
Normal file
7
apps/meteor/client/lib/utils/mapReadReceiptFromApi.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { IReadReceiptWithUser, Serialized } from '@rocket.chat/core-typings';
|
||||
|
||||
export const mapReadReceiptFromApi = ({ ts, _updatedAt, ...receipt }: Serialized<IReadReceiptWithUser>): IReadReceiptWithUser => ({
|
||||
...receipt,
|
||||
ts: new Date(ts),
|
||||
_updatedAt: new Date(_updatedAt),
|
||||
});
|
||||
21
apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts
Normal file
21
apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ISubscription, Serialized } from '@rocket.chat/core-typings';
|
||||
|
||||
export const mapSubscriptionFromApi = ({
|
||||
ts,
|
||||
lr,
|
||||
ls,
|
||||
_updatedAt,
|
||||
oldRoomKeys,
|
||||
suggestedOldRoomKeys,
|
||||
abacLastTimeChecked,
|
||||
...subscription
|
||||
}: Serialized<ISubscription>): ISubscription => ({
|
||||
...subscription,
|
||||
ts: new Date(ts),
|
||||
...(ls && { ls: new Date(ls) }),
|
||||
...(lr && { lr: new Date(lr) }),
|
||||
_updatedAt: new Date(_updatedAt),
|
||||
...(abacLastTimeChecked && { abacLastTimeChecked: new Date(abacLastTimeChecked) }),
|
||||
...(oldRoomKeys && { oldRoomKeys: oldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }),
|
||||
...(suggestedOldRoomKeys && { suggestedOldRoomKeys: suggestedOldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }),
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { IMessage, MessageAttachment, FileAttachmentProps, MessageQuoteAttachment } from '@rocket.chat/core-typings';
|
||||
|
||||
import { modifyMessageOnFilesDelete } from './modifyMessageOnFilesDelete';
|
||||
|
||||
global.structuredClone = (val: any) => JSON.parse(JSON.stringify(val));
|
||||
|
||||
const fileAttachment: FileAttachmentProps = { title: 'Image', title_link: 'url', image_url: 'image.png', type: 'file' };
|
||||
const nonFileAttachment: MessageAttachment = { text: 'Non-file attachment', color: '#ff0000' };
|
||||
const quoteAttachment: MessageQuoteAttachment = {
|
||||
text: 'Quoted message',
|
||||
message_link: 'some-link',
|
||||
attachments: [{ text: 'Quoted inner message' }],
|
||||
author_icon: 'icon',
|
||||
author_link: 'link',
|
||||
author_name: 'name',
|
||||
};
|
||||
|
||||
const messageBase: IMessage = {
|
||||
_id: 'msg1',
|
||||
msg: 'Here is a file',
|
||||
file: {
|
||||
_id: 'file1',
|
||||
name: 'image.png',
|
||||
type: '',
|
||||
format: '',
|
||||
size: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
_id: 'file1',
|
||||
name: 'image.png',
|
||||
type: '',
|
||||
format: '',
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
attachments: [fileAttachment, nonFileAttachment, quoteAttachment],
|
||||
rid: '',
|
||||
ts: new Date().toISOString() as any,
|
||||
u: { username: 'username', _id: '12345' },
|
||||
_updatedAt: new Date().toISOString() as any,
|
||||
};
|
||||
|
||||
const createMessage = () => {
|
||||
const msg = structuredClone(messageBase);
|
||||
return msg as IMessage;
|
||||
};
|
||||
|
||||
describe('modifyMessageOnFilesDelete', () => {
|
||||
it('should remove `file`, empty `files`, and remove file-type attachments', () => {
|
||||
const message = createMessage();
|
||||
|
||||
const result = modifyMessageOnFilesDelete(message);
|
||||
|
||||
expect(result).not.toHaveProperty('file');
|
||||
expect(Array.isArray(result.files)).toBe(true);
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.attachments).toHaveLength(2);
|
||||
expect(result.attachments?.some((att) => att.text === 'Non-file attachment')).toBe(true);
|
||||
});
|
||||
|
||||
it('should replace file-type attachments if `replaceFileAttachmentsWith` is provided', () => {
|
||||
const message = createMessage();
|
||||
|
||||
const replacement: MessageAttachment = { text: 'File removed by prune' };
|
||||
|
||||
const result = modifyMessageOnFilesDelete(message, replacement);
|
||||
|
||||
expect(result).not.toHaveProperty('file');
|
||||
expect(Array.isArray(result.files)).toBe(true);
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.attachments?.some((att) => att.text === 'File removed by prune')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not mutate the original message', () => {
|
||||
const message = createMessage();
|
||||
|
||||
const original = JSON.parse(JSON.stringify(message));
|
||||
modifyMessageOnFilesDelete(message);
|
||||
|
||||
expect(message).toEqual(original);
|
||||
});
|
||||
|
||||
it('should not remove non-file attachments such as text and quote', () => {
|
||||
const message = createMessage();
|
||||
|
||||
const result = modifyMessageOnFilesDelete(message);
|
||||
|
||||
expect(result.attachments?.some((att) => att.text === 'Non-file attachment')).toBe(true);
|
||||
expect(result.attachments?.some((att) => att.text === 'Quoted message')).toBe(true);
|
||||
});
|
||||
});
|
||||
30
apps/meteor/client/lib/utils/modifyMessageOnFilesDelete.ts
Normal file
30
apps/meteor/client/lib/utils/modifyMessageOnFilesDelete.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { isFileAttachment } from '@rocket.chat/core-typings';
|
||||
import type { IMessage, MessageAttachment } from '@rocket.chat/core-typings';
|
||||
|
||||
/**
|
||||
* Clone a message and clear or replace its file attachments.
|
||||
*
|
||||
* - Performs a deep clone via `structuredClone` to avoid mutating the source.
|
||||
* - Removes the single-file `file` field and empties the `files` array.
|
||||
* - If `replaceFileAttachmentsWith` is given, swaps out any file-type
|
||||
* attachments; otherwise filters them out entirely.
|
||||
*
|
||||
* @param message The original `IMessage` to copy.
|
||||
* @param replaceFileAttachmentsWith
|
||||
* Optional attachment to substitute for each file attachment.
|
||||
* @returns A new message object with `file`, `files`, and `attachments` updated.
|
||||
*/
|
||||
|
||||
export const modifyMessageOnFilesDelete = <T extends IMessage = IMessage>(message: T, replaceFileAttachmentsWith?: MessageAttachment) => {
|
||||
const { attachments, file: _, ...rest } = message;
|
||||
|
||||
if (!attachments?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (replaceFileAttachmentsWith) {
|
||||
return { ...rest, files: [], attachments: attachments?.map((att) => (isFileAttachment(att) ? replaceFileAttachmentsWith : att)) };
|
||||
}
|
||||
|
||||
return { ...rest, files: [], attachments: attachments?.filter((att) => !isFileAttachment(att)) };
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { getMessagePreview } from './getMessagePreview';
|
||||
import { normalizeMessagePreview } from './normalizeMessagePreview';
|
||||
import { createFakeSubscription, createFakeMessage } from '../../../../tests/mocks/data';
|
||||
|
||||
jest.mock('./normalizeMessagePreview');
|
||||
|
||||
const mockNormalizeMessagePreview = normalizeMessagePreview as jest.MockedFunction<typeof normalizeMessagePreview>;
|
||||
|
||||
describe('getMessagePreview', () => {
|
||||
const mockT = jest.fn((key: string) => key) as unknown as TFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockNormalizeMessagePreview.mockReturnValue('normalized message');
|
||||
});
|
||||
|
||||
describe('when room has a draft', () => {
|
||||
it('should return "Draft: content" taking precedence over the last message', () => {
|
||||
const room = createFakeSubscription({ draft: 'unfinished text' });
|
||||
const message = createFakeMessage();
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('Draft: unfinished text');
|
||||
expect(mockT).toHaveBeenCalledWith('Draft');
|
||||
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should escape HTML in the draft content', () => {
|
||||
const room = createFakeSubscription({ draft: '<img src=x onerror=alert(1)>' });
|
||||
|
||||
const result = getMessagePreview(room, undefined, mockT);
|
||||
|
||||
expect(result).toBe('Draft: <img src=x onerror=alert(1)>');
|
||||
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to a thread-composer draft when there is no main draft', () => {
|
||||
const room = createFakeSubscription({ draft: undefined, threadDrafts: { 'thread-id': 'unfinished thread reply' } });
|
||||
const message = createFakeMessage();
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('Draft: unfinished thread reply');
|
||||
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer the main draft over thread-composer drafts', () => {
|
||||
const room = createFakeSubscription({ draft: 'main draft', threadDrafts: { 'thread-id': 'thread draft' } });
|
||||
|
||||
const result = getMessagePreview(room, undefined, mockT);
|
||||
|
||||
expect(result).toBe('Draft: main draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when lastMessage is undefined', () => {
|
||||
it('should return translation for No_messages_yet', () => {
|
||||
const room = createFakeSubscription();
|
||||
const result = getMessagePreview(room, undefined, mockT);
|
||||
|
||||
expect(result).toBe('No_messages_yet');
|
||||
expect(mockT).toHaveBeenCalledWith('No_messages_yet');
|
||||
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message is a video conference message', () => {
|
||||
it('should return translation for Call_started', () => {
|
||||
const room = createFakeSubscription();
|
||||
const message = createFakeMessage({ t: 'videoconf' });
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('Call_started');
|
||||
expect(mockT).toHaveBeenCalledWith('Call_started');
|
||||
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message is E2EE', () => {
|
||||
it('should return encrypted message preview when e2e is not done', () => {
|
||||
const room = createFakeSubscription();
|
||||
const message = createFakeMessage({ t: 'e2e', e2e: 'pending' });
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('Encrypted_message_preview_unavailable');
|
||||
expect(mockT).toHaveBeenCalledWith('Encrypted_message_preview_unavailable');
|
||||
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should proceed to normalization when e2e is done', () => {
|
||||
const room = createFakeSubscription({ t: 'c' });
|
||||
const message = createFakeMessage({ t: 'e2e', e2e: 'done', u: { _id: 'user-id', username: 'testuser', name: 'Test User' } });
|
||||
mockNormalizeMessagePreview.mockReturnValue('decrypted message');
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('Test User: decrypted message');
|
||||
expect(mockNormalizeMessagePreview).toHaveBeenCalledWith(message, mockT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message has no user', () => {
|
||||
it('should return normalized message directly', () => {
|
||||
const room = createFakeSubscription();
|
||||
const message = createFakeMessage({ u: undefined });
|
||||
mockNormalizeMessagePreview.mockReturnValue('normalized message');
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('normalized message');
|
||||
expect(mockNormalizeMessagePreview).toHaveBeenCalledWith(message, mockT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message is from current user', () => {
|
||||
it('should return "You: normalized message" when usernames match', () => {
|
||||
const room = createFakeSubscription({ u: { _id: 'user-id', username: 'testuser', name: 'Test User' } });
|
||||
const message = createFakeMessage({ u: { _id: 'user-id', username: 'testuser', name: 'Test User' } });
|
||||
mockNormalizeMessagePreview.mockReturnValue('my message');
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('You: my message');
|
||||
expect(mockT).toHaveBeenCalledWith('You');
|
||||
expect(mockNormalizeMessagePreview).toHaveBeenCalledWith(message, mockT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when room is a direct message room', () => {
|
||||
it('should return normalized message without sender name for single DM', () => {
|
||||
const room = createFakeSubscription({
|
||||
t: 'd',
|
||||
uids: ['user1', 'user2'],
|
||||
});
|
||||
const message = createFakeMessage({
|
||||
u: { _id: 'other-user-id', username: 'otheruser', name: 'Other User' },
|
||||
});
|
||||
mockNormalizeMessagePreview.mockReturnValue('direct message');
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('direct message');
|
||||
expect(mockNormalizeMessagePreview).toHaveBeenCalledWith(message, mockT);
|
||||
});
|
||||
|
||||
it('should return sender name and message for multiple DM', () => {
|
||||
const room = createFakeSubscription({
|
||||
t: 'd',
|
||||
uids: ['user1', 'user2', 'user3'],
|
||||
});
|
||||
const message = createFakeMessage({
|
||||
u: { _id: 'other-user-id', username: 'otheruser', name: 'Other User' },
|
||||
});
|
||||
mockNormalizeMessagePreview.mockReturnValue('group message');
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('Other User: group message');
|
||||
expect(mockNormalizeMessagePreview).toHaveBeenCalledWith(message, mockT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when room is a group or channel', () => {
|
||||
it('should return sender name and message when user has name', () => {
|
||||
const room = createFakeSubscription({ t: 'c' });
|
||||
const message = createFakeMessage({
|
||||
u: { _id: 'other-user-id', username: 'otheruser', name: 'Other User' },
|
||||
});
|
||||
mockNormalizeMessagePreview.mockReturnValue('channel message');
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('Other User: channel message');
|
||||
expect(mockNormalizeMessagePreview).toHaveBeenCalledWith(message, mockT);
|
||||
});
|
||||
|
||||
it('should return username and message when user has no name', () => {
|
||||
const room = createFakeSubscription({ t: 'p' });
|
||||
const message = createFakeMessage({
|
||||
u: { _id: 'other-user-id', username: 'otheruser' },
|
||||
});
|
||||
mockNormalizeMessagePreview.mockReturnValue('private message');
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('otheruser: private message');
|
||||
expect(mockNormalizeMessagePreview).toHaveBeenCalledWith(message, mockT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message has no preview', () => {
|
||||
it('should return empty string', () => {
|
||||
const room = createFakeSubscription();
|
||||
const message = createFakeMessage();
|
||||
mockNormalizeMessagePreview.mockReturnValue(undefined);
|
||||
|
||||
const result = getMessagePreview(room, message, mockT);
|
||||
|
||||
expect(result).toBe('');
|
||||
expect(mockNormalizeMessagePreview).toHaveBeenCalledWith(message, mockT);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { isDirectMessageRoom, isE2EEMessage, isMultipleDirectMessageRoom, isVideoConfMessage } from '@rocket.chat/core-typings';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { normalizeMessagePreview } from './normalizeMessagePreview';
|
||||
import { getSubscriptionDraft } from '../getSubscriptionDraft';
|
||||
|
||||
export const getMessagePreview = (room: SubscriptionWithRoom, lastMessage: IMessage | undefined, t: TFunction): string | undefined => {
|
||||
const draft = getSubscriptionDraft(room);
|
||||
if (draft) {
|
||||
return `${t('Draft')}: ${escapeHTML(draft)}`;
|
||||
}
|
||||
|
||||
if (!lastMessage) {
|
||||
return t('No_messages_yet');
|
||||
}
|
||||
if (isVideoConfMessage(lastMessage)) {
|
||||
return t('Call_started');
|
||||
}
|
||||
if (isE2EEMessage(lastMessage) && lastMessage.e2e !== 'done') {
|
||||
return t('Encrypted_message_preview_unavailable');
|
||||
}
|
||||
|
||||
const normalizedMessage = normalizeMessagePreview(lastMessage, t);
|
||||
if (!normalizedMessage) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!lastMessage.u) {
|
||||
return normalizedMessage;
|
||||
}
|
||||
if (lastMessage.u?.username === room.u?.username) {
|
||||
return `${t('You')}: ${normalizedMessage}`;
|
||||
}
|
||||
if (isDirectMessageRoom(room) && !isMultipleDirectMessageRoom(room)) {
|
||||
return normalizedMessage;
|
||||
}
|
||||
return `${escapeHTML(lastMessage.u.name || lastMessage.u.username)}: ${normalizedMessage}`;
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { normalizeMessagePreview } from './normalizeMessagePreview';
|
||||
import { createFakeMessage, createFakeMessageWithAttachment } from '../../../../tests/mocks/data';
|
||||
|
||||
describe('normalizeMessagePreview', () => {
|
||||
const mockT = jest.fn((key: string) => key) as unknown as TFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when message has msg property', () => {
|
||||
it('should return escaped HTML filtered markdown with emoji conversion', () => {
|
||||
const message = createFakeMessage({ msg: 'Hello :smile: world' });
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Hello 😄 world');
|
||||
});
|
||||
|
||||
it('should filter markdown from message', () => {
|
||||
const message = createFakeMessage({ msg: '*bold* text' });
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('bold text');
|
||||
});
|
||||
|
||||
it('should escape HTML in message', () => {
|
||||
const message = createFakeMessage({ msg: '<script>alert("xss")</script>' });
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
it('should convert emoji shortnames to unicode', () => {
|
||||
const message = createFakeMessage({ msg: 'Hello :smile: :heart:' });
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Hello 😄 ❤️');
|
||||
});
|
||||
|
||||
it('should return undefined when message is empty', () => {
|
||||
const message = createFakeMessage({ msg: '' });
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message has attachments', () => {
|
||||
it('should return attachment description when available', () => {
|
||||
const message = createFakeMessageWithAttachment({
|
||||
msg: '',
|
||||
attachments: [
|
||||
{
|
||||
title: 'Attachment title',
|
||||
description: 'Attachment description',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Attachment description');
|
||||
});
|
||||
|
||||
it('should return attachment title when description is not available', () => {
|
||||
const message = createFakeMessageWithAttachment({
|
||||
msg: '',
|
||||
attachments: [
|
||||
{
|
||||
title: 'Attachment title',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Attachment title');
|
||||
});
|
||||
|
||||
it('should return translation when attachment has no title or description', () => {
|
||||
const message = createFakeMessageWithAttachment({
|
||||
msg: '',
|
||||
attachments: [
|
||||
{
|
||||
type: 'file',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Sent_an_attachment');
|
||||
expect(mockT).toHaveBeenCalledWith('Sent_an_attachment');
|
||||
});
|
||||
|
||||
it('should find first attachment with title', () => {
|
||||
const message = createFakeMessageWithAttachment({
|
||||
msg: '',
|
||||
attachments: [
|
||||
{
|
||||
type: 'file',
|
||||
},
|
||||
{
|
||||
title: 'Second attachment title',
|
||||
},
|
||||
{
|
||||
description: 'Third attachment description',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Second attachment title');
|
||||
});
|
||||
|
||||
it('should find first attachment description', () => {
|
||||
const message = createFakeMessageWithAttachment({
|
||||
msg: '',
|
||||
attachments: [
|
||||
{
|
||||
type: 'file',
|
||||
},
|
||||
{
|
||||
description: 'Second attachment description',
|
||||
},
|
||||
{
|
||||
title: 'Third attachment title',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Second attachment description');
|
||||
});
|
||||
|
||||
it('should escape HTML in attachment description', () => {
|
||||
const message = createFakeMessageWithAttachment({
|
||||
msg: '',
|
||||
attachments: [
|
||||
{
|
||||
description: '<script>alert("xss")</script>',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
it('should escape HTML in attachment title', () => {
|
||||
const message = createFakeMessageWithAttachment({
|
||||
msg: '',
|
||||
attachments: [
|
||||
{
|
||||
title: '<img src="x" onerror="alert(1)">',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('<img src="x" onerror="alert(1)">');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message has both msg and attachments', () => {
|
||||
it('should prioritize msg over attachments', () => {
|
||||
const message = createFakeMessage({
|
||||
msg: 'Message text',
|
||||
attachments: [
|
||||
{
|
||||
title: 'Attachment title',
|
||||
description: 'Attachment description',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Message text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message has no msg and no attachments', () => {
|
||||
it('should return undefined', () => {
|
||||
const message = createFakeMessage({ msg: undefined });
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message has empty attachments array', () => {
|
||||
it('should return "Sent_an_attachment"', () => {
|
||||
const message = createFakeMessage({
|
||||
msg: undefined,
|
||||
attachments: [],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Sent_an_attachment');
|
||||
expect(mockT).toHaveBeenCalledWith('Sent_an_attachment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when message has attachments but none have title or description', () => {
|
||||
it('should return translation for Sent_an_attachment', () => {
|
||||
const message = createFakeMessage({
|
||||
msg: undefined,
|
||||
attachments: [
|
||||
{
|
||||
type: 'file',
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = normalizeMessagePreview(message, mockT);
|
||||
|
||||
expect(result).toBe('Sent_an_attachment');
|
||||
expect(mockT).toHaveBeenCalledWith('Sent_an_attachment');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { shortnameToUnicode } from '../../../../app/emoji-native/lib/shortnameToUnicode';
|
||||
import { filterMarkdown } from '../../../../app/markdown/lib/markdown';
|
||||
|
||||
export const normalizeMessagePreview = (message: IMessage, t: TFunction): string | undefined => {
|
||||
if (message.msg) {
|
||||
return escapeHTML(filterMarkdown(shortnameToUnicode(message.msg)));
|
||||
}
|
||||
|
||||
if (message.attachments) {
|
||||
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description);
|
||||
|
||||
if (attachment?.description) {
|
||||
return escapeHTML(attachment.description);
|
||||
}
|
||||
|
||||
if (attachment?.title) {
|
||||
return escapeHTML(attachment.title);
|
||||
}
|
||||
|
||||
return t('Sent_an_attachment');
|
||||
}
|
||||
};
|
||||
16
apps/meteor/client/lib/utils/prependReplies.ts
Normal file
16
apps/meteor/client/lib/utils/prependReplies.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
import { getPermaLink } from '../getPermaLink';
|
||||
|
||||
export const prependReplies = async (msg: string, replies: IMessage[] = []): Promise<string> => {
|
||||
const chunks = await Promise.all(
|
||||
replies.map(async ({ _id }) => {
|
||||
const permalink = await getPermaLink(_id);
|
||||
|
||||
return `[ ](${permalink})`;
|
||||
}),
|
||||
);
|
||||
|
||||
chunks.push(msg);
|
||||
return chunks.join('\n');
|
||||
};
|
||||
9
apps/meteor/client/lib/utils/preventSyntheticEvent.ts
Normal file
9
apps/meteor/client/lib/utils/preventSyntheticEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { SyntheticEvent } from 'react';
|
||||
|
||||
export const preventSyntheticEvent = (e: SyntheticEvent): void => {
|
||||
if (e) {
|
||||
(e.nativeEvent || e).stopImmediatePropagation();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
57
apps/meteor/client/lib/utils/renderEmoji.ts
Normal file
57
apps/meteor/client/lib/utils/renderEmoji.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { emoji } from '../emoji/lib';
|
||||
|
||||
const emojiList = emoji.list as Record<string, { emojiPackage: string }>;
|
||||
const emojiPackages = emoji.packages as Record<string, { render(emojiName: string): string }>;
|
||||
|
||||
export const renderEmoji = (emojiName: string): string | undefined => {
|
||||
const emojiPackageName = emojiList[emojiName]?.emojiPackage;
|
||||
if (emojiPackageName) {
|
||||
const emojiPackage = emojiPackages[emojiPackageName];
|
||||
return emojiPackage.render(emojiName);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type EmojiParserResult = {
|
||||
'className'?: string;
|
||||
'name': string;
|
||||
'data-title'?: string;
|
||||
'children'?: string;
|
||||
'image'?: string;
|
||||
};
|
||||
|
||||
const createGetEmojiClassNameAndDataTitle =
|
||||
(parser: (emojiName: string) => string | undefined) =>
|
||||
(emojiName: string): EmojiParserResult => {
|
||||
const html = parser(emojiName);
|
||||
if (!html) {
|
||||
return { 'className': '', 'data-title': '', 'children': '', 'name': '' };
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.innerHTML = html;
|
||||
|
||||
const emojiElement = div.firstElementChild;
|
||||
if (!emojiElement) {
|
||||
return { 'className': '', 'data-title': '', 'children': '', 'name': '', 'image': '' };
|
||||
}
|
||||
|
||||
const image =
|
||||
emojiElement instanceof HTMLElement
|
||||
? emojiElement.style.backgroundImage
|
||||
: (Object.fromEntries((emojiElement.getAttribute('style') || '')?.split(';').map((s) => s.split(':'))) as Record<string, string>)[
|
||||
'background-image'
|
||||
];
|
||||
|
||||
return {
|
||||
'className': [image ? 'rcx-message__emoji--custom' : '', emojiElement.getAttribute('class') || ''].filter(Boolean).join(' '),
|
||||
'data-title': emojiElement.getAttribute('data-title') || '',
|
||||
'name': emojiElement.getAttribute('name') || '',
|
||||
'children': emojiElement.innerHTML,
|
||||
image,
|
||||
};
|
||||
};
|
||||
|
||||
export const getEmojiClassNameAndDataTitle = createGetEmojiClassNameAndDataTitle(renderEmoji);
|
||||
3
apps/meteor/client/lib/utils/renderMessageEmoji.ts
Normal file
3
apps/meteor/client/lib/utils/renderMessageEmoji.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { emojiParser } from '../emoji/emojiParser';
|
||||
|
||||
export const renderMessageEmoji = (html: string) => emojiParser(html);
|
||||
9
apps/meteor/client/lib/utils/restrictions.ts
Normal file
9
apps/meteor/client/lib/utils/restrictions.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { fileUploadIsValidContentTypeFromSettings } from '../../../app/utils/lib/restrictions';
|
||||
import { settings } from '../settings';
|
||||
|
||||
export const fileUploadIsValidContentType = function (type: string | undefined, customWhiteList?: string): boolean {
|
||||
const blackList = settings.peek<string>('FileUpload_MediaTypeBlackList') ?? 'image/svg+xml';
|
||||
const whiteList = customWhiteList ?? settings.peek<string>('FileUpload_MediaTypeWhiteList') ?? '';
|
||||
|
||||
return fileUploadIsValidContentTypeFromSettings(type, whiteList, blackList);
|
||||
};
|
||||
8
apps/meteor/client/lib/utils/setDateFnsLocale.ts
Normal file
8
apps/meteor/client/lib/utils/setDateFnsLocale.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { setDefaultOptions } from 'date-fns';
|
||||
|
||||
import { getDateFnsLocale } from '../../../lib/getDateFnsLocale';
|
||||
|
||||
export const setDateFnsLocale = async (language: string): Promise<void> => {
|
||||
const locale = await getDateFnsLocale(language);
|
||||
setDefaultOptions({ locale });
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import type { LocationPathname } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { router } from '../../providers/RouterProvider';
|
||||
|
||||
export const setMessageJumpQueryStringParameter = async (msg: IMessage['_id'] | null, context?: 'jumpToUnread') => {
|
||||
const { msg: _msg, jumpContext: _jumpContext, ...search } = router.getSearchParameters();
|
||||
const locationPathname = new URL(window.location.href).pathname as LocationPathname;
|
||||
|
||||
router.navigate(
|
||||
{
|
||||
pathname: locationPathname,
|
||||
search: msg ? { ...search, msg, ...(context && { jumpContext: context }) } : search,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
237
apps/meteor/client/lib/utils/threadMessageUtils.spec.ts
Normal file
237
apps/meteor/client/lib/utils/threadMessageUtils.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { IThreadMessage } from '@rocket.chat/core-typings';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { mutateThreadMessagesInfiniteData, upsertThreadMessageInCache, type ThreadMessagesInfiniteData } from './threadMessageUtils';
|
||||
|
||||
const rid = 'room1';
|
||||
const tmid = 'thread1';
|
||||
const queryKey = ['rooms', rid, 'threads', tmid, 'messages'] as const;
|
||||
|
||||
const createMessage = (id: string, ts: number): IThreadMessage =>
|
||||
({
|
||||
_id: id,
|
||||
rid,
|
||||
tmid,
|
||||
msg: id,
|
||||
ts: new Date(ts).toISOString() as any,
|
||||
u: { _id: 'user1', username: 'user1' },
|
||||
_updatedAt: new Date(ts).toISOString() as any,
|
||||
}) as IThreadMessage;
|
||||
|
||||
// A thread with 200 replies, one cached page holding reply-075..reply-124.
|
||||
const createAnchoredCache = (): ThreadMessagesInfiniteData => ({
|
||||
pages: [
|
||||
{
|
||||
items: Array.from({ length: 50 }, (_, i) => createMessage(`reply-${String(i + 75).padStart(3, '0')}`, i + 75)),
|
||||
itemCount: 200,
|
||||
},
|
||||
],
|
||||
pageParams: [75],
|
||||
});
|
||||
|
||||
describe('mutateThreadMessagesInfiniteData', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
it('shifts every stored pageParam forward when a message newer than everything cached is inserted', () => {
|
||||
queryClient.setQueryData(queryKey, createAnchoredCache());
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, (messages) => {
|
||||
messages.push(createMessage('reply-200', 1000));
|
||||
messages.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
|
||||
expect(data?.pages[0].itemCount).toBe(201);
|
||||
expect(data?.pages[0].items.map((m) => m._id)).toContain('reply-200');
|
||||
expect(data?.pageParams).toEqual([76]);
|
||||
});
|
||||
|
||||
it('keeps a pageParam of 0 unshifted when a newer message is inserted and user is at the newest message', () => {
|
||||
const cache: ThreadMessagesInfiniteData = {
|
||||
pages: [{ items: [createMessage('reply-150', 150), createMessage('reply-199', 199)], itemCount: 200 }],
|
||||
pageParams: [0],
|
||||
};
|
||||
queryClient.setQueryData(queryKey, cache);
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, (messages) => {
|
||||
messages.push(createMessage('reply-200', 1000));
|
||||
messages.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
|
||||
expect(data?.pages[0].itemCount).toBe(201);
|
||||
expect(data?.pages[0].items.map((m) => m._id)).toContain('reply-200');
|
||||
expect(data?.pageParams).toEqual([0]);
|
||||
});
|
||||
|
||||
it('shifts pageParams when the inserted message ties the newest cached timestamp', () => {
|
||||
queryClient.setQueryData(queryKey, createAnchoredCache());
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, (messages) => {
|
||||
messages.push(createMessage('reply-200', 124));
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
|
||||
expect(data?.pages[0].itemCount).toBe(201);
|
||||
expect(data?.pageParams).toEqual([76]);
|
||||
});
|
||||
|
||||
it('shifts every page pageParam, not just the last one, when a newer message arrives', () => {
|
||||
const cache: ThreadMessagesInfiniteData = {
|
||||
pages: [
|
||||
{ items: [createMessage('reply-025', 25), createMessage('reply-026', 26)], itemCount: 200 },
|
||||
{ items: [createMessage('reply-075', 75), createMessage('reply-076', 76)], itemCount: 200 },
|
||||
],
|
||||
pageParams: [25, 75],
|
||||
};
|
||||
queryClient.setQueryData(queryKey, cache);
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, (messages) => {
|
||||
messages.push(createMessage('reply-200', 1000));
|
||||
messages.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
|
||||
expect(data?.pageParams).toEqual([26, 76]);
|
||||
});
|
||||
|
||||
it('does not shift pageParams when the inserted message is older than what is already cached', () => {
|
||||
queryClient.setQueryData(queryKey, createAnchoredCache());
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, (messages) => {
|
||||
messages.unshift(createMessage('reply-010', 10));
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
|
||||
expect(data?.pages[0].itemCount).toBe(201);
|
||||
expect(data?.pageParams).toEqual([75]);
|
||||
});
|
||||
|
||||
it('does not shift pageParams when a cached message is deleted', () => {
|
||||
queryClient.setQueryData(queryKey, createAnchoredCache());
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, (messages) => {
|
||||
const index = messages.findIndex((m) => m._id === 'reply-100');
|
||||
messages.splice(index, 1);
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
|
||||
expect(data?.pages[0].items.map((m) => m._id)).not.toContain('reply-100');
|
||||
expect(data?.pages[0].itemCount).toBe(199);
|
||||
expect(data?.pageParams).toEqual([75]);
|
||||
});
|
||||
|
||||
it('does not shift pageParams when an existing message is edited in place', () => {
|
||||
queryClient.setQueryData(queryKey, createAnchoredCache());
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, (messages) => {
|
||||
const index = messages.findIndex((m) => m._id === 'reply-100');
|
||||
messages[index] = { ...messages[index], msg: 'edited' };
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
|
||||
expect(data?.pages[0].itemCount).toBe(200);
|
||||
expect(data?.pageParams).toEqual([75]);
|
||||
});
|
||||
|
||||
it('removes a message from every cached page when its id was duplicated across pages, not just the first occurrence', () => {
|
||||
const cache: ThreadMessagesInfiniteData = {
|
||||
pages: [
|
||||
{ items: [createMessage('reply-058', 58), createMessage('reply-059', 59), createMessage('reply-060', 60)], itemCount: 200 },
|
||||
{ items: [createMessage('reply-060', 60), createMessage('reply-061', 61)], itemCount: 200 },
|
||||
],
|
||||
pageParams: [58, 60],
|
||||
};
|
||||
queryClient.setQueryData(queryKey, cache);
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, (messages) => {
|
||||
const index = messages.findIndex((m) => m._id === 'reply-060');
|
||||
messages.splice(index, 1);
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
const allIds = data?.pages.flatMap((page) => page.items.map((m) => m._id)) ?? [];
|
||||
|
||||
expect(allIds).not.toContain('reply-060');
|
||||
expect(data?.pages[0].itemCount).toBe(199);
|
||||
});
|
||||
|
||||
it('collapses a duplicated id into a single entry, keeping the most recently updated copy, even when the mutation is a no-op', () => {
|
||||
const stale = createMessage('reply-060', 60);
|
||||
const fresh: IThreadMessage = { ...createMessage('reply-060', 60), msg: 'edited', _updatedAt: new Date(9999).toISOString() as any };
|
||||
const cache: ThreadMessagesInfiniteData = {
|
||||
pages: [
|
||||
{ items: [stale], itemCount: 200 },
|
||||
{ items: [fresh], itemCount: 200 },
|
||||
],
|
||||
pageParams: [60, 60],
|
||||
};
|
||||
queryClient.setQueryData(queryKey, cache);
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, queryKey, () => {
|
||||
// no-op mutation: only the dedup pass should change the cache
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
const allItems = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
expect(allItems).toHaveLength(1);
|
||||
expect(allItems[0].msg).toBe('edited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertThreadMessageInCache', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
it('seeds the cache with pageParams [0] when nothing was cached yet', () => {
|
||||
upsertThreadMessageInCache(createMessage('reply-000', 0), rid, tmid, queryClient);
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
|
||||
expect(data?.pages[0].itemCount).toBe(1);
|
||||
expect(data?.pageParams).toEqual([0]);
|
||||
});
|
||||
|
||||
it('keeps the next-page fetch offset aligned with the real data after a live reply arrives, so no message is skipped', () => {
|
||||
queryClient.setQueryData(queryKey, createAnchoredCache());
|
||||
|
||||
upsertThreadMessageInCache(createMessage('reply-200', 1000), rid, tmid, queryClient);
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
const [pageParam] = data?.pageParams ?? [];
|
||||
|
||||
expect(data?.pages[0].itemCount).toBe(201);
|
||||
// useThreadMessagesQuery's getNextPageParam computes offset = pageParam - count (50),
|
||||
// so 76 (not the stale 75) produces offset 26, which includes reply-125.
|
||||
expect(pageParam).toBe(76);
|
||||
});
|
||||
|
||||
it('updates an existing message in place without shifting pageParams', () => {
|
||||
queryClient.setQueryData(queryKey, createAnchoredCache());
|
||||
|
||||
const edited = { ...createMessage('reply-100', 100), msg: 'edited' };
|
||||
upsertThreadMessageInCache(edited, rid, tmid, queryClient);
|
||||
|
||||
const data = queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey);
|
||||
const updated = data?.pages[0].items.find((m) => m._id === 'reply-100');
|
||||
|
||||
expect(updated?.msg).toBe('edited');
|
||||
expect(data?.pages[0].itemCount).toBe(200);
|
||||
expect(data?.pageParams).toEqual([75]);
|
||||
});
|
||||
});
|
||||
189
apps/meteor/client/lib/utils/threadMessageUtils.ts
Normal file
189
apps/meteor/client/lib/utils/threadMessageUtils.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { IMessage, IThreadMessage, MessageAttachment } from '@rocket.chat/core-typings';
|
||||
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
|
||||
import type { InfiniteData, QueryClient } from '@tanstack/react-query';
|
||||
import type { Condition, Filter } from 'mongodb';
|
||||
|
||||
import { queryClient as defaultQueryClient } from '../queryClient';
|
||||
import { roomsQueryKeys } from '../queryKeys';
|
||||
|
||||
export type NotifyRoomRidDeleteBulkEvent = {
|
||||
rid: IMessage['rid'];
|
||||
excludePinned: boolean;
|
||||
ignoreDiscussion: boolean;
|
||||
ts: Condition<Date>;
|
||||
users: string[];
|
||||
ids?: string[];
|
||||
showDeletedStatus?: boolean;
|
||||
} & (
|
||||
| {
|
||||
filesOnly: true;
|
||||
replaceFileAttachmentsWith?: MessageAttachment;
|
||||
}
|
||||
| {
|
||||
filesOnly?: false;
|
||||
}
|
||||
);
|
||||
|
||||
export const createDeleteCriteria = (params: NotifyRoomRidDeleteBulkEvent): ((message: IMessage) => boolean) => {
|
||||
const query: Filter<IMessage> = {};
|
||||
|
||||
if (params.ids) {
|
||||
query._id = { $in: params.ids };
|
||||
} else {
|
||||
query.ts = params.ts;
|
||||
}
|
||||
|
||||
if (params.excludePinned) {
|
||||
query.pinned = { $ne: true };
|
||||
}
|
||||
|
||||
if (params.ignoreDiscussion) {
|
||||
query.drid = { $exists: false };
|
||||
}
|
||||
if (params.users?.length) {
|
||||
query['u.username'] = { $in: params.users };
|
||||
}
|
||||
|
||||
return createPredicateFromFilter(query);
|
||||
};
|
||||
|
||||
export type ThreadMessagesPage = {
|
||||
items: IThreadMessage[];
|
||||
itemCount: number;
|
||||
};
|
||||
|
||||
export type ThreadMessagesInfiniteData = InfiniteData<ThreadMessagesPage, number>;
|
||||
|
||||
const dedupeThreadMessagesById = (messages: IThreadMessage[]): IThreadMessage[] => {
|
||||
const byId = new Map<string, IThreadMessage>();
|
||||
|
||||
for (const message of messages) {
|
||||
const existing = byId.get(message._id);
|
||||
if (!existing) {
|
||||
byId.set(message._id, message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const messageTime = new Date(message._updatedAt ?? message.ts).getTime();
|
||||
const existingTime = new Date(existing._updatedAt ?? existing.ts).getTime();
|
||||
if (messageTime > existingTime) {
|
||||
byId.set(message._id, message);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byId.values());
|
||||
};
|
||||
|
||||
export const mutateThreadMessagesInfiniteData = (
|
||||
client: QueryClient,
|
||||
queryKey: readonly unknown[],
|
||||
mutation: (messages: IThreadMessage[]) => void,
|
||||
): void => {
|
||||
client.setQueryData<ThreadMessagesInfiniteData>(queryKey, (old) => {
|
||||
if (!old?.pages.length) {
|
||||
return old;
|
||||
}
|
||||
|
||||
const items = dedupeThreadMessagesById(old.pages.flatMap((page) => page.items));
|
||||
const originalPageLengths = old.pages.map((page) => page.items.length);
|
||||
const oldTotal = old.pages.at(-1)?.itemCount ?? 0;
|
||||
const oldIds = new Set(items.map((message) => message._id));
|
||||
const oldMaxTs = items.reduce((max, message) => Math.max(max, new Date(message.ts).getTime()), -Infinity);
|
||||
|
||||
const beforeMutationItemsLength = items.length;
|
||||
mutation(items);
|
||||
const afterMutationItemsLength = items.length;
|
||||
|
||||
const itemCountDelta = beforeMutationItemsLength - afterMutationItemsLength;
|
||||
const newTotal = Math.max(0, oldTotal - itemCountDelta);
|
||||
|
||||
const newerInsertedCount = items.filter((message) => !oldIds.has(message._id) && new Date(message.ts).getTime() >= oldMaxTs).length;
|
||||
|
||||
const pages: ThreadMessagesPage[] = [];
|
||||
let cursor = 0;
|
||||
for (let pageIndex = 0; pageIndex < old.pages.length; pageIndex++) {
|
||||
const isLastPage = pageIndex === old.pages.length - 1;
|
||||
const take = isLastPage ? items.length - cursor : Math.min(originalPageLengths[pageIndex], items.length - cursor);
|
||||
const slice = items.slice(cursor, cursor + Math.max(0, take));
|
||||
cursor += slice.length;
|
||||
pages.push({ items: slice, itemCount: newTotal });
|
||||
}
|
||||
|
||||
return {
|
||||
pages,
|
||||
pageParams:
|
||||
newerInsertedCount > 0
|
||||
? old.pageParams.map((pageParam) => (pageParam > 0 ? pageParam + newerInsertedCount : pageParam))
|
||||
: old.pageParams,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const upsertThreadMessageInCache = (
|
||||
message: IMessage,
|
||||
rid: IMessage['rid'],
|
||||
tmid: IMessage['_id'],
|
||||
client: QueryClient = defaultQueryClient,
|
||||
): void => {
|
||||
const queryKey = roomsQueryKeys.threadMessages(rid, tmid);
|
||||
|
||||
if (!client.getQueryData<ThreadMessagesInfiniteData>(queryKey)) {
|
||||
client.setQueryData<ThreadMessagesInfiniteData>(queryKey, {
|
||||
pages: [{ items: [message as IThreadMessage], itemCount: 1 }],
|
||||
pageParams: [0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
mutateThreadMessagesInfiniteData(client, queryKey, (messages) => {
|
||||
let shouldReturn = false;
|
||||
for (let idx = 0; idx < messages.length; idx++) {
|
||||
if (messages[idx]._id === message._id) {
|
||||
messages[idx] = message as IThreadMessage;
|
||||
shouldReturn = true;
|
||||
}
|
||||
}
|
||||
if (shouldReturn) {
|
||||
return;
|
||||
}
|
||||
|
||||
messages.push(message as IThreadMessage);
|
||||
messages.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
||||
});
|
||||
};
|
||||
|
||||
export const markThreadMessagesAsRead = (messages: IThreadMessage[], until?: Date): IThreadMessage[] => {
|
||||
return messages.map((msg) => {
|
||||
if (!msg.unread) {
|
||||
return msg;
|
||||
}
|
||||
if (until && new Date(msg.ts) > until) {
|
||||
return msg;
|
||||
}
|
||||
const { unread: _, ...rest } = msg;
|
||||
return rest as IThreadMessage;
|
||||
});
|
||||
};
|
||||
|
||||
export const mergeThreadMessages = (cachedMessages: IThreadMessage[], fetchedMessages: IThreadMessage[]): IThreadMessage[] => {
|
||||
const messageMap = new Map<string, IThreadMessage>();
|
||||
|
||||
for (const msg of cachedMessages) {
|
||||
messageMap.set(msg._id, msg);
|
||||
}
|
||||
|
||||
for (const msg of fetchedMessages) {
|
||||
const existing = messageMap.get(msg._id);
|
||||
if (!existing) {
|
||||
messageMap.set(msg._id, msg);
|
||||
} else {
|
||||
const msgTime = new Date(msg._updatedAt ?? msg.ts).getTime();
|
||||
const existingTime = new Date(existing._updatedAt ?? existing.ts).getTime();
|
||||
if (msgTime > existingTime) {
|
||||
messageMap.set(msg._id, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(messageMap.values()).sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
||||
};
|
||||
21
apps/meteor/client/lib/utils/timeAgo.ts
Normal file
21
apps/meteor/client/lib/utils/timeAgo.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { t } from '../../../app/utils/lib/i18n';
|
||||
import { getUserPreference } from '../getUserPreference';
|
||||
import { settings } from '../settings';
|
||||
import { getUserId } from '../user';
|
||||
import { formatTimeAgo } from './dateFormat';
|
||||
|
||||
const dayFormat = ['h:mm A', 'H:mm'];
|
||||
|
||||
export const timeAgo = (date: string | Date | number) => {
|
||||
const clockMode = getUserPreference(getUserId(), 'clockMode', false) as number | boolean;
|
||||
const messageTimeFormat = settings.peek('Message_TimeFormat');
|
||||
const sameDay = (typeof clockMode === 'number' ? dayFormat[clockMode - 1] : undefined) || messageTimeFormat;
|
||||
|
||||
return formatTimeAgo(date, {
|
||||
yesterdayLabel: t('yesterday'),
|
||||
sameDayFormat: sameDay,
|
||||
lastWeekFormat: 'dddd',
|
||||
otherFormat: 'MMM d',
|
||||
otherYearFormat: 'MMM d yyyy',
|
||||
});
|
||||
};
|
||||
30
apps/meteor/client/lib/utils/timestamp/conversion.ts
Normal file
30
apps/meteor/client/lib/utils/timestamp/conversion.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { UTCOffsets, type TimestampFormat, type TimezoneKey } from './types';
|
||||
|
||||
export const dateToISOString = (date: Date, timezone?: TimezoneKey): string => {
|
||||
if (!date || isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (timezone && timezone !== 'local') {
|
||||
const offset = UTCOffsets[timezone];
|
||||
if (offset) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}${offset}`;
|
||||
}
|
||||
}
|
||||
|
||||
return date.toISOString().replace('Z', '+00:00');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a timestamp markup string in the format <t:timestamp:format>
|
||||
*/
|
||||
export const generateTimestampMarkup = (timestamp: string, format: TimestampFormat): string => {
|
||||
return `<t:${timestamp}:${format}>`;
|
||||
};
|
||||
39
apps/meteor/client/lib/utils/timestamp/formats.ts
Normal file
39
apps/meteor/client/lib/utils/timestamp/formats.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { TimestampFormat, ITimestampFormatConfig } from './types';
|
||||
|
||||
export const TIMESTAMP_FORMATS: Record<TimestampFormat, ITimestampFormatConfig> = {
|
||||
t: {
|
||||
label: 'timestamps.shortTime',
|
||||
format: 'p',
|
||||
description: 'timestamps.shortTimeDescription',
|
||||
},
|
||||
T: {
|
||||
label: 'timestamps.longTime',
|
||||
format: 'pp',
|
||||
description: 'timestamps.longTimeDescription',
|
||||
},
|
||||
d: {
|
||||
label: 'timestamps.shortDate',
|
||||
format: 'P',
|
||||
description: 'timestamps.shortDateDescription',
|
||||
},
|
||||
D: {
|
||||
label: 'timestamps.longDate',
|
||||
format: 'Pp',
|
||||
description: 'timestamps.longDateDescription',
|
||||
},
|
||||
f: {
|
||||
label: 'timestamps.fullDateTime',
|
||||
format: 'PPPP p',
|
||||
description: 'timestamps.fullDateTimeDescription',
|
||||
},
|
||||
F: {
|
||||
label: 'timestamps.fullDateTimeLong',
|
||||
format: 'PPPP pp',
|
||||
description: 'timestamps.fullDateTimeLongDescription',
|
||||
},
|
||||
R: {
|
||||
label: 'timestamps.relativeTime',
|
||||
format: 'relative',
|
||||
description: 'timestamps.relativeTimeDescription',
|
||||
},
|
||||
};
|
||||
37
apps/meteor/client/lib/utils/timestamp/types.ts
Normal file
37
apps/meteor/client/lib/utils/timestamp/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type TimestampFormat = 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R';
|
||||
|
||||
export interface ITimestampFormatConfig {
|
||||
label: string;
|
||||
format: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export enum UTCOffsets {
|
||||
'UTC-12' = '-12:00',
|
||||
'UTC-11' = '-11:00',
|
||||
'UTC-10' = '-10:00',
|
||||
'UTC-9' = '-09:00',
|
||||
'UTC-8' = '-08:00',
|
||||
'UTC-7' = '-07:00',
|
||||
'UTC-6' = '-06:00',
|
||||
'UTC-5' = '-05:00',
|
||||
'UTC-4' = '-04:00',
|
||||
'UTC-3' = '-03:00',
|
||||
'UTC-2' = '-02:00',
|
||||
'UTC-1' = '-01:00',
|
||||
'UTC' = '+00:00',
|
||||
'UTC+1' = '+01:00',
|
||||
'UTC+2' = '+02:00',
|
||||
'UTC+3' = '+03:00',
|
||||
'UTC+4' = '+04:00',
|
||||
'UTC+5' = '+05:00',
|
||||
'UTC+6' = '+06:00',
|
||||
'UTC+7' = '+07:00',
|
||||
'UTC+8' = '+08:00',
|
||||
'UTC+9' = '+09:00',
|
||||
'UTC+10' = '+10:00',
|
||||
'UTC+11' = '+11:00',
|
||||
'UTC+12' = '+12:00',
|
||||
}
|
||||
|
||||
export type TimezoneKey = keyof typeof UTCOffsets | 'local';
|
||||
16
apps/meteor/client/lib/utils/userAgentMIMETypeFallback.ts
Normal file
16
apps/meteor/client/lib/utils/userAgentMIMETypeFallback.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Some browsers don't support the MIME type for quicktime video encoder, so we need to
|
||||
* fallback to the 'video/mp4'. There are other fallbacks for other browsers, but this is
|
||||
* the only one we need for now.
|
||||
* @param type - the MIME type to check
|
||||
* @returns the MIME type to use
|
||||
*/
|
||||
export const userAgentMIMETypeFallback = (type: string): string => {
|
||||
const userAgent = navigator.userAgent.toLocaleLowerCase();
|
||||
|
||||
if (type === 'video/quicktime' && userAgent.indexOf('safari') !== -1) {
|
||||
return 'video/mp4';
|
||||
}
|
||||
|
||||
return type;
|
||||
};
|
||||
Reference in New Issue
Block a user