Start a call when clicking on a contact in the contacts popover

Nathan Sobo and Antonio Scandurra created

Co-Authored-By: Antonio Scandurra <antonio@zed.dev>

Change summary

Cargo.lock                                              |   2 
crates/collab/src/integration_tests.rs                  |  20 
crates/collab_titlebar_item/Cargo.toml                  |   3 
crates/collab_titlebar_item/src/collab_titlebar_item.rs |   3 
crates/collab_titlebar_item/src/contacts_popover.rs     | 163 +++++++++-
crates/gpui/src/app.rs                                  |  11 
crates/room/Cargo.toml                                  |   3 
crates/room/src/room.rs                                 |  74 ++++
crates/zed/src/zed.rs                                   |   3 
9 files changed, 229 insertions(+), 53 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1078,6 +1078,7 @@ dependencies = [
  "menu",
  "postage",
  "project",
+ "room",
  "serde",
  "settings",
  "theme",
@@ -4461,6 +4462,7 @@ dependencies = [
  "futures",
  "gpui",
  "project",
+ "util",
 ]
 
 [[package]]

crates/collab/src/integration_tests.rs 🔗

@@ -83,7 +83,7 @@ async fn test_basic_calls(
         .await;
 
     let room_a = cx_a
-        .update(|cx| Room::create(client_a.clone(), cx))
+        .update(|cx| Room::create(client_a.clone(), client_a.user_store.clone(), cx))
         .await
         .unwrap();
     assert_eq!(
@@ -125,7 +125,7 @@ async fn test_basic_calls(
 
     // User B joins the room using the first client.
     let room_b = cx_b
-        .update(|cx| Room::join(&call_b, client_b.clone(), cx))
+        .update(|cx| Room::join(&call_b, client_b.clone(), client_b.user_store.clone(), cx))
         .await
         .unwrap();
     assert!(incoming_call_b.next().await.unwrap().is_none());
@@ -229,7 +229,7 @@ async fn test_leaving_room_on_disconnection(
         .await;
 
     let room_a = cx_a
-        .update(|cx| Room::create(client_a.clone(), cx))
+        .update(|cx| Room::create(client_a.clone(), client_a.user_store.clone(), cx))
         .await
         .unwrap();
 
@@ -245,7 +245,7 @@ async fn test_leaving_room_on_disconnection(
     // User B receives the call and joins the room.
     let call_b = incoming_call_b.next().await.unwrap().unwrap();
     let room_b = cx_b
-        .update(|cx| Room::join(&call_b, client_b.clone(), cx))
+        .update(|cx| Room::join(&call_b, client_b.clone(), client_b.user_store.clone(), cx))
         .await
         .unwrap();
     deterministic.run_until_parked();
@@ -6284,17 +6284,9 @@ async fn room_participants(
             .collect::<Vec<_>>()
     });
     let remote_users = futures::future::try_join_all(remote_users).await.unwrap();
-    let pending_users = room.update(cx, |room, cx| {
-        room.pending_user_ids()
-            .iter()
-            .map(|user_id| {
-                client
-                    .user_store
-                    .update(cx, |users, cx| users.get_user(*user_id, cx))
-            })
-            .collect::<Vec<_>>()
+    let pending_users = room.read_with(cx, |room, _| {
+        room.pending_users().iter().cloned().collect::<Vec<_>>()
     });
-    let pending_users = futures::future::try_join_all(pending_users).await.unwrap();
 
     RoomParticipants {
         remote: remote_users

crates/collab_titlebar_item/Cargo.toml 🔗

@@ -14,6 +14,7 @@ test-support = [
     "editor/test-support",
     "gpui/test-support",
     "project/test-support",
+    "room/test-support",
     "settings/test-support",
     "util/test-support",
     "workspace/test-support",
@@ -28,6 +29,7 @@ fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 menu = { path = "../menu" }
 project = { path = "../project" }
+room = { path = "../room" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }
@@ -44,6 +46,7 @@ collections = { path = "../collections", features = ["test-support"] }
 editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
+room = { path = "../room", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }

crates/collab_titlebar_item/src/collab_titlebar_item.rs 🔗

@@ -71,8 +71,9 @@ impl CollabTitlebarItem {
             Some(_) => {}
             None => {
                 if let Some(workspace) = self.workspace.upgrade(cx) {
+                    let client = workspace.read(cx).client().clone();
                     let user_store = workspace.read(cx).user_store().clone();
-                    let view = cx.add_view(|cx| ContactsPopover::new(user_store, cx));
+                    let view = cx.add_view(|cx| ContactsPopover::new(client, user_store, cx));
                     cx.focus(&view);
                     cx.subscribe(&view, |this, _, event, cx| {
                         match event {

crates/collab_titlebar_item/src/contacts_popover.rs 🔗

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use client::{Contact, User, UserStore};
+use client::{Client, Contact, User, UserStore};
 use editor::{Cancel, Editor};
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
@@ -9,10 +9,11 @@ use gpui::{
     ViewHandle,
 };
 use menu::{Confirm, SelectNext, SelectPrev};
+use room::Room;
 use settings::Settings;
 use theme::IconButton;
 
-impl_internal_actions!(contacts_panel, [ToggleExpanded]);
+impl_internal_actions!(contacts_panel, [ToggleExpanded, Call]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ContactsPopover::clear_filter);
@@ -20,11 +21,17 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ContactsPopover::select_prev);
     cx.add_action(ContactsPopover::confirm);
     cx.add_action(ContactsPopover::toggle_expanded);
+    cx.add_action(ContactsPopover::call);
 }
 
 #[derive(Clone, PartialEq)]
 struct ToggleExpanded(Section);
 
+#[derive(Clone, PartialEq)]
+struct Call {
+    recipient_user_id: u64,
+}
+
 #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
 enum Section {
     Requests,
@@ -73,18 +80,24 @@ pub enum Event {
 }
 
 pub struct ContactsPopover {
+    room: Option<(ModelHandle<Room>, Subscription)>,
     entries: Vec<ContactEntry>,
     match_candidates: Vec<StringMatchCandidate>,
     list_state: ListState,
+    client: Arc<Client>,
     user_store: ModelHandle<UserStore>,
     filter_editor: ViewHandle<Editor>,
     collapsed_sections: Vec<Section>,
     selection: Option<usize>,
-    _maintain_contacts: Subscription,
+    _subscriptions: Vec<Subscription>,
 }
 
 impl ContactsPopover {
-    pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
+    pub fn new(
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
         let filter_editor = cx.add_view(|cx| {
             let mut editor = Editor::single_line(
                 Some(|theme| theme.contacts_panel.user_query_editor.clone()),
@@ -143,25 +156,52 @@ impl ContactsPopover {
                     cx,
                 ),
                 ContactEntry::Contact(contact) => {
-                    Self::render_contact(&contact.user, &theme.contacts_panel, is_selected)
+                    Self::render_contact(contact, &theme.contacts_panel, is_selected, cx)
                 }
             }
         });
 
+        let mut subscriptions = Vec::new();
+        subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
+
+        let weak_self = cx.weak_handle();
+        subscriptions.push(Room::observe(cx, move |room, cx| {
+            if let Some(this) = weak_self.upgrade(cx) {
+                this.update(cx, |this, cx| this.set_room(room, cx));
+            }
+        }));
+
         let mut this = Self {
+            room: None,
             list_state,
             selection: None,
             collapsed_sections: Default::default(),
             entries: Default::default(),
             match_candidates: Default::default(),
             filter_editor,
-            _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)),
+            _subscriptions: subscriptions,
+            client,
             user_store,
         };
         this.update_entries(cx);
         this
     }
 
+    fn set_room(&mut self, room: Option<ModelHandle<Room>>, cx: &mut ViewContext<Self>) {
+        if let Some(room) = room {
+            let observation = cx.observe(&room, |this, room, cx| this.room_updated(room, cx));
+            self.room = Some((room, observation));
+        } else {
+            self.room = None;
+        }
+
+        cx.notify();
+    }
+
+    fn room_updated(&mut self, room: ModelHandle<Room>, cx: &mut ViewContext<Self>) {
+        cx.notify();
+    }
+
     fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
         let did_clear = self.filter_editor.update(cx, |editor, cx| {
             if editor.buffer().read(cx).len(cx) > 0 {
@@ -357,6 +397,43 @@ impl ContactsPopover {
         cx.notify();
     }
 
+    fn render_active_call(&self, cx: &mut RenderContext<Self>) -> Option<ElementBox> {
+        let (room, _) = self.room.as_ref()?;
+        let theme = &cx.global::<Settings>().theme.contacts_panel;
+
+        Some(
+            Flex::column()
+                .with_children(room.read(cx).pending_users().iter().map(|user| {
+                    Flex::row()
+                        .with_children(user.avatar.clone().map(|avatar| {
+                            Image::new(avatar)
+                                .with_style(theme.contact_avatar)
+                                .aligned()
+                                .left()
+                                .boxed()
+                        }))
+                        .with_child(
+                            Label::new(
+                                user.github_login.clone(),
+                                theme.contact_username.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.contact_username.container)
+                            .aligned()
+                            .left()
+                            .flex(1., true)
+                            .boxed(),
+                        )
+                        .constrained()
+                        .with_height(theme.row_height)
+                        .contained()
+                        .with_style(theme.contact_row.default)
+                        .boxed()
+                }))
+                .boxed(),
+        )
+    }
+
     fn render_header(
         section: Section,
         theme: &theme::ContactsPanel,
@@ -412,32 +489,46 @@ impl ContactsPopover {
         .boxed()
     }
 
-    fn render_contact(user: &User, theme: &theme::ContactsPanel, is_selected: bool) -> ElementBox {
-        Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.contact_avatar)
+    fn render_contact(
+        contact: &Contact,
+        theme: &theme::ContactsPanel,
+        is_selected: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let user_id = contact.user.id;
+        MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
+            Flex::row()
+                .with_children(contact.user.avatar.clone().map(|avatar| {
+                    Image::new(avatar)
+                        .with_style(theme.contact_avatar)
+                        .aligned()
+                        .left()
+                        .boxed()
+                }))
+                .with_child(
+                    Label::new(
+                        contact.user.github_login.clone(),
+                        theme.contact_username.text.clone(),
+                    )
+                    .contained()
+                    .with_style(theme.contact_username.container)
                     .aligned()
                     .left()
-                    .boxed()
-            }))
-            .with_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.contact_username.text.clone(),
+                    .flex(1., true)
+                    .boxed(),
                 )
+                .constrained()
+                .with_height(theme.row_height)
                 .contained()
-                .with_style(theme.contact_username.container)
-                .aligned()
-                .left()
-                .flex(1., true)
-                .boxed(),
-            )
-            .constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
-            .boxed()
+                .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
+                .boxed()
+        })
+        .on_click(MouseButton::Left, move |_, cx| {
+            cx.dispatch_action(Call {
+                recipient_user_id: user_id,
+            })
+        })
+        .boxed()
     }
 
     fn render_contact_request(
@@ -553,6 +644,21 @@ impl ContactsPopover {
             .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
             .boxed()
     }
+
+    fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
+        let client = self.client.clone();
+        let user_store = self.user_store.clone();
+        let recipient_user_id = action.recipient_user_id;
+        cx.spawn_weak(|_, mut cx| async move {
+            let room = cx
+                .update(|cx| Room::get_or_create(&client, &user_store, cx))
+                .await?;
+            room.update(&mut cx, |room, cx| room.call(recipient_user_id, cx))
+                .await?;
+            anyhow::Ok(())
+        })
+        .detach();
+    }
 }
 
 impl Entity for ContactsPopover {
@@ -606,6 +712,7 @@ impl View for ContactsPopover {
                     .with_height(theme.contacts_panel.user_query_editor_height)
                     .boxed(),
             )
+            .with_children(self.render_active_call(cx))
             .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
             .with_children(
                 self.user_store

crates/gpui/src/app.rs 🔗

@@ -1519,6 +1519,17 @@ impl MutableAppContext {
         }
     }
 
+    pub fn observe_default_global<G, F>(&mut self, observe: F) -> Subscription
+    where
+        G: Any + Default,
+        F: 'static + FnMut(&mut MutableAppContext),
+    {
+        if !self.has_global::<G>() {
+            self.set_global(G::default());
+        }
+        self.observe_global::<G, F>(observe)
+    }
+
     pub fn observe_release<E, H, F>(&mut self, handle: &H, callback: F) -> Subscription
     where
         E: Entity,

crates/room/Cargo.toml 🔗

@@ -13,6 +13,7 @@ test-support = [
     "collections/test-support",
     "gpui/test-support",
     "project/test-support",
+    "util/test-support"
 ]
 
 [dependencies]
@@ -20,6 +21,7 @@ client = { path = "../client" }
 collections = { path = "../collections" }
 gpui = { path = "../gpui" }
 project = { path = "../project" }
+util = { path = "../util" }
 
 anyhow = "1.0.38"
 futures = "0.3"
@@ -29,3 +31,4 @@ client = { path = "../client", features = ["test-support"] }
 collections = { path = "../collections", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }

crates/room/src/room.rs 🔗

@@ -1,13 +1,14 @@
 mod participant;
 
 use anyhow::{anyhow, Result};
-use client::{call::Call, proto, Client, PeerId, TypedEnvelope};
+use client::{call::Call, proto, Client, PeerId, TypedEnvelope, User, UserStore};
 use collections::HashMap;
 use futures::StreamExt;
 use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task};
 use participant::{LocalParticipant, ParticipantLocation, RemoteParticipant};
 use project::Project;
 use std::sync::Arc;
+use util::ResultExt;
 
 pub enum Event {
     PeerChangedActiveProject,
@@ -18,9 +19,11 @@ pub struct Room {
     status: RoomStatus,
     local_participant: LocalParticipant,
     remote_participants: HashMap<PeerId, RemoteParticipant>,
-    pending_user_ids: Vec<u64>,
+    pending_users: Vec<Arc<User>>,
     client: Arc<Client>,
+    user_store: ModelHandle<UserStore>,
     _subscriptions: Vec<client::Subscription>,
+    _load_pending_users: Option<Task<()>>,
 }
 
 impl Entity for Room {
@@ -28,7 +31,44 @@ impl Entity for Room {
 }
 
 impl Room {
-    fn new(id: u64, client: Arc<Client>, cx: &mut ModelContext<Self>) -> Self {
+    pub fn observe<F>(cx: &mut MutableAppContext, mut callback: F) -> gpui::Subscription
+    where
+        F: 'static + FnMut(Option<ModelHandle<Self>>, &mut MutableAppContext),
+    {
+        cx.observe_default_global::<Option<ModelHandle<Self>>, _>(move |cx| {
+            let room = cx.global::<Option<ModelHandle<Self>>>().clone();
+            callback(room, cx);
+        })
+    }
+
+    pub fn get_or_create(
+        client: &Arc<Client>,
+        user_store: &ModelHandle<UserStore>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<ModelHandle<Self>>> {
+        if let Some(room) = cx.global::<Option<ModelHandle<Self>>>() {
+            Task::ready(Ok(room.clone()))
+        } else {
+            let client = client.clone();
+            let user_store = user_store.clone();
+            cx.spawn(|mut cx| async move {
+                let room = cx.update(|cx| Room::create(client, user_store, cx)).await?;
+                cx.update(|cx| cx.set_global(Some(room.clone())));
+                Ok(room)
+            })
+        }
+    }
+
+    pub fn clear(cx: &mut MutableAppContext) {
+        cx.set_global::<Option<ModelHandle<Self>>>(None);
+    }
+
+    fn new(
+        id: u64,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
         let mut client_status = client.status();
         cx.spawn_weak(|this, mut cx| async move {
             let is_connected = client_status
@@ -51,32 +91,36 @@ impl Room {
                 projects: Default::default(),
             },
             remote_participants: Default::default(),
-            pending_user_ids: Default::default(),
+            pending_users: Default::default(),
             _subscriptions: vec![client.add_message_handler(cx.handle(), Self::handle_room_updated)],
+            _load_pending_users: None,
             client,
+            user_store,
         }
     }
 
     pub fn create(
         client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
         cx: &mut MutableAppContext,
     ) -> Task<Result<ModelHandle<Self>>> {
         cx.spawn(|mut cx| async move {
             let room = client.request(proto::CreateRoom {}).await?;
-            Ok(cx.add_model(|cx| Self::new(room.id, client, cx)))
+            Ok(cx.add_model(|cx| Self::new(room.id, client, user_store, cx)))
         })
     }
 
     pub fn join(
         call: &Call,
         client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
         cx: &mut MutableAppContext,
     ) -> Task<Result<ModelHandle<Self>>> {
         let room_id = call.room_id;
         cx.spawn(|mut cx| async move {
             let response = client.request(proto::JoinRoom { id: room_id }).await?;
             let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
-            let room = cx.add_model(|cx| Self::new(room_id, client, cx));
+            let room = cx.add_model(|cx| Self::new(room_id, client, user_store, cx));
             room.update(&mut cx, |room, cx| room.apply_room_update(room_proto, cx))?;
             Ok(room)
         })
@@ -98,8 +142,8 @@ impl Room {
         &self.remote_participants
     }
 
-    pub fn pending_user_ids(&self) -> &[u64] {
-        &self.pending_user_ids
+    pub fn pending_users(&self) -> &[Arc<User>] {
+        &self.pending_users
     }
 
     async fn handle_room_updated(
@@ -131,7 +175,19 @@ impl Room {
                 );
             }
         }
-        self.pending_user_ids = room.pending_user_ids;
+
+        let pending_users = self.user_store.update(cx, move |user_store, cx| {
+            user_store.get_users(room.pending_user_ids, cx)
+        });
+        self._load_pending_users = Some(cx.spawn(|this, mut cx| async move {
+            if let Some(pending_users) = pending_users.await.log_err() {
+                this.update(&mut cx, |this, cx| {
+                    this.pending_users = pending_users;
+                    cx.notify();
+                });
+            }
+        }));
+
         cx.notify();
         Ok(())
     }

crates/zed/src/zed.rs 🔗

@@ -21,10 +21,11 @@ use gpui::{
     geometry::vector::vec2f,
     impl_actions,
     platform::{WindowBounds, WindowOptions},
-    AssetSource, AsyncAppContext, TitlebarOptions, ViewContext, WindowKind,
+    AssetSource, AsyncAppContext, ModelHandle, TitlebarOptions, ViewContext, WindowKind,
 };
 use language::Rope;
 pub use lsp;
+use postage::watch;
 pub use project::{self, fs};
 use project_panel::ProjectPanel;
 use search::{BufferSearchBar, ProjectSearchBar};