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