1use std::{mem, sync::Arc};
2
3use crate::contacts_popover;
4use call::ActiveCall;
5use client::{Contact, PeerId, User, UserStore};
6use editor::{Cancel, Editor};
7use fuzzy::{match_strings, StringMatchCandidate};
8use gpui::{
9 elements::*,
10 geometry::{rect::RectF, vector::vec2f},
11 impl_actions, impl_internal_actions, keymap, AppContext, CursorStyle, Entity, ModelHandle,
12 MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
13};
14use menu::{Confirm, SelectNext, SelectPrev};
15use project::Project;
16use serde::Deserialize;
17use settings::Settings;
18use theme::IconButton;
19use util::ResultExt;
20use workspace::{JoinProject, OpenSharedScreen};
21
22impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
23impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
24
25pub fn init(cx: &mut MutableAppContext) {
26 cx.add_action(ContactList::remove_contact);
27 cx.add_action(ContactList::respond_to_contact_request);
28 cx.add_action(ContactList::clear_filter);
29 cx.add_action(ContactList::select_next);
30 cx.add_action(ContactList::select_prev);
31 cx.add_action(ContactList::confirm);
32 cx.add_action(ContactList::toggle_expanded);
33 cx.add_action(ContactList::call);
34 cx.add_action(ContactList::leave_call);
35}
36
37#[derive(Clone, PartialEq)]
38struct ToggleExpanded(Section);
39
40#[derive(Clone, PartialEq)]
41struct Call {
42 recipient_user_id: u64,
43 initial_project: Option<ModelHandle<Project>>,
44}
45
46#[derive(Copy, Clone, PartialEq)]
47struct LeaveCall;
48
49#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
50enum Section {
51 ActiveCall,
52 Requests,
53 Online,
54 Offline,
55}
56
57#[derive(Clone)]
58enum ContactEntry {
59 Header(Section),
60 CallParticipant {
61 user: Arc<User>,
62 is_pending: bool,
63 },
64 ParticipantProject {
65 project_id: u64,
66 worktree_root_names: Vec<String>,
67 host_user_id: u64,
68 is_last: bool,
69 },
70 ParticipantScreen {
71 peer_id: PeerId,
72 is_last: bool,
73 },
74 IncomingRequest(Arc<User>),
75 OutgoingRequest(Arc<User>),
76 Contact {
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.0 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(mat.candidate_id as u32);
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(peer_id.0 as usize, cx, |mouse_state, _| {
885 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
886 let row = theme.project_row.style_for(mouse_state, is_selected);
887
888 Flex::row()
889 .with_child(
890 Stack::new()
891 .with_child(
892 Canvas::new(move |bounds, _, cx| {
893 let start_x = bounds.min_x() + (bounds.width() / 2.)
894 - (tree_branch.width / 2.);
895 let end_x = bounds.max_x();
896 let start_y = bounds.min_y();
897 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
898
899 cx.scene.push_quad(gpui::Quad {
900 bounds: RectF::from_points(
901 vec2f(start_x, start_y),
902 vec2f(
903 start_x + tree_branch.width,
904 if is_last { end_y } else { bounds.max_y() },
905 ),
906 ),
907 background: Some(tree_branch.color),
908 border: gpui::Border::default(),
909 corner_radius: 0.,
910 });
911 cx.scene.push_quad(gpui::Quad {
912 bounds: RectF::from_points(
913 vec2f(start_x, end_y),
914 vec2f(end_x, end_y + tree_branch.width),
915 ),
916 background: Some(tree_branch.color),
917 border: gpui::Border::default(),
918 corner_radius: 0.,
919 });
920 })
921 .boxed(),
922 )
923 .constrained()
924 .with_width(host_avatar_height)
925 .boxed(),
926 )
927 .with_child(
928 Svg::new("icons/disable_screen_sharing_12.svg")
929 .with_color(row.icon.color)
930 .constrained()
931 .with_width(row.icon.width)
932 .aligned()
933 .left()
934 .contained()
935 .with_style(row.icon.container)
936 .boxed(),
937 )
938 .with_child(
939 Label::new("Screen".into(), row.name.text.clone())
940 .aligned()
941 .left()
942 .contained()
943 .with_style(row.name.container)
944 .flex(1., false)
945 .boxed(),
946 )
947 .constrained()
948 .with_height(theme.row_height)
949 .contained()
950 .with_style(row.container)
951 .boxed()
952 })
953 .with_cursor_style(CursorStyle::PointingHand)
954 .on_click(MouseButton::Left, move |_, cx| {
955 cx.dispatch_action(OpenSharedScreen { peer_id });
956 })
957 .boxed()
958 }
959
960 fn render_header(
961 section: Section,
962 theme: &theme::ContactList,
963 is_selected: bool,
964 is_collapsed: bool,
965 cx: &mut RenderContext<Self>,
966 ) -> ElementBox {
967 enum Header {}
968
969 let header_style = theme
970 .header_row
971 .style_for(&mut Default::default(), is_selected);
972 let text = match section {
973 Section::ActiveCall => "Collaborators",
974 Section::Requests => "Contact Requests",
975 Section::Online => "Online",
976 Section::Offline => "Offline",
977 };
978 let leave_call = if section == Section::ActiveCall {
979 Some(
980 MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
981 let style = theme.leave_call.style_for(state, false);
982 Label::new("Leave Session".into(), style.text.clone())
983 .contained()
984 .with_style(style.container)
985 .boxed()
986 })
987 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
988 .aligned()
989 .boxed(),
990 )
991 } else {
992 None
993 };
994
995 let icon_size = theme.section_icon_size;
996 MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
997 Flex::row()
998 .with_child(
999 Svg::new(if is_collapsed {
1000 "icons/chevron_right_8.svg"
1001 } else {
1002 "icons/chevron_down_8.svg"
1003 })
1004 .with_color(header_style.text.color)
1005 .constrained()
1006 .with_max_width(icon_size)
1007 .with_max_height(icon_size)
1008 .aligned()
1009 .constrained()
1010 .with_width(icon_size)
1011 .boxed(),
1012 )
1013 .with_child(
1014 Label::new(text.to_string(), header_style.text.clone())
1015 .aligned()
1016 .left()
1017 .contained()
1018 .with_margin_left(theme.contact_username.container.margin.left)
1019 .flex(1., true)
1020 .boxed(),
1021 )
1022 .with_children(leave_call)
1023 .constrained()
1024 .with_height(theme.row_height)
1025 .contained()
1026 .with_style(header_style.container)
1027 .boxed()
1028 })
1029 .with_cursor_style(CursorStyle::PointingHand)
1030 .on_click(MouseButton::Left, move |_, cx| {
1031 cx.dispatch_action(ToggleExpanded(section))
1032 })
1033 .boxed()
1034 }
1035
1036 fn render_contact(
1037 contact: &Contact,
1038 calling: bool,
1039 project: &ModelHandle<Project>,
1040 theme: &theme::ContactList,
1041 is_selected: bool,
1042 cx: &mut RenderContext<Self>,
1043 ) -> ElementBox {
1044 let online = contact.online;
1045 let busy = contact.busy || calling;
1046 let user_id = contact.user.id;
1047 let initial_project = project.clone();
1048 let mut element =
1049 MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
1050 Flex::row()
1051 .with_children(contact.user.avatar.clone().map(|avatar| {
1052 let status_badge = if contact.online {
1053 Some(
1054 Empty::new()
1055 .collapsed()
1056 .contained()
1057 .with_style(if busy {
1058 theme.contact_status_busy
1059 } else {
1060 theme.contact_status_free
1061 })
1062 .aligned()
1063 .boxed(),
1064 )
1065 } else {
1066 None
1067 };
1068 Stack::new()
1069 .with_child(
1070 Image::new(avatar)
1071 .with_style(theme.contact_avatar)
1072 .aligned()
1073 .left()
1074 .boxed(),
1075 )
1076 .with_children(status_badge)
1077 .boxed()
1078 }))
1079 .with_child(
1080 Label::new(
1081 contact.user.github_login.clone(),
1082 theme.contact_username.text.clone(),
1083 )
1084 .contained()
1085 .with_style(theme.contact_username.container)
1086 .aligned()
1087 .left()
1088 .flex(1., true)
1089 .boxed(),
1090 )
1091 .with_children(if calling {
1092 Some(
1093 Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
1094 .contained()
1095 .with_style(theme.calling_indicator.container)
1096 .aligned()
1097 .boxed(),
1098 )
1099 } else {
1100 None
1101 })
1102 .constrained()
1103 .with_height(theme.row_height)
1104 .contained()
1105 .with_style(
1106 *theme
1107 .contact_row
1108 .style_for(&mut Default::default(), is_selected),
1109 )
1110 .boxed()
1111 })
1112 .on_click(MouseButton::Left, move |_, cx| {
1113 if online && !busy {
1114 cx.dispatch_action(Call {
1115 recipient_user_id: user_id,
1116 initial_project: Some(initial_project.clone()),
1117 });
1118 }
1119 });
1120
1121 if online {
1122 element = element.with_cursor_style(CursorStyle::PointingHand);
1123 }
1124
1125 element.boxed()
1126 }
1127
1128 fn render_contact_request(
1129 user: Arc<User>,
1130 user_store: ModelHandle<UserStore>,
1131 theme: &theme::ContactList,
1132 is_incoming: bool,
1133 is_selected: bool,
1134 cx: &mut RenderContext<Self>,
1135 ) -> ElementBox {
1136 enum Decline {}
1137 enum Accept {}
1138 enum Cancel {}
1139
1140 let mut row = Flex::row()
1141 .with_children(user.avatar.clone().map(|avatar| {
1142 Image::new(avatar)
1143 .with_style(theme.contact_avatar)
1144 .aligned()
1145 .left()
1146 .boxed()
1147 }))
1148 .with_child(
1149 Label::new(
1150 user.github_login.clone(),
1151 theme.contact_username.text.clone(),
1152 )
1153 .contained()
1154 .with_style(theme.contact_username.container)
1155 .aligned()
1156 .left()
1157 .flex(1., true)
1158 .boxed(),
1159 );
1160
1161 let user_id = user.id;
1162 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1163 let button_spacing = theme.contact_button_spacing;
1164
1165 if is_incoming {
1166 row.add_children([
1167 MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
1168 let button_style = if is_contact_request_pending {
1169 &theme.disabled_button
1170 } else {
1171 theme.contact_button.style_for(mouse_state, false)
1172 };
1173 render_icon_button(button_style, "icons/x_mark_8.svg")
1174 .aligned()
1175 .boxed()
1176 })
1177 .with_cursor_style(CursorStyle::PointingHand)
1178 .on_click(MouseButton::Left, move |_, cx| {
1179 cx.dispatch_action(RespondToContactRequest {
1180 user_id,
1181 accept: false,
1182 })
1183 })
1184 .contained()
1185 .with_margin_right(button_spacing)
1186 .boxed(),
1187 MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
1188 let button_style = if is_contact_request_pending {
1189 &theme.disabled_button
1190 } else {
1191 theme.contact_button.style_for(mouse_state, false)
1192 };
1193 render_icon_button(button_style, "icons/check_8.svg")
1194 .aligned()
1195 .flex_float()
1196 .boxed()
1197 })
1198 .with_cursor_style(CursorStyle::PointingHand)
1199 .on_click(MouseButton::Left, move |_, cx| {
1200 cx.dispatch_action(RespondToContactRequest {
1201 user_id,
1202 accept: true,
1203 })
1204 })
1205 .boxed(),
1206 ]);
1207 } else {
1208 row.add_child(
1209 MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
1210 let button_style = if is_contact_request_pending {
1211 &theme.disabled_button
1212 } else {
1213 theme.contact_button.style_for(mouse_state, false)
1214 };
1215 render_icon_button(button_style, "icons/x_mark_8.svg")
1216 .aligned()
1217 .flex_float()
1218 .boxed()
1219 })
1220 .with_padding(Padding::uniform(2.))
1221 .with_cursor_style(CursorStyle::PointingHand)
1222 .on_click(MouseButton::Left, move |_, cx| {
1223 cx.dispatch_action(RemoveContact(user_id))
1224 })
1225 .flex_float()
1226 .boxed(),
1227 );
1228 }
1229
1230 row.constrained()
1231 .with_height(theme.row_height)
1232 .contained()
1233 .with_style(
1234 *theme
1235 .contact_row
1236 .style_for(&mut Default::default(), is_selected),
1237 )
1238 .boxed()
1239 }
1240
1241 fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
1242 let recipient_user_id = action.recipient_user_id;
1243 let initial_project = action.initial_project.clone();
1244 ActiveCall::global(cx)
1245 .update(cx, |call, cx| {
1246 call.invite(recipient_user_id, initial_project, cx)
1247 })
1248 .detach_and_log_err(cx);
1249 }
1250
1251 fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
1252 ActiveCall::global(cx)
1253 .update(cx, |call, cx| call.hang_up(cx))
1254 .log_err();
1255 }
1256}
1257
1258impl Entity for ContactList {
1259 type Event = Event;
1260}
1261
1262impl View for ContactList {
1263 fn ui_name() -> &'static str {
1264 "ContactList"
1265 }
1266
1267 fn keymap_context(&self, _: &AppContext) -> keymap::Context {
1268 let mut cx = Self::default_keymap_context();
1269 cx.set.insert("menu".into());
1270 cx
1271 }
1272
1273 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
1274 enum AddContact {}
1275 let theme = cx.global::<Settings>().theme.clone();
1276
1277 Flex::column()
1278 .with_child(
1279 Flex::row()
1280 .with_child(
1281 ChildView::new(self.filter_editor.clone(), cx)
1282 .contained()
1283 .with_style(theme.contact_list.user_query_editor.container)
1284 .flex(1., true)
1285 .boxed(),
1286 )
1287 .with_child(
1288 MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
1289 render_icon_button(
1290 &theme.contact_list.add_contact_button,
1291 "icons/user_plus_16.svg",
1292 )
1293 .boxed()
1294 })
1295 .with_cursor_style(CursorStyle::PointingHand)
1296 .on_click(MouseButton::Left, |_, cx| {
1297 cx.dispatch_action(contacts_popover::ToggleContactFinder)
1298 })
1299 .with_tooltip::<AddContact, _>(
1300 0,
1301 "Add contact".into(),
1302 None,
1303 theme.tooltip.clone(),
1304 cx,
1305 )
1306 .boxed(),
1307 )
1308 .constrained()
1309 .with_height(theme.contact_list.user_query_editor_height)
1310 .boxed(),
1311 )
1312 .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
1313 .boxed()
1314 }
1315
1316 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1317 if !self.filter_editor.is_focused(cx) {
1318 cx.focus(&self.filter_editor);
1319 }
1320 }
1321
1322 fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1323 if !self.filter_editor.is_focused(cx) {
1324 cx.emit(Event::Dismissed);
1325 }
1326 }
1327}
1328
1329fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
1330 Svg::new(svg_path)
1331 .with_color(style.color)
1332 .constrained()
1333 .with_width(style.icon_width)
1334 .aligned()
1335 .contained()
1336 .with_style(style.container)
1337 .constrained()
1338 .with_width(style.button_width)
1339 .with_height(style.button_width)
1340}