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