contact_list.rs

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