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