mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
156 lines
5.8 KiB
Dart
156 lines
5.8 KiB
Dart
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);
|
||
}
|