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