Show guest only once even if they joined on two different windows

Antonio Scandurra and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                                  |  1 
crates/client/Cargo.toml                    |  4 ++
crates/client/src/user.rs                   | 26 +++++++++++++++-------
crates/contacts_panel/src/contacts_panel.rs |  7 -----
4 files changed, 23 insertions(+), 15 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -768,6 +768,7 @@ dependencies = [
  "anyhow",
  "async-recursion",
  "async-tungstenite",
+ "collections",
  "futures",
  "gpui",
  "image",

crates/client/Cargo.toml 🔗

@@ -8,9 +8,10 @@ path = "src/client.rs"
 doctest = false
 
 [features]
-test-support = ["gpui/test-support", "rpc/test-support"]
+test-support = ["collections/test-support", "gpui/test-support", "rpc/test-support"]
 
 [dependencies]
+collections = { path = "../collections" }
 gpui = { path = "../gpui" }
 util = { path = "../util" }
 rpc = { path = "../rpc" }
@@ -33,5 +34,6 @@ tiny_http = "0.8"
 url = "2.2"
 
 [dev-dependencies]
+collections = { path = "../collections", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }

crates/client/src/user.rs 🔗

@@ -1,13 +1,11 @@
 use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
 use anyhow::{anyhow, Context, Result};
+use collections::{hash_map::Entry, BTreeSet, HashMap, HashSet};
 use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
 use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
 use postage::{prelude::Stream, sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
-use std::{
-    collections::{hash_map::Entry, HashMap, HashSet},
-    sync::{Arc, Weak},
-};
+use std::sync::{Arc, Weak};
 use util::TryFutureExt as _;
 
 #[derive(Debug)]
@@ -17,6 +15,18 @@ pub struct User {
     pub avatar: Option<Arc<ImageData>>,
 }
 
+impl PartialOrd for User {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(&other))
+    }
+}
+
+impl Ord for User {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.github_login.cmp(&other.github_login)
+    }
+}
+
 impl PartialEq for User {
     fn eq(&self, other: &Self) -> bool {
         self.id == other.id && self.github_login == other.github_login
@@ -36,7 +46,7 @@ pub struct Contact {
 pub struct ProjectMetadata {
     pub id: u64,
     pub worktree_root_names: Vec<String>,
-    pub guests: Vec<Arc<User>>,
+    pub guests: BTreeSet<Arc<User>>,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -177,7 +187,7 @@ impl UserStore {
                     self.client.upgrade().unwrap().id,
                     message
                 );
-                let mut user_ids = HashSet::new();
+                let mut user_ids = HashSet::default();
                 for contact in &message.contacts {
                     user_ids.insert(contact.user_id);
                     user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
@@ -554,9 +564,9 @@ impl Contact {
             .await?;
         let mut projects = Vec::new();
         for project in contact.projects {
-            let mut guests = Vec::new();
+            let mut guests = BTreeSet::new();
             for participant_id in project.guests {
-                guests.push(
+                guests.insert(
                     user_store
                         .update(cx, |user_store, cx| {
                             user_store.fetch_user(participant_id, cx)

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -350,11 +350,6 @@ impl ContactsPanel {
         let project = &contact.projects[project_index];
         let project_id = project.id;
         let is_host = Some(contact.user.id) == current_user_id;
-        let is_guest = !is_host
-            && project
-                .guests
-                .iter()
-                .any(|guest| Some(guest.id) == current_user_id);
 
         let font_cache = cx.font_cache();
         let host_avatar_height = theme
@@ -447,7 +442,7 @@ impl ContactsPanel {
             CursorStyle::Arrow
         })
         .on_click(move |_, cx| {
-            if !is_host && !is_guest {
+            if !is_host {
                 cx.dispatch_global_action(JoinProject {
                     contact: contact.clone(),
                     project_index,