diff --git a/crates/theme2/src/default_colors.rs b/crates/theme2/src/default_colors.rs index 3f2f0503cd..2e62b68fae 100644 --- a/crates/theme2/src/default_colors.rs +++ b/crates/theme2/src/default_colors.rs @@ -199,8 +199,8 @@ impl ThemeColors { ghost_element_disabled: neutral().light_alpha(3).into(), text: neutral().light(12).into(), text_muted: neutral().light(11).into(), - text_placeholder: neutral().light(11).into(), - text_disabled: neutral().light(10).into(), + text_placeholder: neutral().light(10).into(), + text_disabled: neutral().light(9).into(), text_accent: blue().light(11).into(), icon: neutral().light(11).into(), icon_muted: neutral().light(10).into(), @@ -244,8 +244,8 @@ impl ThemeColors { ghost_element_disabled: neutral().dark_alpha(3).into(), text: neutral().dark(12).into(), text_muted: neutral().dark(11).into(), - text_placeholder: neutral().dark(11).into(), - text_disabled: neutral().dark(10).into(), + text_placeholder: neutral().dark(10).into(), + text_disabled: neutral().dark(9).into(), text_accent: blue().dark(11).into(), icon: neutral().dark(11).into(), icon_muted: neutral().dark(10).into(), diff --git a/crates/ui2/src/components/list.rs b/crates/ui2/src/components/list.rs index ad7ec2214f..50a86ff256 100644 --- a/crates/ui2/src/components/list.rs +++ b/crates/ui2/src/components/list.rs @@ -39,7 +39,7 @@ impl ListHeader { left_icon: None, meta: None, variant: ListItemVariant::default(), - toggleable: Toggleable::Toggleable(ToggleState::Toggled), + toggleable: Toggleable::NotToggleable, } } @@ -105,7 +105,6 @@ impl ListHeader { }; h_stack() - .flex_1() .w_full() .bg(cx.theme().colors().surface) // TODO: Add focus state @@ -560,7 +559,7 @@ impl ListSeparator { } fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { - div().h_px().w_full().bg(cx.theme().colors().border) + div().h_px().w_full().bg(cx.theme().colors().border_variant) } } @@ -602,9 +601,9 @@ impl List { let is_toggled = Toggleable::is_toggled(&self.toggleable); let list_content = match (self.items.is_empty(), is_toggled) { - (_, false) => div(), (false, _) => div().children(self.items), - (true, _) => { + (true, false) => div(), + (true, true) => { div().child(Label::new(self.empty_message.clone()).color(LabelColor::Muted)) } }; diff --git a/crates/ui2/src/components/notifications_panel.rs b/crates/ui2/src/components/notifications_panel.rs index c102a2cf57..367e0d0ba6 100644 --- a/crates/ui2/src/components/notifications_panel.rs +++ b/crates/ui2/src/components/notifications_panel.rs @@ -1,5 +1,9 @@ -use crate::{prelude::*, static_new_notification_items, Icon, ListHeaderMeta}; -use crate::{List, ListHeader}; +use crate::{ + h_stack, prelude::*, static_new_notification_items, v_stack, Avatar, Button, Icon, IconButton, + IconElement, Label, LabelColor, LineHeightStyle, ListHeaderMeta, ListSeparator, Stack, + UnreadIndicator, +}; +use crate::{ClickHandler, ListHeader}; #[derive(Component)] pub struct NotificationsPanel { @@ -16,33 +20,367 @@ impl NotificationsPanel { .id(self.id.clone()) .flex() .flex_col() - .w_full() - .h_full() + .size_full() .bg(cx.theme().colors().surface) .child( - div() - .id("header") - .w_full() - .flex() - .flex_col() + ListHeader::new("Notifications").meta(Some(ListHeaderMeta::Tools(vec![ + Icon::AtSign, + Icon::BellOff, + Icon::MailOpen, + ]))), + ) + .child(ListSeparator::new()) + .child( + v_stack() + .id("notifications-panel-scroll-view") + .py_1() .overflow_y_scroll() + .flex_1() .child( - List::new(static_new_notification_items()) - .toggle(ToggleState::Toggled) - .header( - ListHeader::new("Notifications") - .toggle(ToggleState::Toggled) - .meta(Some(ListHeaderMeta::Tools(vec![ - Icon::AtSign, - Icon::BellOff, - Icon::MailOpen, - ]))), + div() + .mx_2() + .p_1() + // TODO: Add cursor style + // .cursor(Cursor::IBeam) + .bg(cx.theme().colors().element) + .border() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Search...") + .color(LabelColor::Placeholder) + .line_height_style(LineHeightStyle::UILabel), + ), + ) + .children(static_new_notification_items()), + ) + } +} + +pub enum NotificationItem { + Message(Notification), + // WithEdgeHeader(Notification), + WithRequiredActions(NotificationWithActions), +} + +pub enum ButtonOrIconButton { + Button(Button), + IconButton(IconButton), +} + +pub struct NotificationAction { + button: ButtonOrIconButton, + tooltip: SharedString, + /// Shows after action is chosen + /// + /// For example, if the action is "Accept" the taken message could be: + /// + /// - `(None,"Accepted")` - "Accepted" + /// + /// - `(Some(Icon::Check),"Accepted")` - ✓ "Accepted" + taken_message: (Option, SharedString), +} + +pub struct NotificationWithActions { + notification: Notification, + actions: [NotificationAction; 2], +} + +/// Represents a person with a Zed account's public profile. +/// All data in this struct should be considered public. +pub struct PublicActor { + username: SharedString, + avatar: SharedString, + is_contact: bool, +} + +pub enum ActorOrIcon { + Actor(PublicActor), + Icon(Icon), +} + +pub struct NotificationMeta { + items: Vec<(Option, SharedString, Option>)>, +} + +struct NotificationHandlers { + click: Option>, +} + +impl Default for NotificationHandlers { + fn default() -> Self { + Self { click: None } + } +} + +#[derive(Component)] +pub struct Notification { + id: ElementId, + slot: ActorOrIcon, + message: SharedString, + date_received: NaiveDateTime, + meta: Option>, + actions: Option<[NotificationAction; 2]>, + unread: bool, + new: bool, + action_taken: Option>, + handlers: NotificationHandlers, +} + +impl Notification { + fn new( + id: ElementId, + message: SharedString, + slot: ActorOrIcon, + click_action: Option>, + ) -> Self { + let handlers = if click_action.is_some() { + NotificationHandlers { + click: click_action, + } + } else { + NotificationHandlers::default() + }; + + Self { + id, + date_received: DateTime::parse_from_rfc3339("1969-07-20T00:00:00Z") + .unwrap() + .naive_local(), + message, + meta: None, + slot, + actions: None, + unread: true, + new: false, + action_taken: None, + handlers, + } + } + + /// Creates a new notification with an actor slot. + /// + /// Requires a click action. + pub fn new_actor_message( + id: impl Into, + message: SharedString, + actor: PublicActor, + click_action: ClickHandler, + ) -> Self { + Self::new( + id.into(), + message, + ActorOrIcon::Actor(actor), + Some(click_action), + ) + } + + /// Creates a new notification with an icon slot. + /// + /// Requires a click action. + pub fn new_icon_message( + id: impl Into, + message: SharedString, + icon: Icon, + click_action: ClickHandler, + ) -> Self { + Self::new( + id.into(), + message, + ActorOrIcon::Icon(icon), + Some(click_action), + ) + } + + /// Creates a new notification with an actor slot + /// and a Call To Action row. + /// + /// Cannot take a click action due to required actions. + pub fn new_actor_with_actions( + id: impl Into, + message: SharedString, + actor: PublicActor, + click_action: ClickHandler, + actions: [NotificationAction; 2], + ) -> Self { + Self::new(id.into(), message, ActorOrIcon::Actor(actor), None).actions(actions) + } + + /// Creates a new notification with an icon slot + /// and a Call To Action row. + /// + /// Cannot take a click action due to required actions. + pub fn new_icon_with_actions( + id: impl Into, + message: SharedString, + icon: Icon, + click_action: ClickHandler, + actions: [NotificationAction; 2], + ) -> Self { + Self::new(id.into(), message, ActorOrIcon::Icon(icon), None).actions(actions) + } + + fn on_click(mut self, handler: ClickHandler) -> Self { + self.handlers.click = Some(handler); + self + } + + pub fn actions(mut self, actions: [NotificationAction; 2]) -> Self { + self.actions = Some(actions); + self + } + + pub fn meta(mut self, meta: NotificationMeta) -> Self { + self.meta = Some(meta); + self + } + + fn render_meta_items(&self, cx: &mut ViewContext) -> impl Component { + if let Some(meta) = &self.meta { + h_stack().children( + meta.items + .iter() + .map(|(icon, text, _)| { + let mut meta_el = div(); + if let Some(icon) = icon { + meta_el = meta_el.child(IconElement::new(icon.clone())); + } + meta_el.child(Label::new(text.clone()).color(LabelColor::Muted)) + }) + .collect::>(), + ) + } else { + div() + } + } + + fn render_actions(&self, cx: &mut ViewContext) -> impl Component { + // match (&self.actions, &self.action_taken) { + // // Show nothing + // (None, _) => div(), + // // Show the taken_message + // (Some(_), Some(action_taken)) => h_stack() + // .children( + // action_taken + // .taken_message + // .0 + // .map(|icon| IconElement::new(icon).color(crate::IconColor::Muted)), + // ) + // .child(Label::new(action_taken.taken_message.1.clone()).color(LabelColor::Muted)), + // // Show the actions + // (Some(actions), None) => h_stack() + // .children(actions.iter().map(actiona.ction.tton Component::render(button),Component::render(icon_button))), + // })) + // .collect::>(), + } + + // if let Some(actions) = &self.actions { + // let action_children = actions + // .iter() + // .map(|action| match &action.button { + // ButtonOrIconButton::Button(button) => { + // div().class("action_button").child(button.label.clone()) + // } + // ButtonOrIconButton::IconButton(icon_button) => div() + // .class("action_icon_button") + // .child(icon_button.icon.to_string()), + // }) + // .collect::>(); + + // el = el.child(h_stack().children(action_children)); + // } else { + // el = el.child(h_stack().child(div())); + // } + } + + fn render_slot(&self, cx: &mut ViewContext) -> impl Component { + match &self.slot { + ActorOrIcon::Actor(actor) => Avatar::new(actor.avatar.clone()).render(), + ActorOrIcon::Icon(icon) => IconElement::new(icon.clone()).render(), + } + } + + fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { + div() + .relative() + .id(self.id.clone()) + .children( + Some( + div() + .absolute() + .left(px(3.0)) + .top_3() + .child(UnreadIndicator::new()), + ) + .filter(|_| self.unread), + ) + .child( + v_stack() + .gap_1() + .child( + h_stack() + .gap_2() + .child(self.render_slot(cx)) + .child(div().flex_1().child(Label::new(self.message.clone()))), + ) + .child( + h_stack() + .justify_between() + .child( + h_stack() + .gap_1() + .child( + Label::new( + self.date_received.format("%m/%d/%Y").to_string(), + ) + .color(LabelColor::Muted), + ) + .child(self.render_meta_items(cx)), + ) + .child( + + match (self.actions, self.action_taken) { + // Show nothing + (None, _) => div(), + // Show the taken_message + (Some(_), Some(action_taken)) => h_stack() + .children( + action_taken + .taken_message + .0 + .map(|icon| IconElement::new(icon).color(crate::IconColor::Muted)), + ) + .child(Label::new(action_taken.taken_message.1.clone()).color(LabelColor::Muted)), + // Show the actions + (Some(actions), None) => h_stack() + + } + + // match (&self.actions, &self.action_taken) { + // // Show nothing + // (None, _) => div(), + // // Show the taken_message + // (Some(_), Some(action_taken)) => h_stack() + // .children( + // action_taken + // .taken_message + // .0 + // .map(|icon| IconElement::new(icon).color(crate::IconColor::Muted)), + // ) + // .child(Label::new(action_taken.taken_message.1.clone()).color(LabelColor::Muted)), + // // Show the actions + // (Some(actions), None) => h_stack() + // .children(actions.iter().map(actiona.ction.tton Component::render(button),Component::render(icon_button))), + // })) + // .collect::>(), + ), ), ) } } +use chrono::{DateTime, NaiveDateTime}; +use gpui2::{px, Styled}; #[cfg(feature = "stories")] pub use stories::*; diff --git a/crates/ui2/src/elements.rs b/crates/ui2/src/elements.rs index c60902ae98..dfff2761a7 100644 --- a/crates/ui2/src/elements.rs +++ b/crates/ui2/src/elements.rs @@ -2,6 +2,7 @@ mod avatar; mod button; mod details; mod icon; +mod indicator; mod input; mod label; mod player; @@ -12,6 +13,7 @@ pub use avatar::*; pub use button::*; pub use details::*; pub use icon::*; +pub use indicator::*; pub use input::*; pub use label::*; pub use player::*; diff --git a/crates/ui2/src/elements/indicator.rs b/crates/ui2/src/elements/indicator.rs new file mode 100644 index 0000000000..86c83a1bf1 --- /dev/null +++ b/crates/ui2/src/elements/indicator.rs @@ -0,0 +1,22 @@ +use gpui2::px; + +use crate::prelude::*; + +#[derive(Component)] +pub struct UnreadIndicator; + +impl UnreadIndicator { + pub fn new() -> Self { + Self + } + + fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { + div() + .border_2() + .border_color(cx.theme().colors().surface) + .w(px(9.0)) + .h(px(9.0)) + .z_index(2) + .bg(cx.theme().status().info) + } +} diff --git a/crates/ui2/src/static_data.rs b/crates/ui2/src/static_data.rs index ebbc89832c..dc724ec5c4 100644 --- a/crates/ui2/src/static_data.rs +++ b/crates/ui2/src/static_data.rs @@ -8,8 +8,8 @@ use theme2::ActiveTheme; use crate::{ Buffer, BufferRow, BufferRows, Button, EditorPane, FileSystemStatus, GitStatus, HighlightedLine, Icon, Keybinding, Label, LabelColor, ListEntry, ListEntrySize, ListHeaderMeta, - ListItem, ListSubHeader, Livestream, MicStatus, ModifierKeys, PaletteItem, Player, - PlayerCallStatus, PlayerWithCallStatus, ScreenShareStatus, Symbol, Tab, ToggleState, + ListItem, ListSubHeader, Livestream, MicStatus, ModifierKeys, NotificationItem, PaletteItem, + Player, PlayerCallStatus, PlayerWithCallStatus, ScreenShareStatus, Symbol, Tab, ToggleState, VideoStatus, }; use crate::{HighlightedText, ListDetailsEntry}; @@ -326,6 +326,9 @@ pub fn static_players_with_call_status() -> Vec { ] } +pub fn static_new_notification_items_2() -> Vec> { + vec![] +} pub fn static_new_notification_items() -> Vec> { vec![ ListItem::Header(ListSubHeader::new("New")), @@ -351,6 +354,52 @@ pub fn static_new_notification_items() -> Vec> { ListItem::Details(ListDetailsEntry::new( "as-cii accepted your contact request.", )), + ListItem::Details( + ListDetailsEntry::new("You were added as an admin on the #gpui2 channel.").seen(true), + ), + ListItem::Details(ListDetailsEntry::new( + "osiewicz accepted your contact request.", + )), + ListItem::Details(ListDetailsEntry::new( + "ConradIrwin accepted your contact request.", + )), + ListItem::Details( + ListDetailsEntry::new("nathansobo invited you to a stream in #gpui2.") + .seen(true) + .meta("This stream has ended."), + ), + ListItem::Details(ListDetailsEntry::new( + "nathansobo accepted your contact request.", + )), + ListItem::Header(ListSubHeader::new("Earlier")), + ListItem::Details( + ListDetailsEntry::new("mikaylamaki added you as a contact.").actions(vec![ + Button::new("Decline"), + Button::new("Accept").variant(crate::ButtonVariant::Filled), + ]), + ), + ListItem::Details( + ListDetailsEntry::new("maxdeviant invited you to a stream in #design.") + .seen(true) + .meta("This stream has ended."), + ), + ListItem::Details(ListDetailsEntry::new( + "as-cii accepted your contact request.", + )), + ListItem::Details( + ListDetailsEntry::new("You were added as an admin on the #gpui2 channel.").seen(true), + ), + ListItem::Details(ListDetailsEntry::new( + "osiewicz accepted your contact request.", + )), + ListItem::Details(ListDetailsEntry::new( + "ConradIrwin accepted your contact request.", + )), + ListItem::Details( + ListDetailsEntry::new("nathansobo invited you to a stream in #gpui2.") + .seen(true) + .meta("This stream has ended."), + ), ] .into_iter() .map(From::from)