Implement channel modal

Co-authored-by: Nathan <nathan@zed.dev>
This commit is contained in:
Max Brunsfeld 2023-12-19 12:02:35 -08:00
parent 80b6922de7
commit 1c3698ae20
6 changed files with 213 additions and 243 deletions

View file

@ -2167,13 +2167,13 @@ impl CollabPanel {
let controls = if is_incoming { let controls = if is_incoming {
vec![ vec![
IconButton::new("remove_contact", Icon::Close) IconButton::new("decline-contact", Icon::Close)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.respond_to_contact_request(user_id, false, cx); this.respond_to_contact_request(user_id, false, cx);
})) }))
.icon_color(color) .icon_color(color)
.tooltip(|cx| Tooltip::text("Decline invite", cx)), .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| { .on_click(cx.listener(move |this, _, cx| {
this.respond_to_contact_request(user_id, true, cx); this.respond_to_contact_request(user_id, true, cx);
})) }))
@ -2220,15 +2220,15 @@ impl CollabPanel {
}; };
let controls = [ let controls = [
IconButton::new("remove_contact", Icon::Close) IconButton::new("reject-invite", Icon::Close)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.respond_to_channel_invite(channel_id, false, cx); this.respond_to_channel_invite(channel_id, false, cx);
})) }))
.icon_color(color) .icon_color(color)
.tooltip(|cx| Tooltip::text("Decline invite", cx)), .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| { .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) .icon_color(color)
.tooltip(|cx| Tooltip::text("Accept invite", cx)), .tooltip(|cx| Tooltip::text("Accept invite", cx)),

View file

@ -5,13 +5,13 @@ use client::{
}; };
use fuzzy::{match_strings, StringMatchCandidate}; use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{ use gpui::{
actions, div, AppContext, ClipboardItem, DismissEvent, Div, Entity, EventEmitter, actions, div, overlay, AppContext, ClipboardItem, DismissEvent, Div, EventEmitter,
FocusableView, Model, ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, FocusableView, Model, ParentElement, Render, Styled, Subscription, Task, View, ViewContext,
WeakView, VisualContext, WeakView,
}; };
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use std::sync::Arc; use std::sync::Arc;
use ui::{prelude::*, Checkbox}; use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem};
use util::TryFutureExt; use util::TryFutureExt;
use workspace::ModalView; use workspace::ModalView;
@ -25,19 +25,10 @@ actions!(
] ]
); );
// pub fn init(cx: &mut AppContext) {
// Picker::<ChannelModalDelegate>::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 { pub struct ChannelModal {
picker: View<Picker<ChannelModalDelegate>>, picker: View<Picker<ChannelModalDelegate>>,
channel_store: Model<ChannelStore>, channel_store: Model<ChannelStore>,
channel_id: ChannelId, channel_id: ChannelId,
has_focus: bool,
} }
impl ChannelModal { impl ChannelModal {
@ -62,25 +53,19 @@ impl ChannelModal {
channel_store: channel_store.clone(), channel_store: channel_store.clone(),
channel_id, channel_id,
match_candidates: Vec::new(), match_candidates: Vec::new(),
context_menu: None,
members, members,
mode, mode,
// context_menu: cx.add_view(|cx| {
// let mut menu = ContextMenu::new(cx.view_id(), cx);
// menu.set_position_mode(OverlayPositionMode::Local);
// menu
// }),
}, },
cx, cx,
) )
.modal(false)
}); });
let has_focus = picker.focus_handle(cx).contains_focused(cx);
Self { Self {
picker, picker,
channel_store, channel_store,
channel_id, channel_id,
has_focus,
} }
} }
@ -126,15 +111,19 @@ impl ChannelModal {
.detach(); .detach();
} }
fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) { fn set_channel_visiblity(&mut self, selection: &Selection, cx: &mut ViewContext<Self>) {
self.picker.update(cx, |picker, cx| { self.channel_store.update(cx, |channel_store, cx| {
picker.delegate.toggle_selected_member_admin(cx); channel_store
}) .set_channel_visibility(
} self.channel_id,
match selection {
fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) { Selection::Unselected => ChannelVisibility::Members,
self.picker.update(cx, |picker, cx| { Selection::Selected => ChannelVisibility::Public,
picker.delegate.remove_selected_member(cx); 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 { let Some(channel) = channel_store.channel_for_id(self.channel_id) else {
return div(); 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; let mode = self.picker.read(cx).delegate.mode;
v_stack() 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.)) .w(rems(34.))
.child(Label::new(channel.name.clone()))
.child( .child(
div() v_stack()
.w_full() .px_2()
.flex_row() .py_1()
.child(Checkbox::new( .rounded_t(px(8.))
"is-public", .bg(cx.theme().colors().element_background)
if channel.visibility == ChannelVisibility::Public { .child(IconElement::new(Icon::Hash).size(IconSize::Medium))
ui::Selection::Selected .child(Label::new(channel_name))
} else {
ui::Selection::Unselected
},
))
.child(Label::new("Public")),
)
.child(
div()
.w_full()
.flex_row()
.child( .child(
Button::new("manage-members", "Manage Members") h_stack()
.selected(mode == Mode::ManageMembers) .w_full()
.on_click(cx.listener(|this, _, cx| { .justify_between()
this.picker.update(cx, |picker, _| { .child(
picker.delegate.mode = Mode::ManageMembers h_stack()
}); .gap_2()
cx.notify(); .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( .child(
Button::new("invite-members", "Invite Members") div()
.selected(mode == Mode::InviteMembers) .w_full()
.on_click(cx.listener(|this, _, cx| { .flex()
this.picker.update(cx, |picker, _| { .flex_row()
picker.delegate.mode = Mode::InviteMembers .child(
}); Button::new("manage-members", "Manage Members")
cx.notify(); .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()) .child(self.picker.clone())
@ -226,11 +245,11 @@ pub struct ChannelModalDelegate {
mode: Mode, mode: Mode,
match_candidates: Vec<StringMatchCandidate>, match_candidates: Vec<StringMatchCandidate>,
members: Vec<ChannelMembership>, members: Vec<ChannelMembership>,
// context_menu: ViewHandle<ContextMenu>, context_menu: Option<(View<ContextMenu>, Subscription)>,
} }
impl PickerDelegate for ChannelModalDelegate { impl PickerDelegate for ChannelModalDelegate {
type ListItem = Div; type ListItem = ListItem;
fn placeholder_text(&self) -> Arc<str> { fn placeholder_text(&self) -> Arc<str> {
"Search collaborator by username...".into() "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) { if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
match self.mode { match self.mode {
Mode::ManageMembers => { 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) { Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
Some(proto::channel_member::Kind::Invitee) => { 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 => { Some(proto::channel_member::Kind::AncestorMember) | None => {
self.invite_member(selected_user, cx) self.invite_member(selected_user, cx)
@ -326,11 +345,13 @@ impl PickerDelegate for ChannelModalDelegate {
} }
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) { fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
self.channel_modal if self.context_menu.is_none() {
.update(cx, |_, cx| { self.channel_modal
cx.emit(DismissEvent); .update(cx, |_, cx| {
}) cx.emit(DismissEvent);
.ok(); })
.ok();
}
} }
fn render_match( fn render_match(
@ -339,129 +360,54 @@ impl PickerDelegate for ChannelModalDelegate {
selected: bool, selected: bool,
cx: &mut ViewContext<Picker<Self>>, cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> { ) -> Option<Self::ListItem> {
None let (user, role) = self.user_at_index(ix)?;
// let full_theme = &theme::current(cx); let request_status = self.member_status(user.id, 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 style = tabbed_modal Some(
// .picker ListItem::new(ix)
// .item .inset(true)
// .in_state(selected) .selected(selected)
// .style_for(mouse_state); .start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone()))
// let in_manage = matches!(self.mode, Mode::ManageMembers); .end_slot(h_stack().gap_2().map(|slot| {
match self.mode {
// let mut result = Flex::row() Mode::ManageMembers => slot
// .with_children(user.avatar.clone().map(|avatar| { .children(
// Image::from_data(avatar) if request_status == Some(proto::channel_member::Kind::Invitee) {
// .with_style(theme.contact_avatar) Some(Label::new("Invited"))
// .aligned() } else {
// .left() None
// })) },
// .with_child( )
// Label::new(user.github_login.clone(), style.label.clone()) .children(match role {
// .contained() Some(ChannelRole::Admin) => Some(Label::new("Admin")),
// .with_style(theme.contact_username) Some(ChannelRole::Guest) => Some(Label::new("Guest")),
// .aligned() _ => None,
// .left(), })
// ) .child(IconButton::new("ellipsis", Icon::Ellipsis))
// .with_children({ .children(
// (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then( if let (Some((menu, _)), true) = (&self.context_menu, selected) {
// || { Some(
// Label::new("Invited", theme.member_tag.text.clone()) overlay()
// .contained() .anchor(gpui::AnchorCorner::TopLeft)
// .with_style(theme.member_tag.container) .child(menu.clone()),
// .aligned() )
// .left() } else {
// }, None
// ) },
// }) ),
// .with_children(if in_manage && role == Some(ChannelRole::Admin) { Mode::InviteMembers => match request_status {
// Some( Some(proto::channel_member::Kind::Invitee) => {
// Label::new("Admin", theme.member_tag.text.clone()) slot.children(Some(Label::new("Invited")))
// .contained() }
// .with_style(theme.member_tag.container) Some(proto::channel_member::Kind::Member) => {
// .aligned() slot.children(Some(Label::new("Member")))
// .left(), }
// ) _ => slot,
// } 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
} }
} }
@ -495,21 +441,20 @@ impl ChannelModalDelegate {
} }
} }
fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> { fn set_user_role(
let (user, role) = self.user_at_index(self.selected_index)?; &mut self,
let new_role = if role == Some(ChannelRole::Admin) { user_id: UserId,
ChannelRole::Member new_role: ChannelRole,
} else { cx: &mut ViewContext<Picker<Self>>,
ChannelRole::Admin ) -> Option<()> {
};
let update = self.channel_store.update(cx, |store, cx| { 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 { cx.spawn(|picker, mut cx| async move {
update.await?; update.await?;
picker.update(&mut cx, |picker, cx| { picker.update(&mut cx, |picker, cx| {
let this = &mut picker.delegate; 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; member.role = new_role;
} }
cx.focus_self(); cx.focus_self();
@ -520,9 +465,7 @@ impl ChannelModalDelegate {
Some(()) Some(())
} }
fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> { fn remove_member(&mut self, user_id: UserId, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
let (user, _) = self.user_at_index(self.selected_index)?;
let user_id = user.id;
let update = self.channel_store.update(cx, |store, cx| { let update = self.channel_store.update(cx, |store, cx| {
store.remove_member(self.channel_id, user_id, cx) store.remove_member(self.channel_id, user_id, cx)
}); });
@ -546,7 +489,7 @@ impl ChannelModalDelegate {
.selected_index .selected_index
.min(this.matching_member_indices.len().saturating_sub(1)); .min(this.matching_member_indices.len().saturating_sub(1));
cx.focus_self(); picker.focus(cx);
cx.notify(); cx.notify();
}) })
}) })
@ -579,24 +522,55 @@ impl ChannelModalDelegate {
.detach_and_log_err(cx); .detach_and_log_err(cx);
} }
fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext<Picker<Self>>) { fn show_context_menu(
// self.context_menu.update(cx, |context_menu, cx| { &mut self,
// context_menu.show( user: Arc<User>,
// Default::default(), role: ChannelRole,
// AnchorCorner::TopRight, cx: &mut ViewContext<Picker<Self>>,
// vec![ ) {
// ContextMenuItem::action("Remove", RemoveMember), let user_id = user.id;
// ContextMenuItem::action( let picker = cx.view().clone();
// if role == ChannelRole::Admin { let context_menu = ContextMenu::build(cx, |mut menu, _cx| {
// "Make non-admin" menu = menu.entry("Remove Member", {
// } else { let picker = picker.clone();
// "Make admin" move |cx| {
// }, picker.update(cx, |picker, cx| {
// ToggleMemberAdmin, picker.delegate.remove_member(user_id, cx);
// ), })
// ], }
// 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));
} }
} }

View file

@ -2662,13 +2662,6 @@ impl<'a, V: 'static> ViewContext<'a, V> {
self.defer(|view, cx| view.focus_handle(cx).focus(cx)) 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<E>( pub fn listener<E>(
&self, &self,
f: impl Fn(&mut V, &E, &mut ViewContext<V>) + 'static, f: impl Fn(&mut V, &E, &mut ViewContext<V>) + 'static,

View file

@ -239,7 +239,7 @@ impl<D: PickerDelegate> Render for Picker<D> {
); );
div() div()
.key_context("picker") .key_context("Picker")
.size_full() .size_full()
.when_some(self.width, |el, width| { .when_some(self.width, |el, width| {
el.w(width) el.w(width)

View file

@ -72,11 +72,11 @@ impl ContextMenu {
pub fn entry( pub fn entry(
mut self, mut self,
label: impl Into<SharedString>, label: impl Into<SharedString>,
on_click: impl Fn(&mut WindowContext) + 'static, handler: impl Fn(&mut WindowContext) + 'static,
) -> Self { ) -> Self {
self.items.push(ContextMenuItem::Entry { self.items.push(ContextMenuItem::Entry {
label: label.into(), label: label.into(),
handler: Rc::new(on_click), handler: Rc::new(handler),
icon: None, icon: None,
action: None, action: None,
}); });
@ -114,6 +114,7 @@ impl ContextMenu {
pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) { pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(DismissEvent); cx.emit(DismissEvent);
cx.emit(DismissEvent);
} }
fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) { fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {

View file

@ -51,6 +51,7 @@ pub enum Icon {
CopilotDisabled, CopilotDisabled,
Dash, Dash,
Disconnected, Disconnected,
Ellipsis,
Envelope, Envelope,
ExternalLink, ExternalLink,
ExclamationTriangle, ExclamationTriangle,
@ -133,6 +134,7 @@ impl Icon {
Icon::CopilotDisabled => "icons/copilot_disabled.svg", Icon::CopilotDisabled => "icons/copilot_disabled.svg",
Icon::Dash => "icons/dash.svg", Icon::Dash => "icons/dash.svg",
Icon::Disconnected => "icons/disconnected.svg", Icon::Disconnected => "icons/disconnected.svg",
Icon::Ellipsis => "icons/ellipsis.svg",
Icon::Envelope => "icons/feedback.svg", Icon::Envelope => "icons/feedback.svg",
Icon::ExclamationTriangle => "icons/warning.svg", Icon::ExclamationTriangle => "icons/warning.svg",
Icon::ExternalLink => "icons/external_link.svg", Icon::ExternalLink => "icons/external_link.svg",