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 fa837acb6a..13eb8f2473 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": { @@ -1639,5 +1679,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 6090e856b6..097d2755aa 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": { @@ -1639,5 +1679,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 70d58fc640..c4e33765ce 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": { @@ -1639,5 +1679,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 3a3e5e2628..1cff4df3ad 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": { @@ -1639,5 +1679,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 008b07ed96..8606c78ca0 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": { @@ -1639,5 +1679,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 60ac66e5ed..fb8e781ea0 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": { @@ -1639,5 +1679,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 7add13add7..607cecf990 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": { @@ -1639,5 +1679,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 169d4a5bfa..d66382331a 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": { @@ -1639,5 +1679,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 bb835c6640..460e01c527 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 1874822774..84254da73a 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 4bb61c3404..056f94ecfe 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 c48ac3b83a..1e7384c2c3 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?; + 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)); - } - 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())?; - } + 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 {})?; @@ -7245,7 +7266,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 52cf2b2628..07103204e5 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 b6058c01e5..f724cc19a6 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 5a480911d4..18e17a93d9 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 0000000000..6369f70ce0 --- /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 caa2b34143..6fc8cbb7ce 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,15 +10,18 @@ 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::menu::{Confirm, SelectNext, SelectPrev}; -use workspace::{AppState, JoinProject}; +use workspace::{ + menu::{Confirm, SelectNext, SelectPrev}, + sidebar::SidebarItem, + AppState, JoinProject, Workspace, +}; impl_actions!( contacts_panel, @@ -65,6 +70,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); @@ -75,7 +81,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()), @@ -93,6 +103,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(); @@ -382,7 +413,7 @@ impl ContactsPanel { is_selected: bool, cx: &mut LayoutContext, ) -> ElementBox { - enum Reject {} + enum Decline {} enum Accept {} enum Cancel {} @@ -413,13 +444,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() @@ -463,7 +494,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() @@ -707,6 +738,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) @@ -824,11 +865,16 @@ mod tests { use gpui::TestAppContext; use language::LanguageRegistry; use theme::ThemeRegistry; + use workspace::WorkspaceParams; #[gpui::test] async fn test_contact_panel(cx: &mut TestAppContext) { let (app_state, server) = init(cx).await; - let panel = cx.add_view(0, |cx| ContactsPanel::new(app_state.clone(), cx)); + let workspace_params = cx.update(WorkspaceParams::test); + let workspace = cx.add_view(0, |cx| Workspace::new(&workspace_params, cx)); + let panel = cx.add_view(0, |cx| { + ContactsPanel::new(app_state.clone(), workspace.downgrade(), cx) + }); let get_users_request = server.receive::().await.unwrap(); server @@ -865,6 +911,7 @@ mod tests { proto::Contact { user_id: 3, online: true, + should_notify: false, projects: vec![proto::ProjectMetadata { id: 101, worktree_root_names: vec!["dir1".to_string()], @@ -875,6 +922,7 @@ mod tests { proto::Contact { user_id: 4, online: true, + should_notify: false, projects: vec![proto::ProjectMetadata { id: 102, worktree_root_names: vec!["dir2".to_string()], @@ -885,6 +933,7 @@ mod tests { proto::Contact { user_id: 5, online: false, + should_notify: false, projects: vec![], }, ], diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index a63ff7b0bd..e85147d7e2 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 3f8aa933ba..9e2c79c5dc 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 90b2123163..afe24127b5 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 5658cf2011..f5057ba39d 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 61c97f281d..639d7b44d9 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 157ea8ef73..5322a8924a 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 8adba5fc80..12ff05c757 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 5ec5c25914..c6ad68ace6 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)] @@ -350,6 +361,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 1904ed89d9..718268788c 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/util/src/test/marked_text.rs b/crates/util/src/test/marked_text.rs index 23aa2d5806..23ac35ce86 100644 --- a/crates/util/src/test/marked_text.rs +++ b/crates/util/src/test/marked_text.rs @@ -61,12 +61,12 @@ pub fn marked_text_ranges(full_marked_text: &str) -> (String, Vec>) let (range_marked_text, empty_offsets) = marked_text(full_marked_text); let (unmarked, range_lookup) = marked_text_ranges_by(&range_marked_text, vec![('[', ']'), ('(', ')'), ('<', '>')]); - ( - unmarked, - range_lookup - .into_values() - .flatten() - .chain(empty_offsets.into_iter().map(|offset| offset..offset)) - .collect(), - ) + let mut combined_ranges: Vec<_> = range_lookup + .into_values() + .flatten() + .chain(empty_offsets.into_iter().map(|offset| offset..offset)) + .collect(); + + combined_ranges.sort_by_key(|range| range.start); + (unmarked, combined_ranges) } diff --git a/crates/workspace/src/sidebar.rs b/crates/workspace/src/sidebar.rs index c9cbcbb4fb..366c74e43f 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 d5b0bf2ed5..21d5581640 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 77e400e02f..d4938501b8 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/dist/dark.json b/styles/dist/dark.json index c1be80296e..dac975e8f0 100644 --- a/styles/dist/dark.json +++ b/styles/dist/dark.json @@ -332,6 +332,11 @@ "description": "Step: 900", "type": "color" }, + "onMedia": { + "value": "#0707071a", + "description": "Step: 875", + "type": "color" + }, "ok": { "value": "#1b944726", "description": "Step: 600", diff --git a/styles/dist/light.json b/styles/dist/light.json index 166e00731e..806c50240e 100644 --- a/styles/dist/light.json +++ b/styles/dist/light.json @@ -332,6 +332,11 @@ "description": "Step: 250", "type": "color" }, + "onMedia": { + "value": "#b8b8b84d", + "description": "Step: 250", + "type": "color" + }, "ok": { "value": "#1b944726", "description": "Step: 600", diff --git a/styles/dist/solarized-dark.json b/styles/dist/solarized-dark.json index 862d5afbe8..c59a9dffad 100644 --- a/styles/dist/solarized-dark.json +++ b/styles/dist/solarized-dark.json @@ -271,6 +271,10 @@ "value": "#657b83", "type": "color" }, + "onMedia": { + "value": "#002b361a", + "type": "color" + }, "ok": { "value": "#85990026", "type": "color" diff --git a/styles/dist/solarized-light.json b/styles/dist/solarized-light.json index 993519c8f1..21e8e4ba6f 100644 --- a/styles/dist/solarized-light.json +++ b/styles/dist/solarized-light.json @@ -271,6 +271,10 @@ "value": "#839496", "type": "color" }, + "onMedia": { + "value": "#fdf6e31a", + "type": "color" + }, "ok": { "value": "#85990026", "type": "color" diff --git a/styles/dist/tokens.json b/styles/dist/tokens.json index 5a556b36a3..9ed6998725 100644 --- a/styles/dist/tokens.json +++ b/styles/dist/tokens.json @@ -1514,6 +1514,11 @@ "description": "Step: 900", "type": "color" }, + "onMedia": { + "value": "#0707071a", + "description": "Step: 875", + "type": "color" + }, "ok": { "value": "#1b944726", "description": "Step: 600", @@ -2207,6 +2212,11 @@ "description": "Step: 250", "type": "color" }, + "onMedia": { + "value": "#b8b8b84d", + "description": "Step: 250", + "type": "color" + }, "ok": { "value": "#1b944726", "description": "Step: 600", @@ -2839,6 +2849,10 @@ "value": "#657b83", "type": "color" }, + "onMedia": { + "value": "#002b361a", + "type": "color" + }, "ok": { "value": "#85990026", "type": "color" @@ -3406,6 +3420,10 @@ "value": "#839496", "type": "color" }, + "onMedia": { + "value": "#fdf6e31a", + "type": "color" + }, "ok": { "value": "#85990026", "type": "color" diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index 8e5a12d9b8..9822f6766e 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, bottom: 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 0000000000..09360f2f91 --- /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 621b77639e..c7b7c6a0a3 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 f74715ac0b..65564f5cbc 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 }, + } }; } diff --git a/styles/src/themes/base16.ts b/styles/src/themes/base16.ts index eb67f5f8f2..98f1fb4878 100644 --- a/styles/src/themes/base16.ts +++ b/styles/src/themes/base16.ts @@ -82,6 +82,7 @@ export function createTheme(name: string, isLight: boolean, neutral: ColorToken[ muted: neutral[3], focused: neutral[3], active: neutral[3], + onMedia: withOpacity(neutral[0], 0.1), ok: withOpacity(accent.green, 0.15), error: withOpacity(accent.red, 0.15), warning: withOpacity(accent.yellow, 0.15), diff --git a/styles/src/themes/dark.ts b/styles/src/themes/dark.ts index 3dcf9ea691..77eb493591 100644 --- a/styles/src/themes/dark.ts +++ b/styles/src/themes/dark.ts @@ -65,6 +65,7 @@ const borderColor = { muted: colors.neutral[675], focused: colors.indigo[500], active: colors.neutral[900], + onMedia: withOpacity(colors.neutral[875], 0.1), ok: withOpacity(colors.green[600], 0.15), error: withOpacity(colors.red[500], 0.15), warning: withOpacity(colors.amber[400], 0.15), diff --git a/styles/src/themes/light.ts b/styles/src/themes/light.ts index c17aaf5807..a244267f46 100644 --- a/styles/src/themes/light.ts +++ b/styles/src/themes/light.ts @@ -65,6 +65,7 @@ const borderColor = { muted: colors.neutral[100], focused: colors.indigo[500], active: colors.neutral[250], + onMedia: withOpacity(colors.neutral[250], 0.3), ok: withOpacity(colors.green[600], 0.15), error: withOpacity(colors.red[500], 0.15), warning: withOpacity(colors.amber[400], 0.15), diff --git a/styles/src/themes/theme.ts b/styles/src/themes/theme.ts index aa422e0330..7113ee555c 100644 --- a/styles/src/themes/theme.ts +++ b/styles/src/themes/theme.ts @@ -87,6 +87,10 @@ export default interface Theme { muted: ColorToken; focused: ColorToken; active: ColorToken; + /** + * Used for rendering borders on top of media like avatars, images, video, etc. + */ + onMedia: ColorToken; ok: ColorToken; error: ColorToken; warning: ColorToken;