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