channel_modal.rs

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