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