channel_modal.rs

  1use client::{proto, ChannelId, ChannelMembership, ChannelStore, User, UserId, UserStore};
  2use context_menu::{ContextMenu, ContextMenuItem};
  3use fuzzy::{match_strings, StringMatchCandidate};
  4use gpui::{
  5    actions,
  6    elements::*,
  7    platform::{CursorStyle, MouseButton},
  8    AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
  9};
 10use picker::{Picker, PickerDelegate, PickerEvent};
 11use std::sync::Arc;
 12use util::TryFutureExt;
 13use workspace::Modal;
 14
 15actions!(
 16    channel_modal,
 17    [
 18        SelectNextControl,
 19        ToggleMode,
 20        ToggleMemberAdmin,
 21        RemoveMember
 22    ]
 23);
 24
 25pub fn init(cx: &mut AppContext) {
 26    Picker::<ChannelModalDelegate>::init(cx);
 27    cx.add_action(ChannelModal::toggle_mode);
 28    cx.add_action(ChannelModal::toggle_member_admin);
 29    cx.add_action(ChannelModal::remove_member);
 30    cx.add_action(ChannelModal::dismiss);
 31}
 32
 33pub struct ChannelModal {
 34    picker: ViewHandle<Picker<ChannelModalDelegate>>,
 35    channel_store: ModelHandle<ChannelStore>,
 36    channel_id: ChannelId,
 37    has_focus: bool,
 38}
 39
 40impl ChannelModal {
 41    pub fn new(
 42        user_store: ModelHandle<UserStore>,
 43        channel_store: ModelHandle<ChannelStore>,
 44        channel_id: ChannelId,
 45        mode: Mode,
 46        members: Vec<ChannelMembership>,
 47        cx: &mut ViewContext<Self>,
 48    ) -> Self {
 49        cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
 50        let picker = cx.add_view(|cx| {
 51            Picker::new(
 52                ChannelModalDelegate {
 53                    matching_users: Vec::new(),
 54                    matching_member_indices: Vec::new(),
 55                    selected_index: 0,
 56                    user_store: user_store.clone(),
 57                    channel_store: channel_store.clone(),
 58                    channel_id,
 59                    match_candidates: Vec::new(),
 60                    members,
 61                    mode,
 62                    context_menu: cx.add_view(|cx| {
 63                        let mut menu = ContextMenu::new(cx.view_id(), cx);
 64                        menu.set_position_mode(OverlayPositionMode::Local);
 65                        menu
 66                    }),
 67                },
 68                cx,
 69            )
 70            .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
 71        });
 72
 73        cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
 74
 75        let has_focus = picker.read(cx).has_focus();
 76
 77        Self {
 78            picker,
 79            channel_store,
 80            channel_id,
 81            has_focus,
 82        }
 83    }
 84
 85    fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
 86        let mode = match self.picker.read(cx).delegate().mode {
 87            Mode::ManageMembers => Mode::InviteMembers,
 88            Mode::InviteMembers => Mode::ManageMembers,
 89        };
 90        self.set_mode(mode, cx);
 91    }
 92
 93    fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
 94        let channel_store = self.channel_store.clone();
 95        let channel_id = self.channel_id;
 96        cx.spawn(|this, mut cx| async move {
 97            if mode == Mode::ManageMembers {
 98                let members = channel_store
 99                    .update(&mut cx, |channel_store, cx| {
100                        channel_store.get_channel_member_details(channel_id, cx)
101                    })
102                    .await?;
103                this.update(&mut cx, |this, cx| {
104                    this.picker
105                        .update(cx, |picker, _| picker.delegate_mut().members = members);
106                })?;
107            }
108
109            this.update(&mut cx, |this, cx| {
110                this.picker.update(cx, |picker, cx| {
111                    let delegate = picker.delegate_mut();
112                    delegate.mode = mode;
113                    delegate.selected_index = 0;
114                    picker.set_query("", cx);
115                    picker.update_matches(picker.query(cx), cx);
116                    cx.notify()
117                });
118                cx.notify()
119            })
120        })
121        .detach();
122    }
123
124    fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) {
125        self.picker.update(cx, |picker, cx| {
126            picker.delegate_mut().toggle_selected_member_admin(cx);
127        })
128    }
129
130    fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) {
131        self.picker.update(cx, |picker, cx| {
132            picker.delegate_mut().remove_selected_member(cx);
133        });
134    }
135
136    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
137        cx.emit(PickerEvent::Dismiss);
138    }
139}
140
141impl Entity for ChannelModal {
142    type Event = PickerEvent;
143}
144
145impl View for ChannelModal {
146    fn ui_name() -> &'static str {
147        "ChannelModal"
148    }
149
150    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
151        let theme = &theme::current(cx).collab_panel.tabbed_modal;
152
153        let mode = self.picker.read(cx).delegate().mode;
154        let Some(channel) = self
155            .channel_store
156            .read(cx)
157            .channel_for_id(self.channel_id) else {
158                return Empty::new().into_any()
159            };
160
161        enum InviteMembers {}
162        enum ManageMembers {}
163
164        fn render_mode_button<T: 'static>(
165            mode: Mode,
166            text: &'static str,
167            current_mode: Mode,
168            theme: &theme::TabbedModal,
169            cx: &mut ViewContext<ChannelModal>,
170        ) -> AnyElement<ChannelModal> {
171            let active = mode == current_mode;
172            MouseEventHandler::new::<T, _>(0, cx, move |state, _| {
173                let contained_text = theme.tab_button.style_for(active, state);
174                Label::new(text, contained_text.text.clone())
175                    .contained()
176                    .with_style(contained_text.container.clone())
177            })
178            .on_click(MouseButton::Left, move |_, this, cx| {
179                if !active {
180                    this.set_mode(mode, cx);
181                }
182            })
183            .with_cursor_style(CursorStyle::PointingHand)
184            .into_any()
185        }
186
187        Flex::column()
188            .with_child(
189                Flex::column()
190                    .with_child(
191                        Label::new(format!("#{}", channel.name), theme.title.text.clone())
192                            .contained()
193                            .with_style(theme.title.container.clone()),
194                    )
195                    .with_child(Flex::row().with_children([
196                        render_mode_button::<InviteMembers>(
197                            Mode::InviteMembers,
198                            "Invite members",
199                            mode,
200                            theme,
201                            cx,
202                        ),
203                        render_mode_button::<ManageMembers>(
204                            Mode::ManageMembers,
205                            "Manage members",
206                            mode,
207                            theme,
208                            cx,
209                        ),
210                    ]))
211                    .expanded()
212                    .contained()
213                    .with_style(theme.header),
214            )
215            .with_child(
216                ChildView::new(&self.picker, cx)
217                    .contained()
218                    .with_style(theme.body),
219            )
220            .constrained()
221            .with_max_height(theme.max_height)
222            .with_max_width(theme.max_width)
223            .contained()
224            .with_style(theme.modal)
225            .into_any()
226    }
227
228    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
229        self.has_focus = true;
230        if cx.is_self_focused() {
231            cx.focus(&self.picker)
232        }
233    }
234
235    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
236        self.has_focus = false;
237    }
238}
239
240impl Modal for ChannelModal {
241    fn has_focus(&self) -> bool {
242        self.has_focus
243    }
244
245    fn dismiss_on_event(event: &Self::Event) -> bool {
246        match event {
247            PickerEvent::Dismiss => true,
248        }
249    }
250}
251
252#[derive(Copy, Clone, PartialEq)]
253pub enum Mode {
254    ManageMembers,
255    InviteMembers,
256}
257
258pub struct ChannelModalDelegate {
259    matching_users: Vec<Arc<User>>,
260    matching_member_indices: Vec<usize>,
261    user_store: ModelHandle<UserStore>,
262    channel_store: ModelHandle<ChannelStore>,
263    channel_id: ChannelId,
264    selected_index: usize,
265    mode: Mode,
266    match_candidates: Vec<StringMatchCandidate>,
267    members: Vec<ChannelMembership>,
268    context_menu: ViewHandle<ContextMenu>,
269}
270
271impl PickerDelegate for ChannelModalDelegate {
272    fn placeholder_text(&self) -> Arc<str> {
273        "Search collaborator by username...".into()
274    }
275
276    fn match_count(&self) -> usize {
277        match self.mode {
278            Mode::ManageMembers => self.matching_member_indices.len(),
279            Mode::InviteMembers => self.matching_users.len(),
280        }
281    }
282
283    fn selected_index(&self) -> usize {
284        self.selected_index
285    }
286
287    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
288        self.selected_index = ix;
289    }
290
291    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
292        match self.mode {
293            Mode::ManageMembers => {
294                self.match_candidates.clear();
295                self.match_candidates
296                    .extend(self.members.iter().enumerate().map(|(id, member)| {
297                        StringMatchCandidate {
298                            id,
299                            string: member.user.github_login.clone(),
300                            char_bag: member.user.github_login.chars().collect(),
301                        }
302                    }));
303
304                let matches = cx.background().block(match_strings(
305                    &self.match_candidates,
306                    &query,
307                    true,
308                    usize::MAX,
309                    &Default::default(),
310                    cx.background().clone(),
311                ));
312
313                cx.spawn(|picker, mut cx| async move {
314                    picker
315                        .update(&mut cx, |picker, cx| {
316                            let delegate = picker.delegate_mut();
317                            delegate.matching_member_indices.clear();
318                            delegate
319                                .matching_member_indices
320                                .extend(matches.into_iter().map(|m| m.candidate_id));
321                            cx.notify();
322                        })
323                        .ok();
324                })
325            }
326            Mode::InviteMembers => {
327                let search_users = self
328                    .user_store
329                    .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
330                cx.spawn(|picker, mut cx| async move {
331                    async {
332                        let users = search_users.await?;
333                        picker.update(&mut cx, |picker, cx| {
334                            let delegate = picker.delegate_mut();
335                            delegate.matching_users = users;
336                            cx.notify();
337                        })?;
338                        anyhow::Ok(())
339                    }
340                    .log_err()
341                    .await;
342                })
343            }
344        }
345    }
346
347    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
348        if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) {
349            match self.mode {
350                Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx),
351                Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
352                    Some(proto::channel_member::Kind::Invitee) => {
353                        self.remove_selected_member(cx);
354                    }
355                    Some(proto::channel_member::Kind::AncestorMember) | None => {
356                        self.invite_member(selected_user, cx)
357                    }
358                    Some(proto::channel_member::Kind::Member) => {}
359                },
360            }
361        }
362    }
363
364    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
365        cx.emit(PickerEvent::Dismiss);
366    }
367
368    fn render_match(
369        &self,
370        ix: usize,
371        mouse_state: &mut MouseState,
372        selected: bool,
373        cx: &gpui::AppContext,
374    ) -> AnyElement<Picker<Self>> {
375        let full_theme = &theme::current(cx);
376        let theme = &full_theme.collab_panel.channel_modal;
377        let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
378        let (user, admin) = self.user_at_index(ix).unwrap();
379        let request_status = self.member_status(user.id, cx);
380
381        let style = tabbed_modal
382            .picker
383            .item
384            .in_state(selected)
385            .style_for(mouse_state);
386
387        let in_manage = matches!(self.mode, Mode::ManageMembers);
388
389        let mut result = Flex::row()
390            .with_children(user.avatar.clone().map(|avatar| {
391                Image::from_data(avatar)
392                    .with_style(theme.contact_avatar)
393                    .aligned()
394                    .left()
395            }))
396            .with_child(
397                Label::new(user.github_login.clone(), style.label.clone())
398                    .contained()
399                    .with_style(theme.contact_username)
400                    .aligned()
401                    .left(),
402            )
403            .with_children({
404                (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then(
405                    || {
406                        Label::new("Invited", theme.member_tag.text.clone())
407                            .contained()
408                            .with_style(theme.member_tag.container)
409                            .aligned()
410                            .left()
411                    },
412                )
413            })
414            .with_children(admin.and_then(|admin| {
415                (in_manage && admin).then(|| {
416                    Label::new("Admin", theme.member_tag.text.clone())
417                        .contained()
418                        .with_style(theme.member_tag.container)
419                        .aligned()
420                        .left()
421                })
422            }))
423            .with_children({
424                let svg = match self.mode {
425                    Mode::ManageMembers => Some(
426                        Svg::new("icons/ellipsis.svg")
427                            .with_color(theme.member_icon.color)
428                            .constrained()
429                            .with_width(theme.member_icon.icon_width)
430                            .aligned()
431                            .constrained()
432                            .with_width(theme.member_icon.button_width)
433                            .with_height(theme.member_icon.button_width)
434                            .contained()
435                            .with_style(theme.member_icon.container),
436                    ),
437                    Mode::InviteMembers => match request_status {
438                        Some(proto::channel_member::Kind::Member) => Some(
439                            Svg::new("icons/check.svg")
440                                .with_color(theme.member_icon.color)
441                                .constrained()
442                                .with_width(theme.member_icon.icon_width)
443                                .aligned()
444                                .constrained()
445                                .with_width(theme.member_icon.button_width)
446                                .with_height(theme.member_icon.button_width)
447                                .contained()
448                                .with_style(theme.member_icon.container),
449                        ),
450                        Some(proto::channel_member::Kind::Invitee) => Some(
451                            Svg::new("icons/check.svg")
452                                .with_color(theme.invitee_icon.color)
453                                .constrained()
454                                .with_width(theme.invitee_icon.icon_width)
455                                .aligned()
456                                .constrained()
457                                .with_width(theme.invitee_icon.button_width)
458                                .with_height(theme.invitee_icon.button_width)
459                                .contained()
460                                .with_style(theme.invitee_icon.container),
461                        ),
462                        Some(proto::channel_member::Kind::AncestorMember) | None => None,
463                    },
464                };
465
466                svg.map(|svg| svg.aligned().flex_float().into_any())
467            })
468            .contained()
469            .with_style(style.container)
470            .constrained()
471            .with_height(tabbed_modal.row_height)
472            .into_any();
473
474        if selected {
475            result = Stack::new()
476                .with_child(result)
477                .with_child(
478                    ChildView::new(&self.context_menu, cx)
479                        .aligned()
480                        .top()
481                        .right(),
482                )
483                .into_any();
484        }
485
486        result
487    }
488}
489
490impl ChannelModalDelegate {
491    fn member_status(
492        &self,
493        user_id: UserId,
494        cx: &AppContext,
495    ) -> Option<proto::channel_member::Kind> {
496        self.members
497            .iter()
498            .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
499            .or_else(|| {
500                self.channel_store
501                    .read(cx)
502                    .has_pending_channel_invite(self.channel_id, user_id)
503                    .then_some(proto::channel_member::Kind::Invitee)
504            })
505    }
506
507    fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<bool>)> {
508        match self.mode {
509            Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
510                let channel_membership = self.members.get(*ix)?;
511                Some((
512                    channel_membership.user.clone(),
513                    Some(channel_membership.admin),
514                ))
515            }),
516            Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
517        }
518    }
519
520    fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
521        let (user, admin) = self.user_at_index(self.selected_index)?;
522        let admin = !admin.unwrap_or(false);
523        let update = self.channel_store.update(cx, |store, cx| {
524            store.set_member_admin(self.channel_id, user.id, admin, cx)
525        });
526        cx.spawn(|picker, mut cx| async move {
527            update.await?;
528            picker.update(&mut cx, |picker, cx| {
529                let this = picker.delegate_mut();
530                if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
531                    member.admin = admin;
532                }
533                cx.focus_self();
534                cx.notify();
535            })
536        })
537        .detach_and_log_err(cx);
538        Some(())
539    }
540
541    fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
542        let (user, _) = self.user_at_index(self.selected_index)?;
543        let user_id = user.id;
544        let update = self.channel_store.update(cx, |store, cx| {
545            store.remove_member(self.channel_id, user_id, cx)
546        });
547        cx.spawn(|picker, mut cx| async move {
548            update.await?;
549            picker.update(&mut cx, |picker, cx| {
550                let this = picker.delegate_mut();
551                if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
552                    this.members.remove(ix);
553                    this.matching_member_indices.retain_mut(|member_ix| {
554                        if *member_ix == ix {
555                            return false;
556                        } else if *member_ix > ix {
557                            *member_ix -= 1;
558                        }
559                        true
560                    })
561                }
562
563                this.selected_index = this
564                    .selected_index
565                    .min(this.matching_member_indices.len().saturating_sub(1));
566
567                cx.focus_self();
568                cx.notify();
569            })
570        })
571        .detach_and_log_err(cx);
572        Some(())
573    }
574
575    fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
576        let invite_member = self.channel_store.update(cx, |store, cx| {
577            store.invite_member(self.channel_id, user.id, false, cx)
578        });
579
580        cx.spawn(|this, mut cx| async move {
581            invite_member.await?;
582
583            this.update(&mut cx, |this, cx| {
584                this.delegate_mut().members.push(ChannelMembership {
585                    user,
586                    kind: proto::channel_member::Kind::Invitee,
587                    admin: false,
588                });
589                cx.notify();
590            })
591        })
592        .detach_and_log_err(cx);
593    }
594
595    fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext<Picker<Self>>) {
596        self.context_menu.update(cx, |context_menu, cx| {
597            context_menu.show(
598                Default::default(),
599                AnchorCorner::TopRight,
600                vec![
601                    ContextMenuItem::action("Remove", RemoveMember),
602                    ContextMenuItem::action(
603                        if user_is_admin {
604                            "Make non-admin"
605                        } else {
606                            "Make admin"
607                        },
608                        ToggleMemberAdmin,
609                    ),
610                ],
611                cx,
612            )
613        })
614    }
615}