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(|theme| theme.contact_list.user_query_editor.clone()),
179 cx,
180 );
181 editor.set_placeholder_text("Filter contacts", cx);
182 editor
183 });
184
185 cx.subscribe(&filter_editor, |this, _, event, cx| {
186 if let editor::Event::BufferEdited = event {
187 let query = this.filter_editor.read(cx).text(cx);
188 if !query.is_empty() {
189 this.selection.take();
190 }
191 this.update_entries(cx);
192 if !query.is_empty() {
193 this.selection = this
194 .entries
195 .iter()
196 .position(|entry| !matches!(entry, ContactEntry::Header(_)));
197 }
198 }
199 })
200 .detach();
201
202 let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
203 let theme = cx.global::<Settings>().theme.clone();
204 let is_selected = this.selection == Some(ix);
205 let current_project_id = this.project.read(cx).remote_id();
206
207 match &this.entries[ix] {
208 ContactEntry::Header(section) => {
209 let is_collapsed = this.collapsed_sections.contains(section);
210 Self::render_header(
211 *section,
212 &theme.contact_list,
213 is_selected,
214 is_collapsed,
215 cx,
216 )
217 }
218 ContactEntry::CallParticipant { user, is_pending } => {
219 Self::render_call_participant(
220 user,
221 *is_pending,
222 is_selected,
223 &theme.contact_list,
224 )
225 }
226 ContactEntry::ParticipantProject {
227 project_id,
228 worktree_root_names,
229 host_user_id,
230 is_last,
231 } => Self::render_participant_project(
232 *project_id,
233 worktree_root_names,
234 *host_user_id,
235 Some(*project_id) == current_project_id,
236 *is_last,
237 is_selected,
238 &theme.contact_list,
239 cx,
240 ),
241 ContactEntry::ParticipantScreen { peer_id, is_last } => {
242 Self::render_participant_screen(
243 *peer_id,
244 *is_last,
245 is_selected,
246 &theme.contact_list,
247 cx,
248 )
249 }
250 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
251 user.clone(),
252 this.user_store.clone(),
253 &theme.contact_list,
254 true,
255 is_selected,
256 cx,
257 ),
258 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
259 user.clone(),
260 this.user_store.clone(),
261 &theme.contact_list,
262 false,
263 is_selected,
264 cx,
265 ),
266 ContactEntry::Contact { contact, calling } => Self::render_contact(
267 contact,
268 *calling,
269 &this.project,
270 &theme.contact_list,
271 is_selected,
272 cx,
273 ),
274 }
275 });
276
277 let active_call = ActiveCall::global(cx);
278 let mut subscriptions = Vec::new();
279 subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
280 subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
281
282 let mut this = Self {
283 list_state,
284 selection: None,
285 collapsed_sections: Default::default(),
286 entries: Default::default(),
287 match_candidates: Default::default(),
288 filter_editor,
289 _subscriptions: subscriptions,
290 project,
291 user_store,
292 };
293 this.update_entries(cx);
294 this
295 }
296
297 fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
298 self.user_store
299 .update(cx, |store, cx| store.remove_contact(request.0, cx))
300 .detach();
301 }
302
303 fn respond_to_contact_request(
304 &mut self,
305 action: &RespondToContactRequest,
306 cx: &mut ViewContext<Self>,
307 ) {
308 self.user_store
309 .update(cx, |store, cx| {
310 store.respond_to_contact_request(action.user_id, action.accept, cx)
311 })
312 .detach();
313 }
314
315 fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
316 let did_clear = self.filter_editor.update(cx, |editor, cx| {
317 if editor.buffer().read(cx).len(cx) > 0 {
318 editor.set_text("", cx);
319 true
320 } else {
321 false
322 }
323 });
324 if !did_clear {
325 cx.emit(Event::Dismissed);
326 }
327 }
328
329 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
330 if let Some(ix) = self.selection {
331 if self.entries.len() > ix + 1 {
332 self.selection = Some(ix + 1);
333 }
334 } else if !self.entries.is_empty() {
335 self.selection = Some(0);
336 }
337 self.list_state.reset(self.entries.len());
338 if let Some(ix) = self.selection {
339 self.list_state.scroll_to(ListOffset {
340 item_ix: ix,
341 offset_in_item: 0.,
342 });
343 }
344 cx.notify();
345 }
346
347 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
348 if let Some(ix) = self.selection {
349 if ix > 0 {
350 self.selection = Some(ix - 1);
351 } else {
352 self.selection = None;
353 }
354 }
355 self.list_state.reset(self.entries.len());
356 if let Some(ix) = self.selection {
357 self.list_state.scroll_to(ListOffset {
358 item_ix: ix,
359 offset_in_item: 0.,
360 });
361 }
362 cx.notify();
363 }
364
365 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
366 if let Some(selection) = self.selection {
367 if let Some(entry) = self.entries.get(selection) {
368 match entry {
369 ContactEntry::Header(section) => {
370 let section = *section;
371 self.toggle_expanded(&ToggleExpanded(section), cx);
372 }
373 ContactEntry::Contact { contact, calling } => {
374 if contact.online && !contact.busy && !calling {
375 self.call(
376 &Call {
377 recipient_user_id: contact.user.id,
378 initial_project: Some(self.project.clone()),
379 },
380 cx,
381 );
382 }
383 }
384 ContactEntry::ParticipantProject {
385 project_id,
386 host_user_id,
387 ..
388 } => {
389 cx.dispatch_global_action(JoinProject {
390 project_id: *project_id,
391 follow_user_id: *host_user_id,
392 });
393 }
394 ContactEntry::ParticipantScreen { peer_id, .. } => {
395 cx.dispatch_action(OpenSharedScreen { peer_id: *peer_id });
396 }
397 _ => {}
398 }
399 }
400 }
401 }
402
403 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
404 let section = action.0;
405 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
406 self.collapsed_sections.remove(ix);
407 } else {
408 self.collapsed_sections.push(section);
409 }
410 self.update_entries(cx);
411 }
412
413 fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
414 let user_store = self.user_store.read(cx);
415 let query = self.filter_editor.read(cx).text(cx);
416 let executor = cx.background().clone();
417
418 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
419 let old_entries = mem::take(&mut self.entries);
420
421 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
422 let room = room.read(cx);
423 let mut participant_entries = Vec::new();
424
425 // Populate the active user.
426 if let Some(user) = user_store.current_user() {
427 self.match_candidates.clear();
428 self.match_candidates.push(StringMatchCandidate {
429 id: 0,
430 string: user.github_login.clone(),
431 char_bag: user.github_login.chars().collect(),
432 });
433 let matches = executor.block(match_strings(
434 &self.match_candidates,
435 &query,
436 true,
437 usize::MAX,
438 &Default::default(),
439 executor.clone(),
440 ));
441 if !matches.is_empty() {
442 let user_id = user.id;
443 participant_entries.push(ContactEntry::CallParticipant {
444 user,
445 is_pending: false,
446 });
447 let mut projects = room.local_participant().projects.iter().peekable();
448 while let Some(project) = projects.next() {
449 participant_entries.push(ContactEntry::ParticipantProject {
450 project_id: project.id,
451 worktree_root_names: project.worktree_root_names.clone(),
452 host_user_id: user_id,
453 is_last: projects.peek().is_none(),
454 });
455 }
456 }
457 }
458
459 // Populate remote participants.
460 self.match_candidates.clear();
461 self.match_candidates
462 .extend(
463 room.remote_participants()
464 .iter()
465 .map(|(peer_id, participant)| StringMatchCandidate {
466 id: peer_id.0 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 peer_id = PeerId(mat.candidate_id as u32);
481 let participant = &room.remote_participants()[&peer_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,
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(peer_id.0 as usize, cx, |mouse_state, _| {
883 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
884 let row = theme.project_row.style_for(mouse_state, is_selected);
885
886 Flex::row()
887 .with_child(
888 Stack::new()
889 .with_child(
890 Canvas::new(move |bounds, _, cx| {
891 let start_x = bounds.min_x() + (bounds.width() / 2.)
892 - (tree_branch.width / 2.);
893 let end_x = bounds.max_x();
894 let start_y = bounds.min_y();
895 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
896
897 cx.scene.push_quad(gpui::Quad {
898 bounds: RectF::from_points(
899 vec2f(start_x, start_y),
900 vec2f(
901 start_x + tree_branch.width,
902 if is_last { end_y } else { bounds.max_y() },
903 ),
904 ),
905 background: Some(tree_branch.color),
906 border: gpui::Border::default(),
907 corner_radius: 0.,
908 });
909 cx.scene.push_quad(gpui::Quad {
910 bounds: RectF::from_points(
911 vec2f(start_x, end_y),
912 vec2f(end_x, end_y + tree_branch.width),
913 ),
914 background: Some(tree_branch.color),
915 border: gpui::Border::default(),
916 corner_radius: 0.,
917 });
918 })
919 .boxed(),
920 )
921 .constrained()
922 .with_width(host_avatar_height)
923 .boxed(),
924 )
925 .with_child(
926 Svg::new("icons/disable_screen_sharing_12.svg")
927 .with_color(row.icon.color)
928 .constrained()
929 .with_width(row.icon.width)
930 .aligned()
931 .left()
932 .contained()
933 .with_style(row.icon.container)
934 .boxed(),
935 )
936 .with_child(
937 Label::new("Screen".into(), row.name.text.clone())
938 .aligned()
939 .left()
940 .contained()
941 .with_style(row.name.container)
942 .flex(1., false)
943 .boxed(),
944 )
945 .constrained()
946 .with_height(theme.row_height)
947 .contained()
948 .with_style(row.container)
949 .boxed()
950 })
951 .with_cursor_style(CursorStyle::PointingHand)
952 .on_click(MouseButton::Left, move |_, cx| {
953 cx.dispatch_action(OpenSharedScreen { peer_id });
954 })
955 .boxed()
956 }
957
958 fn render_header(
959 section: Section,
960 theme: &theme::ContactList,
961 is_selected: bool,
962 is_collapsed: bool,
963 cx: &mut RenderContext<Self>,
964 ) -> ElementBox {
965 enum Header {}
966
967 let header_style = theme
968 .header_row
969 .style_for(&mut Default::default(), is_selected);
970 let text = match section {
971 Section::ActiveCall => "Collaborators",
972 Section::Requests => "Contact Requests",
973 Section::Online => "Online",
974 Section::Offline => "Offline",
975 };
976 let leave_call = if section == Section::ActiveCall {
977 Some(
978 MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
979 let style = theme.leave_call.style_for(state, false);
980 Label::new("Leave Session".into(), style.text.clone())
981 .contained()
982 .with_style(style.container)
983 .boxed()
984 })
985 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
986 .aligned()
987 .boxed(),
988 )
989 } else {
990 None
991 };
992
993 let icon_size = theme.section_icon_size;
994 MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
995 Flex::row()
996 .with_child(
997 Svg::new(if is_collapsed {
998 "icons/chevron_right_8.svg"
999 } else {
1000 "icons/chevron_down_8.svg"
1001 })
1002 .with_color(header_style.text.color)
1003 .constrained()
1004 .with_max_width(icon_size)
1005 .with_max_height(icon_size)
1006 .aligned()
1007 .constrained()
1008 .with_width(icon_size)
1009 .boxed(),
1010 )
1011 .with_child(
1012 Label::new(text.to_string(), header_style.text.clone())
1013 .aligned()
1014 .left()
1015 .contained()
1016 .with_margin_left(theme.contact_username.container.margin.left)
1017 .flex(1., true)
1018 .boxed(),
1019 )
1020 .with_children(leave_call)
1021 .constrained()
1022 .with_height(theme.row_height)
1023 .contained()
1024 .with_style(header_style.container)
1025 .boxed()
1026 })
1027 .with_cursor_style(CursorStyle::PointingHand)
1028 .on_click(MouseButton::Left, move |_, cx| {
1029 cx.dispatch_action(ToggleExpanded(section))
1030 })
1031 .boxed()
1032 }
1033
1034 fn render_contact(
1035 contact: &Contact,
1036 calling: bool,
1037 project: &ModelHandle<Project>,
1038 theme: &theme::ContactList,
1039 is_selected: bool,
1040 cx: &mut RenderContext<Self>,
1041 ) -> ElementBox {
1042 let online = contact.online;
1043 let busy = contact.busy || calling;
1044 let user_id = contact.user.id;
1045 let initial_project = project.clone();
1046 let mut element =
1047 MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
1048 Flex::row()
1049 .with_children(contact.user.avatar.clone().map(|avatar| {
1050 let status_badge = if contact.online {
1051 Some(
1052 Empty::new()
1053 .collapsed()
1054 .contained()
1055 .with_style(if busy {
1056 theme.contact_status_busy
1057 } else {
1058 theme.contact_status_free
1059 })
1060 .aligned()
1061 .boxed(),
1062 )
1063 } else {
1064 None
1065 };
1066 Stack::new()
1067 .with_child(
1068 Image::new(avatar)
1069 .with_style(theme.contact_avatar)
1070 .aligned()
1071 .left()
1072 .boxed(),
1073 )
1074 .with_children(status_badge)
1075 .boxed()
1076 }))
1077 .with_child(
1078 Label::new(
1079 contact.user.github_login.clone(),
1080 theme.contact_username.text.clone(),
1081 )
1082 .contained()
1083 .with_style(theme.contact_username.container)
1084 .aligned()
1085 .left()
1086 .flex(1., true)
1087 .boxed(),
1088 )
1089 .with_children(if calling {
1090 Some(
1091 Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
1092 .contained()
1093 .with_style(theme.calling_indicator.container)
1094 .aligned()
1095 .boxed(),
1096 )
1097 } else {
1098 None
1099 })
1100 .constrained()
1101 .with_height(theme.row_height)
1102 .contained()
1103 .with_style(
1104 *theme
1105 .contact_row
1106 .style_for(&mut Default::default(), is_selected),
1107 )
1108 .boxed()
1109 })
1110 .on_click(MouseButton::Left, move |_, cx| {
1111 if online && !busy {
1112 cx.dispatch_action(Call {
1113 recipient_user_id: user_id,
1114 initial_project: Some(initial_project.clone()),
1115 });
1116 }
1117 });
1118
1119 if online {
1120 element = element.with_cursor_style(CursorStyle::PointingHand);
1121 }
1122
1123 element.boxed()
1124 }
1125
1126 fn render_contact_request(
1127 user: Arc<User>,
1128 user_store: ModelHandle<UserStore>,
1129 theme: &theme::ContactList,
1130 is_incoming: bool,
1131 is_selected: bool,
1132 cx: &mut RenderContext<Self>,
1133 ) -> ElementBox {
1134 enum Decline {}
1135 enum Accept {}
1136 enum Cancel {}
1137
1138 let mut row = Flex::row()
1139 .with_children(user.avatar.clone().map(|avatar| {
1140 Image::new(avatar)
1141 .with_style(theme.contact_avatar)
1142 .aligned()
1143 .left()
1144 .boxed()
1145 }))
1146 .with_child(
1147 Label::new(
1148 user.github_login.clone(),
1149 theme.contact_username.text.clone(),
1150 )
1151 .contained()
1152 .with_style(theme.contact_username.container)
1153 .aligned()
1154 .left()
1155 .flex(1., true)
1156 .boxed(),
1157 );
1158
1159 let user_id = user.id;
1160 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1161 let button_spacing = theme.contact_button_spacing;
1162
1163 if is_incoming {
1164 row.add_children([
1165 MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
1166 let button_style = if is_contact_request_pending {
1167 &theme.disabled_button
1168 } else {
1169 theme.contact_button.style_for(mouse_state, false)
1170 };
1171 render_icon_button(button_style, "icons/x_mark_8.svg")
1172 .aligned()
1173 .boxed()
1174 })
1175 .with_cursor_style(CursorStyle::PointingHand)
1176 .on_click(MouseButton::Left, move |_, cx| {
1177 cx.dispatch_action(RespondToContactRequest {
1178 user_id,
1179 accept: false,
1180 })
1181 })
1182 .contained()
1183 .with_margin_right(button_spacing)
1184 .boxed(),
1185 MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
1186 let button_style = if is_contact_request_pending {
1187 &theme.disabled_button
1188 } else {
1189 theme.contact_button.style_for(mouse_state, false)
1190 };
1191 render_icon_button(button_style, "icons/check_8.svg")
1192 .aligned()
1193 .flex_float()
1194 .boxed()
1195 })
1196 .with_cursor_style(CursorStyle::PointingHand)
1197 .on_click(MouseButton::Left, move |_, cx| {
1198 cx.dispatch_action(RespondToContactRequest {
1199 user_id,
1200 accept: true,
1201 })
1202 })
1203 .boxed(),
1204 ]);
1205 } else {
1206 row.add_child(
1207 MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
1208 let button_style = if is_contact_request_pending {
1209 &theme.disabled_button
1210 } else {
1211 theme.contact_button.style_for(mouse_state, false)
1212 };
1213 render_icon_button(button_style, "icons/x_mark_8.svg")
1214 .aligned()
1215 .flex_float()
1216 .boxed()
1217 })
1218 .with_padding(Padding::uniform(2.))
1219 .with_cursor_style(CursorStyle::PointingHand)
1220 .on_click(MouseButton::Left, move |_, cx| {
1221 cx.dispatch_action(RemoveContact(user_id))
1222 })
1223 .flex_float()
1224 .boxed(),
1225 );
1226 }
1227
1228 row.constrained()
1229 .with_height(theme.row_height)
1230 .contained()
1231 .with_style(
1232 *theme
1233 .contact_row
1234 .style_for(&mut Default::default(), is_selected),
1235 )
1236 .boxed()
1237 }
1238
1239 fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
1240 let recipient_user_id = action.recipient_user_id;
1241 let initial_project = action.initial_project.clone();
1242 ActiveCall::global(cx)
1243 .update(cx, |call, cx| {
1244 call.invite(recipient_user_id, initial_project, cx)
1245 })
1246 .detach_and_log_err(cx);
1247 }
1248
1249 fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
1250 ActiveCall::global(cx)
1251 .update(cx, |call, cx| call.hang_up(cx))
1252 .log_err();
1253 }
1254}
1255
1256impl Entity for ContactList {
1257 type Event = Event;
1258}
1259
1260impl View for ContactList {
1261 fn ui_name() -> &'static str {
1262 "ContactList"
1263 }
1264
1265 fn keymap_context(&self, _: &AppContext) -> keymap::Context {
1266 let mut cx = Self::default_keymap_context();
1267 cx.set.insert("menu".into());
1268 cx
1269 }
1270
1271 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
1272 enum AddContact {}
1273 let theme = cx.global::<Settings>().theme.clone();
1274
1275 Flex::column()
1276 .with_child(
1277 Flex::row()
1278 .with_child(
1279 ChildView::new(self.filter_editor.clone(), cx)
1280 .contained()
1281 .with_style(theme.contact_list.user_query_editor.container)
1282 .flex(1., true)
1283 .boxed(),
1284 )
1285 .with_child(
1286 MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
1287 render_icon_button(
1288 &theme.contact_list.add_contact_button,
1289 "icons/user_plus_16.svg",
1290 )
1291 .boxed()
1292 })
1293 .with_cursor_style(CursorStyle::PointingHand)
1294 .on_click(MouseButton::Left, |_, cx| {
1295 cx.dispatch_action(contacts_popover::ToggleContactFinder)
1296 })
1297 .with_tooltip::<AddContact, _>(
1298 0,
1299 "Add contact".into(),
1300 None,
1301 theme.tooltip.clone(),
1302 cx,
1303 )
1304 .boxed(),
1305 )
1306 .constrained()
1307 .with_height(theme.contact_list.user_query_editor_height)
1308 .boxed(),
1309 )
1310 .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
1311 .boxed()
1312 }
1313
1314 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1315 if !self.filter_editor.is_focused(cx) {
1316 cx.focus(&self.filter_editor);
1317 }
1318 }
1319
1320 fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1321 if !self.filter_editor.is_focused(cx) {
1322 cx.emit(Event::Dismissed);
1323 }
1324 }
1325}
1326
1327fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
1328 Svg::new(svg_path)
1329 .with_color(style.color)
1330 .constrained()
1331 .with_width(style.icon_width)
1332 .aligned()
1333 .contained()
1334 .with_style(style.container)
1335 .constrained()
1336 .with_width(style.button_width)
1337 .with_height(style.button_width)
1338}