1use call::ActiveCall;
2use client::{proto::PeerId, Contact, User, UserStore};
3use editor::{Cancel, Editor};
4use futures::StreamExt;
5use fuzzy::{match_strings, StringMatchCandidate};
6use gpui::{
7 elements::*,
8 geometry::{rect::RectF, vector::vec2f},
9 impl_actions,
10 keymap_matcher::KeymapContext,
11 platform::{CursorStyle, MouseButton, PromptLevel},
12 AppContext, Entity, ModelHandle, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
13};
14use menu::{Confirm, SelectNext, SelectPrev};
15use project::Project;
16use serde::Deserialize;
17use settings::Settings;
18use std::{mem, sync::Arc};
19use theme::IconButton;
20use workspace::Workspace;
21
22impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
23
24pub fn init(cx: &mut AppContext) {
25 cx.add_action(ContactList::remove_contact);
26 cx.add_action(ContactList::respond_to_contact_request);
27 cx.add_action(ContactList::cancel);
28 cx.add_action(ContactList::select_next);
29 cx.add_action(ContactList::select_prev);
30 cx.add_action(ContactList::confirm);
31}
32
33#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
34enum Section {
35 ActiveCall,
36 Requests,
37 Online,
38 Offline,
39}
40
41#[derive(Clone)]
42enum ContactEntry {
43 Header(Section),
44 CallParticipant {
45 user: Arc<User>,
46 is_pending: bool,
47 },
48 ParticipantProject {
49 project_id: u64,
50 worktree_root_names: Vec<String>,
51 host_user_id: u64,
52 is_last: bool,
53 },
54 ParticipantScreen {
55 peer_id: PeerId,
56 is_last: bool,
57 },
58 IncomingRequest(Arc<User>),
59 OutgoingRequest(Arc<User>),
60 Contact {
61 contact: Arc<Contact>,
62 calling: bool,
63 },
64}
65
66impl PartialEq for ContactEntry {
67 fn eq(&self, other: &Self) -> bool {
68 match self {
69 ContactEntry::Header(section_1) => {
70 if let ContactEntry::Header(section_2) = other {
71 return section_1 == section_2;
72 }
73 }
74 ContactEntry::CallParticipant { user: user_1, .. } => {
75 if let ContactEntry::CallParticipant { user: user_2, .. } = other {
76 return user_1.id == user_2.id;
77 }
78 }
79 ContactEntry::ParticipantProject {
80 project_id: project_id_1,
81 ..
82 } => {
83 if let ContactEntry::ParticipantProject {
84 project_id: project_id_2,
85 ..
86 } = other
87 {
88 return project_id_1 == project_id_2;
89 }
90 }
91 ContactEntry::ParticipantScreen {
92 peer_id: peer_id_1, ..
93 } => {
94 if let ContactEntry::ParticipantScreen {
95 peer_id: peer_id_2, ..
96 } = other
97 {
98 return peer_id_1 == peer_id_2;
99 }
100 }
101 ContactEntry::IncomingRequest(user_1) => {
102 if let ContactEntry::IncomingRequest(user_2) = other {
103 return user_1.id == user_2.id;
104 }
105 }
106 ContactEntry::OutgoingRequest(user_1) => {
107 if let ContactEntry::OutgoingRequest(user_2) = other {
108 return user_1.id == user_2.id;
109 }
110 }
111 ContactEntry::Contact {
112 contact: contact_1, ..
113 } => {
114 if let ContactEntry::Contact {
115 contact: contact_2, ..
116 } = other
117 {
118 return contact_1.user.id == contact_2.user.id;
119 }
120 }
121 }
122 false
123 }
124}
125
126#[derive(Clone, Deserialize, PartialEq)]
127pub struct RequestContact(pub u64);
128
129#[derive(Clone, Deserialize, PartialEq)]
130pub struct RemoveContact {
131 user_id: u64,
132 github_login: String,
133}
134
135#[derive(Clone, Deserialize, PartialEq)]
136pub struct RespondToContactRequest {
137 pub user_id: u64,
138 pub accept: bool,
139}
140
141pub enum Event {
142 ToggleContactFinder,
143 Dismissed,
144}
145
146pub struct ContactList {
147 entries: Vec<ContactEntry>,
148 match_candidates: Vec<StringMatchCandidate>,
149 list_state: ListState<Self>,
150 project: ModelHandle<Project>,
151 workspace: WeakViewHandle<Workspace>,
152 user_store: ModelHandle<UserStore>,
153 filter_editor: ViewHandle<Editor>,
154 collapsed_sections: Vec<Section>,
155 selection: Option<usize>,
156 _subscriptions: Vec<Subscription>,
157}
158
159impl ContactList {
160 pub fn new(
161 project: ModelHandle<Project>,
162 user_store: ModelHandle<UserStore>,
163 workspace: WeakViewHandle<Workspace>,
164 cx: &mut ViewContext<Self>,
165 ) -> Self {
166 let filter_editor = cx.add_view(|cx| {
167 let mut editor = Editor::single_line(
168 Some(Arc::new(|theme| {
169 theme.contact_list.user_query_editor.clone()
170 })),
171 cx,
172 );
173 editor.set_placeholder_text("Filter contacts", cx);
174 editor
175 });
176
177 cx.subscribe(&filter_editor, |this, _, event, cx| {
178 if let editor::Event::BufferEdited = event {
179 let query = this.filter_editor.read(cx).text(cx);
180 if !query.is_empty() {
181 this.selection.take();
182 }
183 this.update_entries(cx);
184 if !query.is_empty() {
185 this.selection = this
186 .entries
187 .iter()
188 .position(|entry| !matches!(entry, ContactEntry::Header(_)));
189 }
190 }
191 })
192 .detach();
193
194 let list_state = ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
195 let theme = cx.global::<Settings>().theme.clone();
196 let is_selected = this.selection == Some(ix);
197 let current_project_id = this.project.read(cx).remote_id();
198
199 match &this.entries[ix] {
200 ContactEntry::Header(section) => {
201 let is_collapsed = this.collapsed_sections.contains(section);
202 Self::render_header(
203 *section,
204 &theme.contact_list,
205 is_selected,
206 is_collapsed,
207 cx,
208 )
209 }
210 ContactEntry::CallParticipant { user, is_pending } => {
211 Self::render_call_participant(
212 user,
213 *is_pending,
214 is_selected,
215 &theme.contact_list,
216 )
217 }
218 ContactEntry::ParticipantProject {
219 project_id,
220 worktree_root_names,
221 host_user_id,
222 is_last,
223 } => Self::render_participant_project(
224 *project_id,
225 worktree_root_names,
226 *host_user_id,
227 Some(*project_id) == current_project_id,
228 *is_last,
229 is_selected,
230 &theme.contact_list,
231 cx,
232 ),
233 ContactEntry::ParticipantScreen { peer_id, is_last } => {
234 Self::render_participant_screen(
235 *peer_id,
236 *is_last,
237 is_selected,
238 &theme.contact_list,
239 cx,
240 )
241 }
242 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
243 user.clone(),
244 this.user_store.clone(),
245 &theme.contact_list,
246 true,
247 is_selected,
248 cx,
249 ),
250 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
251 user.clone(),
252 this.user_store.clone(),
253 &theme.contact_list,
254 false,
255 is_selected,
256 cx,
257 ),
258 ContactEntry::Contact { contact, calling } => Self::render_contact(
259 contact,
260 *calling,
261 &this.project,
262 &theme.contact_list,
263 is_selected,
264 cx,
265 ),
266 }
267 });
268
269 let active_call = ActiveCall::global(cx);
270 let mut subscriptions = Vec::new();
271 subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
272 subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
273
274 let mut this = Self {
275 list_state,
276 selection: None,
277 collapsed_sections: Default::default(),
278 entries: Default::default(),
279 match_candidates: Default::default(),
280 filter_editor,
281 _subscriptions: subscriptions,
282 project,
283 workspace,
284 user_store,
285 };
286 this.update_entries(cx);
287 this
288 }
289
290 pub fn editor_text(&self, cx: &AppContext) -> String {
291 self.filter_editor.read(cx).text(cx)
292 }
293
294 pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext<Self>) -> Self {
295 self.filter_editor
296 .update(cx, |picker, cx| picker.set_text(editor_text, cx));
297 self
298 }
299
300 fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
301 let user_id = request.user_id;
302 let github_login = &request.github_login;
303 let user_store = self.user_store.clone();
304 let prompt_message = format!(
305 "Are you sure you want to remove \"{}\" from your contacts?",
306 github_login
307 );
308 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
309 let window_id = cx.window_id();
310 cx.spawn(|_, mut cx| async move {
311 if answer.next().await == Some(0) {
312 if let Err(e) = user_store
313 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
314 .await
315 {
316 cx.prompt(
317 window_id,
318 PromptLevel::Info,
319 &format!("Failed to remove contact: {}", e),
320 &["Ok"],
321 );
322 }
323 }
324 })
325 .detach();
326 }
327
328 fn respond_to_contact_request(
329 &mut self,
330 action: &RespondToContactRequest,
331 cx: &mut ViewContext<Self>,
332 ) {
333 self.user_store
334 .update(cx, |store, cx| {
335 store.respond_to_contact_request(action.user_id, action.accept, cx)
336 })
337 .detach();
338 }
339
340 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
341 let did_clear = self.filter_editor.update(cx, |editor, cx| {
342 if editor.buffer().read(cx).len(cx) > 0 {
343 editor.set_text("", cx);
344 true
345 } else {
346 false
347 }
348 });
349
350 if !did_clear {
351 cx.emit(Event::Dismissed);
352 }
353 }
354
355 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
356 if let Some(ix) = self.selection {
357 if self.entries.len() > ix + 1 {
358 self.selection = Some(ix + 1);
359 }
360 } else if !self.entries.is_empty() {
361 self.selection = Some(0);
362 }
363 self.list_state.reset(self.entries.len());
364 if let Some(ix) = self.selection {
365 self.list_state.scroll_to(ListOffset {
366 item_ix: ix,
367 offset_in_item: 0.,
368 });
369 }
370 cx.notify();
371 }
372
373 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
374 if let Some(ix) = self.selection {
375 if ix > 0 {
376 self.selection = Some(ix - 1);
377 } else {
378 self.selection = None;
379 }
380 }
381 self.list_state.reset(self.entries.len());
382 if let Some(ix) = self.selection {
383 self.list_state.scroll_to(ListOffset {
384 item_ix: ix,
385 offset_in_item: 0.,
386 });
387 }
388 cx.notify();
389 }
390
391 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
392 if let Some(selection) = self.selection {
393 if let Some(entry) = self.entries.get(selection) {
394 match entry {
395 ContactEntry::Header(section) => {
396 self.toggle_expanded(*section, cx);
397 }
398 ContactEntry::Contact { contact, calling } => {
399 if contact.online && !contact.busy && !calling {
400 self.call(contact.user.id, Some(self.project.clone()), cx);
401 }
402 }
403 ContactEntry::ParticipantProject {
404 project_id,
405 host_user_id,
406 ..
407 } => {
408 if let Some(workspace) = self.workspace.upgrade(cx) {
409 let app_state = workspace.read(cx).app_state().clone();
410 workspace::join_remote_project(
411 *project_id,
412 *host_user_id,
413 app_state,
414 cx,
415 )
416 .detach_and_log_err(cx);
417 }
418 }
419 ContactEntry::ParticipantScreen { peer_id, .. } => {
420 if let Some(workspace) = self.workspace.upgrade(cx) {
421 workspace.update(cx, |workspace, cx| {
422 workspace.open_shared_screen(*peer_id, cx)
423 });
424 }
425 }
426 _ => {}
427 }
428 }
429 }
430 }
431
432 fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
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 ) -> AnyElement<Self> {
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 }))
751 .with_child(
752 Label::new(
753 user.github_login.clone(),
754 theme.contact_username.text.clone(),
755 )
756 .contained()
757 .with_style(theme.contact_username.container)
758 .aligned()
759 .left()
760 .flex(1., true),
761 )
762 .with_children(if is_pending {
763 Some(
764 Label::new("Calling", theme.calling_indicator.text.clone())
765 .contained()
766 .with_style(theme.calling_indicator.container)
767 .aligned(),
768 )
769 } else {
770 None
771 })
772 .constrained()
773 .with_height(theme.row_height)
774 .contained()
775 .with_style(
776 *theme
777 .contact_row
778 .style_for(&mut Default::default(), is_selected),
779 )
780 .into_any()
781 }
782
783 fn render_participant_project(
784 project_id: u64,
785 worktree_root_names: &[String],
786 host_user_id: u64,
787 is_current: bool,
788 is_last: bool,
789 is_selected: bool,
790 theme: &theme::ContactList,
791 cx: &mut ViewContext<Self>,
792 ) -> AnyElement<Self> {
793 enum JoinProject {}
794
795 let font_cache = cx.font_cache();
796 let host_avatar_height = theme
797 .contact_avatar
798 .width
799 .or(theme.contact_avatar.height)
800 .unwrap_or(0.);
801 let row = &theme.project_row.default;
802 let tree_branch = theme.tree_branch;
803 let line_height = row.name.text.line_height(font_cache);
804 let cap_height = row.name.text.cap_height(font_cache);
805 let baseline_offset =
806 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
807 let project_name = if worktree_root_names.is_empty() {
808 "untitled".to_string()
809 } else {
810 worktree_root_names.join(", ")
811 };
812
813 MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
814 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
815 let row = theme.project_row.style_for(mouse_state, is_selected);
816
817 Flex::row()
818 .with_child(
819 Stack::new()
820 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
821 let start_x =
822 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
823 let end_x = bounds.max_x();
824 let start_y = bounds.min_y();
825 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
826
827 scene.push_quad(gpui::Quad {
828 bounds: RectF::from_points(
829 vec2f(start_x, start_y),
830 vec2f(
831 start_x + tree_branch.width,
832 if is_last { end_y } else { bounds.max_y() },
833 ),
834 ),
835 background: Some(tree_branch.color),
836 border: gpui::Border::default(),
837 corner_radius: 0.,
838 });
839 scene.push_quad(gpui::Quad {
840 bounds: RectF::from_points(
841 vec2f(start_x, end_y),
842 vec2f(end_x, end_y + tree_branch.width),
843 ),
844 background: Some(tree_branch.color),
845 border: gpui::Border::default(),
846 corner_radius: 0.,
847 });
848 }))
849 .constrained()
850 .with_width(host_avatar_height),
851 )
852 .with_child(
853 Label::new(project_name, row.name.text.clone())
854 .aligned()
855 .left()
856 .contained()
857 .with_style(row.name.container)
858 .flex(1., false),
859 )
860 .constrained()
861 .with_height(theme.row_height)
862 .contained()
863 .with_style(row.container)
864 })
865 .with_cursor_style(if !is_current {
866 CursorStyle::PointingHand
867 } else {
868 CursorStyle::Arrow
869 })
870 .on_click(MouseButton::Left, move |_, this, cx| {
871 if !is_current {
872 if let Some(workspace) = this.workspace.upgrade(cx) {
873 let app_state = workspace.read(cx).app_state().clone();
874 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
875 .detach_and_log_err(cx);
876 }
877 }
878 })
879 .into_any()
880 }
881
882 fn render_participant_screen(
883 peer_id: PeerId,
884 is_last: bool,
885 is_selected: bool,
886 theme: &theme::ContactList,
887 cx: &mut ViewContext<Self>,
888 ) -> AnyElement<Self> {
889 enum OpenSharedScreen {}
890
891 let font_cache = cx.font_cache();
892 let host_avatar_height = theme
893 .contact_avatar
894 .width
895 .or(theme.contact_avatar.height)
896 .unwrap_or(0.);
897 let row = &theme.project_row.default;
898 let tree_branch = theme.tree_branch;
899 let line_height = row.name.text.line_height(font_cache);
900 let cap_height = row.name.text.cap_height(font_cache);
901 let baseline_offset =
902 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
903
904 MouseEventHandler::<OpenSharedScreen, Self>::new(
905 peer_id.as_u64() as usize,
906 cx,
907 |mouse_state, _| {
908 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
909 let row = theme.project_row.style_for(mouse_state, is_selected);
910
911 Flex::row()
912 .with_child(
913 Stack::new()
914 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
915 let start_x = bounds.min_x() + (bounds.width() / 2.)
916 - (tree_branch.width / 2.);
917 let end_x = bounds.max_x();
918 let start_y = bounds.min_y();
919 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
920
921 scene.push_quad(gpui::Quad {
922 bounds: RectF::from_points(
923 vec2f(start_x, start_y),
924 vec2f(
925 start_x + tree_branch.width,
926 if is_last { end_y } else { bounds.max_y() },
927 ),
928 ),
929 background: Some(tree_branch.color),
930 border: gpui::Border::default(),
931 corner_radius: 0.,
932 });
933 scene.push_quad(gpui::Quad {
934 bounds: RectF::from_points(
935 vec2f(start_x, end_y),
936 vec2f(end_x, end_y + tree_branch.width),
937 ),
938 background: Some(tree_branch.color),
939 border: gpui::Border::default(),
940 corner_radius: 0.,
941 });
942 }))
943 .constrained()
944 .with_width(host_avatar_height),
945 )
946 .with_child(
947 Svg::new("icons/disable_screen_sharing_12.svg")
948 .with_color(row.icon.color)
949 .constrained()
950 .with_width(row.icon.width)
951 .aligned()
952 .left()
953 .contained()
954 .with_style(row.icon.container),
955 )
956 .with_child(
957 Label::new("Screen", row.name.text.clone())
958 .aligned()
959 .left()
960 .contained()
961 .with_style(row.name.container)
962 .flex(1., false),
963 )
964 .constrained()
965 .with_height(theme.row_height)
966 .contained()
967 .with_style(row.container)
968 },
969 )
970 .with_cursor_style(CursorStyle::PointingHand)
971 .on_click(MouseButton::Left, move |_, this, cx| {
972 if let Some(workspace) = this.workspace.upgrade(cx) {
973 workspace.update(cx, |workspace, cx| {
974 workspace.open_shared_screen(peer_id, cx)
975 });
976 }
977 })
978 .into_any()
979 }
980
981 fn render_header(
982 section: Section,
983 theme: &theme::ContactList,
984 is_selected: bool,
985 is_collapsed: bool,
986 cx: &mut ViewContext<Self>,
987 ) -> AnyElement<Self> {
988 enum Header {}
989 enum LeaveCallContactList {}
990
991 let header_style = theme
992 .header_row
993 .style_for(&mut Default::default(), is_selected);
994 let text = match section {
995 Section::ActiveCall => "Collaborators",
996 Section::Requests => "Contact Requests",
997 Section::Online => "Online",
998 Section::Offline => "Offline",
999 };
1000 let leave_call = if section == Section::ActiveCall {
1001 Some(
1002 MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |state, _| {
1003 let style = theme.leave_call.style_for(state, false);
1004 Label::new("Leave Call", style.text.clone())
1005 .contained()
1006 .with_style(style.container)
1007 })
1008 .on_click(MouseButton::Left, |_, _, cx| {
1009 ActiveCall::global(cx)
1010 .update(cx, |call, cx| call.hang_up(cx))
1011 .detach_and_log_err(cx);
1012 })
1013 .aligned(),
1014 )
1015 } else {
1016 None
1017 };
1018
1019 let icon_size = theme.section_icon_size;
1020 MouseEventHandler::<Header, Self>::new(section as usize, cx, |_, _| {
1021 Flex::row()
1022 .with_child(
1023 Svg::new(if is_collapsed {
1024 "icons/chevron_right_8.svg"
1025 } else {
1026 "icons/chevron_down_8.svg"
1027 })
1028 .with_color(header_style.text.color)
1029 .constrained()
1030 .with_max_width(icon_size)
1031 .with_max_height(icon_size)
1032 .aligned()
1033 .constrained()
1034 .with_width(icon_size),
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 )
1044 .with_children(leave_call)
1045 .constrained()
1046 .with_height(theme.row_height)
1047 .contained()
1048 .with_style(header_style.container)
1049 })
1050 .with_cursor_style(CursorStyle::PointingHand)
1051 .on_click(MouseButton::Left, move |_, this, cx| {
1052 this.toggle_expanded(section, cx);
1053 })
1054 .into_any()
1055 }
1056
1057 fn render_contact(
1058 contact: &Contact,
1059 calling: bool,
1060 project: &ModelHandle<Project>,
1061 theme: &theme::ContactList,
1062 is_selected: bool,
1063 cx: &mut ViewContext<Self>,
1064 ) -> AnyElement<Self> {
1065 let online = contact.online;
1066 let busy = contact.busy || calling;
1067 let user_id = contact.user.id;
1068 let github_login = contact.user.github_login.clone();
1069 let initial_project = project.clone();
1070 let mut event_handler =
1071 MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |_, cx| {
1072 Flex::row()
1073 .with_children(contact.user.avatar.clone().map(|avatar| {
1074 let status_badge = if contact.online {
1075 Some(
1076 Empty::new()
1077 .collapsed()
1078 .contained()
1079 .with_style(if busy {
1080 theme.contact_status_busy
1081 } else {
1082 theme.contact_status_free
1083 })
1084 .aligned(),
1085 )
1086 } else {
1087 None
1088 };
1089 Stack::new()
1090 .with_child(
1091 Image::from_data(avatar)
1092 .with_style(theme.contact_avatar)
1093 .aligned()
1094 .left(),
1095 )
1096 .with_children(status_badge)
1097 }))
1098 .with_child(
1099 Label::new(
1100 contact.user.github_login.clone(),
1101 theme.contact_username.text.clone(),
1102 )
1103 .contained()
1104 .with_style(theme.contact_username.container)
1105 .aligned()
1106 .left()
1107 .flex(1., true),
1108 )
1109 .with_child(
1110 MouseEventHandler::<Cancel, Self>::new(
1111 contact.user.id as usize,
1112 cx,
1113 |mouse_state, _| {
1114 let button_style =
1115 theme.contact_button.style_for(mouse_state, false);
1116 render_icon_button(button_style, "icons/x_mark_8.svg")
1117 .aligned()
1118 .flex_float()
1119 },
1120 )
1121 .with_padding(Padding::uniform(2.))
1122 .with_cursor_style(CursorStyle::PointingHand)
1123 .on_click(MouseButton::Left, move |_, this, cx| {
1124 this.remove_contact(
1125 &RemoveContact {
1126 user_id,
1127 github_login: github_login.clone(),
1128 },
1129 cx,
1130 );
1131 })
1132 .flex_float(),
1133 )
1134 .with_children(if calling {
1135 Some(
1136 Label::new("Calling", theme.calling_indicator.text.clone())
1137 .contained()
1138 .with_style(theme.calling_indicator.container)
1139 .aligned(),
1140 )
1141 } else {
1142 None
1143 })
1144 .constrained()
1145 .with_height(theme.row_height)
1146 .contained()
1147 .with_style(
1148 *theme
1149 .contact_row
1150 .style_for(&mut Default::default(), is_selected),
1151 )
1152 })
1153 .on_click(MouseButton::Left, move |_, this, cx| {
1154 if online && !busy {
1155 this.call(user_id, Some(initial_project.clone()), cx);
1156 }
1157 });
1158
1159 if online {
1160 event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1161 }
1162
1163 event_handler.into_any()
1164 }
1165
1166 fn render_contact_request(
1167 user: Arc<User>,
1168 user_store: ModelHandle<UserStore>,
1169 theme: &theme::ContactList,
1170 is_incoming: bool,
1171 is_selected: bool,
1172 cx: &mut ViewContext<Self>,
1173 ) -> AnyElement<Self> {
1174 enum Decline {}
1175 enum Accept {}
1176 enum Cancel {}
1177
1178 let mut row = Flex::row()
1179 .with_children(user.avatar.clone().map(|avatar| {
1180 Image::from_data(avatar)
1181 .with_style(theme.contact_avatar)
1182 .aligned()
1183 .left()
1184 }))
1185 .with_child(
1186 Label::new(
1187 user.github_login.clone(),
1188 theme.contact_username.text.clone(),
1189 )
1190 .contained()
1191 .with_style(theme.contact_username.container)
1192 .aligned()
1193 .left()
1194 .flex(1., true),
1195 );
1196
1197 let user_id = user.id;
1198 let github_login = user.github_login.clone();
1199 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1200 let button_spacing = theme.contact_button_spacing;
1201
1202 if is_incoming {
1203 row.add_child(
1204 MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
1205 let button_style = if is_contact_request_pending {
1206 &theme.disabled_button
1207 } else {
1208 theme.contact_button.style_for(mouse_state, false)
1209 };
1210 render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1211 })
1212 .with_cursor_style(CursorStyle::PointingHand)
1213 .on_click(MouseButton::Left, move |_, this, cx| {
1214 this.respond_to_contact_request(
1215 &RespondToContactRequest {
1216 user_id,
1217 accept: false,
1218 },
1219 cx,
1220 );
1221 })
1222 .contained()
1223 .with_margin_right(button_spacing),
1224 );
1225
1226 row.add_child(
1227 MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
1228 let button_style = if is_contact_request_pending {
1229 &theme.disabled_button
1230 } else {
1231 theme.contact_button.style_for(mouse_state, false)
1232 };
1233 render_icon_button(button_style, "icons/check_8.svg")
1234 .aligned()
1235 .flex_float()
1236 })
1237 .with_cursor_style(CursorStyle::PointingHand)
1238 .on_click(MouseButton::Left, move |_, this, cx| {
1239 this.respond_to_contact_request(
1240 &RespondToContactRequest {
1241 user_id,
1242 accept: true,
1243 },
1244 cx,
1245 );
1246 }),
1247 );
1248 } else {
1249 row.add_child(
1250 MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
1251 let button_style = if is_contact_request_pending {
1252 &theme.disabled_button
1253 } else {
1254 theme.contact_button.style_for(mouse_state, false)
1255 };
1256 render_icon_button(button_style, "icons/x_mark_8.svg")
1257 .aligned()
1258 .flex_float()
1259 })
1260 .with_padding(Padding::uniform(2.))
1261 .with_cursor_style(CursorStyle::PointingHand)
1262 .on_click(MouseButton::Left, move |_, this, cx| {
1263 this.remove_contact(
1264 &RemoveContact {
1265 user_id,
1266 github_login: github_login.clone(),
1267 },
1268 cx,
1269 );
1270 })
1271 .flex_float(),
1272 );
1273 }
1274
1275 row.constrained()
1276 .with_height(theme.row_height)
1277 .contained()
1278 .with_style(
1279 *theme
1280 .contact_row
1281 .style_for(&mut Default::default(), is_selected),
1282 )
1283 .into_any()
1284 }
1285
1286 fn call(
1287 &mut self,
1288 recipient_user_id: u64,
1289 initial_project: Option<ModelHandle<Project>>,
1290 cx: &mut ViewContext<Self>,
1291 ) {
1292 ActiveCall::global(cx)
1293 .update(cx, |call, cx| {
1294 call.invite(recipient_user_id, initial_project, cx)
1295 })
1296 .detach_and_log_err(cx);
1297 }
1298}
1299
1300impl Entity for ContactList {
1301 type Event = Event;
1302}
1303
1304impl View for ContactList {
1305 fn ui_name() -> &'static str {
1306 "ContactList"
1307 }
1308
1309 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1310 Self::reset_to_default_keymap_context(keymap);
1311 keymap.add_identifier("menu");
1312 }
1313
1314 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1315 enum AddContact {}
1316 let theme = cx.global::<Settings>().theme.clone();
1317
1318 Flex::column()
1319 .with_child(
1320 Flex::row()
1321 .with_child(
1322 ChildView::new(&self.filter_editor, cx)
1323 .contained()
1324 .with_style(theme.contact_list.user_query_editor.container)
1325 .flex(1., true),
1326 )
1327 .with_child(
1328 MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
1329 render_icon_button(
1330 &theme.contact_list.add_contact_button,
1331 "icons/user_plus_16.svg",
1332 )
1333 })
1334 .with_cursor_style(CursorStyle::PointingHand)
1335 .on_click(MouseButton::Left, |_, _, cx| {
1336 cx.emit(Event::ToggleContactFinder)
1337 })
1338 .with_tooltip::<AddContact>(
1339 0,
1340 "Search for new contact".into(),
1341 None,
1342 theme.tooltip.clone(),
1343 cx,
1344 ),
1345 )
1346 .constrained()
1347 .with_height(theme.contact_list.user_query_editor_height),
1348 )
1349 .with_child(List::new(self.list_state.clone()).flex(1., false))
1350 .into_any()
1351 }
1352
1353 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1354 if !self.filter_editor.is_focused(cx) {
1355 cx.focus(&self.filter_editor);
1356 }
1357 }
1358
1359 fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1360 if !self.filter_editor.is_focused(cx) {
1361 cx.emit(Event::Dismissed);
1362 }
1363 }
1364}
1365
1366fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<ContactList> {
1367 Svg::new(svg_path)
1368 .with_color(style.color)
1369 .constrained()
1370 .with_width(style.icon_width)
1371 .aligned()
1372 .contained()
1373 .with_style(style.container)
1374 .constrained()
1375 .with_width(style.button_width)
1376 .with_height(style.button_width)
1377}