assets/icons/reject.svg → assets/icons/decline.svg 🔗
Max Brunsfeld created
Notify when someone requests to add you as a contact or accepts your contact request
assets/icons/decline.svg | 0
assets/themes/cave-dark.json | 91 ++++
assets/themes/cave-light.json | 91 ++++
assets/themes/dark.json | 91 ++++
assets/themes/light.json | 91 ++++
assets/themes/solarized-dark.json | 91 ++++
assets/themes/solarized-light.json | 91 ++++
assets/themes/sulphurpool-dark.json | 91 ++++
assets/themes/sulphurpool-light.json | 91 ++++
crates/chat_panel/src/chat_panel.rs | 2
crates/client/src/user.rs | 91 +++
crates/collab/src/db.rs | 381 +++++++++++-----
crates/collab/src/rpc.rs | 117 +++--
crates/collab/src/rpc/store.rs | 45 +
crates/command_palette/src/command_palette.rs | 2
crates/contacts_panel/src/contact_finder.rs | 4
crates/contacts_panel/src/contact_notification.rs | 237 ++++++++++
crates/contacts_panel/src/contacts_panel.rs | 56 ++
crates/file_finder/src/file_finder.rs | 2
crates/go_to_line/src/go_to_line.rs | 2
crates/gpui/src/elements/empty.rs | 15
crates/outline/src/outline.rs | 2
crates/project_panel/src/project_panel.rs | 6
crates/project_symbols/src/project_symbols.rs | 2
crates/rpc/proto/zed.proto | 4
crates/theme/src/theme.rs | 21
crates/theme_selector/src/theme_selector.rs | 2
crates/workspace/src/sidebar.rs | 120 ++++-
crates/workspace/src/workspace.rs | 86 +++
crates/zed/src/zed.rs | 3
styles/src/styleTree/app.ts | 4
styles/src/styleTree/contactNotification.ts | 44 +
styles/src/styleTree/statusBar.ts | 9
styles/src/styleTree/workspace.ts | 20
34 files changed, 1,745 insertions(+), 260 deletions(-)
@@ -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"
+ }
+ }
}
}
@@ -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"
+ }
+ }
}
}
@@ -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"
+ }
+ }
}
}
@@ -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"
+ }
+ }
}
}
@@ -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"
+ }
+ }
}
}
@@ -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"
+ }
+ }
}
}
@@ -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"
+ }
+ }
}
}
@@ -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"
+ }
+ }
}
}
@@ -69,7 +69,7 @@ impl ChatPanel {
.with_style(move |cx| {
let theme = &cx.global::<Settings>().theme.chat_panel.channel_select;
SelectStyle {
- header: theme.header.container.clone(),
+ header: theme.header.container,
menu: theme.menu.clone(),
}
})
@@ -54,10 +54,21 @@ pub struct UserStore {
_maintain_current_user: Task<()>,
}
-pub enum Event {}
+#[derive(Clone)]
+pub struct ContactEvent {
+ pub user: Arc<User>,
+ 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<Self>,
+ ) -> Task<Result<()>> {
+ 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<T: RequestMessage>(
&mut self,
user_id: u64,
@@ -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<Contacts>;
+ async fn get_contacts(&self, id: UserId) -> Result<Vec<Contact>>;
+ async fn has_contact(&self, user_id_a: UserId, user_id_b: UserId) -> Result<bool>;
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<Contacts> {
+ async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> {
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<bool> {
+ 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<UserId>,
- pub incoming_requests: Vec<IncomingContactRequest>,
- pub outgoing_requests: Vec<UserId>,
+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<Contacts> {
+ async fn get_contacts(&self, id: UserId) -> Result<Vec<Contact>> {
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<bool> {
+ 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);
}
@@ -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<Server>, 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<Self>) -> gpui::ElementBox {
- gpui::Element::boxed(gpui::elements::Empty)
+ gpui::Element::boxed(gpui::elements::Empty::new())
}
}
}
@@ -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<db::Contact>,
+ ) -> 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,
}
}
@@ -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
});
@@ -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>) {
- 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
@@ -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<UserStore>,
+ 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<Self>) -> 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: &<Self as Entity>::Event) -> bool {
+ matches!(event, Event::Dismiss)
+ }
+}
+
+impl ContactNotification {
+ pub fn new(
+ event: ContactEvent,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ViewContext<Self>,
+ ) -> 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<Self>) -> ElementBox {
+ let theme = cx.global::<Settings>().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::<Decline, _, _>(
+ 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::<Accept, _, _>(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<Self>) -> ElementBox {
+ let theme = cx.global::<Settings>().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<Self>,
+ ) -> 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::<Dismiss, _, _>(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>) {
+ 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>,
+ ) {
+ self.user_store
+ .update(cx, |store, cx| {
+ store.respond_to_contact_request(action.user_id, action.accept, cx)
+ })
+ .detach();
+ }
+}
@@ -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<AppState>, cx: &mut ViewContext<Self>) -> Self {
+ pub fn new(
+ app_state: Arc<AppState>,
+ workspace: WeakViewHandle<Workspace>,
+ cx: &mut ViewContext<Self>,
+ ) -> 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::<Reject, _, _>(user.id as usize, cx, |mouse_state, _| {
+ MouseEventHandler::new::<Decline, _, _>(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)
@@ -85,7 +85,7 @@ impl FileFinder {
}
fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
- 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();
@@ -62,7 +62,7 @@ impl GoToLine {
.active_item(cx)
.and_then(|active_item| active_item.downcast::<Editor>())
{
- 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
@@ -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()
@@ -87,7 +87,7 @@ impl OutlineView {
.read(cx)
.outline(Some(cx.global::<Settings>().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
@@ -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::*;
@@ -71,7 +71,7 @@ impl ProjectSymbolsView {
}
fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
- 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();
@@ -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 {
@@ -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<SidebarItem>,
+ 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<ContainedText>,
+ pub dismiss_button: Interactive<IconButton>,
+}
+
#[derive(Clone, Deserialize, Default)]
pub struct Editor {
pub text_color: Color,
@@ -66,7 +66,7 @@ impl ThemeSelector {
fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
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
@@ -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<T> SidebarItemHandle for ViewHandle<T>
+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<AnyViewHandle> 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<dyn SidebarItemHandle>,
+ _observation: Subscription,
}
pub struct SidebarButtons {
@@ -58,13 +85,18 @@ impl Sidebar {
}
}
- pub fn add_item(
+ pub fn add_item<T: SidebarItem>(
&mut self,
icon_path: &'static str,
- view: AnyViewHandle,
+ view: ViewHandle<T>,
cx: &mut ViewContext<Self>,
) {
- 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<Self>) -> 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::<Vec<_>>();
Flex::row()
- .with_children(items.iter().enumerate().map(|(ix, item)| {
- MouseEventHandler::new::<Self, _, _>(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::<Self, _, _>(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()
@@ -604,6 +604,31 @@ impl<T: Item> WeakItemHandle for WeakViewHandle<T> {
}
}
+pub trait Notification: View {
+ fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool;
+}
+
+pub trait NotificationHandle {
+ fn id(&self) -> usize;
+ fn to_any(&self) -> AnyViewHandle;
+}
+
+impl<T: Notification> NotificationHandle for ViewHandle<T> {
+ fn id(&self) -> usize {
+ self.id()
+ }
+
+ fn to_any(&self) -> AnyViewHandle {
+ self.into()
+ }
+}
+
+impl Into<AnyViewHandle> for &dyn NotificationHandle {
+ fn into(self) -> AnyViewHandle {
+ self.to_any()
+ }
+}
+
#[derive(Clone)]
pub struct WorkspaceParams {
pub project: ModelHandle<Project>,
@@ -683,6 +708,7 @@ pub struct Workspace {
panes: Vec<ViewHandle<Pane>>,
active_pane: ViewHandle<Pane>,
status_bar: ViewHandle<StatusBar>,
+ notifications: Vec<Box<dyn NotificationHandle>>,
project: ModelHandle<Project>,
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<ViewHandle<V>>
where
V: 'static + View,
- F: FnOnce(&mut ViewContext<Self>, &mut Self) -> ViewHandle<V>,
+ F: FnOnce(&mut Self, &mut ViewContext<Self>) -> ViewHandle<V>,
{
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<V: Notification>(
+ &mut self,
+ notification: ViewHandle<V>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ 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>) {
+ 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<ElementBox> {
+ 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(),
)
@@ -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)
@@ -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),
};
}
@@ -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")
+ }
+ }
+ }
+}
@@ -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"),
}
}
}
@@ -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 },
+ }
};
}