Detailed changes
@@ -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",
@@ -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 {
@@ -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"] }
@@ -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,
@@ -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"))?;
}
@@ -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?;
@@ -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>,
@@ -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());
}
@@ -1337,6 +1337,7 @@ async fn test_guest_access(
})
.await
.unwrap();
+ executor.run_until_parked();
assert_channels_list_shape(client_b.channel_store(), cx_b, &[]);
@@ -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
@@ -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;
@@ -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 {
@@ -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
}
@@ -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,
}
@@ -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,
@@ -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(
@@ -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,
@@ -1,3 +1,3 @@
pub mod api;
-mod proto;
+pub mod proto;
pub mod token;
@@ -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,
@@ -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));
}
}
@@ -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;
+}
@@ -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!(
@@ -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| {
@@ -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();
+ }
+}