contact_list.rs

   1use super::collab_titlebar_item::LeaveCall;
   2use crate::contacts_popover;
   3use call::ActiveCall;
   4use client::{proto::PeerId, Contact, User, UserStore};
   5use editor::{Cancel, Editor};
   6use futures::StreamExt;
   7use fuzzy::{match_strings, StringMatchCandidate};
   8use gpui::{
   9    elements::*,
  10    geometry::{rect::RectF, vector::vec2f},
  11    impl_actions, impl_internal_actions,
  12    keymap_matcher::KeymapContext,
  13    platform::{CursorStyle, MouseButton, PromptLevel},
  14    AppContext, Entity, ModelHandle, Subscription, View, ViewContext, ViewHandle,
  15};
  16use menu::{Confirm, SelectNext, SelectPrev};
  17use project::Project;
  18use serde::Deserialize;
  19use settings::Settings;
  20use std::{mem, sync::Arc};
  21use theme::IconButton;
  22use workspace::{JoinProject, OpenSharedScreen};
  23
  24impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
  25impl_internal_actions!(contact_list, [ToggleExpanded, Call]);
  26
  27pub fn init(cx: &mut AppContext) {
  28    cx.add_action(ContactList::remove_contact);
  29    cx.add_action(ContactList::respond_to_contact_request);
  30    cx.add_action(ContactList::cancel);
  31    cx.add_action(ContactList::select_next);
  32    cx.add_action(ContactList::select_prev);
  33    cx.add_action(ContactList::confirm);
  34    cx.add_action(ContactList::toggle_expanded);
  35    cx.add_action(ContactList::call);
  36}
  37
  38#[derive(Clone, PartialEq)]
  39struct ToggleExpanded(Section);
  40
  41#[derive(Clone, PartialEq)]
  42struct Call {
  43    recipient_user_id: u64,
  44    initial_project: Option<ModelHandle<Project>>,
  45}
  46
  47#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
  48enum Section {
  49    ActiveCall,
  50    Requests,
  51    Online,
  52    Offline,
  53}
  54
  55#[derive(Clone)]
  56enum ContactEntry {
  57    Header(Section),
  58    CallParticipant {
  59        user: Arc<User>,
  60        is_pending: bool,
  61    },
  62    ParticipantProject {
  63        project_id: u64,
  64        worktree_root_names: Vec<String>,
  65        host_user_id: u64,
  66        is_last: bool,
  67    },
  68    ParticipantScreen {
  69        peer_id: PeerId,
  70        is_last: bool,
  71    },
  72    IncomingRequest(Arc<User>),
  73    OutgoingRequest(Arc<User>),
  74    Contact {
  75        contact: Arc<Contact>,
  76        calling: bool,
  77    },
  78}
  79
  80impl PartialEq for ContactEntry {
  81    fn eq(&self, other: &Self) -> bool {
  82        match self {
  83            ContactEntry::Header(section_1) => {
  84                if let ContactEntry::Header(section_2) = other {
  85                    return section_1 == section_2;
  86                }
  87            }
  88            ContactEntry::CallParticipant { user: user_1, .. } => {
  89                if let ContactEntry::CallParticipant { user: user_2, .. } = other {
  90                    return user_1.id == user_2.id;
  91                }
  92            }
  93            ContactEntry::ParticipantProject {
  94                project_id: project_id_1,
  95                ..
  96            } => {
  97                if let ContactEntry::ParticipantProject {
  98                    project_id: project_id_2,
  99                    ..
 100                } = other
 101                {
 102                    return project_id_1 == project_id_2;
 103                }
 104            }
 105            ContactEntry::ParticipantScreen {
 106                peer_id: peer_id_1, ..
 107            } => {
 108                if let ContactEntry::ParticipantScreen {
 109                    peer_id: peer_id_2, ..
 110                } = other
 111                {
 112                    return peer_id_1 == peer_id_2;
 113                }
 114            }
 115            ContactEntry::IncomingRequest(user_1) => {
 116                if let ContactEntry::IncomingRequest(user_2) = other {
 117                    return user_1.id == user_2.id;
 118                }
 119            }
 120            ContactEntry::OutgoingRequest(user_1) => {
 121                if let ContactEntry::OutgoingRequest(user_2) = other {
 122                    return user_1.id == user_2.id;
 123                }
 124            }
 125            ContactEntry::Contact {
 126                contact: contact_1, ..
 127            } => {
 128                if let ContactEntry::Contact {
 129                    contact: contact_2, ..
 130                } = other
 131                {
 132                    return contact_1.user.id == contact_2.user.id;
 133                }
 134            }
 135        }
 136        false
 137    }
 138}
 139
 140#[derive(Clone, Deserialize, PartialEq)]
 141pub struct RequestContact(pub u64);
 142
 143#[derive(Clone, Deserialize, PartialEq)]
 144pub struct RemoveContact {
 145    user_id: u64,
 146    github_login: String,
 147}
 148
 149#[derive(Clone, Deserialize, PartialEq)]
 150pub struct RespondToContactRequest {
 151    pub user_id: u64,
 152    pub accept: bool,
 153}
 154
 155pub enum Event {
 156    Dismissed,
 157}
 158
 159pub struct ContactList {
 160    entries: Vec<ContactEntry>,
 161    match_candidates: Vec<StringMatchCandidate>,
 162    list_state: ListState<Self>,
 163    project: ModelHandle<Project>,
 164    user_store: ModelHandle<UserStore>,
 165    filter_editor: ViewHandle<Editor>,
 166    collapsed_sections: Vec<Section>,
 167    selection: Option<usize>,
 168    _subscriptions: Vec<Subscription>,
 169}
 170
 171impl ContactList {
 172    pub fn new(
 173        project: ModelHandle<Project>,
 174        user_store: ModelHandle<UserStore>,
 175        cx: &mut ViewContext<Self>,
 176    ) -> Self {
 177        let filter_editor = cx.add_view(|cx| {
 178            let mut editor = Editor::single_line(
 179                Some(Arc::new(|theme| {
 180                    theme.contact_list.user_query_editor.clone()
 181                })),
 182                cx,
 183            );
 184            editor.set_placeholder_text("Filter contacts", cx);
 185            editor
 186        });
 187
 188        cx.subscribe(&filter_editor, |this, _, event, cx| {
 189            if let editor::Event::BufferEdited = event {
 190                let query = this.filter_editor.read(cx).text(cx);
 191                if !query.is_empty() {
 192                    this.selection.take();
 193                }
 194                this.update_entries(cx);
 195                if !query.is_empty() {
 196                    this.selection = this
 197                        .entries
 198                        .iter()
 199                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
 200                }
 201            }
 202        })
 203        .detach();
 204
 205        let list_state = ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
 206            let theme = cx.global::<Settings>().theme.clone();
 207            let is_selected = this.selection == Some(ix);
 208            let current_project_id = this.project.read(cx).remote_id();
 209
 210            match &this.entries[ix] {
 211                ContactEntry::Header(section) => {
 212                    let is_collapsed = this.collapsed_sections.contains(section);
 213                    Self::render_header(
 214                        *section,
 215                        &theme.contact_list,
 216                        is_selected,
 217                        is_collapsed,
 218                        cx,
 219                    )
 220                }
 221                ContactEntry::CallParticipant { user, is_pending } => {
 222                    Self::render_call_participant(
 223                        user,
 224                        *is_pending,
 225                        is_selected,
 226                        &theme.contact_list,
 227                    )
 228                }
 229                ContactEntry::ParticipantProject {
 230                    project_id,
 231                    worktree_root_names,
 232                    host_user_id,
 233                    is_last,
 234                } => Self::render_participant_project(
 235                    *project_id,
 236                    worktree_root_names,
 237                    *host_user_id,
 238                    Some(*project_id) == current_project_id,
 239                    *is_last,
 240                    is_selected,
 241                    &theme.contact_list,
 242                    cx,
 243                ),
 244                ContactEntry::ParticipantScreen { peer_id, is_last } => {
 245                    Self::render_participant_screen(
 246                        *peer_id,
 247                        *is_last,
 248                        is_selected,
 249                        &theme.contact_list,
 250                        cx,
 251                    )
 252                }
 253                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
 254                    user.clone(),
 255                    this.user_store.clone(),
 256                    &theme.contact_list,
 257                    true,
 258                    is_selected,
 259                    cx,
 260                ),
 261                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
 262                    user.clone(),
 263                    this.user_store.clone(),
 264                    &theme.contact_list,
 265                    false,
 266                    is_selected,
 267                    cx,
 268                ),
 269                ContactEntry::Contact { contact, calling } => Self::render_contact(
 270                    contact,
 271                    *calling,
 272                    &this.project,
 273                    &theme.contact_list,
 274                    is_selected,
 275                    cx,
 276                ),
 277            }
 278        });
 279
 280        let active_call = ActiveCall::global(cx);
 281        let mut subscriptions = Vec::new();
 282        subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
 283        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
 284
 285        let mut this = Self {
 286            list_state,
 287            selection: None,
 288            collapsed_sections: Default::default(),
 289            entries: Default::default(),
 290            match_candidates: Default::default(),
 291            filter_editor,
 292            _subscriptions: subscriptions,
 293            project,
 294            user_store,
 295        };
 296        this.update_entries(cx);
 297        this
 298    }
 299
 300    pub fn editor_text(&self, cx: &AppContext) -> String {
 301        self.filter_editor.read(cx).text(cx)
 302    }
 303
 304    pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext<Self>) -> Self {
 305        self.filter_editor
 306            .update(cx, |picker, cx| picker.set_text(editor_text, cx));
 307        self
 308    }
 309
 310    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
 311        let user_id = request.user_id;
 312        let github_login = &request.github_login;
 313        let user_store = self.user_store.clone();
 314        let prompt_message = format!(
 315            "Are you sure you want to remove \"{}\" from your contacts?",
 316            github_login
 317        );
 318        let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
 319        let window_id = cx.window_id();
 320        cx.spawn(|_, mut cx| async move {
 321            if answer.next().await == Some(0) {
 322                if let Err(e) = user_store
 323                    .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
 324                    .await
 325                {
 326                    cx.prompt(
 327                        window_id,
 328                        PromptLevel::Info,
 329                        &format!("Failed to remove contact: {}", e),
 330                        &["Ok"],
 331                    );
 332                }
 333            }
 334        })
 335        .detach();
 336    }
 337
 338    fn respond_to_contact_request(
 339        &mut self,
 340        action: &RespondToContactRequest,
 341        cx: &mut ViewContext<Self>,
 342    ) {
 343        self.user_store
 344            .update(cx, |store, cx| {
 345                store.respond_to_contact_request(action.user_id, action.accept, cx)
 346            })
 347            .detach();
 348    }
 349
 350    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 351        let did_clear = self.filter_editor.update(cx, |editor, cx| {
 352            if editor.buffer().read(cx).len(cx) > 0 {
 353                editor.set_text("", cx);
 354                true
 355            } else {
 356                false
 357            }
 358        });
 359
 360        if !did_clear {
 361            cx.emit(Event::Dismissed);
 362        }
 363    }
 364
 365    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 366        if let Some(ix) = self.selection {
 367            if self.entries.len() > ix + 1 {
 368                self.selection = Some(ix + 1);
 369            }
 370        } else if !self.entries.is_empty() {
 371            self.selection = Some(0);
 372        }
 373        self.list_state.reset(self.entries.len());
 374        if let Some(ix) = self.selection {
 375            self.list_state.scroll_to(ListOffset {
 376                item_ix: ix,
 377                offset_in_item: 0.,
 378            });
 379        }
 380        cx.notify();
 381    }
 382
 383    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 384        if let Some(ix) = self.selection {
 385            if ix > 0 {
 386                self.selection = Some(ix - 1);
 387            } else {
 388                self.selection = None;
 389            }
 390        }
 391        self.list_state.reset(self.entries.len());
 392        if let Some(ix) = self.selection {
 393            self.list_state.scroll_to(ListOffset {
 394                item_ix: ix,
 395                offset_in_item: 0.,
 396            });
 397        }
 398        cx.notify();
 399    }
 400
 401    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 402        if let Some(selection) = self.selection {
 403            if let Some(entry) = self.entries.get(selection) {
 404                match entry {
 405                    ContactEntry::Header(section) => {
 406                        let section = *section;
 407                        self.toggle_expanded(&ToggleExpanded(section), cx);
 408                    }
 409                    ContactEntry::Contact { contact, calling } => {
 410                        if contact.online && !contact.busy && !calling {
 411                            self.call(
 412                                &Call {
 413                                    recipient_user_id: contact.user.id,
 414                                    initial_project: Some(self.project.clone()),
 415                                },
 416                                cx,
 417                            );
 418                        }
 419                    }
 420                    ContactEntry::ParticipantProject {
 421                        project_id,
 422                        host_user_id,
 423                        ..
 424                    } => {
 425                        cx.dispatch_global_action(JoinProject {
 426                            project_id: *project_id,
 427                            follow_user_id: *host_user_id,
 428                        });
 429                    }
 430                    ContactEntry::ParticipantScreen { peer_id, .. } => {
 431                        cx.dispatch_action(OpenSharedScreen { peer_id: *peer_id });
 432                    }
 433                    _ => {}
 434                }
 435            }
 436        }
 437    }
 438
 439    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
 440        let section = action.0;
 441        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
 442            self.collapsed_sections.remove(ix);
 443        } else {
 444            self.collapsed_sections.push(section);
 445        }
 446        self.update_entries(cx);
 447    }
 448
 449    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
 450        let user_store = self.user_store.read(cx);
 451        let query = self.filter_editor.read(cx).text(cx);
 452        let executor = cx.background().clone();
 453
 454        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
 455        let old_entries = mem::take(&mut self.entries);
 456
 457        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
 458            let room = room.read(cx);
 459            let mut participant_entries = Vec::new();
 460
 461            // Populate the active user.
 462            if let Some(user) = user_store.current_user() {
 463                self.match_candidates.clear();
 464                self.match_candidates.push(StringMatchCandidate {
 465                    id: 0,
 466                    string: user.github_login.clone(),
 467                    char_bag: user.github_login.chars().collect(),
 468                });
 469                let matches = executor.block(match_strings(
 470                    &self.match_candidates,
 471                    &query,
 472                    true,
 473                    usize::MAX,
 474                    &Default::default(),
 475                    executor.clone(),
 476                ));
 477                if !matches.is_empty() {
 478                    let user_id = user.id;
 479                    participant_entries.push(ContactEntry::CallParticipant {
 480                        user,
 481                        is_pending: false,
 482                    });
 483                    let mut projects = room.local_participant().projects.iter().peekable();
 484                    while let Some(project) = projects.next() {
 485                        participant_entries.push(ContactEntry::ParticipantProject {
 486                            project_id: project.id,
 487                            worktree_root_names: project.worktree_root_names.clone(),
 488                            host_user_id: user_id,
 489                            is_last: projects.peek().is_none(),
 490                        });
 491                    }
 492                }
 493            }
 494
 495            // Populate remote participants.
 496            self.match_candidates.clear();
 497            self.match_candidates
 498                .extend(room.remote_participants().iter().map(|(_, participant)| {
 499                    StringMatchCandidate {
 500                        id: participant.user.id as usize,
 501                        string: participant.user.github_login.clone(),
 502                        char_bag: participant.user.github_login.chars().collect(),
 503                    }
 504                }));
 505            let matches = executor.block(match_strings(
 506                &self.match_candidates,
 507                &query,
 508                true,
 509                usize::MAX,
 510                &Default::default(),
 511                executor.clone(),
 512            ));
 513            for mat in matches {
 514                let user_id = mat.candidate_id as u64;
 515                let participant = &room.remote_participants()[&user_id];
 516                participant_entries.push(ContactEntry::CallParticipant {
 517                    user: participant.user.clone(),
 518                    is_pending: false,
 519                });
 520                let mut projects = participant.projects.iter().peekable();
 521                while let Some(project) = projects.next() {
 522                    participant_entries.push(ContactEntry::ParticipantProject {
 523                        project_id: project.id,
 524                        worktree_root_names: project.worktree_root_names.clone(),
 525                        host_user_id: participant.user.id,
 526                        is_last: projects.peek().is_none() && participant.tracks.is_empty(),
 527                    });
 528                }
 529                if !participant.tracks.is_empty() {
 530                    participant_entries.push(ContactEntry::ParticipantScreen {
 531                        peer_id: participant.peer_id,
 532                        is_last: true,
 533                    });
 534                }
 535            }
 536
 537            // Populate pending participants.
 538            self.match_candidates.clear();
 539            self.match_candidates
 540                .extend(
 541                    room.pending_participants()
 542                        .iter()
 543                        .enumerate()
 544                        .map(|(id, participant)| StringMatchCandidate {
 545                            id,
 546                            string: participant.github_login.clone(),
 547                            char_bag: participant.github_login.chars().collect(),
 548                        }),
 549                );
 550            let matches = executor.block(match_strings(
 551                &self.match_candidates,
 552                &query,
 553                true,
 554                usize::MAX,
 555                &Default::default(),
 556                executor.clone(),
 557            ));
 558            participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
 559                user: room.pending_participants()[mat.candidate_id].clone(),
 560                is_pending: true,
 561            }));
 562
 563            if !participant_entries.is_empty() {
 564                self.entries.push(ContactEntry::Header(Section::ActiveCall));
 565                if !self.collapsed_sections.contains(&Section::ActiveCall) {
 566                    self.entries.extend(participant_entries);
 567                }
 568            }
 569        }
 570
 571        let mut request_entries = Vec::new();
 572        let incoming = user_store.incoming_contact_requests();
 573        if !incoming.is_empty() {
 574            self.match_candidates.clear();
 575            self.match_candidates
 576                .extend(
 577                    incoming
 578                        .iter()
 579                        .enumerate()
 580                        .map(|(ix, user)| StringMatchCandidate {
 581                            id: ix,
 582                            string: user.github_login.clone(),
 583                            char_bag: user.github_login.chars().collect(),
 584                        }),
 585                );
 586            let matches = executor.block(match_strings(
 587                &self.match_candidates,
 588                &query,
 589                true,
 590                usize::MAX,
 591                &Default::default(),
 592                executor.clone(),
 593            ));
 594            request_entries.extend(
 595                matches
 596                    .iter()
 597                    .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
 598            );
 599        }
 600
 601        let outgoing = user_store.outgoing_contact_requests();
 602        if !outgoing.is_empty() {
 603            self.match_candidates.clear();
 604            self.match_candidates
 605                .extend(
 606                    outgoing
 607                        .iter()
 608                        .enumerate()
 609                        .map(|(ix, user)| StringMatchCandidate {
 610                            id: ix,
 611                            string: user.github_login.clone(),
 612                            char_bag: user.github_login.chars().collect(),
 613                        }),
 614                );
 615            let matches = executor.block(match_strings(
 616                &self.match_candidates,
 617                &query,
 618                true,
 619                usize::MAX,
 620                &Default::default(),
 621                executor.clone(),
 622            ));
 623            request_entries.extend(
 624                matches
 625                    .iter()
 626                    .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
 627            );
 628        }
 629
 630        if !request_entries.is_empty() {
 631            self.entries.push(ContactEntry::Header(Section::Requests));
 632            if !self.collapsed_sections.contains(&Section::Requests) {
 633                self.entries.append(&mut request_entries);
 634            }
 635        }
 636
 637        let contacts = user_store.contacts();
 638        if !contacts.is_empty() {
 639            self.match_candidates.clear();
 640            self.match_candidates
 641                .extend(
 642                    contacts
 643                        .iter()
 644                        .enumerate()
 645                        .map(|(ix, contact)| StringMatchCandidate {
 646                            id: ix,
 647                            string: contact.user.github_login.clone(),
 648                            char_bag: contact.user.github_login.chars().collect(),
 649                        }),
 650                );
 651
 652            let matches = executor.block(match_strings(
 653                &self.match_candidates,
 654                &query,
 655                true,
 656                usize::MAX,
 657                &Default::default(),
 658                executor.clone(),
 659            ));
 660
 661            let (mut online_contacts, offline_contacts) = matches
 662                .iter()
 663                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
 664            if let Some(room) = ActiveCall::global(cx).read(cx).room() {
 665                let room = room.read(cx);
 666                online_contacts.retain(|contact| {
 667                    let contact = &contacts[contact.candidate_id];
 668                    !room.contains_participant(contact.user.id)
 669                });
 670            }
 671
 672            for (matches, section) in [
 673                (online_contacts, Section::Online),
 674                (offline_contacts, Section::Offline),
 675            ] {
 676                if !matches.is_empty() {
 677                    self.entries.push(ContactEntry::Header(section));
 678                    if !self.collapsed_sections.contains(&section) {
 679                        let active_call = &ActiveCall::global(cx).read(cx);
 680                        for mat in matches {
 681                            let contact = &contacts[mat.candidate_id];
 682                            self.entries.push(ContactEntry::Contact {
 683                                contact: contact.clone(),
 684                                calling: active_call.pending_invites().contains(&contact.user.id),
 685                            });
 686                        }
 687                    }
 688                }
 689            }
 690        }
 691
 692        if let Some(prev_selected_entry) = prev_selected_entry {
 693            self.selection.take();
 694            for (ix, entry) in self.entries.iter().enumerate() {
 695                if *entry == prev_selected_entry {
 696                    self.selection = Some(ix);
 697                    break;
 698                }
 699            }
 700        }
 701
 702        let old_scroll_top = self.list_state.logical_scroll_top();
 703        self.list_state.reset(self.entries.len());
 704
 705        // Attempt to maintain the same scroll position.
 706        if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
 707            let new_scroll_top = self
 708                .entries
 709                .iter()
 710                .position(|entry| entry == old_top_entry)
 711                .map(|item_ix| ListOffset {
 712                    item_ix,
 713                    offset_in_item: old_scroll_top.offset_in_item,
 714                })
 715                .or_else(|| {
 716                    let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
 717                    let item_ix = self
 718                        .entries
 719                        .iter()
 720                        .position(|entry| entry == entry_after_old_top)?;
 721                    Some(ListOffset {
 722                        item_ix,
 723                        offset_in_item: 0.,
 724                    })
 725                })
 726                .or_else(|| {
 727                    let entry_before_old_top =
 728                        old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
 729                    let item_ix = self
 730                        .entries
 731                        .iter()
 732                        .position(|entry| entry == entry_before_old_top)?;
 733                    Some(ListOffset {
 734                        item_ix,
 735                        offset_in_item: 0.,
 736                    })
 737                });
 738
 739            self.list_state
 740                .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
 741        }
 742
 743        cx.notify();
 744    }
 745
 746    fn render_call_participant(
 747        user: &User,
 748        is_pending: bool,
 749        is_selected: bool,
 750        theme: &theme::ContactList,
 751    ) -> AnyElement<Self> {
 752        Flex::row()
 753            .with_children(user.avatar.clone().map(|avatar| {
 754                Image::from_data(avatar)
 755                    .with_style(theme.contact_avatar)
 756                    .aligned()
 757                    .left()
 758            }))
 759            .with_child(
 760                Label::new(
 761                    user.github_login.clone(),
 762                    theme.contact_username.text.clone(),
 763                )
 764                .contained()
 765                .with_style(theme.contact_username.container)
 766                .aligned()
 767                .left()
 768                .flex(1., true),
 769            )
 770            .with_children(if is_pending {
 771                Some(
 772                    Label::new("Calling", theme.calling_indicator.text.clone())
 773                        .contained()
 774                        .with_style(theme.calling_indicator.container)
 775                        .aligned(),
 776                )
 777            } else {
 778                None
 779            })
 780            .constrained()
 781            .with_height(theme.row_height)
 782            .contained()
 783            .with_style(
 784                *theme
 785                    .contact_row
 786                    .style_for(&mut Default::default(), is_selected),
 787            )
 788            .into_any()
 789    }
 790
 791    fn render_participant_project(
 792        project_id: u64,
 793        worktree_root_names: &[String],
 794        host_user_id: u64,
 795        is_current: bool,
 796        is_last: bool,
 797        is_selected: bool,
 798        theme: &theme::ContactList,
 799        cx: &mut ViewContext<Self>,
 800    ) -> AnyElement<Self> {
 801        let font_cache = cx.font_cache();
 802        let host_avatar_height = theme
 803            .contact_avatar
 804            .width
 805            .or(theme.contact_avatar.height)
 806            .unwrap_or(0.);
 807        let row = &theme.project_row.default;
 808        let tree_branch = theme.tree_branch;
 809        let line_height = row.name.text.line_height(font_cache);
 810        let cap_height = row.name.text.cap_height(font_cache);
 811        let baseline_offset =
 812            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
 813        let project_name = if worktree_root_names.is_empty() {
 814            "untitled".to_string()
 815        } else {
 816            worktree_root_names.join(", ")
 817        };
 818
 819        MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
 820            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
 821            let row = theme.project_row.style_for(mouse_state, is_selected);
 822
 823            Flex::row()
 824                .with_child(
 825                    Stack::new()
 826                        .with_child(Canvas::new(move |scene, bounds, _, _, _| {
 827                            let start_x =
 828                                bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
 829                            let end_x = bounds.max_x();
 830                            let start_y = bounds.min_y();
 831                            let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
 832
 833                            scene.push_quad(gpui::Quad {
 834                                bounds: RectF::from_points(
 835                                    vec2f(start_x, start_y),
 836                                    vec2f(
 837                                        start_x + tree_branch.width,
 838                                        if is_last { end_y } else { bounds.max_y() },
 839                                    ),
 840                                ),
 841                                background: Some(tree_branch.color),
 842                                border: gpui::Border::default(),
 843                                corner_radius: 0.,
 844                            });
 845                            scene.push_quad(gpui::Quad {
 846                                bounds: RectF::from_points(
 847                                    vec2f(start_x, end_y),
 848                                    vec2f(end_x, end_y + tree_branch.width),
 849                                ),
 850                                background: Some(tree_branch.color),
 851                                border: gpui::Border::default(),
 852                                corner_radius: 0.,
 853                            });
 854                        }))
 855                        .constrained()
 856                        .with_width(host_avatar_height),
 857                )
 858                .with_child(
 859                    Label::new(project_name, row.name.text.clone())
 860                        .aligned()
 861                        .left()
 862                        .contained()
 863                        .with_style(row.name.container)
 864                        .flex(1., false),
 865                )
 866                .constrained()
 867                .with_height(theme.row_height)
 868                .contained()
 869                .with_style(row.container)
 870        })
 871        .with_cursor_style(if !is_current {
 872            CursorStyle::PointingHand
 873        } else {
 874            CursorStyle::Arrow
 875        })
 876        .on_click(MouseButton::Left, move |_, _, cx| {
 877            if !is_current {
 878                cx.dispatch_global_action(JoinProject {
 879                    project_id,
 880                    follow_user_id: host_user_id,
 881                });
 882            }
 883        })
 884        .into_any()
 885    }
 886
 887    fn render_participant_screen(
 888        peer_id: PeerId,
 889        is_last: bool,
 890        is_selected: bool,
 891        theme: &theme::ContactList,
 892        cx: &mut ViewContext<Self>,
 893    ) -> AnyElement<Self> {
 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.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.style_for(mouse_state, is_selected);
 912                let row = theme.project_row.style_for(mouse_state, is_selected);
 913
 914                Flex::row()
 915                    .with_child(
 916                        Stack::new()
 917                            .with_child(Canvas::new(move |scene, bounds, _, _, _| {
 918                                let start_x = bounds.min_x() + (bounds.width() / 2.)
 919                                    - (tree_branch.width / 2.);
 920                                let end_x = bounds.max_x();
 921                                let start_y = bounds.min_y();
 922                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
 923
 924                                scene.push_quad(gpui::Quad {
 925                                    bounds: RectF::from_points(
 926                                        vec2f(start_x, start_y),
 927                                        vec2f(
 928                                            start_x + tree_branch.width,
 929                                            if is_last { end_y } else { bounds.max_y() },
 930                                        ),
 931                                    ),
 932                                    background: Some(tree_branch.color),
 933                                    border: gpui::Border::default(),
 934                                    corner_radius: 0.,
 935                                });
 936                                scene.push_quad(gpui::Quad {
 937                                    bounds: RectF::from_points(
 938                                        vec2f(start_x, end_y),
 939                                        vec2f(end_x, end_y + tree_branch.width),
 940                                    ),
 941                                    background: Some(tree_branch.color),
 942                                    border: gpui::Border::default(),
 943                                    corner_radius: 0.,
 944                                });
 945                            }))
 946                            .constrained()
 947                            .with_width(host_avatar_height),
 948                    )
 949                    .with_child(
 950                        Svg::new("icons/disable_screen_sharing_12.svg")
 951                            .with_color(row.icon.color)
 952                            .constrained()
 953                            .with_width(row.icon.width)
 954                            .aligned()
 955                            .left()
 956                            .contained()
 957                            .with_style(row.icon.container),
 958                    )
 959                    .with_child(
 960                        Label::new("Screen", row.name.text.clone())
 961                            .aligned()
 962                            .left()
 963                            .contained()
 964                            .with_style(row.name.container)
 965                            .flex(1., false),
 966                    )
 967                    .constrained()
 968                    .with_height(theme.row_height)
 969                    .contained()
 970                    .with_style(row.container)
 971            },
 972        )
 973        .with_cursor_style(CursorStyle::PointingHand)
 974        .on_click(MouseButton::Left, move |_, _, cx| {
 975            cx.dispatch_action(OpenSharedScreen { peer_id });
 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| cx.dispatch_action(LeaveCall))
1008                .aligned(),
1009            )
1010        } else {
1011            None
1012        };
1013
1014        let icon_size = theme.section_icon_size;
1015        MouseEventHandler::<Header, Self>::new(section as usize, cx, |_, _| {
1016            Flex::row()
1017                .with_child(
1018                    Svg::new(if is_collapsed {
1019                        "icons/chevron_right_8.svg"
1020                    } else {
1021                        "icons/chevron_down_8.svg"
1022                    })
1023                    .with_color(header_style.text.color)
1024                    .constrained()
1025                    .with_max_width(icon_size)
1026                    .with_max_height(icon_size)
1027                    .aligned()
1028                    .constrained()
1029                    .with_width(icon_size),
1030                )
1031                .with_child(
1032                    Label::new(text, header_style.text.clone())
1033                        .aligned()
1034                        .left()
1035                        .contained()
1036                        .with_margin_left(theme.contact_username.container.margin.left)
1037                        .flex(1., true),
1038                )
1039                .with_children(leave_call)
1040                .constrained()
1041                .with_height(theme.row_height)
1042                .contained()
1043                .with_style(header_style.container)
1044        })
1045        .with_cursor_style(CursorStyle::PointingHand)
1046        .on_click(MouseButton::Left, move |_, _, cx| {
1047            cx.dispatch_action(ToggleExpanded(section))
1048        })
1049        .into_any()
1050    }
1051
1052    fn render_contact(
1053        contact: &Contact,
1054        calling: bool,
1055        project: &ModelHandle<Project>,
1056        theme: &theme::ContactList,
1057        is_selected: bool,
1058        cx: &mut ViewContext<Self>,
1059    ) -> AnyElement<Self> {
1060        let online = contact.online;
1061        let busy = contact.busy || calling;
1062        let user_id = contact.user.id;
1063        let github_login = contact.user.github_login.clone();
1064        let initial_project = project.clone();
1065        let mut event_handler =
1066            MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |_, cx| {
1067                Flex::row()
1068                    .with_children(contact.user.avatar.clone().map(|avatar| {
1069                        let status_badge = if contact.online {
1070                            Some(
1071                                Empty::new()
1072                                    .collapsed()
1073                                    .contained()
1074                                    .with_style(if busy {
1075                                        theme.contact_status_busy
1076                                    } else {
1077                                        theme.contact_status_free
1078                                    })
1079                                    .aligned(),
1080                            )
1081                        } else {
1082                            None
1083                        };
1084                        Stack::new()
1085                            .with_child(
1086                                Image::from_data(avatar)
1087                                    .with_style(theme.contact_avatar)
1088                                    .aligned()
1089                                    .left(),
1090                            )
1091                            .with_children(status_badge)
1092                    }))
1093                    .with_child(
1094                        Label::new(
1095                            contact.user.github_login.clone(),
1096                            theme.contact_username.text.clone(),
1097                        )
1098                        .contained()
1099                        .with_style(theme.contact_username.container)
1100                        .aligned()
1101                        .left()
1102                        .flex(1., true),
1103                    )
1104                    .with_child(
1105                        MouseEventHandler::<Cancel, Self>::new(
1106                            contact.user.id as usize,
1107                            cx,
1108                            |mouse_state, _| {
1109                                let button_style =
1110                                    theme.contact_button.style_for(mouse_state, false);
1111                                render_icon_button(button_style, "icons/x_mark_8.svg")
1112                                    .aligned()
1113                                    .flex_float()
1114                            },
1115                        )
1116                        .with_padding(Padding::uniform(2.))
1117                        .with_cursor_style(CursorStyle::PointingHand)
1118                        .on_click(MouseButton::Left, move |_, _, cx| {
1119                            cx.dispatch_action(RemoveContact {
1120                                user_id,
1121                                github_login: github_login.clone(),
1122                            })
1123                        })
1124                        .flex_float(),
1125                    )
1126                    .with_children(if calling {
1127                        Some(
1128                            Label::new("Calling", theme.calling_indicator.text.clone())
1129                                .contained()
1130                                .with_style(theme.calling_indicator.container)
1131                                .aligned(),
1132                        )
1133                    } else {
1134                        None
1135                    })
1136                    .constrained()
1137                    .with_height(theme.row_height)
1138                    .contained()
1139                    .with_style(
1140                        *theme
1141                            .contact_row
1142                            .style_for(&mut Default::default(), is_selected),
1143                    )
1144            })
1145            .on_click(MouseButton::Left, move |_, _, cx| {
1146                if online && !busy {
1147                    cx.dispatch_action(Call {
1148                        recipient_user_id: user_id,
1149                        initial_project: Some(initial_project.clone()),
1150                    });
1151                }
1152            });
1153
1154        if online {
1155            event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1156        }
1157
1158        event_handler.into_any()
1159    }
1160
1161    fn render_contact_request(
1162        user: Arc<User>,
1163        user_store: ModelHandle<UserStore>,
1164        theme: &theme::ContactList,
1165        is_incoming: bool,
1166        is_selected: bool,
1167        cx: &mut ViewContext<Self>,
1168    ) -> AnyElement<Self> {
1169        enum Decline {}
1170        enum Accept {}
1171        enum Cancel {}
1172
1173        let mut row = Flex::row()
1174            .with_children(user.avatar.clone().map(|avatar| {
1175                Image::from_data(avatar)
1176                    .with_style(theme.contact_avatar)
1177                    .aligned()
1178                    .left()
1179            }))
1180            .with_child(
1181                Label::new(
1182                    user.github_login.clone(),
1183                    theme.contact_username.text.clone(),
1184                )
1185                .contained()
1186                .with_style(theme.contact_username.container)
1187                .aligned()
1188                .left()
1189                .flex(1., true),
1190            );
1191
1192        let user_id = user.id;
1193        let github_login = user.github_login.clone();
1194        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1195        let button_spacing = theme.contact_button_spacing;
1196
1197        if is_incoming {
1198            row.add_child(
1199                MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
1200                    let button_style = if is_contact_request_pending {
1201                        &theme.disabled_button
1202                    } else {
1203                        theme.contact_button.style_for(mouse_state, false)
1204                    };
1205                    render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1206                })
1207                .with_cursor_style(CursorStyle::PointingHand)
1208                .on_click(MouseButton::Left, move |_, _, cx| {
1209                    cx.dispatch_action(RespondToContactRequest {
1210                        user_id,
1211                        accept: false,
1212                    })
1213                })
1214                .contained()
1215                .with_margin_right(button_spacing),
1216            );
1217
1218            row.add_child(
1219                MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
1220                    let button_style = if is_contact_request_pending {
1221                        &theme.disabled_button
1222                    } else {
1223                        theme.contact_button.style_for(mouse_state, false)
1224                    };
1225                    render_icon_button(button_style, "icons/check_8.svg")
1226                        .aligned()
1227                        .flex_float()
1228                })
1229                .with_cursor_style(CursorStyle::PointingHand)
1230                .on_click(MouseButton::Left, move |_, _, cx| {
1231                    cx.dispatch_action(RespondToContactRequest {
1232                        user_id,
1233                        accept: true,
1234                    })
1235                }),
1236            );
1237        } else {
1238            row.add_child(
1239                MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
1240                    let button_style = if is_contact_request_pending {
1241                        &theme.disabled_button
1242                    } else {
1243                        theme.contact_button.style_for(mouse_state, false)
1244                    };
1245                    render_icon_button(button_style, "icons/x_mark_8.svg")
1246                        .aligned()
1247                        .flex_float()
1248                })
1249                .with_padding(Padding::uniform(2.))
1250                .with_cursor_style(CursorStyle::PointingHand)
1251                .on_click(MouseButton::Left, move |_, _, cx| {
1252                    cx.dispatch_action(RemoveContact {
1253                        user_id,
1254                        github_login: github_login.clone(),
1255                    })
1256                })
1257                .flex_float(),
1258            );
1259        }
1260
1261        row.constrained()
1262            .with_height(theme.row_height)
1263            .contained()
1264            .with_style(
1265                *theme
1266                    .contact_row
1267                    .style_for(&mut Default::default(), is_selected),
1268            )
1269            .into_any()
1270    }
1271
1272    fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
1273        let recipient_user_id = action.recipient_user_id;
1274        let initial_project = action.initial_project.clone();
1275        ActiveCall::global(cx)
1276            .update(cx, |call, cx| {
1277                call.invite(recipient_user_id, initial_project, cx)
1278            })
1279            .detach_and_log_err(cx);
1280    }
1281}
1282
1283impl Entity for ContactList {
1284    type Event = Event;
1285}
1286
1287impl View for ContactList {
1288    fn ui_name() -> &'static str {
1289        "ContactList"
1290    }
1291
1292    fn keymap_context(&self, _: &AppContext) -> KeymapContext {
1293        let mut cx = Self::default_keymap_context();
1294        cx.add_identifier("menu");
1295        cx
1296    }
1297
1298    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1299        enum AddContact {}
1300        let theme = cx.global::<Settings>().theme.clone();
1301
1302        Flex::column()
1303            .with_child(
1304                Flex::row()
1305                    .with_child(
1306                        ChildView::new(&self.filter_editor, cx)
1307                            .contained()
1308                            .with_style(theme.contact_list.user_query_editor.container)
1309                            .flex(1., true),
1310                    )
1311                    .with_child(
1312                        MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
1313                            render_icon_button(
1314                                &theme.contact_list.add_contact_button,
1315                                "icons/user_plus_16.svg",
1316                            )
1317                        })
1318                        .with_cursor_style(CursorStyle::PointingHand)
1319                        .on_click(MouseButton::Left, |_, _, cx| {
1320                            cx.dispatch_action(contacts_popover::ToggleContactFinder)
1321                        })
1322                        .with_tooltip::<AddContact>(
1323                            0,
1324                            "Search for new contact".into(),
1325                            None,
1326                            theme.tooltip.clone(),
1327                            cx,
1328                        ),
1329                    )
1330                    .constrained()
1331                    .with_height(theme.contact_list.user_query_editor_height),
1332            )
1333            .with_child(List::new(self.list_state.clone()).flex(1., false))
1334            .into_any()
1335    }
1336
1337    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1338        if !self.filter_editor.is_focused(cx) {
1339            cx.focus(&self.filter_editor);
1340        }
1341    }
1342
1343    fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1344        if !self.filter_editor.is_focused(cx) {
1345            cx.emit(Event::Dismissed);
1346        }
1347    }
1348}
1349
1350fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<ContactList> {
1351    Svg::new(svg_path)
1352        .with_color(style.color)
1353        .constrained()
1354        .with_width(style.icon_width)
1355        .aligned()
1356        .contained()
1357        .with_style(style.container)
1358        .constrained()
1359        .with_width(style.button_width)
1360        .with_height(style.button_width)
1361}