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