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