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(room.remote_participants().iter().map(|(_, participant)| {
465 StringMatchCandidate {
466 id: participant.user.id as usize,
467 string: participant.user.github_login.clone(),
468 char_bag: participant.user.github_login.chars().collect(),
469 }
470 }));
471 let matches = executor.block(match_strings(
472 &self.match_candidates,
473 &query,
474 true,
475 usize::MAX,
476 &Default::default(),
477 executor.clone(),
478 ));
479 for mat in matches {
480 let user_id = mat.candidate_id as u64;
481 let participant = &room.remote_participants()[&user_id];
482 participant_entries.push(ContactEntry::CallParticipant {
483 user: participant.user.clone(),
484 is_pending: false,
485 });
486 let mut projects = participant.projects.iter().peekable();
487 while let Some(project) = projects.next() {
488 participant_entries.push(ContactEntry::ParticipantProject {
489 project_id: project.id,
490 worktree_root_names: project.worktree_root_names.clone(),
491 host_user_id: participant.user.id,
492 is_last: projects.peek().is_none() && participant.tracks.is_empty(),
493 });
494 }
495 if !participant.tracks.is_empty() {
496 participant_entries.push(ContactEntry::ParticipantScreen {
497 peer_id: participant.peer_id,
498 is_last: true,
499 });
500 }
501 }
502
503 // Populate pending participants.
504 self.match_candidates.clear();
505 self.match_candidates
506 .extend(
507 room.pending_participants()
508 .iter()
509 .enumerate()
510 .map(|(id, participant)| StringMatchCandidate {
511 id,
512 string: participant.github_login.clone(),
513 char_bag: participant.github_login.chars().collect(),
514 }),
515 );
516 let matches = executor.block(match_strings(
517 &self.match_candidates,
518 &query,
519 true,
520 usize::MAX,
521 &Default::default(),
522 executor.clone(),
523 ));
524 participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
525 user: room.pending_participants()[mat.candidate_id].clone(),
526 is_pending: true,
527 }));
528
529 if !participant_entries.is_empty() {
530 self.entries.push(ContactEntry::Header(Section::ActiveCall));
531 if !self.collapsed_sections.contains(&Section::ActiveCall) {
532 self.entries.extend(participant_entries);
533 }
534 }
535 }
536
537 let mut request_entries = Vec::new();
538 let incoming = user_store.incoming_contact_requests();
539 if !incoming.is_empty() {
540 self.match_candidates.clear();
541 self.match_candidates
542 .extend(
543 incoming
544 .iter()
545 .enumerate()
546 .map(|(ix, user)| StringMatchCandidate {
547 id: ix,
548 string: user.github_login.clone(),
549 char_bag: user.github_login.chars().collect(),
550 }),
551 );
552 let matches = executor.block(match_strings(
553 &self.match_candidates,
554 &query,
555 true,
556 usize::MAX,
557 &Default::default(),
558 executor.clone(),
559 ));
560 request_entries.extend(
561 matches
562 .iter()
563 .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
564 );
565 }
566
567 let outgoing = user_store.outgoing_contact_requests();
568 if !outgoing.is_empty() {
569 self.match_candidates.clear();
570 self.match_candidates
571 .extend(
572 outgoing
573 .iter()
574 .enumerate()
575 .map(|(ix, user)| StringMatchCandidate {
576 id: ix,
577 string: user.github_login.clone(),
578 char_bag: user.github_login.chars().collect(),
579 }),
580 );
581 let matches = executor.block(match_strings(
582 &self.match_candidates,
583 &query,
584 true,
585 usize::MAX,
586 &Default::default(),
587 executor.clone(),
588 ));
589 request_entries.extend(
590 matches
591 .iter()
592 .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
593 );
594 }
595
596 if !request_entries.is_empty() {
597 self.entries.push(ContactEntry::Header(Section::Requests));
598 if !self.collapsed_sections.contains(&Section::Requests) {
599 self.entries.append(&mut request_entries);
600 }
601 }
602
603 let contacts = user_store.contacts();
604 if !contacts.is_empty() {
605 self.match_candidates.clear();
606 self.match_candidates
607 .extend(
608 contacts
609 .iter()
610 .enumerate()
611 .map(|(ix, contact)| StringMatchCandidate {
612 id: ix,
613 string: contact.user.github_login.clone(),
614 char_bag: contact.user.github_login.chars().collect(),
615 }),
616 );
617
618 let matches = executor.block(match_strings(
619 &self.match_candidates,
620 &query,
621 true,
622 usize::MAX,
623 &Default::default(),
624 executor.clone(),
625 ));
626
627 let (mut online_contacts, offline_contacts) = matches
628 .iter()
629 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
630 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
631 let room = room.read(cx);
632 online_contacts.retain(|contact| {
633 let contact = &contacts[contact.candidate_id];
634 !room.contains_participant(contact.user.id)
635 });
636 }
637
638 for (matches, section) in [
639 (online_contacts, Section::Online),
640 (offline_contacts, Section::Offline),
641 ] {
642 if !matches.is_empty() {
643 self.entries.push(ContactEntry::Header(section));
644 if !self.collapsed_sections.contains(§ion) {
645 let active_call = &ActiveCall::global(cx).read(cx);
646 for mat in matches {
647 let contact = &contacts[mat.candidate_id];
648 self.entries.push(ContactEntry::Contact {
649 contact: contact.clone(),
650 calling: active_call.pending_invites().contains(&contact.user.id),
651 });
652 }
653 }
654 }
655 }
656 }
657
658 if let Some(prev_selected_entry) = prev_selected_entry {
659 self.selection.take();
660 for (ix, entry) in self.entries.iter().enumerate() {
661 if *entry == prev_selected_entry {
662 self.selection = Some(ix);
663 break;
664 }
665 }
666 }
667
668 let old_scroll_top = self.list_state.logical_scroll_top();
669 self.list_state.reset(self.entries.len());
670
671 // Attempt to maintain the same scroll position.
672 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
673 let new_scroll_top = self
674 .entries
675 .iter()
676 .position(|entry| entry == old_top_entry)
677 .map(|item_ix| ListOffset {
678 item_ix,
679 offset_in_item: old_scroll_top.offset_in_item,
680 })
681 .or_else(|| {
682 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
683 let item_ix = self
684 .entries
685 .iter()
686 .position(|entry| entry == entry_after_old_top)?;
687 Some(ListOffset {
688 item_ix,
689 offset_in_item: 0.,
690 })
691 })
692 .or_else(|| {
693 let entry_before_old_top =
694 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
695 let item_ix = self
696 .entries
697 .iter()
698 .position(|entry| entry == entry_before_old_top)?;
699 Some(ListOffset {
700 item_ix,
701 offset_in_item: 0.,
702 })
703 });
704
705 self.list_state
706 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
707 }
708
709 cx.notify();
710 }
711
712 fn render_call_participant(
713 user: &User,
714 is_pending: bool,
715 is_selected: bool,
716 theme: &theme::ContactList,
717 ) -> ElementBox {
718 Flex::row()
719 .with_children(user.avatar.clone().map(|avatar| {
720 Image::new(avatar)
721 .with_style(theme.contact_avatar)
722 .aligned()
723 .left()
724 .boxed()
725 }))
726 .with_child(
727 Label::new(
728 user.github_login.clone(),
729 theme.contact_username.text.clone(),
730 )
731 .contained()
732 .with_style(theme.contact_username.container)
733 .aligned()
734 .left()
735 .flex(1., true)
736 .boxed(),
737 )
738 .with_children(if is_pending {
739 Some(
740 Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
741 .contained()
742 .with_style(theme.calling_indicator.container)
743 .aligned()
744 .boxed(),
745 )
746 } else {
747 None
748 })
749 .constrained()
750 .with_height(theme.row_height)
751 .contained()
752 .with_style(
753 *theme
754 .contact_row
755 .style_for(&mut Default::default(), is_selected),
756 )
757 .boxed()
758 }
759
760 fn render_participant_project(
761 project_id: u64,
762 worktree_root_names: &[String],
763 host_user_id: u64,
764 is_current: bool,
765 is_last: bool,
766 is_selected: bool,
767 theme: &theme::ContactList,
768 cx: &mut RenderContext<Self>,
769 ) -> ElementBox {
770 let font_cache = cx.font_cache();
771 let host_avatar_height = theme
772 .contact_avatar
773 .width
774 .or(theme.contact_avatar.height)
775 .unwrap_or(0.);
776 let row = &theme.project_row.default;
777 let tree_branch = theme.tree_branch;
778 let line_height = row.name.text.line_height(font_cache);
779 let cap_height = row.name.text.cap_height(font_cache);
780 let baseline_offset =
781 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
782 let project_name = if worktree_root_names.is_empty() {
783 "untitled".to_string()
784 } else {
785 worktree_root_names.join(", ")
786 };
787
788 MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, _| {
789 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
790 let row = theme.project_row.style_for(mouse_state, is_selected);
791
792 Flex::row()
793 .with_child(
794 Stack::new()
795 .with_child(
796 Canvas::new(move |bounds, _, cx| {
797 let start_x = bounds.min_x() + (bounds.width() / 2.)
798 - (tree_branch.width / 2.);
799 let end_x = bounds.max_x();
800 let start_y = bounds.min_y();
801 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
802
803 cx.scene.push_quad(gpui::Quad {
804 bounds: RectF::from_points(
805 vec2f(start_x, start_y),
806 vec2f(
807 start_x + tree_branch.width,
808 if is_last { end_y } else { bounds.max_y() },
809 ),
810 ),
811 background: Some(tree_branch.color),
812 border: gpui::Border::default(),
813 corner_radius: 0.,
814 });
815 cx.scene.push_quad(gpui::Quad {
816 bounds: RectF::from_points(
817 vec2f(start_x, end_y),
818 vec2f(end_x, end_y + tree_branch.width),
819 ),
820 background: Some(tree_branch.color),
821 border: gpui::Border::default(),
822 corner_radius: 0.,
823 });
824 })
825 .boxed(),
826 )
827 .constrained()
828 .with_width(host_avatar_height)
829 .boxed(),
830 )
831 .with_child(
832 Label::new(project_name, row.name.text.clone())
833 .aligned()
834 .left()
835 .contained()
836 .with_style(row.name.container)
837 .flex(1., false)
838 .boxed(),
839 )
840 .constrained()
841 .with_height(theme.row_height)
842 .contained()
843 .with_style(row.container)
844 .boxed()
845 })
846 .with_cursor_style(if !is_current {
847 CursorStyle::PointingHand
848 } else {
849 CursorStyle::Arrow
850 })
851 .on_click(MouseButton::Left, move |_, cx| {
852 if !is_current {
853 cx.dispatch_global_action(JoinProject {
854 project_id,
855 follow_user_id: host_user_id,
856 });
857 }
858 })
859 .boxed()
860 }
861
862 fn render_participant_screen(
863 peer_id: PeerId,
864 is_last: bool,
865 is_selected: bool,
866 theme: &theme::ContactList,
867 cx: &mut RenderContext<Self>,
868 ) -> ElementBox {
869 let font_cache = cx.font_cache();
870 let host_avatar_height = theme
871 .contact_avatar
872 .width
873 .or(theme.contact_avatar.height)
874 .unwrap_or(0.);
875 let row = &theme.project_row.default;
876 let tree_branch = theme.tree_branch;
877 let line_height = row.name.text.line_height(font_cache);
878 let cap_height = row.name.text.cap_height(font_cache);
879 let baseline_offset =
880 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
881
882 MouseEventHandler::<OpenSharedScreen>::new(
883 peer_id.as_u64() as usize,
884 cx,
885 |mouse_state, _| {
886 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
887 let row = theme.project_row.style_for(mouse_state, is_selected);
888
889 Flex::row()
890 .with_child(
891 Stack::new()
892 .with_child(
893 Canvas::new(move |bounds, _, cx| {
894 let start_x = bounds.min_x() + (bounds.width() / 2.)
895 - (tree_branch.width / 2.);
896 let end_x = bounds.max_x();
897 let start_y = bounds.min_y();
898 let end_y =
899 bounds.min_y() + baseline_offset - (cap_height / 2.);
900
901 cx.scene.push_quad(gpui::Quad {
902 bounds: RectF::from_points(
903 vec2f(start_x, start_y),
904 vec2f(
905 start_x + tree_branch.width,
906 if is_last { end_y } else { bounds.max_y() },
907 ),
908 ),
909 background: Some(tree_branch.color),
910 border: gpui::Border::default(),
911 corner_radius: 0.,
912 });
913 cx.scene.push_quad(gpui::Quad {
914 bounds: RectF::from_points(
915 vec2f(start_x, end_y),
916 vec2f(end_x, end_y + tree_branch.width),
917 ),
918 background: Some(tree_branch.color),
919 border: gpui::Border::default(),
920 corner_radius: 0.,
921 });
922 })
923 .boxed(),
924 )
925 .constrained()
926 .with_width(host_avatar_height)
927 .boxed(),
928 )
929 .with_child(
930 Svg::new("icons/disable_screen_sharing_12.svg")
931 .with_color(row.icon.color)
932 .constrained()
933 .with_width(row.icon.width)
934 .aligned()
935 .left()
936 .contained()
937 .with_style(row.icon.container)
938 .boxed(),
939 )
940 .with_child(
941 Label::new("Screen".into(), row.name.text.clone())
942 .aligned()
943 .left()
944 .contained()
945 .with_style(row.name.container)
946 .flex(1., false)
947 .boxed(),
948 )
949 .constrained()
950 .with_height(theme.row_height)
951 .contained()
952 .with_style(row.container)
953 .boxed()
954 },
955 )
956 .with_cursor_style(CursorStyle::PointingHand)
957 .on_click(MouseButton::Left, move |_, cx| {
958 cx.dispatch_action(OpenSharedScreen { peer_id });
959 })
960 .boxed()
961 }
962
963 fn render_header(
964 section: Section,
965 theme: &theme::ContactList,
966 is_selected: bool,
967 is_collapsed: bool,
968 cx: &mut RenderContext<Self>,
969 ) -> ElementBox {
970 enum Header {}
971
972 let header_style = theme
973 .header_row
974 .style_for(&mut Default::default(), is_selected);
975 let text = match section {
976 Section::ActiveCall => "Collaborators",
977 Section::Requests => "Contact Requests",
978 Section::Online => "Online",
979 Section::Offline => "Offline",
980 };
981 let leave_call = if section == Section::ActiveCall {
982 Some(
983 MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
984 let style = theme.leave_call.style_for(state, false);
985 Label::new("Leave Session".into(), style.text.clone())
986 .contained()
987 .with_style(style.container)
988 .boxed()
989 })
990 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
991 .aligned()
992 .boxed(),
993 )
994 } else {
995 None
996 };
997
998 let icon_size = theme.section_icon_size;
999 MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
1000 Flex::row()
1001 .with_child(
1002 Svg::new(if is_collapsed {
1003 "icons/chevron_right_8.svg"
1004 } else {
1005 "icons/chevron_down_8.svg"
1006 })
1007 .with_color(header_style.text.color)
1008 .constrained()
1009 .with_max_width(icon_size)
1010 .with_max_height(icon_size)
1011 .aligned()
1012 .constrained()
1013 .with_width(icon_size)
1014 .boxed(),
1015 )
1016 .with_child(
1017 Label::new(text.to_string(), header_style.text.clone())
1018 .aligned()
1019 .left()
1020 .contained()
1021 .with_margin_left(theme.contact_username.container.margin.left)
1022 .flex(1., true)
1023 .boxed(),
1024 )
1025 .with_children(leave_call)
1026 .constrained()
1027 .with_height(theme.row_height)
1028 .contained()
1029 .with_style(header_style.container)
1030 .boxed()
1031 })
1032 .with_cursor_style(CursorStyle::PointingHand)
1033 .on_click(MouseButton::Left, move |_, cx| {
1034 cx.dispatch_action(ToggleExpanded(section))
1035 })
1036 .boxed()
1037 }
1038
1039 fn render_contact(
1040 contact: &Contact,
1041 calling: bool,
1042 project: &ModelHandle<Project>,
1043 theme: &theme::ContactList,
1044 is_selected: bool,
1045 cx: &mut RenderContext<Self>,
1046 ) -> ElementBox {
1047 let online = contact.online;
1048 let busy = contact.busy || calling;
1049 let user_id = contact.user.id;
1050 let initial_project = project.clone();
1051 let mut element =
1052 MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
1053 Flex::row()
1054 .with_children(contact.user.avatar.clone().map(|avatar| {
1055 let status_badge = if contact.online {
1056 Some(
1057 Empty::new()
1058 .collapsed()
1059 .contained()
1060 .with_style(if busy {
1061 theme.contact_status_busy
1062 } else {
1063 theme.contact_status_free
1064 })
1065 .aligned()
1066 .boxed(),
1067 )
1068 } else {
1069 None
1070 };
1071 Stack::new()
1072 .with_child(
1073 Image::new(avatar)
1074 .with_style(theme.contact_avatar)
1075 .aligned()
1076 .left()
1077 .boxed(),
1078 )
1079 .with_children(status_badge)
1080 .boxed()
1081 }))
1082 .with_child(
1083 Label::new(
1084 contact.user.github_login.clone(),
1085 theme.contact_username.text.clone(),
1086 )
1087 .contained()
1088 .with_style(theme.contact_username.container)
1089 .aligned()
1090 .left()
1091 .flex(1., true)
1092 .boxed(),
1093 )
1094 .with_children(if calling {
1095 Some(
1096 Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
1097 .contained()
1098 .with_style(theme.calling_indicator.container)
1099 .aligned()
1100 .boxed(),
1101 )
1102 } else {
1103 None
1104 })
1105 .constrained()
1106 .with_height(theme.row_height)
1107 .contained()
1108 .with_style(
1109 *theme
1110 .contact_row
1111 .style_for(&mut Default::default(), is_selected),
1112 )
1113 .boxed()
1114 })
1115 .on_click(MouseButton::Left, move |_, cx| {
1116 if online && !busy {
1117 cx.dispatch_action(Call {
1118 recipient_user_id: user_id,
1119 initial_project: Some(initial_project.clone()),
1120 });
1121 }
1122 });
1123
1124 if online {
1125 element = element.with_cursor_style(CursorStyle::PointingHand);
1126 }
1127
1128 element.boxed()
1129 }
1130
1131 fn render_contact_request(
1132 user: Arc<User>,
1133 user_store: ModelHandle<UserStore>,
1134 theme: &theme::ContactList,
1135 is_incoming: bool,
1136 is_selected: bool,
1137 cx: &mut RenderContext<Self>,
1138 ) -> ElementBox {
1139 enum Decline {}
1140 enum Accept {}
1141 enum Cancel {}
1142
1143 let mut row = Flex::row()
1144 .with_children(user.avatar.clone().map(|avatar| {
1145 Image::new(avatar)
1146 .with_style(theme.contact_avatar)
1147 .aligned()
1148 .left()
1149 .boxed()
1150 }))
1151 .with_child(
1152 Label::new(
1153 user.github_login.clone(),
1154 theme.contact_username.text.clone(),
1155 )
1156 .contained()
1157 .with_style(theme.contact_username.container)
1158 .aligned()
1159 .left()
1160 .flex(1., true)
1161 .boxed(),
1162 );
1163
1164 let user_id = user.id;
1165 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1166 let button_spacing = theme.contact_button_spacing;
1167
1168 if is_incoming {
1169 row.add_children([
1170 MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
1171 let button_style = if is_contact_request_pending {
1172 &theme.disabled_button
1173 } else {
1174 theme.contact_button.style_for(mouse_state, false)
1175 };
1176 render_icon_button(button_style, "icons/x_mark_8.svg")
1177 .aligned()
1178 .boxed()
1179 })
1180 .with_cursor_style(CursorStyle::PointingHand)
1181 .on_click(MouseButton::Left, move |_, cx| {
1182 cx.dispatch_action(RespondToContactRequest {
1183 user_id,
1184 accept: false,
1185 })
1186 })
1187 .contained()
1188 .with_margin_right(button_spacing)
1189 .boxed(),
1190 MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
1191 let button_style = if is_contact_request_pending {
1192 &theme.disabled_button
1193 } else {
1194 theme.contact_button.style_for(mouse_state, false)
1195 };
1196 render_icon_button(button_style, "icons/check_8.svg")
1197 .aligned()
1198 .flex_float()
1199 .boxed()
1200 })
1201 .with_cursor_style(CursorStyle::PointingHand)
1202 .on_click(MouseButton::Left, move |_, cx| {
1203 cx.dispatch_action(RespondToContactRequest {
1204 user_id,
1205 accept: true,
1206 })
1207 })
1208 .boxed(),
1209 ]);
1210 } else {
1211 row.add_child(
1212 MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
1213 let button_style = if is_contact_request_pending {
1214 &theme.disabled_button
1215 } else {
1216 theme.contact_button.style_for(mouse_state, false)
1217 };
1218 render_icon_button(button_style, "icons/x_mark_8.svg")
1219 .aligned()
1220 .flex_float()
1221 .boxed()
1222 })
1223 .with_padding(Padding::uniform(2.))
1224 .with_cursor_style(CursorStyle::PointingHand)
1225 .on_click(MouseButton::Left, move |_, cx| {
1226 cx.dispatch_action(RemoveContact(user_id))
1227 })
1228 .flex_float()
1229 .boxed(),
1230 );
1231 }
1232
1233 row.constrained()
1234 .with_height(theme.row_height)
1235 .contained()
1236 .with_style(
1237 *theme
1238 .contact_row
1239 .style_for(&mut Default::default(), is_selected),
1240 )
1241 .boxed()
1242 }
1243
1244 fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
1245 let recipient_user_id = action.recipient_user_id;
1246 let initial_project = action.initial_project.clone();
1247 ActiveCall::global(cx)
1248 .update(cx, |call, cx| {
1249 call.invite(recipient_user_id, initial_project, cx)
1250 })
1251 .detach_and_log_err(cx);
1252 }
1253
1254 fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
1255 ActiveCall::global(cx)
1256 .update(cx, |call, cx| call.hang_up(cx))
1257 .log_err();
1258 }
1259}
1260
1261impl Entity for ContactList {
1262 type Event = Event;
1263}
1264
1265impl View for ContactList {
1266 fn ui_name() -> &'static str {
1267 "ContactList"
1268 }
1269
1270 fn keymap_context(&self, _: &AppContext) -> keymap::Context {
1271 let mut cx = Self::default_keymap_context();
1272 cx.set.insert("menu".into());
1273 cx
1274 }
1275
1276 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
1277 enum AddContact {}
1278 let theme = cx.global::<Settings>().theme.clone();
1279
1280 Flex::column()
1281 .with_child(
1282 Flex::row()
1283 .with_child(
1284 ChildView::new(self.filter_editor.clone(), cx)
1285 .contained()
1286 .with_style(theme.contact_list.user_query_editor.container)
1287 .flex(1., true)
1288 .boxed(),
1289 )
1290 .with_child(
1291 MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
1292 render_icon_button(
1293 &theme.contact_list.add_contact_button,
1294 "icons/user_plus_16.svg",
1295 )
1296 .boxed()
1297 })
1298 .with_cursor_style(CursorStyle::PointingHand)
1299 .on_click(MouseButton::Left, |_, cx| {
1300 cx.dispatch_action(contacts_popover::ToggleContactFinder)
1301 })
1302 .with_tooltip::<AddContact, _>(
1303 0,
1304 "Add contact".into(),
1305 None,
1306 theme.tooltip.clone(),
1307 cx,
1308 )
1309 .boxed(),
1310 )
1311 .constrained()
1312 .with_height(theme.contact_list.user_query_editor_height)
1313 .boxed(),
1314 )
1315 .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
1316 .boxed()
1317 }
1318
1319 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1320 if !self.filter_editor.is_focused(cx) {
1321 cx.focus(&self.filter_editor);
1322 }
1323 }
1324
1325 fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1326 if !self.filter_editor.is_focused(cx) {
1327 cx.emit(Event::Dismissed);
1328 }
1329 }
1330}
1331
1332fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
1333 Svg::new(svg_path)
1334 .with_color(style.color)
1335 .constrained()
1336 .with_width(style.icon_width)
1337 .aligned()
1338 .contained()
1339 .with_style(style.container)
1340 .constrained()
1341 .with_width(style.button_width)
1342 .with_height(style.button_width)
1343}