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

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

View File

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

View 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('');
});
});

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

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

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

View 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') ?? '*');
};

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

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

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

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

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

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

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

View 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(),
};
};

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

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

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

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

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

View File

@@ -0,0 +1 @@
export const isIOsDevice = !!navigator.platform.match(/iPhone|iPod|iPad/);

View File

@@ -0,0 +1,9 @@
import { router } from '../../providers/RouterProvider';
let embedded = false;
router.subscribeToRouteChange(() => {
embedded = router.getSearchParameters().layout === 'embedded';
});
export const isLayoutEmbedded = () => embedded;

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

View File

@@ -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: &lt;img src=x onerror=alert(1)&gt;');
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);
});
});
});

View File

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

View File

@@ -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('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
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('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
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('&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;');
});
});
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');
});
});
});

View File

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

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

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

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

View File

@@ -0,0 +1,3 @@
import { emojiParser } from '../emoji/emojiParser';
export const renderMessageEmoji = (html: string) => emojiParser(html);

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

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

View File

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

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

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

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

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

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

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

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