guest promotion (#3969)

Conrad Irwin created

Release Notes:

- Adds the ability to promote read-only guests to read-write
participants in calls

Change summary

Cargo.lock                                     |   2 
crates/call/src/room.rs                        |  59 +++++
crates/collab/Cargo.toml                       |   2 
crates/collab/src/db/ids.rs                    |   2 
crates/collab/src/db/queries/projects.rs       |   2 
crates/collab/src/db/queries/rooms.rs          |  40 ++++
crates/collab/src/rpc.rs                       |  45 ++++
crates/collab/src/tests/channel_guest_tests.rs | 155 +++++++++++----
crates/collab/src/tests/channel_tests.rs       |   1 
crates/collab/src/tests/following_tests.rs     |  15 -
crates/collab/src/tests/integration_tests.rs   |   1 
crates/collab/src/tests/test_server.rs         |  63 ++++++
crates/collab_ui/src/collab_panel.rs           | 199 +++++++++++--------
crates/gpui/src/app/test_context.rs            |   8 
crates/language/src/buffer.rs                  |   6 
crates/live_kit_client/src/test.rs             |  53 +++++
crates/live_kit_server/src/api.rs              |  29 ++
crates/live_kit_server/src/live_kit_server.rs  |   2 
crates/multi_buffer/src/multi_buffer.rs        |   7 
crates/project/src/project.rs                  |  18 +
crates/rpc/proto/zed.proto                     |   9 
crates/rpc/src/proto.rs                        |   2 
crates/vim/src/test/vim_test_context.rs        |   1 
crates/workspace/src/notifications.rs          |  17 +
24 files changed, 577 insertions(+), 161 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1473,6 +1473,7 @@ dependencies = [
  "editor",
  "env_logger",
  "envy",
+ "file_finder",
  "fs",
  "futures 0.3.28",
  "git",
@@ -1486,6 +1487,7 @@ dependencies = [
  "live_kit_server",
  "log",
  "lsp",
+ "menu",
  "nanoid",
  "node_runtime",
  "notifications",

crates/call/src/room.rs 🔗

@@ -173,7 +173,11 @@ impl Room {
             cx.spawn(|this, mut cx| async move {
                 connect.await?;
 
-                if !cx.update(|cx| Self::mute_on_join(cx))? {
+                let is_read_only = this
+                    .update(&mut cx, |room, _| room.read_only())
+                    .unwrap_or(true);
+
+                if !cx.update(|cx| Self::mute_on_join(cx))? && !is_read_only {
                     this.update(&mut cx, |this, cx| this.share_microphone(cx))?
                         .await?;
                 }
@@ -620,6 +624,27 @@ impl Room {
         self.local_participant.role == proto::ChannelRole::Admin
     }
 
+    pub fn set_participant_role(
+        &mut self,
+        user_id: u64,
+        role: proto::ChannelRole,
+        cx: &ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.clone();
+        let room_id = self.id;
+        let role = role.into();
+        cx.spawn(|_, _| async move {
+            client
+                .request(proto::SetRoomParticipantRole {
+                    room_id,
+                    user_id,
+                    role,
+                })
+                .await
+                .map(|_| ())
+        })
+    }
+
     pub fn pending_participants(&self) -> &[Arc<User>] {
         &self.pending_participants
     }
@@ -729,9 +754,21 @@ impl Room {
                     if this.local_participant.role != role {
                         this.local_participant.role = role;
 
+                        if role == proto::ChannelRole::Guest {
+                            for project in mem::take(&mut this.shared_projects) {
+                                if let Some(project) = project.upgrade() {
+                                    this.unshare_project(project, cx).log_err();
+                                }
+                            }
+                            this.local_participant.projects.clear();
+                            if let Some(live_kit_room) = &mut this.live_kit {
+                                live_kit_room.stop_publishing(cx);
+                            }
+                        }
+
                         this.joined_projects.retain(|project| {
                             if let Some(project) = project.upgrade() {
-                                project.update(cx, |project, _| project.set_role(role));
+                                project.update(cx, |project, cx| project.set_role(role, cx));
                                 true
                             } else {
                                 false
@@ -1607,6 +1644,24 @@ impl LiveKitRoom {
 
         Ok((result, old_muted))
     }
+
+    fn stop_publishing(&mut self, cx: &mut ModelContext<Room>) {
+        if let LocalTrack::Published {
+            track_publication, ..
+        } = mem::replace(&mut self.microphone_track, LocalTrack::None)
+        {
+            self.room.unpublish_track(track_publication);
+            cx.notify();
+        }
+
+        if let LocalTrack::Published {
+            track_publication, ..
+        } = mem::replace(&mut self.screen_track, LocalTrack::None)
+        {
+            self.room.unpublish_track(track_publication);
+            cx.notify();
+        }
+    }
 }
 
 enum LocalTrack {

crates/collab/Cargo.toml 🔗

@@ -74,6 +74,8 @@ live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }
 node_runtime = { path = "../node_runtime" }
 notifications = { path = "../notifications", features = ["test-support"] }
+file_finder = { path = "../file_finder"}
+menu = { path = "../menu"}
 
 project = { path = "../project", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }

crates/collab/src/db/ids.rs 🔗

@@ -133,7 +133,7 @@ impl ChannelRole {
         }
     }
 
-    pub fn can_share_projects(&self) -> bool {
+    pub fn can_publish_to_rooms(&self) -> bool {
         use ChannelRole::*;
         match self {
             Admin | Member => true,

crates/collab/src/db/queries/projects.rs 🔗

@@ -49,7 +49,7 @@ impl Database {
             if !participant
                 .role
                 .unwrap_or(ChannelRole::Member)
-                .can_share_projects()
+                .can_publish_to_rooms()
             {
                 return Err(anyhow!("guests cannot share projects"))?;
             }

crates/collab/src/db/queries/rooms.rs 🔗

@@ -1004,6 +1004,46 @@ impl Database {
         .await
     }
 
+    pub async fn set_room_participant_role(
+        &self,
+        admin_id: UserId,
+        room_id: RoomId,
+        user_id: UserId,
+        role: ChannelRole,
+    ) -> Result<RoomGuard<proto::Room>> {
+        self.room_transaction(room_id, |tx| async move {
+            room_participant::Entity::find()
+                .filter(
+                    Condition::all()
+                        .add(room_participant::Column::RoomId.eq(room_id))
+                        .add(room_participant::Column::UserId.eq(admin_id))
+                        .add(room_participant::Column::Role.eq(ChannelRole::Admin)),
+                )
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("only admins can set participant role"))?;
+
+            let result = room_participant::Entity::update_many()
+                .filter(
+                    Condition::all()
+                        .add(room_participant::Column::RoomId.eq(room_id))
+                        .add(room_participant::Column::UserId.eq(user_id)),
+                )
+                .set(room_participant::ActiveModel {
+                    role: ActiveValue::set(Some(ChannelRole::from(role))),
+                    ..Default::default()
+                })
+                .exec(&*tx)
+                .await?;
+
+            if result.rows_affected != 1 {
+                Err(anyhow!("could not update room participant role"))?;
+            }
+            Ok(self.get_room(room_id, &tx).await?)
+        })
+        .await
+    }
+
     pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> {
         self.transaction(|tx| async move {
             self.room_connection_lost(connection, &*tx).await?;

crates/collab/src/rpc.rs 🔗

@@ -202,6 +202,7 @@ impl Server {
             .add_request_handler(join_room)
             .add_request_handler(rejoin_room)
             .add_request_handler(leave_room)
+            .add_request_handler(set_room_participant_role)
             .add_request_handler(call)
             .add_request_handler(cancel_call)
             .add_message_handler(decline_call)
@@ -1258,6 +1259,50 @@ async fn leave_room(
     Ok(())
 }
 
+async fn set_room_participant_role(
+    request: proto::SetRoomParticipantRole,
+    response: Response<proto::SetRoomParticipantRole>,
+    session: Session,
+) -> Result<()> {
+    let (live_kit_room, can_publish) = {
+        let room = session
+            .db()
+            .await
+            .set_room_participant_role(
+                session.user_id,
+                RoomId::from_proto(request.room_id),
+                UserId::from_proto(request.user_id),
+                ChannelRole::from(request.role()),
+            )
+            .await?;
+
+        let live_kit_room = room.live_kit_room.clone();
+        let can_publish = ChannelRole::from(request.role()).can_publish_to_rooms();
+        room_updated(&room, &session.peer);
+        (live_kit_room, can_publish)
+    };
+
+    if let Some(live_kit) = session.live_kit_client.as_ref() {
+        live_kit
+            .update_participant(
+                live_kit_room.clone(),
+                request.user_id.to_string(),
+                live_kit_server::proto::ParticipantPermission {
+                    can_subscribe: true,
+                    can_publish,
+                    can_publish_data: can_publish,
+                    hidden: false,
+                    recorder: false,
+                },
+            )
+            .await
+            .trace_err();
+    }
+
+    response.send(proto::Ack {})?;
+    Ok(())
+}
+
 async fn call(
     request: proto::Call,
     response: Response<proto::Call>,

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

@@ -1,8 +1,8 @@
 use crate::tests::TestServer;
 use call::ActiveCall;
-use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
+use editor::Editor;
+use gpui::{BackgroundExecutor, TestAppContext};
 use rpc::proto;
-use workspace::Workspace;
 
 #[gpui::test]
 async fn test_channel_guests(
@@ -13,37 +13,18 @@ async fn test_channel_guests(
     let mut server = TestServer::start(executor.clone()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
+    let active_call_a = cx_a.read(ActiveCall::global);
 
     let channel_id = server
-        .make_channel("the-channel", None, (&client_a, cx_a), &mut [])
-        .await;
-
-    client_a
-        .channel_store()
-        .update(cx_a, |channel_store, cx| {
-            channel_store.set_channel_visibility(channel_id, proto::ChannelVisibility::Public, cx)
-        })
-        .await
-        .unwrap();
-
-    client_a
-        .fs()
-        .insert_tree(
-            "/a",
-            serde_json::json!({
-                "a.txt": "a-contents",
-            }),
-        )
+        .make_public_channel("the-channel", &client_a, cx_a)
         .await;
 
-    let active_call_a = cx_a.read(ActiveCall::global);
-
     // Client A shares a project in the channel
+    let project_a = client_a.build_test_project(cx_a).await;
     active_call_a
         .update(cx_a, |call, cx| call.join_channel(channel_id, cx))
         .await
         .unwrap();
-    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let project_id = active_call_a
         .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await
@@ -57,38 +38,122 @@ async fn test_channel_guests(
 
     // b should be following a in the shared project.
     // B is a guest,
-    cx_a.executor().run_until_parked();
+    executor.run_until_parked();
 
-    // todo!() the test window does not call activation handlers
-    // correctly yet, so this API does not work.
-    // let project_b = active_call_b.read_with(cx_b, |call, _| {
-    //     call.location()
-    //         .unwrap()
-    //         .upgrade()
-    //         .expect("should not be weak")
-    // });
-
-    let window_b = cx_b.update(|cx| cx.active_window().unwrap());
-    let cx_b = &mut VisualTestContext::from_window(window_b, cx_b);
-
-    let workspace_b = window_b
-        .downcast::<Workspace>()
-        .unwrap()
-        .root_view(cx_b)
-        .unwrap();
-    let project_b = workspace_b.update(cx_b, |workspace, _| workspace.project().clone());
+    let active_call_b = cx_b.read(ActiveCall::global);
+    let project_b =
+        active_call_b.read_with(cx_b, |call, _| call.location().unwrap().upgrade().unwrap());
+    let room_b = active_call_b.update(cx_b, |call, _| call.room().unwrap().clone());
 
     assert_eq!(
         project_b.read_with(cx_b, |project, _| project.remote_id()),
         Some(project_id),
     );
     assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
-
     assert!(project_b
         .update(cx_b, |project, cx| {
             let worktree_id = project.worktrees().next().unwrap().read(cx).id();
             project.create_entry((worktree_id, "b.txt"), false, cx)
         })
         .await
-        .is_err())
+        .is_err());
+    assert!(room_b.read_with(cx_b, |room, _| !room.is_sharing_mic()));
+}
+
+#[gpui::test]
+async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    let mut server = TestServer::start(cx_a.executor()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    let channel_id = server
+        .make_public_channel("the-channel", &client_a, cx_a)
+        .await;
+
+    let project_a = client_a.build_test_project(cx_a).await;
+    cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx))
+        .await
+        .unwrap();
+
+    // Client A shares a project in the channel
+    active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    cx_a.run_until_parked();
+
+    // Client B joins channel A as a guest
+    cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
+        .await
+        .unwrap();
+    cx_a.run_until_parked();
+
+    // client B opens 1.txt as a guest
+    let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
+    let room_b = cx_b
+        .read(ActiveCall::global)
+        .update(cx_b, |call, _| call.room().unwrap().clone());
+    cx_b.simulate_keystrokes("cmd-p 1 enter");
+
+    let (project_b, editor_b) = workspace_b.update(cx_b, |workspace, cx| {
+        (
+            workspace.project().clone(),
+            workspace.active_item_as::<Editor>(cx).unwrap(),
+        )
+    });
+    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+    assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx)));
+    assert!(dbg!(
+        room_b
+            .update(cx_b, |room, cx| room.share_microphone(cx))
+            .await
+    )
+    .is_err());
+
+    // B is promoted
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.room().unwrap().update(cx, |room, cx| {
+                room.set_participant_role(
+                    client_b.user_id().unwrap(),
+                    proto::ChannelRole::Member,
+                    cx,
+                )
+            })
+        })
+        .await
+        .unwrap();
+    cx_a.run_until_parked();
+
+    // project and buffers are now editable
+    assert!(project_b.read_with(cx_b, |project, _| !project.is_read_only()));
+    assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx)));
+    room_b
+        .update(cx_b, |room, cx| room.share_microphone(cx))
+        .await
+        .unwrap();
+
+    // B is demoted
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.room().unwrap().update(cx, |room, cx| {
+                room.set_participant_role(
+                    client_b.user_id().unwrap(),
+                    proto::ChannelRole::Guest,
+                    cx,
+                )
+            })
+        })
+        .await
+        .unwrap();
+    cx_a.run_until_parked();
+
+    // project and buffers are no longer editable
+    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+    assert!(editor_b.update(cx_b, |editor, cx| editor.read_only(cx)));
+    assert!(room_b
+        .update(cx_b, |room, cx| room.share_microphone(cx))
+        .await
+        .is_err());
 }

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

@@ -234,14 +234,14 @@ async fn test_basic_following(
     workspace_c.update(cx_c, |workspace, cx| {
         workspace.close_window(&Default::default(), cx);
     });
-    cx_c.update(|_| {
-        drop(workspace_c);
-    });
-    cx_b.executor().run_until_parked();
+    executor.run_until_parked();
     // are you sure you want to leave the call?
     cx_c.simulate_prompt_answer(0);
-    cx_b.executor().run_until_parked();
+    cx_c.cx.update(|_| {
+        drop(workspace_c);
+    });
     executor.run_until_parked();
+    cx_c.cx.update(|_| {});
 
     weak_workspace_c.assert_dropped();
     weak_project_c.assert_dropped();
@@ -1363,8 +1363,6 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
     let mut server = TestServer::start(executor.clone()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
 
     client_a
         .fs()
@@ -1400,9 +1398,6 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
     let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
     let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
 
-    cx_a.update(|cx| collab_ui::init(&client_a.app_state, cx));
-    cx_b.update(|cx| collab_ui::init(&client_b.app_state, cx));
-
     active_call_a
         .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await

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

@@ -3065,6 +3065,7 @@ async fn test_local_settings(
         .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await
         .unwrap();
+    executor.run_until_parked();
 
     // As client B, join that project and observe the local settings.
     let project_b = client_b.build_remote_project(project_id, cx_b).await;

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

@@ -20,7 +20,11 @@ use node_runtime::FakeNodeRuntime;
 use notifications::NotificationStore;
 use parking_lot::Mutex;
 use project::{Project, WorktreeId};
-use rpc::{proto::ChannelRole, RECEIVE_TIMEOUT};
+use rpc::{
+    proto::{self, ChannelRole},
+    RECEIVE_TIMEOUT,
+};
+use serde_json::json;
 use settings::SettingsStore;
 use std::{
     cell::{Ref, RefCell, RefMut},
@@ -228,12 +232,16 @@ impl TestServer {
             Project::init(&client, cx);
             client::init(&client, cx);
             language::init(cx);
-            editor::init_settings(cx);
+            editor::init(cx);
             workspace::init(app_state.clone(), cx);
             audio::init((), cx);
             call::init(client.clone(), user_store.clone(), cx);
             channel::init(&client, user_store.clone(), cx);
             notifications::init(client.clone(), user_store, cx);
+            collab_ui::init(&app_state, cx);
+            file_finder::init(cx);
+            menu::init();
+            settings::KeymapFile::load_asset("keymaps/default.json", cx).unwrap();
         });
 
         client
@@ -351,6 +359,31 @@ impl TestServer {
         channel_id
     }
 
+    pub async fn make_public_channel(
+        &self,
+        channel: &str,
+        client: &TestClient,
+        cx: &mut TestAppContext,
+    ) -> u64 {
+        let channel_id = self
+            .make_channel(channel, None, (client, cx), &mut [])
+            .await;
+
+        client
+            .channel_store()
+            .update(cx, |channel_store, cx| {
+                channel_store.set_channel_visibility(
+                    channel_id,
+                    proto::ChannelVisibility::Public,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        channel_id
+    }
+
     pub async fn make_channel_tree(
         &self,
         channels: &[(&str, Option<&str>)],
@@ -580,6 +613,20 @@ impl TestClient {
         (project, worktree.read_with(cx, |tree, _| tree.id()))
     }
 
+    pub async fn build_test_project(&self, cx: &mut TestAppContext) -> Model<Project> {
+        self.fs()
+            .insert_tree(
+                "/a",
+                json!({
+                    "1.txt": "one\none\none",
+                    "2.txt": "two\ntwo\ntwo",
+                    "3.txt": "three\nthree\nthree",
+                }),
+            )
+            .await;
+        self.build_local_project("/a", cx).await.0
+    }
+
     pub fn build_empty_local_project(&self, cx: &mut TestAppContext) -> Model<Project> {
         cx.update(|cx| {
             Project::local(
@@ -619,6 +666,18 @@ impl TestClient {
     ) -> (View<Workspace>, &'a mut VisualTestContext) {
         cx.add_window_view(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx))
     }
+
+    pub fn active_workspace<'a>(
+        &'a self,
+        cx: &'a mut TestAppContext,
+    ) -> (View<Workspace>, &'a mut VisualTestContext) {
+        let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
+
+        let view = window.root_view(cx).unwrap();
+        let cx = Box::new(VisualTestContext::from_window(*window.deref(), cx));
+        // it might be nice to try and cleanup these at the end of each test.
+        (view, Box::leak(cx))
+    }
 }
 
 impl Drop for TestClient {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -37,7 +37,7 @@ use ui::{
 use util::{maybe, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
-    notifications::NotifyResultExt,
+    notifications::{NotifyResultExt, NotifyTaskExt},
     Workspace,
 };
 
@@ -140,6 +140,7 @@ enum ListEntry {
         user: Arc<User>,
         peer_id: Option<PeerId>,
         is_pending: bool,
+        role: proto::ChannelRole,
     },
     ParticipantProject {
         project_id: u64,
@@ -151,10 +152,6 @@ enum ListEntry {
         peer_id: Option<PeerId>,
         is_last: bool,
     },
-    GuestCount {
-        count: usize,
-        has_visible_participants: bool,
-    },
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
     ChannelInvite(Arc<Channel>),
@@ -384,14 +381,10 @@ impl CollabPanel {
 
             if !self.collapsed_sections.contains(&Section::ActiveCall) {
                 let room = room.read(cx);
-                let mut guest_count_ix = 0;
-                let mut guest_count = if room.read_only() { 1 } else { 0 };
-                let mut non_guest_count = if room.read_only() { 0 } else { 1 };
 
                 if let Some(channel_id) = room.channel_id() {
                     self.entries.push(ListEntry::ChannelNotes { channel_id });
                     self.entries.push(ListEntry::ChannelChat { channel_id });
-                    guest_count_ix = self.entries.len();
                 }
 
                 // Populate the active user.
@@ -410,12 +403,13 @@ impl CollabPanel {
                         &Default::default(),
                         executor.clone(),
                     ));
-                    if !matches.is_empty() && !room.read_only() {
+                    if !matches.is_empty() {
                         let user_id = user.id;
                         self.entries.push(ListEntry::CallParticipant {
                             user,
                             peer_id: None,
                             is_pending: false,
+                            role: room.local_participant().role,
                         });
                         let mut projects = room.local_participant().projects.iter().peekable();
                         while let Some(project) = projects.next() {
@@ -442,12 +436,6 @@ impl CollabPanel {
                         room.remote_participants()
                             .iter()
                             .filter_map(|(_, participant)| {
-                                if participant.role == proto::ChannelRole::Guest {
-                                    guest_count += 1;
-                                    return None;
-                                } else {
-                                    non_guest_count += 1;
-                                }
                                 Some(StringMatchCandidate {
                                     id: participant.user.id as usize,
                                     string: participant.user.github_login.clone(),
@@ -455,7 +443,7 @@ impl CollabPanel {
                                 })
                             }),
                     );
-                let matches = executor.block(match_strings(
+                let mut matches = executor.block(match_strings(
                     &self.match_candidates,
                     &query,
                     true,
@@ -463,6 +451,15 @@ impl CollabPanel {
                     &Default::default(),
                     executor.clone(),
                 ));
+                matches.sort_by(|a, b| {
+                    let a_is_guest = room.role_for_user(a.candidate_id as u64)
+                        == Some(proto::ChannelRole::Guest);
+                    let b_is_guest = room.role_for_user(b.candidate_id as u64)
+                        == Some(proto::ChannelRole::Guest);
+                    a_is_guest
+                        .cmp(&b_is_guest)
+                        .then_with(|| a.string.cmp(&b.string))
+                });
                 for mat in matches {
                     let user_id = mat.candidate_id as u64;
                     let participant = &room.remote_participants()[&user_id];
@@ -470,6 +467,7 @@ impl CollabPanel {
                         user: participant.user.clone(),
                         peer_id: Some(participant.peer_id),
                         is_pending: false,
+                        role: participant.role,
                     });
                     let mut projects = participant.projects.iter().peekable();
                     while let Some(project) = projects.next() {
@@ -488,15 +486,6 @@ impl CollabPanel {
                         });
                     }
                 }
-                if guest_count > 0 {
-                    self.entries.insert(
-                        guest_count_ix,
-                        ListEntry::GuestCount {
-                            count: guest_count,
-                            has_visible_participants: non_guest_count > 0,
-                        },
-                    );
-                }
 
                 // Populate pending participants.
                 self.match_candidates.clear();
@@ -521,6 +510,7 @@ impl CollabPanel {
                         user: room.pending_participants()[mat.candidate_id].clone(),
                         peer_id: None,
                         is_pending: true,
+                        role: proto::ChannelRole::Member,
                     }));
             }
         }
@@ -834,13 +824,19 @@ impl CollabPanel {
         user: &Arc<User>,
         peer_id: Option<PeerId>,
         is_pending: bool,
+        role: proto::ChannelRole,
         is_selected: bool,
         cx: &mut ViewContext<Self>,
     ) -> ListItem {
+        let user_id = user.id;
         let is_current_user =
-            self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
+            self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
         let tooltip = format!("Follow {}", user.github_login);
 
+        let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
+            room.read(cx).local_participant().role == proto::ChannelRole::Admin
+        });
+
         ListItem::new(SharedString::from(user.github_login.clone()))
             .start_slot(Avatar::new(user.avatar_uri.clone()))
             .child(Label::new(user.github_login.clone()))
@@ -853,17 +849,27 @@ impl CollabPanel {
                     .on_click(move |_, cx| Self::leave_call(cx))
                     .tooltip(|cx| Tooltip::text("Leave Call", cx))
                     .into_any_element()
+            } else if role == proto::ChannelRole::Guest {
+                Label::new("Guest").color(Color::Muted).into_any_element()
             } else {
                 div().into_any_element()
             })
-            .when_some(peer_id, |this, peer_id| {
-                this.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
+            .when_some(peer_id, |el, peer_id| {
+                if role == proto::ChannelRole::Guest {
+                    return el;
+                }
+                el.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
                     .on_click(cx.listener(move |this, _, cx| {
                         this.workspace
                             .update(cx, |workspace, cx| workspace.follow(peer_id, cx))
                             .ok();
                     }))
             })
+            .when(is_call_admin, |el| {
+                el.on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
+                    this.deploy_participant_context_menu(event.position, user_id, role, cx)
+                }))
+            })
     }
 
     fn render_participant_project(
@@ -986,41 +992,6 @@ impl CollabPanel {
             .tooltip(move |cx| Tooltip::text("Open Chat", cx))
     }
 
-    fn render_guest_count(
-        &self,
-        count: usize,
-        has_visible_participants: bool,
-        is_selected: bool,
-        cx: &mut ViewContext<Self>,
-    ) -> impl IntoElement {
-        let manageable_channel_id = ActiveCall::global(cx).read(cx).room().and_then(|room| {
-            let room = room.read(cx);
-            if room.local_participant_is_admin() {
-                room.channel_id()
-            } else {
-                None
-            }
-        });
-
-        ListItem::new("guest_count")
-            .selected(is_selected)
-            .start_slot(
-                h_stack()
-                    .gap_1()
-                    .child(render_tree_branch(!has_visible_participants, false, cx))
-                    .child(""),
-            )
-            .child(Label::new(if count == 1 {
-                format!("{} guest", count)
-            } else {
-                format!("{} guests", count)
-            }))
-            .when_some(manageable_channel_id, |el, channel_id| {
-                el.tooltip(move |cx| Tooltip::text("Manage Members", cx))
-                    .on_click(cx.listener(move |this, _, cx| this.manage_members(channel_id, cx)))
-            })
-    }
-
     fn has_subchannels(&self, ix: usize) -> bool {
         self.entries.get(ix).map_or(false, |entry| {
             if let ListEntry::Channel { has_children, .. } = entry {
@@ -1031,6 +1002,80 @@ impl CollabPanel {
         })
     }
 
+    fn deploy_participant_context_menu(
+        &mut self,
+        position: Point<Pixels>,
+        user_id: u64,
+        role: proto::ChannelRole,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let this = cx.view().clone();
+        if !(role == proto::ChannelRole::Guest || role == proto::ChannelRole::Member) {
+            return;
+        }
+
+        let context_menu = ContextMenu::build(cx, |context_menu, cx| {
+            if role == proto::ChannelRole::Guest {
+                context_menu.entry(
+                    "Grant Write Access",
+                    None,
+                    cx.handler_for(&this, move |_, cx| {
+                        ActiveCall::global(cx)
+                            .update(cx, |call, cx| {
+                                let Some(room) = call.room() else {
+                                    return Task::ready(Ok(()));
+                                };
+                                room.update(cx, |room, cx| {
+                                    room.set_participant_role(
+                                        user_id,
+                                        proto::ChannelRole::Member,
+                                        cx,
+                                    )
+                                })
+                            })
+                            .detach_and_notify_err(cx)
+                    }),
+                )
+            } else if role == proto::ChannelRole::Member {
+                context_menu.entry(
+                    "Revoke Write Access",
+                    None,
+                    cx.handler_for(&this, move |_, cx| {
+                        ActiveCall::global(cx)
+                            .update(cx, |call, cx| {
+                                let Some(room) = call.room() else {
+                                    return Task::ready(Ok(()));
+                                };
+                                room.update(cx, |room, cx| {
+                                    room.set_participant_role(
+                                        user_id,
+                                        proto::ChannelRole::Guest,
+                                        cx,
+                                    )
+                                })
+                            })
+                            .detach_and_notify_err(cx)
+                    }),
+                )
+            } else {
+                unreachable!()
+            }
+        });
+
+        cx.focus_view(&context_menu);
+        let subscription =
+            cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
+                if this.context_menu.as_ref().is_some_and(|context_menu| {
+                    context_menu.0.focus_handle(cx).contains_focused(cx)
+                }) {
+                    cx.focus_self();
+                }
+                this.context_menu.take();
+                cx.notify();
+            });
+        self.context_menu = Some((context_menu, position, subscription));
+    }
+
     fn deploy_channel_context_menu(
         &mut self,
         position: Point<Pixels>,
@@ -1242,18 +1287,6 @@ impl CollabPanel {
                             });
                         }
                     }
-                    ListEntry::GuestCount { .. } => {
-                        let Some(room) = ActiveCall::global(cx).read(cx).room() else {
-                            return;
-                        };
-                        let room = room.read(cx);
-                        let Some(channel_id) = room.channel_id() else {
-                            return;
-                        };
-                        if room.local_participant_is_admin() {
-                            self.manage_members(channel_id, cx)
-                        }
-                    }
                     ListEntry::Channel { channel, .. } => {
                         let is_active = maybe!({
                             let call_channel = ActiveCall::global(cx)
@@ -1788,8 +1821,9 @@ impl CollabPanel {
                 user,
                 peer_id,
                 is_pending,
+                role,
             } => self
-                .render_call_participant(user, *peer_id, *is_pending, is_selected, cx)
+                .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
                 .into_any_element(),
             ListEntry::ParticipantProject {
                 project_id,
@@ -1809,12 +1843,6 @@ impl CollabPanel {
             ListEntry::ParticipantScreen { peer_id, is_last } => self
                 .render_participant_screen(*peer_id, *is_last, is_selected, cx)
                 .into_any_element(),
-            ListEntry::GuestCount {
-                count,
-                has_visible_participants,
-            } => self
-                .render_guest_count(*count, *has_visible_participants, is_selected, cx)
-                .into_any_element(),
             ListEntry::ChannelNotes { channel_id } => self
                 .render_channel_notes(*channel_id, is_selected, cx)
                 .into_any_element(),
@@ -2584,11 +2612,6 @@ impl PartialEq for ListEntry {
                     return true;
                 }
             }
-            ListEntry::GuestCount { .. } => {
-                if let ListEntry::GuestCount { .. } = other {
-                    return true;
-                }
-            }
         }
         false
     }

crates/gpui/src/app/test_context.rs 🔗

@@ -290,6 +290,11 @@ impl TestAppContext {
         }
     }
 
+    /// Wait until there are no more pending tasks.
+    pub fn run_until_parked(&mut self) {
+        self.background_executor.run_until_parked()
+    }
+
     /// Simulate dispatching an action to the currently focused node in the window.
     pub fn dispatch_action<A>(&mut self, window: AnyWindowHandle, action: A)
     where
@@ -552,7 +557,8 @@ use derive_more::{Deref, DerefMut};
 pub struct VisualTestContext {
     #[deref]
     #[deref_mut]
-    cx: TestAppContext,
+    /// cx is the original TestAppContext (you can more easily access this using Deref)
+    pub cx: TestAppContext,
     window: AnyWindowHandle,
 }
 

crates/language/src/buffer.rs 🔗

@@ -254,6 +254,7 @@ pub enum Event {
     LanguageChanged,
     Reparsed,
     DiagnosticsUpdated,
+    CapabilityChanged,
     Closed,
 }
 
@@ -631,6 +632,11 @@ impl Buffer {
             .set_language_registry(language_registry);
     }
 
+    pub fn set_capability(&mut self, capability: Capability, cx: &mut ModelContext<Self>) {
+        self.capability = capability;
+        cx.emit(Event::CapabilityChanged)
+    }
+
     pub fn did_save(
         &mut self,
         version: clock::Global,

crates/live_kit_client/src/test.rs 🔗

@@ -3,7 +3,7 @@ use async_trait::async_trait;
 use collections::{BTreeMap, HashMap};
 use futures::Stream;
 use gpui::BackgroundExecutor;
-use live_kit_server::token;
+use live_kit_server::{proto, token};
 use media::core_video::CVImageBuffer;
 use parking_lot::Mutex;
 use postage::watch;
@@ -151,6 +151,21 @@ impl TestServer {
         Ok(())
     }
 
+    async fn update_participant(
+        &self,
+        room_name: String,
+        identity: String,
+        permission: proto::ParticipantPermission,
+    ) -> Result<()> {
+        self.executor.simulate_random_delay().await;
+        let mut server_rooms = self.rooms.lock();
+        let room = server_rooms
+            .get_mut(&room_name)
+            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
+        room.participant_permissions.insert(identity, permission);
+        Ok(())
+    }
+
     pub async fn disconnect_client(&self, client_identity: String) {
         self.executor.simulate_random_delay().await;
         let mut server_rooms = self.rooms.lock();
@@ -172,6 +187,17 @@ impl TestServer {
             .get_mut(&*room_name)
             .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
 
+        let can_publish = room
+            .participant_permissions
+            .get(&identity)
+            .map(|permission| permission.can_publish)
+            .or(claims.video.can_publish)
+            .unwrap_or(true);
+
+        if !can_publish {
+            return Err(anyhow!("user is not allowed to publish"));
+        }
+
         let track = Arc::new(RemoteVideoTrack {
             sid: nanoid::nanoid!(17),
             publisher_id: identity.clone(),
@@ -210,6 +236,17 @@ impl TestServer {
             .get_mut(&*room_name)
             .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
 
+        let can_publish = room
+            .participant_permissions
+            .get(&identity)
+            .map(|permission| permission.can_publish)
+            .or(claims.video.can_publish)
+            .unwrap_or(true);
+
+        if !can_publish {
+            return Err(anyhow!("user is not allowed to publish"));
+        }
+
         let track = Arc::new(RemoteAudioTrack {
             sid: nanoid::nanoid!(17),
             publisher_id: identity.clone(),
@@ -265,6 +302,7 @@ struct TestServerRoom {
     client_rooms: HashMap<Sid, Arc<Room>>,
     video_tracks: Vec<Arc<RemoteVideoTrack>>,
     audio_tracks: Vec<Arc<RemoteAudioTrack>>,
+    participant_permissions: HashMap<Sid, proto::ParticipantPermission>,
 }
 
 impl TestServerRoom {}
@@ -297,6 +335,19 @@ impl live_kit_server::api::Client for TestApiClient {
         Ok(())
     }
 
+    async fn update_participant(
+        &self,
+        room: String,
+        identity: String,
+        permission: live_kit_server::proto::ParticipantPermission,
+    ) -> Result<()> {
+        let server = TestServer::get(&self.url)?;
+        server
+            .update_participant(room, identity, permission)
+            .await?;
+        Ok(())
+    }
+
     fn room_token(&self, room: &str, identity: &str) -> Result<String> {
         let server = TestServer::get(&self.url)?;
         token::create(

crates/live_kit_server/src/api.rs 🔗

@@ -11,10 +11,18 @@ pub trait Client: Send + Sync {
     async fn create_room(&self, name: String) -> Result<()>;
     async fn delete_room(&self, name: String) -> Result<()>;
     async fn remove_participant(&self, room: String, identity: String) -> Result<()>;
+    async fn update_participant(
+        &self,
+        room: String,
+        identity: String,
+        permission: proto::ParticipantPermission,
+    ) -> Result<()>;
     fn room_token(&self, room: &str, identity: &str) -> Result<String>;
     fn guest_token(&self, room: &str, identity: &str) -> Result<String>;
 }
 
+pub struct LiveKitParticipantUpdate {}
+
 #[derive(Clone)]
 pub struct LiveKitClient {
     http: reqwest::Client,
@@ -131,6 +139,27 @@ impl Client for LiveKitClient {
         Ok(())
     }
 
+    async fn update_participant(
+        &self,
+        room: String,
+        identity: String,
+        permission: proto::ParticipantPermission,
+    ) -> Result<()> {
+        let _: proto::ParticipantInfo = self
+            .request(
+                "twirp/livekit.RoomService/UpdateParticipant",
+                token::VideoGrant::to_admin(&room),
+                proto::UpdateParticipantRequest {
+                    room: room.clone(),
+                    identity,
+                    metadata: "".to_string(),
+                    permission: Some(permission),
+                },
+            )
+            .await?;
+        Ok(())
+    }
+
     fn room_token(&self, room: &str, identity: &str) -> Result<String> {
         token::create(
             &self.key,

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -80,6 +80,7 @@ pub enum Event {
     Reloaded,
     DiffBaseChanged,
     LanguageChanged,
+    CapabilityChanged,
     Reparsed,
     Saved,
     FileHandleChanged,
@@ -1404,7 +1405,7 @@ impl MultiBuffer {
 
     fn on_buffer_event(
         &mut self,
-        _: Model<Buffer>,
+        buffer: Model<Buffer>,
         event: &language::Event,
         cx: &mut ModelContext<Self>,
     ) {
@@ -1421,6 +1422,10 @@ impl MultiBuffer {
             language::Event::Reparsed => Event::Reparsed,
             language::Event::DiagnosticsUpdated => Event::DiagnosticsUpdated,
             language::Event::Closed => Event::Closed,
+            language::Event::CapabilityChanged => {
+                self.capability = buffer.read(cx).capability();
+                Event::CapabilityChanged
+            }
 
             //
             language::Event::Operation(_) => return,

crates/project/src/project.rs 🔗

@@ -799,7 +799,7 @@ impl Project {
                 prettiers_per_worktree: HashMap::default(),
                 prettier_instances: HashMap::default(),
             };
-            this.set_role(role);
+            this.set_role(role, cx);
             for worktree in worktrees {
                 let _ = this.add_worktree(&worktree, cx);
             }
@@ -1622,14 +1622,22 @@ impl Project {
         cx.notify();
     }
 
-    pub fn set_role(&mut self, role: proto::ChannelRole) {
-        if let Some(ProjectClientState::Remote { capability, .. }) = &mut self.client_state {
-            *capability = if role == proto::ChannelRole::Member || role == proto::ChannelRole::Admin
-            {
+    pub fn set_role(&mut self, role: proto::ChannelRole, cx: &mut ModelContext<Self>) {
+        let new_capability =
+            if role == proto::ChannelRole::Member || role == proto::ChannelRole::Admin {
                 Capability::ReadWrite
             } else {
                 Capability::ReadOnly
             };
+        if let Some(ProjectClientState::Remote { capability, .. }) = &mut self.client_state {
+            if *capability == new_capability {
+                return;
+            }
+
+            *capability = new_capability;
+        }
+        for buffer in self.opened_buffers() {
+            buffer.update(cx, |buffer, cx| buffer.set_capability(new_capability, cx));
         }
     }
 

crates/rpc/proto/zed.proto 🔗

@@ -180,7 +180,8 @@ message Envelope {
         DeleteNotification delete_notification = 152;
         MarkNotificationRead mark_notification_read = 153;
         LspExtExpandMacro lsp_ext_expand_macro = 154;
-        LspExtExpandMacroResponse lsp_ext_expand_macro_response = 155; // Current max
+        LspExtExpandMacroResponse lsp_ext_expand_macro_response = 155;
+        SetRoomParticipantRole set_room_participant_role = 156; // Current max
     }
 }
 
@@ -1633,3 +1634,9 @@ message LspExtExpandMacroResponse {
     string name = 1;
     string expansion = 2;
 }
+
+message SetRoomParticipantRole {
+    uint64 room_id = 1;
+    uint64 user_id = 2;
+    ChannelRole role = 3;
+}

crates/rpc/src/proto.rs 🔗

@@ -283,6 +283,7 @@ messages!(
     (UsersResponse, Foreground),
     (LspExtExpandMacro, Background),
     (LspExtExpandMacroResponse, Background),
+    (SetRoomParticipantRole, Foreground),
 );
 
 request_messages!(
@@ -367,6 +368,7 @@ request_messages!(
     (UpdateProject, Ack),
     (UpdateWorktree, Ack),
     (LspExtExpandMacro, LspExtExpandMacroResponse),
+    (SetRoomParticipantRole, Ack),
 );
 
 entity_messages!(

crates/vim/src/test/vim_test_context.rs 🔗

@@ -17,7 +17,6 @@ pub struct VimTestContext {
 impl VimTestContext {
     pub fn init(cx: &mut gpui::TestAppContext) {
         if cx.has_global::<Vim>() {
-            dbg!("OOPS");
             return;
         }
         cx.update(|cx| {

crates/workspace/src/notifications.rs 🔗

@@ -2,7 +2,7 @@ use crate::{Toast, Workspace};
 use collections::HashMap;
 use gpui::{
     AnyView, AppContext, AsyncWindowContext, DismissEvent, Entity, EntityId, EventEmitter, Render,
-    View, ViewContext, VisualContext,
+    Task, View, ViewContext, VisualContext, WindowContext,
 };
 use std::{any::TypeId, ops::DerefMut};
 
@@ -292,3 +292,18 @@ where
         }
     }
 }
+
+pub trait NotifyTaskExt {
+    fn detach_and_notify_err(self, cx: &mut WindowContext);
+}
+
+impl<R, E> NotifyTaskExt for Task<Result<R, E>>
+where
+    E: std::fmt::Debug + 'static,
+    R: 'static,
+{
+    fn detach_and_notify_err(self, cx: &mut WindowContext) {
+        cx.spawn(|mut cx| async move { self.await.notify_async_err(&mut cx) })
+            .detach();
+    }
+}