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