Add context menu controls to the channel member management

Mikayla and Max created

co-authored-by: Max <max@zed.dev>

Change summary

crates/collab_ui/src/collab_panel/channel_modal.rs | 244 ++++++++++-----
crates/theme/src/theme.rs                          |   2 
styles/src/style_tree/channel_modal.ts             |   8 
3 files changed, 161 insertions(+), 93 deletions(-)

Detailed changes

crates/collab_ui/src/collab_panel/channel_modal.rs 🔗

@@ -1,4 +1,5 @@
 use client::{proto, ChannelId, ChannelMembership, ChannelStore, User, UserId, UserStore};
+use context_menu::{ContextMenu, ContextMenuItem};
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
     actions,
@@ -11,12 +12,21 @@ use std::sync::Arc;
 use util::TryFutureExt;
 use workspace::Modal;
 
-actions!(channel_modal, [SelectNextControl, ToggleMode]);
+actions!(
+    channel_modal,
+    [
+        SelectNextControl,
+        ToggleMode,
+        ToggleMemberAdmin,
+        RemoveMember
+    ]
+);
 
 pub fn init(cx: &mut AppContext) {
     Picker::<ChannelModalDelegate>::init(cx);
     cx.add_action(ChannelModal::toggle_mode);
-    // cx.add_action(ChannelModal::select_next_control);
+    cx.add_action(ChannelModal::toggle_member_admin);
+    cx.add_action(ChannelModal::remove_member);
 }
 
 pub struct ChannelModal {
@@ -48,7 +58,11 @@ impl ChannelModal {
                     match_candidates: Vec::new(),
                     members,
                     mode,
-                    selected_column: None,
+                    context_menu: cx.add_view(|cx| {
+                        let mut menu = ContextMenu::new(cx.view_id(), cx);
+                        menu.set_position_mode(OverlayPositionMode::Local);
+                        menu
+                    }),
                 },
                 cx,
             )
@@ -95,6 +109,8 @@ impl ChannelModal {
                 this.picker.update(cx, |picker, cx| {
                     let delegate = picker.delegate_mut();
                     delegate.mode = mode;
+                    delegate.selected_index = 0;
+                    picker.set_query("", cx);
                     picker.update_matches(picker.query(cx), cx);
                     cx.notify()
                 });
@@ -104,24 +120,17 @@ impl ChannelModal {
         .detach();
     }
 
-    // fn select_next_control(&mut self, _: &SelectNextControl, cx: &mut ViewContext<Self>) {
-    //     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()
-    //     });
-    // }
+    fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate_mut().toggle_selected_member_admin(cx);
+        })
+    }
+
+    fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate_mut().remove_selected_member(cx);
+        });
+    }
 }
 
 impl Entity for ChannelModal {
@@ -233,12 +242,6 @@ pub enum Mode {
     InviteMembers,
 }
 
-#[derive(Copy, Clone, PartialEq)]
-pub enum UserColumn {
-    ToggleAdmin,
-    Remove,
-}
-
 pub struct ChannelModalDelegate {
     matching_users: Vec<Arc<User>>,
     matching_member_indices: Vec<usize>,
@@ -247,9 +250,9 @@ pub struct ChannelModalDelegate {
     channel_id: ChannelId,
     selected_index: usize,
     mode: Mode,
-    selected_column: Option<UserColumn>,
     match_candidates: Vec<StringMatchCandidate>,
     members: Vec<ChannelMembership>,
+    context_menu: ViewHandle<ContextMenu>,
 }
 
 impl PickerDelegate for ChannelModalDelegate {
@@ -270,10 +273,6 @@ impl PickerDelegate for ChannelModalDelegate {
 
     fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
         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<Picker<Self>>) -> Task<()> {
@@ -334,18 +333,17 @@ impl PickerDelegate for ChannelModalDelegate {
 
     fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
         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 {
+            match self.mode {
+                Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx),
+                Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
+                    Some(proto::channel_member::Kind::Invitee) => {
                         self.remove_member(selected_user.id, cx);
                     }
-                }
-                Some(proto::channel_member::Kind::AncestorMember) | None => {
-                    self.invite_member(selected_user, cx)
-                }
+                    Some(proto::channel_member::Kind::AncestorMember) | None => {
+                        self.invite_member(selected_user, cx)
+                    }
+                    Some(proto::channel_member::Kind::Member) => {}
+                },
             }
         }
     }
@@ -366,7 +364,10 @@ impl PickerDelegate for ChannelModalDelegate {
         let request_status = self.member_status(user.id, cx);
 
         let style = theme.picker.item.in_state(selected).style_for(mouse_state);
-        Flex::row()
+
+        let in_manage = matches!(self.mode, Mode::ManageMembers);
+
+        let mut result = Flex::row()
             .with_children(user.avatar.clone().map(|avatar| {
                 Image::from_data(avatar)
                     .with_style(theme.contact_avatar)
@@ -380,57 +381,81 @@ impl PickerDelegate for ChannelModalDelegate {
                     .aligned()
                     .left(),
             )
-            .with_children(admin.map(|_| {
-                Label::new("admin", theme.admin_toggle.text.clone())
-                    .contained()
-                    .with_style(theme.admin_toggle.container)
-                    .aligned()
+            .with_children({
+                (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then(
+                    || {
+                        Label::new("Invited", theme.member_tag.text.clone())
+                            .contained()
+                            .with_style(theme.member_tag.container)
+                            .aligned()
+                            .left()
+                    },
+                )
+            })
+            .with_children(admin.and_then(|admin| {
+                (in_manage && admin).then(|| {
+                    Label::new("Admin", theme.member_tag.text.clone())
+                        .contained()
+                        .with_style(theme.member_tag.container)
+                        .aligned()
+                        .left()
+                })
             }))
             .with_children({
-                match self.mode {
-                    Mode::ManageMembers => match request_status {
+                let svg = match self.mode {
+                    Mode::ManageMembers => Some(
+                        Svg::new("icons/ellipsis_14.svg")
+                            .with_color(theme.member_icon.color)
+                            .constrained()
+                            .with_width(theme.member_icon.width)
+                            .aligned()
+                            .contained()
+                            .with_style(theme.member_icon.container),
+                    ),
+                    Mode::InviteMembers => 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(
-                            Label::new("cancel invite", theme.cancel_invite_button.text.clone())
+                            Svg::new("icons/check_8.svg")
+                                .with_color(theme.invitee_icon.color)
+                                .constrained()
+                                .with_width(theme.invitee_icon.width)
+                                .aligned()
                                 .contained()
-                                .with_style(theme.cancel_invite_button.container)
-                                .into_any(),
+                                .with_style(theme.invitee_icon.container),
                         ),
-                        Some(proto::channel_member::Kind::Member)
-                        | Some(proto::channel_member::Kind::AncestorMember)
-                        | None => None,
+                        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())
-                    }
-                }
+                };
+
+                svg.map(|svg| svg.aligned().flex_float().into_any())
             })
             .contained()
             .with_style(style.container)
             .constrained()
             .with_height(theme.row_height)
-            .into_any()
+            .into_any();
+
+        if selected {
+            result = Stack::new()
+                .with_child(result)
+                .with_child(
+                    ChildView::new(&self.context_menu, cx)
+                        .aligned()
+                        .top()
+                        .right(),
+                )
+                .into_any();
+        }
+
+        result
     }
 }
 
@@ -464,20 +489,30 @@ impl ChannelModalDelegate {
         }
     }
 
-    fn set_member_admin(&mut self, user_id: u64, admin: bool, cx: &mut ViewContext<Picker<Self>>) {
+    fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
+        let (user, admin) = self.user_at_index(self.selected_index)?;
+        let admin = !admin.unwrap_or(false);
         let update = self.channel_store.update(cx, |store, cx| {
-            store.set_member_admin(self.channel_id, user_id, admin, 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, _| {
+            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) {
+                if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
                     member.admin = admin;
                 }
+                cx.notify();
             })
         })
         .detach_and_log_err(cx);
+        Some(())
+    }
+
+    fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
+        let (user, _) = self.user_at_index(self.selected_index)?;
+        self.remove_member(user.id, cx);
+        Some(())
     }
 
     fn remove_member(&mut self, user_id: u64, cx: &mut ViewContext<Picker<Self>>) {
@@ -486,11 +521,20 @@ impl ChannelModalDelegate {
         });
         cx.spawn(|picker, mut cx| async move {
             update.await?;
-            picker.update(&mut cx, |picker, _| {
+            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);
+                    this.matching_member_indices.retain_mut(|member_ix| {
+                        if *member_ix == ix {
+                            return false;
+                        } else if *member_ix > ix {
+                            *member_ix -= 1;
+                        }
+                        true
+                    })
                 }
+                cx.notify();
             })
         })
         .detach_and_log_err(cx);
@@ -505,8 +549,7 @@ impl ChannelModalDelegate {
             invite_member.await?;
 
             this.update(&mut cx, |this, cx| {
-                let delegate_mut = this.delegate_mut();
-                delegate_mut.members.push(ChannelMembership {
+                this.delegate_mut().members.push(ChannelMembership {
                     user,
                     kind: proto::channel_member::Kind::Invitee,
                     admin: false,
@@ -516,4 +559,25 @@ impl ChannelModalDelegate {
         })
         .detach_and_log_err(cx);
     }
+
+    fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext<Picker<Self>>) {
+        self.context_menu.update(cx, |context_menu, cx| {
+            context_menu.show(
+                Default::default(),
+                AnchorCorner::TopRight,
+                vec![
+                    ContextMenuItem::action("Remove", RemoveMember),
+                    ContextMenuItem::action(
+                        if user_is_admin {
+                            "Make non-admin"
+                        } else {
+                            "Make admin"
+                        },
+                        ToggleMemberAdmin,
+                    ),
+                ],
+                cx,
+            )
+        })
+    }
 }

crates/theme/src/theme.rs 🔗

@@ -261,7 +261,7 @@ pub struct ChannelModal {
     pub cancel_invite_button: ContainedText,
     pub member_icon: Icon,
     pub invitee_icon: Icon,
-    pub admin_toggle: ContainedText,
+    pub member_tag: ContainedText,
 }
 
 #[derive(Deserialize, Default, JsonSchema)]

styles/src/style_tree/channel_modal.ts 🔗

@@ -76,12 +76,16 @@ export default function channel_modal(): any {
             ...text(theme.middle, "sans", { size: "xs" }),
             background: background(theme.middle),
         },
-        admin_toggle: {
+        member_tag: {
             ...text(theme.middle, "sans", { size: "xs" }),
             border: border(theme.middle, "active"),
             background: background(theme.middle),
             margin: {
-                right: 8,
+                left: 8,
+            },
+            padding: {
+                left: 4,
+                right: 4,
             }
         },
         container: {