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