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, RenderContext, Subscription, View, ViewContext, ViewHandle,
15};
16use menu::{Confirm, SelectNext, SelectPrev};
17use project::Project;
18use serde::Deserialize;
19use settings::Settings;
20use std::{mem, sync::Arc};
21use theme::IconButton;
22use workspace::{JoinProject, OpenSharedScreen};
23
24impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
25impl_internal_actions!(contact_list, [ToggleExpanded, Call]);
26
27pub fn init(cx: &mut 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,
163 project: ModelHandle<Project>,
164 user_store: ModelHandle<UserStore>,
165 filter_editor: ViewHandle<Editor>,
166 collapsed_sections: Vec<Section>,
167 selection: Option<usize>,
168 _subscriptions: Vec<Subscription>,
169}
170
171impl ContactList {
172 pub fn new(
173 project: ModelHandle<Project>,
174 user_store: ModelHandle<UserStore>,
175 cx: &mut ViewContext<Self>,
176 ) -> Self {
177 let filter_editor = cx.add_view(|cx| {
178 let mut editor = Editor::single_line(
179 Some(Arc::new(|theme| {
180 theme.contact_list.user_query_editor.clone()
181 })),
182 cx,
183 );
184 editor.set_placeholder_text("Filter contacts", cx);
185 editor
186 });
187
188 cx.subscribe(&filter_editor, |this, _, event, cx| {
189 if let editor::Event::BufferEdited = event {
190 let query = this.filter_editor.read(cx).text(cx);
191 if !query.is_empty() {
192 this.selection.take();
193 }
194 this.update_entries(cx);
195 if !query.is_empty() {
196 this.selection = this
197 .entries
198 .iter()
199 .position(|entry| !matches!(entry, ContactEntry::Header(_)));
200 }
201 }
202 })
203 .detach();
204
205 let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
206 let theme = cx.global::<Settings>().theme.clone();
207 let is_selected = this.selection == Some(ix);
208 let current_project_id = this.project.read(cx).remote_id();
209
210 match &this.entries[ix] {
211 ContactEntry::Header(section) => {
212 let is_collapsed = this.collapsed_sections.contains(section);
213 Self::render_header(
214 *section,
215 &theme.contact_list,
216 is_selected,
217 is_collapsed,
218 cx,
219 )
220 }
221 ContactEntry::CallParticipant { user, is_pending } => {
222 Self::render_call_participant(
223 user,
224 *is_pending,
225 is_selected,
226 &theme.contact_list,
227 )
228 }
229 ContactEntry::ParticipantProject {
230 project_id,
231 worktree_root_names,
232 host_user_id,
233 is_last,
234 } => Self::render_participant_project(
235 *project_id,
236 worktree_root_names,
237 *host_user_id,
238 Some(*project_id) == current_project_id,
239 *is_last,
240 is_selected,
241 &theme.contact_list,
242 cx,
243 ),
244 ContactEntry::ParticipantScreen { peer_id, is_last } => {
245 Self::render_participant_screen(
246 *peer_id,
247 *is_last,
248 is_selected,
249 &theme.contact_list,
250 cx,
251 )
252 }
253 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
254 user.clone(),
255 this.user_store.clone(),
256 &theme.contact_list,
257 true,
258 is_selected,
259 cx,
260 ),
261 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
262 user.clone(),
263 this.user_store.clone(),
264 &theme.contact_list,
265 false,
266 is_selected,
267 cx,
268 ),
269 ContactEntry::Contact { contact, calling } => Self::render_contact(
270 contact,
271 *calling,
272 &this.project,
273 &theme.contact_list,
274 is_selected,
275 cx,
276 ),
277 }
278 });
279
280 let active_call = ActiveCall::global(cx);
281 let mut subscriptions = Vec::new();
282 subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
283 subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
284
285 let mut this = Self {
286 list_state,
287 selection: None,
288 collapsed_sections: Default::default(),
289 entries: Default::default(),
290 match_candidates: Default::default(),
291 filter_editor,
292 _subscriptions: subscriptions,
293 project,
294 user_store,
295 };
296 this.update_entries(cx);
297 this
298 }
299
300 pub fn editor_text(&self, cx: &AppContext) -> String {
301 self.filter_editor.read(cx).text(cx)
302 }
303
304 pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext<Self>) -> Self {
305 self.filter_editor
306 .update(cx, |picker, cx| picker.set_text(editor_text, cx));
307 self
308 }
309
310 fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
311 let user_id = request.user_id;
312 let github_login = &request.github_login;
313 let user_store = self.user_store.clone();
314 let prompt_message = format!(
315 "Are you sure you want to remove \"{}\" from your contacts?",
316 github_login
317 );
318 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
319 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 ) -> ElementBox {
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 .boxed()
759 }))
760 .with_child(
761 Label::new(
762 user.github_login.clone(),
763 theme.contact_username.text.clone(),
764 )
765 .contained()
766 .with_style(theme.contact_username.container)
767 .aligned()
768 .left()
769 .flex(1., true)
770 .boxed(),
771 )
772 .with_children(if is_pending {
773 Some(
774 Label::new("Calling", theme.calling_indicator.text.clone())
775 .contained()
776 .with_style(theme.calling_indicator.container)
777 .aligned()
778 .boxed(),
779 )
780 } else {
781 None
782 })
783 .constrained()
784 .with_height(theme.row_height)
785 .contained()
786 .with_style(
787 *theme
788 .contact_row
789 .style_for(&mut Default::default(), is_selected),
790 )
791 .boxed()
792 }
793
794 fn render_participant_project(
795 project_id: u64,
796 worktree_root_names: &[String],
797 host_user_id: u64,
798 is_current: bool,
799 is_last: bool,
800 is_selected: bool,
801 theme: &theme::ContactList,
802 cx: &mut RenderContext<Self>,
803 ) -> ElementBox {
804 let font_cache = cx.font_cache();
805 let host_avatar_height = theme
806 .contact_avatar
807 .width
808 .or(theme.contact_avatar.height)
809 .unwrap_or(0.);
810 let row = &theme.project_row.default;
811 let tree_branch = theme.tree_branch;
812 let line_height = row.name.text.line_height(font_cache);
813 let cap_height = row.name.text.cap_height(font_cache);
814 let baseline_offset =
815 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
816 let project_name = if worktree_root_names.is_empty() {
817 "untitled".to_string()
818 } else {
819 worktree_root_names.join(", ")
820 };
821
822 MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, _| {
823 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
824 let row = theme.project_row.style_for(mouse_state, is_selected);
825
826 Flex::row()
827 .with_child(
828 Stack::new()
829 .with_child(
830 Canvas::new(move |bounds, _, cx| {
831 let start_x = bounds.min_x() + (bounds.width() / 2.)
832 - (tree_branch.width / 2.);
833 let end_x = bounds.max_x();
834 let start_y = bounds.min_y();
835 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
836
837 cx.scene.push_quad(gpui::Quad {
838 bounds: RectF::from_points(
839 vec2f(start_x, start_y),
840 vec2f(
841 start_x + tree_branch.width,
842 if is_last { end_y } else { bounds.max_y() },
843 ),
844 ),
845 background: Some(tree_branch.color),
846 border: gpui::Border::default(),
847 corner_radius: 0.,
848 });
849 cx.scene.push_quad(gpui::Quad {
850 bounds: RectF::from_points(
851 vec2f(start_x, end_y),
852 vec2f(end_x, end_y + tree_branch.width),
853 ),
854 background: Some(tree_branch.color),
855 border: gpui::Border::default(),
856 corner_radius: 0.,
857 });
858 })
859 .boxed(),
860 )
861 .constrained()
862 .with_width(host_avatar_height)
863 .boxed(),
864 )
865 .with_child(
866 Label::new(project_name, row.name.text.clone())
867 .aligned()
868 .left()
869 .contained()
870 .with_style(row.name.container)
871 .flex(1., false)
872 .boxed(),
873 )
874 .constrained()
875 .with_height(theme.row_height)
876 .contained()
877 .with_style(row.container)
878 .boxed()
879 })
880 .with_cursor_style(if !is_current {
881 CursorStyle::PointingHand
882 } else {
883 CursorStyle::Arrow
884 })
885 .on_click(MouseButton::Left, move |_, cx| {
886 if !is_current {
887 cx.dispatch_global_action(JoinProject {
888 project_id,
889 follow_user_id: host_user_id,
890 });
891 }
892 })
893 .boxed()
894 }
895
896 fn render_participant_screen(
897 peer_id: PeerId,
898 is_last: bool,
899 is_selected: bool,
900 theme: &theme::ContactList,
901 cx: &mut RenderContext<Self>,
902 ) -> ElementBox {
903 let font_cache = cx.font_cache();
904 let host_avatar_height = theme
905 .contact_avatar
906 .width
907 .or(theme.contact_avatar.height)
908 .unwrap_or(0.);
909 let row = &theme.project_row.default;
910 let tree_branch = theme.tree_branch;
911 let line_height = row.name.text.line_height(font_cache);
912 let cap_height = row.name.text.cap_height(font_cache);
913 let baseline_offset =
914 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
915
916 MouseEventHandler::<OpenSharedScreen>::new(
917 peer_id.as_u64() as usize,
918 cx,
919 |mouse_state, _| {
920 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
921 let row = theme.project_row.style_for(mouse_state, is_selected);
922
923 Flex::row()
924 .with_child(
925 Stack::new()
926 .with_child(
927 Canvas::new(move |bounds, _, cx| {
928 let start_x = bounds.min_x() + (bounds.width() / 2.)
929 - (tree_branch.width / 2.);
930 let end_x = bounds.max_x();
931 let start_y = bounds.min_y();
932 let end_y =
933 bounds.min_y() + baseline_offset - (cap_height / 2.);
934
935 cx.scene.push_quad(gpui::Quad {
936 bounds: RectF::from_points(
937 vec2f(start_x, start_y),
938 vec2f(
939 start_x + tree_branch.width,
940 if is_last { end_y } else { bounds.max_y() },
941 ),
942 ),
943 background: Some(tree_branch.color),
944 border: gpui::Border::default(),
945 corner_radius: 0.,
946 });
947 cx.scene.push_quad(gpui::Quad {
948 bounds: RectF::from_points(
949 vec2f(start_x, end_y),
950 vec2f(end_x, end_y + tree_branch.width),
951 ),
952 background: Some(tree_branch.color),
953 border: gpui::Border::default(),
954 corner_radius: 0.,
955 });
956 })
957 .boxed(),
958 )
959 .constrained()
960 .with_width(host_avatar_height)
961 .boxed(),
962 )
963 .with_child(
964 Svg::new("icons/disable_screen_sharing_12.svg")
965 .with_color(row.icon.color)
966 .constrained()
967 .with_width(row.icon.width)
968 .aligned()
969 .left()
970 .contained()
971 .with_style(row.icon.container)
972 .boxed(),
973 )
974 .with_child(
975 Label::new("Screen", row.name.text.clone())
976 .aligned()
977 .left()
978 .contained()
979 .with_style(row.name.container)
980 .flex(1., false)
981 .boxed(),
982 )
983 .constrained()
984 .with_height(theme.row_height)
985 .contained()
986 .with_style(row.container)
987 .boxed()
988 },
989 )
990 .with_cursor_style(CursorStyle::PointingHand)
991 .on_click(MouseButton::Left, move |_, cx| {
992 cx.dispatch_action(OpenSharedScreen { peer_id });
993 })
994 .boxed()
995 }
996
997 fn render_header(
998 section: Section,
999 theme: &theme::ContactList,
1000 is_selected: bool,
1001 is_collapsed: bool,
1002 cx: &mut RenderContext<Self>,
1003 ) -> ElementBox {
1004 enum Header {}
1005 enum LeaveCallContactList {}
1006
1007 let header_style = theme
1008 .header_row
1009 .style_for(&mut Default::default(), is_selected);
1010 let text = match section {
1011 Section::ActiveCall => "Collaborators",
1012 Section::Requests => "Contact Requests",
1013 Section::Online => "Online",
1014 Section::Offline => "Offline",
1015 };
1016 let leave_call = if section == Section::ActiveCall {
1017 Some(
1018 MouseEventHandler::<LeaveCallContactList>::new(0, cx, |state, _| {
1019 let style = theme.leave_call.style_for(state, false);
1020 Label::new("Leave Call", style.text.clone())
1021 .contained()
1022 .with_style(style.container)
1023 .boxed()
1024 })
1025 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
1026 .aligned()
1027 .boxed(),
1028 )
1029 } else {
1030 None
1031 };
1032
1033 let icon_size = theme.section_icon_size;
1034 MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
1035 Flex::row()
1036 .with_child(
1037 Svg::new(if is_collapsed {
1038 "icons/chevron_right_8.svg"
1039 } else {
1040 "icons/chevron_down_8.svg"
1041 })
1042 .with_color(header_style.text.color)
1043 .constrained()
1044 .with_max_width(icon_size)
1045 .with_max_height(icon_size)
1046 .aligned()
1047 .constrained()
1048 .with_width(icon_size)
1049 .boxed(),
1050 )
1051 .with_child(
1052 Label::new(text, header_style.text.clone())
1053 .aligned()
1054 .left()
1055 .contained()
1056 .with_margin_left(theme.contact_username.container.margin.left)
1057 .flex(1., true)
1058 .boxed(),
1059 )
1060 .with_children(leave_call)
1061 .constrained()
1062 .with_height(theme.row_height)
1063 .contained()
1064 .with_style(header_style.container)
1065 .boxed()
1066 })
1067 .with_cursor_style(CursorStyle::PointingHand)
1068 .on_click(MouseButton::Left, move |_, cx| {
1069 cx.dispatch_action(ToggleExpanded(section))
1070 })
1071 .boxed()
1072 }
1073
1074 fn render_contact(
1075 contact: &Contact,
1076 calling: bool,
1077 project: &ModelHandle<Project>,
1078 theme: &theme::ContactList,
1079 is_selected: bool,
1080 cx: &mut RenderContext<Self>,
1081 ) -> ElementBox {
1082 let online = contact.online;
1083 let busy = contact.busy || calling;
1084 let user_id = contact.user.id;
1085 let github_login = contact.user.github_login.clone();
1086 let initial_project = project.clone();
1087 let mut element =
1088 MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, cx| {
1089 Flex::row()
1090 .with_children(contact.user.avatar.clone().map(|avatar| {
1091 let status_badge = if contact.online {
1092 Some(
1093 Empty::new()
1094 .collapsed()
1095 .contained()
1096 .with_style(if busy {
1097 theme.contact_status_busy
1098 } else {
1099 theme.contact_status_free
1100 })
1101 .aligned()
1102 .boxed(),
1103 )
1104 } else {
1105 None
1106 };
1107 Stack::new()
1108 .with_child(
1109 Image::from_data(avatar)
1110 .with_style(theme.contact_avatar)
1111 .aligned()
1112 .left()
1113 .boxed(),
1114 )
1115 .with_children(status_badge)
1116 .boxed()
1117 }))
1118 .with_child(
1119 Label::new(
1120 contact.user.github_login.clone(),
1121 theme.contact_username.text.clone(),
1122 )
1123 .contained()
1124 .with_style(theme.contact_username.container)
1125 .aligned()
1126 .left()
1127 .flex(1., true)
1128 .boxed(),
1129 )
1130 .with_child(
1131 MouseEventHandler::<Cancel>::new(
1132 contact.user.id as usize,
1133 cx,
1134 |mouse_state, _| {
1135 let button_style =
1136 theme.contact_button.style_for(mouse_state, false);
1137 render_icon_button(button_style, "icons/x_mark_8.svg")
1138 .aligned()
1139 .flex_float()
1140 .boxed()
1141 },
1142 )
1143 .with_padding(Padding::uniform(2.))
1144 .with_cursor_style(CursorStyle::PointingHand)
1145 .on_click(MouseButton::Left, move |_, cx| {
1146 cx.dispatch_action(RemoveContact {
1147 user_id,
1148 github_login: github_login.clone(),
1149 })
1150 })
1151 .flex_float()
1152 .boxed(),
1153 )
1154 .with_children(if calling {
1155 Some(
1156 Label::new("Calling", theme.calling_indicator.text.clone())
1157 .contained()
1158 .with_style(theme.calling_indicator.container)
1159 .aligned()
1160 .boxed(),
1161 )
1162 } else {
1163 None
1164 })
1165 .constrained()
1166 .with_height(theme.row_height)
1167 .contained()
1168 .with_style(
1169 *theme
1170 .contact_row
1171 .style_for(&mut Default::default(), is_selected),
1172 )
1173 .boxed()
1174 })
1175 .on_click(MouseButton::Left, move |_, cx| {
1176 if online && !busy {
1177 cx.dispatch_action(Call {
1178 recipient_user_id: user_id,
1179 initial_project: Some(initial_project.clone()),
1180 });
1181 }
1182 });
1183
1184 if online {
1185 element = element.with_cursor_style(CursorStyle::PointingHand);
1186 }
1187
1188 element.boxed()
1189 }
1190
1191 fn render_contact_request(
1192 user: Arc<User>,
1193 user_store: ModelHandle<UserStore>,
1194 theme: &theme::ContactList,
1195 is_incoming: bool,
1196 is_selected: bool,
1197 cx: &mut RenderContext<Self>,
1198 ) -> ElementBox {
1199 enum Decline {}
1200 enum Accept {}
1201 enum Cancel {}
1202
1203 let mut row = Flex::row()
1204 .with_children(user.avatar.clone().map(|avatar| {
1205 Image::from_data(avatar)
1206 .with_style(theme.contact_avatar)
1207 .aligned()
1208 .left()
1209 .boxed()
1210 }))
1211 .with_child(
1212 Label::new(
1213 user.github_login.clone(),
1214 theme.contact_username.text.clone(),
1215 )
1216 .contained()
1217 .with_style(theme.contact_username.container)
1218 .aligned()
1219 .left()
1220 .flex(1., true)
1221 .boxed(),
1222 );
1223
1224 let user_id = user.id;
1225 let github_login = user.github_login.clone();
1226 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1227 let button_spacing = theme.contact_button_spacing;
1228
1229 if is_incoming {
1230 row.add_children([
1231 MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
1232 let button_style = if is_contact_request_pending {
1233 &theme.disabled_button
1234 } else {
1235 theme.contact_button.style_for(mouse_state, false)
1236 };
1237 render_icon_button(button_style, "icons/x_mark_8.svg")
1238 .aligned()
1239 .boxed()
1240 })
1241 .with_cursor_style(CursorStyle::PointingHand)
1242 .on_click(MouseButton::Left, move |_, cx| {
1243 cx.dispatch_action(RespondToContactRequest {
1244 user_id,
1245 accept: false,
1246 })
1247 })
1248 .contained()
1249 .with_margin_right(button_spacing)
1250 .boxed(),
1251 MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
1252 let button_style = if is_contact_request_pending {
1253 &theme.disabled_button
1254 } else {
1255 theme.contact_button.style_for(mouse_state, false)
1256 };
1257 render_icon_button(button_style, "icons/check_8.svg")
1258 .aligned()
1259 .flex_float()
1260 .boxed()
1261 })
1262 .with_cursor_style(CursorStyle::PointingHand)
1263 .on_click(MouseButton::Left, move |_, cx| {
1264 cx.dispatch_action(RespondToContactRequest {
1265 user_id,
1266 accept: true,
1267 })
1268 })
1269 .boxed(),
1270 ]);
1271 } else {
1272 row.add_child(
1273 MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
1274 let button_style = if is_contact_request_pending {
1275 &theme.disabled_button
1276 } else {
1277 theme.contact_button.style_for(mouse_state, false)
1278 };
1279 render_icon_button(button_style, "icons/x_mark_8.svg")
1280 .aligned()
1281 .flex_float()
1282 .boxed()
1283 })
1284 .with_padding(Padding::uniform(2.))
1285 .with_cursor_style(CursorStyle::PointingHand)
1286 .on_click(MouseButton::Left, move |_, cx| {
1287 cx.dispatch_action(RemoveContact {
1288 user_id,
1289 github_login: github_login.clone(),
1290 })
1291 })
1292 .flex_float()
1293 .boxed(),
1294 );
1295 }
1296
1297 row.constrained()
1298 .with_height(theme.row_height)
1299 .contained()
1300 .with_style(
1301 *theme
1302 .contact_row
1303 .style_for(&mut Default::default(), is_selected),
1304 )
1305 .boxed()
1306 }
1307
1308 fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
1309 let recipient_user_id = action.recipient_user_id;
1310 let initial_project = action.initial_project.clone();
1311 ActiveCall::global(cx)
1312 .update(cx, |call, cx| {
1313 call.invite(recipient_user_id, initial_project, cx)
1314 })
1315 .detach_and_log_err(cx);
1316 }
1317}
1318
1319impl Entity for ContactList {
1320 type Event = Event;
1321}
1322
1323impl View for ContactList {
1324 fn ui_name() -> &'static str {
1325 "ContactList"
1326 }
1327
1328 fn keymap_context(&self, _: &AppContext) -> KeymapContext {
1329 let mut cx = Self::default_keymap_context();
1330 cx.add_identifier("menu");
1331 cx
1332 }
1333
1334 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
1335 enum AddContact {}
1336 let theme = cx.global::<Settings>().theme.clone();
1337
1338 Flex::column()
1339 .with_child(
1340 Flex::row()
1341 .with_child(
1342 ChildView::new(&self.filter_editor, cx)
1343 .contained()
1344 .with_style(theme.contact_list.user_query_editor.container)
1345 .flex(1., true)
1346 .boxed(),
1347 )
1348 .with_child(
1349 MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
1350 render_icon_button(
1351 &theme.contact_list.add_contact_button,
1352 "icons/user_plus_16.svg",
1353 )
1354 .boxed()
1355 })
1356 .with_cursor_style(CursorStyle::PointingHand)
1357 .on_click(MouseButton::Left, |_, cx| {
1358 cx.dispatch_action(contacts_popover::ToggleContactFinder)
1359 })
1360 .with_tooltip::<AddContact, _>(
1361 0,
1362 "Search for new contact".into(),
1363 None,
1364 theme.tooltip.clone(),
1365 cx,
1366 )
1367 .boxed(),
1368 )
1369 .constrained()
1370 .with_height(theme.contact_list.user_query_editor_height)
1371 .boxed(),
1372 )
1373 .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
1374 .boxed()
1375 }
1376
1377 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1378 if !self.filter_editor.is_focused(cx) {
1379 cx.focus(&self.filter_editor);
1380 }
1381 }
1382
1383 fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1384 if !self.filter_editor.is_focused(cx) {
1385 cx.emit(Event::Dismissed);
1386 }
1387 }
1388}
1389
1390fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
1391 Svg::new(svg_path)
1392 .with_color(style.color)
1393 .constrained()
1394 .with_width(style.icon_width)
1395 .aligned()
1396 .contained()
1397 .with_style(style.container)
1398 .constrained()
1399 .with_width(style.button_width)
1400 .with_height(style.button_width)
1401}