diff --git a/Cargo.lock b/Cargo.lock index 86ae29e16b..f1013bf72d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12071,6 +12071,7 @@ dependencies = [ "lsp2", "menu2", "node_runtime", + "notifications2", "num_cpus", "outline2", "parking_lot 0.11.2", diff --git a/crates/channel2/src/channel_store_tests.rs b/crates/channel2/src/channel_store_tests.rs index e193917b76..7f392032cd 100644 --- a/crates/channel2/src/channel_store_tests.rs +++ b/crates/channel2/src/channel_store_tests.rs @@ -345,7 +345,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { fn init_test(cx: &mut AppContext) -> Model { let http = FakeHttpClient::with_404_response(); let client = Client::new(http.clone(), cx); - let user_store = cx.build_model(|cx| UserStore::new(client.clone(), http, cx)); + let user_store = cx.build_model(|cx| UserStore::new(client.clone(), cx)); let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); diff --git a/crates/client2/src/test.rs b/crates/client2/src/test.rs index 086cbd570e..ce3ea740b6 100644 --- a/crates/client2/src/test.rs +++ b/crates/client2/src/test.rs @@ -8,7 +8,6 @@ use rpc::{ ConnectionId, Peer, Receipt, TypedEnvelope, }; use std::sync::Arc; -use util::http::FakeHttpClient; pub struct FakeServer { peer: Arc, @@ -195,8 +194,7 @@ impl FakeServer { client: Arc, cx: &mut TestAppContext, ) -> Model { - let http_client = FakeHttpClient::with_404_response(); - let user_store = cx.build_model(|cx| UserStore::new(client, http_client, cx)); + let user_store = cx.build_model(|cx| UserStore::new(client, cx)); assert_eq!( self.receive::() .await diff --git a/crates/client2/src/user.rs b/crates/client2/src/user.rs index a5dba03d2d..b08d423cae 100644 --- a/crates/client2/src/user.rs +++ b/crates/client2/src/user.rs @@ -2,13 +2,12 @@ use super::{proto, Client, Status, TypedEnvelope}; use anyhow::{anyhow, Context, Result}; use collections::{hash_map::Entry, HashMap, HashSet}; use feature_flags::FeatureFlagAppExt; -use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt}; -use gpui::{AsyncAppContext, EventEmitter, ImageData, Model, ModelContext, Task}; +use futures::{channel::mpsc, Future, StreamExt}; +use gpui::{AsyncAppContext, EventEmitter, Model, ModelContext, SharedString, Task}; use postage::{sink::Sink, watch}; use rpc::proto::{RequestMessage, UsersResponse}; use std::sync::{Arc, Weak}; use text::ReplicaId; -use util::http::HttpClient; use util::TryFutureExt as _; pub type UserId = u64; @@ -20,7 +19,7 @@ pub struct ParticipantIndex(pub u32); pub struct User { pub id: UserId, pub github_login: String, - pub avatar: Option>, + pub avatar_uri: SharedString, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -76,7 +75,6 @@ pub struct UserStore { pending_contact_requests: HashMap, invite_info: Option, client: Weak, - http: Arc, _maintain_contacts: Task<()>, _maintain_current_user: Task>, } @@ -112,11 +110,7 @@ enum UpdateContacts { } impl UserStore { - pub fn new( - client: Arc, - http: Arc, - cx: &mut ModelContext, - ) -> Self { + pub fn new(client: Arc, cx: &mut ModelContext) -> Self { let (mut current_user_tx, current_user_rx) = watch::channel(); let (update_contacts_tx, mut update_contacts_rx) = mpsc::unbounded(); let rpc_subscriptions = vec![ @@ -134,7 +128,6 @@ impl UserStore { invite_info: None, client: Arc::downgrade(&client), update_contacts_tx, - http, _maintain_contacts: cx.spawn(|this, mut cx| async move { let _subscriptions = rpc_subscriptions; while let Some(message) = update_contacts_rx.next().await { @@ -445,6 +438,12 @@ impl UserStore { self.perform_contact_request(user_id, proto::RemoveContact { user_id }, cx) } + pub fn has_incoming_contact_request(&self, user_id: u64) -> bool { + self.incoming_contact_requests + .iter() + .any(|user| user.id == user_id) + } + pub fn respond_to_contact_request( &mut self, requester_id: u64, @@ -616,17 +615,14 @@ impl UserStore { cx: &mut ModelContext, ) -> Task>>> { let client = self.client.clone(); - let http = self.http.clone(); cx.spawn(|this, mut cx| async move { if let Some(rpc) = client.upgrade() { let response = rpc.request(request).await.context("error loading users")?; - let users = future::join_all( - response - .users - .into_iter() - .map(|user| User::new(user, http.as_ref())), - ) - .await; + let users = response + .users + .into_iter() + .map(|user| User::new(user)) + .collect::>(); this.update(&mut cx, |this, _| { for user in &users { @@ -659,11 +655,11 @@ impl UserStore { } impl User { - async fn new(message: proto::User, http: &dyn HttpClient) -> Arc { + fn new(message: proto::User) -> Arc { Arc::new(User { id: message.id, github_login: message.github_login, - avatar: fetch_avatar(http, &message.avatar_url).warn_on_err().await, + avatar_uri: message.avatar_url.into(), }) } } @@ -696,25 +692,3 @@ impl Collaborator { }) } } - -// todo!("we probably don't need this now that we fetch") -async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result> { - let mut response = http - .get(url, Default::default(), true) - .await - .map_err(|e| anyhow!("failed to send user avatar request: {}", e))?; - - if !response.status().is_success() { - return Err(anyhow!("avatar request failed {:?}", response.status())); - } - - let mut body = Vec::new(); - response - .body_mut() - .read_to_end(&mut body) - .await - .map_err(|e| anyhow!("failed to read user avatar response body: {}", e))?; - let format = image::guess_format(&body)?; - let image = image::load_from_memory_with_format(&body, format)?.into_bgra8(); - Ok(Arc::new(ImageData::new(image))) -} diff --git a/crates/collab2/src/tests/integration_tests.rs b/crates/collab2/src/tests/integration_tests.rs index 823c8e9045..201ba07dbb 100644 --- a/crates/collab2/src/tests/integration_tests.rs +++ b/crates/collab2/src/tests/integration_tests.rs @@ -1823,7 +1823,7 @@ async fn test_active_call_events( owner: Arc::new(User { id: client_a.user_id().unwrap(), github_login: "user_a".to_string(), - avatar: None, + avatar_uri: "avatar_a".into(), }), project_id: project_a_id, worktree_root_names: vec!["a".to_string()], @@ -1841,7 +1841,7 @@ async fn test_active_call_events( owner: Arc::new(User { id: client_b.user_id().unwrap(), github_login: "user_b".to_string(), - avatar: None, + avatar_uri: "avatar_b".into(), }), project_id: project_b_id, worktree_root_names: vec!["b".to_string()] diff --git a/crates/collab2/src/tests/test_server.rs b/crates/collab2/src/tests/test_server.rs index f751736971..cfecb4880d 100644 --- a/crates/collab2/src/tests/test_server.rs +++ b/crates/collab2/src/tests/test_server.rs @@ -209,7 +209,7 @@ impl TestServer { }); let fs = FakeFs::new(cx.executor()); - let user_store = cx.build_model(|cx| UserStore::new(client.clone(), http, cx)); + let user_store = cx.build_model(|cx| UserStore::new(client.clone(), cx)); let workspace_store = cx.build_model(|cx| WorkspaceStore::new(client.clone(), cx)); let mut language_registry = LanguageRegistry::test(); language_registry.set_executor(cx.executor()); diff --git a/crates/collab_ui2/src/chat_panel.rs b/crates/collab_ui2/src/chat_panel.rs index c484016aae..6715ddf513 100644 --- a/crates/collab_ui2/src/chat_panel.rs +++ b/crates/collab_ui2/src/chat_panel.rs @@ -364,13 +364,7 @@ impl ChatPanel { if !is_continuation { result = result.child( h_stack() - .children( - message - .sender - .avatar - .clone() - .map(|avatar| Avatar::data(avatar)), - ) + .child(Avatar::new(message.sender.avatar_uri.clone())) .child(Label::new(message.sender.github_login.clone())) .child(Label::new(format_timestamp( message.timestamp, @@ -659,7 +653,7 @@ mod tests { timestamp: OffsetDateTime::now_utc(), sender: Arc::new(client::User { github_login: "fgh".into(), - avatar: None, + avatar_uri: "avatar_fgh".into(), id: 103, }), nonce: 5, diff --git a/crates/collab_ui2/src/chat_panel/message_editor.rs b/crates/collab_ui2/src/chat_panel/message_editor.rs index 61e621525f..0b9d0be0b4 100644 --- a/crates/collab_ui2/src/chat_panel/message_editor.rs +++ b/crates/collab_ui2/src/chat_panel/message_editor.rs @@ -234,7 +234,7 @@ mod tests { user: Arc::new(User { github_login: "a-b".into(), id: 101, - avatar: None, + avatar_uri: "avatar_a-b".into(), }), kind: proto::channel_member::Kind::Member, role: proto::ChannelRole::Member, @@ -243,7 +243,7 @@ mod tests { user: Arc::new(User { github_login: "C_D".into(), id: 102, - avatar: None, + avatar_uri: "avatar_C_D".into(), }), kind: proto::channel_member::Kind::Member, role: proto::ChannelRole::Member, @@ -275,7 +275,7 @@ mod tests { cx.update(|cx| { let http = FakeHttpClient::with_404_response(); let client = Client::new(http.clone(), cx); - let user_store = cx.build_model(|cx| UserStore::new(client.clone(), http, cx)); + let user_store = cx.build_model(|cx| UserStore::new(client.clone(), cx)); let settings = SettingsStore::test(cx); cx.set_global(settings); theme::init(theme::LoadThemes::JustBase, cx); diff --git a/crates/collab_ui2/src/collab_panel.rs b/crates/collab_ui2/src/collab_panel.rs index 09a8e859b4..ac7457abe0 100644 --- a/crates/collab_ui2/src/collab_panel.rs +++ b/crates/collab_ui2/src/collab_panel.rs @@ -19,6 +19,7 @@ mod contact_finder; use contact_finder::ContactFinder; use menu::{Cancel, Confirm, SelectNext, SelectPrev}; use rpc::proto::{self, PeerId}; +use smallvec::SmallVec; use theme::{ActiveTheme, ThemeSettings}; // use context_menu::{ContextMenu, ContextMenuItem}; // use db::kvp::KEY_VALUE_STORE; @@ -1155,7 +1156,7 @@ impl CollabPanel { let tooltip = format!("Follow {}", user.github_login); ListItem::new(SharedString::from(user.github_login.clone())) - .left_child(Avatar::data(user.avatar.clone().unwrap())) + .left_child(Avatar::new(user.avatar_uri.clone())) .child( h_stack() .w_full() @@ -2349,44 +2350,45 @@ impl CollabPanel { let busy = contact.busy || calling; let user_id = contact.user.id; let github_login = SharedString::from(contact.user.github_login.clone()); - let mut item = ListItem::new(github_login.clone()) - .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) - .child( - h_stack() - .w_full() - .justify_between() - .child(Label::new(github_login.clone())) - .when(calling, |el| { - el.child(Label::new("Calling").color(Color::Muted)) - }) - .when(!calling, |el| { - el.child( - div() - .id("remove_contact") - .invisible() - .group_hover("", |style| style.visible()) - .child( - IconButton::new("remove_contact", Icon::Close) - .icon_color(Color::Muted) - .tooltip(|cx| Tooltip::text("Remove Contact", cx)) - .on_click(cx.listener({ - let github_login = github_login.clone(); - move |this, _, cx| { - this.remove_contact(user_id, &github_login, cx); - } - })), - ), - ) - }), - ) - .left_child( - // todo!() handle contacts with no avatar - Avatar::data(contact.user.avatar.clone().unwrap()) - .availability_indicator(if online { Some(!busy) } else { None }), - ) - .when(online && !busy, |el| { - el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) - }); + let mut item = + ListItem::new(github_login.clone()) + .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) + .child( + h_stack() + .w_full() + .justify_between() + .child(Label::new(github_login.clone())) + .when(calling, |el| { + el.child(Label::new("Calling").color(Color::Muted)) + }) + .when(!calling, |el| { + el.child( + div() + .id("remove_contact") + .invisible() + .group_hover("", |style| style.visible()) + .child( + IconButton::new("remove_contact", Icon::Close) + .icon_color(Color::Muted) + .tooltip(|cx| Tooltip::text("Remove Contact", cx)) + .on_click(cx.listener({ + let github_login = github_login.clone(); + move |this, _, cx| { + this.remove_contact(user_id, &github_login, cx); + } + })), + ), + ) + }), + ) + .left_child( + // todo!() handle contacts with no avatar + Avatar::new(contact.user.avatar_uri.clone()) + .availability_indicator(if online { Some(!busy) } else { None }), + ) + .when(online && !busy, |el| { + el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) + }); div() .id(github_login.clone()) @@ -2458,7 +2460,7 @@ impl CollabPanel { .child(Label::new(github_login.clone())) .child(h_stack().children(controls)), ) - .when_some(user.avatar.clone(), |el, avatar| el.left_avatar(avatar)) + .left_avatar(user.avatar_uri.clone()) } fn render_contact_placeholder( @@ -2516,7 +2518,9 @@ impl CollabPanel { let result = FacePile { faces: participants .iter() - .filter_map(|user| Some(Avatar::data(user.avatar.clone()?).into_any_element())) + .filter_map(|user| { + Some(Avatar::new(user.avatar_uri.clone()).into_any_element()) + }) .take(FACEPILE_LIMIT) .chain(if extra_count > 0 { // todo!() @nate - this label looks wrong. @@ -2524,7 +2528,7 @@ impl CollabPanel { } else { None }) - .collect::>(), + .collect::>(), }; Some(result) diff --git a/crates/collab_ui2/src/collab_panel/contact_finder.rs b/crates/collab_ui2/src/collab_panel/contact_finder.rs index 742c25d148..bd0c5d4b07 100644 --- a/crates/collab_ui2/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui2/src/collab_panel/contact_finder.rs @@ -7,7 +7,7 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use std::sync::Arc; use theme::ActiveTheme as _; -use ui::prelude::*; +use ui::{prelude::*, Avatar}; use util::{ResultExt as _, TryFutureExt}; use workspace::ModalView; @@ -187,7 +187,7 @@ impl PickerDelegate for ContactFinderDelegate { div() .flex_1() .justify_between() - .children(user.avatar.clone().map(|avatar| img(avatar))) + .child(Avatar::new(user.avatar_uri.clone())) .child(Label::new(user.github_login.clone())) .children(icon_path.map(|icon_path| svg().path(icon_path))), ) diff --git a/crates/collab_ui2/src/collab_titlebar_item.rs b/crates/collab_ui2/src/collab_titlebar_item.rs index 4940893a6c..2b931f7085 100644 --- a/crates/collab_ui2/src/collab_titlebar_item.rs +++ b/crates/collab_ui2/src/collab_titlebar_item.rs @@ -232,43 +232,41 @@ impl Render for CollabTitlebarItem { }) .child(h_stack().px_1p5().map(|this| { if let Some(user) = current_user { - this.when_some(user.avatar.clone(), |this, avatar| { - // TODO: Finish implementing user menu popover - // - this.child( - popover_menu("user-menu") - .menu(|cx| { - ContextMenu::build(cx, |menu, _| menu.header("ADADA")) - }) - .trigger( - ButtonLike::new("user-menu") - .child( - h_stack() - .gap_0p5() - .child(Avatar::data(avatar)) - .child( - IconElement::new(Icon::ChevronDown) - .color(Color::Muted), - ), - ) - .style(ButtonStyle::Subtle) - .tooltip(move |cx| { - Tooltip::text("Toggle User Menu", cx) - }), - ) - .anchor(gpui::AnchorCorner::TopRight), - ) - // this.child( - // ButtonLike::new("user-menu") - // .child( - // h_stack().gap_0p5().child(Avatar::data(avatar)).child( - // IconElement::new(Icon::ChevronDown).color(Color::Muted), - // ), - // ) - // .style(ButtonStyle::Subtle) - // .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), - // ) - }) + // TODO: Finish implementing user menu popover + // + this.child( + popover_menu("user-menu") + .menu(|cx| { + ContextMenu::build(cx, |menu, _| menu.header("ADADA")) + }) + .trigger( + ButtonLike::new("user-menu") + .child( + h_stack() + .gap_0p5() + .child(Avatar::new(user.avatar_uri.clone())) + .child( + IconElement::new(Icon::ChevronDown) + .color(Color::Muted), + ), + ) + .style(ButtonStyle::Subtle) + .tooltip(move |cx| { + Tooltip::text("Toggle User Menu", cx) + }), + ) + .anchor(gpui::AnchorCorner::TopRight), + ) + // this.child( + // ButtonLike::new("user-menu") + // .child( + // h_stack().gap_0p5().child(Avatar::data(avatar)).child( + // IconElement::new(Icon::ChevronDown).color(Color::Muted), + // ), + // ) + // .style(ButtonStyle::Subtle) + // .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), + // ) } else { this.child(Button::new("sign_in", "Sign in").on_click(move |_, cx| { let client = client.clone(); @@ -424,27 +422,21 @@ impl CollabTitlebarItem { current_user: &Arc, ) -> Option { let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id)); - let mut pile = FacePile::default(); - pile.extend( - user.avatar - .clone() - .map(|avatar| { - div() - .child( - Avatar::data(avatar.clone()) - .grayscale(!is_present) - .border_color(if is_speaking { - gpui::blue() - } else if is_muted { - gpui::red() - } else { - Hsla::default() - }), - ) - .into_any_element() - }) - .into_iter() - .chain(followers.iter().filter_map(|follower_peer_id| { + + let pile = FacePile::default().child( + div() + .child( + Avatar::new(user.avatar_uri.clone()) + .grayscale(!is_present) + .border_color(if is_speaking { + gpui::blue() + } else if is_muted { + gpui::red() + } else { + Hsla::default() + }), + ) + .children(followers.iter().filter_map(|follower_peer_id| { let follower = room .remote_participants() .values() @@ -454,12 +446,11 @@ impl CollabTitlebarItem { .then_some(current_user) })? .clone(); - follower - .avatar - .clone() - .map(|avatar| div().child(Avatar::data(avatar.clone())).into_any_element()) + + Some(div().child(Avatar::new(follower.avatar_uri.clone()))) })), ); + Some(pile) } diff --git a/crates/collab_ui2/src/collab_ui.rs b/crates/collab_ui2/src/collab_ui.rs index b23971b3f2..df81af3e57 100644 --- a/crates/collab_ui2/src/collab_ui.rs +++ b/crates/collab_ui2/src/collab_ui.rs @@ -39,6 +39,7 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { collab_panel::init(cx); channel_view::init(cx); chat_panel::init(cx); + notification_panel::init(cx); notifications::init(&app_state, cx); // cx.add_global_action(toggle_screen_sharing); diff --git a/crates/collab_ui2/src/face_pile.rs b/crates/collab_ui2/src/face_pile.rs index 52b30062ea..d181509c46 100644 --- a/crates/collab_ui2/src/face_pile.rs +++ b/crates/collab_ui2/src/face_pile.rs @@ -1,11 +1,11 @@ use gpui::{ - div, AnyElement, Div, ElementId, IntoElement, ParentElement as _, RenderOnce, Styled, - WindowContext, + div, AnyElement, Div, ElementId, IntoElement, ParentElement, RenderOnce, Styled, WindowContext, }; +use smallvec::SmallVec; #[derive(Default, IntoElement)] pub struct FacePile { - pub faces: Vec, + pub faces: SmallVec<[AnyElement; 2]>, } impl RenderOnce for FacePile { @@ -25,8 +25,8 @@ impl RenderOnce for FacePile { } } -impl Extend for FacePile { - fn extend>(&mut self, children: T) { - self.faces.extend(children); +impl ParentElement for FacePile { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { + &mut self.faces } } diff --git a/crates/collab_ui2/src/notification_panel.rs b/crates/collab_ui2/src/notification_panel.rs index 4b5a99a0ed..35288e810d 100644 --- a/crates/collab_ui2/src/notification_panel.rs +++ b/crates/collab_ui2/src/notification_panel.rs @@ -1,884 +1,716 @@ -// use crate::{chat_panel::ChatPanel, render_avatar, NotificationPanelSettings}; -// use anyhow::Result; -// use channel::ChannelStore; -// use client::{Client, Notification, User, UserStore}; -// use collections::HashMap; -// use db::kvp::KEY_VALUE_STORE; -// use futures::StreamExt; -// use gpui::{ -// actions, -// elements::*, -// platform::{CursorStyle, MouseButton}, -// serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View, -// ViewContext, ViewHandle, WeakViewHandle, WindowContext, -// }; -// use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; -// use project::Fs; -// use rpc::proto; -// use serde::{Deserialize, Serialize}; -// use settings::SettingsStore; -// use std::{sync::Arc, time::Duration}; -// use theme::{ui, Theme}; -// use time::{OffsetDateTime, UtcOffset}; -// use util::{ResultExt, TryFutureExt}; -// use workspace::{ -// dock::{DockPosition, Panel}, -// Workspace, -// }; - -// const LOADING_THRESHOLD: usize = 30; -// const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1); -// const TOAST_DURATION: Duration = Duration::from_secs(5); -// const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel"; - -// pub struct NotificationPanel { -// client: Arc, -// user_store: ModelHandle, -// channel_store: ModelHandle, -// notification_store: ModelHandle, -// fs: Arc, -// width: Option, -// active: bool, -// notification_list: ListState, -// pending_serialization: Task>, -// subscriptions: Vec, -// workspace: WeakViewHandle, -// current_notification_toast: Option<(u64, Task<()>)>, -// local_timezone: UtcOffset, -// has_focus: bool, -// mark_as_read_tasks: HashMap>>, -// } - -// #[derive(Serialize, Deserialize)] -// struct SerializedNotificationPanel { -// width: Option, -// } - -// #[derive(Debug)] -// pub enum Event { -// DockPositionChanged, -// Focus, -// Dismissed, -// } - -// pub struct NotificationPresenter { -// pub actor: Option>, -// pub text: String, -// pub icon: &'static str, -// pub needs_response: bool, -// pub can_navigate: bool, -// } - -// actions!(notification_panel, [ToggleFocus]); - -// pub fn init(_cx: &mut AppContext) {} - -// impl NotificationPanel { -// pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { -// let fs = workspace.app_state().fs.clone(); -// let client = workspace.app_state().client.clone(); -// let user_store = workspace.app_state().user_store.clone(); -// let workspace_handle = workspace.weak_handle(); - -// cx.add_view(|cx| { -// let mut status = client.status(); -// cx.spawn(|this, mut cx| async move { -// while let Some(_) = status.next().await { -// if this -// .update(&mut cx, |_, cx| { -// cx.notify(); -// }) -// .is_err() -// { -// break; -// } -// } -// }) -// .detach(); - -// let mut notification_list = -// ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { -// this.render_notification(ix, cx) -// .unwrap_or_else(|| Empty::new().into_any()) -// }); -// notification_list.set_scroll_handler(|visible_range, count, this, cx| { -// if count.saturating_sub(visible_range.end) < LOADING_THRESHOLD { -// if let Some(task) = this -// .notification_store -// .update(cx, |store, cx| store.load_more_notifications(false, cx)) -// { -// task.detach(); -// } -// } -// }); - -// let mut this = Self { -// fs, -// client, -// user_store, -// local_timezone: cx.platform().local_timezone(), -// channel_store: ChannelStore::global(cx), -// notification_store: NotificationStore::global(cx), -// notification_list, -// pending_serialization: Task::ready(None), -// workspace: workspace_handle, -// has_focus: false, -// current_notification_toast: None, -// subscriptions: Vec::new(), -// active: false, -// mark_as_read_tasks: HashMap::default(), -// width: None, -// }; - -// let mut old_dock_position = this.position(cx); -// this.subscriptions.extend([ -// cx.observe(&this.notification_store, |_, _, cx| cx.notify()), -// cx.subscribe(&this.notification_store, Self::on_notification_event), -// cx.observe_global::(move |this: &mut Self, cx| { -// let new_dock_position = this.position(cx); -// if new_dock_position != old_dock_position { -// old_dock_position = new_dock_position; -// cx.emit(Event::DockPositionChanged); -// } -// cx.notify(); -// }), -// ]); -// this -// }) -// } - -// pub fn load( -// workspace: WeakViewHandle, -// cx: AsyncAppContext, -// ) -> Task>> { -// cx.spawn(|mut cx| async move { -// let serialized_panel = if let Some(panel) = cx -// .background() -// .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) }) -// .await -// .log_err() -// .flatten() -// { -// Some(serde_json::from_str::(&panel)?) -// } else { -// None -// }; - -// workspace.update(&mut cx, |workspace, cx| { -// let panel = Self::new(workspace, cx); -// if let Some(serialized_panel) = serialized_panel { -// panel.update(cx, |panel, cx| { -// panel.width = serialized_panel.width; -// cx.notify(); -// }); -// } -// panel -// }) -// }) -// } - -// fn serialize(&mut self, cx: &mut ViewContext) { -// let width = self.width; -// self.pending_serialization = cx.background().spawn( -// async move { -// KEY_VALUE_STORE -// .write_kvp( -// NOTIFICATION_PANEL_KEY.into(), -// serde_json::to_string(&SerializedNotificationPanel { width })?, -// ) -// .await?; -// anyhow::Ok(()) -// } -// .log_err(), -// ); -// } - -// fn render_notification( -// &mut self, -// ix: usize, -// cx: &mut ViewContext, -// ) -> Option> { -// let entry = self.notification_store.read(cx).notification_at(ix)?; -// let notification_id = entry.id; -// let now = OffsetDateTime::now_utc(); -// let timestamp = entry.timestamp; -// let NotificationPresenter { -// actor, -// text, -// needs_response, -// can_navigate, -// .. -// } = self.present_notification(entry, cx)?; - -// let theme = theme::current(cx); -// let style = &theme.notification_panel; -// let response = entry.response; -// let notification = entry.notification.clone(); - -// let message_style = if entry.is_read { -// style.read_text.clone() -// } else { -// style.unread_text.clone() -// }; - -// if self.active && !entry.is_read { -// self.did_render_notification(notification_id, ¬ification, cx); -// } - -// enum Decline {} -// enum Accept {} - -// Some( -// MouseEventHandler::new::(ix, cx, |_, cx| { -// let container = message_style.container; - -// Flex::row() -// .with_children(actor.map(|actor| { -// render_avatar(actor.avatar.clone(), &style.avatar, style.avatar_container) -// })) -// .with_child( -// Flex::column() -// .with_child(Text::new(text, message_style.text.clone())) -// .with_child( -// Flex::row() -// .with_child( -// Label::new( -// format_timestamp(timestamp, now, self.local_timezone), -// style.timestamp.text.clone(), -// ) -// .contained() -// .with_style(style.timestamp.container), -// ) -// .with_children(if let Some(is_accepted) = response { -// Some( -// Label::new( -// if is_accepted { -// "You accepted" -// } else { -// "You declined" -// }, -// style.read_text.text.clone(), -// ) -// .flex_float() -// .into_any(), -// ) -// } else if needs_response { -// Some( -// Flex::row() -// .with_children([ -// MouseEventHandler::new::( -// ix, -// cx, -// |state, _| { -// let button = -// style.button.style_for(state); -// Label::new( -// "Decline", -// button.text.clone(), -// ) -// .contained() -// .with_style(button.container) -// }, -// ) -// .with_cursor_style(CursorStyle::PointingHand) -// .on_click(MouseButton::Left, { -// let notification = notification.clone(); -// move |_, view, cx| { -// view.respond_to_notification( -// notification.clone(), -// false, -// cx, -// ); -// } -// }), -// MouseEventHandler::new::( -// ix, -// cx, -// |state, _| { -// let button = -// style.button.style_for(state); -// Label::new( -// "Accept", -// button.text.clone(), -// ) -// .contained() -// .with_style(button.container) -// }, -// ) -// .with_cursor_style(CursorStyle::PointingHand) -// .on_click(MouseButton::Left, { -// let notification = notification.clone(); -// move |_, view, cx| { -// view.respond_to_notification( -// notification.clone(), -// true, -// cx, -// ); -// } -// }), -// ]) -// .flex_float() -// .into_any(), -// ) -// } else { -// None -// }), -// ) -// .flex(1.0, true), -// ) -// .contained() -// .with_style(container) -// .into_any() -// }) -// .with_cursor_style(if can_navigate { -// CursorStyle::PointingHand -// } else { -// CursorStyle::default() -// }) -// .on_click(MouseButton::Left, { -// let notification = notification.clone(); -// move |_, this, cx| this.did_click_notification(¬ification, cx) -// }) -// .into_any(), -// ) -// } - -// fn present_notification( -// &self, -// entry: &NotificationEntry, -// cx: &AppContext, -// ) -> Option { -// let user_store = self.user_store.read(cx); -// let channel_store = self.channel_store.read(cx); -// match entry.notification { -// Notification::ContactRequest { sender_id } => { -// let requester = user_store.get_cached_user(sender_id)?; -// Some(NotificationPresenter { -// icon: "icons/plus.svg", -// text: format!("{} wants to add you as a contact", requester.github_login), -// needs_response: user_store.has_incoming_contact_request(requester.id), -// actor: Some(requester), -// can_navigate: false, -// }) -// } -// Notification::ContactRequestAccepted { responder_id } => { -// let responder = user_store.get_cached_user(responder_id)?; -// Some(NotificationPresenter { -// icon: "icons/plus.svg", -// text: format!("{} accepted your contact invite", responder.github_login), -// needs_response: false, -// actor: Some(responder), -// can_navigate: false, -// }) -// } -// Notification::ChannelInvitation { -// ref channel_name, -// channel_id, -// inviter_id, -// } => { -// let inviter = user_store.get_cached_user(inviter_id)?; -// Some(NotificationPresenter { -// icon: "icons/hash.svg", -// text: format!( -// "{} invited you to join the #{channel_name} channel", -// inviter.github_login -// ), -// needs_response: channel_store.has_channel_invitation(channel_id), -// actor: Some(inviter), -// can_navigate: false, -// }) -// } -// Notification::ChannelMessageMention { -// sender_id, -// channel_id, -// message_id, -// } => { -// let sender = user_store.get_cached_user(sender_id)?; -// let channel = channel_store.channel_for_id(channel_id)?; -// let message = self -// .notification_store -// .read(cx) -// .channel_message_for_id(message_id)?; -// Some(NotificationPresenter { -// icon: "icons/conversations.svg", -// text: format!( -// "{} mentioned you in #{}:\n{}", -// sender.github_login, channel.name, message.body, -// ), -// needs_response: false, -// actor: Some(sender), -// can_navigate: true, -// }) -// } -// } -// } - -// fn did_render_notification( -// &mut self, -// notification_id: u64, -// notification: &Notification, -// cx: &mut ViewContext, -// ) { -// let should_mark_as_read = match notification { -// Notification::ContactRequestAccepted { .. } => true, -// Notification::ContactRequest { .. } -// | Notification::ChannelInvitation { .. } -// | Notification::ChannelMessageMention { .. } => false, -// }; - -// if should_mark_as_read { -// self.mark_as_read_tasks -// .entry(notification_id) -// .or_insert_with(|| { -// let client = self.client.clone(); -// cx.spawn(|this, mut cx| async move { -// cx.background().timer(MARK_AS_READ_DELAY).await; -// client -// .request(proto::MarkNotificationRead { notification_id }) -// .await?; -// this.update(&mut cx, |this, _| { -// this.mark_as_read_tasks.remove(¬ification_id); -// })?; -// Ok(()) -// }) -// }); -// } -// } - -// fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext) { -// if let Notification::ChannelMessageMention { -// message_id, -// channel_id, -// .. -// } = notification.clone() -// { -// if let Some(workspace) = self.workspace.upgrade(cx) { -// cx.app_context().defer(move |cx| { -// workspace.update(cx, |workspace, cx| { -// if let Some(panel) = workspace.focus_panel::(cx) { -// panel.update(cx, |panel, cx| { -// panel -// .select_channel(channel_id, Some(message_id), cx) -// .detach_and_log_err(cx); -// }); -// } -// }); -// }); -// } -// } -// } - -// fn is_showing_notification(&self, notification: &Notification, cx: &AppContext) -> bool { -// if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification { -// if let Some(workspace) = self.workspace.upgrade(cx) { -// return workspace -// .read_with(cx, |workspace, cx| { -// if let Some(panel) = workspace.panel::(cx) { -// return panel.read_with(cx, |panel, cx| { -// panel.is_scrolled_to_bottom() -// && panel.active_chat().map_or(false, |chat| { -// chat.read(cx).channel_id == *channel_id -// }) -// }); -// } -// false -// }) -// .unwrap_or_default(); -// } -// } - -// false -// } - -// fn render_sign_in_prompt( -// &self, -// theme: &Arc, -// cx: &mut ViewContext, -// ) -> AnyElement { -// enum SignInPromptLabel {} - -// MouseEventHandler::new::(0, cx, |mouse_state, _| { -// Label::new( -// "Sign in to view your notifications".to_string(), -// theme -// .chat_panel -// .sign_in_prompt -// .style_for(mouse_state) -// .clone(), -// ) -// }) -// .with_cursor_style(CursorStyle::PointingHand) -// .on_click(MouseButton::Left, move |_, this, cx| { -// let client = this.client.clone(); -// cx.spawn(|_, cx| async move { -// client.authenticate_and_connect(true, &cx).log_err().await; -// }) -// .detach(); -// }) -// .aligned() -// .into_any() -// } - -// fn render_empty_state( -// &self, -// theme: &Arc, -// _cx: &mut ViewContext, -// ) -> AnyElement { -// Label::new( -// "You have no notifications".to_string(), -// theme.chat_panel.sign_in_prompt.default.clone(), -// ) -// .aligned() -// .into_any() -// } - -// fn on_notification_event( -// &mut self, -// _: ModelHandle, -// event: &NotificationEvent, -// cx: &mut ViewContext, -// ) { -// match event { -// NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), -// NotificationEvent::NotificationRemoved { entry } -// | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx), -// NotificationEvent::NotificationsUpdated { -// old_range, -// new_count, -// } => { -// self.notification_list.splice(old_range.clone(), *new_count); -// cx.notify(); -// } -// } -// } - -// fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { -// if self.is_showing_notification(&entry.notification, cx) { -// return; -// } - -// let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx) -// else { -// return; -// }; - -// let notification_id = entry.id; -// self.current_notification_toast = Some(( -// notification_id, -// cx.spawn(|this, mut cx| async move { -// cx.background().timer(TOAST_DURATION).await; -// this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx)) -// .ok(); -// }), -// )); - -// self.workspace -// .update(cx, |workspace, cx| { -// workspace.dismiss_notification::(0, cx); -// workspace.show_notification(0, cx, |cx| { -// let workspace = cx.weak_handle(); -// cx.add_view(|_| NotificationToast { -// notification_id, -// actor, -// text, -// workspace, -// }) -// }) -// }) -// .ok(); -// } - -// fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext) { -// if let Some((current_id, _)) = &self.current_notification_toast { -// if *current_id == notification_id { -// self.current_notification_toast.take(); -// self.workspace -// .update(cx, |workspace, cx| { -// workspace.dismiss_notification::(0, cx) -// }) -// .ok(); -// } -// } -// } - -// fn respond_to_notification( -// &mut self, -// notification: Notification, -// response: bool, -// cx: &mut ViewContext, -// ) { -// self.notification_store.update(cx, |store, cx| { -// store.respond_to_notification(notification, response, cx); -// }); -// } -// } - -// impl Entity for NotificationPanel { -// type Event = Event; -// } - -// impl View for NotificationPanel { -// fn ui_name() -> &'static str { -// "NotificationPanel" -// } - -// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { -// let theme = theme::current(cx); -// let style = &theme.notification_panel; -// let element = if self.client.user_id().is_none() { -// self.render_sign_in_prompt(&theme, cx) -// } else if self.notification_list.item_count() == 0 { -// self.render_empty_state(&theme, cx) -// } else { -// Flex::column() -// .with_child( -// Flex::row() -// .with_child(Label::new("Notifications", style.title.text.clone())) -// .with_child(ui::svg(&style.title_icon).flex_float()) -// .align_children_center() -// .contained() -// .with_style(style.title.container) -// .constrained() -// .with_height(style.title_height), -// ) -// .with_child( -// List::new(self.notification_list.clone()) -// .contained() -// .with_style(style.list) -// .flex(1., true), -// ) -// .into_any() -// }; -// element -// .contained() -// .with_style(style.container) -// .constrained() -// .with_min_width(150.) -// .into_any() -// } - -// fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext) { -// self.has_focus = true; -// } - -// fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { -// self.has_focus = false; -// } -// } - -// impl Panel for NotificationPanel { -// fn position(&self, cx: &gpui::WindowContext) -> DockPosition { -// settings::get::(cx).dock -// } - -// fn position_is_valid(&self, position: DockPosition) -> bool { -// matches!(position, DockPosition::Left | DockPosition::Right) -// } - -// fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { -// settings::update_settings_file::( -// self.fs.clone(), -// cx, -// move |settings| settings.dock = Some(position), -// ); -// } - -// fn size(&self, cx: &gpui::WindowContext) -> f32 { -// self.width -// .unwrap_or_else(|| settings::get::(cx).default_width) -// } - -// fn set_size(&mut self, size: Option, cx: &mut ViewContext) { -// self.width = size; -// self.serialize(cx); -// cx.notify(); -// } - -// fn set_active(&mut self, active: bool, cx: &mut ViewContext) { -// self.active = active; -// if self.notification_store.read(cx).notification_count() == 0 { -// cx.emit(Event::Dismissed); -// } -// } - -// fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { -// (settings::get::(cx).button -// && self.notification_store.read(cx).notification_count() > 0) -// .then(|| "icons/bell.svg") -// } - -// fn icon_tooltip(&self) -> (String, Option>) { -// ( -// "Notification Panel".to_string(), -// Some(Box::new(ToggleFocus)), -// ) -// } - -// fn icon_label(&self, cx: &WindowContext) -> Option { -// let count = self.notification_store.read(cx).unread_notification_count(); -// if count == 0 { -// None -// } else { -// Some(count.to_string()) -// } -// } - -// fn should_change_position_on_event(event: &Self::Event) -> bool { -// matches!(event, Event::DockPositionChanged) -// } - -// fn should_close_on_event(event: &Self::Event) -> bool { -// matches!(event, Event::Dismissed) -// } - -// fn has_focus(&self, _cx: &gpui::WindowContext) -> bool { -// self.has_focus -// } - -// fn is_focus_event(event: &Self::Event) -> bool { -// matches!(event, Event::Focus) -// } -// } - -// pub struct NotificationToast { -// notification_id: u64, -// actor: Option>, -// text: String, -// workspace: WeakViewHandle, -// } - -// pub enum ToastEvent { -// Dismiss, -// } - -// impl NotificationToast { -// fn focus_notification_panel(&self, cx: &mut AppContext) { -// let workspace = self.workspace.clone(); -// let notification_id = self.notification_id; -// cx.defer(move |cx| { -// workspace -// .update(cx, |workspace, cx| { -// if let Some(panel) = workspace.focus_panel::(cx) { -// panel.update(cx, |panel, cx| { -// let store = panel.notification_store.read(cx); -// if let Some(entry) = store.notification_for_id(notification_id) { -// panel.did_click_notification(&entry.clone().notification, cx); -// } -// }); -// } -// }) -// .ok(); -// }) -// } -// } - -// impl Entity for NotificationToast { -// type Event = ToastEvent; -// } - -// impl View for NotificationToast { -// fn ui_name() -> &'static str { -// "ContactNotification" -// } - -// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { -// let user = self.actor.clone(); -// let theme = theme::current(cx).clone(); -// let theme = &theme.contact_notification; - -// MouseEventHandler::new::(0, cx, |_, cx| { -// Flex::row() -// .with_children(user.and_then(|user| { -// Some( -// Image::from_data(user.avatar.clone()?) -// .with_style(theme.header_avatar) -// .aligned() -// .constrained() -// .with_height( -// cx.font_cache() -// .line_height(theme.header_message.text.font_size), -// ) -// .aligned() -// .top(), -// ) -// })) -// .with_child( -// Text::new(self.text.clone(), theme.header_message.text.clone()) -// .contained() -// .with_style(theme.header_message.container) -// .aligned() -// .top() -// .left() -// .flex(1., true), -// ) -// .with_child( -// MouseEventHandler::new::(0, cx, |state, _| { -// let style = theme.dismiss_button.style_for(state); -// Svg::new("icons/x.svg") -// .with_color(style.color) -// .constrained() -// .with_width(style.icon_width) -// .aligned() -// .contained() -// .with_style(style.container) -// .constrained() -// .with_width(style.button_width) -// .with_height(style.button_width) -// }) -// .with_cursor_style(CursorStyle::PointingHand) -// .with_padding(Padding::uniform(5.)) -// .on_click(MouseButton::Left, move |_, _, cx| { -// cx.emit(ToastEvent::Dismiss) -// }) -// .aligned() -// .constrained() -// .with_height( -// cx.font_cache() -// .line_height(theme.header_message.text.font_size), -// ) -// .aligned() -// .top() -// .flex_float(), -// ) -// .contained() -// }) -// .with_cursor_style(CursorStyle::PointingHand) -// .on_click(MouseButton::Left, move |_, this, cx| { -// this.focus_notification_panel(cx); -// cx.emit(ToastEvent::Dismiss); -// }) -// .into_any() -// } -// } - -// impl workspace::notifications::Notification for NotificationToast { -// fn should_dismiss_notification_on_event(&self, event: &::Event) -> bool { -// matches!(event, ToastEvent::Dismiss) -// } -// } - -// 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) { -// format!("yesterday") -// } else { -// format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) -// } -// } +use crate::{chat_panel::ChatPanel, NotificationPanelSettings}; +use anyhow::Result; +use channel::ChannelStore; +use client::{Client, Notification, User, UserStore}; +use collections::HashMap; +use db::kvp::KEY_VALUE_STORE; +use futures::StreamExt; +use gpui::{ + actions, div, list, px, serde_json, AnyElement, AppContext, AsyncWindowContext, CursorStyle, + DismissEvent, Div, Element, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + IntoElement, ListAlignment, ListScrollEvent, ListState, Model, ParentElement, Render, Stateful, + StatefulInteractiveElement, Styled, Task, View, ViewContext, VisualContext, WeakView, + WindowContext, +}; +use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; +use project::Fs; +use rpc::proto; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsStore}; +use std::{sync::Arc, time::Duration}; +use time::{OffsetDateTime, UtcOffset}; +use ui::{h_stack, v_stack, Avatar, Button, Clickable, Icon, IconButton, IconElement, Label}; +use util::{ResultExt, TryFutureExt}; +use workspace::{ + dock::{DockPosition, Panel, PanelEvent}, + Workspace, +}; + +const LOADING_THRESHOLD: usize = 30; +const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1); +const TOAST_DURATION: Duration = Duration::from_secs(5); +const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel"; + +pub struct NotificationPanel { + client: Arc, + user_store: Model, + channel_store: Model, + notification_store: Model, + fs: Arc, + width: Option, + active: bool, + notification_list: ListState, + pending_serialization: Task>, + subscriptions: Vec, + workspace: WeakView, + current_notification_toast: Option<(u64, Task<()>)>, + local_timezone: UtcOffset, + focus_handle: FocusHandle, + mark_as_read_tasks: HashMap>>, +} + +#[derive(Serialize, Deserialize)] +struct SerializedNotificationPanel { + width: Option, +} + +#[derive(Debug)] +pub enum Event { + DockPositionChanged, + Focus, + Dismissed, +} + +pub struct NotificationPresenter { + pub actor: Option>, + pub text: String, + pub icon: &'static str, + pub needs_response: bool, + pub can_navigate: bool, +} + +actions!(notification_panel, [ToggleFocus]); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(|workspace: &mut Workspace, _| { + workspace.register_action(|workspace, _: &ToggleFocus, cx| { + workspace.toggle_panel_focus::(cx); + }); + }) + .detach(); +} + +impl NotificationPanel { + pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> View { + let fs = workspace.app_state().fs.clone(); + let client = workspace.app_state().client.clone(); + let user_store = workspace.app_state().user_store.clone(); + let workspace_handle = workspace.weak_handle(); + + cx.build_view(|cx: &mut ViewContext| { + let view = cx.view().clone(); + + let mut status = client.status(); + cx.spawn(|this, mut cx| async move { + while let Some(_) = status.next().await { + if this + .update(&mut cx, |_, cx| { + cx.notify(); + }) + .is_err() + { + break; + } + } + }) + .detach(); + + let notification_list = + ListState::new(0, ListAlignment::Top, px(1000.), move |ix, cx| { + view.update(cx, |this, cx| { + this.render_notification(ix, cx) + .unwrap_or_else(|| div().into_any()) + }) + }); + notification_list.set_scroll_handler(cx.listener( + |this, event: &ListScrollEvent, cx| { + if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD { + if let Some(task) = this + .notification_store + .update(cx, |store, cx| store.load_more_notifications(false, cx)) + { + task.detach(); + } + } + }, + )); + + let mut this = Self { + fs, + client, + user_store, + local_timezone: cx.local_timezone(), + channel_store: ChannelStore::global(cx), + notification_store: NotificationStore::global(cx), + notification_list, + pending_serialization: Task::ready(None), + workspace: workspace_handle, + focus_handle: cx.focus_handle(), + current_notification_toast: None, + subscriptions: Vec::new(), + active: false, + mark_as_read_tasks: HashMap::default(), + width: None, + }; + + let mut old_dock_position = this.position(cx); + this.subscriptions.extend([ + cx.observe(&this.notification_store, |_, _, cx| cx.notify()), + cx.subscribe(&this.notification_store, Self::on_notification_event), + cx.observe_global::(move |this: &mut Self, cx| { + let new_dock_position = this.position(cx); + if new_dock_position != old_dock_position { + old_dock_position = new_dock_position; + cx.emit(Event::DockPositionChanged); + } + cx.notify(); + }), + ]); + this + }) + } + + pub fn load( + workspace: WeakView, + cx: AsyncWindowContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let serialized_panel = if let Some(panel) = cx + .background_executor() + .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) }) + .await + .log_err() + .flatten() + { + Some(serde_json::from_str::(&panel)?) + } else { + None + }; + + workspace.update(&mut cx, |workspace, cx| { + let panel = Self::new(workspace, cx); + if let Some(serialized_panel) = serialized_panel { + panel.update(cx, |panel, cx| { + panel.width = serialized_panel.width; + cx.notify(); + }); + } + panel + }) + }) + } + + fn serialize(&mut self, cx: &mut ViewContext) { + let width = self.width; + self.pending_serialization = cx.background_executor().spawn( + async move { + KEY_VALUE_STORE + .write_kvp( + NOTIFICATION_PANEL_KEY.into(), + serde_json::to_string(&SerializedNotificationPanel { width })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ); + } + + fn render_notification(&mut self, ix: usize, cx: &mut ViewContext) -> Option { + let entry = self.notification_store.read(cx).notification_at(ix)?; + let notification_id = entry.id; + let now = OffsetDateTime::now_utc(); + let timestamp = entry.timestamp; + let NotificationPresenter { + actor, + text, + needs_response, + can_navigate, + .. + } = self.present_notification(entry, cx)?; + + let response = entry.response; + let notification = entry.notification.clone(); + + if self.active && !entry.is_read { + self.did_render_notification(notification_id, ¬ification, cx); + } + + Some( + div() + .id(ix) + .child( + h_stack() + .children(actor.map(|actor| Avatar::new(actor.avatar_uri.clone()))) + .child( + v_stack().child(Label::new(text)).child( + h_stack() + .child(Label::new(format_timestamp( + timestamp, + now, + self.local_timezone, + ))) + .children(if let Some(is_accepted) = response { + Some(div().child(Label::new(if is_accepted { + "You accepted" + } else { + "You declined" + }))) + } else if needs_response { + Some( + h_stack() + .child(Button::new("decline", "Decline").on_click( + { + let notification = notification.clone(); + let view = cx.view().clone(); + move |_, cx| { + view.update(cx, |this, cx| { + this.respond_to_notification( + notification.clone(), + false, + cx, + ) + }); + } + }, + )) + .child(Button::new("accept", "Accept").on_click({ + let notification = notification.clone(); + let view = cx.view().clone(); + move |_, cx| { + view.update(cx, |this, cx| { + this.respond_to_notification( + notification.clone(), + true, + cx, + ) + }); + } + })), + ) + } else { + None + }), + ), + ), + ) + .when(can_navigate, |el| { + el.cursor(CursorStyle::PointingHand).on_click({ + let notification = notification.clone(); + cx.listener(move |this, _, cx| { + this.did_click_notification(¬ification, cx) + }) + }) + }) + .into_any(), + ) + } + + fn present_notification( + &self, + entry: &NotificationEntry, + cx: &AppContext, + ) -> Option { + let user_store = self.user_store.read(cx); + let channel_store = self.channel_store.read(cx); + match entry.notification { + Notification::ContactRequest { sender_id } => { + let requester = user_store.get_cached_user(sender_id)?; + Some(NotificationPresenter { + icon: "icons/plus.svg", + text: format!("{} wants to add you as a contact", requester.github_login), + needs_response: user_store.has_incoming_contact_request(requester.id), + actor: Some(requester), + can_navigate: false, + }) + } + Notification::ContactRequestAccepted { responder_id } => { + let responder = user_store.get_cached_user(responder_id)?; + Some(NotificationPresenter { + icon: "icons/plus.svg", + text: format!("{} accepted your contact invite", responder.github_login), + needs_response: false, + actor: Some(responder), + can_navigate: false, + }) + } + Notification::ChannelInvitation { + ref channel_name, + channel_id, + inviter_id, + } => { + let inviter = user_store.get_cached_user(inviter_id)?; + Some(NotificationPresenter { + icon: "icons/hash.svg", + text: format!( + "{} invited you to join the #{channel_name} channel", + inviter.github_login + ), + needs_response: channel_store.has_channel_invitation(channel_id), + actor: Some(inviter), + can_navigate: false, + }) + } + Notification::ChannelMessageMention { + sender_id, + channel_id, + message_id, + } => { + let sender = user_store.get_cached_user(sender_id)?; + let channel = channel_store.channel_for_id(channel_id)?; + let message = self + .notification_store + .read(cx) + .channel_message_for_id(message_id)?; + Some(NotificationPresenter { + icon: "icons/conversations.svg", + text: format!( + "{} mentioned you in #{}:\n{}", + sender.github_login, channel.name, message.body, + ), + needs_response: false, + actor: Some(sender), + can_navigate: true, + }) + } + } + } + + fn did_render_notification( + &mut self, + notification_id: u64, + notification: &Notification, + cx: &mut ViewContext, + ) { + let should_mark_as_read = match notification { + Notification::ContactRequestAccepted { .. } => true, + Notification::ContactRequest { .. } + | Notification::ChannelInvitation { .. } + | Notification::ChannelMessageMention { .. } => false, + }; + + if should_mark_as_read { + self.mark_as_read_tasks + .entry(notification_id) + .or_insert_with(|| { + let client = self.client.clone(); + cx.spawn(|this, mut cx| async move { + cx.background_executor().timer(MARK_AS_READ_DELAY).await; + client + .request(proto::MarkNotificationRead { notification_id }) + .await?; + this.update(&mut cx, |this, _| { + this.mark_as_read_tasks.remove(¬ification_id); + })?; + Ok(()) + }) + }); + } + } + + fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext) { + if let Notification::ChannelMessageMention { + message_id, + channel_id, + .. + } = notification.clone() + { + if let Some(workspace) = self.workspace.upgrade() { + cx.window_context().defer(move |cx| { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.focus_panel::(cx) { + panel.update(cx, |panel, cx| { + panel + .select_channel(channel_id, Some(message_id), cx) + .detach_and_log_err(cx); + }); + } + }); + }); + } + } + } + + fn is_showing_notification(&self, notification: &Notification, cx: &ViewContext) -> bool { + if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification { + if let Some(workspace) = self.workspace.upgrade() { + return if let Some(panel) = workspace.read(cx).panel::(cx) { + let panel = panel.read(cx); + panel.is_scrolled_to_bottom() + && panel + .active_chat() + .map_or(false, |chat| chat.read(cx).channel_id == *channel_id) + } else { + false + }; + } + } + + false + } + + fn render_sign_in_prompt(&self) -> AnyElement { + Button::new( + "sign_in_prompt_button", + "Sign in to view your notifications", + ) + .on_click({ + let client = self.client.clone(); + move |_, cx| { + let client = client.clone(); + cx.spawn(move |cx| async move { + client.authenticate_and_connect(true, &cx).log_err().await; + }) + .detach() + } + }) + .into_any_element() + } + + fn render_empty_state(&self) -> AnyElement { + Label::new("You have no notifications").into_any_element() + } + + fn on_notification_event( + &mut self, + _: Model, + event: &NotificationEvent, + cx: &mut ViewContext, + ) { + match event { + NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), + NotificationEvent::NotificationRemoved { entry } + | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx), + NotificationEvent::NotificationsUpdated { + old_range, + new_count, + } => { + self.notification_list.splice(old_range.clone(), *new_count); + cx.notify(); + } + } + } + + fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { + if self.is_showing_notification(&entry.notification, cx) { + return; + } + + let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx) + else { + return; + }; + + let notification_id = entry.id; + self.current_notification_toast = Some(( + notification_id, + cx.spawn(|this, mut cx| async move { + cx.background_executor().timer(TOAST_DURATION).await; + this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx)) + .ok(); + }), + )); + + self.workspace + .update(cx, |workspace, cx| { + workspace.dismiss_notification::(0, cx); + workspace.show_notification(0, cx, |cx| { + let workspace = cx.view().downgrade(); + cx.build_view(|_| NotificationToast { + notification_id, + actor, + text, + workspace, + }) + }) + }) + .ok(); + } + + fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext) { + if let Some((current_id, _)) = &self.current_notification_toast { + if *current_id == notification_id { + self.current_notification_toast.take(); + self.workspace + .update(cx, |workspace, cx| { + workspace.dismiss_notification::(0, cx) + }) + .ok(); + } + } + } + + fn respond_to_notification( + &mut self, + notification: Notification, + response: bool, + cx: &mut ViewContext, + ) { + self.notification_store.update(cx, |store, cx| { + store.respond_to_notification(notification, response, cx); + }); + } +} + +impl Render for NotificationPanel { + type Element = AnyElement; + + fn render(&mut self, _: &mut ViewContext) -> AnyElement { + if self.client.user_id().is_none() { + self.render_sign_in_prompt() + } else if self.notification_list.item_count() == 0 { + self.render_empty_state() + } else { + v_stack() + .bg(gpui::red()) + .child( + h_stack() + .child(Label::new("Notifications")) + .child(IconElement::new(Icon::Envelope)), + ) + .child(list(self.notification_list.clone()).size_full()) + .size_full() + .into_any_element() + } + } +} + +impl FocusableView for NotificationPanel { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for NotificationPanel {} +impl EventEmitter for NotificationPanel {} + +impl Panel for NotificationPanel { + fn persistent_name() -> &'static str { + "NotificationPanel" + } + + fn position(&self, cx: &gpui::WindowContext) -> DockPosition { + NotificationPanelSettings::get_global(cx).dock + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + matches!(position, DockPosition::Left | DockPosition::Right) + } + + fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { + settings::update_settings_file::( + self.fs.clone(), + cx, + move |settings| settings.dock = Some(position), + ); + } + + fn size(&self, cx: &gpui::WindowContext) -> f32 { + self.width + .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width) + } + + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + self.width = size; + self.serialize(cx); + cx.notify(); + } + + fn set_active(&mut self, active: bool, cx: &mut ViewContext) { + self.active = active; + if self.notification_store.read(cx).notification_count() == 0 { + cx.emit(Event::Dismissed); + } + } + + fn icon(&self, cx: &gpui::WindowContext) -> Option { + (NotificationPanelSettings::get_global(cx).button + && self.notification_store.read(cx).notification_count() > 0) + .then(|| Icon::Bell) + } + + fn icon_label(&self, cx: &WindowContext) -> Option { + let count = self.notification_store.read(cx).unread_notification_count(); + if count == 0 { + None + } else { + Some(count.to_string()) + } + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } +} + +pub struct NotificationToast { + notification_id: u64, + actor: Option>, + text: String, + workspace: WeakView, +} + +pub enum ToastEvent { + Dismiss, +} + +impl NotificationToast { + fn focus_notification_panel(&self, cx: &mut ViewContext) { + let workspace = self.workspace.clone(); + let notification_id = self.notification_id; + cx.window_context().defer(move |cx| { + workspace + .update(cx, |workspace, cx| { + if let Some(panel) = workspace.focus_panel::(cx) { + panel.update(cx, |panel, cx| { + let store = panel.notification_store.read(cx); + if let Some(entry) = store.notification_for_id(notification_id) { + panel.did_click_notification(&entry.clone().notification, cx); + } + }); + } + }) + .ok(); + }) + } +} + +impl Render for NotificationToast { + type Element = Stateful
; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let user = self.actor.clone(); + + h_stack() + .id("notification_panel_toast") + .children(user.map(|user| Avatar::new(user.avatar_uri.clone()))) + .child(Label::new(self.text.clone())) + .child( + IconButton::new("close", Icon::Close) + .on_click(cx.listener(|_, _, cx| cx.emit(ToastEvent::Dismiss))), + ) + .on_click(cx.listener(|this, _, cx| { + this.focus_notification_panel(cx); + cx.emit(ToastEvent::Dismiss); + })) + } +} + +impl EventEmitter 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) { + format!("yesterday") + } else { + format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) + } +} diff --git a/crates/collab_ui2/src/notifications/incoming_call_notification.rs b/crates/collab_ui2/src/notifications/incoming_call_notification.rs index b277e6fe94..53ff51d1cd 100644 --- a/crates/collab_ui2/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui2/src/notifications/incoming_call_notification.rs @@ -114,14 +114,7 @@ impl IncomingCallNotification { } fn render_caller(&self, cx: &mut ViewContext) -> impl Element { h_stack() - .children( - self.state - .call - .calling_user - .avatar - .as_ref() - .map(|avatar| Avatar::data(avatar.clone())), - ) + .child(Avatar::new(self.state.call.calling_user.avatar_uri.clone())) .child( v_stack() .child(Label::new(format!( diff --git a/crates/collab_ui2/src/notifications/project_shared_notification.rs b/crates/collab_ui2/src/notifications/project_shared_notification.rs index c7e667e284..e130f09b16 100644 --- a/crates/collab_ui2/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui2/src/notifications/project_shared_notification.rs @@ -119,12 +119,7 @@ impl ProjectSharedNotification { fn render_owner(&self) -> impl Element { h_stack() - .children( - self.owner - .avatar - .clone() - .map(|avatar| Avatar::data(avatar.clone())), - ) + .child(Avatar::new(self.owner.avatar_uri.clone())) .child( v_stack() .child(Label::new(self.owner.github_login.clone())) diff --git a/crates/project2/src/project2.rs b/crates/project2/src/project2.rs index 773b68c9e3..e598bad3a0 100644 --- a/crates/project2/src/project2.rs +++ b/crates/project2/src/project2.rs @@ -868,7 +868,7 @@ impl Project { languages.set_executor(cx.executor()); let http_client = util::http::FakeHttpClient::with_404_response(); let client = cx.update(|cx| client::Client::new(http_client.clone(), cx)); - let user_store = cx.build_model(|cx| UserStore::new(client.clone(), http_client, cx)); + let user_store = cx.build_model(|cx| UserStore::new(client.clone(), cx)); let project = cx.update(|cx| { Project::local( client, diff --git a/crates/ui2/src/components/avatar.rs b/crates/ui2/src/components/avatar.rs index 454daacc42..33c8d71625 100644 --- a/crates/ui2/src/components/avatar.rs +++ b/crates/ui2/src/components/avatar.rs @@ -1,6 +1,5 @@ use crate::prelude::*; -use gpui::{img, Div, Hsla, ImageData, ImageSource, Img, IntoElement, Styled}; -use std::sync::Arc; +use gpui::{img, Div, Hsla, ImageSource, Img, IntoElement, Styled}; #[derive(Debug, Default, PartialEq, Clone)] pub enum Shape { @@ -58,16 +57,8 @@ impl RenderOnce for Avatar { } impl Avatar { - pub fn uri(src: impl Into) -> Self { - Self::source(src.into().into()) - } - - pub fn data(src: Arc) -> Self { - Self::source(src.into()) - } - - pub fn source(src: ImageSource) -> Self { - Self { + pub fn new(src: impl Into) -> Self { + Avatar { image: img(src), is_available: None, border_color: None, diff --git a/crates/ui2/src/components/list/list_item.rs b/crates/ui2/src/components/list/list_item.rs index f2cdd2055c..5a337c316a 100644 --- a/crates/ui2/src/components/list/list_item.rs +++ b/crates/ui2/src/components/list/list_item.rs @@ -111,7 +111,7 @@ impl ListItem { } pub fn left_avatar(mut self, left_avatar: impl Into) -> Self { - self.left_slot = Some(Avatar::source(left_avatar.into()).into_any_element()); + self.left_slot = Some(Avatar::new(left_avatar).into_any_element()); self } } diff --git a/crates/ui2/src/components/stories/avatar.rs b/crates/ui2/src/components/stories/avatar.rs index 1b5ceec2fd..09adfe480a 100644 --- a/crates/ui2/src/components/stories/avatar.rs +++ b/crates/ui2/src/components/stories/avatar.rs @@ -13,18 +13,18 @@ impl Render for AvatarStory { Story::container() .child(Story::title_for::()) .child(Story::label("Default")) - .child(Avatar::uri( + .child(Avatar::new( "https://avatars.githubusercontent.com/u/1714999?v=4", )) - .child(Avatar::uri( + .child(Avatar::new( "https://avatars.githubusercontent.com/u/326587?v=4", )) .child( - Avatar::uri("https://avatars.githubusercontent.com/u/326587?v=4") + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") .availability_indicator(true), ) .child( - Avatar::uri("https://avatars.githubusercontent.com/u/326587?v=4") + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") .availability_indicator(false), ) } diff --git a/crates/workspace2/src/workspace2.rs b/crates/workspace2/src/workspace2.rs index 520eee0d2f..17ee4ca8fc 100644 --- a/crates/workspace2/src/workspace2.rs +++ b/crates/workspace2/src/workspace2.rs @@ -363,7 +363,7 @@ impl AppState { let languages = Arc::new(LanguageRegistry::test()); let http_client = util::http::FakeHttpClient::with_404_response(); let client = Client::new(http_client.clone(), cx); - let user_store = cx.build_model(|cx| UserStore::new(client.clone(), http_client, cx)); + let user_store = cx.build_model(|cx| UserStore::new(client.clone(), cx)); let workspace_store = cx.build_model(|cx| WorkspaceStore::new(client.clone(), cx)); theme::init(theme::LoadThemes::JustBase, cx); diff --git a/crates/zed2/Cargo.toml b/crates/zed2/Cargo.toml index c1862fb116..859afee4f7 100644 --- a/crates/zed2/Cargo.toml +++ b/crates/zed2/Cargo.toml @@ -49,6 +49,7 @@ lsp = { package = "lsp2", path = "../lsp2" } menu = { package = "menu2", path = "../menu2" } # language_tools = { path = "../language_tools" } node_runtime = { path = "../node_runtime" } +notifications = { package = "notifications2", path = "../notifications2" } assistant = { package = "assistant2", path = "../assistant2" } outline = { package = "outline2", path = "../outline2" } # plugin_runtime = { path = "../plugin_runtime",optional = true } diff --git a/crates/zed2/src/main.rs b/crates/zed2/src/main.rs index a3a9082b1a..bbb8382cb2 100644 --- a/crates/zed2/src/main.rs +++ b/crates/zed2/src/main.rs @@ -143,7 +143,7 @@ fn main() { language::init(cx); languages::init(languages.clone(), node_runtime.clone(), cx); - let user_store = cx.build_model(|cx| UserStore::new(client.clone(), http.clone(), cx)); + let user_store = cx.build_model(|cx| UserStore::new(client.clone(), cx)); let workspace_store = cx.build_model(|cx| WorkspaceStore::new(client.clone(), cx)); cx.set_global(client.clone()); @@ -220,6 +220,7 @@ fn main() { // activity_indicator::init(cx); // language_tools::init(cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); + notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); collab_ui::init(&app_state, cx); feedback::init(cx); welcome::init(cx); diff --git a/crates/zed2/src/zed2.rs b/crates/zed2/src/zed2.rs index 958ea4cf04..611cf64552 100644 --- a/crates/zed2/src/zed2.rs +++ b/crates/zed2/src/zed2.rs @@ -164,24 +164,24 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); let chat_panel = collab_ui::chat_panel::ChatPanel::load(workspace_handle.clone(), cx.clone()); - // let notification_panel = collab_ui::notification_panel::NotificationPanel::load( - // workspace_handle.clone(), - // cx.clone(), - // ); + let notification_panel = collab_ui::notification_panel::NotificationPanel::load( + workspace_handle.clone(), + cx.clone(), + ); let ( project_panel, terminal_panel, assistant_panel, channels_panel, chat_panel, - // notification_panel, + notification_panel, ) = futures::try_join!( project_panel, terminal_panel, assistant_panel, channels_panel, chat_panel, - // notification_panel, + notification_panel, )?; workspace_handle.update(&mut cx, |workspace, cx| { @@ -191,7 +191,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { workspace.add_panel(assistant_panel, cx); workspace.add_panel(channels_panel, cx); workspace.add_panel(chat_panel, cx); - // workspace.add_panel(notification_panel, cx); + workspace.add_panel(notification_panel, cx); // if !was_deserialized // && workspace