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