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 .style_for(&mut Default::default(), is_selected),
778 )
779 .into_any()
780 }
781
782 fn render_participant_project(
783 project_id: u64,
784 worktree_root_names: &[String],
785 host_user_id: u64,
786 is_current: bool,
787 is_last: bool,
788 is_selected: bool,
789 theme: &theme::ContactList,
790 cx: &mut ViewContext<Self>,
791 ) -> AnyElement<Self> {
792 enum JoinProject {}
793
794 let font_cache = cx.font_cache();
795 let host_avatar_height = theme
796 .contact_avatar
797 .width
798 .or(theme.contact_avatar.height)
799 .unwrap_or(0.);
800 let row = &theme.project_row.default;
801 let tree_branch = theme.tree_branch;
802 let line_height = row.name.text.line_height(font_cache);
803 let cap_height = row.name.text.cap_height(font_cache);
804 let baseline_offset =
805 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
806 let project_name = if worktree_root_names.is_empty() {
807 "untitled".to_string()
808 } else {
809 worktree_root_names.join(", ")
810 };
811
812 MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
813 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
814 let row = theme.project_row.style_for(mouse_state, is_selected);
815
816 Flex::row()
817 .with_child(
818 Stack::new()
819 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
820 let start_x =
821 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
822 let end_x = bounds.max_x();
823 let start_y = bounds.min_y();
824 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
825
826 scene.push_quad(gpui::Quad {
827 bounds: RectF::from_points(
828 vec2f(start_x, start_y),
829 vec2f(
830 start_x + tree_branch.width,
831 if is_last { end_y } else { bounds.max_y() },
832 ),
833 ),
834 background: Some(tree_branch.color),
835 border: gpui::Border::default(),
836 corner_radius: 0.,
837 });
838 scene.push_quad(gpui::Quad {
839 bounds: RectF::from_points(
840 vec2f(start_x, end_y),
841 vec2f(end_x, end_y + tree_branch.width),
842 ),
843 background: Some(tree_branch.color),
844 border: gpui::Border::default(),
845 corner_radius: 0.,
846 });
847 }))
848 .constrained()
849 .with_width(host_avatar_height),
850 )
851 .with_child(
852 Label::new(project_name, row.name.text.clone())
853 .aligned()
854 .left()
855 .contained()
856 .with_style(row.name.container)
857 .flex(1., false),
858 )
859 .constrained()
860 .with_height(theme.row_height)
861 .contained()
862 .with_style(row.container)
863 })
864 .with_cursor_style(if !is_current {
865 CursorStyle::PointingHand
866 } else {
867 CursorStyle::Arrow
868 })
869 .on_click(MouseButton::Left, move |_, this, cx| {
870 if !is_current {
871 if let Some(workspace) = this.workspace.upgrade(cx) {
872 let app_state = workspace.read(cx).app_state().clone();
873 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
874 .detach_and_log_err(cx);
875 }
876 }
877 })
878 .into_any()
879 }
880
881 fn render_participant_screen(
882 peer_id: PeerId,
883 is_last: bool,
884 is_selected: bool,
885 theme: &theme::ContactList,
886 cx: &mut ViewContext<Self>,
887 ) -> AnyElement<Self> {
888 enum OpenSharedScreen {}
889
890 let font_cache = cx.font_cache();
891 let host_avatar_height = theme
892 .contact_avatar
893 .width
894 .or(theme.contact_avatar.height)
895 .unwrap_or(0.);
896 let row = &theme.project_row.default;
897 let tree_branch = theme.tree_branch;
898 let line_height = row.name.text.line_height(font_cache);
899 let cap_height = row.name.text.cap_height(font_cache);
900 let baseline_offset =
901 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
902
903 MouseEventHandler::<OpenSharedScreen, Self>::new(
904 peer_id.as_u64() as usize,
905 cx,
906 |mouse_state, _| {
907 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
908 let row = theme.project_row.style_for(mouse_state, is_selected);
909
910 Flex::row()
911 .with_child(
912 Stack::new()
913 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
914 let start_x = bounds.min_x() + (bounds.width() / 2.)
915 - (tree_branch.width / 2.);
916 let end_x = bounds.max_x();
917 let start_y = bounds.min_y();
918 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
919
920 scene.push_quad(gpui::Quad {
921 bounds: RectF::from_points(
922 vec2f(start_x, start_y),
923 vec2f(
924 start_x + tree_branch.width,
925 if is_last { end_y } else { bounds.max_y() },
926 ),
927 ),
928 background: Some(tree_branch.color),
929 border: gpui::Border::default(),
930 corner_radius: 0.,
931 });
932 scene.push_quad(gpui::Quad {
933 bounds: RectF::from_points(
934 vec2f(start_x, end_y),
935 vec2f(end_x, end_y + tree_branch.width),
936 ),
937 background: Some(tree_branch.color),
938 border: gpui::Border::default(),
939 corner_radius: 0.,
940 });
941 }))
942 .constrained()
943 .with_width(host_avatar_height),
944 )
945 .with_child(
946 Svg::new("icons/disable_screen_sharing_12.svg")
947 .with_color(row.icon.color)
948 .constrained()
949 .with_width(row.icon.width)
950 .aligned()
951 .left()
952 .contained()
953 .with_style(row.icon.container),
954 )
955 .with_child(
956 Label::new("Screen", row.name.text.clone())
957 .aligned()
958 .left()
959 .contained()
960 .with_style(row.name.container)
961 .flex(1., false),
962 )
963 .constrained()
964 .with_height(theme.row_height)
965 .contained()
966 .with_style(row.container)
967 },
968 )
969 .with_cursor_style(CursorStyle::PointingHand)
970 .on_click(MouseButton::Left, move |_, this, cx| {
971 if let Some(workspace) = this.workspace.upgrade(cx) {
972 workspace.update(cx, |workspace, cx| {
973 workspace.open_shared_screen(peer_id, cx)
974 });
975 }
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| {
1008 ActiveCall::global(cx)
1009 .update(cx, |call, cx| call.hang_up(cx))
1010 .detach_and_log_err(cx);
1011 })
1012 .aligned(),
1013 )
1014 } else {
1015 None
1016 };
1017
1018 let icon_size = theme.section_icon_size;
1019 MouseEventHandler::<Header, Self>::new(section as usize, cx, |_, _| {
1020 Flex::row()
1021 .with_child(
1022 Svg::new(if is_collapsed {
1023 "icons/chevron_right_8.svg"
1024 } else {
1025 "icons/chevron_down_8.svg"
1026 })
1027 .with_color(header_style.text.color)
1028 .constrained()
1029 .with_max_width(icon_size)
1030 .with_max_height(icon_size)
1031 .aligned()
1032 .constrained()
1033 .with_width(icon_size),
1034 )
1035 .with_child(
1036 Label::new(text, header_style.text.clone())
1037 .aligned()
1038 .left()
1039 .contained()
1040 .with_margin_left(theme.contact_username.container.margin.left)
1041 .flex(1., true),
1042 )
1043 .with_children(leave_call)
1044 .constrained()
1045 .with_height(theme.row_height)
1046 .contained()
1047 .with_style(header_style.container)
1048 })
1049 .with_cursor_style(CursorStyle::PointingHand)
1050 .on_click(MouseButton::Left, move |_, this, cx| {
1051 this.toggle_expanded(section, cx);
1052 })
1053 .into_any()
1054 }
1055
1056 fn render_contact(
1057 contact: &Contact,
1058 calling: bool,
1059 project: &ModelHandle<Project>,
1060 theme: &theme::ContactList,
1061 is_selected: bool,
1062 cx: &mut ViewContext<Self>,
1063 ) -> AnyElement<Self> {
1064 let online = contact.online;
1065 let busy = contact.busy || calling;
1066 let user_id = contact.user.id;
1067 let github_login = contact.user.github_login.clone();
1068 let initial_project = project.clone();
1069 let mut event_handler =
1070 MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |_, cx| {
1071 Flex::row()
1072 .with_children(contact.user.avatar.clone().map(|avatar| {
1073 let status_badge = if contact.online {
1074 Some(
1075 Empty::new()
1076 .collapsed()
1077 .contained()
1078 .with_style(if busy {
1079 theme.contact_status_busy
1080 } else {
1081 theme.contact_status_free
1082 })
1083 .aligned(),
1084 )
1085 } else {
1086 None
1087 };
1088 Stack::new()
1089 .with_child(
1090 Image::from_data(avatar)
1091 .with_style(theme.contact_avatar)
1092 .aligned()
1093 .left(),
1094 )
1095 .with_children(status_badge)
1096 }))
1097 .with_child(
1098 Label::new(
1099 contact.user.github_login.clone(),
1100 theme.contact_username.text.clone(),
1101 )
1102 .contained()
1103 .with_style(theme.contact_username.container)
1104 .aligned()
1105 .left()
1106 .flex(1., true),
1107 )
1108 .with_child(
1109 MouseEventHandler::<Cancel, Self>::new(
1110 contact.user.id as usize,
1111 cx,
1112 |mouse_state, _| {
1113 let button_style =
1114 theme.contact_button.style_for(mouse_state, false);
1115 render_icon_button(button_style, "icons/x_mark_8.svg")
1116 .aligned()
1117 .flex_float()
1118 },
1119 )
1120 .with_padding(Padding::uniform(2.))
1121 .with_cursor_style(CursorStyle::PointingHand)
1122 .on_click(MouseButton::Left, move |_, this, cx| {
1123 this.remove_contact(
1124 &RemoveContact {
1125 user_id,
1126 github_login: github_login.clone(),
1127 },
1128 cx,
1129 );
1130 })
1131 .flex_float(),
1132 )
1133 .with_children(if calling {
1134 Some(
1135 Label::new("Calling", theme.calling_indicator.text.clone())
1136 .contained()
1137 .with_style(theme.calling_indicator.container)
1138 .aligned(),
1139 )
1140 } else {
1141 None
1142 })
1143 .constrained()
1144 .with_height(theme.row_height)
1145 .contained()
1146 .with_style(
1147 *theme
1148 .contact_row
1149 .style_for(&mut Default::default(), is_selected),
1150 )
1151 })
1152 .on_click(MouseButton::Left, move |_, this, cx| {
1153 if online && !busy {
1154 this.call(user_id, Some(initial_project.clone()), cx);
1155 }
1156 });
1157
1158 if online {
1159 event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1160 }
1161
1162 event_handler.into_any()
1163 }
1164
1165 fn render_contact_request(
1166 user: Arc<User>,
1167 user_store: ModelHandle<UserStore>,
1168 theme: &theme::ContactList,
1169 is_incoming: bool,
1170 is_selected: bool,
1171 cx: &mut ViewContext<Self>,
1172 ) -> AnyElement<Self> {
1173 enum Decline {}
1174 enum Accept {}
1175 enum Cancel {}
1176
1177 let mut row = Flex::row()
1178 .with_children(user.avatar.clone().map(|avatar| {
1179 Image::from_data(avatar)
1180 .with_style(theme.contact_avatar)
1181 .aligned()
1182 .left()
1183 }))
1184 .with_child(
1185 Label::new(
1186 user.github_login.clone(),
1187 theme.contact_username.text.clone(),
1188 )
1189 .contained()
1190 .with_style(theme.contact_username.container)
1191 .aligned()
1192 .left()
1193 .flex(1., true),
1194 );
1195
1196 let user_id = user.id;
1197 let github_login = user.github_login.clone();
1198 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1199 let button_spacing = theme.contact_button_spacing;
1200
1201 if is_incoming {
1202 row.add_child(
1203 MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
1204 let button_style = if is_contact_request_pending {
1205 &theme.disabled_button
1206 } else {
1207 theme.contact_button.style_for(mouse_state, false)
1208 };
1209 render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1210 })
1211 .with_cursor_style(CursorStyle::PointingHand)
1212 .on_click(MouseButton::Left, move |_, this, cx| {
1213 this.respond_to_contact_request(
1214 &RespondToContactRequest {
1215 user_id,
1216 accept: false,
1217 },
1218 cx,
1219 );
1220 })
1221 .contained()
1222 .with_margin_right(button_spacing),
1223 );
1224
1225 row.add_child(
1226 MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
1227 let button_style = if is_contact_request_pending {
1228 &theme.disabled_button
1229 } else {
1230 theme.contact_button.style_for(mouse_state, false)
1231 };
1232 render_icon_button(button_style, "icons/check_8.svg")
1233 .aligned()
1234 .flex_float()
1235 })
1236 .with_cursor_style(CursorStyle::PointingHand)
1237 .on_click(MouseButton::Left, move |_, this, cx| {
1238 this.respond_to_contact_request(
1239 &RespondToContactRequest {
1240 user_id,
1241 accept: true,
1242 },
1243 cx,
1244 );
1245 }),
1246 );
1247 } else {
1248 row.add_child(
1249 MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
1250 let button_style = if is_contact_request_pending {
1251 &theme.disabled_button
1252 } else {
1253 theme.contact_button.style_for(mouse_state, false)
1254 };
1255 render_icon_button(button_style, "icons/x_mark_8.svg")
1256 .aligned()
1257 .flex_float()
1258 })
1259 .with_padding(Padding::uniform(2.))
1260 .with_cursor_style(CursorStyle::PointingHand)
1261 .on_click(MouseButton::Left, move |_, this, cx| {
1262 this.remove_contact(
1263 &RemoveContact {
1264 user_id,
1265 github_login: github_login.clone(),
1266 },
1267 cx,
1268 );
1269 })
1270 .flex_float(),
1271 );
1272 }
1273
1274 row.constrained()
1275 .with_height(theme.row_height)
1276 .contained()
1277 .with_style(
1278 *theme
1279 .contact_row
1280 .style_for(&mut Default::default(), is_selected),
1281 )
1282 .into_any()
1283 }
1284
1285 fn call(
1286 &mut self,
1287 recipient_user_id: u64,
1288 initial_project: Option<ModelHandle<Project>>,
1289 cx: &mut ViewContext<Self>,
1290 ) {
1291 ActiveCall::global(cx)
1292 .update(cx, |call, cx| {
1293 call.invite(recipient_user_id, initial_project, cx)
1294 })
1295 .detach_and_log_err(cx);
1296 }
1297}
1298
1299impl Entity for ContactList {
1300 type Event = Event;
1301}
1302
1303impl View for ContactList {
1304 fn ui_name() -> &'static str {
1305 "ContactList"
1306 }
1307
1308 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1309 Self::reset_to_default_keymap_context(keymap);
1310 keymap.add_identifier("menu");
1311 }
1312
1313 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1314 enum AddContact {}
1315 let theme = theme::current(cx).clone();
1316
1317 Flex::column()
1318 .with_child(
1319 Flex::row()
1320 .with_child(
1321 ChildView::new(&self.filter_editor, cx)
1322 .contained()
1323 .with_style(theme.contact_list.user_query_editor.container)
1324 .flex(1., true),
1325 )
1326 .with_child(
1327 MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
1328 render_icon_button(
1329 &theme.contact_list.add_contact_button,
1330 "icons/user_plus_16.svg",
1331 )
1332 })
1333 .with_cursor_style(CursorStyle::PointingHand)
1334 .on_click(MouseButton::Left, |_, _, cx| {
1335 cx.emit(Event::ToggleContactFinder)
1336 })
1337 .with_tooltip::<AddContact>(
1338 0,
1339 "Search for new contact".into(),
1340 None,
1341 theme.tooltip.clone(),
1342 cx,
1343 ),
1344 )
1345 .constrained()
1346 .with_height(theme.contact_list.user_query_editor_height),
1347 )
1348 .with_child(List::new(self.list_state.clone()).flex(1., false))
1349 .into_any()
1350 }
1351
1352 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1353 if !self.filter_editor.is_focused(cx) {
1354 cx.focus(&self.filter_editor);
1355 }
1356 }
1357
1358 fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1359 if !self.filter_editor.is_focused(cx) {
1360 cx.emit(Event::Dismissed);
1361 }
1362 }
1363}
1364
1365fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<ContactList> {
1366 Svg::new(svg_path)
1367 .with_color(style.color)
1368 .constrained()
1369 .with_width(style.icon_width)
1370 .aligned()
1371 .contained()
1372 .with_style(style.container)
1373 .constrained()
1374 .with_width(style.button_width)
1375 .with_height(style.button_width)
1376}