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