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