diff --git a/assets/icons/channels.svg b/assets/icons/channels.svg new file mode 100644 index 0000000000..edd0462678 --- /dev/null +++ b/assets/icons/channels.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index a7f4b55084..d99a660850 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -550,6 +550,14 @@ "alt-shift-f": "project_panel::NewSearchInDirectory" } }, + { + "context": "ChannelModal", + "bindings": { + "left": "channel_modal::SelectNextControl", + "right": "channel_modal::SelectNextControl", + "tab": "channel_modal::ToggleMode" + } + }, { "context": "Terminal", "bindings": { diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index c04b123acf..51176986ef 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -30,6 +30,12 @@ pub struct Channel { pub depth: usize, } +pub struct ChannelMembership { + pub user: Arc, + pub kind: proto::channel_member::Kind, + pub admin: bool, +} + impl Entity for ChannelStore { type Event = (); } @@ -72,6 +78,20 @@ impl ChannelStore { self.channels.iter().find(|c| c.id == channel_id).cloned() } + pub fn is_user_admin(&self, mut channel_id: ChannelId) -> bool { + while let Some(channel) = self.channel_for_id(channel_id) { + if channel.user_is_admin { + return true; + } + if let Some(parent_id) = channel.parent_id { + channel_id = parent_id; + } else { + break; + } + } + false + } + pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc] { self.channel_participants .get(&channel_id) @@ -149,6 +169,35 @@ impl ChannelStore { }) } + pub fn set_member_admin( + &mut self, + channel_id: ChannelId, + user_id: UserId, + admin: bool, + cx: &mut ModelContext, + ) -> Task> { + if !self.outgoing_invites.insert((channel_id, user_id)) { + return Task::ready(Err(anyhow!("member request already in progress"))); + } + + cx.notify(); + let client = self.client.clone(); + cx.spawn(|this, mut cx| async move { + client + .request(proto::SetChannelMemberAdmin { + channel_id, + user_id, + admin, + }) + .await?; + this.update(&mut cx, |this, cx| { + this.outgoing_invites.remove(&(channel_id, user_id)); + cx.notify(); + }); + Ok(()) + }) + } + pub fn respond_to_channel_invite( &mut self, channel_id: ChannelId, @@ -167,7 +216,7 @@ impl ChannelStore { &self, channel_id: ChannelId, cx: &mut ModelContext, - ) -> Task, proto::channel_member::Kind)>>> { + ) -> Task>> { let client = self.client.clone(); let user_store = self.user_store.downgrade(); cx.spawn(|_, mut cx| async move { @@ -187,7 +236,11 @@ impl ChannelStore { .into_iter() .zip(response.members) .filter_map(|(user, member)| { - Some((user, proto::channel_member::Kind::from_i32(member.kind)?)) + Some(ChannelMembership { + user, + admin: member.admin, + kind: proto::channel_member::Kind::from_i32(member.kind)?, + }) }) .collect()) }) @@ -239,7 +292,8 @@ impl ChannelStore { .iter_mut() .find(|c| c.id == channel.id) { - Arc::make_mut(existing_channel).name = channel.name; + let existing_channel = Arc::make_mut(existing_channel); + existing_channel.name = channel.name; continue; } @@ -257,7 +311,9 @@ impl ChannelStore { for channel in payload.channels { if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) { - Arc::make_mut(existing_channel).name = channel.name; + let existing_channel = Arc::make_mut(existing_channel); + existing_channel.name = channel.name; + existing_channel.user_is_admin = channel.user_is_admin; continue; } @@ -270,7 +326,7 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, - user_is_admin: channel.user_is_admin || parent_channel.user_is_admin, + user_is_admin: channel.user_is_admin, parent_id: Some(parent_id), depth, }), diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 6ebf5933df..9dc4ad805b 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3243,8 +3243,9 @@ impl Database { } } + let channel_ancestors = self.get_channel_ancestors(channel_id, &*tx).await?; let members_to_notify: Vec = channel_member::Entity::find() - .filter(channel_member::Column::ChannelId.is_in(channels_to_remove.keys().copied())) + .filter(channel_member::Column::ChannelId.is_in(channel_ancestors)) .select_only() .column(channel_member::Column::UserId) .distinct() @@ -3472,6 +3473,39 @@ impl Database { .await } + pub async fn set_channel_member_admin( + &self, + channel_id: ChannelId, + from: UserId, + for_user: UserId, + admin: bool, + ) -> Result<()> { + self.transaction(|tx| async move { + self.check_user_is_channel_admin(channel_id, from, &*tx) + .await?; + + let result = channel_member::Entity::update_many() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(for_user)), + ) + .set(channel_member::ActiveModel { + admin: ActiveValue::set(admin), + ..Default::default() + }) + .exec(&*tx) + .await?; + + if result.rows_affected == 0 { + Err(anyhow!("no such member"))?; + } + + Ok(()) + }) + .await + } + pub async fn get_channel_member_details( &self, channel_id: ChannelId, @@ -3484,6 +3518,7 @@ impl Database { #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryMemberDetails { UserId, + Admin, IsDirectMember, Accepted, } @@ -3495,6 +3530,7 @@ impl Database { .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied())) .select_only() .column(channel_member::Column::UserId) + .column(channel_member::Column::Admin) .column_as( channel_member::Column::ChannelId.eq(channel_id), QueryMemberDetails::IsDirectMember, @@ -3507,7 +3543,12 @@ impl Database { let mut rows = Vec::::new(); while let Some(row) = stream.next().await { - let (user_id, is_direct_member, is_invite_accepted): (UserId, bool, bool) = row?; + let (user_id, is_admin, is_direct_member, is_invite_accepted): ( + UserId, + bool, + bool, + bool, + ) = row?; let kind = match (is_direct_member, is_invite_accepted) { (true, true) => proto::channel_member::Kind::Member, (true, false) => proto::channel_member::Kind::Invitee, @@ -3518,11 +3559,18 @@ impl Database { let kind = kind.into(); if let Some(last_row) = rows.last_mut() { if last_row.user_id == user_id { - last_row.kind = last_row.kind.min(kind); + if is_direct_member { + last_row.kind = kind; + last_row.admin = is_admin; + } continue; } } - rows.push(proto::ChannelMember { user_id, kind }); + rows.push(proto::ChannelMember { + user_id, + kind, + admin: is_admin, + }); } Ok(rows) diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 3067fd063e..efc35a5c24 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -915,7 +915,7 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap(); - db.invite_channel_member(zed_id, b_id, a_id, true) + db.invite_channel_member(zed_id, b_id, a_id, false) .await .unwrap(); @@ -1000,6 +1000,43 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { ] ); + let (channels, _) = db.get_channels_for_user(b_id).await.unwrap(); + assert_eq!( + channels, + vec![ + Channel { + id: zed_id, + name: "zed".to_string(), + parent_id: None, + user_is_admin: false, + }, + Channel { + id: crdb_id, + name: "crdb".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + Channel { + id: livestreaming_id, + name: "livestreaming".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + Channel { + id: replace_id, + name: "replace".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + ] + ); + + // Update member permissions + let set_subchannel_admin = db.set_channel_member_admin(crdb_id, a_id, b_id, true).await; + assert!(set_subchannel_admin.is_err()); + let set_channel_admin = db.set_channel_member_admin(zed_id, a_id, b_id, true).await; + assert!(set_channel_admin.is_ok()); + let (channels, _) = db.get_channels_for_user(b_id).await.unwrap(); assert_eq!( channels, @@ -1176,7 +1213,7 @@ test_both_dbs!( db.invite_channel_member(channel_1_2, user_2, user_1, false) .await .unwrap(); - db.invite_channel_member(channel_1_1, user_3, user_1, false) + db.invite_channel_member(channel_1_1, user_3, user_1, true) .await .unwrap(); @@ -1210,14 +1247,17 @@ test_both_dbs!( proto::ChannelMember { user_id: user_1.to_proto(), kind: proto::channel_member::Kind::Member.into(), + admin: true, }, proto::ChannelMember { user_id: user_2.to_proto(), kind: proto::channel_member::Kind::Invitee.into(), + admin: false, }, proto::ChannelMember { user_id: user_3.to_proto(), kind: proto::channel_member::Kind::Invitee.into(), + admin: true, }, ] ); @@ -1241,10 +1281,12 @@ test_both_dbs!( proto::ChannelMember { user_id: user_1.to_proto(), kind: proto::channel_member::Kind::Member.into(), + admin: true, }, proto::ChannelMember { user_id: user_2.to_proto(), kind: proto::channel_member::Kind::AncestorMember.into(), + admin: false, }, ] ); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 6893c4bde4..f1fd97db41 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -246,6 +246,7 @@ impl Server { .add_request_handler(remove_channel) .add_request_handler(invite_channel_member) .add_request_handler(remove_channel_member) + .add_request_handler(set_channel_member_admin) .add_request_handler(get_channel_members) .add_request_handler(respond_to_channel_invite) .add_request_handler(join_channel) @@ -2150,19 +2151,24 @@ async fn create_channel( id: id.to_proto(), name: request.name, parent_id: request.parent_id, - user_is_admin: true, + user_is_admin: false, }); - if let Some(parent_id) = parent_id { - let member_ids = db.get_channel_members(parent_id).await?; - let connection_pool = session.connection_pool().await; - for member_id in member_ids { - for connection_id in connection_pool.user_connection_ids(member_id) { - session.peer.send(connection_id, update.clone())?; - } - } + let user_ids_to_notify = if let Some(parent_id) = parent_id { + db.get_channel_members(parent_id).await? } else { - session.peer.send(session.connection_id, update)?; + vec![session.user_id] + }; + + let connection_pool = session.connection_pool().await; + for user_id in user_ids_to_notify { + for connection_id in connection_pool.user_connection_ids(user_id) { + let mut update = update.clone(); + if user_id == session.user_id { + update.channels[0].user_is_admin = true; + } + session.peer.send(connection_id, update)?; + } } Ok(()) @@ -2239,8 +2245,57 @@ async fn remove_channel_member( let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); let member_id = UserId::from_proto(request.user_id); + db.remove_channel_member(channel_id, member_id, session.user_id) .await?; + + let mut update = proto::UpdateChannels::default(); + update.remove_channels.push(channel_id.to_proto()); + + for connection_id in session + .connection_pool() + .await + .user_connection_ids(member_id) + { + session.peer.send(connection_id, update.clone())?; + } + + response.send(proto::Ack {})?; + Ok(()) +} + +async fn set_channel_member_admin( + request: proto::SetChannelMemberAdmin, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let member_id = UserId::from_proto(request.user_id); + db.set_channel_member_admin(channel_id, session.user_id, member_id, request.admin) + .await?; + + let channel = db + .get_channel(channel_id, member_id) + .await? + .ok_or_else(|| anyhow!("channel not found"))?; + + let mut update = proto::UpdateChannels::default(); + update.channels.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + parent_id: None, + user_is_admin: request.admin, + }); + + for connection_id in session + .connection_pool() + .await + .user_connection_ids(member_id) + { + session.peer.send(connection_id, update.clone())?; + } + response.send(proto::Ack {})?; Ok(()) } diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 43e5a296c4..ae149f6a8a 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,12 +1,12 @@ use crate::tests::{room_participants, RoomParticipants, TestServer}; use call::ActiveCall; -use client::{Channel, User}; +use client::{Channel, ChannelMembership, User}; use gpui::{executor::Deterministic, TestAppContext}; use rpc::proto; use std::sync::Arc; #[gpui::test] -async fn test_basic_channels( +async fn test_core_channels( deterministic: Arc, cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, @@ -64,7 +64,7 @@ async fn test_basic_channels( .update(cx_a, |store, cx| { assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); - let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), true, cx); + let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), false, cx); // Make sure we're synchronously storing the pending invite assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); @@ -84,7 +84,7 @@ async fn test_basic_channels( parent_id: None, user_is_admin: false, depth: 0, - }),] + })] ) }); let members = client_a @@ -100,10 +100,12 @@ async fn test_basic_channels( &[ ( client_a.user_id().unwrap(), + true, proto::channel_member::Kind::Member, ), ( client_b.user_id().unwrap(), + false, proto::channel_member::Kind::Invitee, ), ], @@ -117,10 +119,82 @@ async fn test_basic_channels( }) .await .unwrap(); - - // Client B now sees that they are a member of channel A and its existing - // subchannels. Their admin priveleges extend to subchannels of channel A. deterministic.run_until_parked(); + + // Client B now sees that they are a member of channel A and its existing subchannels. + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!(channels.channel_invitations(), &[]); + assert_eq!( + channels.channels(), + &[ + Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: false, + depth: 0, + }), + Arc::new(Channel { + id: channel_b_id, + name: "channel-b".to_string(), + parent_id: Some(channel_a_id), + user_is_admin: false, + depth: 1, + }) + ] + ) + }); + + let channel_c_id = client_a + .channel_store() + .update(cx_a, |channel_store, _| { + channel_store.create_channel("channel-c", Some(channel_b_id)) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!( + channels.channels(), + &[ + Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: false, + depth: 0, + }), + Arc::new(Channel { + id: channel_b_id, + name: "channel-b".to_string(), + parent_id: Some(channel_a_id), + user_is_admin: false, + depth: 1, + }), + Arc::new(Channel { + id: channel_c_id, + name: "channel-c".to_string(), + parent_id: Some(channel_b_id), + user_is_admin: false, + depth: 2, + }), + ] + ) + }); + + // Update client B's membership to channel A to be an admin. + client_a + .channel_store() + .update(cx_a, |store, cx| { + store.set_member_admin(channel_a_id, client_b.user_id().unwrap(), true, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + + // Observe that client B is now an admin of channel A, and that + // their admin priveleges extend to subchannels of channel A. client_b.channel_store().read_with(cx_b, |channels, _| { assert_eq!(channels.channel_invitations(), &[]); assert_eq!( @@ -137,65 +211,83 @@ async fn test_basic_channels( id: channel_b_id, name: "channel-b".to_string(), parent_id: Some(channel_a_id), - user_is_admin: true, + user_is_admin: false, depth: 1, - }) - ] - ) - }); - - let channel_c_id = client_a - .channel_store() - .update(cx_a, |channel_store, _| { - channel_store.create_channel("channel-c", Some(channel_a_id)) - }) - .await - .unwrap(); - - // TODO - ensure sibling channels are sorted in a stable way - deterministic.run_until_parked(); - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!( - channels.channels(), - &[ - Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - user_is_admin: true, - depth: 0, }), Arc::new(Channel { id: channel_c_id, name: "channel-c".to_string(), - parent_id: Some(channel_a_id), - user_is_admin: true, - depth: 1, - }), - Arc::new(Channel { - id: channel_b_id, - name: "channel-b".to_string(), - parent_id: Some(channel_a_id), - user_is_admin: true, - depth: 1, + parent_id: Some(channel_b_id), + user_is_admin: false, + depth: 2, }), ] - ) + ); + + assert!(channels.is_user_admin(channel_c_id)) }); - // Client A deletes the channel + // Client A deletes the channel, deletion also deletes subchannels. client_a .channel_store() .update(cx_a, |channel_store, _| { - channel_store.remove_channel(channel_a_id) + channel_store.remove_channel(channel_b_id) }) .await .unwrap(); deterministic.run_until_parked(); + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + })] + ) + }); + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + })] + ) + }); + + // Remove client B client_a .channel_store() - .read_with(cx_a, |channels, _| assert_eq!(channels.channels(), &[])); + .update(cx_a, |channel_store, cx| { + channel_store.remove_member(channel_a_id, client_b.user_id().unwrap(), cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + // Client A still has their channel + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + })] + ) + }); + + // Client B is gone client_b .channel_store() .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); @@ -209,13 +301,13 @@ fn assert_participants_eq(participants: &[Arc], expected_partitipants: &[u } fn assert_members_eq( - members: &[(Arc, proto::channel_member::Kind)], - expected_members: &[(u64, proto::channel_member::Kind)], + members: &[ChannelMembership], + expected_members: &[(u64, bool, proto::channel_member::Kind)], ) { assert_eq!( members .iter() - .map(|(user, status)| (user.id, *status)) + .map(|member| (member.user.id, member.admin, member.kind)) .collect::>(), expected_members ); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index df27ea5005..a84c5c111e 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -41,8 +41,7 @@ use workspace::{ }; use crate::face_pile::FacePile; - -use self::channel_modal::build_channel_modal; +use channel_modal::ChannelModal; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RemoveChannel { @@ -1284,7 +1283,14 @@ impl CollabPanel { let channel_id = channel.id; MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { Flex::row() - .with_child(Svg::new("icons/file_icons/hash.svg").aligned().left()) + .with_child( + Svg::new("icons/channels.svg") + .with_color(theme.add_channel_button.color) + .constrained() + .with_width(14.) + .aligned() + .left(), + ) .with_child( Label::new(channel.name.clone(), theme.contact_username.text.clone()) .contained() @@ -1509,21 +1515,19 @@ impl CollabPanel { channel_id: u64, cx: &mut ViewContext, ) { - if let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) { - if channel.user_is_admin { - self.context_menu.update(cx, |context_menu, cx| { - context_menu.show( - position, - gpui::elements::AnchorCorner::BottomLeft, - vec![ - ContextMenuItem::action("New Channel", NewChannel { channel_id }), - ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), - ContextMenuItem::action("Add member", AddMember { channel_id }), - ], - cx, - ); - }); - } + if self.channel_store.read(cx).is_user_admin(channel_id) { + self.context_menu.update(cx, |context_menu, cx| { + context_menu.show( + position, + gpui::elements::AnchorCorner::BottomLeft, + vec![ + ContextMenuItem::action("New Channel", NewChannel { channel_id }), + ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), + ContextMenuItem::action("Add member", AddMember { channel_id }), + ], + cx, + ); + }); } } @@ -1697,7 +1701,7 @@ impl CollabPanel { workspace.update(&mut cx, |workspace, cx| { workspace.toggle_modal(cx, |_, cx| { cx.add_view(|cx| { - build_channel_modal( + ChannelModal::new( user_store.clone(), channel_store.clone(), channel_id, diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 5628540022..0286e30b80 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -1,6 +1,7 @@ -use client::{proto, ChannelId, ChannelStore, User, UserId, UserStore}; +use client::{proto, ChannelId, ChannelMembership, ChannelStore, User, UserId, UserStore}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ + actions, elements::*, platform::{CursorStyle, MouseButton}, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle, @@ -10,8 +11,12 @@ use std::sync::Arc; use util::TryFutureExt; use workspace::Modal; +actions!(channel_modal, [SelectNextControl, ToggleMode]); + pub fn init(cx: &mut AppContext) { Picker::::init(cx); + cx.add_action(ChannelModal::toggle_mode); + cx.add_action(ChannelModal::select_next_control); } pub struct ChannelModal { @@ -21,6 +26,110 @@ pub struct ChannelModal { has_focus: bool, } +impl ChannelModal { + pub fn new( + user_store: ModelHandle, + channel_store: ModelHandle, + channel_id: ChannelId, + mode: Mode, + members: Vec, + cx: &mut ViewContext, + ) -> Self { + cx.observe(&channel_store, |_, _, cx| cx.notify()).detach(); + let picker = cx.add_view(|cx| { + Picker::new( + ChannelModalDelegate { + matching_users: Vec::new(), + matching_member_indices: Vec::new(), + selected_index: 0, + user_store: user_store.clone(), + channel_store: channel_store.clone(), + channel_id, + match_candidates: members + .iter() + .enumerate() + .map(|(id, member)| StringMatchCandidate { + id, + string: member.user.github_login.clone(), + char_bag: member.user.github_login.chars().collect(), + }) + .collect(), + members, + mode, + selected_column: None, + }, + cx, + ) + .with_theme(|theme| theme.collab_panel.channel_modal.picker.clone()) + }); + + cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); + let has_focus = picker.read(cx).has_focus(); + + Self { + picker, + channel_store, + channel_id, + has_focus, + } + } + + fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext) { + let mode = match self.picker.read(cx).delegate().mode { + Mode::ManageMembers => Mode::InviteMembers, + Mode::InviteMembers => Mode::ManageMembers, + }; + self.set_mode(mode, cx); + } + + fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext) { + let channel_store = self.channel_store.clone(); + let channel_id = self.channel_id; + cx.spawn(|this, mut cx| async move { + if mode == Mode::ManageMembers { + let members = channel_store + .update(&mut cx, |channel_store, cx| { + channel_store.get_channel_member_details(channel_id, cx) + }) + .await?; + this.update(&mut cx, |this, cx| { + this.picker + .update(cx, |picker, _| picker.delegate_mut().members = members); + })?; + } + + this.update(&mut cx, |this, cx| { + this.picker.update(cx, |picker, cx| { + let delegate = picker.delegate_mut(); + delegate.mode = mode; + picker.update_matches(picker.query(cx), cx); + cx.notify() + }); + }) + }) + .detach(); + } + + fn select_next_control(&mut self, _: &SelectNextControl, cx: &mut ViewContext) { + self.picker.update(cx, |picker, cx| { + let delegate = picker.delegate_mut(); + match delegate.mode { + Mode::ManageMembers => match delegate.selected_column { + Some(UserColumn::Remove) => { + delegate.selected_column = Some(UserColumn::ToggleAdmin) + } + Some(UserColumn::ToggleAdmin) => { + delegate.selected_column = Some(UserColumn::Remove) + } + None => todo!(), + }, + Mode::InviteMembers => {} + } + cx.notify() + }); + } +} + impl Entity for ChannelModal { type Event = PickerEvent; } @@ -60,11 +169,7 @@ impl View for ChannelModal { }) .on_click(MouseButton::Left, move |_, this, cx| { if !active { - this.picker.update(cx, |picker, cx| { - picker.delegate_mut().mode = mode; - picker.update_matches(picker.query(cx), cx); - cx.notify(); - }) + this.set_mode(mode, cx); } }) .with_cursor_style(if active { @@ -125,65 +230,29 @@ impl Modal for ChannelModal { } } -pub fn build_channel_modal( - user_store: ModelHandle, - channel_store: ModelHandle, - channel_id: ChannelId, - mode: Mode, - members: Vec<(Arc, proto::channel_member::Kind)>, - cx: &mut ViewContext, -) -> ChannelModal { - let picker = cx.add_view(|cx| { - Picker::new( - ChannelModalDelegate { - matches: Vec::new(), - selected_index: 0, - user_store: user_store.clone(), - channel_store: channel_store.clone(), - channel_id, - match_candidates: members - .iter() - .enumerate() - .map(|(id, member)| StringMatchCandidate { - id, - string: member.0.github_login.clone(), - char_bag: member.0.github_login.chars().collect(), - }) - .collect(), - members, - mode, - }, - cx, - ) - .with_theme(|theme| theme.collab_panel.channel_modal.picker.clone()) - }); - - cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); - let has_focus = picker.read(cx).has_focus(); - - ChannelModal { - picker, - channel_store, - channel_id, - has_focus, - } -} - #[derive(Copy, Clone, PartialEq)] pub enum Mode { ManageMembers, InviteMembers, } +#[derive(Copy, Clone, PartialEq)] +pub enum UserColumn { + ToggleAdmin, + Remove, +} + pub struct ChannelModalDelegate { - matches: Vec<(Arc, Option)>, + matching_users: Vec>, + matching_member_indices: Vec, user_store: ModelHandle, channel_store: ModelHandle, channel_id: ChannelId, selected_index: usize, mode: Mode, + selected_column: Option, match_candidates: Arc<[StringMatchCandidate]>, - members: Vec<(Arc, proto::channel_member::Kind)>, + members: Vec, } impl PickerDelegate for ChannelModalDelegate { @@ -192,7 +261,10 @@ impl PickerDelegate for ChannelModalDelegate { } fn match_count(&self) -> usize { - self.matches.len() + match self.mode { + Mode::ManageMembers => self.matching_member_indices.len(), + Mode::InviteMembers => self.matching_users.len(), + } } fn selected_index(&self) -> usize { @@ -201,6 +273,10 @@ impl PickerDelegate for ChannelModalDelegate { fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext>) { self.selected_index = ix; + self.selected_column = match self.mode { + Mode::ManageMembers => Some(UserColumn::ToggleAdmin), + Mode::InviteMembers => None, + }; } fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { @@ -220,11 +296,10 @@ impl PickerDelegate for ChannelModalDelegate { .await; picker.update(&mut cx, |picker, cx| { let delegate = picker.delegate_mut(); - delegate.matches.clear(); - delegate.matches.extend(matches.into_iter().map(|m| { - let member = &delegate.members[m.candidate_id]; - (member.0.clone(), Some(member.1)) - })); + delegate.matching_member_indices.clear(); + delegate + .matching_member_indices + .extend(matches.into_iter().map(|m| m.candidate_id)); cx.notify(); })?; anyhow::Ok(()) @@ -242,10 +317,7 @@ impl PickerDelegate for ChannelModalDelegate { let users = search_users.await?; picker.update(&mut cx, |picker, cx| { let delegate = picker.delegate_mut(); - delegate.matches.clear(); - delegate - .matches - .extend(users.into_iter().map(|user| (user, None))); + delegate.matching_users = users; cx.notify(); })?; anyhow::Ok(()) @@ -258,29 +330,23 @@ impl PickerDelegate for ChannelModalDelegate { } fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { - if let Some((user, _)) = self.matches.get(self.selected_index) { - match self.mode { - Mode::ManageMembers => self - .channel_store - .update(cx, |store, cx| { - store.remove_member(self.channel_id, user.id, cx) - }) - .detach(), - Mode::InviteMembers => match self.member_status(user.id, cx) { - Some(proto::channel_member::Kind::Member) => {} - Some(proto::channel_member::Kind::Invitee) => self - .channel_store + if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) { + match self.member_status(selected_user.id, cx) { + Some(proto::channel_member::Kind::Member) + | Some(proto::channel_member::Kind::Invitee) => { + if self.selected_column == Some(UserColumn::ToggleAdmin) { + self.set_member_admin(selected_user.id, !admin.unwrap_or(false), cx); + } else { + self.remove_member(selected_user.id, cx); + } + } + Some(proto::channel_member::Kind::AncestorMember) | None => { + self.channel_store .update(cx, |store, cx| { - store.remove_member(self.channel_id, user.id, cx) + store.invite_member(self.channel_id, selected_user.id, false, cx) }) - .detach(), - Some(proto::channel_member::Kind::AncestorMember) | None => self - .channel_store - .update(cx, |store, cx| { - store.invite_member(self.channel_id, user.id, false, cx) - }) - .detach(), - }, + .detach(); + } } } } @@ -297,17 +363,9 @@ impl PickerDelegate for ChannelModalDelegate { cx: &gpui::AppContext, ) -> AnyElement> { let theme = &theme::current(cx).collab_panel.channel_modal; - let (user, _) = &self.matches[ix]; + let (user, admin) = self.user_at_index(ix).unwrap(); let request_status = self.member_status(user.id, cx); - let icon_path = match request_status { - Some(proto::channel_member::Kind::AncestorMember) => Some("icons/check_8.svg"), - Some(proto::channel_member::Kind::Member) => Some("icons/check_8.svg"), - Some(proto::channel_member::Kind::Invitee) => Some("icons/x_mark_8.svg"), - None => None, - }; - let button_style = &theme.contact_button; - let style = theme.picker.item.in_state(selected).style_for(mouse_state); Flex::row() .with_children(user.avatar.clone().map(|avatar| { @@ -323,20 +381,69 @@ impl PickerDelegate for ChannelModalDelegate { .aligned() .left(), ) - .with_children(icon_path.map(|icon_path| { - Svg::new(icon_path) - .with_color(button_style.color) - .constrained() - .with_width(button_style.icon_width) - .aligned() + .with_children(admin.map(|admin| { + let member_style = theme.admin_toggle_part.in_state(!admin); + let admin_style = theme.admin_toggle_part.in_state(admin); + Flex::row() + .with_child( + Label::new("member", member_style.text.clone()) + .contained() + .with_style(member_style.container), + ) + .with_child( + Label::new("admin", admin_style.text.clone()) + .contained() + .with_style(admin_style.container), + ) .contained() - .with_style(button_style.container) - .constrained() - .with_width(button_style.button_width) - .with_height(button_style.button_width) + .with_style(theme.admin_toggle) .aligned() .flex_float() })) + .with_children({ + match self.mode { + Mode::ManageMembers => match request_status { + Some(proto::channel_member::Kind::Member) => Some( + Label::new("remove member", theme.remove_member_button.text.clone()) + .contained() + .with_style(theme.remove_member_button.container) + .into_any(), + ), + Some(proto::channel_member::Kind::Invitee) => Some( + Label::new("cancel invite", theme.cancel_invite_button.text.clone()) + .contained() + .with_style(theme.cancel_invite_button.container) + .into_any(), + ), + Some(proto::channel_member::Kind::AncestorMember) | None => None, + }, + Mode::InviteMembers => { + let svg = match request_status { + Some(proto::channel_member::Kind::Member) => Some( + Svg::new("icons/check_8.svg") + .with_color(theme.member_icon.color) + .constrained() + .with_width(theme.member_icon.width) + .aligned() + .contained() + .with_style(theme.member_icon.container), + ), + Some(proto::channel_member::Kind::Invitee) => Some( + Svg::new("icons/check_8.svg") + .with_color(theme.invitee_icon.color) + .constrained() + .with_width(theme.invitee_icon.width) + .aligned() + .contained() + .with_style(theme.invitee_icon.container), + ), + Some(proto::channel_member::Kind::AncestorMember) | None => None, + }; + + svg.map(|svg| svg.aligned().flex_float().into_any()) + } + } + }) .contained() .with_style(style.container) .constrained() @@ -353,11 +460,56 @@ impl ChannelModalDelegate { ) -> Option { self.members .iter() - .find_map(|(user, status)| (user.id == user_id).then_some(*status)) + .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind)) .or(self .channel_store .read(cx) .has_pending_channel_invite(self.channel_id, user_id) .then_some(proto::channel_member::Kind::Invitee)) } + + fn user_at_index(&self, ix: usize) -> Option<(Arc, Option)> { + match self.mode { + Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| { + let channel_membership = self.members.get(*ix)?; + Some(( + channel_membership.user.clone(), + Some(channel_membership.admin), + )) + }), + Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)), + } + } + + fn set_member_admin(&mut self, user_id: u64, admin: bool, cx: &mut ViewContext>) { + let update = self.channel_store.update(cx, |store, cx| { + store.set_member_admin(self.channel_id, user_id, admin, cx) + }); + cx.spawn(|picker, mut cx| async move { + update.await?; + picker.update(&mut cx, |picker, cx| { + let this = picker.delegate_mut(); + if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) { + member.admin = admin; + } + }) + }) + .detach_and_log_err(cx); + } + + fn remove_member(&mut self, user_id: u64, cx: &mut ViewContext>) { + let update = self.channel_store.update(cx, |store, cx| { + store.remove_member(self.channel_id, user_id, cx) + }); + cx.spawn(|picker, mut cx| async move { + update.await?; + picker.update(&mut cx, |picker, cx| { + let this = picker.delegate_mut(); + if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) { + this.members.remove(ix); + } + }) + }) + .detach_and_log_err(cx); + } } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 7dd5a0a893..8f187a87c6 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -140,6 +140,7 @@ message Envelope { RemoveChannel remove_channel = 127; GetChannelMembers get_channel_members = 128; GetChannelMembersResponse get_channel_members_response = 129; + SetChannelMemberAdmin set_channel_member_admin = 130; } } @@ -898,7 +899,8 @@ message GetChannelMembersResponse { message ChannelMember { uint64 user_id = 1; - Kind kind = 2; + bool admin = 2; + Kind kind = 3; enum Kind { Member = 0; @@ -927,6 +929,12 @@ message RemoveChannelMember { uint64 user_id = 2; } +message SetChannelMemberAdmin { + uint64 channel_id = 1; + uint64 user_id = 2; + bool admin = 3; +} + message RespondToChannelInvite { uint64 channel_id = 1; bool accept = 2; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index c23bbb23e4..fac011f803 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -217,6 +217,7 @@ messages!( (JoinChannel, Foreground), (RoomUpdated, Foreground), (SaveBuffer, Foreground), + (SetChannelMemberAdmin, Foreground), (SearchProject, Background), (SearchProjectResponse, Background), (ShareProject, Foreground), @@ -298,6 +299,7 @@ request_messages!( (RemoveContact, Ack), (RespondToContactRequest, Ack), (RespondToChannelInvite, Ack), + (SetChannelMemberAdmin, Ack), (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), (RemoveChannel, Ack), diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 8d0159d7ad..448f6ca5dd 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -255,8 +255,12 @@ pub struct ChannelModal { pub row_height: f32, pub contact_avatar: ImageStyle, pub contact_username: ContainerStyle, - pub contact_button: IconButton, - pub disabled_contact_button: IconButton, + pub remove_member_button: ContainedText, + pub cancel_invite_button: ContainedText, + pub member_icon: Icon, + pub invitee_icon: Icon, + pub admin_toggle: ContainerStyle, + pub admin_toggle_part: Toggleable, } #[derive(Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 951591676b..a097bc561f 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -41,6 +41,61 @@ export default function channel_modal(): any { } return { + member_icon: { + background: background(theme.middle), + padding: { + bottom: 4, + left: 4, + right: 4, + top: 4, + }, + width: 5, + color: foreground(theme.middle, "accent"), + }, + invitee_icon: { + background: background(theme.middle), + padding: { + bottom: 4, + left: 4, + right: 4, + top: 4, + }, + width: 5, + color: foreground(theme.middle, "accent"), + }, + remove_member_button: { + ...text(theme.middle, "sans", { size: "xs" }), + background: background(theme.middle), + padding: { + left: 7, + right: 7 + } + }, + cancel_invite_button: { + ...text(theme.middle, "sans", { size: "xs" }), + background: background(theme.middle), + }, + admin_toggle_part: toggleable({ + base: { + ...text(theme.middle, "sans", { size: "xs" }), + padding: { + left: 7, + right: 7, + }, + }, + state: { + active: { + background: background(theme.middle, "on"), + } + } + }), + admin_toggle: { + border: border(theme.middle, "active"), + background: background(theme.middle), + margin: { + right: 8, + } + }, container: { background: background(theme.lowest), border: border(theme.lowest),