1mod contact_finder;
2mod contact_notification;
3mod join_project_notification;
4mod notifications;
5
6use client::{Contact, ContactEventKind, User, UserStore};
7use contact_notification::ContactNotification;
8use editor::{Cancel, Editor};
9use fuzzy::{match_strings, StringMatchCandidate};
10use gpui::{
11 elements::*,
12 geometry::{rect::RectF, vector::vec2f},
13 impl_actions, impl_internal_actions,
14 platform::CursorStyle,
15 AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MutableAppContext,
16 RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
17};
18use join_project_notification::JoinProjectNotification;
19use menu::{Confirm, SelectNext, SelectPrev};
20use serde::Deserialize;
21use settings::Settings;
22use std::sync::Arc;
23use theme::IconButton;
24use workspace::{sidebar::SidebarItem, JoinProject, Workspace};
25
26impl_actions!(
27 contacts_panel,
28 [RequestContact, RemoveContact, RespondToContactRequest]
29);
30
31impl_internal_actions!(contacts_panel, [ToggleExpanded]);
32
33#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
34enum Section {
35 Requests,
36 Online,
37 Offline,
38}
39
40#[derive(Clone, Debug)]
41enum ContactEntry {
42 Header(Section),
43 IncomingRequest(Arc<User>),
44 OutgoingRequest(Arc<User>),
45 Contact(Arc<Contact>),
46 ContactProject(Arc<Contact>, usize),
47}
48
49#[derive(Clone)]
50struct ToggleExpanded(Section);
51
52pub struct ContactsPanel {
53 entries: Vec<ContactEntry>,
54 match_candidates: Vec<StringMatchCandidate>,
55 list_state: ListState,
56 user_store: ModelHandle<UserStore>,
57 filter_editor: ViewHandle<Editor>,
58 collapsed_sections: Vec<Section>,
59 selection: Option<usize>,
60 _maintain_contacts: Subscription,
61}
62
63#[derive(Clone, Deserialize)]
64pub struct RequestContact(pub u64);
65
66#[derive(Clone, Deserialize)]
67pub struct RemoveContact(pub u64);
68
69#[derive(Clone, Deserialize)]
70pub struct RespondToContactRequest {
71 pub user_id: u64,
72 pub accept: bool,
73}
74
75pub fn init(cx: &mut MutableAppContext) {
76 contact_finder::init(cx);
77 contact_notification::init(cx);
78 join_project_notification::init(cx);
79 cx.add_action(ContactsPanel::request_contact);
80 cx.add_action(ContactsPanel::remove_contact);
81 cx.add_action(ContactsPanel::respond_to_contact_request);
82 cx.add_action(ContactsPanel::clear_filter);
83 cx.add_action(ContactsPanel::select_next);
84 cx.add_action(ContactsPanel::select_prev);
85 cx.add_action(ContactsPanel::confirm);
86 cx.add_action(ContactsPanel::toggle_expanded);
87}
88
89impl ContactsPanel {
90 pub fn new(
91 user_store: ModelHandle<UserStore>,
92 workspace: WeakViewHandle<Workspace>,
93 cx: &mut ViewContext<Self>,
94 ) -> Self {
95 let filter_editor = cx.add_view(|cx| {
96 let mut editor = Editor::single_line(
97 Some(|theme| theme.contacts_panel.user_query_editor.clone()),
98 cx,
99 );
100 editor.set_placeholder_text("Filter contacts", cx);
101 editor
102 });
103
104 cx.subscribe(&filter_editor, |this, _, event, cx| {
105 if let editor::Event::BufferEdited = event {
106 let query = this.filter_editor.read(cx).text(cx);
107 if !query.is_empty() {
108 this.selection.take();
109 }
110 this.update_entries(cx);
111 if !query.is_empty() {
112 this.selection = this
113 .entries
114 .iter()
115 .position(|entry| !matches!(entry, ContactEntry::Header(_)));
116 }
117 }
118 })
119 .detach();
120
121 cx.defer({
122 let workspace = workspace.clone();
123 move |_, cx| {
124 if let Some(workspace_handle) = workspace.upgrade(cx) {
125 cx.subscribe(&workspace_handle.read(cx).project().clone(), {
126 let workspace = workspace.clone();
127 move |_, project, event, cx| match event {
128 project::Event::ContactRequestedJoin(user) => {
129 if let Some(workspace) = workspace.upgrade(cx) {
130 workspace.update(cx, |workspace, cx| {
131 workspace.show_notification(user.id as usize, cx, |cx| {
132 cx.add_view(|cx| {
133 JoinProjectNotification::new(
134 project,
135 user.clone(),
136 cx,
137 )
138 })
139 })
140 });
141 }
142 }
143 _ => {}
144 }
145 })
146 .detach();
147 }
148 }
149 });
150
151 cx.subscribe(&user_store, {
152 let user_store = user_store.downgrade();
153 move |_, _, event, cx| {
154 if let Some((workspace, user_store)) =
155 workspace.upgrade(cx).zip(user_store.upgrade(cx))
156 {
157 workspace.update(cx, |workspace, cx| match event {
158 client::Event::Contact { user, kind } => match kind {
159 ContactEventKind::Requested | ContactEventKind::Accepted => workspace
160 .show_notification(user.id as usize, cx, |cx| {
161 cx.add_view(|cx| {
162 ContactNotification::new(
163 user.clone(),
164 *kind,
165 user_store,
166 cx,
167 )
168 })
169 }),
170 _ => {}
171 },
172 _ => {}
173 });
174 }
175
176 if let client::Event::ShowContacts = event {
177 cx.emit(Event::Activate);
178 }
179 }
180 })
181 .detach();
182
183 let mut this = Self {
184 list_state: ListState::new(0, Orientation::Top, 1000., cx, {
185 move |this, ix, cx| {
186 let theme = cx.global::<Settings>().theme.clone();
187 let theme = &theme.contacts_panel;
188 let current_user_id =
189 this.user_store.read(cx).current_user().map(|user| user.id);
190 let is_selected = this.selection == Some(ix);
191
192 match &this.entries[ix] {
193 ContactEntry::Header(section) => {
194 let is_collapsed = this.collapsed_sections.contains(§ion);
195 Self::render_header(*section, theme, is_selected, is_collapsed, cx)
196 }
197 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
198 user.clone(),
199 this.user_store.clone(),
200 theme,
201 true,
202 is_selected,
203 cx,
204 ),
205 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
206 user.clone(),
207 this.user_store.clone(),
208 theme,
209 false,
210 is_selected,
211 cx,
212 ),
213 ContactEntry::Contact(contact) => {
214 Self::render_contact(contact.clone(), theme, is_selected)
215 }
216 ContactEntry::ContactProject(contact, project_ix) => {
217 let is_last_project_for_contact =
218 this.entries.get(ix + 1).map_or(true, |next| {
219 if let ContactEntry::ContactProject(next_contact, _) = next {
220 next_contact.user.id != contact.user.id
221 } else {
222 true
223 }
224 });
225 Self::render_contact_project(
226 contact.clone(),
227 current_user_id,
228 *project_ix,
229 theme,
230 is_last_project_for_contact,
231 is_selected,
232 cx,
233 )
234 }
235 }
236 }
237 }),
238 selection: None,
239 collapsed_sections: Default::default(),
240 entries: Default::default(),
241 match_candidates: Default::default(),
242 filter_editor,
243 _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)),
244 user_store,
245 };
246 this.update_entries(cx);
247 this
248 }
249
250 fn render_header(
251 section: Section,
252 theme: &theme::ContactsPanel,
253 is_selected: bool,
254 is_collapsed: bool,
255 cx: &mut RenderContext<Self>,
256 ) -> ElementBox {
257 enum Header {}
258
259 let header_style = theme.header_row.style_for(Default::default(), is_selected);
260 let text = match section {
261 Section::Requests => "Requests",
262 Section::Online => "Online",
263 Section::Offline => "Offline",
264 };
265 let icon_size = theme.section_icon_size;
266 MouseEventHandler::new::<Header, _, _>(section as usize, cx, |_, _| {
267 Flex::row()
268 .with_child(
269 Svg::new(if is_collapsed {
270 "icons/disclosure-closed.svg"
271 } else {
272 "icons/disclosure-open.svg"
273 })
274 .with_color(header_style.text.color)
275 .constrained()
276 .with_max_width(icon_size)
277 .with_max_height(icon_size)
278 .aligned()
279 .constrained()
280 .with_width(icon_size)
281 .boxed(),
282 )
283 .with_child(
284 Label::new(text.to_string(), header_style.text.clone())
285 .aligned()
286 .left()
287 .contained()
288 .with_margin_left(theme.contact_username.container.margin.left)
289 .flex(1., true)
290 .boxed(),
291 )
292 .constrained()
293 .with_height(theme.row_height)
294 .contained()
295 .with_style(header_style.container)
296 .boxed()
297 })
298 .with_cursor_style(CursorStyle::PointingHand)
299 .on_click(move |_, _, cx| cx.dispatch_action(ToggleExpanded(section)))
300 .boxed()
301 }
302
303 fn render_contact(
304 contact: Arc<Contact>,
305 theme: &theme::ContactsPanel,
306 is_selected: bool,
307 ) -> ElementBox {
308 Flex::row()
309 .with_children(contact.user.avatar.clone().map(|avatar| {
310 Image::new(avatar)
311 .with_style(theme.contact_avatar)
312 .aligned()
313 .left()
314 .boxed()
315 }))
316 .with_child(
317 Label::new(
318 contact.user.github_login.clone(),
319 theme.contact_username.text.clone(),
320 )
321 .contained()
322 .with_style(theme.contact_username.container)
323 .aligned()
324 .left()
325 .flex(1., true)
326 .boxed(),
327 )
328 .constrained()
329 .with_height(theme.row_height)
330 .contained()
331 .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
332 .boxed()
333 }
334
335 fn render_contact_project(
336 contact: Arc<Contact>,
337 current_user_id: Option<u64>,
338 project_index: usize,
339 theme: &theme::ContactsPanel,
340 is_last_project: bool,
341 is_selected: bool,
342 cx: &mut RenderContext<Self>,
343 ) -> ElementBox {
344 let project = &contact.projects[project_index];
345 let project_id = project.id;
346 let is_host = Some(contact.user.id) == current_user_id;
347
348 let font_cache = cx.font_cache();
349 let host_avatar_height = theme
350 .contact_avatar
351 .width
352 .or(theme.contact_avatar.height)
353 .unwrap_or(0.);
354 let row = &theme.project_row.default;
355 let tree_branch = theme.tree_branch.clone();
356 let line_height = row.name.text.line_height(font_cache);
357 let cap_height = row.name.text.cap_height(font_cache);
358 let baseline_offset =
359 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
360
361 MouseEventHandler::new::<JoinProject, _, _>(project_id as usize, cx, |mouse_state, _| {
362 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
363 let row = theme.project_row.style_for(mouse_state, is_selected);
364
365 Flex::row()
366 .with_child(
367 Canvas::new(move |bounds, _, cx| {
368 let start_x =
369 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
370 let end_x = bounds.max_x();
371 let start_y = bounds.min_y();
372 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
373
374 cx.scene.push_quad(gpui::Quad {
375 bounds: RectF::from_points(
376 vec2f(start_x, start_y),
377 vec2f(
378 start_x + tree_branch.width,
379 if is_last_project {
380 end_y
381 } else {
382 bounds.max_y()
383 },
384 ),
385 ),
386 background: Some(tree_branch.color),
387 border: gpui::Border::default(),
388 corner_radius: 0.,
389 });
390 cx.scene.push_quad(gpui::Quad {
391 bounds: RectF::from_points(
392 vec2f(start_x, end_y),
393 vec2f(end_x, end_y + tree_branch.width),
394 ),
395 background: Some(tree_branch.color),
396 border: gpui::Border::default(),
397 corner_radius: 0.,
398 });
399 })
400 .constrained()
401 .with_width(host_avatar_height)
402 .boxed(),
403 )
404 .with_child(
405 Label::new(
406 project.worktree_root_names.join(", "),
407 row.name.text.clone(),
408 )
409 .aligned()
410 .left()
411 .contained()
412 .with_style(row.name.container)
413 .flex(1., false)
414 .boxed(),
415 )
416 .with_children(project.guests.iter().filter_map(|participant| {
417 participant.avatar.clone().map(|avatar| {
418 Image::new(avatar)
419 .with_style(row.guest_avatar)
420 .aligned()
421 .left()
422 .contained()
423 .with_margin_right(row.guest_avatar_spacing)
424 .boxed()
425 })
426 }))
427 .constrained()
428 .with_height(theme.row_height)
429 .contained()
430 .with_style(row.container)
431 .boxed()
432 })
433 .with_cursor_style(if !is_host {
434 CursorStyle::PointingHand
435 } else {
436 CursorStyle::Arrow
437 })
438 .on_click(move |_, _, cx| {
439 if !is_host {
440 cx.dispatch_global_action(JoinProject {
441 contact: contact.clone(),
442 project_index,
443 });
444 }
445 })
446 .boxed()
447 }
448
449 fn render_contact_request(
450 user: Arc<User>,
451 user_store: ModelHandle<UserStore>,
452 theme: &theme::ContactsPanel,
453 is_incoming: bool,
454 is_selected: bool,
455 cx: &mut RenderContext<ContactsPanel>,
456 ) -> ElementBox {
457 enum Decline {}
458 enum Accept {}
459 enum Cancel {}
460
461 let mut row = Flex::row()
462 .with_children(user.avatar.clone().map(|avatar| {
463 Image::new(avatar)
464 .with_style(theme.contact_avatar)
465 .aligned()
466 .left()
467 .boxed()
468 }))
469 .with_child(
470 Label::new(
471 user.github_login.clone(),
472 theme.contact_username.text.clone(),
473 )
474 .contained()
475 .with_style(theme.contact_username.container)
476 .aligned()
477 .left()
478 .flex(1., true)
479 .boxed(),
480 );
481
482 let user_id = user.id;
483 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
484 let button_spacing = theme.contact_button_spacing;
485
486 if is_incoming {
487 row.add_children([
488 MouseEventHandler::new::<Decline, _, _>(user.id as usize, cx, |mouse_state, _| {
489 let button_style = if is_contact_request_pending {
490 &theme.disabled_contact_button
491 } else {
492 &theme.contact_button.style_for(mouse_state, false)
493 };
494 render_icon_button(button_style, "icons/decline.svg")
495 .aligned()
496 // .flex_float()
497 .boxed()
498 })
499 .with_cursor_style(CursorStyle::PointingHand)
500 .on_click(move |_, _, cx| {
501 cx.dispatch_action(RespondToContactRequest {
502 user_id,
503 accept: false,
504 })
505 })
506 // .flex_float()
507 .contained()
508 .with_margin_right(button_spacing)
509 .boxed(),
510 MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |mouse_state, _| {
511 let button_style = if is_contact_request_pending {
512 &theme.disabled_contact_button
513 } else {
514 &theme.contact_button.style_for(mouse_state, false)
515 };
516 render_icon_button(button_style, "icons/accept.svg")
517 .aligned()
518 .flex_float()
519 .boxed()
520 })
521 .with_cursor_style(CursorStyle::PointingHand)
522 .on_click(move |_, _, cx| {
523 cx.dispatch_action(RespondToContactRequest {
524 user_id,
525 accept: true,
526 })
527 })
528 .boxed(),
529 ]);
530 } else {
531 row.add_child(
532 MouseEventHandler::new::<Cancel, _, _>(user.id as usize, cx, |mouse_state, _| {
533 let button_style = if is_contact_request_pending {
534 &theme.disabled_contact_button
535 } else {
536 &theme.contact_button.style_for(mouse_state, false)
537 };
538 render_icon_button(button_style, "icons/decline.svg")
539 .aligned()
540 .flex_float()
541 .boxed()
542 })
543 .with_padding(Padding::uniform(2.))
544 .with_cursor_style(CursorStyle::PointingHand)
545 .on_click(move |_, _, cx| cx.dispatch_action(RemoveContact(user_id)))
546 .flex_float()
547 .boxed(),
548 );
549 }
550
551 row.constrained()
552 .with_height(theme.row_height)
553 .contained()
554 .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
555 .boxed()
556 }
557
558 fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
559 let user_store = self.user_store.read(cx);
560 let query = self.filter_editor.read(cx).text(cx);
561 let executor = cx.background().clone();
562
563 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
564 self.entries.clear();
565
566 let mut request_entries = Vec::new();
567 let incoming = user_store.incoming_contact_requests();
568 if !incoming.is_empty() {
569 self.match_candidates.clear();
570 self.match_candidates
571 .extend(
572 incoming
573 .iter()
574 .enumerate()
575 .map(|(ix, user)| StringMatchCandidate {
576 id: ix,
577 string: user.github_login.clone(),
578 char_bag: user.github_login.chars().collect(),
579 }),
580 );
581 let matches = executor.block(match_strings(
582 &self.match_candidates,
583 &query,
584 true,
585 usize::MAX,
586 &Default::default(),
587 executor.clone(),
588 ));
589 request_entries.extend(
590 matches
591 .iter()
592 .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
593 );
594 }
595
596 let outgoing = user_store.outgoing_contact_requests();
597 if !outgoing.is_empty() {
598 self.match_candidates.clear();
599 self.match_candidates
600 .extend(
601 outgoing
602 .iter()
603 .enumerate()
604 .map(|(ix, user)| StringMatchCandidate {
605 id: ix,
606 string: user.github_login.clone(),
607 char_bag: user.github_login.chars().collect(),
608 }),
609 );
610 let matches = executor.block(match_strings(
611 &self.match_candidates,
612 &query,
613 true,
614 usize::MAX,
615 &Default::default(),
616 executor.clone(),
617 ));
618 request_entries.extend(
619 matches
620 .iter()
621 .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
622 );
623 }
624
625 if !request_entries.is_empty() {
626 self.entries.push(ContactEntry::Header(Section::Requests));
627 if !self.collapsed_sections.contains(&Section::Requests) {
628 self.entries.append(&mut request_entries);
629 }
630 }
631
632 let contacts = user_store.contacts();
633 if !contacts.is_empty() {
634 self.match_candidates.clear();
635 self.match_candidates
636 .extend(
637 contacts
638 .iter()
639 .enumerate()
640 .map(|(ix, contact)| StringMatchCandidate {
641 id: ix,
642 string: contact.user.github_login.clone(),
643 char_bag: contact.user.github_login.chars().collect(),
644 }),
645 );
646 let matches = executor.block(match_strings(
647 &self.match_candidates,
648 &query,
649 true,
650 usize::MAX,
651 &Default::default(),
652 executor.clone(),
653 ));
654
655 let (online_contacts, offline_contacts) = matches
656 .iter()
657 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
658
659 for (matches, section) in [
660 (online_contacts, Section::Online),
661 (offline_contacts, Section::Offline),
662 ] {
663 if !matches.is_empty() {
664 self.entries.push(ContactEntry::Header(section));
665 if !self.collapsed_sections.contains(§ion) {
666 for mat in matches {
667 let contact = &contacts[mat.candidate_id];
668 self.entries.push(ContactEntry::Contact(contact.clone()));
669 self.entries
670 .extend(contact.projects.iter().enumerate().filter_map(
671 |(ix, project)| {
672 if project.worktree_root_names.is_empty() {
673 None
674 } else {
675 Some(ContactEntry::ContactProject(contact.clone(), ix))
676 }
677 },
678 ));
679 }
680 }
681 }
682 }
683 }
684
685 if let Some(prev_selected_entry) = prev_selected_entry {
686 self.selection.take();
687 for (ix, entry) in self.entries.iter().enumerate() {
688 if *entry == prev_selected_entry {
689 self.selection = Some(ix);
690 break;
691 }
692 }
693 }
694
695 self.list_state.reset(self.entries.len());
696 cx.notify();
697 }
698
699 fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
700 self.user_store
701 .update(cx, |store, cx| store.request_contact(request.0, cx))
702 .detach();
703 }
704
705 fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
706 self.user_store
707 .update(cx, |store, cx| store.remove_contact(request.0, cx))
708 .detach();
709 }
710
711 fn respond_to_contact_request(
712 &mut self,
713 action: &RespondToContactRequest,
714 cx: &mut ViewContext<Self>,
715 ) {
716 self.user_store
717 .update(cx, |store, cx| {
718 store.respond_to_contact_request(action.user_id, action.accept, cx)
719 })
720 .detach();
721 }
722
723 fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
724 self.filter_editor
725 .update(cx, |editor, cx| editor.set_text("", cx));
726 }
727
728 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
729 if let Some(ix) = self.selection {
730 if self.entries.len() > ix + 1 {
731 self.selection = Some(ix + 1);
732 }
733 } else if !self.entries.is_empty() {
734 self.selection = Some(0);
735 }
736 cx.notify();
737 self.list_state.reset(self.entries.len());
738 }
739
740 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
741 if let Some(ix) = self.selection {
742 if ix > 0 {
743 self.selection = Some(ix - 1);
744 } else {
745 self.selection = None;
746 }
747 }
748 cx.notify();
749 self.list_state.reset(self.entries.len());
750 }
751
752 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
753 if let Some(selection) = self.selection {
754 if let Some(entry) = self.entries.get(selection) {
755 match entry {
756 ContactEntry::Header(section) => {
757 let section = *section;
758 self.toggle_expanded(&ToggleExpanded(section), cx);
759 }
760 ContactEntry::ContactProject(contact, project_index) => cx
761 .dispatch_global_action(JoinProject {
762 contact: contact.clone(),
763 project_index: *project_index,
764 }),
765 _ => {}
766 }
767 }
768 }
769 }
770
771 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
772 let section = action.0;
773 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
774 self.collapsed_sections.remove(ix);
775 } else {
776 self.collapsed_sections.push(section);
777 }
778 self.update_entries(cx);
779 }
780}
781
782impl SidebarItem for ContactsPanel {
783 fn should_show_badge(&self, cx: &AppContext) -> bool {
784 !self
785 .user_store
786 .read(cx)
787 .incoming_contact_requests()
788 .is_empty()
789 }
790
791 fn contains_focused_view(&self, cx: &AppContext) -> bool {
792 self.filter_editor.is_focused(cx)
793 }
794
795 fn should_activate_item_on_event(&self, event: &Event, _: &AppContext) -> bool {
796 matches!(event, Event::Activate)
797 }
798}
799
800fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
801 Svg::new(svg_path)
802 .with_color(style.color)
803 .constrained()
804 .with_width(style.icon_width)
805 .aligned()
806 .contained()
807 .with_style(style.container)
808 .constrained()
809 .with_width(style.button_width)
810 .with_height(style.button_width)
811}
812
813pub enum Event {
814 Activate,
815}
816
817impl Entity for ContactsPanel {
818 type Event = Event;
819}
820
821impl View for ContactsPanel {
822 fn ui_name() -> &'static str {
823 "ContactsPanel"
824 }
825
826 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
827 enum AddContact {}
828
829 let theme = cx.global::<Settings>().theme.clone();
830 let theme = &theme.contacts_panel;
831 Container::new(
832 Flex::column()
833 .with_child(
834 Flex::row()
835 .with_child(
836 ChildView::new(self.filter_editor.clone())
837 .contained()
838 .with_style(theme.user_query_editor.container)
839 .flex(1., true)
840 .boxed(),
841 )
842 .with_child(
843 MouseEventHandler::new::<AddContact, _, _>(0, cx, |_, _| {
844 Svg::new("icons/add-contact.svg")
845 .with_color(theme.add_contact_button.color)
846 .constrained()
847 .with_height(12.)
848 .contained()
849 .with_style(theme.add_contact_button.container)
850 .aligned()
851 .boxed()
852 })
853 .with_cursor_style(CursorStyle::PointingHand)
854 .on_click(|_, _, cx| cx.dispatch_action(contact_finder::Toggle))
855 .boxed(),
856 )
857 .constrained()
858 .with_height(theme.user_query_editor_height)
859 .boxed(),
860 )
861 .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
862 .with_children(
863 self.user_store
864 .read(cx)
865 .invite_info()
866 .cloned()
867 .and_then(|info| {
868 enum InviteLink {}
869
870 if info.count > 0 {
871 Some(
872 MouseEventHandler::new::<InviteLink, _, _>(
873 0,
874 cx,
875 |state, cx| {
876 let style =
877 theme.invite_row.style_for(state, false).clone();
878
879 let copied =
880 cx.read_from_clipboard().map_or(false, |item| {
881 item.text().as_str() == info.url.as_ref()
882 });
883
884 Label::new(
885 format!(
886 "{} invite link ({} left)",
887 if copied { "Copied" } else { "Copy" },
888 info.count
889 ),
890 style.label.clone(),
891 )
892 .aligned()
893 .left()
894 .constrained()
895 .with_height(theme.row_height)
896 .contained()
897 .with_style(style.container)
898 .boxed()
899 },
900 )
901 .with_cursor_style(CursorStyle::PointingHand)
902 .on_click(move |_, _, cx| {
903 cx.write_to_clipboard(ClipboardItem::new(
904 info.url.to_string(),
905 ));
906 cx.notify();
907 })
908 .boxed(),
909 )
910 } else {
911 None
912 }
913 }),
914 )
915 .boxed(),
916 )
917 .with_style(theme.container)
918 .boxed()
919 }
920
921 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
922 cx.focus(&self.filter_editor);
923 }
924
925 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
926 let mut cx = Self::default_keymap_context();
927 cx.set.insert("menu".into());
928 cx
929 }
930}
931
932impl PartialEq for ContactEntry {
933 fn eq(&self, other: &Self) -> bool {
934 match self {
935 ContactEntry::Header(section_1) => {
936 if let ContactEntry::Header(section_2) = other {
937 return section_1 == section_2;
938 }
939 }
940 ContactEntry::IncomingRequest(user_1) => {
941 if let ContactEntry::IncomingRequest(user_2) = other {
942 return user_1.id == user_2.id;
943 }
944 }
945 ContactEntry::OutgoingRequest(user_1) => {
946 if let ContactEntry::OutgoingRequest(user_2) = other {
947 return user_1.id == user_2.id;
948 }
949 }
950 ContactEntry::Contact(contact_1) => {
951 if let ContactEntry::Contact(contact_2) = other {
952 return contact_1.user.id == contact_2.user.id;
953 }
954 }
955 ContactEntry::ContactProject(contact_1, ix_1) => {
956 if let ContactEntry::ContactProject(contact_2, ix_2) = other {
957 return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
958 }
959 }
960 }
961 false
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use client::{proto, test::FakeServer, Client};
969 use gpui::TestAppContext;
970 use language::LanguageRegistry;
971 use project::Project;
972 use theme::ThemeRegistry;
973 use workspace::AppState;
974
975 #[gpui::test]
976 async fn test_contact_panel(cx: &mut TestAppContext) {
977 let (app_state, server) = init(cx).await;
978 let project = Project::test(app_state.fs.clone(), [], cx).await;
979 let workspace = cx.add_view(0, |cx| Workspace::new(project, cx));
980 let panel = cx.add_view(0, |cx| {
981 ContactsPanel::new(app_state.user_store.clone(), workspace.downgrade(), cx)
982 });
983
984 let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
985 server
986 .respond(
987 get_users_request.receipt(),
988 proto::UsersResponse {
989 users: [
990 "user_zero",
991 "user_one",
992 "user_two",
993 "user_three",
994 "user_four",
995 "user_five",
996 ]
997 .into_iter()
998 .enumerate()
999 .map(|(id, name)| proto::User {
1000 id: id as u64,
1001 github_login: name.to_string(),
1002 ..Default::default()
1003 })
1004 .collect(),
1005 },
1006 )
1007 .await;
1008
1009 server.send(proto::UpdateContacts {
1010 incoming_requests: vec![proto::IncomingContactRequest {
1011 requester_id: 1,
1012 should_notify: false,
1013 }],
1014 outgoing_requests: vec![2],
1015 contacts: vec![
1016 proto::Contact {
1017 user_id: 3,
1018 online: true,
1019 should_notify: false,
1020 projects: vec![proto::ProjectMetadata {
1021 id: 101,
1022 worktree_root_names: vec!["dir1".to_string()],
1023 guests: vec![2],
1024 }],
1025 },
1026 proto::Contact {
1027 user_id: 4,
1028 online: true,
1029 should_notify: false,
1030 projects: vec![proto::ProjectMetadata {
1031 id: 102,
1032 worktree_root_names: vec!["dir2".to_string()],
1033 guests: vec![2],
1034 }],
1035 },
1036 proto::Contact {
1037 user_id: 5,
1038 online: false,
1039 should_notify: false,
1040 projects: vec![],
1041 },
1042 ],
1043 ..Default::default()
1044 });
1045
1046 cx.foreground().run_until_parked();
1047 assert_eq!(
1048 render_to_strings(&panel, cx),
1049 &[
1050 "+",
1051 "v Requests",
1052 " incoming user_one",
1053 " outgoing user_two",
1054 "v Online",
1055 " user_four",
1056 " dir2",
1057 " user_three",
1058 " dir1",
1059 "v Offline",
1060 " user_five",
1061 ]
1062 );
1063
1064 panel.update(cx, |panel, cx| {
1065 panel
1066 .filter_editor
1067 .update(cx, |editor, cx| editor.set_text("f", cx))
1068 });
1069 cx.foreground().run_until_parked();
1070 assert_eq!(
1071 render_to_strings(&panel, cx),
1072 &[
1073 "+",
1074 "v Online",
1075 " user_four <=== selected",
1076 " dir2",
1077 "v Offline",
1078 " user_five",
1079 ]
1080 );
1081
1082 panel.update(cx, |panel, cx| {
1083 panel.select_next(&Default::default(), cx);
1084 });
1085 assert_eq!(
1086 render_to_strings(&panel, cx),
1087 &[
1088 "+",
1089 "v Online",
1090 " user_four",
1091 " dir2 <=== selected",
1092 "v Offline",
1093 " user_five",
1094 ]
1095 );
1096
1097 panel.update(cx, |panel, cx| {
1098 panel.select_next(&Default::default(), cx);
1099 });
1100 assert_eq!(
1101 render_to_strings(&panel, cx),
1102 &[
1103 "+",
1104 "v Online",
1105 " user_four",
1106 " dir2",
1107 "v Offline <=== selected",
1108 " user_five",
1109 ]
1110 );
1111 }
1112
1113 fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &TestAppContext) -> Vec<String> {
1114 panel.read_with(cx, |panel, _| {
1115 let mut entries = Vec::new();
1116 entries.push("+".to_string());
1117 entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
1118 let mut string = match entry {
1119 ContactEntry::Header(name) => {
1120 let icon = if panel.collapsed_sections.contains(name) {
1121 ">"
1122 } else {
1123 "v"
1124 };
1125 format!("{} {:?}", icon, name)
1126 }
1127 ContactEntry::IncomingRequest(user) => {
1128 format!(" incoming {}", user.github_login)
1129 }
1130 ContactEntry::OutgoingRequest(user) => {
1131 format!(" outgoing {}", user.github_login)
1132 }
1133 ContactEntry::Contact(contact) => {
1134 format!(" {}", contact.user.github_login)
1135 }
1136 ContactEntry::ContactProject(contact, project_ix) => {
1137 format!(
1138 " {}",
1139 contact.projects[*project_ix].worktree_root_names.join(", ")
1140 )
1141 }
1142 };
1143
1144 if panel.selection == Some(ix) {
1145 string.push_str(" <=== selected");
1146 }
1147
1148 string
1149 }));
1150 entries
1151 })
1152 }
1153
1154 async fn init(cx: &mut TestAppContext) -> (Arc<AppState>, FakeServer) {
1155 cx.update(|cx| cx.set_global(Settings::test(cx)));
1156 let themes = ThemeRegistry::new((), cx.font_cache());
1157 let fs = project::FakeFs::new(cx.background().clone());
1158 let languages = Arc::new(LanguageRegistry::test());
1159 let http_client = client::test::FakeHttpClient::with_404_response();
1160 let mut client = Client::new(http_client.clone());
1161 let server = FakeServer::for_client(100, &mut client, &cx).await;
1162 let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
1163
1164 (
1165 Arc::new(AppState {
1166 languages,
1167 themes,
1168 client,
1169 user_store: user_store.clone(),
1170 fs,
1171 build_window_options: || Default::default(),
1172 initialize_workspace: |_, _, _| {},
1173 }),
1174 server,
1175 )
1176 }
1177}