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