Files
buzz/mobile/lib/features/channels/date_formatters.dart
2026-10-17 16:51:44 -07:00

156 lines
5.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
// Re-export shortPubkey so existing callers continue to compile.
export '../../shared/utils/string_utils.dart' show shortPubkey;
final _weekdayFormat = DateFormat('EEEE');
final _weekdayMonthDayFormat = DateFormat('EEEE, MMMM d');
final _monthDayYearFormat = DateFormat('MMMM d, y');
final _shortMonthDayFormat = DateFormat('MMM d');
final _messageTimeFormat = DateFormat('h:mm a', 'en_US');
/// Days in a week, past which the weekday name stops being unambiguous.
const _weekdayBandDays = 7;
/// Label for a day divider: "Today", "Yesterday", "Monday",
/// "Tuesday, March 31", or "March 31, 2025".
///
/// ```
/// Today → "Today"
/// Yesterday → "Yesterday"
/// 2–6 days ago → "Monday"
/// older, this year → "Tuesday, March 31"
/// earlier years → "March 31, 2025"
/// ```
///
/// The weekday rides along within the current year — it still orients ("was
/// that a weekend?") well past the six-day band. Beyond a year it stops
/// earning its width, and the year takes its place.
///
/// Mirrors `formatDayGroupLabel` in `desktop/src/shared/lib/datetime.ts`,
/// including its two departures from the Block writing standard: the day is
/// kept in the oldest band rather than collapsing to month-and-year, because a
/// divider has to *identify* its day — collapsing would give every day in a
/// month the same header. And there is no ordinal suffix ("March 31", never
/// "March 31st"), which the standard does ask for.
///
/// [now] is exposed for testing; production callers should omit it.
String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) {
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
now ??= DateTime.now();
final dayDiff = _calendarDaysBetween(now, date);
if (dayDiff == 0) return 'Today';
if (dayDiff == 1) return 'Yesterday';
// Bounded below as well as above: a timestamp in the future (clock skew, or a
// relay ahead of this device) must not be labelled with a weekday that reads
// as the recent past.
if (dayDiff > 1 && dayDiff < _weekdayBandDays) {
return _weekdayFormat.format(date);
}
return date.year == now.year
? _weekdayMonthDayFormat.format(date)
: _monthDayYearFormat.format(date);
}
/// Whole calendar days from [date] to [now], in local time. Rounded rather than
/// truncated so a DST transition — a 23- or 25-hour day — still counts as one.
int _calendarDaysBetween(DateTime now, DateTime date) {
final startOfNow = DateTime(now.year, now.month, now.day);
final startOfDate = DateTime(date.year, date.month, date.day);
return (startOfNow.difference(startOfDate).inHours / 24).round();
}
/// Whether two unix-second timestamps fall on the same calendar day (local time).
bool isSameDay(int a, int b) {
final dtA = DateTime.fromMillisecondsSinceEpoch(
a * 1000,
isUtc: true,
).toLocal();
final dtB = DateTime.fromMillisecondsSinceEpoch(
b * 1000,
isUtc: true,
).toLocal();
return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day;
}
/// Reuses local calendar components while building one timeline.
///
/// Adjacent rows compare the same timestamps for dividers, author grouping,
/// avatars, and sticky headers. Resolve each timestamp only once. Create a new
/// instance on each build so a timezone change cannot leave stale cached days.
class TimelineCalendarDays {
final _days = <int, (int, int, int)>{};
(int, int, int) _day(int unixSeconds) => _days.putIfAbsent(unixSeconds, () {
final date = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
return (date.year, date.month, date.day);
});
/// Whether two timestamps fall on the same local calendar day.
bool isSameDay(int a, int b) => _day(a) == _day(b);
}
/// Returns a compact relative time string like "just now", "5m ago", "3h ago",
/// "2d ago", or a short date for older timestamps.
String relativeTime(int unixSeconds) {
final now = DateTime.now();
final time = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
final diff = now.difference(time);
if (diff.inMinutes < 1) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${time.month}/${time.day}/${time.year}';
}
/// Returns desktop-parity thread activity copy such as "just now",
/// "3 hours ago", or "on May 19".
String formatThreadSummaryLastReplyTime(
int unixSeconds, {
@visibleForTesting int? nowSeconds,
}) {
nowSeconds ??= DateTime.now().millisecondsSinceEpoch ~/ 1000;
var diff = nowSeconds - unixSeconds;
if (diff < 0) diff = 0;
if (diff < 60) return 'just now';
if (diff < 3600) return _formatAgo(diff ~/ 60, 'minute');
if (diff < 86400) return _formatAgo(diff ~/ 3600, 'hour');
if (diff < 604800) return _formatAgo(diff ~/ 86400, 'day');
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
// No ordinal suffix, per the writing standard.
return 'on ${_shortMonthDayFormat.format(date)}';
}
String _formatAgo(int value, String unit) =>
'$value $unit${value == 1 ? '' : 's'} ago';
/// Desktop-parity message clock time, e.g. "2:34 PM".
///
/// Deliberately clock-only at every band, unlike desktop's message header,
/// which reads "Yesterday at 2:34 PM". Mobile timestamps sit inside a chat
/// bubble on a narrow screen with the day divider a short scroll away, so this
/// is the compact side of that split — not an oversight. Change it only
/// alongside a layout that has room for a date.
String formatMessageTime(int unixSeconds) {
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
return _messageTimeFormat.format(date);
}