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