contact_list.rs

   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(&section) {
 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}