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