Merge pull request #2205 from zed-industries/call-ui-follow-up

Antonio Scandurra created

Refine new call UI

Change summary

.github/workflows/ci.yml                     |   4 
Cargo.lock                                   |   1 
assets/icons/ellipsis_14.svg                 |   3 
crates/call/src/room.rs                      |  45 +-
crates/client/src/client.rs                  |  22 +
crates/collab/src/db.rs                      |  33 -
crates/collab/src/tests/integration_tests.rs |   8 
crates/collab_ui/Cargo.toml                  |   1 
crates/collab_ui/src/collab_titlebar_item.rs | 339 +++++++++------------
crates/context_menu/src/context_menu.rs      |  45 ++
crates/theme/src/theme.rs                    |   1 
styles/src/styleTree/workspace.ts            |   5 
12 files changed, 252 insertions(+), 255 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -19,7 +19,9 @@ env:
 jobs:
   rustfmt:
     name: Check formatting
-    runs-on: self-hosted
+    runs-on:
+      - self-hosted
+      - test
     steps:
       - name: Install Rust
         run: |

Cargo.lock 🔗

@@ -1257,6 +1257,7 @@ dependencies = [
  "client",
  "clock",
  "collections",
+ "context_menu",
  "editor",
  "futures 0.3.25",
  "fuzzy",

assets/icons/ellipsis_14.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="4" viewBox="0 0 14 4" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.125 2C3.125 2.62132 2.62132 3.125 2 3.125C1.37868 3.125 0.875 2.62132 0.875 2C0.875 1.37868 1.37868 0.875 2 0.875C2.62132 0.875 3.125 1.37868 3.125 2ZM8.125 2C8.125 2.62132 7.62132 3.125 7 3.125C6.37868 3.125 5.875 2.62132 5.875 2C5.875 1.37868 6.37868 0.875 7 0.875C7.62132 0.875 8.125 1.37868 8.125 2ZM12 3.125C12.6213 3.125 13.125 2.62132 13.125 2C13.125 1.37868 12.6213 0.875 12 0.875C11.3787 0.875 10.875 1.37868 10.875 2C10.875 2.62132 11.3787 3.125 12 3.125Z" fill="#ABB2BF"/>
+</svg>

crates/call/src/room.rs 🔗

@@ -277,14 +277,12 @@ impl Room {
     ) -> Result<()> {
         let mut client_status = client.status();
         loop {
-            let is_connected = client_status
-                .next()
-                .await
-                .map_or(false, |s| s.is_connected());
-
+            let _ = client_status.try_recv();
+            let is_connected = client_status.borrow().is_connected();
             // Even if we're initially connected, any future change of the status means we momentarily disconnected.
             if !is_connected || client_status.next().await.is_some() {
                 log::info!("detected client disconnection");
+
                 this.upgrade(&cx)
                     .ok_or_else(|| anyhow!("room was dropped"))?
                     .update(&mut cx, |this, cx| {
@@ -298,12 +296,7 @@ impl Room {
                     let client_reconnection = async {
                         let mut remaining_attempts = 3;
                         while remaining_attempts > 0 {
-                            log::info!(
-                                "waiting for client status change, remaining attempts {}",
-                                remaining_attempts
-                            );
-                            let Some(status) = client_status.next().await else { break };
-                            if status.is_connected() {
+                            if client_status.borrow().is_connected() {
                                 log::info!("client reconnected, attempting to rejoin room");
 
                                 let Some(this) = this.upgrade(&cx) else { break };
@@ -317,7 +310,15 @@ impl Room {
                                 } else {
                                     remaining_attempts -= 1;
                                 }
+                            } else if client_status.borrow().is_signed_out() {
+                                return false;
                             }
+
+                            log::info!(
+                                "waiting for client status change, remaining attempts {}",
+                                remaining_attempts
+                            );
+                            client_status.next().await;
                         }
                         false
                     }
@@ -339,18 +340,20 @@ impl Room {
                     }
                 }
 
-                // The client failed to re-establish a connection to the server
-                // or an error occurred while trying to re-join the room. Either way
-                // we leave the room and return an error.
-                if let Some(this) = this.upgrade(&cx) {
-                    log::info!("reconnection failed, leaving room");
-                    let _ = this.update(&mut cx, |this, cx| this.leave(cx));
-                }
-                return Err(anyhow!(
-                    "can't reconnect to room: client failed to re-establish connection"
-                ));
+                break;
             }
         }
+
+        // The client failed to re-establish a connection to the server
+        // or an error occurred while trying to re-join the room. Either way
+        // we leave the room and return an error.
+        if let Some(this) = this.upgrade(&cx) {
+            log::info!("reconnection failed, leaving room");
+            let _ = this.update(&mut cx, |this, cx| this.leave(cx));
+        }
+        Err(anyhow!(
+            "can't reconnect to room: client failed to re-establish connection"
+        ))
     }
 
     fn rejoin(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {

crates/client/src/client.rs 🔗

@@ -66,7 +66,7 @@ pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
 pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100);
 pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
 
-actions!(client, [Authenticate]);
+actions!(client, [Authenticate, SignOut]);
 
 pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
     cx.add_global_action({
@@ -79,6 +79,16 @@ pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
             .detach();
         }
     });
+    cx.add_global_action({
+        let client = client.clone();
+        move |_: &SignOut, cx| {
+            let client = client.clone();
+            cx.spawn(|cx| async move {
+                client.disconnect(&cx);
+            })
+            .detach();
+        }
+    });
 }
 
 pub struct Client {
@@ -169,6 +179,10 @@ impl Status {
     pub fn is_connected(&self) -> bool {
         matches!(self, Self::Connected { .. })
     }
+
+    pub fn is_signed_out(&self) -> bool {
+        matches!(self, Self::SignedOut | Self::UpgradeRequired)
+    }
 }
 
 struct ClientState {
@@ -1152,11 +1166,9 @@ impl Client {
         })
     }
 
-    pub fn disconnect(self: &Arc<Self>, cx: &AsyncAppContext) -> Result<()> {
-        let conn_id = self.connection_id()?;
-        self.peer.disconnect(conn_id);
+    pub fn disconnect(self: &Arc<Self>, cx: &AsyncAppContext) {
+        self.peer.teardown();
         self.set_status(Status::SignedOut, cx);
-        Ok(())
     }
 
     fn connection_id(&self) -> Result<ConnectionId> {

crates/collab/src/db.rs 🔗

@@ -1724,8 +1724,8 @@ impl Database {
         leader_connection: ConnectionId,
         follower_connection: ConnectionId,
     ) -> Result<RoomGuard<proto::Room>> {
-        self.room_transaction(|tx| async move {
-            let room_id = self.room_id_for_project(project_id, &*tx).await?;
+        let room_id = self.room_id_for_project(project_id).await?;
+        self.room_transaction(room_id, |tx| async move {
             follower::ActiveModel {
                 room_id: ActiveValue::set(room_id),
                 project_id: ActiveValue::set(project_id),
@@ -1742,7 +1742,8 @@ impl Database {
             .insert(&*tx)
             .await?;
 
-            Ok((room_id, self.get_room(room_id, &*tx).await?))
+            let room = self.get_room(room_id, &*tx).await?;
+            Ok(room)
         })
         .await
     }
@@ -1753,8 +1754,8 @@ impl Database {
         leader_connection: ConnectionId,
         follower_connection: ConnectionId,
     ) -> Result<RoomGuard<proto::Room>> {
-        self.room_transaction(|tx| async move {
-            let room_id = self.room_id_for_project(project_id, &*tx).await?;
+        let room_id = self.room_id_for_project(project_id).await?;
+        self.room_transaction(room_id, |tx| async move {
             follower::Entity::delete_many()
                 .filter(
                     Condition::all()
@@ -1776,30 +1777,12 @@ impl Database {
                 .exec(&*tx)
                 .await?;
 
-            Ok((room_id, self.get_room(room_id, &*tx).await?))
+            let room = self.get_room(room_id, &*tx).await?;
+            Ok(room)
         })
         .await
     }
 
-    async fn room_id_for_project(
-        &self,
-        project_id: ProjectId,
-        tx: &DatabaseTransaction,
-    ) -> Result<RoomId> {
-        #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
-        enum QueryAs {
-            RoomId,
-        }
-
-        Ok(project::Entity::find_by_id(project_id)
-            .select_only()
-            .column(project::Column::RoomId)
-            .into_values::<_, QueryAs>()
-            .one(&*tx)
-            .await?
-            .ok_or_else(|| anyhow!("no such project"))?)
-    }
-
     pub async fn update_room_participant_location(
         &self,
         room_id: RoomId,

crates/collab/src/tests/integration_tests.rs 🔗

@@ -1083,7 +1083,7 @@ async fn test_calls_on_multiple_connections(
     assert!(incoming_call_b2.next().await.unwrap().is_none());
 
     // User B disconnects the client that is not on the call. Everything should be fine.
-    client_b1.disconnect(&cx_b1.to_async()).unwrap();
+    client_b1.disconnect(&cx_b1.to_async());
     deterministic.advance_clock(RECEIVE_TIMEOUT);
     client_b1
         .authenticate_and_connect(false, &cx_b1.to_async())
@@ -3227,7 +3227,7 @@ async fn test_leaving_project(
     buffer_b2.read_with(cx_b, |buffer, _| assert_eq!(buffer.text(), "a-contents"));
 
     // Drop client B's connection and ensure client A and client C observe client B leaving.
-    client_b.disconnect(&cx_b.to_async()).unwrap();
+    client_b.disconnect(&cx_b.to_async());
     deterministic.advance_clock(RECONNECT_TIMEOUT);
     project_a.read_with(cx_a, |project, _| {
         assert_eq!(project.collaborators().len(), 1);
@@ -5772,7 +5772,7 @@ async fn test_contact_requests(
         .is_empty());
 
     async fn disconnect_and_reconnect(client: &TestClient, cx: &mut TestAppContext) {
-        client.disconnect(&cx.to_async()).unwrap();
+        client.disconnect(&cx.to_async());
         client.clear_contacts(cx).await;
         client
             .authenticate_and_connect(false, &cx.to_async())
@@ -6186,7 +6186,7 @@ async fn test_following(
     );
 
     // Following interrupts when client B disconnects.
-    client_b.disconnect(&cx_b.to_async()).unwrap();
+    client_b.disconnect(&cx_b.to_async());
     deterministic.advance_clock(RECONNECT_TIMEOUT);
     assert_eq!(
         workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),

crates/collab_ui/Cargo.toml 🔗

@@ -27,6 +27,7 @@ call = { path = "../call" }
 client = { path = "../client" }
 clock = { path = "../clock" }
 collections = { path = "../collections" }
+context_menu = { path = "../context_menu" }
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -4,9 +4,10 @@ use crate::{
     ToggleScreenSharing,
 };
 use call::{ActiveCall, ParticipantLocation, Room};
-use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore};
+use client::{proto::PeerId, Authenticate, ContactEventKind, SignOut, User, UserStore};
 use clock::ReplicaId;
 use contacts_popover::ContactsPopover;
+use context_menu::{ContextMenu, ContextMenuItem};
 use gpui::{
     actions,
     color::Color,
@@ -28,8 +29,9 @@ actions!(
     [
         ToggleCollaboratorList,
         ToggleContactsMenu,
+        ToggleUserMenu,
         ShareProject,
-        UnshareProject
+        UnshareProject,
     ]
 );
 
@@ -38,25 +40,20 @@ impl_internal_actions!(collab, [LeaveCall]);
 #[derive(Copy, Clone, PartialEq)]
 pub(crate) struct LeaveCall;
 
-#[derive(PartialEq, Eq)]
-enum ContactsPopoverSide {
-    Left,
-    Right,
-}
-
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(CollabTitlebarItem::toggle_collaborator_list_popover);
     cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
     cx.add_action(CollabTitlebarItem::share_project);
     cx.add_action(CollabTitlebarItem::unshare_project);
     cx.add_action(CollabTitlebarItem::leave_call);
+    cx.add_action(CollabTitlebarItem::toggle_user_menu);
 }
 
 pub struct CollabTitlebarItem {
     workspace: WeakViewHandle<Workspace>,
     user_store: ModelHandle<UserStore>,
     contacts_popover: Option<ViewHandle<ContactsPopover>>,
-    contacts_popover_side: ContactsPopoverSide,
+    user_menu: ViewHandle<ContextMenu>,
     collaborator_list_popover: Option<ViewHandle<CollaboratorListPopover>>,
     _subscriptions: Vec<Subscription>,
 }
@@ -90,9 +87,9 @@ impl View for CollabTitlebarItem {
         }
 
         let theme = cx.global::<Settings>().theme.clone();
-        let user = workspace.read(cx).user_store().read(cx).current_user();
 
         let mut left_container = Flex::row();
+        let mut right_container = Flex::row();
 
         left_container.add_child(
             Label::new(project_title, theme.workspace.titlebar.title.clone())
@@ -103,41 +100,31 @@ impl View for CollabTitlebarItem {
                 .boxed(),
         );
 
-        if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
-            left_container.add_child(self.render_current_user(&workspace, &theme, &user, cx));
-            left_container.add_children(self.render_collaborators(&workspace, &theme, room, cx));
-            left_container.add_child(self.render_toggle_contacts_button(&theme, cx));
-        }
-
-        let mut right_container = Flex::row();
+        let user = workspace.read(cx).user_store().read(cx).current_user();
+        let peer_id = workspace.read(cx).client().peer_id();
+        if let Some(((user, peer_id), room)) = user
+            .zip(peer_id)
+            .zip(ActiveCall::global(cx).read(cx).room().cloned())
+        {
+            left_container
+                .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
 
-        if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
-            right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
-            right_container.add_child(self.render_leave_call_button(&theme, cx));
+            right_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx));
             right_container
-                .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
-        } else {
-            right_container.add_child(self.render_outside_call_share_button(&theme, cx));
+                .add_child(self.render_current_user(&workspace, &theme, &user, peer_id, cx));
+            right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
         }
 
-        right_container.add_children(self.render_connection_status(&workspace, cx));
-
-        if let Some(user) = user {
-            //TODO: Add style
-            right_container.add_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.workspace.titlebar.title.clone(),
-                )
-                .aligned()
-                .contained()
-                .with_margin_left(theme.workspace.titlebar.item_spacing)
-                .boxed(),
-            );
+        let status = workspace.read(cx).client().status();
+        let status = &*status.borrow();
+        if matches!(status, client::Status::Connected { .. }) {
+            right_container.add_child(self.render_toggle_contacts_button(&theme, cx));
         } else {
-            right_container.add_child(Self::render_authenticate(&theme, cx));
+            right_container.add_children(self.render_connection_status(status, cx));
         }
 
+        right_container.add_child(self.render_user_menu_button(&theme, cx));
+
         Stack::new()
             .with_child(left_container.boxed())
             .with_child(right_container.aligned().right().boxed())
@@ -186,7 +173,11 @@ impl CollabTitlebarItem {
             workspace: workspace.downgrade(),
             user_store: user_store.clone(),
             contacts_popover: None,
-            contacts_popover_side: ContactsPopoverSide::Right,
+            user_menu: cx.add_view(|cx| {
+                let mut menu = ContextMenu::new(cx);
+                menu.set_position_mode(OverlayPositionMode::Local);
+                menu
+            }),
             collaborator_list_popover: None,
             _subscriptions: subscriptions,
         }
@@ -278,12 +269,6 @@ impl CollabTitlebarItem {
                     cx.notify();
                 })
                 .detach();
-
-                self.contacts_popover_side = match ActiveCall::global(cx).read(cx).room() {
-                    Some(_) => ContactsPopoverSide::Left,
-                    None => ContactsPopoverSide::Right,
-                };
-
                 self.contacts_popover = Some(view);
             }
         }
@@ -291,6 +276,59 @@ impl CollabTitlebarItem {
         cx.notify();
     }
 
+    pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
+        let theme = cx.global::<Settings>().theme.clone();
+        let avatar_style = theme.workspace.titlebar.avatar.clone();
+        let item_style = theme.context_menu.item.disabled_style().clone();
+        self.user_menu.update(cx, |user_menu, cx| {
+            let items = if let Some(user) = self.user_store.read(cx).current_user() {
+                vec![
+                    ContextMenuItem::Static(Box::new(move |_| {
+                        Flex::row()
+                            .with_children(user.avatar.clone().map(|avatar| {
+                                Self::render_face(
+                                    avatar,
+                                    avatar_style.clone(),
+                                    Color::transparent_black(),
+                                )
+                            }))
+                            .with_child(
+                                Label::new(user.github_login.clone(), item_style.label.clone())
+                                    .boxed(),
+                            )
+                            .contained()
+                            .with_style(item_style.container)
+                            .boxed()
+                    })),
+                    ContextMenuItem::Item {
+                        label: "Sign out".into(),
+                        action: Box::new(SignOut),
+                    },
+                ]
+            } else {
+                vec![ContextMenuItem::Item {
+                    label: "Sign in".into(),
+                    action: Box::new(Authenticate),
+                }]
+            };
+
+            user_menu.show(
+                vec2f(
+                    theme
+                        .workspace
+                        .titlebar
+                        .user_menu_button
+                        .default
+                        .button_width,
+                    theme.workspace.titlebar.height,
+                ),
+                AnchorCorner::TopRight,
+                items,
+                cx,
+            );
+        });
+    }
+
     fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
         ActiveCall::global(cx)
             .update(cx, |call, cx| call.hang_up(cx))
@@ -328,11 +366,9 @@ impl CollabTitlebarItem {
         Stack::new()
             .with_child(
                 MouseEventHandler::<ToggleContactsMenu>::new(0, cx, |state, _| {
-                    let style = titlebar.toggle_contacts_button.style_for(
-                        state,
-                        self.contacts_popover.is_some()
-                            && self.contacts_popover_side == ContactsPopoverSide::Left,
-                    );
+                    let style = titlebar
+                        .toggle_contacts_button
+                        .style_for(state, self.contacts_popover.is_some());
                     Svg::new("icons/plus_8.svg")
                         .with_color(style.color)
                         .constrained()
@@ -360,11 +396,7 @@ impl CollabTitlebarItem {
                 .boxed(),
             )
             .with_children(badge)
-            .with_children(self.render_contacts_popover_host(
-                ContactsPopoverSide::Left,
-                titlebar,
-                cx,
-            ))
+            .with_children(self.render_contacts_popover_host(titlebar, cx))
             .boxed()
     }
 
@@ -414,40 +446,6 @@ impl CollabTitlebarItem {
         .boxed()
     }
 
-    fn render_leave_call_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
-        let titlebar = &theme.workspace.titlebar;
-
-        MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
-            let style = titlebar.call_control.style_for(state, false);
-            Svg::new("icons/leave_12.svg")
-                .with_color(style.color)
-                .constrained()
-                .with_width(style.icon_width)
-                .aligned()
-                .constrained()
-                .with_width(style.button_width)
-                .with_height(style.button_width)
-                .contained()
-                .with_style(style.container)
-                .boxed()
-        })
-        .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(MouseButton::Left, move |_, cx| {
-            cx.dispatch_action(LeaveCall);
-        })
-        .with_tooltip::<LeaveCall, _>(
-            0,
-            "Leave call".to_owned(),
-            Some(Box::new(LeaveCall)),
-            theme.tooltip.clone(),
-            cx,
-        )
-        .contained()
-        .with_margin_left(theme.workspace.titlebar.item_spacing)
-        .aligned()
-        .boxed()
-    }
-
     fn render_in_call_share_unshare_button(
         &self,
         workspace: &ViewHandle<Workspace>,
@@ -475,11 +473,9 @@ impl CollabTitlebarItem {
                 .with_child(
                     MouseEventHandler::<ShareUnshare>::new(0, cx, |state, _| {
                         //TODO: Ensure this button has consistant width for both text variations
-                        let style = titlebar.share_button.style_for(
-                            state,
-                            self.contacts_popover.is_some()
-                                && self.contacts_popover_side == ContactsPopoverSide::Right,
-                        );
+                        let style = titlebar
+                            .share_button
+                            .style_for(state, self.contacts_popover.is_some());
                         Label::new(label, style.text.clone())
                             .contained()
                             .with_style(style.container)
@@ -502,11 +498,6 @@ impl CollabTitlebarItem {
                     )
                     .boxed(),
                 )
-                .with_children(self.render_contacts_popover_host(
-                    ContactsPopoverSide::Right,
-                    titlebar,
-                    cx,
-                ))
                 .aligned()
                 .contained()
                 .with_margin_left(theme.workspace.titlebar.item_spacing)
@@ -514,83 +505,71 @@ impl CollabTitlebarItem {
         )
     }
 
-    fn render_outside_call_share_button(
-        &self,
-        theme: &Theme,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        let tooltip = "Share project with new call";
+    fn render_user_menu_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
         let titlebar = &theme.workspace.titlebar;
 
-        enum OutsideCallShare {}
         Stack::new()
             .with_child(
-                MouseEventHandler::<OutsideCallShare>::new(0, cx, |state, _| {
-                    //TODO: Ensure this button has consistant width for both text variations
-                    let style = titlebar.share_button.style_for(
-                        state,
-                        self.contacts_popover.is_some()
-                            && self.contacts_popover_side == ContactsPopoverSide::Right,
-                    );
-                    Label::new("Share".to_owned(), style.text.clone())
+                MouseEventHandler::<ToggleUserMenu>::new(0, cx, |state, _| {
+                    let style = titlebar.call_control.style_for(state, false);
+                    Svg::new("icons/ellipsis_14.svg")
+                        .with_color(style.color)
+                        .constrained()
+                        .with_width(style.icon_width)
+                        .aligned()
+                        .constrained()
+                        .with_width(style.button_width)
+                        .with_height(style.button_width)
                         .contained()
                         .with_style(style.container)
                         .boxed()
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(ToggleContactsMenu);
+                    cx.dispatch_action(ToggleUserMenu);
                 })
-                .with_tooltip::<OutsideCallShare, _>(
+                .with_tooltip::<ToggleUserMenu, _>(
                     0,
-                    tooltip.to_owned(),
-                    None,
+                    "Toggle user menu".to_owned(),
+                    Some(Box::new(ToggleUserMenu)),
                     theme.tooltip.clone(),
                     cx,
                 )
+                .contained()
+                .with_margin_left(theme.workspace.titlebar.item_spacing)
+                .aligned()
                 .boxed(),
             )
-            .with_children(self.render_contacts_popover_host(
-                ContactsPopoverSide::Right,
-                titlebar,
-                cx,
-            ))
-            .aligned()
-            .contained()
-            .with_margin_left(theme.workspace.titlebar.item_spacing)
+            .with_child(ChildView::new(&self.user_menu, cx).boxed())
             .boxed()
     }
 
     fn render_contacts_popover_host<'a>(
         &'a self,
-        side: ContactsPopoverSide,
         theme: &'a theme::Titlebar,
         cx: &'a RenderContext<Self>,
-    ) -> impl Iterator<Item = ElementBox> + 'a {
-        self.contacts_popover
-            .iter()
-            .filter(move |_| self.contacts_popover_side == side)
-            .map(|popover| {
-                Overlay::new(
-                    ChildView::new(popover, cx)
-                        .contained()
-                        .with_margin_top(theme.height)
-                        .with_margin_left(theme.toggle_contacts_button.default.button_width)
-                        .with_margin_right(-theme.toggle_contacts_button.default.button_width)
-                        .boxed(),
-                )
-                .with_fit_mode(OverlayFitMode::SwitchAnchor)
-                .with_anchor_corner(AnchorCorner::BottomLeft)
-                .with_z_index(999)
-                .boxed()
-            })
+    ) -> Option<ElementBox> {
+        self.contacts_popover.as_ref().map(|popover| {
+            Overlay::new(
+                ChildView::new(popover, cx)
+                    .contained()
+                    .with_margin_top(theme.height)
+                    .with_margin_left(theme.toggle_contacts_button.default.button_width)
+                    .with_margin_right(-theme.toggle_contacts_button.default.button_width)
+                    .boxed(),
+            )
+            .with_fit_mode(OverlayFitMode::SwitchAnchor)
+            .with_anchor_corner(AnchorCorner::BottomLeft)
+            .with_z_index(999)
+            .boxed()
+        })
     }
 
     fn render_collaborators(
         &self,
         workspace: &ViewHandle<Workspace>,
         theme: &Theme,
-        room: ModelHandle<Room>,
+        room: &ModelHandle<Room>,
         cx: &mut RenderContext<Self>,
     ) -> Vec<ElementBox> {
         let project = workspace.read(cx).project().read(cx);
@@ -622,7 +601,7 @@ impl CollabTitlebarItem {
                         theme,
                         cx,
                     ))
-                    .with_margin_left(theme.workspace.titlebar.face_pile_spacing)
+                    .with_margin_right(theme.workspace.titlebar.face_pile_spacing)
                     .boxed(),
                 )
             })
@@ -633,35 +612,21 @@ impl CollabTitlebarItem {
         &self,
         workspace: &ViewHandle<Workspace>,
         theme: &Theme,
-        user: &Option<Arc<User>>,
+        user: &Arc<User>,
+        peer_id: PeerId,
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
-        let user = user.as_ref().expect("Active call without user");
         let replica_id = workspace.read(cx).project().read(cx).replica_id();
-        let peer_id = workspace
-            .read(cx)
-            .client()
-            .peer_id()
-            .expect("Active call without peer id");
-        self.render_face_pile(user, Some(replica_id), peer_id, None, workspace, theme, cx)
-    }
-
-    fn render_authenticate(theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
-        MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
-            let style = theme
-                .workspace
-                .titlebar
-                .sign_in_prompt
-                .style_for(state, false);
-            Label::new("Sign in", style.text.clone())
-                .contained()
-                .with_style(style.container)
-                .with_margin_left(theme.workspace.titlebar.item_spacing)
-                .boxed()
-        })
-        .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
-        .with_cursor_style(CursorStyle::PointingHand)
-        .aligned()
+        Container::new(self.render_face_pile(
+            user,
+            Some(replica_id),
+            peer_id,
+            None,
+            workspace,
+            theme,
+            cx,
+        ))
+        .with_margin_right(theme.workspace.titlebar.item_spacing)
         .boxed()
     }
 
@@ -717,7 +682,7 @@ impl CollabTitlebarItem {
             }
         }
 
-        let content = Stack::new()
+        let mut content = Stack::new()
             .with_children(user.avatar.as_ref().map(|avatar| {
                 let face_pile = FacePile::new(theme.workspace.titlebar.follower_avatar_overlap)
                     .with_child(Self::render_face(
@@ -789,7 +754,10 @@ impl CollabTitlebarItem {
 
         if let Some(location) = location {
             if let Some(replica_id) = replica_id {
-                MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
+                content =
+                    MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| {
+                        content
+                    })
                     .with_cursor_style(CursorStyle::PointingHand)
                     .on_click(MouseButton::Left, move |_, cx| {
                         cx.dispatch_action(ToggleFollow(peer_id))
@@ -805,12 +773,14 @@ impl CollabTitlebarItem {
                         theme.tooltip.clone(),
                         cx,
                     )
-                    .boxed()
+                    .boxed();
             } else if let ParticipantLocation::SharedProject { project_id } = location {
                 let user_id = user.id;
-                MouseEventHandler::<JoinProject>::new(peer_id.as_u64() as usize, cx, move |_, _| {
-                    content
-                })
+                content = MouseEventHandler::<JoinProject>::new(
+                    peer_id.as_u64() as usize,
+                    cx,
+                    move |_, _| content,
+                )
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, move |_, cx| {
                     cx.dispatch_action(JoinProject {
@@ -825,13 +795,10 @@ impl CollabTitlebarItem {
                     theme.tooltip.clone(),
                     cx,
                 )
-                .boxed()
-            } else {
-                content
+                .boxed();
             }
-        } else {
-            content
         }
+        content
     }
 
     fn render_face(
@@ -854,13 +821,13 @@ impl CollabTitlebarItem {
 
     fn render_connection_status(
         &self,
-        workspace: &ViewHandle<Workspace>,
+        status: &client::Status,
         cx: &mut RenderContext<Self>,
     ) -> Option<ElementBox> {
         enum ConnectionStatusButton {}
 
         let theme = &cx.global::<Settings>().theme.clone();
-        match &*workspace.read(cx).client().status().borrow() {
+        match status {
             client::Status::ConnectionError
             | client::Status::ConnectionLost
             | client::Status::Reauthenticating { .. }

crates/context_menu/src/context_menu.rs 🔗

@@ -5,7 +5,9 @@ use gpui::{
 };
 use menu::*;
 use settings::Settings;
-use std::{any::TypeId, time::Duration};
+use std::{any::TypeId, borrow::Cow, time::Duration};
+
+pub type StaticItem = Box<dyn Fn(&mut MutableAppContext) -> ElementBox>;
 
 #[derive(Copy, Clone, PartialEq)]
 struct Clicked;
@@ -24,16 +26,17 @@ pub fn init(cx: &mut MutableAppContext) {
 
 pub enum ContextMenuItem {
     Item {
-        label: String,
+        label: Cow<'static, str>,
         action: Box<dyn Action>,
     },
+    Static(StaticItem),
     Separator,
 }
 
 impl ContextMenuItem {
-    pub fn item(label: impl ToString, action: impl 'static + Action) -> Self {
+    pub fn item(label: impl Into<Cow<'static, str>>, action: impl 'static + Action) -> Self {
         Self::Item {
-            label: label.to_string(),
+            label: label.into(),
             action: Box::new(action),
         }
     }
@@ -42,14 +45,14 @@ impl ContextMenuItem {
         Self::Separator
     }
 
-    fn is_separator(&self) -> bool {
-        matches!(self, Self::Separator)
+    fn is_action(&self) -> bool {
+        matches!(self, Self::Item { .. })
     }
 
     fn action_id(&self) -> Option<TypeId> {
         match self {
             ContextMenuItem::Item { action, .. } => Some(action.id()),
-            ContextMenuItem::Separator => None,
+            ContextMenuItem::Static(..) | ContextMenuItem::Separator => None,
         }
     }
 }
@@ -58,6 +61,7 @@ pub struct ContextMenu {
     show_count: usize,
     anchor_position: Vector2F,
     anchor_corner: AnchorCorner,
+    position_mode: OverlayPositionMode,
     items: Vec<ContextMenuItem>,
     selected_index: Option<usize>,
     visible: bool,
@@ -105,6 +109,7 @@ impl View for ContextMenu {
             .with_fit_mode(OverlayFitMode::SnapToWindow)
             .with_anchor_position(self.anchor_position)
             .with_anchor_corner(self.anchor_corner)
+            .with_position_mode(self.position_mode)
             .boxed()
     }
 
@@ -121,6 +126,7 @@ impl ContextMenu {
             show_count: 0,
             anchor_position: Default::default(),
             anchor_corner: AnchorCorner::TopLeft,
+            position_mode: OverlayPositionMode::Window,
             items: Default::default(),
             selected_index: Default::default(),
             visible: Default::default(),
@@ -188,13 +194,13 @@ impl ContextMenu {
     }
 
     fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
-        self.selected_index = self.items.iter().position(|item| !item.is_separator());
+        self.selected_index = self.items.iter().position(|item| item.is_action());
         cx.notify();
     }
 
     fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
         for (ix, item) in self.items.iter().enumerate().rev() {
-            if !item.is_separator() {
+            if item.is_action() {
                 self.selected_index = Some(ix);
                 cx.notify();
                 break;
@@ -205,7 +211,7 @@ impl ContextMenu {
     fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
         if let Some(ix) = self.selected_index {
             for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
-                if !item.is_separator() {
+                if item.is_action() {
                     self.selected_index = Some(ix);
                     cx.notify();
                     break;
@@ -219,7 +225,7 @@ impl ContextMenu {
     fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
         if let Some(ix) = self.selected_index {
             for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
-                if !item.is_separator() {
+                if item.is_action() {
                     self.selected_index = Some(ix);
                     cx.notify();
                     break;
@@ -234,7 +240,7 @@ impl ContextMenu {
         &mut self,
         anchor_position: Vector2F,
         anchor_corner: AnchorCorner,
-        items: impl IntoIterator<Item = ContextMenuItem>,
+        items: Vec<ContextMenuItem>,
         cx: &mut ViewContext<Self>,
     ) {
         let mut items = items.into_iter().peekable();
@@ -254,6 +260,10 @@ impl ContextMenu {
         cx.notify();
     }
 
+    pub fn set_position_mode(&mut self, mode: OverlayPositionMode) {
+        self.position_mode = mode;
+    }
+
     fn render_menu_for_measurement(&self, cx: &mut RenderContext<Self>) -> impl Element {
         let window_id = cx.window_id();
         let style = cx.global::<Settings>().theme.context_menu.clone();
@@ -273,6 +283,9 @@ impl ContextMenu {
                                     .with_style(style.container)
                                     .boxed()
                             }
+
+                            ContextMenuItem::Static(f) => f(cx),
+
                             ContextMenuItem::Separator => Empty::new()
                                 .collapsed()
                                 .contained()
@@ -302,6 +315,9 @@ impl ContextMenu {
                                 )
                                 .boxed()
                             }
+
+                            ContextMenuItem::Static(_) => Empty::new().boxed(),
+
                             ContextMenuItem::Separator => Empty::new()
                                 .collapsed()
                                 .constrained()
@@ -339,7 +355,7 @@ impl ContextMenu {
 
                                 Flex::row()
                                     .with_child(
-                                        Label::new(label.to_string(), style.label.clone())
+                                        Label::new(label.clone(), style.label.clone())
                                             .contained()
                                             .boxed(),
                                     )
@@ -366,6 +382,9 @@ impl ContextMenu {
                             .on_drag(MouseButton::Left, |_, _| {})
                             .boxed()
                         }
+
+                        ContextMenuItem::Static(f) => f(cx),
+
                         ContextMenuItem::Separator => Empty::new()
                             .constrained()
                             .with_height(1.)

crates/theme/src/theme.rs 🔗

@@ -88,6 +88,7 @@ pub struct Titlebar {
     pub share_button: Interactive<ContainedText>,
     pub call_control: Interactive<IconButton>,
     pub toggle_contacts_button: Interactive<IconButton>,
+    pub user_menu_button: Interactive<IconButton>,
     pub toggle_contacts_badge: ContainerStyle,
 }
 

styles/src/styleTree/workspace.ts 🔗

@@ -197,6 +197,11 @@ export default function workspace(colorScheme: ColorScheme) {
           color: foreground(layer, "variant", "hovered"),
         },
       },
+      userMenuButton: {
+        buttonWidth: 20,
+        iconWidth: 12,
+        ...titlebarButton,
+      },
       toggleContactsBadge: {
         cornerRadius: 3,
         padding: 2,