1use channel::{ChannelId, ChannelMembership, ChannelStore};
2use client::{
3 proto::{self, ChannelRole, ChannelVisibility},
4 User, UserId, UserStore,
5};
6use context_menu::{ContextMenu, ContextMenuItem};
7use fuzzy::{match_strings, StringMatchCandidate};
8use gpui::{
9 actions,
10 elements::*,
11 platform::{CursorStyle, MouseButton},
12 AppContext, ClipboardItem, Entity, ModelHandle, MouseState, Task, View, ViewContext,
13 ViewHandle,
14};
15use picker::{Picker, PickerDelegate, PickerEvent};
16use std::sync::Arc;
17use util::TryFutureExt;
18use workspace::Modal;
19
20actions!(
21 channel_modal,
22 [
23 SelectNextControl,
24 ToggleMode,
25 ToggleMemberAdmin,
26 RemoveMember
27 ]
28);
29
30pub fn init(cx: &mut AppContext) {
31 Picker::<ChannelModalDelegate>::init(cx);
32 cx.add_action(ChannelModal::toggle_mode);
33 cx.add_action(ChannelModal::toggle_member_admin);
34 cx.add_action(ChannelModal::remove_member);
35 cx.add_action(ChannelModal::dismiss);
36}
37
38pub struct ChannelModal {
39 picker: ViewHandle<Picker<ChannelModalDelegate>>,
40 channel_store: ModelHandle<ChannelStore>,
41 channel_id: ChannelId,
42 has_focus: bool,
43}
44
45impl ChannelModal {
46 pub fn new(
47 user_store: ModelHandle<UserStore>,
48 channel_store: ModelHandle<ChannelStore>,
49 channel_id: ChannelId,
50 mode: Mode,
51 members: Vec<ChannelMembership>,
52 cx: &mut ViewContext<Self>,
53 ) -> Self {
54 cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
55 let picker = cx.add_view(|cx| {
56 Picker::new(
57 ChannelModalDelegate {
58 matching_users: Vec::new(),
59 matching_member_indices: Vec::new(),
60 selected_index: 0,
61 user_store: user_store.clone(),
62 channel_store: channel_store.clone(),
63 channel_id,
64 match_candidates: Vec::new(),
65 members,
66 mode,
67 context_menu: cx.add_view(|cx| {
68 let mut menu = ContextMenu::new(cx.view_id(), cx);
69 menu.set_position_mode(OverlayPositionMode::Local);
70 menu
71 }),
72 },
73 cx,
74 )
75 .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
76 });
77
78 cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
79
80 let has_focus = picker.read(cx).has_focus();
81
82 Self {
83 picker,
84 channel_store,
85 channel_id,
86 has_focus,
87 }
88 }
89
90 fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
91 let mode = match self.picker.read(cx).delegate().mode {
92 Mode::ManageMembers => Mode::InviteMembers,
93 Mode::InviteMembers => Mode::ManageMembers,
94 };
95 self.set_mode(mode, cx);
96 }
97
98 fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
99 let channel_store = self.channel_store.clone();
100 let channel_id = self.channel_id;
101 cx.spawn(|this, mut cx| async move {
102 if mode == Mode::ManageMembers {
103 let mut members = channel_store
104 .update(&mut cx, |channel_store, cx| {
105 channel_store.get_channel_member_details(channel_id, cx)
106 })
107 .await?;
108
109 members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
110
111 this.update(&mut cx, |this, cx| {
112 this.picker
113 .update(cx, |picker, _| picker.delegate_mut().members = members);
114 })?;
115 }
116
117 this.update(&mut cx, |this, cx| {
118 this.picker.update(cx, |picker, cx| {
119 let delegate = picker.delegate_mut();
120 delegate.mode = mode;
121 delegate.selected_index = 0;
122 picker.set_query("", cx);
123 picker.update_matches(picker.query(cx), cx);
124 cx.notify()
125 });
126 cx.notify()
127 })
128 })
129 .detach();
130 }
131
132 fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) {
133 self.picker.update(cx, |picker, cx| {
134 picker.delegate_mut().toggle_selected_member_admin(cx);
135 })
136 }
137
138 fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) {
139 self.picker.update(cx, |picker, cx| {
140 picker.delegate_mut().remove_selected_member(cx);
141 });
142 }
143
144 fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
145 cx.emit(PickerEvent::Dismiss);
146 }
147}
148
149impl Entity for ChannelModal {
150 type Event = PickerEvent;
151}
152
153impl View for ChannelModal {
154 fn ui_name() -> &'static str {
155 "ChannelModal"
156 }
157
158 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
159 let theme = &theme::current(cx).collab_panel.tabbed_modal;
160
161 let mode = self.picker.read(cx).delegate().mode;
162 let Some(channel) = self.channel_store.read(cx).channel_for_id(self.channel_id) else {
163 return Empty::new().into_any();
164 };
165
166 enum InviteMembers {}
167 enum ManageMembers {}
168
169 fn render_mode_button<T: 'static>(
170 mode: Mode,
171 text: &'static str,
172 current_mode: Mode,
173 theme: &theme::TabbedModal,
174 cx: &mut ViewContext<ChannelModal>,
175 ) -> AnyElement<ChannelModal> {
176 let active = mode == current_mode;
177 MouseEventHandler::new::<T, _>(0, cx, move |state, _| {
178 let contained_text = theme.tab_button.style_for(active, state);
179 Label::new(text, contained_text.text.clone())
180 .contained()
181 .with_style(contained_text.container.clone())
182 })
183 .on_click(MouseButton::Left, move |_, this, cx| {
184 if !active {
185 this.set_mode(mode, cx);
186 }
187 })
188 .with_cursor_style(CursorStyle::PointingHand)
189 .into_any()
190 }
191
192 fn render_visibility(
193 channel_id: ChannelId,
194 visibility: ChannelVisibility,
195 theme: &theme::TabbedModal,
196 cx: &mut ViewContext<ChannelModal>,
197 ) -> AnyElement<ChannelModal> {
198 enum TogglePublic {}
199
200 if visibility == ChannelVisibility::Members {
201 return Flex::row()
202 .with_child(
203 MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
204 let style = theme.visibility_toggle.style_for(state);
205 Label::new(format!("{}", "Public access: OFF"), style.text.clone())
206 .contained()
207 .with_style(style.container.clone())
208 })
209 .on_click(MouseButton::Left, move |_, this, cx| {
210 this.channel_store
211 .update(cx, |channel_store, cx| {
212 channel_store.set_channel_visibility(
213 channel_id,
214 ChannelVisibility::Public,
215 cx,
216 )
217 })
218 .detach_and_log_err(cx);
219 })
220 .with_cursor_style(CursorStyle::PointingHand),
221 )
222 .into_any();
223 }
224
225 Flex::row()
226 .with_child(
227 MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
228 let style = theme.visibility_toggle.style_for(state);
229 Label::new(format!("{}", "Public access: ON"), style.text.clone())
230 .contained()
231 .with_style(style.container.clone())
232 })
233 .on_click(MouseButton::Left, move |_, this, cx| {
234 this.channel_store
235 .update(cx, |channel_store, cx| {
236 channel_store.set_channel_visibility(
237 channel_id,
238 ChannelVisibility::Members,
239 cx,
240 )
241 })
242 .detach_and_log_err(cx);
243 })
244 .with_cursor_style(CursorStyle::PointingHand),
245 )
246 .with_spacing(14.0)
247 .with_child(
248 MouseEventHandler::new::<TogglePublic, _>(1, cx, move |state, _| {
249 let style = theme.channel_link.style_for(state);
250 Label::new(format!("{}", "copy link"), style.text.clone())
251 .contained()
252 .with_style(style.container.clone())
253 })
254 .on_click(MouseButton::Left, move |_, this, cx| {
255 if let Some(channel) =
256 this.channel_store.read(cx).channel_for_id(channel_id)
257 {
258 let item = ClipboardItem::new(channel.link());
259 cx.write_to_clipboard(item);
260 }
261 })
262 .with_cursor_style(CursorStyle::PointingHand),
263 )
264 .into_any()
265 }
266
267 Flex::column()
268 .with_child(
269 Flex::column()
270 .with_child(
271 Label::new(format!("#{}", channel.name), theme.title.text.clone())
272 .contained()
273 .with_style(theme.title.container.clone()),
274 )
275 .with_child(render_visibility(channel.id, channel.visibility, theme, cx))
276 .with_child(Flex::row().with_children([
277 render_mode_button::<InviteMembers>(
278 Mode::InviteMembers,
279 "Invite members",
280 mode,
281 theme,
282 cx,
283 ),
284 render_mode_button::<ManageMembers>(
285 Mode::ManageMembers,
286 "Manage members",
287 mode,
288 theme,
289 cx,
290 ),
291 ]))
292 .expanded()
293 .contained()
294 .with_style(theme.header),
295 )
296 .with_child(
297 ChildView::new(&self.picker, cx)
298 .contained()
299 .with_style(theme.body),
300 )
301 .constrained()
302 .with_max_height(theme.max_height)
303 .with_max_width(theme.max_width)
304 .contained()
305 .with_style(theme.modal)
306 .into_any()
307 }
308
309 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
310 self.has_focus = true;
311 if cx.is_self_focused() {
312 cx.focus(&self.picker)
313 }
314 }
315
316 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
317 self.has_focus = false;
318 }
319}
320
321impl Modal for ChannelModal {
322 fn has_focus(&self) -> bool {
323 self.has_focus
324 }
325
326 fn dismiss_on_event(event: &Self::Event) -> bool {
327 match event {
328 PickerEvent::Dismiss => true,
329 }
330 }
331}
332
333#[derive(Copy, Clone, PartialEq)]
334pub enum Mode {
335 ManageMembers,
336 InviteMembers,
337}
338
339pub struct ChannelModalDelegate {
340 matching_users: Vec<Arc<User>>,
341 matching_member_indices: Vec<usize>,
342 user_store: ModelHandle<UserStore>,
343 channel_store: ModelHandle<ChannelStore>,
344 channel_id: ChannelId,
345 selected_index: usize,
346 mode: Mode,
347 match_candidates: Vec<StringMatchCandidate>,
348 members: Vec<ChannelMembership>,
349 context_menu: ViewHandle<ContextMenu>,
350}
351
352impl PickerDelegate for ChannelModalDelegate {
353 fn placeholder_text(&self) -> Arc<str> {
354 "Search collaborator by username...".into()
355 }
356
357 fn match_count(&self) -> usize {
358 match self.mode {
359 Mode::ManageMembers => self.matching_member_indices.len(),
360 Mode::InviteMembers => self.matching_users.len(),
361 }
362 }
363
364 fn selected_index(&self) -> usize {
365 self.selected_index
366 }
367
368 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
369 self.selected_index = ix;
370 }
371
372 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
373 match self.mode {
374 Mode::ManageMembers => {
375 self.match_candidates.clear();
376 self.match_candidates
377 .extend(self.members.iter().enumerate().map(|(id, member)| {
378 StringMatchCandidate {
379 id,
380 string: member.user.github_login.clone(),
381 char_bag: member.user.github_login.chars().collect(),
382 }
383 }));
384
385 let matches = cx.background().block(match_strings(
386 &self.match_candidates,
387 &query,
388 true,
389 usize::MAX,
390 &Default::default(),
391 cx.background().clone(),
392 ));
393
394 cx.spawn(|picker, mut cx| async move {
395 picker
396 .update(&mut cx, |picker, cx| {
397 let delegate = picker.delegate_mut();
398 delegate.matching_member_indices.clear();
399 delegate
400 .matching_member_indices
401 .extend(matches.into_iter().map(|m| m.candidate_id));
402 cx.notify();
403 })
404 .ok();
405 })
406 }
407 Mode::InviteMembers => {
408 let search_users = self
409 .user_store
410 .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
411 cx.spawn(|picker, mut cx| async move {
412 async {
413 let users = search_users.await?;
414 picker.update(&mut cx, |picker, cx| {
415 let delegate = picker.delegate_mut();
416 delegate.matching_users = users;
417 cx.notify();
418 })?;
419 anyhow::Ok(())
420 }
421 .log_err()
422 .await;
423 })
424 }
425 }
426 }
427
428 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
429 if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
430 match self.mode {
431 Mode::ManageMembers => {
432 self.show_context_menu(role.unwrap_or(ChannelRole::Member), cx)
433 }
434 Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
435 Some(proto::channel_member::Kind::Invitee) => {
436 self.remove_selected_member(cx);
437 }
438 Some(proto::channel_member::Kind::AncestorMember) | None => {
439 self.invite_member(selected_user, cx)
440 }
441 Some(proto::channel_member::Kind::Member) => {}
442 },
443 }
444 }
445 }
446
447 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
448 cx.emit(PickerEvent::Dismiss);
449 }
450
451 fn render_match(
452 &self,
453 ix: usize,
454 mouse_state: &mut MouseState,
455 selected: bool,
456 cx: &gpui::AppContext,
457 ) -> AnyElement<Picker<Self>> {
458 let full_theme = &theme::current(cx);
459 let theme = &full_theme.collab_panel.channel_modal;
460 let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
461 let (user, role) = self.user_at_index(ix).unwrap();
462 let request_status = self.member_status(user.id, cx);
463
464 let style = tabbed_modal
465 .picker
466 .item
467 .in_state(selected)
468 .style_for(mouse_state);
469
470 let in_manage = matches!(self.mode, Mode::ManageMembers);
471
472 let mut result = Flex::row()
473 .with_children(user.avatar.clone().map(|avatar| {
474 Image::from_data(avatar)
475 .with_style(theme.contact_avatar)
476 .aligned()
477 .left()
478 }))
479 .with_child(
480 Label::new(user.github_login.clone(), style.label.clone())
481 .contained()
482 .with_style(theme.contact_username)
483 .aligned()
484 .left(),
485 )
486 .with_children({
487 (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then(
488 || {
489 Label::new("Invited", theme.member_tag.text.clone())
490 .contained()
491 .with_style(theme.member_tag.container)
492 .aligned()
493 .left()
494 },
495 )
496 })
497 .with_children(if in_manage && role == Some(ChannelRole::Admin) {
498 Some(
499 Label::new("Admin", theme.member_tag.text.clone())
500 .contained()
501 .with_style(theme.member_tag.container)
502 .aligned()
503 .left(),
504 )
505 } else if in_manage && role == Some(ChannelRole::Guest) {
506 Some(
507 Label::new("Guest", theme.member_tag.text.clone())
508 .contained()
509 .with_style(theme.member_tag.container)
510 .aligned()
511 .left(),
512 )
513 } else {
514 None
515 })
516 .with_children({
517 let svg = match self.mode {
518 Mode::ManageMembers => Some(
519 Svg::new("icons/ellipsis.svg")
520 .with_color(theme.member_icon.color)
521 .constrained()
522 .with_width(theme.member_icon.icon_width)
523 .aligned()
524 .constrained()
525 .with_width(theme.member_icon.button_width)
526 .with_height(theme.member_icon.button_width)
527 .contained()
528 .with_style(theme.member_icon.container),
529 ),
530 Mode::InviteMembers => match request_status {
531 Some(proto::channel_member::Kind::Member) => Some(
532 Svg::new("icons/check.svg")
533 .with_color(theme.member_icon.color)
534 .constrained()
535 .with_width(theme.member_icon.icon_width)
536 .aligned()
537 .constrained()
538 .with_width(theme.member_icon.button_width)
539 .with_height(theme.member_icon.button_width)
540 .contained()
541 .with_style(theme.member_icon.container),
542 ),
543 Some(proto::channel_member::Kind::Invitee) => Some(
544 Svg::new("icons/check.svg")
545 .with_color(theme.invitee_icon.color)
546 .constrained()
547 .with_width(theme.invitee_icon.icon_width)
548 .aligned()
549 .constrained()
550 .with_width(theme.invitee_icon.button_width)
551 .with_height(theme.invitee_icon.button_width)
552 .contained()
553 .with_style(theme.invitee_icon.container),
554 ),
555 Some(proto::channel_member::Kind::AncestorMember) | None => None,
556 },
557 };
558
559 svg.map(|svg| svg.aligned().flex_float().into_any())
560 })
561 .contained()
562 .with_style(style.container)
563 .constrained()
564 .with_height(tabbed_modal.row_height)
565 .into_any();
566
567 if selected {
568 result = Stack::new()
569 .with_child(result)
570 .with_child(
571 ChildView::new(&self.context_menu, cx)
572 .aligned()
573 .top()
574 .right(),
575 )
576 .into_any();
577 }
578
579 result
580 }
581}
582
583impl ChannelModalDelegate {
584 fn member_status(
585 &self,
586 user_id: UserId,
587 cx: &AppContext,
588 ) -> Option<proto::channel_member::Kind> {
589 self.members
590 .iter()
591 .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
592 .or_else(|| {
593 self.channel_store
594 .read(cx)
595 .has_pending_channel_invite(self.channel_id, user_id)
596 .then_some(proto::channel_member::Kind::Invitee)
597 })
598 }
599
600 fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<ChannelRole>)> {
601 match self.mode {
602 Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
603 let channel_membership = self.members.get(*ix)?;
604 Some((
605 channel_membership.user.clone(),
606 Some(channel_membership.role),
607 ))
608 }),
609 Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
610 }
611 }
612
613 fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
614 let (user, role) = self.user_at_index(self.selected_index)?;
615 let new_role = if role == Some(ChannelRole::Admin) {
616 ChannelRole::Member
617 } else {
618 ChannelRole::Admin
619 };
620 let update = self.channel_store.update(cx, |store, cx| {
621 store.set_member_role(self.channel_id, user.id, new_role, cx)
622 });
623 cx.spawn(|picker, mut cx| async move {
624 update.await?;
625 picker.update(&mut cx, |picker, cx| {
626 let this = picker.delegate_mut();
627 if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
628 member.role = new_role;
629 }
630 cx.focus_self();
631 cx.notify();
632 })
633 })
634 .detach_and_log_err(cx);
635 Some(())
636 }
637
638 fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
639 let (user, _) = self.user_at_index(self.selected_index)?;
640 let user_id = user.id;
641 let update = self.channel_store.update(cx, |store, cx| {
642 store.remove_member(self.channel_id, user_id, cx)
643 });
644 cx.spawn(|picker, mut cx| async move {
645 update.await?;
646 picker.update(&mut cx, |picker, cx| {
647 let this = picker.delegate_mut();
648 if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
649 this.members.remove(ix);
650 this.matching_member_indices.retain_mut(|member_ix| {
651 if *member_ix == ix {
652 return false;
653 } else if *member_ix > ix {
654 *member_ix -= 1;
655 }
656 true
657 })
658 }
659
660 this.selected_index = this
661 .selected_index
662 .min(this.matching_member_indices.len().saturating_sub(1));
663
664 cx.focus_self();
665 cx.notify();
666 })
667 })
668 .detach_and_log_err(cx);
669 Some(())
670 }
671
672 fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
673 let invite_member = self.channel_store.update(cx, |store, cx| {
674 store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
675 });
676
677 cx.spawn(|this, mut cx| async move {
678 invite_member.await?;
679
680 this.update(&mut cx, |this, cx| {
681 let new_member = ChannelMembership {
682 user,
683 kind: proto::channel_member::Kind::Invitee,
684 role: ChannelRole::Member,
685 };
686 let members = &mut this.delegate_mut().members;
687 match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
688 Ok(ix) | Err(ix) => members.insert(ix, new_member),
689 }
690
691 cx.notify();
692 })
693 })
694 .detach_and_log_err(cx);
695 }
696
697 fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext<Picker<Self>>) {
698 self.context_menu.update(cx, |context_menu, cx| {
699 context_menu.show(
700 Default::default(),
701 AnchorCorner::TopRight,
702 vec![
703 ContextMenuItem::action("Remove", RemoveMember),
704 ContextMenuItem::action(
705 if role == ChannelRole::Admin {
706 "Make non-admin"
707 } else {
708 "Make admin"
709 },
710 ToggleMemberAdmin,
711 ),
712 ],
713 cx,
714 )
715 })
716 }
717}