channel_modal.rs

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