diff --git a/assets/icons/reject.svg b/assets/icons/decline.svg similarity index 100% rename from assets/icons/reject.svg rename to assets/icons/decline.svg diff --git a/assets/themes/cave-dark.json b/assets/themes/cave-dark.json index cb1208a1db764bbb540dc73540eb723bfded2d91..bdc611b830065dfee72f0e0104eb08373579ee80 100644 --- a/assets/themes/cave-dark.json +++ b/assets/themes/cave-dark.json @@ -341,6 +341,19 @@ "icon_color": "#efecf4", "background": "#5852605c" } + }, + "badge": { + "corner_radius": 3, + "padding": 2, + "margin": { + "bottom": -1, + "right": -1 + }, + "border": { + "width": 1, + "color": "#26232a" + }, + "background": "#576ddb" } } }, @@ -470,6 +483,33 @@ "color": "#efecf4", "size": 14, "background": "#000000aa" + }, + "notification": { + "margin": { + "top": 10 + }, + "background": "#26232a", + "corner_radius": 6, + "padding": 12, + "border": { + "color": "#19171c", + "width": 1 + }, + "shadow": { + "blur": 16, + "color": "#0000003d", + "offset": [ + 0, + 2 + ] + } + }, + "notifications": { + "width": 380, + "margin": { + "right": 10, + "bottom": 10 + } } }, "editor": { @@ -1646,5 +1686,56 @@ "padding": { "left": 6 } + }, + "contact_notification": { + "header_avatar": { + "height": 12, + "width": 12, + "corner_radius": 6 + }, + "header_message": { + "family": "Zed Sans", + "color": "#e2dfe7", + "size": 12, + "margin": { + "left": 8, + "right": 8 + } + }, + "header_height": 18, + "body_message": { + "family": "Zed Sans", + "color": "#8b8792", + "size": 12, + "margin": { + "left": 20, + "top": 6, + "bottom": 6 + } + }, + "button": { + "family": "Zed Sans", + "color": "#e2dfe7", + "size": 12, + "background": "#19171c", + "padding": 4, + "corner_radius": 6, + "margin": { + "left": 6 + }, + "hover": { + "background": "#26232a3d" + } + }, + "dismiss_button": { + "color": "#8b8792", + "icon_width": 8, + "icon_height": 8, + "button_width": 8, + "button_height": 8, + "hover": { + "color": "#e2dfe7" + } + } } } \ No newline at end of file diff --git a/assets/themes/cave-light.json b/assets/themes/cave-light.json index f0b3f5bd438a53178d9f56f88e8fbf1dbd41472d..b97b7f13fd9a45238c0bee8a9408c775ec6dfd1b 100644 --- a/assets/themes/cave-light.json +++ b/assets/themes/cave-light.json @@ -341,6 +341,19 @@ "icon_color": "#19171c", "background": "#8b87922e" } + }, + "badge": { + "corner_radius": 3, + "padding": 2, + "margin": { + "bottom": -1, + "right": -1 + }, + "border": { + "width": 1, + "color": "#e2dfe7" + }, + "background": "#576ddb" } } }, @@ -470,6 +483,33 @@ "color": "#19171c", "size": 14, "background": "#000000aa" + }, + "notification": { + "margin": { + "top": 10 + }, + "background": "#e2dfe7", + "corner_radius": 6, + "padding": 12, + "border": { + "color": "#efecf4", + "width": 1 + }, + "shadow": { + "blur": 16, + "color": "#0000001f", + "offset": [ + 0, + 2 + ] + } + }, + "notifications": { + "width": 380, + "margin": { + "right": 10, + "bottom": 10 + } } }, "editor": { @@ -1646,5 +1686,56 @@ "padding": { "left": 6 } + }, + "contact_notification": { + "header_avatar": { + "height": 12, + "width": 12, + "corner_radius": 6 + }, + "header_message": { + "family": "Zed Sans", + "color": "#26232a", + "size": 12, + "margin": { + "left": 8, + "right": 8 + } + }, + "header_height": 18, + "body_message": { + "family": "Zed Sans", + "color": "#585260", + "size": 12, + "margin": { + "left": 20, + "top": 6, + "bottom": 6 + } + }, + "button": { + "family": "Zed Sans", + "color": "#26232a", + "size": 12, + "background": "#efecf4", + "padding": 4, + "corner_radius": 6, + "margin": { + "left": 6 + }, + "hover": { + "background": "#e2dfe71f" + } + }, + "dismiss_button": { + "color": "#585260", + "icon_width": 8, + "icon_height": 8, + "button_width": 8, + "button_height": 8, + "hover": { + "color": "#26232a" + } + } } } \ No newline at end of file diff --git a/assets/themes/dark.json b/assets/themes/dark.json index 9cc3badc8104dbee88d2862ad7feeedba22383e8..37cb0a80bbde9b35a5e725c674380adad86a02d7 100644 --- a/assets/themes/dark.json +++ b/assets/themes/dark.json @@ -341,6 +341,19 @@ "icon_color": "#ffffff", "background": "#2b2b2b" } + }, + "badge": { + "corner_radius": 3, + "padding": 2, + "margin": { + "bottom": -1, + "right": -1 + }, + "border": { + "width": 1, + "color": "#1c1c1c" + }, + "background": "#2472f2" } } }, @@ -470,6 +483,33 @@ "color": "#ffffff", "size": 14, "background": "#000000aa" + }, + "notification": { + "margin": { + "top": 10 + }, + "background": "#1c1c1c", + "corner_radius": 6, + "padding": 12, + "border": { + "color": "#070707", + "width": 1 + }, + "shadow": { + "blur": 16, + "color": "#00000052", + "offset": [ + 0, + 2 + ] + } + }, + "notifications": { + "width": 380, + "margin": { + "right": 10, + "bottom": 10 + } } }, "editor": { @@ -1646,5 +1686,56 @@ "padding": { "left": 6 } + }, + "contact_notification": { + "header_avatar": { + "height": 12, + "width": 12, + "corner_radius": 6 + }, + "header_message": { + "family": "Zed Sans", + "color": "#f1f1f1", + "size": 12, + "margin": { + "left": 8, + "right": 8 + } + }, + "header_height": 18, + "body_message": { + "family": "Zed Sans", + "color": "#9c9c9c", + "size": 12, + "margin": { + "left": 20, + "top": 6, + "bottom": 6 + } + }, + "button": { + "family": "Zed Sans", + "color": "#f1f1f1", + "size": 12, + "background": "#0e0e0e80", + "padding": 4, + "corner_radius": 6, + "margin": { + "left": 6 + }, + "hover": { + "background": "#070707" + } + }, + "dismiss_button": { + "color": "#9c9c9c", + "icon_width": 8, + "icon_height": 8, + "button_width": 8, + "button_height": 8, + "hover": { + "color": "#c6c6c6" + } + } } } \ No newline at end of file diff --git a/assets/themes/light.json b/assets/themes/light.json index e2563fadad64d74d0b7add301cbfb2fc0969be6d..7491b039b91eb91b780f4a815a018d2eafde5549 100644 --- a/assets/themes/light.json +++ b/assets/themes/light.json @@ -341,6 +341,19 @@ "icon_color": "#000000", "background": "#e3e3e3" } + }, + "badge": { + "corner_radius": 3, + "padding": 2, + "margin": { + "bottom": -1, + "right": -1 + }, + "border": { + "width": 1, + "color": "#f8f8f8" + }, + "background": "#484bed" } } }, @@ -470,6 +483,33 @@ "color": "#000000", "size": 14, "background": "#000000aa" + }, + "notification": { + "margin": { + "top": 10 + }, + "background": "#f8f8f8", + "corner_radius": 6, + "padding": 12, + "border": { + "color": "#d5d5d5", + "width": 1 + }, + "shadow": { + "blur": 16, + "color": "#0000001f", + "offset": [ + 0, + 2 + ] + } + }, + "notifications": { + "width": 380, + "margin": { + "right": 10, + "bottom": 10 + } } }, "editor": { @@ -1646,5 +1686,56 @@ "padding": { "left": 6 } + }, + "contact_notification": { + "header_avatar": { + "height": 12, + "width": 12, + "corner_radius": 6 + }, + "header_message": { + "family": "Zed Sans", + "color": "#2b2b2b", + "size": 12, + "margin": { + "left": 8, + "right": 8 + } + }, + "header_height": 18, + "body_message": { + "family": "Zed Sans", + "color": "#474747", + "size": 12, + "margin": { + "left": 20, + "top": 6, + "bottom": 6 + } + }, + "button": { + "family": "Zed Sans", + "color": "#2b2b2b", + "size": 12, + "background": "#f1f1f1", + "padding": 4, + "corner_radius": 6, + "margin": { + "left": 6 + }, + "hover": { + "background": "#e3e3e3" + } + }, + "dismiss_button": { + "color": "#717171", + "icon_width": 8, + "icon_height": 8, + "button_width": 8, + "button_height": 8, + "hover": { + "color": "#393939" + } + } } } \ No newline at end of file diff --git a/assets/themes/solarized-dark.json b/assets/themes/solarized-dark.json index 6e8c405b6c212bf77fc99c99ea3c2a6dcf5a2f07..b1fc074ff73131023d55aeb5d78678164d488db4 100644 --- a/assets/themes/solarized-dark.json +++ b/assets/themes/solarized-dark.json @@ -341,6 +341,19 @@ "icon_color": "#fdf6e3", "background": "#586e755c" } + }, + "badge": { + "corner_radius": 3, + "padding": 2, + "margin": { + "bottom": -1, + "right": -1 + }, + "border": { + "width": 1, + "color": "#073642" + }, + "background": "#268bd2" } } }, @@ -470,6 +483,33 @@ "color": "#fdf6e3", "size": 14, "background": "#000000aa" + }, + "notification": { + "margin": { + "top": 10 + }, + "background": "#073642", + "corner_radius": 6, + "padding": 12, + "border": { + "color": "#002b36", + "width": 1 + }, + "shadow": { + "blur": 16, + "color": "#0000003d", + "offset": [ + 0, + 2 + ] + } + }, + "notifications": { + "width": 380, + "margin": { + "right": 10, + "bottom": 10 + } } }, "editor": { @@ -1646,5 +1686,56 @@ "padding": { "left": 6 } + }, + "contact_notification": { + "header_avatar": { + "height": 12, + "width": 12, + "corner_radius": 6 + }, + "header_message": { + "family": "Zed Sans", + "color": "#eee8d5", + "size": 12, + "margin": { + "left": 8, + "right": 8 + } + }, + "header_height": 18, + "body_message": { + "family": "Zed Sans", + "color": "#93a1a1", + "size": 12, + "margin": { + "left": 20, + "top": 6, + "bottom": 6 + } + }, + "button": { + "family": "Zed Sans", + "color": "#eee8d5", + "size": 12, + "background": "#002b36", + "padding": 4, + "corner_radius": 6, + "margin": { + "left": 6 + }, + "hover": { + "background": "#0736423d" + } + }, + "dismiss_button": { + "color": "#93a1a1", + "icon_width": 8, + "icon_height": 8, + "button_width": 8, + "button_height": 8, + "hover": { + "color": "#eee8d5" + } + } } } \ No newline at end of file diff --git a/assets/themes/solarized-light.json b/assets/themes/solarized-light.json index 3f5b26ee56a24a883fc21c45a9de60bec20b9a5d..72e12bcb5b5b00653056c79ca2d33caaebdf4bef 100644 --- a/assets/themes/solarized-light.json +++ b/assets/themes/solarized-light.json @@ -341,6 +341,19 @@ "icon_color": "#002b36", "background": "#93a1a12e" } + }, + "badge": { + "corner_radius": 3, + "padding": 2, + "margin": { + "bottom": -1, + "right": -1 + }, + "border": { + "width": 1, + "color": "#eee8d5" + }, + "background": "#268bd2" } } }, @@ -470,6 +483,33 @@ "color": "#002b36", "size": 14, "background": "#000000aa" + }, + "notification": { + "margin": { + "top": 10 + }, + "background": "#eee8d5", + "corner_radius": 6, + "padding": 12, + "border": { + "color": "#fdf6e3", + "width": 1 + }, + "shadow": { + "blur": 16, + "color": "#0000001f", + "offset": [ + 0, + 2 + ] + } + }, + "notifications": { + "width": 380, + "margin": { + "right": 10, + "bottom": 10 + } } }, "editor": { @@ -1646,5 +1686,56 @@ "padding": { "left": 6 } + }, + "contact_notification": { + "header_avatar": { + "height": 12, + "width": 12, + "corner_radius": 6 + }, + "header_message": { + "family": "Zed Sans", + "color": "#073642", + "size": 12, + "margin": { + "left": 8, + "right": 8 + } + }, + "header_height": 18, + "body_message": { + "family": "Zed Sans", + "color": "#586e75", + "size": 12, + "margin": { + "left": 20, + "top": 6, + "bottom": 6 + } + }, + "button": { + "family": "Zed Sans", + "color": "#073642", + "size": 12, + "background": "#fdf6e3", + "padding": 4, + "corner_radius": 6, + "margin": { + "left": 6 + }, + "hover": { + "background": "#eee8d51f" + } + }, + "dismiss_button": { + "color": "#586e75", + "icon_width": 8, + "icon_height": 8, + "button_width": 8, + "button_height": 8, + "hover": { + "color": "#073642" + } + } } } \ No newline at end of file diff --git a/assets/themes/sulphurpool-dark.json b/assets/themes/sulphurpool-dark.json index 0f2a868f24d028be4c960db27b39b36b540a3e5d..26568e91b2be123ca2005cc5c165736fab776f36 100644 --- a/assets/themes/sulphurpool-dark.json +++ b/assets/themes/sulphurpool-dark.json @@ -341,6 +341,19 @@ "icon_color": "#f5f7ff", "background": "#5e66875c" } + }, + "badge": { + "corner_radius": 3, + "padding": 2, + "margin": { + "bottom": -1, + "right": -1 + }, + "border": { + "width": 1, + "color": "#293256" + }, + "background": "#3d8fd1" } } }, @@ -470,6 +483,33 @@ "color": "#f5f7ff", "size": 14, "background": "#000000aa" + }, + "notification": { + "margin": { + "top": 10 + }, + "background": "#293256", + "corner_radius": 6, + "padding": 12, + "border": { + "color": "#202746", + "width": 1 + }, + "shadow": { + "blur": 16, + "color": "#0000003d", + "offset": [ + 0, + 2 + ] + } + }, + "notifications": { + "width": 380, + "margin": { + "right": 10, + "bottom": 10 + } } }, "editor": { @@ -1646,5 +1686,56 @@ "padding": { "left": 6 } + }, + "contact_notification": { + "header_avatar": { + "height": 12, + "width": 12, + "corner_radius": 6 + }, + "header_message": { + "family": "Zed Sans", + "color": "#dfe2f1", + "size": 12, + "margin": { + "left": 8, + "right": 8 + } + }, + "header_height": 18, + "body_message": { + "family": "Zed Sans", + "color": "#979db4", + "size": 12, + "margin": { + "left": 20, + "top": 6, + "bottom": 6 + } + }, + "button": { + "family": "Zed Sans", + "color": "#dfe2f1", + "size": 12, + "background": "#202746", + "padding": 4, + "corner_radius": 6, + "margin": { + "left": 6 + }, + "hover": { + "background": "#2932563d" + } + }, + "dismiss_button": { + "color": "#979db4", + "icon_width": 8, + "icon_height": 8, + "button_width": 8, + "button_height": 8, + "hover": { + "color": "#dfe2f1" + } + } } } \ No newline at end of file diff --git a/assets/themes/sulphurpool-light.json b/assets/themes/sulphurpool-light.json index b9106c62f3d273a537616f0cbd38090b8c854411..031d6652b9f9acc8aa1c123d993b0dd7db231a77 100644 --- a/assets/themes/sulphurpool-light.json +++ b/assets/themes/sulphurpool-light.json @@ -341,6 +341,19 @@ "icon_color": "#202746", "background": "#979db42e" } + }, + "badge": { + "corner_radius": 3, + "padding": 2, + "margin": { + "bottom": -1, + "right": -1 + }, + "border": { + "width": 1, + "color": "#dfe2f1" + }, + "background": "#3d8fd1" } } }, @@ -470,6 +483,33 @@ "color": "#202746", "size": 14, "background": "#000000aa" + }, + "notification": { + "margin": { + "top": 10 + }, + "background": "#dfe2f1", + "corner_radius": 6, + "padding": 12, + "border": { + "color": "#f5f7ff", + "width": 1 + }, + "shadow": { + "blur": 16, + "color": "#0000001f", + "offset": [ + 0, + 2 + ] + } + }, + "notifications": { + "width": 380, + "margin": { + "right": 10, + "bottom": 10 + } } }, "editor": { @@ -1646,5 +1686,56 @@ "padding": { "left": 6 } + }, + "contact_notification": { + "header_avatar": { + "height": 12, + "width": 12, + "corner_radius": 6 + }, + "header_message": { + "family": "Zed Sans", + "color": "#293256", + "size": 12, + "margin": { + "left": 8, + "right": 8 + } + }, + "header_height": 18, + "body_message": { + "family": "Zed Sans", + "color": "#5e6687", + "size": 12, + "margin": { + "left": 20, + "top": 6, + "bottom": 6 + } + }, + "button": { + "family": "Zed Sans", + "color": "#293256", + "size": 12, + "background": "#f5f7ff", + "padding": 4, + "corner_radius": 6, + "margin": { + "left": 6 + }, + "hover": { + "background": "#dfe2f11f" + } + }, + "dismiss_button": { + "color": "#5e6687", + "icon_width": 8, + "icon_height": 8, + "button_width": 8, + "button_height": 8, + "hover": { + "color": "#293256" + } + } } } \ No newline at end of file diff --git a/crates/chat_panel/src/chat_panel.rs b/crates/chat_panel/src/chat_panel.rs index bb835c66401d595607546fbca5453d85461c2164..460e01c527bddca2145f5f0fbf284403fdeb952a 100644 --- a/crates/chat_panel/src/chat_panel.rs +++ b/crates/chat_panel/src/chat_panel.rs @@ -69,7 +69,7 @@ impl ChatPanel { .with_style(move |cx| { let theme = &cx.global::().theme.chat_panel.channel_select; SelectStyle { - header: theme.header.container.clone(), + header: theme.header.container, menu: theme.menu.clone(), } }) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 1874822774a36ef2c7c777bd5e7d91f2d5e72b71..84254da73a5bb0815acf3b66bf0cef112088f030 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -54,10 +54,21 @@ pub struct UserStore { _maintain_current_user: Task<()>, } -pub enum Event {} +#[derive(Clone)] +pub struct ContactEvent { + pub user: Arc, + pub kind: ContactEventKind, +} + +#[derive(Clone, Copy)] +pub enum ContactEventKind { + Requested, + Accepted, + Cancelled, +} impl Entity for UserStore { - type Event = Event; + type Event = ContactEvent; } enum UpdateContacts { @@ -175,19 +186,23 @@ impl UserStore { // No need to paralellize here let mut updated_contacts = Vec::new(); for contact in message.contacts { - updated_contacts.push(Arc::new( - Contact::from_proto(contact, &this, &mut cx).await?, + let should_notify = contact.should_notify; + updated_contacts.push(( + Arc::new(Contact::from_proto(contact, &this, &mut cx).await?), + should_notify, )); } let mut incoming_requests = Vec::new(); for request in message.incoming_requests { - incoming_requests.push( - this.update(&mut cx, |this, cx| { - this.fetch_user(request.requester_id, cx) - }) - .await?, - ); + incoming_requests.push({ + let user = this + .update(&mut cx, |this, cx| { + this.fetch_user(request.requester_id, cx) + }) + .await?; + (user, request.should_notify) + }); } let mut outgoing_requests = Vec::new(); @@ -210,7 +225,13 @@ impl UserStore { this.contacts .retain(|contact| !removed_contacts.contains(&contact.user.id)); // Update existing contacts and insert new ones - for updated_contact in updated_contacts { + for (updated_contact, should_notify) in updated_contacts { + if should_notify { + cx.emit(ContactEvent { + user: updated_contact.user.clone(), + kind: ContactEventKind::Accepted, + }); + } match this.contacts.binary_search_by_key( &&updated_contact.user.github_login, |contact| &contact.user.github_login, @@ -221,17 +242,33 @@ impl UserStore { } // Remove incoming contact requests - this.incoming_contact_requests - .retain(|user| !removed_incoming_requests.contains(&user.id)); + this.incoming_contact_requests.retain(|user| { + if removed_incoming_requests.contains(&user.id) { + cx.emit(ContactEvent { + user: user.clone(), + kind: ContactEventKind::Cancelled, + }); + false + } else { + true + } + }); // Update existing incoming requests and insert new ones - for request in incoming_requests { + for (user, should_notify) in incoming_requests { + if should_notify { + cx.emit(ContactEvent { + user: user.clone(), + kind: ContactEventKind::Requested, + }); + } + match this .incoming_contact_requests - .binary_search_by_key(&&request.github_login, |contact| { + .binary_search_by_key(&&user.github_login, |contact| { &contact.github_login }) { - Ok(ix) => this.incoming_contact_requests[ix] = request, - Err(ix) => this.incoming_contact_requests.insert(ix, request), + Ok(ix) => this.incoming_contact_requests[ix] = user, + Err(ix) => this.incoming_contact_requests.insert(ix, user), } } @@ -334,13 +371,31 @@ impl UserStore { response: if accept { proto::ContactRequestResponse::Accept } else { - proto::ContactRequestResponse::Reject + proto::ContactRequestResponse::Decline } as i32, }, cx, ) } + pub fn dismiss_contact_request( + &mut self, + requester_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.upgrade(); + cx.spawn_weak(|_, _| async move { + client + .ok_or_else(|| anyhow!("can't upgrade client reference"))? + .request(proto::RespondToContactRequest { + requester_id, + response: proto::ContactRequestResponse::Dismiss as i32, + }) + .await?; + Ok(()) + }) + } + fn perform_contact_request( &mut self, user_id: u64, diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 4bb61c34046df885acb4a97960f0392da275f093..056f94ecfe7d099b6d07f42fb8ded114435be397 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -17,10 +17,11 @@ pub trait Db: Send + Sync { async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()>; async fn destroy_user(&self, id: UserId) -> Result<()>; - async fn get_contacts(&self, id: UserId) -> Result; + async fn get_contacts(&self, id: UserId) -> Result>; + async fn has_contact(&self, user_id_a: UserId, user_id_b: UserId) -> Result; async fn send_contact_request(&self, requester_id: UserId, responder_id: UserId) -> Result<()>; async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()>; - async fn dismiss_contact_request( + async fn dismiss_contact_notification( &self, responder_id: UserId, requester_id: UserId, @@ -190,7 +191,7 @@ impl Db for PostgresDb { // contacts - async fn get_contacts(&self, user_id: UserId) -> Result { + async fn get_contacts(&self, user_id: UserId) -> Result> { let query = " SELECT user_id_a, user_id_b, a_to_b, accepted, should_notify FROM contacts @@ -201,46 +202,67 @@ impl Db for PostgresDb { .bind(user_id) .fetch(&self.pool); - let mut current = vec![user_id]; - let mut outgoing_requests = Vec::new(); - let mut incoming_requests = Vec::new(); + let mut contacts = vec![Contact::Accepted { + user_id, + should_notify: false, + }]; while let Some(row) = rows.next().await { let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?; if user_id_a == user_id { if accepted { - current.push(user_id_b); + contacts.push(Contact::Accepted { + user_id: user_id_b, + should_notify: should_notify && a_to_b, + }); } else if a_to_b { - outgoing_requests.push(user_id_b); + contacts.push(Contact::Outgoing { user_id: user_id_b }) } else { - incoming_requests.push(IncomingContactRequest { - requester_id: user_id_b, + contacts.push(Contact::Incoming { + user_id: user_id_b, should_notify, }); } } else { if accepted { - current.push(user_id_a); + contacts.push(Contact::Accepted { + user_id: user_id_a, + should_notify: should_notify && !a_to_b, + }); } else if a_to_b { - incoming_requests.push(IncomingContactRequest { - requester_id: user_id_a, + contacts.push(Contact::Incoming { + user_id: user_id_a, should_notify, }); } else { - outgoing_requests.push(user_id_a); + contacts.push(Contact::Outgoing { user_id: user_id_a }); } } } - current.sort_unstable(); - outgoing_requests.sort_unstable(); - incoming_requests.sort_unstable(); + contacts.sort_unstable_by_key(|contact| contact.user_id()); - Ok(Contacts { - current, - outgoing_requests, - incoming_requests, - }) + Ok(contacts) + } + + async fn has_contact(&self, user_id_1: UserId, user_id_2: UserId) -> Result { + let (id_a, id_b) = if user_id_1 < user_id_2 { + (user_id_1, user_id_2) + } else { + (user_id_2, user_id_1) + }; + + let query = " + SELECT 1 FROM contacts + WHERE user_id_a = $1 AND user_id_b = $2 AND accepted = 't' + LIMIT 1 + "; + Ok(sqlx::query_scalar::<_, i32>(query) + .bind(id_a.0) + .bind(id_b.0) + .fetch_optional(&self.pool) + .await? + .is_some()) } async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> { @@ -254,7 +276,8 @@ impl Db for PostgresDb { VALUES ($1, $2, $3, 'f', 't') ON CONFLICT (user_id_a, user_id_b) DO UPDATE SET - accepted = 't' + accepted = 't', + should_notify = 'f' WHERE NOT contacts.accepted AND ((contacts.a_to_b = excluded.a_to_b AND contacts.user_id_a = excluded.user_id_b) OR @@ -297,21 +320,26 @@ impl Db for PostgresDb { } } - async fn dismiss_contact_request( + async fn dismiss_contact_notification( &self, - responder_id: UserId, - requester_id: UserId, + user_id: UserId, + contact_user_id: UserId, ) -> Result<()> { - let (id_a, id_b, a_to_b) = if responder_id < requester_id { - (responder_id, requester_id, false) + let (id_a, id_b, a_to_b) = if user_id < contact_user_id { + (user_id, contact_user_id, true) } else { - (requester_id, responder_id, true) + (contact_user_id, user_id, false) }; let query = " UPDATE contacts SET should_notify = 'f' - WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3; + WHERE + user_id_a = $1 AND user_id_b = $2 AND + ( + (a_to_b = $3 AND accepted) OR + (a_to_b != $3 AND NOT accepted) + ); "; let result = sqlx::query(query) @@ -342,7 +370,7 @@ impl Db for PostgresDb { let result = if accept { let query = " UPDATE contacts - SET accepted = 't', should_notify = 'f' + SET accepted = 't', should_notify = 't' WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3; "; sqlx::query(query) @@ -702,10 +730,28 @@ pub struct ChannelMessage { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct Contacts { - pub current: Vec, - pub incoming_requests: Vec, - pub outgoing_requests: Vec, +pub enum Contact { + Accepted { + user_id: UserId, + should_notify: bool, + }, + Outgoing { + user_id: UserId, + }, + Incoming { + user_id: UserId, + should_notify: bool, + }, +} + +impl Contact { + pub fn user_id(&self) -> UserId { + match self { + Contact::Accepted { user_id, .. } => *user_id, + Contact::Outgoing { user_id } => *user_id, + Contact::Incoming { user_id, .. } => *user_id, + } + } } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -947,51 +993,60 @@ pub mod tests { // User starts with no contacts assert_eq!( db.get_contacts(user_1).await.unwrap(), - Contacts { - current: vec![user_1], - outgoing_requests: vec![], - incoming_requests: vec![], - }, + vec![Contact::Accepted { + user_id: user_1, + should_notify: false + }], ); // User requests a contact. Both users see the pending request. db.send_contact_request(user_1, user_2).await.unwrap(); + assert!(!db.has_contact(user_1, user_2).await.unwrap()); + assert!(!db.has_contact(user_2, user_1).await.unwrap()); assert_eq!( db.get_contacts(user_1).await.unwrap(), - Contacts { - current: vec![user_1], - outgoing_requests: vec![user_2], - incoming_requests: vec![], - }, + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Outgoing { user_id: user_2 } + ], ); assert_eq!( db.get_contacts(user_2).await.unwrap(), - Contacts { - current: vec![user_2], - outgoing_requests: vec![], - incoming_requests: vec![IncomingContactRequest { - requester_id: user_1, + &[ + Contact::Incoming { + user_id: user_1, should_notify: true - }], - }, + }, + Contact::Accepted { + user_id: user_2, + should_notify: false + }, + ] ); // User 2 dismisses the contact request notification without accepting or rejecting. // We shouldn't notify them again. - db.dismiss_contact_request(user_1, user_2) + db.dismiss_contact_notification(user_1, user_2) .await .unwrap_err(); - db.dismiss_contact_request(user_2, user_1).await.unwrap(); + db.dismiss_contact_notification(user_2, user_1) + .await + .unwrap(); assert_eq!( db.get_contacts(user_2).await.unwrap(), - Contacts { - current: vec![user_2], - outgoing_requests: vec![], - incoming_requests: vec![IncomingContactRequest { - requester_id: user_1, + &[ + Contact::Incoming { + user_id: user_1, should_notify: false - }], - }, + }, + Contact::Accepted { + user_id: user_2, + should_notify: false + }, + ] ); // User can't accept their own contact request @@ -1005,44 +1060,106 @@ pub mod tests { .unwrap(); assert_eq!( db.get_contacts(user_1).await.unwrap(), - Contacts { - current: vec![user_1, user_2], - outgoing_requests: vec![], - incoming_requests: vec![], - }, + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Accepted { + user_id: user_2, + should_notify: true + } + ], ); + assert!(db.has_contact(user_1, user_2).await.unwrap()); + assert!(db.has_contact(user_2, user_1).await.unwrap()); assert_eq!( db.get_contacts(user_2).await.unwrap(), - Contacts { - current: vec![user_1, user_2], - outgoing_requests: vec![], - incoming_requests: vec![], - }, + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false, + }, + Contact::Accepted { + user_id: user_2, + should_notify: false, + }, + ] ); // Users cannot re-request existing contacts. db.send_contact_request(user_1, user_2).await.unwrap_err(); db.send_contact_request(user_2, user_1).await.unwrap_err(); + // Users can't dismiss notifications of them accepting other users' requests. + db.dismiss_contact_notification(user_2, user_1) + .await + .unwrap_err(); + assert_eq!( + db.get_contacts(user_1).await.unwrap(), + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Accepted { + user_id: user_2, + should_notify: true, + }, + ] + ); + + // Users can dismiss notifications of other users accepting their requests. + db.dismiss_contact_notification(user_1, user_2) + .await + .unwrap(); + assert_eq!( + db.get_contacts(user_1).await.unwrap(), + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Accepted { + user_id: user_2, + should_notify: false, + }, + ] + ); + // Users send each other concurrent contact requests and // see that they are immediately accepted. db.send_contact_request(user_1, user_3).await.unwrap(); db.send_contact_request(user_3, user_1).await.unwrap(); assert_eq!( db.get_contacts(user_1).await.unwrap(), - Contacts { - current: vec![user_1, user_2, user_3], - outgoing_requests: vec![], - incoming_requests: vec![], - }, + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Accepted { + user_id: user_2, + should_notify: false, + }, + Contact::Accepted { + user_id: user_3, + should_notify: false + }, + ] ); assert_eq!( db.get_contacts(user_3).await.unwrap(), - Contacts { - current: vec![user_1, user_3], - outgoing_requests: vec![], - incoming_requests: vec![], - }, + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Accepted { + user_id: user_3, + should_notify: false + } + ], ); // User declines a contact request. Both users see that it is gone. @@ -1050,21 +1167,33 @@ pub mod tests { db.respond_to_contact_request(user_3, user_2, false) .await .unwrap(); + assert!(!db.has_contact(user_2, user_3).await.unwrap()); + assert!(!db.has_contact(user_3, user_2).await.unwrap()); assert_eq!( db.get_contacts(user_2).await.unwrap(), - Contacts { - current: vec![user_1, user_2], - outgoing_requests: vec![], - incoming_requests: vec![], - }, + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Accepted { + user_id: user_2, + should_notify: false + } + ] ); assert_eq!( db.get_contacts(user_3).await.unwrap(), - Contacts { - current: vec![user_1, user_3], - outgoing_requests: vec![], - incoming_requests: vec![], - }, + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Accepted { + user_id: user_3, + should_notify: false + } + ], ); } } @@ -1219,40 +1348,51 @@ pub mod tests { unimplemented!() } - async fn get_contacts(&self, id: UserId) -> Result { + async fn get_contacts(&self, id: UserId) -> Result> { self.background.simulate_random_delay().await; - let mut current = vec![id]; - let mut outgoing_requests = Vec::new(); - let mut incoming_requests = Vec::new(); + let mut contacts = vec![Contact::Accepted { + user_id: id, + should_notify: false, + }]; for contact in self.contacts.lock().iter() { if contact.requester_id == id { if contact.accepted { - current.push(contact.responder_id); + contacts.push(Contact::Accepted { + user_id: contact.responder_id, + should_notify: contact.should_notify, + }); } else { - outgoing_requests.push(contact.responder_id); + contacts.push(Contact::Outgoing { + user_id: contact.responder_id, + }); } } else if contact.responder_id == id { if contact.accepted { - current.push(contact.requester_id); + contacts.push(Contact::Accepted { + user_id: contact.requester_id, + should_notify: false, + }); } else { - incoming_requests.push(IncomingContactRequest { - requester_id: contact.requester_id, + contacts.push(Contact::Incoming { + user_id: contact.requester_id, should_notify: contact.should_notify, }); } } } - current.sort_unstable(); - outgoing_requests.sort_unstable(); - incoming_requests.sort_unstable(); + contacts.sort_unstable_by_key(|contact| contact.user_id()); + Ok(contacts) + } - Ok(Contacts { - current, - outgoing_requests, - incoming_requests, - }) + async fn has_contact(&self, user_id_a: UserId, user_id_b: UserId) -> Result { + self.background.simulate_random_delay().await; + Ok(self.contacts.lock().iter().any(|contact| { + contact.accepted + && ((contact.requester_id == user_id_a && contact.responder_id == user_id_b) + || (contact.requester_id == user_id_b && contact.responder_id == user_id_a)) + })) } async fn send_contact_request( @@ -1274,6 +1414,7 @@ pub mod tests { Err(anyhow!("contact already exists"))?; } else { contact.accepted = true; + contact.should_notify = false; return Ok(()); } } @@ -1294,22 +1435,29 @@ pub mod tests { Ok(()) } - async fn dismiss_contact_request( + async fn dismiss_contact_notification( &self, - responder_id: UserId, - requester_id: UserId, + user_id: UserId, + contact_user_id: UserId, ) -> Result<()> { let mut contacts = self.contacts.lock(); for contact in contacts.iter_mut() { - if contact.requester_id == requester_id && contact.responder_id == responder_id { - if contact.accepted { - return Err(anyhow!("contact already confirmed")); - } + if contact.requester_id == contact_user_id + && contact.responder_id == user_id + && !contact.accepted + { + contact.should_notify = false; + return Ok(()); + } + if contact.requester_id == user_id + && contact.responder_id == contact_user_id + && contact.accepted + { contact.should_notify = false; return Ok(()); } } - Err(anyhow!("no such contact request")) + Err(anyhow!("no such notification")) } async fn respond_to_contact_request( @@ -1326,6 +1474,7 @@ pub mod tests { } if accept { contact.accepted = true; + contact.should_notify = true; } else { contacts.remove(ix); } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 33d1d526775f841e98e24e154d1ae29dcf54292e..ecd384794551e46f0d42f34072b79f2b32962d2b 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2,7 +2,7 @@ mod store; use crate::{ auth, - db::{ChannelId, MessageId, UserId}, + db::{self, ChannelId, MessageId, UserId}, AppState, Result, }; use anyhow::anyhow; @@ -420,22 +420,28 @@ impl Server { async fn update_user_contacts(self: &Arc, user_id: UserId) -> Result<()> { let contacts = self.app_state.db.get_contacts(user_id).await?; let store = self.store().await; - let updated_contact = store.contact_for_user(user_id); - for contact_user_id in contacts.current { - for contact_conn_id in store.connection_ids_for_user(contact_user_id) { - self.peer - .send( - contact_conn_id, - proto::UpdateContacts { - contacts: vec![updated_contact.clone()], - remove_contacts: Default::default(), - incoming_requests: Default::default(), - remove_incoming_requests: Default::default(), - outgoing_requests: Default::default(), - remove_outgoing_requests: Default::default(), - }, - ) - .trace_err(); + let updated_contact = store.contact_for_user(user_id, false); + for contact in contacts { + if let db::Contact::Accepted { + user_id: contact_user_id, + .. + } = contact + { + for contact_conn_id in store.connection_ids_for_user(contact_user_id) { + self.peer + .send( + contact_conn_id, + proto::UpdateContacts { + contacts: vec![updated_contact.clone()], + remove_contacts: Default::default(), + incoming_requests: Default::default(), + remove_incoming_requests: Default::default(), + outgoing_requests: Default::default(), + remove_outgoing_requests: Default::default(), + }, + ) + .trace_err(); + } } } Ok(()) @@ -473,8 +479,12 @@ impl Server { guest_user_id = state.user_id_for_connection(request.sender_id)?; }; - let guest_contacts = self.app_state.db.get_contacts(guest_user_id).await?; - if !guest_contacts.current.contains(&host_user_id) { + let has_contact = self + .app_state + .db + .has_contact(guest_user_id, host_user_id) + .await?; + if !has_contact { return Err(anyhow!("no such project"))?; } @@ -1023,35 +1033,46 @@ impl Server { .await .user_id_for_connection(request.sender_id)?; let requester_id = UserId::from_proto(request.payload.requester_id); - let accept = request.payload.response == proto::ContactRequestResponse::Accept as i32; - self.app_state - .db - .respond_to_contact_request(responder_id, requester_id, accept) - .await?; - - let store = self.store().await; - // Update responder with new contact - let mut update = proto::UpdateContacts::default(); - if accept { - update.contacts.push(store.contact_for_user(requester_id)); - } - update - .remove_incoming_requests - .push(requester_id.to_proto()); - for connection_id in store.connection_ids_for_user(responder_id) { - self.peer.send(connection_id, update.clone())?; - } + if request.payload.response == proto::ContactRequestResponse::Dismiss as i32 { + self.app_state + .db + .dismiss_contact_notification(responder_id, requester_id) + .await?; + } else { + let accept = request.payload.response == proto::ContactRequestResponse::Accept as i32; + self.app_state + .db + .respond_to_contact_request(responder_id, requester_id, accept) + .await?; + + let store = self.store().await; + // Update responder with new contact + let mut update = proto::UpdateContacts::default(); + if accept { + update + .contacts + .push(store.contact_for_user(requester_id, false)); + } + update + .remove_incoming_requests + .push(requester_id.to_proto()); + for connection_id in store.connection_ids_for_user(responder_id) { + self.peer.send(connection_id, update.clone())?; + } - // Update requester with new contact - let mut update = proto::UpdateContacts::default(); - if accept { - update.contacts.push(store.contact_for_user(responder_id)); - } - update - .remove_outgoing_requests - .push(responder_id.to_proto()); - for connection_id in store.connection_ids_for_user(requester_id) { - self.peer.send(connection_id, update.clone())?; + // Update requester with new contact + let mut update = proto::UpdateContacts::default(); + if accept { + update + .contacts + .push(store.contact_for_user(responder_id, true)); + } + update + .remove_outgoing_requests + .push(responder_id.to_proto()); + for connection_id in store.connection_ids_for_user(requester_id) { + self.peer.send(connection_id, update.clone())?; + } } response.send(proto::Ack {})?; @@ -7257,7 +7278,7 @@ mod tests { } fn render(&mut self, _: &mut gpui::RenderContext) -> gpui::ElementBox { - gpui::Element::boxed(gpui::elements::Empty) + gpui::Element::boxed(gpui::elements::Empty::new()) } } } diff --git a/crates/collab/src/rpc/store.rs b/crates/collab/src/rpc/store.rs index 8ca270622832b4fc23151508d3df4a4fa9f013c8..4ab6df0adc9fd4bbdc62a2bd187abf5f029a12eb 100644 --- a/crates/collab/src/rpc/store.rs +++ b/crates/collab/src/rpc/store.rs @@ -217,33 +217,46 @@ impl Store { .is_empty() } - pub fn build_initial_contacts_update(&self, contacts: db::Contacts) -> proto::UpdateContacts { + pub fn build_initial_contacts_update( + &self, + contacts: Vec, + ) -> proto::UpdateContacts { let mut update = proto::UpdateContacts::default(); - for user_id in contacts.current { - update.contacts.push(self.contact_for_user(user_id)); - } - - for request in contacts.incoming_requests { - update - .incoming_requests - .push(proto::IncomingContactRequest { - requester_id: request.requester_id.to_proto(), - should_notify: request.should_notify, - }) - } - for requested_user_id in contacts.outgoing_requests { - update.outgoing_requests.push(requested_user_id.to_proto()) + for contact in contacts { + match contact { + db::Contact::Accepted { + user_id, + should_notify, + } => { + update + .contacts + .push(self.contact_for_user(user_id, should_notify)); + } + db::Contact::Outgoing { user_id } => { + update.outgoing_requests.push(user_id.to_proto()) + } + db::Contact::Incoming { + user_id, + should_notify, + } => update + .incoming_requests + .push(proto::IncomingContactRequest { + requester_id: user_id.to_proto(), + should_notify, + }), + } } update } - pub fn contact_for_user(&self, user_id: UserId) -> proto::Contact { + pub fn contact_for_user(&self, user_id: UserId, should_notify: bool) -> proto::Contact { proto::Contact { user_id: user_id.to_proto(), projects: self.project_metadata_for_user(user_id), online: self.is_user_online(user_id), + should_notify, } } diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index b6058c01e5ce2b453308acf2e0e0c4700b0f8d98..f724cc19a611866e6252af79c9e67ac1d61dff6c 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -71,7 +71,7 @@ impl CommandPalette { cx.as_mut().defer(move |cx| { let this = cx.add_view(window_id, |cx| Self::new(focused_view_id, cx)); workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(cx, |cx, _| { + workspace.toggle_modal(cx, |_, cx| { cx.subscribe(&this, Self::on_event).detach(); this }); diff --git a/crates/contacts_panel/src/contact_finder.rs b/crates/contacts_panel/src/contact_finder.rs index 5a480911d4a5ae3f6f2855da23bffdba29bf7216..18e17a93d9ba448f533d83a0a09701b5d3ae84cc 100644 --- a/crates/contacts_panel/src/contact_finder.rs +++ b/crates/contacts_panel/src/contact_finder.rs @@ -118,7 +118,7 @@ impl PickerDelegate for ContactFinder { "icons/accept.svg" } ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => { - "icons/reject.svg" + "icons/decline.svg" } }; let button_style = if self.user_store.read(cx).is_contact_request_pending(&user) { @@ -159,7 +159,7 @@ impl PickerDelegate for ContactFinder { impl ContactFinder { fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { - workspace.toggle_modal(cx, |cx, workspace| { + workspace.toggle_modal(cx, |workspace, cx| { let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx)); cx.subscribe(&finder, Self::on_event).detach(); finder diff --git a/crates/contacts_panel/src/contact_notification.rs b/crates/contacts_panel/src/contact_notification.rs new file mode 100644 index 0000000000000000000000000000000000000000..6369f70ce0244fdd0a1a8540d4a91a0d86c0acbc --- /dev/null +++ b/crates/contacts_panel/src/contact_notification.rs @@ -0,0 +1,237 @@ +use client::{ContactEvent, ContactEventKind, UserStore}; +use gpui::{ + elements::*, impl_internal_actions, platform::CursorStyle, Entity, ModelHandle, + MutableAppContext, RenderContext, View, ViewContext, +}; +use settings::Settings; +use workspace::Notification; + +use crate::render_icon_button; + +impl_internal_actions!(contact_notifications, [Dismiss, RespondToContactRequest]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(ContactNotification::dismiss); + cx.add_action(ContactNotification::respond_to_contact_request); +} + +pub struct ContactNotification { + user_store: ModelHandle, + event: ContactEvent, +} + +#[derive(Clone)] +struct Dismiss(u64); + +#[derive(Clone)] +pub struct RespondToContactRequest { + pub user_id: u64, + pub accept: bool, +} + +pub enum Event { + Dismiss, +} + +enum Decline {} +enum Accept {} + +impl Entity for ContactNotification { + type Event = Event; +} + +impl View for ContactNotification { + fn ui_name() -> &'static str { + "ContactNotification" + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + match self.event.kind { + ContactEventKind::Requested => self.render_incoming_request(cx), + ContactEventKind::Accepted => self.render_acceptance(cx), + _ => unreachable!(), + } + } +} + +impl Notification for ContactNotification { + fn should_dismiss_notification_on_event(&self, event: &::Event) -> bool { + matches!(event, Event::Dismiss) + } +} + +impl ContactNotification { + pub fn new( + event: ContactEvent, + user_store: ModelHandle, + cx: &mut ViewContext, + ) -> Self { + cx.subscribe(&user_store, move |this, _, event, cx| { + if let client::ContactEvent { + kind: ContactEventKind::Cancelled, + user, + } = event + { + if user.id == this.event.user.id { + cx.emit(Event::Dismiss); + } + } + }) + .detach(); + + Self { event, user_store } + } + + fn render_incoming_request(&mut self, cx: &mut RenderContext) -> ElementBox { + let theme = cx.global::().theme.clone(); + let theme = &theme.contact_notification; + let user = &self.event.user; + let user_id = user.id; + + Flex::column() + .with_child(self.render_header("wants to add you as a contact.", theme, cx)) + .with_child( + Label::new( + "They won't know if you decline.".to_string(), + theme.body_message.text.clone(), + ) + .contained() + .with_style(theme.body_message.container) + .boxed(), + ) + .with_child( + Flex::row() + .with_child( + MouseEventHandler::new::( + self.event.user.id as usize, + cx, + |state, _| { + let button = theme.button.style_for(state, false); + Label::new("Decline".to_string(), button.text.clone()) + .contained() + .with_style(button.container) + .boxed() + }, + ) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(move |_, cx| { + cx.dispatch_action(RespondToContactRequest { + user_id, + accept: false, + }); + }) + .boxed(), + ) + .with_child( + MouseEventHandler::new::(user.id as usize, cx, |state, _| { + let button = theme.button.style_for(state, false); + Label::new("Accept".to_string(), button.text.clone()) + .contained() + .with_style(button.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(move |_, cx| { + cx.dispatch_action(RespondToContactRequest { + user_id, + accept: true, + }); + }) + .boxed(), + ) + .aligned() + .right() + .boxed(), + ) + .contained() + .boxed() + } + + fn render_acceptance(&mut self, cx: &mut RenderContext) -> ElementBox { + let theme = cx.global::().theme.clone(); + let theme = &theme.contact_notification; + + self.render_header("accepted your contact request", theme, cx) + } + + fn render_header( + &self, + message: &'static str, + theme: &theme::ContactNotification, + cx: &mut RenderContext, + ) -> ElementBox { + let user = &self.event.user; + let user_id = user.id; + Flex::row() + .with_children(user.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(theme.header_avatar) + .aligned() + .constrained() + .with_height( + cx.font_cache() + .line_height(theme.header_message.text.font_size), + ) + .aligned() + .top() + .boxed() + })) + .with_child( + Text::new( + format!("{} {}", user.github_login, message), + theme.header_message.text.clone(), + ) + .contained() + .with_style(theme.header_message.container) + .aligned() + .top() + .left() + .flex(1., true) + .boxed(), + ) + .with_child( + MouseEventHandler::new::(user.id as usize, cx, |state, _| { + render_icon_button( + theme.dismiss_button.style_for(state, false), + "icons/decline.svg", + ) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .with_padding(Padding::uniform(5.)) + .on_click(move |_, cx| cx.dispatch_action(Dismiss(user_id))) + .aligned() + .constrained() + .with_height( + cx.font_cache() + .line_height(theme.header_message.text.font_size), + ) + .aligned() + .top() + .flex_float() + .boxed(), + ) + .named("contact notification header") + } + + fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext) { + self.user_store.update(cx, |store, cx| { + store + .dismiss_contact_request(self.event.user.id, cx) + .detach_and_log_err(cx); + }); + cx.emit(Event::Dismiss); + } + + fn respond_to_contact_request( + &mut self, + action: &RespondToContactRequest, + cx: &mut ViewContext, + ) { + self.user_store + .update(cx, |store, cx| { + store.respond_to_contact_request(action.user_id, action.accept, cx) + }) + .detach(); + } +} diff --git a/crates/contacts_panel/src/contacts_panel.rs b/crates/contacts_panel/src/contacts_panel.rs index 5d96a1b0c20f351c659e83256eb30610cde9ba10..e26e64f9f6ee18353dd00163fe1008d52b384b92 100644 --- a/crates/contacts_panel/src/contacts_panel.rs +++ b/crates/contacts_panel/src/contacts_panel.rs @@ -1,6 +1,8 @@ mod contact_finder; +mod contact_notification; -use client::{Contact, User, UserStore}; +use client::{Contact, ContactEventKind, User, UserStore}; +use contact_notification::ContactNotification; use editor::{Cancel, Editor}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ @@ -8,14 +10,14 @@ use gpui::{ geometry::{rect::RectF, vector::vec2f}, impl_actions, platform::CursorStyle, - Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext, RenderContext, - Subscription, View, ViewContext, ViewHandle, + AppContext, Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext, + RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle, }; use serde::Deserialize; use settings::Settings; use std::sync::Arc; use theme::IconButton; -use workspace::{AppState, JoinProject}; +use workspace::{sidebar::SidebarItem, AppState, JoinProject, Workspace}; impl_actions!( contacts_panel, @@ -53,6 +55,7 @@ pub struct RespondToContactRequest { pub fn init(cx: &mut MutableAppContext) { contact_finder::init(cx); + contact_notification::init(cx); cx.add_action(ContactsPanel::request_contact); cx.add_action(ContactsPanel::remove_contact); cx.add_action(ContactsPanel::respond_to_contact_request); @@ -60,7 +63,11 @@ pub fn init(cx: &mut MutableAppContext) { } impl ContactsPanel { - pub fn new(app_state: Arc, cx: &mut ViewContext) -> Self { + pub fn new( + app_state: Arc, + workspace: WeakViewHandle, + cx: &mut ViewContext, + ) -> Self { let user_query_editor = cx.add_view(|cx| { let mut editor = Editor::single_line( Some(|theme| theme.contacts_panel.user_query_editor.clone()), @@ -77,6 +84,27 @@ impl ContactsPanel { }) .detach(); + cx.subscribe(&app_state.user_store, { + let user_store = app_state.user_store.downgrade(); + move |_, _, event, cx| { + if let Some((workspace, user_store)) = + workspace.upgrade(cx).zip(user_store.upgrade(cx)) + { + workspace.update(cx, |workspace, cx| match event.kind { + ContactEventKind::Requested | ContactEventKind::Accepted => workspace + .show_notification( + cx.add_view(|cx| { + ContactNotification::new(event.clone(), user_store, cx) + }), + cx, + ), + _ => {} + }); + } + } + }) + .detach(); + let mut this = Self { list_state: ListState::new(0, Orientation::Top, 1000., { let this = cx.weak_handle(); @@ -316,7 +344,7 @@ impl ContactsPanel { is_incoming: bool, cx: &mut LayoutContext, ) -> ElementBox { - enum Reject {} + enum Decline {} enum Accept {} enum Cancel {} @@ -345,13 +373,13 @@ impl ContactsPanel { if is_incoming { row.add_children([ - MouseEventHandler::new::(user.id as usize, cx, |mouse_state, _| { + MouseEventHandler::new::(user.id as usize, cx, |mouse_state, _| { let button_style = if is_contact_request_pending { &theme.disabled_contact_button } else { &theme.contact_button.style_for(mouse_state, false) }; - render_icon_button(button_style, "icons/reject.svg") + render_icon_button(button_style, "icons/decline.svg") .aligned() .flex_float() .boxed() @@ -393,7 +421,7 @@ impl ContactsPanel { } else { &theme.contact_button.style_for(mouse_state, false) }; - render_icon_button(button_style, "icons/reject.svg") + render_icon_button(button_style, "icons/decline.svg") .aligned() .flex_float() .boxed() @@ -568,6 +596,16 @@ impl ContactsPanel { } } +impl SidebarItem for ContactsPanel { + fn should_show_badge(&self, cx: &AppContext) -> bool { + !self + .user_store + .read(cx) + .incoming_contact_requests() + .is_empty() + } +} + fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { Svg::new(svg_path) .with_color(style.color) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index a63ff7b0bd18c6270963bbaf759940b1042cbdde..e85147d7e2a9b5a20e86ebd20fcce87f07be073d 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -85,7 +85,7 @@ impl FileFinder { } fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { - workspace.toggle_modal(cx, |cx, workspace| { + workspace.toggle_modal(cx, |workspace, cx| { let project = workspace.project().clone(); let finder = cx.add_view(|cx| Self::new(project, cx)); cx.subscribe(&finder, Self::on_event).detach(); diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 3f8aa933ba483e3a3d989ef753b3f8cabd42ce10..9e2c79c5dca5596ac372c4cf6fabad4d839a85f2 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -62,7 +62,7 @@ impl GoToLine { .active_item(cx) .and_then(|active_item| active_item.downcast::()) { - workspace.toggle_modal(cx, |cx, _| { + workspace.toggle_modal(cx, |_, cx| { let view = cx.add_view(|cx| GoToLine::new(editor, cx)); cx.subscribe(&view, Self::on_event).detach(); view diff --git a/crates/gpui/src/elements/empty.rs b/crates/gpui/src/elements/empty.rs index 90b21231639665aec813967e9595d2673437d777..afe24127b58ef6eadc4acf20801d95011ca1036b 100644 --- a/crates/gpui/src/elements/empty.rs +++ b/crates/gpui/src/elements/empty.rs @@ -8,11 +8,18 @@ use crate::{ }; use crate::{Element, Event, EventContext, LayoutContext, PaintContext, SizeConstraint}; -pub struct Empty; +pub struct Empty { + collapsed: bool, +} impl Empty { pub fn new() -> Self { - Self + Self { collapsed: false } + } + + pub fn collapsed(mut self) -> Self { + self.collapsed = true; + self } } @@ -25,12 +32,12 @@ impl Element for Empty { constraint: SizeConstraint, _: &mut LayoutContext, ) -> (Vector2F, Self::LayoutState) { - let x = if constraint.max.x().is_finite() { + let x = if constraint.max.x().is_finite() && !self.collapsed { constraint.max.x() } else { constraint.min.x() }; - let y = if constraint.max.y().is_finite() { + let y = if constraint.max.y().is_finite() && !self.collapsed { constraint.max.y() } else { constraint.min.y() diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 5658cf201197ad0db637f6a11d956e6b9854671f..f5057ba39d37232a0b39758d6d25ce2531850aeb 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -87,7 +87,7 @@ impl OutlineView { .read(cx) .outline(Some(cx.global::().theme.editor.syntax.as_ref())); if let Some(outline) = buffer { - workspace.toggle_modal(cx, |cx, _| { + workspace.toggle_modal(cx, |_, cx| { let view = cx.add_view(|cx| OutlineView::new(outline, editor, cx)); cx.subscribe(&view, Self::on_event).detach(); view diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 61c97f281d327f01f1cad86bb81dac7bca92bc0f..639d7b44d9c7f1d89d6af6486842a258765ab3d1 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -900,6 +900,12 @@ impl Entity for ProjectPanel { type Event = Event; } +impl workspace::sidebar::SidebarItem for ProjectPanel { + fn should_show_badge(&self, _: &AppContext) -> bool { + false + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 157ea8ef7380795d318430bffbd2dc989a3b6596..5322a8924aa209969cc4d7ec611dc765ac4e072e 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -71,7 +71,7 @@ impl ProjectSymbolsView { } fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { - workspace.toggle_modal(cx, |cx, workspace| { + workspace.toggle_modal(cx, |workspace, cx| { let project = workspace.project().clone(); let symbols = cx.add_view(|cx| Self::new(project, cx)); cx.subscribe(&symbols, Self::on_event).detach(); diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 8adba5fc80c10dbac3413e803750e086a9ba8563..12ff05c7575ec53b1102ab253d4a9db08790b69a 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -564,8 +564,9 @@ message RespondToContactRequest { enum ContactRequestResponse { Accept = 0; - Reject = 1; + Decline = 1; Block = 2; + Dismiss = 3; } message SendChannelMessage { @@ -876,6 +877,7 @@ message Contact { uint64 user_id = 1; repeated ProjectMetadata projects = 2; bool online = 3; + bool should_notify = 4; } message ProjectMetadata { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 72db11c4931436ad6bf90650561d3baa5fe1eef1..9875f974983706204b0287913cc2eb15402123ab 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -29,6 +29,7 @@ pub struct Theme { pub search: Search, pub project_diagnostics: ProjectDiagnostics, pub breadcrumbs: ContainedText, + pub contact_notification: ContactNotification, } #[derive(Deserialize, Default)] @@ -45,6 +46,8 @@ pub struct Workspace { pub toolbar: Toolbar, pub disconnected_overlay: ContainedText, pub modal: ContainerStyle, + pub notification: ContainerStyle, + pub notifications: Notifications, } #[derive(Clone, Deserialize, Default)] @@ -109,6 +112,13 @@ pub struct Toolbar { pub item_spacing: f32, } +#[derive(Clone, Deserialize, Default)] +pub struct Notifications { + #[serde(flatten)] + pub container: ContainerStyle, + pub width: f32, +} + #[derive(Clone, Deserialize, Default)] pub struct Search { #[serde(flatten)] @@ -152,6 +162,7 @@ pub struct StatusBarSidebarButtons { pub group_left: ContainerStyle, pub group_right: ContainerStyle, pub item: Interactive, + pub badge: ContainerStyle, } #[derive(Deserialize, Default)] @@ -345,6 +356,16 @@ pub struct ProjectDiagnostics { pub tab_summary_spacing: f32, } +#[derive(Deserialize, Default)] +pub struct ContactNotification { + pub header_avatar: ImageStyle, + pub header_message: ContainedText, + pub header_height: f32, + pub body_message: ContainedText, + pub button: Interactive, + pub dismiss_button: Interactive, +} + #[derive(Clone, Deserialize, Default)] pub struct Editor { pub text_color: Color, diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 1904ed89d90292c6ed176ed512748e2a4f72bd7e..718268788c39f17f32ca993ca5e9ce2f2b9fb4ce 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -66,7 +66,7 @@ impl ThemeSelector { fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { let themes = workspace.themes(); - workspace.toggle_modal(cx, |cx, _| { + workspace.toggle_modal(cx, |_, cx| { let this = cx.add_view(|cx| Self::new(themes, cx)); cx.subscribe(&this, Self::on_event).detach(); this diff --git a/crates/workspace/src/sidebar.rs b/crates/workspace/src/sidebar.rs index c9cbcbb4fb073367c04ca63b9d951310a0ecaed4..366c74e43f7a3bcd31dd44cf6ee0465b07f66558 100644 --- a/crates/workspace/src/sidebar.rs +++ b/crates/workspace/src/sidebar.rs @@ -1,13 +1,40 @@ +use crate::StatusItemView; use gpui::{ - elements::*, impl_actions, platform::CursorStyle, AnyViewHandle, Entity, RenderContext, View, - ViewContext, ViewHandle, + elements::*, impl_actions, platform::CursorStyle, AnyViewHandle, AppContext, Entity, + RenderContext, Subscription, View, ViewContext, ViewHandle, }; use serde::Deserialize; use settings::Settings; use std::{cell::RefCell, rc::Rc}; use theme::Theme; -use crate::StatusItemView; +pub trait SidebarItem: View { + fn should_show_badge(&self, cx: &AppContext) -> bool; +} + +pub trait SidebarItemHandle { + fn should_show_badge(&self, cx: &AppContext) -> bool; + fn to_any(&self) -> AnyViewHandle; +} + +impl SidebarItemHandle for ViewHandle +where + T: SidebarItem, +{ + fn should_show_badge(&self, cx: &AppContext) -> bool { + self.read(cx).should_show_badge(cx) + } + + fn to_any(&self) -> AnyViewHandle { + self.into() + } +} + +impl Into for &dyn SidebarItemHandle { + fn into(self) -> AnyViewHandle { + self.to_any() + } +} pub struct Sidebar { side: Side, @@ -23,10 +50,10 @@ pub enum Side { Right, } -#[derive(Clone)] struct Item { icon_path: &'static str, - view: AnyViewHandle, + view: Rc, + _observation: Subscription, } pub struct SidebarButtons { @@ -58,13 +85,18 @@ impl Sidebar { } } - pub fn add_item( + pub fn add_item( &mut self, icon_path: &'static str, - view: AnyViewHandle, + view: ViewHandle, cx: &mut ViewContext, ) { - self.items.push(Item { icon_path, view }); + let subscription = cx.observe(&view, |_, _, cx| cx.notify()); + self.items.push(Item { + icon_path, + view: Rc::new(view), + _observation: subscription, + }); cx.notify() } @@ -82,10 +114,10 @@ impl Sidebar { cx.notify(); } - pub fn active_item(&self) -> Option<&AnyViewHandle> { + pub fn active_item(&self) -> Option<&dyn SidebarItemHandle> { self.active_item_ix .and_then(|ix| self.items.get(ix)) - .map(|item| &item.view) + .map(|item| item.view.as_ref()) } fn render_resize_handle(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { @@ -185,34 +217,62 @@ impl View for SidebarButtons { .sidebar_buttons; let sidebar = self.sidebar.read(cx); let item_style = theme.item; + let badge_style = theme.badge; let active_ix = sidebar.active_item_ix; let side = sidebar.side; let group_style = match side { Side::Left => theme.group_left, Side::Right => theme.group_right, }; - let items = sidebar.items.clone(); + let items = sidebar + .items + .iter() + .map(|item| (item.icon_path, item.view.clone())) + .collect::>(); Flex::row() - .with_children(items.iter().enumerate().map(|(ix, item)| { - MouseEventHandler::new::(ix, cx, move |state, _| { - let style = item_style.style_for(state, Some(ix) == active_ix); - Svg::new(item.icon_path) - .with_color(style.icon_color) - .constrained() - .with_height(style.icon_size) - .contained() - .with_style(style.container) + .with_children( + items + .into_iter() + .enumerate() + .map(|(ix, (icon_path, item_view))| { + MouseEventHandler::new::(ix, cx, move |state, cx| { + let is_active = Some(ix) == active_ix; + let style = item_style.style_for(state, is_active); + Stack::new() + .with_child( + Svg::new(icon_path).with_color(style.icon_color).boxed(), + ) + .with_children(if !is_active && item_view.should_show_badge(cx) { + Some( + Empty::new() + .collapsed() + .contained() + .with_style(badge_style) + .aligned() + .bottom() + .right() + .boxed(), + ) + } else { + None + }) + .constrained() + .with_width(style.icon_size) + .with_height(style.icon_size) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(move |_, cx| { + cx.dispatch_action(ToggleSidebarItem { + side, + item_index: ix, + }) + }) .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(move |_, cx| { - cx.dispatch_action(ToggleSidebarItem { - side, - item_index: ix, - }) - }) - .boxed() - })) + }), + ) .contained() .with_style(group_style) .boxed() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d5b0bf2ed54f8c999fa669e72a44dc8d1c7595eb..21d5581640d8654f7192bb3d7b07d771fcf8ff2f 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -604,6 +604,31 @@ impl WeakItemHandle for WeakViewHandle { } } +pub trait Notification: View { + fn should_dismiss_notification_on_event(&self, event: &::Event) -> bool; +} + +pub trait NotificationHandle { + fn id(&self) -> usize; + fn to_any(&self) -> AnyViewHandle; +} + +impl NotificationHandle for ViewHandle { + fn id(&self) -> usize { + self.id() + } + + fn to_any(&self) -> AnyViewHandle { + self.into() + } +} + +impl Into for &dyn NotificationHandle { + fn into(self) -> AnyViewHandle { + self.to_any() + } +} + #[derive(Clone)] pub struct WorkspaceParams { pub project: ModelHandle, @@ -683,6 +708,7 @@ pub struct Workspace { panes: Vec>, active_pane: ViewHandle, status_bar: ViewHandle, + notifications: Vec>, project: ModelHandle, leader_state: LeaderState, follower_states_by_leader: FollowerStatesByLeader, @@ -791,6 +817,7 @@ impl Workspace { panes: vec![pane.clone()], active_pane: pane.clone(), status_bar, + notifications: Default::default(), client: params.client.clone(), remote_entity_subscription: None, user_store: params.user_store.clone(), @@ -943,7 +970,7 @@ impl Workspace { ) -> Option> where V: 'static + View, - F: FnOnce(&mut ViewContext, &mut Self) -> ViewHandle, + F: FnOnce(&mut Self, &mut ViewContext) -> ViewHandle, { cx.notify(); // Whatever modal was visible is getting clobbered. If its the same type as V, then return @@ -953,7 +980,7 @@ impl Workspace { cx.focus_self(); Some(already_open_modal) } else { - let modal = add_view(cx, self); + let modal = add_view(self, cx); cx.focus(&modal); self.modal = Some(modal.into()); None @@ -971,6 +998,32 @@ impl Workspace { } } + pub fn show_notification( + &mut self, + notification: ViewHandle, + cx: &mut ViewContext, + ) { + cx.subscribe(¬ification, |this, handle, event, cx| { + if handle.read(cx).should_dismiss_notification_on_event(event) { + this.dismiss_notification(handle.id(), cx); + } + }) + .detach(); + self.notifications.push(Box::new(notification)); + cx.notify(); + } + + fn dismiss_notification(&mut self, id: usize, cx: &mut ViewContext) { + self.notifications.retain(|handle| { + if handle.id() == id { + cx.notify(); + false + } else { + true + } + }); + } + pub fn items<'a>( &'a self, cx: &'a AppContext, @@ -1049,7 +1102,7 @@ impl Workspace { }; let active_item = sidebar.update(cx, |sidebar, cx| { sidebar.toggle_item(action.item_index, cx); - sidebar.active_item().cloned() + sidebar.active_item().map(|item| item.to_any()) }); if let Some(active_item) = active_item { cx.focus(active_item); @@ -1070,7 +1123,7 @@ impl Workspace { }; let active_item = sidebar.update(cx, |sidebar, cx| { sidebar.activate_item(action.item_index, cx); - sidebar.active_item().cloned() + sidebar.active_item().map(|item| item.to_any()) }); if let Some(active_item) = active_item { if active_item.is_focused(cx) { @@ -1703,6 +1756,30 @@ impl Workspace { } } + fn render_notifications(&self, theme: &theme::Workspace) -> Option { + if self.notifications.is_empty() { + None + } else { + Some( + Flex::column() + .with_children(self.notifications.iter().map(|notification| { + ChildView::new(notification.as_ref()) + .contained() + .with_style(theme.notification) + .boxed() + })) + .constrained() + .with_width(theme.notifications.width) + .contained() + .with_style(theme.notifications.container) + .aligned() + .bottom() + .right() + .boxed(), + ) + } + } + // RPC handlers async fn handle_follow( @@ -2037,6 +2114,7 @@ impl View for Workspace { .top() .boxed() })) + .with_children(self.render_notifications(&theme.workspace)) .flex(1.0, true) .boxed(), ) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 77e400e02f96d276002018c63048a1fd133d5b2d..d4938501b81c04ee329765056857a9266cecedd8 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -172,7 +172,8 @@ pub fn build_workspace( }); let project_panel = ProjectPanel::new(project, cx); - let contact_panel = cx.add_view(|cx| ContactsPanel::new(app_state.clone(), cx)); + let contact_panel = + cx.add_view(|cx| ContactsPanel::new(app_state.clone(), workspace.weak_handle(), cx)); workspace.left_sidebar().update(cx, |sidebar, cx| { sidebar.add_item("icons/folder-tree-solid-14.svg", project_panel.into(), cx) diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index 0da6ada222d77ba1ec2cdbe7b1446ef76535cf05..84835970279e89de515547d6232bcf3dd647ffbf 100644 --- a/styles/src/styleTree/app.ts +++ b/styles/src/styleTree/app.ts @@ -10,6 +10,7 @@ import search from "./search"; import picker from "./picker"; import workspace from "./workspace"; import projectDiagnostics from "./projectDiagnostics"; +import contactNotification from "./contactNotification"; export const panel = { padding: { top: 12, left: 12, bottom: 12, right: 12 }, @@ -32,6 +33,7 @@ export default function app(theme: Theme): Object { padding: { left: 6, }, - } + }, + contactNotification: contactNotification(theme), }; } diff --git a/styles/src/styleTree/contactNotification.ts b/styles/src/styleTree/contactNotification.ts new file mode 100644 index 0000000000000000000000000000000000000000..09360f2f9118876070a0700392f6fc2756b9e5c4 --- /dev/null +++ b/styles/src/styleTree/contactNotification.ts @@ -0,0 +1,44 @@ +import Theme from "../themes/theme"; +import { backgroundColor, iconColor, text } from "./components"; + +const avatarSize = 12; +const headerPadding = 8; + +export default function contactNotification(theme: Theme): Object { + return { + headerAvatar: { + height: avatarSize, + width: avatarSize, + cornerRadius: 6, + }, + headerMessage: { + ...text(theme, "sans", "primary", { size: "xs" }), + margin: { left: headerPadding, right: headerPadding } + }, + headerHeight: 18, + bodyMessage: { + ...text(theme, "sans", "secondary", { size: "xs" }), + margin: { left: avatarSize + headerPadding, top: 6, bottom: 6 }, + }, + button: { + ...text(theme, "sans", "primary", { size: "xs" }), + background: backgroundColor(theme, "on300"), + padding: 4, + cornerRadius: 6, + margin: { left: 6 }, + hover: { + background: backgroundColor(theme, "on300", "hovered") + } + }, + dismissButton: { + color: iconColor(theme, "secondary"), + iconWidth: 8, + iconHeight: 8, + buttonWidth: 8, + buttonHeight: 8, + hover: { + color: iconColor(theme, "primary") + } + } + } +} \ No newline at end of file diff --git a/styles/src/styleTree/statusBar.ts b/styles/src/styleTree/statusBar.ts index 621b77639ea5f7a89f78d3756811fff7d41b6101..c7b7c6a0a35b3138e77d648b042344f98c507ff2 100644 --- a/styles/src/styleTree/statusBar.ts +++ b/styles/src/styleTree/statusBar.ts @@ -1,8 +1,8 @@ import Theme from "../themes/theme"; import { backgroundColor, border, iconColor, text } from "./components"; +import { workspaceBackground } from "./workspace"; export default function statusBar(theme: Theme) { - const statusContainer = { cornerRadius: 6, padding: { top: 3, bottom: 3, left: 6, right: 6 } @@ -100,6 +100,13 @@ export default function statusBar(theme: Theme) { iconColor: iconColor(theme, "active"), background: backgroundColor(theme, 300, "active"), } + }, + badge: { + cornerRadius: 3, + padding: 2, + margin: { bottom: -1, right: -1 }, + border: { width: 1, color: workspaceBackground(theme) }, + background: iconColor(theme, "feature"), } } } diff --git a/styles/src/styleTree/workspace.ts b/styles/src/styleTree/workspace.ts index f74715ac0b928d3162a526e3b3ee18617e7efd37..65564f5cbc35299fd49e39656e97370798312084 100644 --- a/styles/src/styleTree/workspace.ts +++ b/styles/src/styleTree/workspace.ts @@ -1,12 +1,16 @@ import Theme from "../themes/theme"; -import { backgroundColor, border, iconColor, text } from "./components"; +import { backgroundColor, border, iconColor, shadow, text } from "./components"; import statusBar from "./statusBar"; +export function workspaceBackground(theme: Theme) { + return backgroundColor(theme, 300) +} + export default function workspace(theme: Theme) { const tab = { height: 32, - background: backgroundColor(theme, 300), + background: workspaceBackground(theme), iconClose: iconColor(theme, "muted"), iconCloseActive: iconColor(theme, "active"), iconConflict: iconColor(theme, "warning"), @@ -146,5 +150,17 @@ export default function workspace(theme: Theme) { ...text(theme, "sans", "active"), background: "#000000aa", }, + notification: { + margin: { top: 10 }, + background: backgroundColor(theme, 300), + cornerRadius: 6, + padding: 12, + border: border(theme, "primary"), + shadow: shadow(theme), + }, + notifications: { + width: 380, + margin: { right: 10, bottom: 10 }, + } }; }