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