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