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}