1use channel::{ChannelMembership, ChannelStore};
2use client::{
3 proto::{self, ChannelRole, ChannelVisibility},
4 ChannelId, User, UserId, UserStore,
5};
6use fuzzy::{match_strings, StringMatchCandidate};
7use gpui::{
8 actions, anchored, deferred, div, AppContext, ClipboardItem, DismissEvent, EventEmitter,
9 FocusableView, Model, ParentElement, Render, Styled, Subscription, Task, View, ViewContext,
10 VisualContext, WeakView,
11};
12use picker::{Picker, PickerDelegate};
13use std::sync::Arc;
14use ui::{prelude::*, Avatar, CheckboxWithLabel, ContextMenu, ListItem, ListItemSpacing};
15use util::TryFutureExt;
16use workspace::{notifications::DetachAndPromptErr, ModalView};
17
18actions!(
19 channel_modal,
20 [
21 SelectNextControl,
22 ToggleMode,
23 ToggleMemberAdmin,
24 RemoveMember
25 ]
26);
27
28pub struct ChannelModal {
29 picker: View<Picker<ChannelModalDelegate>>,
30 channel_store: Model<ChannelStore>,
31 channel_id: ChannelId,
32}
33
34impl ChannelModal {
35 pub fn new(
36 user_store: Model<UserStore>,
37 channel_store: Model<ChannelStore>,
38 channel_id: ChannelId,
39 mode: Mode,
40 members: Vec<ChannelMembership>,
41 cx: &mut ViewContext<Self>,
42 ) -> Self {
43 cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
44 let channel_modal = cx.view().downgrade();
45 let picker = cx.new_view(|cx| {
46 Picker::uniform_list(
47 ChannelModalDelegate {
48 channel_modal,
49 matching_users: Vec::new(),
50 matching_member_indices: Vec::new(),
51 selected_index: 0,
52 user_store: user_store.clone(),
53 channel_store: channel_store.clone(),
54 channel_id,
55 match_candidates: Vec::new(),
56 context_menu: None,
57 members,
58 mode,
59 },
60 cx,
61 )
62 .modal(false)
63 });
64
65 Self {
66 picker,
67 channel_store,
68 channel_id,
69 }
70 }
71
72 fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
73 let mode = match self.picker.read(cx).delegate.mode {
74 Mode::ManageMembers => Mode::InviteMembers,
75 Mode::InviteMembers => Mode::ManageMembers,
76 };
77 self.set_mode(mode, cx);
78 }
79
80 fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
81 let channel_store = self.channel_store.clone();
82 let channel_id = self.channel_id;
83 cx.spawn(|this, mut cx| async move {
84 if mode == Mode::ManageMembers {
85 let mut members = channel_store
86 .update(&mut cx, |channel_store, cx| {
87 channel_store.get_channel_member_details(channel_id, cx)
88 })?
89 .await?;
90
91 members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
92
93 this.update(&mut cx, |this, cx| {
94 this.picker
95 .update(cx, |picker, _| picker.delegate.members = members);
96 })?;
97 }
98
99 this.update(&mut cx, |this, cx| {
100 this.picker.update(cx, |picker, cx| {
101 let delegate = &mut picker.delegate;
102 delegate.mode = mode;
103 delegate.selected_index = 0;
104 picker.set_query("", cx);
105 picker.update_matches(picker.query(cx), cx);
106 cx.notify()
107 });
108 cx.notify()
109 })
110 })
111 .detach();
112 }
113
114 fn set_channel_visibility(&mut self, selection: &Selection, cx: &mut ViewContext<Self>) {
115 self.channel_store.update(cx, |channel_store, cx| {
116 channel_store
117 .set_channel_visibility(
118 self.channel_id,
119 match selection {
120 Selection::Unselected => ChannelVisibility::Members,
121 Selection::Selected => ChannelVisibility::Public,
122 Selection::Indeterminate => return,
123 },
124 cx,
125 )
126 .detach_and_log_err(cx)
127 });
128 }
129
130 fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
131 cx.emit(DismissEvent);
132 }
133}
134
135impl EventEmitter<DismissEvent> for ChannelModal {}
136impl ModalView for ChannelModal {}
137
138impl FocusableView for ChannelModal {
139 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
140 self.picker.focus_handle(cx)
141 }
142}
143
144impl Render for ChannelModal {
145 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
146 let channel_store = self.channel_store.read(cx);
147 let Some(channel) = channel_store.channel_for_id(self.channel_id) else {
148 return div();
149 };
150 let channel_name = channel.name.clone();
151 let channel_id = channel.id;
152 let visibility = channel.visibility;
153 let mode = self.picker.read(cx).delegate.mode;
154
155 v_flex()
156 .key_context("ChannelModal")
157 .on_action(cx.listener(Self::toggle_mode))
158 .on_action(cx.listener(Self::dismiss))
159 .elevation_3(cx)
160 .w(rems(34.))
161 .child(
162 v_flex()
163 .px_2()
164 .py_1()
165 .gap_2()
166 .child(
167 h_flex()
168 .w_px()
169 .flex_1()
170 .gap_1()
171 .child(Icon::new(IconName::Hash).size(IconSize::Medium))
172 .child(Label::new(channel_name)),
173 )
174 .child(
175 h_flex()
176 .w_full()
177 .h(rems_from_px(22.))
178 .justify_between()
179 .line_height(rems(1.25))
180 .child(CheckboxWithLabel::new(
181 "is-public",
182 Label::new("Public").size(LabelSize::Small),
183 if visibility == ChannelVisibility::Public {
184 ui::Selection::Selected
185 } else {
186 ui::Selection::Unselected
187 },
188 cx.listener(Self::set_channel_visibility),
189 ))
190 .children(
191 Some(
192 Button::new("copy-link", "Copy Link")
193 .label_size(LabelSize::Small)
194 .on_click(cx.listener(move |this, _, cx| {
195 if let Some(channel) = this
196 .channel_store
197 .read(cx)
198 .channel_for_id(channel_id)
199 {
200 let item = ClipboardItem::new(channel.link(cx));
201 cx.write_to_clipboard(item);
202 }
203 })),
204 )
205 .filter(|_| visibility == ChannelVisibility::Public),
206 ),
207 )
208 .child(
209 h_flex()
210 .child(
211 div()
212 .id("manage-members")
213 .px_2()
214 .py_1()
215 .cursor_pointer()
216 .border_b_2()
217 .when(mode == Mode::ManageMembers, |this| {
218 this.border_color(cx.theme().colors().border)
219 })
220 .child(Label::new("Manage Members"))
221 .on_click(cx.listener(|this, _, cx| {
222 this.set_mode(Mode::ManageMembers, cx);
223 })),
224 )
225 .child(
226 div()
227 .id("invite-members")
228 .px_2()
229 .py_1()
230 .cursor_pointer()
231 .border_b_2()
232 .when(mode == Mode::InviteMembers, |this| {
233 this.border_color(cx.theme().colors().border)
234 })
235 .child(Label::new("Invite Members"))
236 .on_click(cx.listener(|this, _, cx| {
237 this.set_mode(Mode::InviteMembers, cx);
238 })),
239 ),
240 ),
241 )
242 .child(self.picker.clone())
243 }
244}
245
246#[derive(Copy, Clone, PartialEq)]
247pub enum Mode {
248 ManageMembers,
249 InviteMembers,
250}
251
252pub struct ChannelModalDelegate {
253 channel_modal: WeakView<ChannelModal>,
254 matching_users: Vec<Arc<User>>,
255 matching_member_indices: Vec<usize>,
256 user_store: Model<UserStore>,
257 channel_store: Model<ChannelStore>,
258 channel_id: ChannelId,
259 selected_index: usize,
260 mode: Mode,
261 match_candidates: Vec<StringMatchCandidate>,
262 members: Vec<ChannelMembership>,
263 context_menu: Option<(View<ContextMenu>, Subscription)>,
264}
265
266impl PickerDelegate for ChannelModalDelegate {
267 type ListItem = ListItem;
268
269 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
270 "Search collaborator by username...".into()
271 }
272
273 fn match_count(&self) -> usize {
274 match self.mode {
275 Mode::ManageMembers => self.matching_member_indices.len(),
276 Mode::InviteMembers => self.matching_users.len(),
277 }
278 }
279
280 fn selected_index(&self) -> usize {
281 self.selected_index
282 }
283
284 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
285 self.selected_index = ix;
286 }
287
288 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
289 match self.mode {
290 Mode::ManageMembers => {
291 self.match_candidates.clear();
292 self.match_candidates
293 .extend(self.members.iter().enumerate().map(|(id, member)| {
294 StringMatchCandidate {
295 id,
296 string: member.user.github_login.clone(),
297 char_bag: member.user.github_login.chars().collect(),
298 }
299 }));
300
301 let matches = cx.background_executor().block(match_strings(
302 &self.match_candidates,
303 &query,
304 true,
305 usize::MAX,
306 &Default::default(),
307 cx.background_executor().clone(),
308 ));
309
310 cx.spawn(|picker, mut cx| async move {
311 picker
312 .update(&mut cx, |picker, cx| {
313 let delegate = &mut picker.delegate;
314 delegate.matching_member_indices.clear();
315 delegate
316 .matching_member_indices
317 .extend(matches.into_iter().map(|m| m.candidate_id));
318 cx.notify();
319 })
320 .ok();
321 })
322 }
323 Mode::InviteMembers => {
324 let search_users = self
325 .user_store
326 .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
327 cx.spawn(|picker, mut cx| async move {
328 async {
329 let users = search_users.await?;
330 picker.update(&mut cx, |picker, cx| {
331 picker.delegate.matching_users = users;
332 cx.notify();
333 })?;
334 anyhow::Ok(())
335 }
336 .log_err()
337 .await;
338 })
339 }
340 }
341 }
342
343 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
344 if let Some(selected_user) = self.user_at_index(self.selected_index) {
345 if Some(selected_user.id) == self.user_store.read(cx).current_user().map(|user| user.id)
346 {
347 return;
348 }
349 match self.mode {
350 Mode::ManageMembers => self.show_context_menu(self.selected_index, cx),
351 Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
352 Some(proto::channel_member::Kind::Invitee) => {
353 self.remove_member(selected_user.id, cx);
354 }
355 Some(proto::channel_member::Kind::Member) => {}
356 None => self.invite_member(selected_user, cx),
357 },
358 }
359 }
360 }
361
362 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
363 if self.context_menu.is_none() {
364 self.channel_modal
365 .update(cx, |_, cx| {
366 cx.emit(DismissEvent);
367 })
368 .ok();
369 }
370 }
371
372 fn render_match(
373 &self,
374 ix: usize,
375 selected: bool,
376 cx: &mut ViewContext<Picker<Self>>,
377 ) -> Option<Self::ListItem> {
378 let user = self.user_at_index(ix)?;
379 let membership = self.member_at_index(ix);
380 let request_status = self.member_status(user.id, cx);
381 let is_me = self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
382
383 Some(
384 ListItem::new(ix)
385 .inset(true)
386 .spacing(ListItemSpacing::Sparse)
387 .selected(selected)
388 .start_slot(Avatar::new(user.avatar_uri.clone()))
389 .child(Label::new(user.github_login.clone()))
390 .end_slot(h_flex().gap_2().map(|slot| {
391 match self.mode {
392 Mode::ManageMembers => slot
393 .children(
394 if request_status == Some(proto::channel_member::Kind::Invitee) {
395 Some(Label::new("Invited"))
396 } else {
397 None
398 },
399 )
400 .children(match membership.map(|m| m.role) {
401 Some(ChannelRole::Admin) => Some(Label::new("Admin")),
402 Some(ChannelRole::Guest) => Some(Label::new("Guest")),
403 _ => None,
404 })
405 .when(!is_me, |el| {
406 el.child(IconButton::new("ellipsis", IconName::Ellipsis))
407 })
408 .when(is_me, |el| el.child(Label::new("You").color(Color::Muted)))
409 .children(
410 if let (Some((menu, _)), true) = (&self.context_menu, selected) {
411 Some(
412 deferred(
413 anchored()
414 .anchor(gpui::AnchorCorner::TopRight)
415 .child(menu.clone()),
416 )
417 .with_priority(1),
418 )
419 } else {
420 None
421 },
422 ),
423 Mode::InviteMembers => match request_status {
424 Some(proto::channel_member::Kind::Invitee) => {
425 slot.children(Some(Label::new("Invited")))
426 }
427 Some(proto::channel_member::Kind::Member) => {
428 slot.children(Some(Label::new("Member")))
429 }
430 _ => slot,
431 },
432 }
433 })),
434 )
435 }
436}
437
438impl ChannelModalDelegate {
439 fn member_status(
440 &self,
441 user_id: UserId,
442 cx: &AppContext,
443 ) -> Option<proto::channel_member::Kind> {
444 self.members
445 .iter()
446 .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
447 .or_else(|| {
448 self.channel_store
449 .read(cx)
450 .has_pending_channel_invite(self.channel_id, user_id)
451 .then_some(proto::channel_member::Kind::Invitee)
452 })
453 }
454
455 fn member_at_index(&self, ix: usize) -> Option<&ChannelMembership> {
456 self.matching_member_indices
457 .get(ix)
458 .and_then(|ix| self.members.get(*ix))
459 }
460
461 fn user_at_index(&self, ix: usize) -> Option<Arc<User>> {
462 match self.mode {
463 Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
464 let channel_membership = self.members.get(*ix)?;
465 Some(channel_membership.user.clone())
466 }),
467 Mode::InviteMembers => self.matching_users.get(ix).cloned(),
468 }
469 }
470
471 fn set_user_role(
472 &mut self,
473 user_id: UserId,
474 new_role: ChannelRole,
475 cx: &mut ViewContext<Picker<Self>>,
476 ) -> Option<()> {
477 let update = self.channel_store.update(cx, |store, cx| {
478 store.set_member_role(self.channel_id, user_id, new_role, cx)
479 });
480 cx.spawn(|picker, mut cx| async move {
481 update.await?;
482 picker.update(&mut cx, |picker, cx| {
483 let this = &mut picker.delegate;
484 if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) {
485 member.role = new_role;
486 }
487 cx.focus_self();
488 cx.notify();
489 })
490 })
491 .detach_and_prompt_err("Failed to update role", cx, |_, _| None);
492 Some(())
493 }
494
495 fn remove_member(&mut self, user_id: UserId, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
496 let update = self.channel_store.update(cx, |store, cx| {
497 store.remove_member(self.channel_id, user_id, cx)
498 });
499 cx.spawn(|picker, mut cx| async move {
500 update.await?;
501 picker.update(&mut cx, |picker, cx| {
502 let this = &mut picker.delegate;
503 if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
504 this.members.remove(ix);
505 this.matching_member_indices.retain_mut(|member_ix| {
506 if *member_ix == ix {
507 return false;
508 } else if *member_ix > ix {
509 *member_ix -= 1;
510 }
511 true
512 })
513 }
514
515 this.selected_index = this
516 .selected_index
517 .min(this.matching_member_indices.len().saturating_sub(1));
518
519 picker.focus(cx);
520 cx.notify();
521 })
522 })
523 .detach_and_prompt_err("Failed to remove member", cx, |_, _| None);
524 Some(())
525 }
526
527 fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
528 let invite_member = self.channel_store.update(cx, |store, cx| {
529 store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
530 });
531
532 cx.spawn(|this, mut cx| async move {
533 invite_member.await?;
534
535 this.update(&mut cx, |this, cx| {
536 let new_member = ChannelMembership {
537 user,
538 kind: proto::channel_member::Kind::Invitee,
539 role: ChannelRole::Member,
540 };
541 let members = &mut this.delegate.members;
542 match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
543 Ok(ix) | Err(ix) => members.insert(ix, new_member),
544 }
545
546 cx.notify();
547 })
548 })
549 .detach_and_prompt_err("Failed to invite member", cx, |_, _| None);
550 }
551
552 fn show_context_menu(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
553 let Some(membership) = self.member_at_index(ix) else {
554 return;
555 };
556 let user_id = membership.user.id;
557 let picker = cx.view().clone();
558 let context_menu = ContextMenu::build(cx, |mut menu, _cx| {
559 let role = membership.role;
560
561 if role == ChannelRole::Admin || role == ChannelRole::Member {
562 let picker = picker.clone();
563 menu = menu.entry("Demote to Guest", None, move |cx| {
564 picker.update(cx, |picker, cx| {
565 picker
566 .delegate
567 .set_user_role(user_id, ChannelRole::Guest, cx);
568 })
569 });
570 }
571
572 if role == ChannelRole::Admin || role == ChannelRole::Guest {
573 let picker = picker.clone();
574 let label = if role == ChannelRole::Guest {
575 "Promote to Member"
576 } else {
577 "Demote to Member"
578 };
579
580 menu = menu.entry(label, None, move |cx| {
581 picker.update(cx, |picker, cx| {
582 picker
583 .delegate
584 .set_user_role(user_id, ChannelRole::Member, cx);
585 })
586 });
587 }
588
589 if role == ChannelRole::Member || role == ChannelRole::Guest {
590 let picker = picker.clone();
591 menu = menu.entry("Promote to Admin", None, move |cx| {
592 picker.update(cx, |picker, cx| {
593 picker
594 .delegate
595 .set_user_role(user_id, ChannelRole::Admin, cx);
596 })
597 });
598 };
599
600 menu = menu.separator();
601 menu = menu.entry("Remove from Channel", None, {
602 let picker = picker.clone();
603 move |cx| {
604 picker.update(cx, |picker, cx| {
605 picker.delegate.remove_member(user_id, cx);
606 })
607 }
608 });
609 menu
610 });
611 cx.focus_view(&context_menu);
612 let subscription = cx.subscribe(&context_menu, |picker, _, _: &DismissEvent, cx| {
613 picker.delegate.context_menu = None;
614 picker.focus(cx);
615 cx.notify();
616 });
617 self.context_menu = Some((context_menu, subscription));
618 }
619}