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