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}