WIP

Nate Butler created

Change summary

crates/theme2/src/default_colors.rs              |   8 
crates/ui2/src/components/list.rs                |   9 
crates/ui2/src/components/notifications_panel.rs | 376 +++++++++++++++++
crates/ui2/src/elements.rs                       |   2 
crates/ui2/src/elements/indicator.rs             |  22 +
crates/ui2/src/static_data.rs                    |  53 ++
6 files changed, 440 insertions(+), 30 deletions(-)

Detailed changes

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(),

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<V: 'static>(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
-        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<V: 'static> List<V> {
         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))
             }
         };

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<V: 'static> {
+    Message(Notification<V>),
+    // WithEdgeHeader(Notification<V>),
+    WithRequiredActions(NotificationWithActions<V>),
+}
+
+pub enum ButtonOrIconButton<V: 'static> {
+    Button(Button<V>),
+    IconButton(IconButton<V>),
+}
+
+pub struct NotificationAction<V: 'static> {
+    button: ButtonOrIconButton<V>,
+    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<Icon>, SharedString),
+}
+
+pub struct NotificationWithActions<V: 'static> {
+    notification: Notification<V>,
+    actions: [NotificationAction<V>; 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<V: 'static> {
+    items: Vec<(Option<Icon>, SharedString, Option<ClickHandler<V>>)>,
+}
+
+struct NotificationHandlers<V: 'static> {
+    click: Option<ClickHandler<V>>,
+}
+
+impl<V: 'static> Default for NotificationHandlers<V> {
+    fn default() -> Self {
+        Self { click: None }
+    }
+}
+
+#[derive(Component)]
+pub struct Notification<V: 'static> {
+    id: ElementId,
+    slot: ActorOrIcon,
+    message: SharedString,
+    date_received: NaiveDateTime,
+    meta: Option<NotificationMeta<V>>,
+    actions: Option<[NotificationAction<V>; 2]>,
+    unread: bool,
+    new: bool,
+    action_taken: Option<NotificationAction<V>>,
+    handlers: NotificationHandlers<V>,
+}
+
+impl<V> Notification<V> {
+    fn new(
+        id: ElementId,
+        message: SharedString,
+        slot: ActorOrIcon,
+        click_action: Option<ClickHandler<V>>,
+    ) -> 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<ElementId>,
+        message: SharedString,
+        actor: PublicActor,
+        click_action: ClickHandler<V>,
+    ) -> 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<ElementId>,
+        message: SharedString,
+        icon: Icon,
+        click_action: ClickHandler<V>,
+    ) -> 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<ElementId>,
+        message: SharedString,
+        actor: PublicActor,
+        click_action: ClickHandler<V>,
+        actions: [NotificationAction<V>; 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<ElementId>,
+        message: SharedString,
+        icon: Icon,
+        click_action: ClickHandler<V>,
+        actions: [NotificationAction<V>; 2],
+    ) -> Self {
+        Self::new(id.into(), message, ActorOrIcon::Icon(icon), None).actions(actions)
+    }
+
+    fn on_click(mut self, handler: ClickHandler<V>) -> Self {
+        self.handlers.click = Some(handler);
+        self
+    }
+
+    pub fn actions(mut self, actions: [NotificationAction<V>; 2]) -> Self {
+        self.actions = Some(actions);
+        self
+    }
+
+    pub fn meta(mut self, meta: NotificationMeta<V>) -> Self {
+        self.meta = Some(meta);
+        self
+    }
+
+    fn render_meta_items(&self, cx: &mut ViewContext<V>) -> impl Component<V> {
+        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::<Vec<_>>(),
+            )
+        } else {
+            div()
+        }
+    }
+
+    fn render_actions(&self, cx: &mut ViewContext<V>) -> impl Component<V> {
+        // 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::<Vec<_>>(),
+        }
+
+        // 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::<Vec<_>>();
+
+        //     el = el.child(h_stack().children(action_children));
+        // } else {
+        //     el = el.child(h_stack().child(div()));
+        // }
+    }
+
+    fn render_slot(&self, cx: &mut ViewContext<V>) -> impl Component<V> {
+        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<V>) -> impl Component<V> {
+        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::<Vec<_>>(),
+
                             ),
                     ),
             )
     }
 }
 
+use chrono::{DateTime, NaiveDateTime};
+use gpui2::{px, Styled};
 #[cfg(feature = "stories")]
 pub use stories::*;
 

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::*;

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<V: 'static>(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
+        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)
+    }
+}

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<PlayerWithCallStatus> {
     ]
 }
 
+pub fn static_new_notification_items_2<V: 'static>() -> Vec<NotificationItem<V>> {
+    vec![]
+}
 pub fn static_new_notification_items<V: 'static>() -> Vec<ListItem<V>> {
     vec![
         ListItem::Header(ListSubHeader::new("New")),
@@ -351,6 +354,52 @@ pub fn static_new_notification_items<V: 'static>() -> Vec<ListItem<V>> {
         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)