mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Checked every widget at its smallest size with long names, big counts
and German labels. What spilled over:
- the Inbox header at two cells: the count pushed the compose button
off the edge (search now goes at that width, the title truncates
first, and only the unread count stays);
- a long sender pushed the triage card's time and position out;
- German button labels were cut ("Archivie", "Lösc"); the triage
buttons now drop to icons, Archive and Delete first, when the labels
do not fit;
- five-digit counts overflowed Unread count, Tag and Mail and next, and
"/ 23000" wrapped a character per line; counts from 10,000 up are
shortened (10K, 12,3K) and the total goes where it does not fit;
- "ungelesen" was cut beside the number in Mail and next and moves
under it instead.
Every widget header now lets its title give way before the buttons on
its right.
149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
TypeScript
// Strings and date formatting for widget layouts. Widgets render in a headless
|
|
// task with no React tree, so this uses the plain translate() lookup instead
|
|
// of the useTranslation hook. Keys live under `widgets.` in locales/rn/*.json
|
|
// (added by `npm run i18n:harvest`); each call repeats the English text as
|
|
// the fallback so a missing key never shows up raw on the home screen.
|
|
|
|
import { isSupportedLocale, translate, type LocaleCode, type MessageParams } from '../i18n';
|
|
|
|
export interface Fmt {
|
|
locale: string;
|
|
hour12: boolean;
|
|
t: (key: string, fallback: string, params?: MessageParams) => string;
|
|
/** Clock time in the user's 12h/24h setting. */
|
|
time: (ms: number) => string;
|
|
weekdayShort: (ms: number) => string;
|
|
weekdayLong: (ms: number) => string;
|
|
monthLong: (ms: number) => string;
|
|
/** "Sep 28" */
|
|
dayMonth: (ms: number) => string;
|
|
/** "Mon, Sep 28" */
|
|
weekdayDayMonth: (ms: number) => string;
|
|
/** "Sep 28, 2026" */
|
|
fullDate: (ms: number) => string;
|
|
/** Time today, weekday this week, date otherwise - the list's receivedAt style. */
|
|
listDate: (ms: number, now: number) => string;
|
|
/** "in 25 min", "in 2 h", "now". */
|
|
relative: (ms: number, now: number) => string;
|
|
bytes: (n: number) => string;
|
|
/** A count for a big number: as is below 10,000, then compact ("12K"). */
|
|
count: (n: number) => string;
|
|
}
|
|
|
|
function safeFormat(locale: string, options: Intl.DateTimeFormatOptions): (ms: number) => string {
|
|
let formatter: Intl.DateTimeFormat | null = null;
|
|
try {
|
|
formatter = new Intl.DateTimeFormat(locale, options);
|
|
} catch {
|
|
try {
|
|
formatter = new Intl.DateTimeFormat('en', options);
|
|
} catch {
|
|
formatter = null;
|
|
}
|
|
}
|
|
return (ms: number) => {
|
|
const d = new Date(ms);
|
|
if (formatter) {
|
|
try {
|
|
return formatter.format(d);
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return d.toDateString();
|
|
};
|
|
}
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
|
export function startOfDay(ms: number): number {
|
|
const d = new Date(ms);
|
|
d.setHours(0, 0, 0, 0);
|
|
return d.getTime();
|
|
}
|
|
|
|
export function addDays(ms: number, days: number): number {
|
|
const d = new Date(ms);
|
|
d.setDate(d.getDate() + days);
|
|
return d.getTime();
|
|
}
|
|
|
|
export function sameDay(a: number, b: number): boolean {
|
|
return startOfDay(a) === startOfDay(b);
|
|
}
|
|
|
|
export function makeFmt(locale: string, hour12: boolean): Fmt {
|
|
const lang: LocaleCode = isSupportedLocale(locale) ? locale : 'en';
|
|
const t = (key: string, fallback: string, params?: MessageParams) =>
|
|
translate(lang, key, fallback, params);
|
|
|
|
const weekdayShort = safeFormat(locale, { weekday: 'short' });
|
|
const weekdayLong = safeFormat(locale, { weekday: 'long' });
|
|
const monthLong = safeFormat(locale, { month: 'long' });
|
|
const dayMonth = safeFormat(locale, { month: 'short', day: 'numeric' });
|
|
const weekdayDayMonth = safeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
const fullDate = safeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
|
|
const time = (ms: number) => {
|
|
const d = new Date(ms);
|
|
const h = d.getHours();
|
|
const m = pad(d.getMinutes());
|
|
if (!hour12) return `${pad(h)}:${m}`;
|
|
const suffix = h < 12 ? 'AM' : 'PM';
|
|
return `${h % 12 === 0 ? 12 : h % 12}:${m} ${suffix}`;
|
|
};
|
|
|
|
return {
|
|
locale,
|
|
hour12,
|
|
t,
|
|
time,
|
|
weekdayShort,
|
|
weekdayLong,
|
|
monthLong,
|
|
dayMonth,
|
|
weekdayDayMonth,
|
|
fullDate,
|
|
listDate: (ms, now) => {
|
|
if (sameDay(ms, now)) return time(ms);
|
|
const days = Math.round((startOfDay(now) - startOfDay(ms)) / 86400000);
|
|
if (days > 0 && days < 7) return weekdayShort(ms);
|
|
return dayMonth(ms);
|
|
},
|
|
relative: (ms, now) => {
|
|
const minutes = Math.round((ms - now) / 60000);
|
|
if (minutes <= 0) return t('widgets.relative.now', 'now');
|
|
if (minutes < 60) return t('widgets.relative.minutes', 'in {count} min', { count: minutes });
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 24) return t('widgets.relative.hours', 'in {count} h', { count: hours });
|
|
const days = Math.round((startOfDay(ms) - startOfDay(now)) / 86400000);
|
|
return t('widgets.relative.days', '{count, plural, one {in # day} other {in # days}}', { count: days });
|
|
},
|
|
bytes: (n) => {
|
|
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let v = n;
|
|
let i = 0;
|
|
while (v >= 1024 && i < units.length - 1) {
|
|
v /= 1024;
|
|
i++;
|
|
}
|
|
return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
|
},
|
|
count: (n) => {
|
|
if (n < 10000) return String(n);
|
|
// Not Intl's compact notation: German leaves thousands unshortened
|
|
// ("10.007"), which is what has to fit.
|
|
const [value, unit] = n >= 1_000_000 ? [n / 1_000_000, 'M'] : [n / 1000, 'K'];
|
|
const digits = value < 100 ? 1 : 0;
|
|
let text: string;
|
|
try {
|
|
text = new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value);
|
|
} catch {
|
|
text = value.toFixed(digits).replace(/\.0$/, '');
|
|
}
|
|
return `${text}${unit}`;
|
|
},
|
|
};
|
|
}
|