channel_modal.rs

  1use channel::{ChannelId, ChannelMembership, ChannelStore};
  2use client::{
  3    proto::{self, ChannelRole, ChannelVisibility},
  4    User, UserId, UserStore,
  5};
  6use fuzzy::{match_strings, StringMatchCandidate};
  7use gpui::{
  8    actions, div, overlay, AppContext, ClipboardItem, DismissEvent, Div, EventEmitter,
  9    FocusableView, Model, ParentElement, Render, Styled, Subscription, Task, View, ViewContext,
 10    VisualContext, WeakView,
 11};
 12use picker::{Picker, PickerDelegate};
 13use std::sync::Arc;
 14use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem};
 15use util::TryFutureExt;
 16use workspace::ModalView;
 17
 18actions!(
 19    channel_modal,
 20    [
 21        SelectNextControl,
 22        ToggleMode,
 23        ToggleMemberAdmin,
 24        RemoveMember
 25    ]
 26);
 27
 28pub struct ChannelModal {
 29    picker: View<Picker<ChannelModalDelegate>>,
 30    channel_store: Model<ChannelStore>,
 31    channel_id: ChannelId,
 32}
 33
 34impl ChannelModal {
 35    pub fn new(
 36        user_store: Model<UserStore>,
 37        channel_store: Model<ChannelStore>,
 38        channel_id: ChannelId,
 39        mode: Mode,
 40        members: Vec<ChannelMembership>,
 41        cx: &mut ViewContext<Self>,
 42    ) -> Self {
 43        cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
 44        let channel_modal = cx.view().downgrade();
 45        let picker = cx.build_view(|cx| {
 46            Picker::new(
 47                ChannelModalDelegate {
 48                    channel_modal,
 49                    matching_users: Vec::new(),
 50                    matching_member_indices: Vec::new(),
 51                    selected_index: 0,
 52                    user_store: user_store.clone(),
 53                    channel_store: channel_store.clone(),
 54                    channel_id,
 55                    match_candidates: Vec::new(),
 56                    context_menu: None,
 57                    members,
 58                    mode,
 59                },
 60                cx,
 61            )
 62            .modal(false)
 63        });
 64
 65        Self {
 66            picker,
 67            channel_store,
 68            channel_id,
 69        }
 70    }
 71
 72    fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
 73        let mode = match self.picker.read(cx).delegate.mode {
 74            Mode::ManageMembers => Mode::InviteMembers,
 75            Mode::InviteMembers => Mode::ManageMembers,
 76        };
 77        self.set_mode(mode, cx);
 78    }
 79
 80    fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
 81        let channel_store = self.channel_store.clone();
 82        let channel_id = self.channel_id;
 83        cx.spawn(|this, mut cx| async move {
 84            if mode == Mode::ManageMembers {
 85                let mut members = channel_store
 86                    .update(&mut cx, |channel_store, cx| {
 87                        channel_store.get_channel_member_details(channel_id, cx)
 88                    })?
 89                    .await?;
 90
 91                members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
 92
 93                this.update(&mut cx, |this, cx| {
 94                    this.picker
 95                        .update(cx, |picker, _| picker.delegate.members = members);
 96                })?;
 97            }
 98
 99            this.update(&mut cx, |this, cx| {
100                this.picker.update(cx, |picker, cx| {
101                    let delegate = &mut picker.delegate;
102                    delegate.mode = mode;
103                    delegate.selected_index = 0;
104                    picker.set_query("", cx);
105                    picker.update_matches(picker.query(cx), cx);
106                    cx.notify()
107                });
108                cx.notify()
109            })
110        })
111        .detach();
112    }
113
114    fn set_channel_visiblity(&mut self, selection: &Selection, cx: &mut ViewContext<Self>) {
115        self.channel_store.update(cx, |channel_store, cx| {
116            channel_store
117                .set_channel_visibility(
118                    self.channel_id,
119                    match selection {
120                        Selection::Unselected => ChannelVisibility::Members,
121                        Selection::Selected => ChannelVisibility::Public,
122                        Selection::Indeterminate => return,
123                    },
124                    cx,
125                )
126                .detach_and_log_err(cx)
127        });
128    }
129
130    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
131        cx.emit(DismissEvent);
132    }
133}
134
135impl EventEmitter<DismissEvent> for ChannelModal {}
136impl ModalView for ChannelModal {}
137
138impl FocusableView for ChannelModal {
139    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
140        self.picker.focus_handle(cx)
141    }
142}
143
144impl Render for ChannelModal {
145    type Element = Div;
146
147    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
148        let channel_store = self.channel_store.read(cx);
149        let Some(channel) = channel_store.channel_for_id(self.channel_id) else {
150            return div();
151        };
152        let channel_name = channel.name.clone();
153        let channel_id = channel.id;
154        let visibility = channel.visibility;
155        let mode = self.picker.read(cx).delegate.mode;
156
157        v_stack()
158            .key_context("ChannelModal")
159            .on_action(cx.listener(Self::toggle_mode))
160            .on_action(cx.listener(Self::dismiss))
161            .elevation_3(cx)
162            .w(rems(34.))
163            .child(
164                v_stack()
165                    .px_2()
166                    .py_1()
167                    .rounded_t(px(8.))
168                    .bg(cx.theme().colors().element_background)
169                    .child(IconElement::new(Icon::Hash).size(IconSize::Medium))
170                    .child(Label::new(channel_name))
171                    .child(
172                        h_stack()
173                            .w_full()
174                            .justify_between()
175                            .child(
176                                h_stack()
177                                    .gap_2()
178                                    .child(
179                                        Checkbox::new(
180                                            "is-public",
181                                            if visibility == ChannelVisibility::Public {
182                                                ui::Selection::Selected
183                                            } else {
184                                                ui::Selection::Unselected
185                                            },
186                                        )
187                                        .on_click(cx.listener(Self::set_channel_visiblity)),
188                                    )
189                                    .child(Label::new("Public")),
190                            )
191                            .children(if visibility == ChannelVisibility::Public {
192                                Some(Button::new("copy-link", "Copy Link").on_click(cx.listener(
193                                    move |this, _, cx| {
194                                        if let Some(channel) =
195                                            this.channel_store.read(cx).channel_for_id(channel_id)
196                                        {
197                                            let item = ClipboardItem::new(channel.link());
198                                            cx.write_to_clipboard(item);
199                                        }
200                                    },
201                                )))
202                            } else {
203                                None
204                            }),
205                    )
206                    .child(
207                        div()
208                            .w_full()
209                            .flex()
210                            .flex_row()
211                            .child(
212                                Button::new("manage-members", "Manage Members")
213                                    .selected(mode == Mode::ManageMembers)
214                                    .on_click(cx.listener(|this, _, cx| {
215                                        this.set_mode(Mode::ManageMembers, cx);
216                                    })),
217                            )
218                            .child(
219                                Button::new("invite-members", "Invite Members")
220                                    .selected(mode == Mode::InviteMembers)
221                                    .on_click(cx.listener(|this, _, cx| {
222                                        this.set_mode(Mode::InviteMembers, cx);
223                                    })),
224                            ),
225                    ),
226            )
227            .child(self.picker.clone())
228    }
229}
230
231#[derive(Copy, Clone, PartialEq)]
232pub enum Mode {
233    ManageMembers,
234    InviteMembers,
235}
236
237pub struct ChannelModalDelegate {
238    channel_modal: WeakView<ChannelModal>,
239    matching_users: Vec<Arc<User>>,
240    matching_member_indices: Vec<usize>,
241    user_store: Model<UserStore>,
242    channel_store: Model<ChannelStore>,
243    channel_id: ChannelId,
244    selected_index: usize,
245    mode: Mode,
246    match_candidates: Vec<StringMatchCandidate>,
247    members: Vec<ChannelMembership>,
248    context_menu: Option<(View<ContextMenu>, Subscription)>,
249}
250
251impl PickerDelegate for ChannelModalDelegate {
252    type ListItem = ListItem;
253
254    fn placeholder_text(&self) -> Arc<str> {
255        "Search collaborator by username...".into()
256    }
257
258    fn match_count(&self) -> usize {
259        match self.mode {
260            Mode::ManageMembers => self.matching_member_indices.len(),
261            Mode::InviteMembers => self.matching_users.len(),
262        }
263    }
264
265    fn selected_index(&self) -> usize {
266        self.selected_index
267    }
268
269    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
270        self.selected_index = ix;
271    }
272
273    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
274        match self.mode {
275            Mode::ManageMembers => {
276                self.match_candidates.clear();
277                self.match_candidates
278                    .extend(self.members.iter().enumerate().map(|(id, member)| {
279                        StringMatchCandidate {
280                            id,
281                            string: member.user.github_login.clone(),
282                            char_bag: member.user.github_login.chars().collect(),
283                        }
284                    }));
285
286                let matches = cx.background_executor().block(match_strings(
287                    &self.match_candidates,
288                    &query,
289                    true,
290                    usize::MAX,
291                    &Default::default(),
292                    cx.background_executor().clone(),
293                ));
294
295                cx.spawn(|picker, mut cx| async move {
296                    picker
297                        .update(&mut cx, |picker, cx| {
298                            let delegate = &mut picker.delegate;
299                            delegate.matching_member_indices.clear();
300                            delegate
301                                .matching_member_indices
302                                .extend(matches.into_iter().map(|m| m.candidate_id));
303                            cx.notify();
304                        })
305                        .ok();
306                })
307            }
308            Mode::InviteMembers => {
309                let search_users = self
310                    .user_store
311                    .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
312                cx.spawn(|picker, mut cx| async move {
313                    async {
314                        let users = search_users.await?;
315                        picker.update(&mut cx, |picker, cx| {
316                            picker.delegate.matching_users = users;
317                            cx.notify();
318                        })?;
319                        anyhow::Ok(())
320                    }
321                    .log_err()
322                    .await;
323                })
324            }
325        }
326    }
327
328    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
329        if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
330            match self.mode {
331                Mode::ManageMembers => {
332                    self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx)
333                }
334                Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
335                    Some(proto::channel_member::Kind::Invitee) => {
336                        self.remove_member(selected_user.id, cx);
337                    }
338                    Some(proto::channel_member::Kind::AncestorMember) | None => {
339                        self.invite_member(selected_user, cx)
340                    }
341                    Some(proto::channel_member::Kind::Member) => {}
342                },
343            }
344        }
345    }
346
347    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
348        if self.context_menu.is_none() {
349            self.channel_modal
350                .update(cx, |_, cx| {
351                    cx.emit(DismissEvent);
352                })
353                .ok();
354        }
355    }
356
357    fn render_match(
358        &self,
359        ix: usize,
360        selected: bool,
361        cx: &mut ViewContext<Picker<Self>>,
362    ) -> Option<Self::ListItem> {
363        let (user, role) = self.user_at_index(ix)?;
364        let request_status = self.member_status(user.id, cx);
365
366        Some(
367            ListItem::new(ix)
368                .inset(true)
369                .selected(selected)
370                .start_slot(Avatar::new(user.avatar_uri.clone()))
371                .child(Label::new(user.github_login.clone()))
372                .end_slot(h_stack().gap_2().map(|slot| {
373                    match self.mode {
374                        Mode::ManageMembers => slot
375                            .children(
376                                if request_status == Some(proto::channel_member::Kind::Invitee) {
377                                    Some(Label::new("Invited"))
378                                } else {
379                                    None
380                                },
381                            )
382                            .children(match role {
383                                Some(ChannelRole::Admin) => Some(Label::new("Admin")),
384                                Some(ChannelRole::Guest) => Some(Label::new("Guest")),
385                                _ => None,
386                            })
387                            .child(IconButton::new("ellipsis", Icon::Ellipsis))
388                            .children(
389                                if let (Some((menu, _)), true) = (&self.context_menu, selected) {
390                                    Some(
391                                        overlay()
392                                            .anchor(gpui::AnchorCorner::TopLeft)
393                                            .child(menu.clone()),
394                                    )
395                                } else {
396                                    None
397                                },
398                            ),
399                        Mode::InviteMembers => match request_status {
400                            Some(proto::channel_member::Kind::Invitee) => {
401                                slot.children(Some(Label::new("Invited")))
402                            }
403                            Some(proto::channel_member::Kind::Member) => {
404                                slot.children(Some(Label::new("Member")))
405                            }
406                            _ => slot,
407                        },
408                    }
409                })),
410        )
411    }
412}
413
414impl ChannelModalDelegate {
415    fn member_status(
416        &self,
417        user_id: UserId,
418        cx: &AppContext,
419    ) -> Option<proto::channel_member::Kind> {
420        self.members
421            .iter()
422            .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
423            .or_else(|| {
424                self.channel_store
425                    .read(cx)
426                    .has_pending_channel_invite(self.channel_id, user_id)
427                    .then_some(proto::channel_member::Kind::Invitee)
428            })
429    }
430
431    fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<ChannelRole>)> {
432        match self.mode {
433            Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
434                let channel_membership = self.members.get(*ix)?;
435                Some((
436                    channel_membership.user.clone(),
437                    Some(channel_membership.role),
438                ))
439            }),
440            Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
441        }
442    }
443
444    fn set_user_role(
445        &mut self,
446        user_id: UserId,
447        new_role: ChannelRole,
448        cx: &mut ViewContext<Picker<Self>>,
449    ) -> Option<()> {
450        let update = self.channel_store.update(cx, |store, cx| {
451            store.set_member_role(self.channel_id, user_id, new_role, cx)
452        });
453        cx.spawn(|picker, mut cx| async move {
454            update.await?;
455            picker.update(&mut cx, |picker, cx| {
456                let this = &mut picker.delegate;
457                if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) {
458                    member.role = new_role;
459                }
460                cx.focus_self();
461                cx.notify();
462            })
463        })
464        .detach_and_log_err(cx);
465        Some(())
466    }
467
468    fn remove_member(&mut self, user_id: UserId, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
469        let update = self.channel_store.update(cx, |store, cx| {
470            store.remove_member(self.channel_id, user_id, cx)
471        });
472        cx.spawn(|picker, mut cx| async move {
473            update.await?;
474            picker.update(&mut cx, |picker, cx| {
475                let this = &mut picker.delegate;
476                if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
477                    this.members.remove(ix);
478                    this.matching_member_indices.retain_mut(|member_ix| {
479                        if *member_ix == ix {
480                            return false;
481                        } else if *member_ix > ix {
482                            *member_ix -= 1;
483                        }
484                        true
485                    })
486                }
487
488                this.selected_index = this
489                    .selected_index
490                    .min(this.matching_member_indices.len().saturating_sub(1));
491
492                picker.focus(cx);
493                cx.notify();
494            })
495        })
496        .detach_and_log_err(cx);
497        Some(())
498    }
499
500    fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
501        let invite_member = self.channel_store.update(cx, |store, cx| {
502            store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
503        });
504
505        cx.spawn(|this, mut cx| async move {
506            invite_member.await?;
507
508            this.update(&mut cx, |this, cx| {
509                let new_member = ChannelMembership {
510                    user,
511                    kind: proto::channel_member::Kind::Invitee,
512                    role: ChannelRole::Member,
513                };
514                let members = &mut this.delegate.members;
515                match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
516                    Ok(ix) | Err(ix) => members.insert(ix, new_member),
517                }
518
519                cx.notify();
520            })
521        })
522        .detach_and_log_err(cx);
523    }
524
525    fn show_context_menu(
526        &mut self,
527        user: Arc<User>,
528        role: ChannelRole,
529        cx: &mut ViewContext<Picker<Self>>,
530    ) {
531        let user_id = user.id;
532        let picker = cx.view().clone();
533        let context_menu = ContextMenu::build(cx, |mut menu, _cx| {
534            menu = menu.entry("Remove Member", {
535                let picker = picker.clone();
536                move |cx| {
537                    picker.update(cx, |picker, cx| {
538                        picker.delegate.remove_member(user_id, cx);
539                    })
540                }
541            });
542
543            let picker = picker.clone();
544            match role {
545                ChannelRole::Admin => {
546                    menu = menu.entry("Revoke Admin", move |cx| {
547                        picker.update(cx, |picker, cx| {
548                            picker
549                                .delegate
550                                .set_user_role(user_id, ChannelRole::Member, cx);
551                        })
552                    });
553                }
554                ChannelRole::Member => {
555                    menu = menu.entry("Make Admin", move |cx| {
556                        picker.update(cx, |picker, cx| {
557                            picker
558                                .delegate
559                                .set_user_role(user_id, ChannelRole::Admin, cx);
560                        })
561                    });
562                }
563                _ => {}
564            };
565
566            menu
567        });
568        cx.focus_view(&context_menu);
569        let subscription = cx.subscribe(&context_menu, |picker, _, _: &DismissEvent, cx| {
570            picker.delegate.context_menu = None;
571            picker.focus(cx);
572            cx.notify();
573        });
574        self.context_menu = Some((context_menu, subscription));
575    }
576}