1use time::{OffsetDateTime, UtcOffset};
2
3/// The formatting style for a timestamp.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum TimestampFormat {
6 /// Formats the timestamp as an absolute time, e.g. "2021-12-31 3:00AM".
7 Absolute,
8 /// Formats the timestamp as an absolute time.
9 /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.
10 /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".
11 EnhancedAbsolute,
12 /// Formats the timestamp as an absolute time, using month name, day of month, year. e.g. "Feb. 24, 2024".
13 MediumAbsolute,
14 /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".
15 Relative,
16}
17
18/// Formats a timestamp, which respects the user's date and time preferences/custom format.
19pub fn format_localized_timestamp(
20 timestamp: OffsetDateTime,
21 reference: OffsetDateTime,
22 timezone: UtcOffset,
23 format: TimestampFormat,
24) -> String {
25 let timestamp_local = timestamp.to_offset(timezone);
26 let reference_local = reference.to_offset(timezone);
27
28 match format {
29 TimestampFormat::Absolute => {
30 format_absolute_timestamp(timestamp_local, reference_local, false)
31 }
32 TimestampFormat::EnhancedAbsolute => {
33 format_absolute_timestamp(timestamp_local, reference_local, true)
34 }
35 TimestampFormat::MediumAbsolute => {
36 format_absolute_timestamp_medium(timestamp_local, reference_local)
37 }
38 TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)
39 .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),
40 }
41}
42
43fn format_absolute_timestamp(
44 timestamp: OffsetDateTime,
45 reference: OffsetDateTime,
46 #[allow(unused_variables)] enhanced_date_formatting: bool,
47) -> String {
48 #[cfg(target_os = "macos")]
49 {
50 if !enhanced_date_formatting {
51 return format!(
52 "{} {}",
53 macos::format_date(×tamp),
54 macos::format_time(×tamp)
55 );
56 }
57
58 let timestamp_date = timestamp.date();
59 let reference_date = reference.date();
60 if timestamp_date == reference_date {
61 format!("Today at {}", macos::format_time(×tamp))
62 } else if reference_date.previous_day() == Some(timestamp_date) {
63 format!("Yesterday at {}", macos::format_time(×tamp))
64 } else {
65 format!(
66 "{} {}",
67 macos::format_date(×tamp),
68 macos::format_time(×tamp)
69 )
70 }
71 }
72 #[cfg(not(target_os = "macos"))]
73 {
74 // todo(linux) respect user's date/time preferences
75 // todo(windows) respect user's date/time preferences
76 format_timestamp_fallback(timestamp, reference)
77 }
78}
79
80fn format_absolute_timestamp_medium(
81 timestamp: OffsetDateTime,
82 #[allow(unused_variables)] reference: OffsetDateTime,
83) -> String {
84 #[cfg(target_os = "macos")]
85 {
86 macos::format_date_medium(×tamp)
87 }
88 #[cfg(not(target_os = "macos"))]
89 {
90 // todo(linux) respect user's date/time preferences
91 // todo(windows) respect user's date/time preferences
92 format_timestamp_fallback(timestamp, reference)
93 }
94}
95
96fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {
97 let difference = reference - timestamp;
98 let minutes = difference.whole_minutes();
99 match minutes {
100 0 => Some("Just now".to_string()),
101 1 => Some("1 minute ago".to_string()),
102 2..=59 => Some(format!("{} minutes ago", minutes)),
103 _ => {
104 let hours = difference.whole_hours();
105 match hours {
106 1 => Some("1 hour ago".to_string()),
107 2..=23 => Some(format!("{} hours ago", hours)),
108 _ => None,
109 }
110 }
111 }
112}
113
114fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
115 let timestamp_date = timestamp.date();
116 let reference_date = reference.date();
117 let difference = reference_date - timestamp_date;
118 let days = difference.whole_days();
119 match days {
120 0 => "Today".to_string(),
121 1 => "Yesterday".to_string(),
122 2..=6 => format!("{} days ago", days),
123 _ => {
124 let weeks = difference.whole_weeks();
125 match weeks {
126 1 => "1 week ago".to_string(),
127 2..=4 => format!("{} weeks ago", weeks),
128 _ => {
129 let month_diff = calculate_month_difference(timestamp, reference);
130 match month_diff {
131 0..=1 => "1 month ago".to_string(),
132 2..=11 => format!("{} months ago", month_diff),
133 _ => {
134 let timestamp_year = timestamp_date.year();
135 let reference_year = reference_date.year();
136 let years = reference_year - timestamp_year;
137 match years {
138 1 => "1 year ago".to_string(),
139 _ => format!("{} years ago", years),
140 }
141 }
142 }
143 }
144 }
145 }
146 }
147}
148
149/// Calculates the difference in months between two timestamps.
150/// The reference timestamp should always be greater than the timestamp.
151fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTime) -> usize {
152 let timestamp_year = timestamp.year();
153 let reference_year = reference.year();
154 let timestamp_month: u8 = timestamp.month().into();
155 let reference_month: u8 = reference.month().into();
156
157 let month_diff = if reference_month >= timestamp_month {
158 reference_month as usize - timestamp_month as usize
159 } else {
160 12 - timestamp_month as usize + reference_month as usize
161 };
162
163 let year_diff = (reference_year - timestamp_year) as usize;
164 if year_diff == 0 {
165 reference_month as usize - timestamp_month as usize
166 } else if month_diff == 0 {
167 year_diff * 12
168 } else if timestamp_month > reference_month {
169 (year_diff - 1) * 12 + month_diff
170 } else {
171 year_diff * 12 + month_diff
172 }
173}
174
175/// Formats a timestamp, which is either in 12-hour or 24-hour time format.
176/// Note:
177/// This function does not respect the user's date and time preferences.
178/// This should only be used as a fallback mechanism when the OS time formatting fails.
179pub fn format_timestamp_naive(
180 timestamp_local: OffsetDateTime,
181 reference_local: OffsetDateTime,
182 is_12_hour_time: bool,
183) -> String {
184 let timestamp_local_hour = timestamp_local.hour();
185 let timestamp_local_minute = timestamp_local.minute();
186 let reference_local_date = reference_local.date();
187 let timestamp_local_date = timestamp_local.date();
188
189 let (hour, meridiem) = if is_12_hour_time {
190 let meridiem = if timestamp_local_hour >= 12 {
191 "PM"
192 } else {
193 "AM"
194 };
195
196 let hour_12 = match timestamp_local_hour {
197 0 => 12, // Midnight
198 13..=23 => timestamp_local_hour - 12, // PM hours
199 _ => timestamp_local_hour, // AM hours
200 };
201
202 (hour_12, Some(meridiem))
203 } else {
204 (timestamp_local_hour, None)
205 };
206
207 let formatted_time = match meridiem {
208 Some(meridiem) => format!("{}:{:02} {}", hour, timestamp_local_minute, meridiem),
209 None => format!("{:02}:{:02}", hour, timestamp_local_minute),
210 };
211
212 let formatted_date = match meridiem {
213 Some(_) => format!(
214 "{:02}/{:02}/{}",
215 timestamp_local_date.month() as u32,
216 timestamp_local_date.day(),
217 timestamp_local_date.year()
218 ),
219 None => format!(
220 "{:02}/{:02}/{}",
221 timestamp_local_date.day(),
222 timestamp_local_date.month() as u32,
223 timestamp_local_date.year()
224 ),
225 };
226
227 if timestamp_local_date == reference_local_date {
228 format!("Today at {}", formatted_time)
229 } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
230 format!("Yesterday at {}", formatted_time)
231 } else {
232 format!("{} {}", formatted_date, formatted_time)
233 }
234}
235
236#[cfg(not(target_os = "macos"))]
237fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
238 static CURRENT_LOCALE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
239 let current_locale = CURRENT_LOCALE
240 .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
241
242 let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str());
243 format_timestamp_naive(timestamp, reference, is_12_hour_time)
244}
245
246#[cfg(not(target_os = "macos"))]
247/// Returns `true` if the locale is recognized as a 12-hour time locale.
248fn is_12_hour_time_by_locale(locale: &str) -> bool {
249 [
250 "es-MX", "es-CO", "es-SV", "es-NI",
251 "es-HN", // Mexico, Colombia, El Salvador, Nicaragua, Honduras
252 "en-US", "en-CA", "en-AU", "en-NZ", // U.S, Canada, Australia, New Zealand
253 "ar-SA", "ar-EG", "ar-JO", // Saudi Arabia, Egypt, Jordan
254 "en-IN", "hi-IN", // India, Hindu
255 "en-PK", "ur-PK", // Pakistan, Urdu
256 "en-PH", "fil-PH", // Philippines, Filipino
257 "bn-BD", "ccp-BD", // Bangladesh, Chakma
258 "en-IE", "ga-IE", // Ireland, Irish
259 "en-MY", "ms-MY", // Malaysia, Malay
260 ]
261 .contains(&locale)
262}
263
264#[cfg(target_os = "macos")]
265mod macos {
266 use core_foundation::base::TCFType;
267 use core_foundation::date::CFAbsoluteTime;
268 use core_foundation::string::CFString;
269 use core_foundation_sys::date_formatter::CFDateFormatterCreateStringWithAbsoluteTime;
270 use core_foundation_sys::date_formatter::CFDateFormatterRef;
271 use core_foundation_sys::locale::CFLocaleRef;
272 use core_foundation_sys::{
273 base::kCFAllocatorDefault,
274 date_formatter::{
275 kCFDateFormatterMediumStyle, kCFDateFormatterNoStyle, kCFDateFormatterShortStyle,
276 CFDateFormatterCreate,
277 },
278 locale::CFLocaleCopyCurrent,
279 };
280
281 pub fn format_time(timestamp: &time::OffsetDateTime) -> String {
282 format_with_date_formatter(timestamp, TIME_FORMATTER.with(|f| *f))
283 }
284
285 pub fn format_date(timestamp: &time::OffsetDateTime) -> String {
286 format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
287 }
288
289 pub fn format_date_medium(timestamp: &time::OffsetDateTime) -> String {
290 format_with_date_formatter(timestamp, MEDIUM_DATE_FORMATTER.with(|f| *f))
291 }
292
293 fn format_with_date_formatter(
294 timestamp: &time::OffsetDateTime,
295 fmt: CFDateFormatterRef,
296 ) -> String {
297 const UNIX_TO_CF_ABSOLUTE_TIME_OFFSET: i64 = 978307200;
298 // Convert timestamp to macOS absolute time
299 let timestamp_macos = timestamp.unix_timestamp() - UNIX_TO_CF_ABSOLUTE_TIME_OFFSET;
300 let cf_absolute_time = timestamp_macos as CFAbsoluteTime;
301 unsafe {
302 let s = CFDateFormatterCreateStringWithAbsoluteTime(
303 kCFAllocatorDefault,
304 fmt,
305 cf_absolute_time,
306 );
307 CFString::wrap_under_create_rule(s).to_string()
308 }
309 }
310
311 thread_local! {
312 static CURRENT_LOCALE: CFLocaleRef = unsafe { CFLocaleCopyCurrent() };
313 static TIME_FORMATTER: CFDateFormatterRef = unsafe {
314 CFDateFormatterCreate(
315 kCFAllocatorDefault,
316 CURRENT_LOCALE.with(|locale| *locale),
317 kCFDateFormatterNoStyle,
318 kCFDateFormatterShortStyle,
319 )
320 };
321 static DATE_FORMATTER: CFDateFormatterRef = unsafe {
322 CFDateFormatterCreate(
323 kCFAllocatorDefault,
324 CURRENT_LOCALE.with(|locale| *locale),
325 kCFDateFormatterShortStyle,
326 kCFDateFormatterNoStyle,
327 )
328 };
329
330 static MEDIUM_DATE_FORMATTER: CFDateFormatterRef = unsafe {
331 CFDateFormatterCreate(
332 kCFAllocatorDefault,
333 CURRENT_LOCALE.with(|locale| *locale),
334 kCFDateFormatterMediumStyle,
335 kCFDateFormatterNoStyle,
336 )
337 };
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_format_24_hour_time() {
347 let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
348 let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
349
350 assert_eq!(
351 format_timestamp_naive(timestamp, reference, false),
352 "Today at 15:30"
353 );
354 }
355
356 #[test]
357 fn test_format_today() {
358 let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
359 let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
360
361 assert_eq!(
362 format_timestamp_naive(timestamp, reference, true),
363 "Today at 3:30 PM"
364 );
365 }
366
367 #[test]
368 fn test_format_yesterday() {
369 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
370 let timestamp = create_offset_datetime(1990, 4, 11, 9, 0, 0);
371
372 assert_eq!(
373 format_timestamp_naive(timestamp, reference, true),
374 "Yesterday at 9:00 AM"
375 );
376 }
377
378 #[test]
379 fn test_format_yesterday_less_than_24_hours_ago() {
380 let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
381 let timestamp = create_offset_datetime(1990, 4, 11, 20, 0, 0);
382
383 assert_eq!(
384 format_timestamp_naive(timestamp, reference, true),
385 "Yesterday at 8:00 PM"
386 );
387 }
388
389 #[test]
390 fn test_format_yesterday_more_than_24_hours_ago() {
391 let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
392 let timestamp = create_offset_datetime(1990, 4, 11, 18, 0, 0);
393
394 assert_eq!(
395 format_timestamp_naive(timestamp, reference, true),
396 "Yesterday at 6:00 PM"
397 );
398 }
399
400 #[test]
401 fn test_format_yesterday_over_midnight() {
402 let reference = create_offset_datetime(1990, 4, 12, 0, 5, 0);
403 let timestamp = create_offset_datetime(1990, 4, 11, 23, 55, 0);
404
405 assert_eq!(
406 format_timestamp_naive(timestamp, reference, true),
407 "Yesterday at 11:55 PM"
408 );
409 }
410
411 #[test]
412 fn test_format_yesterday_over_month() {
413 let reference = create_offset_datetime(1990, 4, 2, 9, 0, 0);
414 let timestamp = create_offset_datetime(1990, 4, 1, 20, 0, 0);
415
416 assert_eq!(
417 format_timestamp_naive(timestamp, reference, true),
418 "Yesterday at 8:00 PM"
419 );
420 }
421
422 #[test]
423 fn test_format_before_yesterday() {
424 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
425 let timestamp = create_offset_datetime(1990, 4, 10, 20, 20, 0);
426
427 assert_eq!(
428 format_timestamp_naive(timestamp, reference, true),
429 "04/10/1990 8:20 PM"
430 );
431 }
432
433 #[test]
434 fn test_relative_format_minutes() {
435 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
436 let mut current_timestamp = reference;
437
438 let mut next_minute = || {
439 current_timestamp = if current_timestamp.minute() == 0 {
440 current_timestamp
441 .replace_hour(current_timestamp.hour() - 1)
442 .unwrap()
443 .replace_minute(59)
444 .unwrap()
445 } else {
446 current_timestamp
447 .replace_minute(current_timestamp.minute() - 1)
448 .unwrap()
449 };
450 current_timestamp
451 };
452
453 assert_eq!(
454 format_relative_time(reference, reference),
455 Some("Just now".to_string())
456 );
457
458 assert_eq!(
459 format_relative_time(next_minute(), reference),
460 Some("1 minute ago".to_string())
461 );
462
463 for i in 2..=59 {
464 assert_eq!(
465 format_relative_time(next_minute(), reference),
466 Some(format!("{} minutes ago", i))
467 );
468 }
469
470 assert_eq!(
471 format_relative_time(next_minute(), reference),
472 Some("1 hour ago".to_string())
473 );
474 }
475
476 #[test]
477 fn test_relative_format_hours() {
478 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
479 let mut current_timestamp = reference;
480
481 let mut next_hour = || {
482 current_timestamp = if current_timestamp.hour() == 0 {
483 let date = current_timestamp.date().previous_day().unwrap();
484 current_timestamp.replace_date(date)
485 } else {
486 current_timestamp
487 .replace_hour(current_timestamp.hour() - 1)
488 .unwrap()
489 };
490 current_timestamp
491 };
492
493 assert_eq!(
494 format_relative_time(next_hour(), reference),
495 Some("1 hour ago".to_string())
496 );
497
498 for i in 2..=23 {
499 assert_eq!(
500 format_relative_time(next_hour(), reference),
501 Some(format!("{} hours ago", i))
502 );
503 }
504
505 assert_eq!(format_relative_time(next_hour(), reference), None);
506 }
507
508 #[test]
509 fn test_relative_format_days() {
510 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
511 let mut current_timestamp = reference;
512
513 let mut next_day = || {
514 let date = current_timestamp.date().previous_day().unwrap();
515 current_timestamp = current_timestamp.replace_date(date);
516 current_timestamp
517 };
518
519 assert_eq!(
520 format_relative_date(reference, reference),
521 "Today".to_string()
522 );
523
524 assert_eq!(
525 format_relative_date(next_day(), reference),
526 "Yesterday".to_string()
527 );
528
529 for i in 2..=6 {
530 assert_eq!(
531 format_relative_date(next_day(), reference),
532 format!("{} days ago", i)
533 );
534 }
535
536 assert_eq!(format_relative_date(next_day(), reference), "1 week ago");
537 }
538
539 #[test]
540 fn test_relative_format_weeks() {
541 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
542 let mut current_timestamp = reference;
543
544 let mut next_week = || {
545 for _ in 0..7 {
546 let date = current_timestamp.date().previous_day().unwrap();
547 current_timestamp = current_timestamp.replace_date(date);
548 }
549 current_timestamp
550 };
551
552 assert_eq!(
553 format_relative_date(next_week(), reference),
554 "1 week ago".to_string()
555 );
556
557 for i in 2..=4 {
558 assert_eq!(
559 format_relative_date(next_week(), reference),
560 format!("{} weeks ago", i)
561 );
562 }
563
564 assert_eq!(format_relative_date(next_week(), reference), "1 month ago");
565 }
566
567 #[test]
568 fn test_relative_format_months() {
569 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
570 let mut current_timestamp = reference;
571
572 let mut next_month = || {
573 if current_timestamp.month() == time::Month::January {
574 current_timestamp = current_timestamp
575 .replace_month(time::Month::December)
576 .unwrap()
577 .replace_year(current_timestamp.year() - 1)
578 .unwrap();
579 } else {
580 current_timestamp = current_timestamp
581 .replace_month(current_timestamp.month().previous())
582 .unwrap();
583 }
584 current_timestamp
585 };
586
587 assert_eq!(
588 format_relative_date(next_month(), reference),
589 "4 weeks ago".to_string()
590 );
591
592 for i in 2..=11 {
593 assert_eq!(
594 format_relative_date(next_month(), reference),
595 format!("{} months ago", i)
596 );
597 }
598
599 assert_eq!(format_relative_date(next_month(), reference), "1 year ago");
600 }
601
602 #[test]
603 fn test_calculate_month_difference() {
604 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
605
606 assert_eq!(calculate_month_difference(reference, reference), 0);
607
608 assert_eq!(
609 calculate_month_difference(create_offset_datetime(1990, 1, 12, 23, 0, 0), reference),
610 3
611 );
612
613 assert_eq!(
614 calculate_month_difference(create_offset_datetime(1989, 11, 12, 23, 0, 0), reference),
615 5
616 );
617
618 assert_eq!(
619 calculate_month_difference(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference),
620 12
621 );
622
623 assert_eq!(
624 calculate_month_difference(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference),
625 13
626 );
627
628 assert_eq!(
629 calculate_month_difference(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference),
630 35
631 );
632
633 assert_eq!(
634 calculate_month_difference(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference),
635 36
636 );
637
638 assert_eq!(
639 calculate_month_difference(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference),
640 37
641 );
642 }
643
644 fn test_timezone() -> UtcOffset {
645 UtcOffset::from_hms(0, 0, 0).expect("Valid timezone offset")
646 }
647
648 fn create_offset_datetime(
649 year: i32,
650 month: u8,
651 day: u8,
652 hour: u8,
653 minute: u8,
654 second: u8,
655 ) -> OffsetDateTime {
656 let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
657 .unwrap();
658 let time = time::Time::from_hms(hour, minute, second).unwrap();
659 let date = date.with_time(time).assume_utc(); // Assume UTC for simplicity
660 date.to_offset(test_timezone())
661 }
662}