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, overlay, AppContext, ClipboardItem, DismissEvent, EventEmitter, FocusableView,
9 Model, ParentElement, Render, Styled, Subscription, Task, View, ViewContext, VisualContext,
10 WeakView,
11};
12use picker::{Picker, PickerDelegate};
13use std::sync::Arc;
14use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem, ListItemSpacing};
15use util::TryFutureExt;
16use workspace::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::new(
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_visiblity(&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_stack()
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_stack()
163 .px_2()
164 .py_1()
165 .rounded_t(px(8.))
166 .bg(cx.theme().colors().element_background)
167 .child(
168 h_stack()
169 .w_px()
170 .flex_1()
171 .gap_1()
172 .child(IconElement::new(Icon::Hash).size(IconSize::Medium))
173 .child(Label::new(channel_name)),
174 )
175 .child(
176 h_stack()
177 .w_full()
178 .justify_between()
179 .child(
180 h_stack()
181 .gap_2()
182 .child(
183 Checkbox::new(
184 "is-public",
185 if visibility == ChannelVisibility::Public {
186 ui::Selection::Selected
187 } else {
188 ui::Selection::Unselected
189 },
190 )
191 .on_click(cx.listener(Self::set_channel_visiblity)),
192 )
193 .child(Label::new("Public")),
194 )
195 .children(if visibility == ChannelVisibility::Public {
196 Some(Button::new("copy-link", "Copy Link").on_click(cx.listener(
197 move |this, _, cx| {
198 if let Some(channel) =
199 this.channel_store.read(cx).channel_for_id(channel_id)
200 {
201 let item = ClipboardItem::new(channel.link());
202 cx.write_to_clipboard(item);
203 }
204 },
205 )))
206 } else {
207 None
208 }),
209 )
210 .child(
211 div()
212 .w_full()
213 .flex()
214 .flex_row()
215 .child(
216 Button::new("manage-members", "Manage Members")
217 .selected(mode == Mode::ManageMembers)
218 .on_click(cx.listener(|this, _, cx| {
219 this.set_mode(Mode::ManageMembers, cx);
220 })),
221 )
222 .child(
223 Button::new("invite-members", "Invite Members")
224 .selected(mode == Mode::InviteMembers)
225 .on_click(cx.listener(|this, _, cx| {
226 this.set_mode(Mode::InviteMembers, cx);
227 })),
228 ),
229 ),
230 )
231 .child(self.picker.clone())
232 }
233}
234
235#[derive(Copy, Clone, PartialEq)]
236pub enum Mode {
237 ManageMembers,
238 InviteMembers,
239}
240
241pub struct ChannelModalDelegate {
242 channel_modal: WeakView<ChannelModal>,
243 matching_users: Vec<Arc<User>>,
244 matching_member_indices: Vec<usize>,
245 user_store: Model<UserStore>,
246 channel_store: Model<ChannelStore>,
247 channel_id: ChannelId,
248 selected_index: usize,
249 mode: Mode,
250 match_candidates: Vec<StringMatchCandidate>,
251 members: Vec<ChannelMembership>,
252 context_menu: Option<(View<ContextMenu>, Subscription)>,
253}
254
255impl PickerDelegate for ChannelModalDelegate {
256 type ListItem = ListItem;
257
258 fn placeholder_text(&self) -> Arc<str> {
259 "Search collaborator by username...".into()
260 }
261
262 fn match_count(&self) -> usize {
263 match self.mode {
264 Mode::ManageMembers => self.matching_member_indices.len(),
265 Mode::InviteMembers => self.matching_users.len(),
266 }
267 }
268
269 fn selected_index(&self) -> usize {
270 self.selected_index
271 }
272
273 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
274 self.selected_index = ix;
275 }
276
277 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
278 match self.mode {
279 Mode::ManageMembers => {
280 self.match_candidates.clear();
281 self.match_candidates
282 .extend(self.members.iter().enumerate().map(|(id, member)| {
283 StringMatchCandidate {
284 id,
285 string: member.user.github_login.clone(),
286 char_bag: member.user.github_login.chars().collect(),
287 }
288 }));
289
290 let matches = cx.background_executor().block(match_strings(
291 &self.match_candidates,
292 &query,
293 true,
294 usize::MAX,
295 &Default::default(),
296 cx.background_executor().clone(),
297 ));
298
299 cx.spawn(|picker, mut cx| async move {
300 picker
301 .update(&mut cx, |picker, cx| {
302 let delegate = &mut picker.delegate;
303 delegate.matching_member_indices.clear();
304 delegate
305 .matching_member_indices
306 .extend(matches.into_iter().map(|m| m.candidate_id));
307 cx.notify();
308 })
309 .ok();
310 })
311 }
312 Mode::InviteMembers => {
313 let search_users = self
314 .user_store
315 .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
316 cx.spawn(|picker, mut cx| async move {
317 async {
318 let users = search_users.await?;
319 picker.update(&mut cx, |picker, cx| {
320 picker.delegate.matching_users = users;
321 cx.notify();
322 })?;
323 anyhow::Ok(())
324 }
325 .log_err()
326 .await;
327 })
328 }
329 }
330 }
331
332 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
333 if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
334 match self.mode {
335 Mode::ManageMembers => {
336 self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx)
337 }
338 Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
339 Some(proto::channel_member::Kind::Invitee) => {
340 self.remove_member(selected_user.id, cx);
341 }
342 Some(proto::channel_member::Kind::AncestorMember) | None => {
343 self.invite_member(selected_user, cx)
344 }
345 Some(proto::channel_member::Kind::Member) => {}
346 },
347 }
348 }
349 }
350
351 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
352 if self.context_menu.is_none() {
353 self.channel_modal
354 .update(cx, |_, cx| {
355 cx.emit(DismissEvent);
356 })
357 .ok();
358 }
359 }
360
361 fn render_match(
362 &self,
363 ix: usize,
364 selected: bool,
365 cx: &mut ViewContext<Picker<Self>>,
366 ) -> Option<Self::ListItem> {
367 let (user, role) = self.user_at_index(ix)?;
368 let request_status = self.member_status(user.id, cx);
369
370 Some(
371 ListItem::new(ix)
372 .inset(true)
373 .spacing(ListItemSpacing::Sparse)
374 .selected(selected)
375 .start_slot(Avatar::new(user.avatar_uri.clone()))
376 .child(Label::new(user.github_login.clone()))
377 .end_slot(h_stack().gap_2().map(|slot| {
378 match self.mode {
379 Mode::ManageMembers => slot
380 .children(
381 if request_status == Some(proto::channel_member::Kind::Invitee) {
382 Some(Label::new("Invited"))
383 } else {
384 None
385 },
386 )
387 .children(match role {
388 Some(ChannelRole::Admin) => Some(Label::new("Admin")),
389 Some(ChannelRole::Guest) => Some(Label::new("Guest")),
390 _ => None,
391 })
392 .child(IconButton::new("ellipsis", Icon::Ellipsis))
393 .children(
394 if let (Some((menu, _)), true) = (&self.context_menu, selected) {
395 Some(
396 overlay()
397 .anchor(gpui::AnchorCorner::TopLeft)
398 .child(menu.clone()),
399 )
400 } else {
401 None
402 },
403 ),
404 Mode::InviteMembers => match request_status {
405 Some(proto::channel_member::Kind::Invitee) => {
406 slot.children(Some(Label::new("Invited")))
407 }
408 Some(proto::channel_member::Kind::Member) => {
409 slot.children(Some(Label::new("Member")))
410 }
411 _ => slot,
412 },
413 }
414 })),
415 )
416 }
417}
418
419impl ChannelModalDelegate {
420 fn member_status(
421 &self,
422 user_id: UserId,
423 cx: &AppContext,
424 ) -> Option<proto::channel_member::Kind> {
425 self.members
426 .iter()
427 .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
428 .or_else(|| {
429 self.channel_store
430 .read(cx)
431 .has_pending_channel_invite(self.channel_id, user_id)
432 .then_some(proto::channel_member::Kind::Invitee)
433 })
434 }
435
436 fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<ChannelRole>)> {
437 match self.mode {
438 Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
439 let channel_membership = self.members.get(*ix)?;
440 Some((
441 channel_membership.user.clone(),
442 Some(channel_membership.role),
443 ))
444 }),
445 Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
446 }
447 }
448
449 fn set_user_role(
450 &mut self,
451 user_id: UserId,
452 new_role: ChannelRole,
453 cx: &mut ViewContext<Picker<Self>>,
454 ) -> Option<()> {
455 let update = self.channel_store.update(cx, |store, cx| {
456 store.set_member_role(self.channel_id, user_id, new_role, cx)
457 });
458 cx.spawn(|picker, mut cx| async move {
459 update.await?;
460 picker.update(&mut cx, |picker, cx| {
461 let this = &mut picker.delegate;
462 if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) {
463 member.role = new_role;
464 }
465 cx.focus_self();
466 cx.notify();
467 })
468 })
469 .detach_and_log_err(cx);
470 Some(())
471 }
472
473 fn remove_member(&mut self, user_id: UserId, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
474 let update = self.channel_store.update(cx, |store, cx| {
475 store.remove_member(self.channel_id, user_id, cx)
476 });
477 cx.spawn(|picker, mut cx| async move {
478 update.await?;
479 picker.update(&mut cx, |picker, cx| {
480 let this = &mut picker.delegate;
481 if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
482 this.members.remove(ix);
483 this.matching_member_indices.retain_mut(|member_ix| {
484 if *member_ix == ix {
485 return false;
486 } else if *member_ix > ix {
487 *member_ix -= 1;
488 }
489 true
490 })
491 }
492
493 this.selected_index = this
494 .selected_index
495 .min(this.matching_member_indices.len().saturating_sub(1));
496
497 picker.focus(cx);
498 cx.notify();
499 })
500 })
501 .detach_and_log_err(cx);
502 Some(())
503 }
504
505 fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
506 let invite_member = self.channel_store.update(cx, |store, cx| {
507 store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
508 });
509
510 cx.spawn(|this, mut cx| async move {
511 invite_member.await?;
512
513 this.update(&mut cx, |this, cx| {
514 let new_member = ChannelMembership {
515 user,
516 kind: proto::channel_member::Kind::Invitee,
517 role: ChannelRole::Member,
518 };
519 let members = &mut this.delegate.members;
520 match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
521 Ok(ix) | Err(ix) => members.insert(ix, new_member),
522 }
523
524 cx.notify();
525 })
526 })
527 .detach_and_log_err(cx);
528 }
529
530 fn show_context_menu(
531 &mut self,
532 user: Arc<User>,
533 role: ChannelRole,
534 cx: &mut ViewContext<Picker<Self>>,
535 ) {
536 let user_id = user.id;
537 let picker = cx.view().clone();
538 let context_menu = ContextMenu::build(cx, |mut menu, _cx| {
539 menu = menu.entry("Remove Member", None, {
540 let picker = picker.clone();
541 move |cx| {
542 picker.update(cx, |picker, cx| {
543 picker.delegate.remove_member(user_id, cx);
544 })
545 }
546 });
547
548 let picker = picker.clone();
549 match role {
550 ChannelRole::Admin => {
551 menu = menu.entry("Revoke Admin", None, move |cx| {
552 picker.update(cx, |picker, cx| {
553 picker
554 .delegate
555 .set_user_role(user_id, ChannelRole::Member, cx);
556 })
557 });
558 }
559 ChannelRole::Member => {
560 menu = menu.entry("Make Admin", None, move |cx| {
561 picker.update(cx, |picker, cx| {
562 picker
563 .delegate
564 .set_user_role(user_id, ChannelRole::Admin, cx);
565 })
566 });
567 }
568 _ => {}
569 };
570
571 menu
572 });
573 cx.focus_view(&context_menu);
574 let subscription = cx.subscribe(&context_menu, |picker, _, _: &DismissEvent, cx| {
575 picker.delegate.context_menu = None;
576 picker.focus(cx);
577 cx.notify();
578 });
579 self.context_menu = Some((context_menu, subscription));
580 }
581}