Wire up UI for requesting contacts and cancelling requests

Nathan Sobo and Max Brunsfeld created

Co-Authored-By: Max Brunsfeld <maxbrunsfeld@gmail.com>

Change summary

crates/client/src/user.rs                   | 98 +++++++++++++++++++---
crates/collab/src/db.rs                     | 32 +++++++
crates/collab/src/rpc.rs                    | 50 ++++++++++-
crates/contacts_panel/src/contacts_panel.rs | 48 +++++++++--
crates/rpc/proto/zed.proto                  | 13 ++
crates/rpc/src/proto.rs                     |  2 
crates/zed/src/main.rs                      |  1 
7 files changed, 212 insertions(+), 32 deletions(-)

Detailed changes

crates/client/src/user.rs 🔗

@@ -5,7 +5,7 @@ use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
 use postage::{prelude::Stream, sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
 use std::{
-    collections::{HashMap, HashSet},
+    collections::{hash_map::Entry, HashMap, HashSet},
     sync::{Arc, Weak},
 };
 use util::TryFutureExt as _;
@@ -31,6 +31,14 @@ pub struct ProjectMetadata {
     pub guests: Vec<Arc<User>>,
 }
 
+#[derive(Debug, Clone, Copy)]
+pub enum ContactRequestStatus {
+    None,
+    SendingRequest,
+    Requested,
+    RequestAccepted,
+}
+
 pub struct UserStore {
     users: HashMap<u64, Arc<User>>,
     update_contacts_tx: watch::Sender<Option<proto::UpdateContacts>>,
@@ -38,6 +46,7 @@ pub struct UserStore {
     contacts: Vec<Arc<Contact>>,
     incoming_contact_requests: Vec<Arc<User>>,
     outgoing_contact_requests: Vec<Arc<User>>,
+    pending_contact_requests: HashMap<u64, usize>,
     client: Weak<Client>,
     http: Arc<dyn HttpClient>,
     _maintain_contacts: Task<()>,
@@ -100,6 +109,7 @@ impl UserStore {
                     }
                 }
             }),
+            pending_contact_requests: Default::default(),
         }
     }
 
@@ -237,23 +247,85 @@ impl UserStore {
         &self.outgoing_contact_requests
     }
 
-    pub fn has_outgoing_contact_request(&self, user: &User) -> bool {
-        self.outgoing_contact_requests
-            .binary_search_by_key(&&user.github_login, |requested_user| {
-                &requested_user.github_login
-            })
+    pub fn contact_request_status(&self, user: &User) -> ContactRequestStatus {
+        if self
+            .contacts
+            .binary_search_by_key(&&user.id, |contact| &contact.user.id)
             .is_ok()
+        {
+            ContactRequestStatus::RequestAccepted
+        } else if self
+            .outgoing_contact_requests
+            .binary_search_by_key(&&user.id, |user| &user.id)
+            .is_ok()
+        {
+            ContactRequestStatus::Requested
+        } else if self.pending_contact_requests.contains_key(&user.id) {
+            ContactRequestStatus::SendingRequest
+        } else {
+            ContactRequestStatus::None
+        }
     }
 
-    pub fn request_contact(&self, responder_id: u64) -> impl Future<Output = Result<()>> {
+    pub fn request_contact(
+        &mut self,
+        responder_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
         let client = self.client.upgrade();
-        async move {
-            client
-                .ok_or_else(|| anyhow!("not logged in"))?
-                .request(proto::RequestContact { responder_id })
-                .await?;
+        *self
+            .pending_contact_requests
+            .entry(responder_id)
+            .or_insert(0) += 1;
+        cx.notify();
+
+        cx.spawn(|this, mut cx| async move {
+            let request = client
+                .ok_or_else(|| anyhow!("can't upgrade client reference"))?
+                .request(proto::RequestContact { responder_id });
+            request.await?;
+            this.update(&mut cx, |this, cx| {
+                if let Entry::Occupied(mut request_count) =
+                    this.pending_contact_requests.entry(responder_id)
+                {
+                    *request_count.get_mut() -= 1;
+                    if *request_count.get() == 0 {
+                        request_count.remove();
+                    }
+                }
+                cx.notify();
+            });
             Ok(())
-        }
+        })
+    }
+
+    pub fn remove_contact(
+        &mut self,
+        user_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.upgrade();
+        *self.pending_contact_requests.entry(user_id).or_insert(0) += 1;
+        cx.notify();
+
+        cx.spawn(|this, mut cx| async move {
+            let request = client
+                .ok_or_else(|| anyhow!("can't upgrade client reference"))?
+                .request(proto::RemoveContact { user_id });
+            request.await?;
+            this.update(&mut cx, |this, cx| {
+                if let Entry::Occupied(mut request_count) =
+                    this.pending_contact_requests.entry(user_id)
+                {
+                    *request_count.get_mut() -= 1;
+                    if *request_count.get() == 0 {
+                        request_count.remove();
+                    }
+                }
+                cx.notify();
+            });
+            Ok(())
+        })
     }
 
     pub fn respond_to_contact_request(

crates/collab/src/db.rs 🔗

@@ -19,6 +19,7 @@ pub trait Db: Send + Sync {
 
     async fn get_contacts(&self, id: UserId) -> Result<Contacts>;
     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(
         &self,
         responder_id: UserId,
@@ -267,6 +268,30 @@ impl Db for PostgresDb {
         }
     }
 
+    async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()> {
+        let (id_a, id_b, a_to_b) = if responder_id < requester_id {
+            (responder_id, requester_id, false)
+        } else {
+            (requester_id, responder_id, true)
+        };
+        let query = "
+            DELETE FROM contacts
+            WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3;
+        ";
+        let result = sqlx::query(query)
+            .bind(id_a.0)
+            .bind(id_b.0)
+            .bind(a_to_b)
+            .execute(&self.pool)
+            .await?;
+
+        if result.rows_affected() == 1 {
+            Ok(())
+        } else {
+            Err(anyhow!("no such contact"))
+        }
+    }
+
     async fn respond_to_contact_request(
         &self,
         responder_id: UserId,
@@ -1248,6 +1273,13 @@ pub mod tests {
             Ok(())
         }
 
+        async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()> {
+            self.contacts.lock().retain(|contact| {
+                !(contact.requester_id == requester_id && contact.responder_id == responder_id)
+            });
+            Ok(())
+        }
+
         async fn dismiss_contact_request(
             &self,
             responder_id: UserId,

crates/collab/src/rpc.rs 🔗

@@ -155,6 +155,7 @@ impl Server {
             .add_request_handler(Server::get_users)
             .add_request_handler(Server::fuzzy_search_users)
             .add_request_handler(Server::request_contact)
+            .add_request_handler(Server::remove_contact)
             .add_request_handler(Server::respond_to_contact_request)
             .add_request_handler(Server::join_channel)
             .add_message_handler(Server::leave_channel)
@@ -1048,6 +1049,43 @@ impl Server {
         Ok(())
     }
 
+    async fn remove_contact(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::RemoveContact>,
+        response: Response<proto::RemoveContact>,
+    ) -> Result<()> {
+        let requester_id = self
+            .store()
+            .await
+            .user_id_for_connection(request.sender_id)?;
+        let responder_id = UserId::from_proto(request.payload.user_id);
+        self.app_state
+            .db
+            .remove_contact(requester_id, responder_id)
+            .await?;
+
+        // Update outgoing contact requests of requester
+        let mut update = proto::UpdateContacts::default();
+        update
+            .remove_outgoing_requests
+            .push(responder_id.to_proto());
+        for connection_id in self.store().await.connection_ids_for_user(requester_id) {
+            self.peer.send(connection_id, update.clone())?;
+        }
+
+        // Update incoming contact requests of responder
+        let mut update = proto::UpdateContacts::default();
+        update
+            .remove_incoming_requests
+            .push(requester_id.to_proto());
+        for connection_id in self.store().await.connection_ids_for_user(responder_id) {
+            self.peer.send(connection_id, update.clone())?;
+        }
+
+        response.send(proto::Ack {})?;
+        Ok(())
+    }
+
     // #[instrument(skip(self, state, user_ids))]
     // fn update_contacts_for_users<'a>(
     //     self: &Arc<Self>,
@@ -5138,15 +5176,15 @@ mod tests {
         // User A and User C request that user B become their contact.
         client_a
             .user_store
-            .read_with(cx_a, |store, _| {
-                store.request_contact(client_b.user_id().unwrap())
+            .update(cx_a, |store, cx| {
+                store.request_contact(client_b.user_id().unwrap(), cx)
             })
             .await
             .unwrap();
         client_c
             .user_store
-            .read_with(cx_c, |store, _| {
-                store.request_contact(client_b.user_id().unwrap())
+            .update(cx_c, |store, cx| {
+                store.request_contact(client_b.user_id().unwrap(), cx)
             })
             .await
             .unwrap();
@@ -6460,8 +6498,8 @@ mod tests {
                 for (client_b, cx_b) in &mut clients {
                     client_a
                         .user_store
-                        .update(cx_a, |store, _| {
-                            store.request_contact(client_b.user_id().unwrap())
+                        .update(cx_a, |store, cx| {
+                            store.request_contact(client_b.user_id().unwrap(), cx)
                         })
                         .await
                         .unwrap();

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,4 +1,4 @@
-use client::{Contact, User, UserStore};
+use client::{Contact, ContactRequestStatus, User, UserStore};
 use editor::Editor;
 use fuzzy::StringMatchCandidate;
 use gpui::{
@@ -7,8 +7,8 @@ use gpui::{
     geometry::{rect::RectF, vector::vec2f},
     impl_actions,
     platform::CursorStyle,
-    Element, ElementBox, Entity, LayoutContext, ModelHandle, RenderContext, Subscription, Task,
-    View, ViewContext, ViewHandle,
+    Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext, RenderContext,
+    Subscription, Task, View, ViewContext, ViewHandle,
 };
 use serde::Deserialize;
 use settings::Settings;
@@ -16,7 +16,7 @@ use std::sync::Arc;
 use util::ResultExt;
 use workspace::{AppState, JoinProject};
 
-impl_actions!(contacts_panel, [RequestContact]);
+impl_actions!(contacts_panel, [RequestContact, RemoveContact]);
 
 pub struct ContactsPanel {
     list_state: ListState,
@@ -31,6 +31,14 @@ pub struct ContactsPanel {
 #[derive(Clone, Deserialize)]
 pub struct RequestContact(pub u64);
 
+#[derive(Clone, Deserialize)]
+pub struct RemoveContact(pub u64);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ContactsPanel::request_contact);
+    cx.add_action(ContactsPanel::remove_contact);
+}
+
 impl ContactsPanel {
     pub fn new(app_state: Arc<AppState>, cx: &mut ViewContext<Self>) -> Self {
         let user_query_editor = cx.add_view(|cx| {
@@ -295,7 +303,7 @@ impl ContactsPanel {
     ) -> ElementBox {
         enum RequestContactButton {}
 
-        let requested_contact = user_store.read(cx).has_outgoing_contact_request(&contact);
+        let request_status = user_store.read(cx).contact_request_status(&contact);
 
         Flex::row()
             .with_children(contact.avatar.clone().map(|avatar| {
@@ -321,7 +329,13 @@ impl ContactsPanel {
                     contact.id as usize,
                     cx,
                     |_, _| {
-                        let label = if requested_contact { "-" } else { "+" };
+                        let label = match request_status {
+                            ContactRequestStatus::None => "+",
+                            ContactRequestStatus::SendingRequest => "…",
+                            ContactRequestStatus::Requested => "-",
+                            ContactRequestStatus::RequestAccepted => unreachable!(),
+                        };
+
                         Label::new(label.to_string(), theme.edit_contact.text.clone())
                             .contained()
                             .with_style(theme.edit_contact.container)
@@ -330,12 +344,16 @@ impl ContactsPanel {
                             .boxed()
                     },
                 )
-                .on_click(move |_, cx| {
-                    if requested_contact {
-                    } else {
+                .on_click(move |_, cx| match request_status {
+                    ContactRequestStatus::None => {
                         cx.dispatch_action(RequestContact(contact.id));
                     }
+                    ContactRequestStatus::Requested => {
+                        cx.dispatch_action(RemoveContact(contact.id));
+                    }
+                    _ => {}
                 })
+                .with_cursor_style(CursorStyle::PointingHand)
                 .boxed(),
             )
             .constrained()
@@ -415,6 +433,18 @@ impl ContactsPanel {
             None
         }));
     }
+
+    fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
+        self.user_store
+            .update(cx, |store, cx| store.request_contact(request.0, cx))
+            .detach();
+    }
+
+    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
+        self.user_store
+            .update(cx, |store, cx| store.remove_contact(request.0, cx))
+            .detach();
+    }
 }
 
 pub enum Event {}

crates/rpc/proto/zed.proto 🔗

@@ -91,11 +91,12 @@ message Envelope {
         UsersResponse users_response = 78;
         RequestContact request_contact = 79;
         RespondToContactRequest respond_to_contact_request = 80;
+        RemoveContact remove_contact = 81;
 
-        Follow follow = 81;
-        FollowResponse follow_response = 82;
-        UpdateFollowers update_followers = 83;
-        Unfollow unfollow = 84;
+        Follow follow = 82;
+        FollowResponse follow_response = 83;
+        UpdateFollowers update_followers = 84;
+        Unfollow unfollow = 85;
     }
 }
 
@@ -553,6 +554,10 @@ message RequestContact {
     uint64 responder_id = 1;
 }
 
+message RemoveContact {
+    uint64 user_id = 1;
+}
+
 message RespondToContactRequest {
     uint64 requester_id = 1;
     ContactRequestResponse response = 2;

crates/rpc/src/proto.rs 🔗

@@ -82,6 +82,7 @@ messages!(
     (ApplyCompletionAdditionalEditsResponse, Background),
     (BufferReloaded, Foreground),
     (BufferSaved, Foreground),
+    (RemoveContact, Foreground),
     (ChannelMessageSent, Foreground),
     (CreateProjectEntry, Foreground),
     (DeleteProjectEntry, Foreground),
@@ -188,6 +189,7 @@ request_messages!(
     (RegisterWorktree, Ack),
     (ReloadBuffers, ReloadBuffersResponse),
     (RequestContact, Ack),
+    (RemoveContact, Ack),
     (RespondToContactRequest, Ack),
     (RenameProjectEntry, ProjectEntryResponse),
     (SaveBuffer, BufferSaved),

crates/zed/src/main.rs 🔗

@@ -146,6 +146,7 @@ fn main() {
         go_to_line::init(cx);
         file_finder::init(cx);
         chat_panel::init(cx);
+        contacts_panel::init(cx);
         outline::init(cx);
         project_symbols::init(cx);
         project_panel::init(cx);