channel_modal.rs

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