diff --git a/Cargo.lock b/Cargo.lock index 802488a3cd..20d35c96e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9947,7 +9947,6 @@ dependencies = [ name = "time_format" version = "0.1.0" dependencies = [ - "anyhow", "core-foundation", "core-foundation-sys 0.8.6", "sys-locale", diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index bf34ad4326..8a39054df8 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -493,9 +493,10 @@ impl ChatPanel { ) .child( Label::new(time_format::format_localized_timestamp( - OffsetDateTime::now_utc(), message.timestamp, + OffsetDateTime::now_utc(), self.local_timezone, + time_format::TimestampFormat::EnhancedAbsolute, )) .size(LabelSize::Small) .color(Color::Muted), diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 6bd4abe588..d5a591d845 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use std::{sync::Arc, time::Duration}; use time::{OffsetDateTime, UtcOffset}; -use ui::{h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label}; +use ui::{h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label, Tooltip}; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, @@ -228,6 +228,20 @@ impl NotificationPanel { self.did_render_notification(notification_id, ¬ification, cx); } + let relative_timestamp = time_format::format_localized_timestamp( + timestamp, + now, + self.local_timezone, + time_format::TimestampFormat::Relative, + ); + + let absolute_timestamp = time_format::format_localized_timestamp( + timestamp, + now, + self.local_timezone, + time_format::TimestampFormat::Absolute, + ); + Some( div() .id(ix) @@ -237,6 +251,7 @@ impl NotificationPanel { .px_2() .py_1() .gap_2() + .hover(|style| style.bg(cx.theme().colors().element_hover)) .when(can_navigate, |el| { el.cursor(CursorStyle::PointingHand).on_click({ let notification = notification.clone(); @@ -261,12 +276,17 @@ impl NotificationPanel { .child( h_flex() .child( - Label::new(format_timestamp( - timestamp, - now, - self.local_timezone, - )) - .color(Color::Muted), + div() + .id("notification_timestamp") + .hover(|style| { + style + .bg(cx.theme().colors().element_selected) + .rounded_md() + }) + .child(Label::new(relative_timestamp).color(Color::Muted)) + .tooltip(move |cx| { + Tooltip::text(absolute_timestamp.clone(), cx) + }), ) .children(if let Some(is_accepted) = response { Some(div().flex().flex_grow().justify_end().child(Label::new( @@ -757,29 +777,3 @@ impl Render for NotificationToast { } impl EventEmitter for NotificationToast {} - -fn format_timestamp( - mut timestamp: OffsetDateTime, - mut now: OffsetDateTime, - local_timezone: UtcOffset, -) -> String { - timestamp = timestamp.to_offset(local_timezone); - now = now.to_offset(local_timezone); - - let today = now.date(); - let date = timestamp.date(); - if date == today { - let difference = now - timestamp; - if difference >= Duration::from_secs(3600) { - format!("{}h", difference.whole_seconds() / 3600) - } else if difference >= Duration::from_secs(60) { - format!("{}m", difference.whole_seconds() / 60) - } else { - "just now".to_string() - } - } else if date.next_day() == Some(today) { - "yesterday".to_string() - } else { - format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) - } -} diff --git a/crates/time_format/Cargo.toml b/crates/time_format/Cargo.toml index 1b5d36eb6c..2613a425fa 100644 --- a/crates/time_format/Cargo.toml +++ b/crates/time_format/Cargo.toml @@ -13,7 +13,6 @@ path = "src/time_format.rs" doctest = false [dependencies] -anyhow.workspace = true sys-locale.workspace = true time.workspace = true diff --git a/crates/time_format/src/time_format.rs b/crates/time_format/src/time_format.rs index b80d52ab65..81b031c199 100644 --- a/crates/time_format/src/time_format.rs +++ b/crates/time_format/src/time_format.rs @@ -1,48 +1,156 @@ -use std::sync::OnceLock; - use time::{OffsetDateTime, UtcOffset}; +/// The formatting style for a timestamp. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TimestampFormat { + /// Formats the timestamp as an absolute time, e.g. "2021-12-31 3:00AM". + Absolute, + /// Formats the timestamp as an absolute time. + /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively. + /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM". + EnhancedAbsolute, + /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago". + Relative, +} + /// Formats a timestamp, which respects the user's date and time preferences/custom format. pub fn format_localized_timestamp( - reference: OffsetDateTime, timestamp: OffsetDateTime, + reference: OffsetDateTime, timezone: UtcOffset, + format: TimestampFormat, +) -> String { + let timestamp_local = timestamp.to_offset(timezone); + let reference_local = reference.to_offset(timezone); + + match format { + TimestampFormat::Absolute => { + format_absolute_timestamp(timestamp_local, reference_local, false) + } + TimestampFormat::EnhancedAbsolute => { + format_absolute_timestamp(timestamp_local, reference_local, true) + } + TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local) + .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)), + } +} + +fn format_absolute_timestamp( + timestamp: OffsetDateTime, + reference: OffsetDateTime, + #[allow(unused_variables)] enhanced_date_formatting: bool, ) -> String { #[cfg(target_os = "macos")] { - let timestamp_local = timestamp.to_offset(timezone); - let reference_local = reference.to_offset(timezone); - let reference_local_date = reference_local.date(); - let timestamp_local_date = timestamp_local.date(); + if !enhanced_date_formatting { + return format!( + "{} {}", + macos::format_date(×tamp), + macos::format_time(×tamp) + ); + } - let native_fmt = if timestamp_local_date == reference_local_date { - macos::format_time(×tamp) - } else if reference_local_date.previous_day() == Some(timestamp_local_date) { - macos::format_time(×tamp).map(|t| format!("yesterday at {}", t).to_string()) + let timestamp_date = timestamp.date(); + let reference_date = reference.date(); + if timestamp_date == reference_date { + format!("Today at {}", macos::format_time(×tamp)) + } else if reference_date.previous_day() == Some(timestamp_date) { + format!("Yesterday at {}", macos::format_time(×tamp)) } else { - macos::format_date(×tamp) - }; - native_fmt.unwrap_or_else(|_| format_timestamp_fallback(reference, timestamp, timezone)) + format!( + "{} {}", + macos::format_date(×tamp), + macos::format_time(×tamp) + ) + } } #[cfg(not(target_os = "macos"))] { // todo(linux) respect user's date/time preferences // todo(windows) respect user's date/time preferences - format_timestamp_fallback(reference, timestamp, timezone) + format_timestamp_fallback(timestamp, reference) } } -fn format_timestamp_fallback( - reference: OffsetDateTime, - timestamp: OffsetDateTime, - timezone: UtcOffset, -) -> String { - static CURRENT_LOCALE: OnceLock = OnceLock::new(); - let current_locale = CURRENT_LOCALE - .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))); +fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option { + let difference = reference - timestamp; + let minutes = difference.whole_minutes(); + match minutes { + 0 => Some("Just now".to_string()), + 1 => Some("1 minute ago".to_string()), + 2..=59 => Some(format!("{} minutes ago", minutes)), + _ => { + let hours = difference.whole_hours(); + match hours { + 1 => Some("1 hour ago".to_string()), + 2..=23 => Some(format!("{} hours ago", hours)), + _ => None, + } + } + } +} - let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str()); - format_timestamp_naive(reference, timestamp, timezone, is_12_hour_time) +fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String { + let timestamp_date = timestamp.date(); + let reference_date = reference.date(); + let difference = reference_date - timestamp_date; + let days = difference.whole_days(); + match days { + 0 => "Today".to_string(), + 1 => "Yesterday".to_string(), + 2..=6 => format!("{} days ago", days), + _ => { + let weeks = difference.whole_weeks(); + match weeks { + 1 => "1 week ago".to_string(), + 2..=4 => format!("{} weeks ago", weeks), + _ => { + let month_diff = calculate_month_difference(timestamp, reference); + match month_diff { + 0..=1 => "1 month ago".to_string(), + 2..=11 => format!("{} months ago", month_diff), + _ => { + let timestamp_year = timestamp_date.year(); + let reference_year = reference_date.year(); + let years = reference_year - timestamp_year; + match years { + 1 => "1 year ago".to_string(), + _ => format!("{} years ago", years), + } + } + } + } + } + } + } +} + +/// Calculates the difference in months between two timestamps. +/// The reference timestamp should always be greater than the timestamp. +fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTime) -> usize { + let timestamp_year = timestamp.year(); + let reference_year = reference.year(); + let timestamp_month: u8 = timestamp.month().into(); + let reference_month: u8 = reference.month().into(); + + let month_diff = if reference_month >= timestamp_month { + reference_month as usize - timestamp_month as usize + } else { + 12 - timestamp_month as usize + reference_month as usize + }; + + let year_diff = (reference_year - timestamp_year) as usize; + if year_diff == 0 { + reference_month as usize - timestamp_month as usize + } else { + if month_diff == 0 { + year_diff * 12 + } else if timestamp_month > reference_month { + (year_diff - 1) * 12 + month_diff + } else { + year_diff * 12 + month_diff + } + } } /// Formats a timestamp, which is either in 12-hour or 24-hour time format. @@ -50,20 +158,20 @@ fn format_timestamp_fallback( /// This function does not respect the user's date and time preferences. /// This should only be used as a fallback mechanism when the OS time formatting fails. pub fn format_timestamp_naive( - reference: OffsetDateTime, - timestamp: OffsetDateTime, - timezone: UtcOffset, + timestamp_local: OffsetDateTime, + reference_local: OffsetDateTime, is_12_hour_time: bool, ) -> String { - let timestamp_local = timestamp.to_offset(timezone); let timestamp_local_hour = timestamp_local.hour(); let timestamp_local_minute = timestamp_local.minute(); + let reference_local_date = reference_local.date(); + let timestamp_local_date = timestamp_local.date(); let (hour, meridiem) = if is_12_hour_time { let meridiem = if timestamp_local_hour >= 12 { - "pm" + "PM" } else { - "am" + "AM" }; let hour_12 = match timestamp_local_hour { @@ -78,23 +186,11 @@ pub fn format_timestamp_naive( }; let formatted_time = match meridiem { - Some(meridiem) => format!("{:02}:{:02} {}", hour, timestamp_local_minute, meridiem), + Some(meridiem) => format!("{}:{:02} {}", hour, timestamp_local_minute, meridiem), None => format!("{:02}:{:02}", hour, timestamp_local_minute), }; - let reference_local = reference.to_offset(timezone); - let reference_local_date = reference_local.date(); - let timestamp_local_date = timestamp_local.date(); - - if timestamp_local_date == reference_local_date { - return formatted_time; - } - - if reference_local_date.previous_day() == Some(timestamp_local_date) { - return format!("yesterday at {}", formatted_time); - } - - match meridiem { + let formatted_date = match meridiem { Some(_) => format!( "{:02}/{:02}/{}", timestamp_local_date.month() as u32, @@ -107,9 +203,28 @@ pub fn format_timestamp_naive( timestamp_local_date.month() as u32, timestamp_local_date.year() ), + }; + + if timestamp_local_date == reference_local_date { + format!("Today at {}", formatted_time) + } else if reference_local_date.previous_day() == Some(timestamp_local_date) { + format!("Yesterday at {}", formatted_time) + } else { + format!("{} {}", formatted_date, formatted_time) } } +#[cfg(not(target_os = "macos"))] +fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String { + static CURRENT_LOCALE: std::sync::OnceLock = std::sync::OnceLock::new(); + let current_locale = CURRENT_LOCALE + .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))); + + let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str()); + format_timestamp_naive(timestamp, reference, is_12_hour_time) +} + +#[cfg(not(target_os = "macos"))] /// Returns `true` if the locale is recognized as a 12-hour time locale. fn is_12_hour_time_by_locale(locale: &str) -> bool { [ @@ -129,7 +244,6 @@ fn is_12_hour_time_by_locale(locale: &str) -> bool { #[cfg(target_os = "macos")] mod macos { - use anyhow::Result; use core_foundation::base::TCFType; use core_foundation::date::CFAbsoluteTime; use core_foundation::string::CFString; @@ -144,18 +258,18 @@ mod macos { locale::CFLocaleCopyCurrent, }; - pub fn format_time(timestamp: &time::OffsetDateTime) -> Result { + pub fn format_time(timestamp: &time::OffsetDateTime) -> String { format_with_date_formatter(timestamp, TIME_FORMATTER.with(|f| *f)) } - pub fn format_date(timestamp: &time::OffsetDateTime) -> Result { + pub fn format_date(timestamp: &time::OffsetDateTime) -> String { format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f)) } fn format_with_date_formatter( timestamp: &time::OffsetDateTime, fmt: CFDateFormatterRef, - ) -> Result { + ) -> String { const UNIX_TO_CF_ABSOLUTE_TIME_OFFSET: i64 = 978307200; // Convert timestamp to macOS absolute time let timestamp_macos = timestamp.unix_timestamp() - UNIX_TO_CF_ABSOLUTE_TIME_OFFSET; @@ -166,7 +280,7 @@ mod macos { fmt, cf_absolute_time, ); - Ok(CFString::wrap_under_create_rule(s).to_string()) + CFString::wrap_under_create_rule(s).to_string() } } @@ -201,8 +315,8 @@ mod tests { let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0); assert_eq!( - format_timestamp_naive(reference, timestamp, test_timezone(), false), - "15:30" + format_timestamp_naive(timestamp, reference, false), + "Today at 15:30" ); } @@ -212,8 +326,8 @@ mod tests { let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0); assert_eq!( - format_timestamp_naive(reference, timestamp, test_timezone(), true), - "03:30 pm" + format_timestamp_naive(timestamp, reference, true), + "Today at 3:30 PM" ); } @@ -223,8 +337,8 @@ mod tests { let timestamp = create_offset_datetime(1990, 4, 11, 9, 0, 0); assert_eq!( - format_timestamp_naive(reference, timestamp, test_timezone(), true), - "yesterday at 09:00 am" + format_timestamp_naive(timestamp, reference, true), + "Yesterday at 9:00 AM" ); } @@ -234,8 +348,8 @@ mod tests { let timestamp = create_offset_datetime(1990, 4, 11, 20, 0, 0); assert_eq!( - format_timestamp_naive(reference, timestamp, test_timezone(), true), - "yesterday at 08:00 pm" + format_timestamp_naive(timestamp, reference, true), + "Yesterday at 8:00 PM" ); } @@ -245,8 +359,8 @@ mod tests { let timestamp = create_offset_datetime(1990, 4, 11, 18, 0, 0); assert_eq!( - format_timestamp_naive(reference, timestamp, test_timezone(), true), - "yesterday at 06:00 pm" + format_timestamp_naive(timestamp, reference, true), + "Yesterday at 6:00 PM" ); } @@ -256,8 +370,8 @@ mod tests { let timestamp = create_offset_datetime(1990, 4, 11, 23, 55, 0); assert_eq!( - format_timestamp_naive(reference, timestamp, test_timezone(), true), - "yesterday at 11:55 pm" + format_timestamp_naive(timestamp, reference, true), + "Yesterday at 11:55 PM" ); } @@ -267,8 +381,8 @@ mod tests { let timestamp = create_offset_datetime(1990, 4, 1, 20, 0, 0); assert_eq!( - format_timestamp_naive(reference, timestamp, test_timezone(), true), - "yesterday at 08:00 pm" + format_timestamp_naive(timestamp, reference, true), + "Yesterday at 8:00 PM" ); } @@ -278,8 +392,219 @@ mod tests { let timestamp = create_offset_datetime(1990, 4, 10, 20, 20, 0); assert_eq!( - format_timestamp_naive(reference, timestamp, test_timezone(), true), - "04/10/1990" + format_timestamp_naive(timestamp, reference, true), + "04/10/1990 8:20 PM" + ); + } + + #[test] + fn test_relative_format_minutes() { + let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0); + let mut current_timestamp = reference; + + let mut next_minute = || { + current_timestamp = if current_timestamp.minute() == 0 { + current_timestamp + .replace_hour(current_timestamp.hour() - 1) + .unwrap() + .replace_minute(59) + .unwrap() + } else { + current_timestamp + .replace_minute(current_timestamp.minute() - 1) + .unwrap() + }; + current_timestamp + }; + + assert_eq!( + format_relative_time(reference, reference), + Some("Just now".to_string()) + ); + + assert_eq!( + format_relative_time(next_minute(), reference), + Some("1 minute ago".to_string()) + ); + + for i in 2..=59 { + assert_eq!( + format_relative_time(next_minute(), reference), + Some(format!("{} minutes ago", i)) + ); + } + + assert_eq!( + format_relative_time(next_minute(), reference), + Some("1 hour ago".to_string()) + ); + } + + #[test] + fn test_relative_format_hours() { + let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0); + let mut current_timestamp = reference; + + let mut next_hour = || { + current_timestamp = if current_timestamp.hour() == 0 { + let date = current_timestamp.date().previous_day().unwrap(); + current_timestamp.replace_date(date) + } else { + current_timestamp + .replace_hour(current_timestamp.hour() - 1) + .unwrap() + }; + current_timestamp + }; + + assert_eq!( + format_relative_time(next_hour(), reference), + Some("1 hour ago".to_string()) + ); + + for i in 2..=23 { + assert_eq!( + format_relative_time(next_hour(), reference), + Some(format!("{} hours ago", i)) + ); + } + + assert_eq!(format_relative_time(next_hour(), reference), None); + } + + #[test] + fn test_relative_format_days() { + let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0); + let mut current_timestamp = reference; + + let mut next_day = || { + let date = current_timestamp.date().previous_day().unwrap(); + current_timestamp = current_timestamp.replace_date(date); + current_timestamp + }; + + assert_eq!( + format_relative_date(reference, reference), + "Today".to_string() + ); + + assert_eq!( + format_relative_date(next_day(), reference), + "Yesterday".to_string() + ); + + for i in 2..=6 { + assert_eq!( + format_relative_date(next_day(), reference), + format!("{} days ago", i) + ); + } + + assert_eq!(format_relative_date(next_day(), reference), "1 week ago"); + } + + #[test] + fn test_relative_format_weeks() { + let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0); + let mut current_timestamp = reference; + + let mut next_week = || { + for _ in 0..7 { + let date = current_timestamp.date().previous_day().unwrap(); + current_timestamp = current_timestamp.replace_date(date); + } + current_timestamp + }; + + assert_eq!( + format_relative_date(next_week(), reference), + "1 week ago".to_string() + ); + + for i in 2..=4 { + assert_eq!( + format_relative_date(next_week(), reference), + format!("{} weeks ago", i) + ); + } + + assert_eq!(format_relative_date(next_week(), reference), "1 month ago"); + } + + #[test] + fn test_relative_format_months() { + let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0); + let mut current_timestamp = reference; + + let mut next_month = || { + if current_timestamp.month() == time::Month::January { + current_timestamp = current_timestamp + .replace_month(time::Month::December) + .unwrap() + .replace_year(current_timestamp.year() - 1) + .unwrap(); + } else { + current_timestamp = current_timestamp + .replace_month(current_timestamp.month().previous()) + .unwrap(); + } + current_timestamp + }; + + assert_eq!( + format_relative_date(next_month(), reference), + "4 weeks ago".to_string() + ); + + for i in 2..=11 { + assert_eq!( + format_relative_date(next_month(), reference), + format!("{} months ago", i) + ); + } + + assert_eq!(format_relative_date(next_month(), reference), "1 year ago"); + } + + #[test] + fn test_calculate_month_difference() { + let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0); + + assert_eq!(calculate_month_difference(reference, reference), 0); + + assert_eq!( + calculate_month_difference(create_offset_datetime(1990, 1, 12, 23, 0, 0), reference), + 3 + ); + + assert_eq!( + calculate_month_difference(create_offset_datetime(1989, 11, 12, 23, 0, 0), reference), + 5 + ); + + assert_eq!( + calculate_month_difference(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference), + 12 + ); + + assert_eq!( + calculate_month_difference(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference), + 13 + ); + + assert_eq!( + calculate_month_difference(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference), + 35 + ); + + assert_eq!( + calculate_month_difference(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference), + 36 + ); + + assert_eq!( + calculate_month_difference(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference), + 37 ); } @@ -298,6 +623,7 @@ mod tests { let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day) .unwrap(); let time = time::Time::from_hms(hour, minute, second).unwrap(); - date.with_time(time).assume_utc() // Assume UTC for simplicity + let date = date.with_time(time).assume_utc(); // Assume UTC for simplicity + date.to_offset(test_timezone()) } }