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