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