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