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