From 1c3698ae20cdb2ec9aace81bb29d56f19e4f2888 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 19 Dec 2023 12:02:35 -0800 Subject: [PATCH] Implement channel modal Co-authored-by: Nathan --- crates/collab_ui2/src/collab_panel.rs | 10 +- .../src/collab_panel/channel_modal.rs | 430 ++++++++---------- crates/gpui2/src/window.rs | 7 - crates/picker2/src/picker2.rs | 2 +- crates/ui2/src/components/context_menu.rs | 5 +- crates/ui2/src/components/icon.rs | 2 + 6 files changed, 213 insertions(+), 243 deletions(-) diff --git a/crates/collab_ui2/src/collab_panel.rs b/crates/collab_ui2/src/collab_panel.rs index c58d7c47bc..3df8f06d3b 100644 --- a/crates/collab_ui2/src/collab_panel.rs +++ b/crates/collab_ui2/src/collab_panel.rs @@ -2167,13 +2167,13 @@ impl CollabPanel { let controls = if is_incoming { vec![ - IconButton::new("remove_contact", Icon::Close) + IconButton::new("decline-contact", Icon::Close) .on_click(cx.listener(move |this, _, cx| { this.respond_to_contact_request(user_id, false, cx); })) .icon_color(color) .tooltip(|cx| Tooltip::text("Decline invite", cx)), - IconButton::new("remove_contact", Icon::Check) + IconButton::new("accept-contact", Icon::Check) .on_click(cx.listener(move |this, _, cx| { this.respond_to_contact_request(user_id, true, cx); })) @@ -2220,15 +2220,15 @@ impl CollabPanel { }; let controls = [ - IconButton::new("remove_contact", Icon::Close) + IconButton::new("reject-invite", Icon::Close) .on_click(cx.listener(move |this, _, cx| { this.respond_to_channel_invite(channel_id, false, cx); })) .icon_color(color) .tooltip(|cx| Tooltip::text("Decline invite", cx)), - IconButton::new("remove_contact", Icon::Check) + IconButton::new("accept-invite", Icon::Check) .on_click(cx.listener(move |this, _, cx| { - this.respond_to_contact_request(channel_id, true, cx); + this.respond_to_channel_invite(channel_id, true, cx); })) .icon_color(color) .tooltip(|cx| Tooltip::text("Accept invite", cx)), diff --git a/crates/collab_ui2/src/collab_panel/channel_modal.rs b/crates/collab_ui2/src/collab_panel/channel_modal.rs index 0b407f0647..f844c609ca 100644 --- a/crates/collab_ui2/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui2/src/collab_panel/channel_modal.rs @@ -5,13 +5,13 @@ use client::{ }; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, div, AppContext, ClipboardItem, DismissEvent, Div, Entity, EventEmitter, - FocusableView, Model, ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, - WeakView, + actions, div, overlay, AppContext, ClipboardItem, DismissEvent, Div, EventEmitter, + FocusableView, Model, ParentElement, Render, Styled, Subscription, Task, View, ViewContext, + VisualContext, WeakView, }; use picker::{Picker, PickerDelegate}; use std::sync::Arc; -use ui::{prelude::*, Checkbox}; +use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem}; use util::TryFutureExt; use workspace::ModalView; @@ -25,19 +25,10 @@ actions!( ] ); -// pub fn init(cx: &mut AppContext) { -// Picker::::init(cx); -// cx.add_action(ChannelModal::toggle_mode); -// cx.add_action(ChannelModal::toggle_member_admin); -// cx.add_action(ChannelModal::remove_member); -// cx.add_action(ChannelModal::dismiss); -// } - pub struct ChannelModal { picker: View>, channel_store: Model, channel_id: ChannelId, - has_focus: bool, } impl ChannelModal { @@ -62,25 +53,19 @@ impl ChannelModal { channel_store: channel_store.clone(), channel_id, match_candidates: Vec::new(), + context_menu: None, members, mode, - // context_menu: cx.add_view(|cx| { - // let mut menu = ContextMenu::new(cx.view_id(), cx); - // menu.set_position_mode(OverlayPositionMode::Local); - // menu - // }), }, cx, ) + .modal(false) }); - let has_focus = picker.focus_handle(cx).contains_focused(cx); - Self { picker, channel_store, channel_id, - has_focus, } } @@ -126,15 +111,19 @@ impl ChannelModal { .detach(); } - fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext) { - self.picker.update(cx, |picker, cx| { - picker.delegate.toggle_selected_member_admin(cx); - }) - } - - fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext) { - self.picker.update(cx, |picker, cx| { - picker.delegate.remove_selected_member(cx); + fn set_channel_visiblity(&mut self, selection: &Selection, cx: &mut ViewContext) { + self.channel_store.update(cx, |channel_store, cx| { + channel_store + .set_channel_visibility( + self.channel_id, + match selection { + Selection::Unselected => ChannelVisibility::Members, + Selection::Selected => ChannelVisibility::Public, + Selection::Indeterminate => return, + }, + cx, + ) + .detach_and_log_err(cx) }); } @@ -160,49 +149,79 @@ impl Render for ChannelModal { let Some(channel) = channel_store.channel_for_id(self.channel_id) else { return div(); }; + let channel_name = channel.name.clone(); + let channel_id = channel.id; + let visibility = channel.visibility; let mode = self.picker.read(cx).delegate.mode; v_stack() - .bg(cx.theme().colors().elevated_surface_background) + .key_context("ChannelModal") + .on_action(cx.listener(Self::toggle_mode)) + .on_action(cx.listener(Self::dismiss)) + .elevation_3(cx) .w(rems(34.)) - .child(Label::new(channel.name.clone())) .child( - div() - .w_full() - .flex_row() - .child(Checkbox::new( - "is-public", - if channel.visibility == ChannelVisibility::Public { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, - )) - .child(Label::new("Public")), - ) - .child( - div() - .w_full() - .flex_row() + v_stack() + .px_2() + .py_1() + .rounded_t(px(8.)) + .bg(cx.theme().colors().element_background) + .child(IconElement::new(Icon::Hash).size(IconSize::Medium)) + .child(Label::new(channel_name)) .child( - Button::new("manage-members", "Manage Members") - .selected(mode == Mode::ManageMembers) - .on_click(cx.listener(|this, _, cx| { - this.picker.update(cx, |picker, _| { - picker.delegate.mode = Mode::ManageMembers - }); - cx.notify(); - })), + h_stack() + .w_full() + .justify_between() + .child( + h_stack() + .gap_2() + .child( + Checkbox::new( + "is-public", + if visibility == ChannelVisibility::Public { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, + ) + .on_click(cx.listener(Self::set_channel_visiblity)), + ) + .child(Label::new("Public")), + ) + .children(if visibility == ChannelVisibility::Public { + Some(Button::new("copy-link", "Copy Link").on_click(cx.listener( + move |this, _, cx| { + if let Some(channel) = + this.channel_store.read(cx).channel_for_id(channel_id) + { + let item = ClipboardItem::new(channel.link()); + cx.write_to_clipboard(item); + } + }, + ))) + } else { + None + }), ) .child( - Button::new("invite-members", "Invite Members") - .selected(mode == Mode::InviteMembers) - .on_click(cx.listener(|this, _, cx| { - this.picker.update(cx, |picker, _| { - picker.delegate.mode = Mode::InviteMembers - }); - cx.notify(); - })), + div() + .w_full() + .flex() + .flex_row() + .child( + Button::new("manage-members", "Manage Members") + .selected(mode == Mode::ManageMembers) + .on_click(cx.listener(|this, _, cx| { + this.set_mode(Mode::ManageMembers, cx); + })), + ) + .child( + Button::new("invite-members", "Invite Members") + .selected(mode == Mode::InviteMembers) + .on_click(cx.listener(|this, _, cx| { + this.set_mode(Mode::InviteMembers, cx); + })), + ), ), ) .child(self.picker.clone()) @@ -226,11 +245,11 @@ pub struct ChannelModalDelegate { mode: Mode, match_candidates: Vec, members: Vec, - // context_menu: ViewHandle, + context_menu: Option<(View, Subscription)>, } impl PickerDelegate for ChannelModalDelegate { - type ListItem = Div; + type ListItem = ListItem; fn placeholder_text(&self) -> Arc { "Search collaborator by username...".into() @@ -310,11 +329,11 @@ impl PickerDelegate for ChannelModalDelegate { if let Some((selected_user, role)) = self.user_at_index(self.selected_index) { match self.mode { Mode::ManageMembers => { - self.show_context_menu(role.unwrap_or(ChannelRole::Member), cx) + self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx) } Mode::InviteMembers => match self.member_status(selected_user.id, cx) { Some(proto::channel_member::Kind::Invitee) => { - self.remove_selected_member(cx); + self.remove_member(selected_user.id, cx); } Some(proto::channel_member::Kind::AncestorMember) | None => { self.invite_member(selected_user, cx) @@ -326,11 +345,13 @@ impl PickerDelegate for ChannelModalDelegate { } fn dismissed(&mut self, cx: &mut ViewContext>) { - self.channel_modal - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); + if self.context_menu.is_none() { + self.channel_modal + .update(cx, |_, cx| { + cx.emit(DismissEvent); + }) + .ok(); + } } fn render_match( @@ -339,129 +360,54 @@ impl PickerDelegate for ChannelModalDelegate { selected: bool, cx: &mut ViewContext>, ) -> Option { - None - // let full_theme = &theme::current(cx); - // let theme = &full_theme.collab_panel.channel_modal; - // let tabbed_modal = &full_theme.collab_panel.tabbed_modal; - // let (user, role) = self.user_at_index(ix).unwrap(); - // let request_status = self.member_status(user.id, cx); + let (user, role) = self.user_at_index(ix)?; + let request_status = self.member_status(user.id, cx); - // let style = tabbed_modal - // .picker - // .item - // .in_state(selected) - // .style_for(mouse_state); - - // 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) - // .aligned() - // .left() - // })) - // .with_child( - // Label::new(user.github_login.clone(), style.label.clone()) - // .contained() - // .with_style(theme.contact_username) - // .aligned() - // .left(), - // ) - // .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(if in_manage && role == Some(ChannelRole::Admin) { - // Some( - // Label::new("Admin", theme.member_tag.text.clone()) - // .contained() - // .with_style(theme.member_tag.container) - // .aligned() - // .left(), - // ) - // } else if in_manage && role == Some(ChannelRole::Guest) { - // Some( - // Label::new("Guest", theme.member_tag.text.clone()) - // .contained() - // .with_style(theme.member_tag.container) - // .aligned() - // .left(), - // ) - // } else { - // None - // }) - // .with_children({ - // let svg = match self.mode { - // Mode::ManageMembers => Some( - // Svg::new("icons/ellipsis.svg") - // .with_color(theme.member_icon.color) - // .constrained() - // .with_width(theme.member_icon.icon_width) - // .aligned() - // .constrained() - // .with_width(theme.member_icon.button_width) - // .with_height(theme.member_icon.button_width) - // .contained() - // .with_style(theme.member_icon.container), - // ), - // Mode::InviteMembers => match request_status { - // Some(proto::channel_member::Kind::Member) => Some( - // Svg::new("icons/check.svg") - // .with_color(theme.member_icon.color) - // .constrained() - // .with_width(theme.member_icon.icon_width) - // .aligned() - // .constrained() - // .with_width(theme.member_icon.button_width) - // .with_height(theme.member_icon.button_width) - // .contained() - // .with_style(theme.member_icon.container), - // ), - // Some(proto::channel_member::Kind::Invitee) => Some( - // Svg::new("icons/check.svg") - // .with_color(theme.invitee_icon.color) - // .constrained() - // .with_width(theme.invitee_icon.icon_width) - // .aligned() - // .constrained() - // .with_width(theme.invitee_icon.button_width) - // .with_height(theme.invitee_icon.button_width) - // .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() - // .with_height(tabbed_modal.row_height) - // .into_any(); - - // if selected { - // result = Stack::new() - // .with_child(result) - // .with_child( - // ChildView::new(&self.context_menu, cx) - // .aligned() - // .top() - // .right(), - // ) - // .into_any(); - // } - - // result + Some( + ListItem::new(ix) + .inset(true) + .selected(selected) + .start_slot(Avatar::new(user.avatar_uri.clone())) + .child(Label::new(user.github_login.clone())) + .end_slot(h_stack().gap_2().map(|slot| { + match self.mode { + Mode::ManageMembers => slot + .children( + if request_status == Some(proto::channel_member::Kind::Invitee) { + Some(Label::new("Invited")) + } else { + None + }, + ) + .children(match role { + Some(ChannelRole::Admin) => Some(Label::new("Admin")), + Some(ChannelRole::Guest) => Some(Label::new("Guest")), + _ => None, + }) + .child(IconButton::new("ellipsis", Icon::Ellipsis)) + .children( + if let (Some((menu, _)), true) = (&self.context_menu, selected) { + Some( + overlay() + .anchor(gpui::AnchorCorner::TopLeft) + .child(menu.clone()), + ) + } else { + None + }, + ), + Mode::InviteMembers => match request_status { + Some(proto::channel_member::Kind::Invitee) => { + slot.children(Some(Label::new("Invited"))) + } + Some(proto::channel_member::Kind::Member) => { + slot.children(Some(Label::new("Member"))) + } + _ => slot, + }, + } + })), + ) } } @@ -495,21 +441,20 @@ impl ChannelModalDelegate { } } - fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext>) -> Option<()> { - let (user, role) = self.user_at_index(self.selected_index)?; - let new_role = if role == Some(ChannelRole::Admin) { - ChannelRole::Member - } else { - ChannelRole::Admin - }; + fn set_user_role( + &mut self, + user_id: UserId, + new_role: ChannelRole, + cx: &mut ViewContext>, + ) -> Option<()> { let update = self.channel_store.update(cx, |store, cx| { - store.set_member_role(self.channel_id, user.id, new_role, cx) + store.set_member_role(self.channel_id, user_id, new_role, cx) }); cx.spawn(|picker, mut cx| async move { update.await?; picker.update(&mut cx, |picker, cx| { let this = &mut picker.delegate; - 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.role = new_role; } cx.focus_self(); @@ -520,9 +465,7 @@ impl ChannelModalDelegate { Some(()) } - fn remove_selected_member(&mut self, cx: &mut ViewContext>) -> Option<()> { - let (user, _) = self.user_at_index(self.selected_index)?; - let user_id = user.id; + fn remove_member(&mut self, user_id: UserId, cx: &mut ViewContext>) -> Option<()> { let update = self.channel_store.update(cx, |store, cx| { store.remove_member(self.channel_id, user_id, cx) }); @@ -546,7 +489,7 @@ impl ChannelModalDelegate { .selected_index .min(this.matching_member_indices.len().saturating_sub(1)); - cx.focus_self(); + picker.focus(cx); cx.notify(); }) }) @@ -579,24 +522,55 @@ impl ChannelModalDelegate { .detach_and_log_err(cx); } - fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext>) { - // self.context_menu.update(cx, |context_menu, cx| { - // context_menu.show( - // Default::default(), - // AnchorCorner::TopRight, - // vec![ - // ContextMenuItem::action("Remove", RemoveMember), - // ContextMenuItem::action( - // if role == ChannelRole::Admin { - // "Make non-admin" - // } else { - // "Make admin" - // }, - // ToggleMemberAdmin, - // ), - // ], - // cx, - // ) - // }) + fn show_context_menu( + &mut self, + user: Arc, + role: ChannelRole, + cx: &mut ViewContext>, + ) { + let user_id = user.id; + let picker = cx.view().clone(); + let context_menu = ContextMenu::build(cx, |mut menu, _cx| { + menu = menu.entry("Remove Member", { + let picker = picker.clone(); + move |cx| { + picker.update(cx, |picker, cx| { + picker.delegate.remove_member(user_id, cx); + }) + } + }); + + let picker = picker.clone(); + match role { + ChannelRole::Admin => { + menu = menu.entry("Revoke Admin", move |cx| { + picker.update(cx, |picker, cx| { + picker + .delegate + .set_user_role(user_id, ChannelRole::Member, cx); + }) + }); + } + ChannelRole::Member => { + menu = menu.entry("Make Admin", move |cx| { + picker.update(cx, |picker, cx| { + picker + .delegate + .set_user_role(user_id, ChannelRole::Admin, cx); + }) + }); + } + _ => {} + }; + + menu + }); + cx.focus_view(&context_menu); + let subscription = cx.subscribe(&context_menu, |picker, _, _: &DismissEvent, cx| { + picker.delegate.context_menu = None; + picker.focus(cx); + cx.notify(); + }); + self.context_menu = Some((context_menu, subscription)); } } diff --git a/crates/gpui2/src/window.rs b/crates/gpui2/src/window.rs index 71799a6e00..f7ebddd0fe 100644 --- a/crates/gpui2/src/window.rs +++ b/crates/gpui2/src/window.rs @@ -2662,13 +2662,6 @@ impl<'a, V: 'static> ViewContext<'a, V> { self.defer(|view, cx| view.focus_handle(cx).focus(cx)) } - pub fn dismiss_self(&mut self) - where - V: ManagedView, - { - self.defer(|_, cx| cx.emit(DismissEvent)) - } - pub fn listener( &self, f: impl Fn(&mut V, &E, &mut ViewContext) + 'static, diff --git a/crates/picker2/src/picker2.rs b/crates/picker2/src/picker2.rs index 70180112f9..8a75996f48 100644 --- a/crates/picker2/src/picker2.rs +++ b/crates/picker2/src/picker2.rs @@ -239,7 +239,7 @@ impl Render for Picker { ); div() - .key_context("picker") + .key_context("Picker") .size_full() .when_some(self.width, |el, width| { el.w(width) diff --git a/crates/ui2/src/components/context_menu.rs b/crates/ui2/src/components/context_menu.rs index 250272b198..8fce15d1c6 100644 --- a/crates/ui2/src/components/context_menu.rs +++ b/crates/ui2/src/components/context_menu.rs @@ -72,11 +72,11 @@ impl ContextMenu { pub fn entry( mut self, label: impl Into, - on_click: impl Fn(&mut WindowContext) + 'static, + handler: impl Fn(&mut WindowContext) + 'static, ) -> Self { self.items.push(ContextMenuItem::Entry { label: label.into(), - handler: Rc::new(on_click), + handler: Rc::new(handler), icon: None, action: None, }); @@ -114,6 +114,7 @@ impl ContextMenu { pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { cx.emit(DismissEvent); + cx.emit(DismissEvent); } fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { diff --git a/crates/ui2/src/components/icon.rs b/crates/ui2/src/components/icon.rs index ca50cae7f8..a168f97a50 100644 --- a/crates/ui2/src/components/icon.rs +++ b/crates/ui2/src/components/icon.rs @@ -51,6 +51,7 @@ pub enum Icon { CopilotDisabled, Dash, Disconnected, + Ellipsis, Envelope, ExternalLink, ExclamationTriangle, @@ -133,6 +134,7 @@ impl Icon { Icon::CopilotDisabled => "icons/copilot_disabled.svg", Icon::Dash => "icons/dash.svg", Icon::Disconnected => "icons/disconnected.svg", + Icon::Ellipsis => "icons/ellipsis.svg", Icon::Envelope => "icons/feedback.svg", Icon::ExclamationTriangle => "icons/warning.svg", Icon::ExternalLink => "icons/external_link.svg",