mattermost-mobile-upstream/app/utils/datetime.ts
yasserfaraazkhan 27b9f80e5b fix(datetime): keep the scheduled label when the engine rejects timeZone
MM-T5720 fails on iOS only. The failure screenshot (run 31977498176, ios shard
9272797291) shows the Scheduled tab listing the post with its channel row and
post_header.date_time, but no "Send on ..." row at all — so
scheduled_post_header.scheduled_at never rendered.

Chain: getReadableTimestamp returns '' when the engine cannot format, and
DraftAndScheduledPostHeader drops the entire scheduled row on an empty string.
Hermes (iOS) returns the literal "Invalid Date" for an option it cannot
honour, timeZone being the usual one, while Android's engine accepts the same
input — which is exactly the platform split we see.

Losing the label is worse than showing the time in the device zone, so retry
without timeZone before giving up. A NaN timestamp still yields '' — that is
genuinely no data, and its existing test still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:41:37 +05:30

162 lines
5.7 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {Platform} from 'react-native';
import type {IntlShape} from 'react-intl';
export type ValidMinuteInterval = 1 | 2 | 3 | 4 | 5 | 6 | 10 | 12 | 15 | 20 | 30;
const IOS_VALID_INTERVALS = new Set<ValidMinuteInterval>([1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30]);
export function toValidMinuteInterval(interval?: number): ValidMinuteInterval {
if (Platform.OS !== 'ios') {
// Android doesn't use minuteInterval — return 30 as a no-op default
return 30;
}
if (interval && IOS_VALID_INTERVALS.has(interval as ValidMinuteInterval)) {
return interval as ValidMinuteInterval;
}
// iOS doesn't support 60+ — clamp to 30
return 30;
}
/**
* Parses flexible time string input into hours and minutes.
* Supports: "13:40", "1:30pm", "1:30 PM", "2pm", "14:00", etc.
*/
export function parseTimeString(input: string): {hours: number; minutes: number} | null {
const trimmed = input.trim();
const formats = [
'H:mm', // 0:00 - 23:59
'HH:mm',
'h:mm A', // 2:30 PM
'h:mma', // 2:30pm
'ha', // 2pm
'h A', // 2 PM
];
const m = moment(trimmed, formats, true);
if (!m.isValid()) {
return null;
}
return {
hours: m.hour(),
minutes: m.minute(),
};
}
export function isSameDate(a: Date, b: Date = new Date()): boolean {
return a.getDate() === b.getDate() && isSameMonth(a, b) && isSameYear(a, b);
}
export function isSameMonth(a: Date, b: Date = new Date()): boolean {
return a.getMonth() === b.getMonth() && isSameYear(a, b);
}
export function isSameYear(a: Date, b: Date = new Date()): boolean {
return a.getFullYear() === b.getFullYear();
}
export function isToday(date: Date) {
const now = new Date();
return isSameDate(date, now);
}
export function isYesterday(date: Date): boolean {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return isSameDate(date, yesterday);
}
export function toMilliseconds({days, hours, minutes, seconds}: {days?: number; hours?: number; minutes?: number; seconds?: number}) {
const totalSeconds = toSeconds({days, hours, minutes, seconds});
return totalSeconds * 1000;
}
export function toSeconds({days, hours, minutes, seconds}: {days?: number; hours?: number; minutes?: number; seconds?: number}) {
const totalHours = ((days || 0) * 24) + (hours || 0);
const totalMinutes = (totalHours * 60) + (minutes || 0);
const totalSeconds = (totalMinutes * 60) + (seconds || 0);
return totalSeconds;
}
export function getReadableTimestamp(timestamp: number, timeZone: string, isMilitaryTime: boolean, currentUserLocale: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
// Guard: callers (e.g. scheduled-message "Send on …" label) may pass an undefined/NaN
// timestamp during reschedule transitions. Returning '' avoids rendering the literal
// string "Invalid Date" in the UI (surfaced as MM-T5720 on the Drafts > Scheduled tab).
return '';
}
const now = new Date();
const isCurrentYear = date.getFullYear() === now.getFullYear();
// Empty/invalid timeZone makes Intl throw (Node) or return "Invalid Date" (Hermes/iOS).
// Omit the option so formatting falls back to the device zone instead of poisoning the label.
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: !isMilitaryTime,
...(timeZone ? {timeZone} : {}),
...(isCurrentYear ? {} : {year: 'numeric'}),
};
const format = (opts: Intl.DateTimeFormatOptions) => {
try {
const formatted = date.toLocaleString(currentUserLocale, opts);
return formatted === 'Invalid Date' ? '' : formatted;
} catch {
return '';
}
};
const formatted = format(options);
if (formatted) {
return formatted;
}
// Hermes (iOS) returns the literal "Invalid Date" when it cannot honour an option,
// and timeZone is the usual culprit — Android's engine accepts the same input. An
// empty result makes DraftAndScheduledPostHeader drop the whole "Send on ..." row,
// so the label vanishes instead of merely being in the wrong zone (MM-T5720, iOS
// only, run 31977498176). Retry in the device zone rather than lose the label.
delete options.timeZone;
return format(options);
}
export function formatTime(seconds: number, textTime: boolean = false, intl?: IntlShape) {
const h = Math.max(Math.floor(seconds / 3600), 0);
const m = Math.max(Math.floor((seconds % 3600) / 60), 0);
const s = Math.max(Math.floor(seconds % 60), 0);
if (textTime && intl) {
const parts: string[] = [];
if (h > 0) {
parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.hours_component', defaultMessage: '{hours}h'}, {hours: h}));
}
if (m > 0) {
parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.minutes_component', defaultMessage: '{minutes}m'}, {minutes: m}));
}
if (s > 0) {
parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.seconds_component', defaultMessage: '{seconds}s'}, {seconds: s}));
}
return parts.length > 0 ? parts.join(' ') : intl.formatMessage({id: 'mobile.format_time.text_time.seconds_component', defaultMessage: '{seconds}s'}, {seconds: 0});
}
const hh = h > 0 ? `${h}:` : '';
const mm = h > 0 ? `${m.toString().padStart(2, '0')}` : `${m}`;
const ss = s.toString().padStart(2, '0');
return `${hh}${mm}:${ss}`;
}