Files
Desktop/ts/util/formatTimestamp.dom.ts
2025-10-16 23:46:00 -07:00

272 lines
6.4 KiB
TypeScript

// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import moment from 'moment';
import { HourCyclePreference } from '../types/I18N.std.js';
import type { LocalizerType } from '../types/Util.std.js';
import { assertDev } from './assert.std.js';
import { isYesterday, isToday, type RawTimestamp } from './timestamp.std.js';
import { HOUR, MINUTE, MONTH, WEEK } from './durations/index.std.js';
function getOptionsWithPreferences(
options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormatOptions {
const hourCyclePreference = window.SignalContext.getHourCyclePreference();
if (options.hour12 != null) {
return options;
}
if (hourCyclePreference === HourCyclePreference.Prefer12) {
return { ...options, hour12: true };
}
if (hourCyclePreference === HourCyclePreference.Prefer24) {
return { ...options, hour12: false };
}
return options;
}
/**
* Chrome doesn't implement hour12 correctly
*/
function fixBuggyOptions(
locales: Array<string>,
options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormatOptions {
const resolvedOptions = new Intl.DateTimeFormat(
locales,
options
).resolvedOptions();
const resolvedLocale = new Intl.Locale(resolvedOptions.locale);
let { hourCycle } = resolvedOptions;
// Most languages should use either h24 or h12
if (hourCycle === 'h24') {
hourCycle = 'h23';
}
if (hourCycle === 'h11') {
hourCycle = 'h12';
}
// Only Japanese should use h11 when using hour12 time
if (hourCycle === 'h12' && resolvedLocale.language === 'ja') {
hourCycle = 'h11';
}
return {
...options,
hour12: undefined,
hourCycle,
};
}
function getCacheKey(
locales: Array<string>,
options: Intl.DateTimeFormatOptions
) {
return `${locales.join(',')}:${Object.keys(options)
.sort()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(key => `${key}=${(options as any)[key]}`)
.join(',')}`;
}
const formatterCache = new Map<string, Intl.DateTimeFormat>();
export function getDateTimeFormatter(
options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormat {
const preferredSystemLocales =
window.SignalContext.getPreferredSystemLocales();
const localeOverride = window.SignalContext.getLocaleOverride();
const locales =
localeOverride != null ? [localeOverride] : preferredSystemLocales;
const optionsWithPreferences = getOptionsWithPreferences(options);
const cacheKey = getCacheKey(locales, optionsWithPreferences);
const cachedFormatter = formatterCache.get(cacheKey);
if (cachedFormatter) {
return cachedFormatter;
}
const fixedOptions = fixBuggyOptions(locales, optionsWithPreferences);
const formatter = new Intl.DateTimeFormat(locales, fixedOptions);
formatterCache.set(cacheKey, formatter);
return formatter;
}
export function formatTimestamp(
timestamp: number,
options: Intl.DateTimeFormatOptions
): string {
const formatter = getDateTimeFormatter(options);
try {
return formatter.format(timestamp);
} catch (err) {
assertDev(false, 'invalid timestamp');
return '';
}
}
export function formatDateTimeShort(
i18n: LocalizerType,
rawTimestamp: RawTimestamp
): string {
const timestamp = rawTimestamp.valueOf();
const now = Date.now();
const diff = now - timestamp;
if (diff < HOUR || isToday(timestamp)) {
return formatTime(i18n, rawTimestamp, now);
}
const m = moment(timestamp);
if (diff < WEEK && m.isSame(now, 'month')) {
return formatTimestamp(timestamp, { weekday: 'short' });
}
if (m.isSame(now, 'year')) {
return formatTimestamp(timestamp, {
day: 'numeric',
month: 'short',
});
}
return formatTimestamp(timestamp, {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
export function formatDateTimeForAttachment(
i18n: LocalizerType,
rawTimestamp: RawTimestamp
): string {
const timestamp = rawTimestamp.valueOf();
const now = Date.now();
const diff = now - timestamp;
if (diff < HOUR || isToday(timestamp)) {
return formatTime(i18n, rawTimestamp, now);
}
const m = moment(timestamp);
if (diff < WEEK && m.isSame(now, 'month')) {
return formatTimestamp(timestamp, {
weekday: 'short',
hour: 'numeric',
minute: 'numeric',
});
}
if (m.isSame(now, 'year')) {
return formatTimestamp(timestamp, {
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric',
});
}
return formatTimestamp(timestamp, {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
});
}
export function formatDateTimeLong(
i18n: LocalizerType,
rawTimestamp: RawTimestamp
): string {
const timestamp = rawTimestamp.valueOf();
if (isToday(rawTimestamp)) {
return i18n('icu:timestampFormat__long--today', {
time: formatTimestamp(timestamp, {
hour: 'numeric',
minute: 'numeric',
}),
});
}
if (isYesterday(rawTimestamp)) {
return i18n('icu:timestampFormat__long--yesterday', {
time: formatTimestamp(timestamp, {
hour: 'numeric',
minute: 'numeric',
}),
});
}
return formatTimestamp(timestamp, {
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
month: 'short',
year: 'numeric',
});
}
export function formatTime(
i18n: LocalizerType,
rawTimestamp: RawTimestamp,
now: RawTimestamp,
isRelativeTime?: boolean
): string {
const timestamp = rawTimestamp.valueOf();
const diff = now.valueOf() - timestamp;
if (diff < MINUTE) {
return i18n('icu:justNow');
}
if (diff < HOUR) {
return i18n('icu:minutesAgo', {
minutes: Math.floor(diff / MINUTE),
});
}
if (isRelativeTime) {
return i18n('icu:hoursAgo', {
hours: Math.floor(diff / HOUR),
});
}
return formatTimestamp(timestamp, {
hour: 'numeric',
minute: '2-digit',
});
}
export function formatDate(
i18n: LocalizerType,
rawTimestamp: RawTimestamp
): string {
if (isToday(rawTimestamp)) {
return i18n('icu:today');
}
if (isYesterday(rawTimestamp)) {
return i18n('icu:yesterday');
}
const m = moment(rawTimestamp);
const timestamp = rawTimestamp.valueOf();
if (Math.abs(m.diff(Date.now())) < 6 * MONTH) {
return formatTimestamp(timestamp, {
day: 'numeric',
month: 'short',
weekday: 'short',
});
}
return formatTimestamp(timestamp, {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}