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