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