mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-09 20:50:12 +00:00
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>
162 lines
5.7 KiB
TypeScript
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}`;
|
|
}
|