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