channel_modal.rs

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