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