contact_list.rs

   1use super::collab_titlebar_item::LeaveCall;
   2use crate::contacts_popover;
   3use call::ActiveCall;
   4use client::{proto::PeerId, Contact, User, UserStore};
   5use editor::{Cancel, Editor};
   6use futures::StreamExt;
   7use fuzzy::{match_strings, StringMatchCandidate};
   8use gpui::{
   9    elements::*,
  10    geometry::{rect::RectF, vector::vec2f},
  11    impl_actions, impl_internal_actions,
  12    keymap_matcher::KeymapContext,
  13    AppContext, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, PromptLevel,
  14    RenderContext, Subscription, View, ViewContext, ViewHandle,
  15};
  16use menu::{Confirm, SelectNext, SelectPrev};
  17use project::Project;
  18use serde::Deserialize;
  19use settings::Settings;
  20use std::{mem, sync::Arc};
  21use theme::IconButton;
  22use workspace::{JoinProject, OpenSharedScreen};
  23
  24impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
  25impl_internal_actions!(contact_list, [ToggleExpanded, Call]);
  26
  27pub fn init(cx: &mut MutableAppContext) {
  28    cx.add_action(ContactList::remove_contact);
  29    cx.add_action(ContactList::respond_to_contact_request);
  30    cx.add_action(ContactList::cancel);
  31    cx.add_action(ContactList::select_next);
  32    cx.add_action(ContactList::select_prev);
  33    cx.add_action(ContactList::confirm);
  34    cx.add_action(ContactList::toggle_expanded);
  35    cx.add_action(ContactList::call);
  36}
  37
  38#[derive(Clone, PartialEq)]
  39struct ToggleExpanded(Section);
  40
  41#[derive(Clone, PartialEq)]
  42struct Call {
  43    recipient_user_id: u64,
  44    initial_project: Option<ModelHandle<Project>>,
  45}
  46
  47#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
  48enum Section {
  49    ActiveCall,
  50    Requests,
  51    Online,
  52    Offline,
  53}
  54
  55#[derive(Clone)]
  56enum ContactEntry {
  57    Header(Section),
  58    CallParticipant {
  59        user: Arc<User>,
  60        is_pending: bool,
  61    },
  62    ParticipantProject {
  63        project_id: u64,
  64        worktree_root_names: Vec<String>,
  65        host_user_id: u64,
  66        is_last: bool,
  67    },
  68    ParticipantScreen {
  69        peer_id: PeerId,
  70        is_last: bool,
  71    },
  72    IncomingRequest(Arc<User>),
  73    OutgoingRequest(Arc<User>),
  74    Contact {
  75        contact: Arc<Contact>,
  76        calling: bool,
  77    },
  78}
  79
  80impl PartialEq for ContactEntry {
  81    fn eq(&self, other: &Self) -> bool {
  82        match self {
  83            ContactEntry::Header(section_1) => {
  84                if let ContactEntry::Header(section_2) = other {
  85                    return section_1 == section_2;
  86                }
  87            }
  88            ContactEntry::CallParticipant { user: user_1, .. } => {
  89                if let ContactEntry::CallParticipant { user: user_2, .. } = other {
  90                    return user_1.id == user_2.id;
  91                }
  92            }
  93            ContactEntry::ParticipantProject {
  94                project_id: project_id_1,
  95                ..
  96            } => {
  97                if let ContactEntry::ParticipantProject {
  98                    project_id: project_id_2,
  99                    ..
 100                } = other
 101                {
 102                    return project_id_1 == project_id_2;
 103                }
 104            }
 105            ContactEntry::ParticipantScreen {
 106                peer_id: peer_id_1, ..
 107            } => {
 108                if let ContactEntry::ParticipantScreen {
 109                    peer_id: peer_id_2, ..
 110                } = other
 111                {
 112                    return peer_id_1 == peer_id_2;
 113                }
 114            }
 115            ContactEntry::IncomingRequest(user_1) => {
 116                if let ContactEntry::IncomingRequest(user_2) = other {
 117                    return user_1.id == user_2.id;
 118                }
 119            }
 120            ContactEntry::OutgoingRequest(user_1) => {
 121                if let ContactEntry::OutgoingRequest(user_2) = other {
 122                    return user_1.id == user_2.id;
 123                }
 124            }
 125            ContactEntry::Contact {
 126                contact: contact_1, ..
 127            } => {
 128                if let ContactEntry::Contact {
 129                    contact: contact_2, ..
 130                } = other
 131                {
 132                    return contact_1.user.id == contact_2.user.id;
 133                }
 134            }
 135        }
 136        false
 137    }
 138}
 139
 140#[derive(Clone, Deserialize, PartialEq)]
 141pub struct RequestContact(pub u64);
 142
 143#[derive(Clone, Deserialize, PartialEq)]
 144pub struct RemoveContact {
 145    user_id: u64,
 146    github_login: String,
 147}
 148
 149#[derive(Clone, Deserialize, PartialEq)]
 150pub struct RespondToContactRequest {
 151    pub user_id: u64,
 152    pub accept: bool,
 153}
 154
 155pub enum Event {
 156    Dismissed,
 157}
 158
 159pub struct ContactList {
 160    entries: Vec<ContactEntry>,
 161    match_candidates: Vec<StringMatchCandidate>,
 162    list_state: ListState,
 163    project: ModelHandle<Project>,
 164    user_store: ModelHandle<UserStore>,
 165    filter_editor: ViewHandle<Editor>,
 166    collapsed_sections: Vec<Section>,
 167    selection: Option<usize>,
 168    _subscriptions: Vec<Subscription>,
 169}
 170
 171impl ContactList {
 172    pub fn new(
 173        project: ModelHandle<Project>,
 174        user_store: ModelHandle<UserStore>,
 175        cx: &mut ViewContext<Self>,
 176    ) -> Self {
 177        let filter_editor = cx.add_view(|cx| {
 178            let mut editor = Editor::single_line(
 179                Some(Arc::new(|theme| {
 180                    theme.contact_list.user_query_editor.clone()
 181                })),
 182                cx,
 183            );
 184            editor.set_placeholder_text("Filter contacts", cx);
 185            editor
 186        });
 187
 188        cx.subscribe(&filter_editor, |this, _, event, cx| {
 189            if let editor::Event::BufferEdited = event {
 190                let query = this.filter_editor.read(cx).text(cx);
 191                if !query.is_empty() {
 192                    this.selection.take();
 193                }
 194                this.update_entries(cx);
 195                if !query.is_empty() {
 196                    this.selection = this
 197                        .entries
 198                        .iter()
 199                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
 200                }
 201            }
 202        })
 203        .detach();
 204
 205        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
 206            let theme = cx.global::<Settings>().theme.clone();
 207            let is_selected = this.selection == Some(ix);
 208            let current_project_id = this.project.read(cx).remote_id();
 209
 210            match &this.entries[ix] {
 211                ContactEntry::Header(section) => {
 212                    let is_collapsed = this.collapsed_sections.contains(section);
 213                    Self::render_header(
 214                        *section,
 215                        &theme.contact_list,
 216                        is_selected,
 217                        is_collapsed,
 218                        cx,
 219                    )
 220                }
 221                ContactEntry::CallParticipant { user, is_pending } => {
 222                    Self::render_call_participant(
 223                        user,
 224                        *is_pending,
 225                        is_selected,
 226                        &theme.contact_list,
 227                    )
 228                }
 229                ContactEntry::ParticipantProject {
 230                    project_id,
 231                    worktree_root_names,
 232                    host_user_id,
 233                    is_last,
 234                } => Self::render_participant_project(
 235                    *project_id,
 236                    worktree_root_names,
 237                    *host_user_id,
 238                    Some(*project_id) == current_project_id,
 239                    *is_last,
 240                    is_selected,
 241                    &theme.contact_list,
 242                    cx,
 243                ),
 244                ContactEntry::ParticipantScreen { peer_id, is_last } => {
 245                    Self::render_participant_screen(
 246                        *peer_id,
 247                        *is_last,
 248                        is_selected,
 249                        &theme.contact_list,
 250                        cx,
 251                    )
 252                }
 253                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
 254                    user.clone(),
 255                    this.user_store.clone(),
 256                    &theme.contact_list,
 257                    true,
 258                    is_selected,
 259                    cx,
 260                ),
 261                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
 262                    user.clone(),
 263                    this.user_store.clone(),
 264                    &theme.contact_list,
 265                    false,
 266                    is_selected,
 267                    cx,
 268                ),
 269                ContactEntry::Contact { contact, calling } => Self::render_contact(
 270                    contact,
 271                    *calling,
 272                    &this.project,
 273                    &theme.contact_list,
 274                    is_selected,
 275                    cx,
 276                ),
 277            }
 278        });
 279
 280        let active_call = ActiveCall::global(cx);
 281        let mut subscriptions = Vec::new();
 282        subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
 283        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
 284
 285        let mut this = Self {
 286            list_state,
 287            selection: None,
 288            collapsed_sections: Default::default(),
 289            entries: Default::default(),
 290            match_candidates: Default::default(),
 291            filter_editor,
 292            _subscriptions: subscriptions,
 293            project,
 294            user_store,
 295        };
 296        this.update_entries(cx);
 297        this
 298    }
 299
 300    pub fn editor_text(&self, cx: &AppContext) -> String {
 301        self.filter_editor.read(cx).text(cx)
 302    }
 303
 304    pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext<Self>) -> Self {
 305        self.filter_editor
 306            .update(cx, |picker, cx| picker.set_text(editor_text, cx));
 307        self
 308    }
 309
 310    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
 311        let user_id = request.user_id;
 312        let github_login = &request.github_login;
 313        let user_store = self.user_store.clone();
 314        let prompt_message = format!(
 315            "Are you sure you want to remove \"{}\" from your contacts?",
 316            github_login
 317        );
 318        let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
 319        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    ) -> ElementBox {
 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                    .boxed()
 759            }))
 760            .with_child(
 761                Label::new(
 762                    user.github_login.clone(),
 763                    theme.contact_username.text.clone(),
 764                )
 765                .contained()
 766                .with_style(theme.contact_username.container)
 767                .aligned()
 768                .left()
 769                .flex(1., true)
 770                .boxed(),
 771            )
 772            .with_children(if is_pending {
 773                Some(
 774                    Label::new("Calling", theme.calling_indicator.text.clone())
 775                        .contained()
 776                        .with_style(theme.calling_indicator.container)
 777                        .aligned()
 778                        .boxed(),
 779                )
 780            } else {
 781                None
 782            })
 783            .constrained()
 784            .with_height(theme.row_height)
 785            .contained()
 786            .with_style(
 787                *theme
 788                    .contact_row
 789                    .style_for(&mut Default::default(), is_selected),
 790            )
 791            .boxed()
 792    }
 793
 794    fn render_participant_project(
 795        project_id: u64,
 796        worktree_root_names: &[String],
 797        host_user_id: u64,
 798        is_current: bool,
 799        is_last: bool,
 800        is_selected: bool,
 801        theme: &theme::ContactList,
 802        cx: &mut RenderContext<Self>,
 803    ) -> ElementBox {
 804        let font_cache = cx.font_cache();
 805        let host_avatar_height = theme
 806            .contact_avatar
 807            .width
 808            .or(theme.contact_avatar.height)
 809            .unwrap_or(0.);
 810        let row = &theme.project_row.default;
 811        let tree_branch = theme.tree_branch;
 812        let line_height = row.name.text.line_height(font_cache);
 813        let cap_height = row.name.text.cap_height(font_cache);
 814        let baseline_offset =
 815            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
 816        let project_name = if worktree_root_names.is_empty() {
 817            "untitled".to_string()
 818        } else {
 819            worktree_root_names.join(", ")
 820        };
 821
 822        MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, _| {
 823            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
 824            let row = theme.project_row.style_for(mouse_state, is_selected);
 825
 826            Flex::row()
 827                .with_child(
 828                    Stack::new()
 829                        .with_child(
 830                            Canvas::new(move |bounds, _, cx| {
 831                                let start_x = bounds.min_x() + (bounds.width() / 2.)
 832                                    - (tree_branch.width / 2.);
 833                                let end_x = bounds.max_x();
 834                                let start_y = bounds.min_y();
 835                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
 836
 837                                cx.scene.push_quad(gpui::Quad {
 838                                    bounds: RectF::from_points(
 839                                        vec2f(start_x, start_y),
 840                                        vec2f(
 841                                            start_x + tree_branch.width,
 842                                            if is_last { end_y } else { bounds.max_y() },
 843                                        ),
 844                                    ),
 845                                    background: Some(tree_branch.color),
 846                                    border: gpui::Border::default(),
 847                                    corner_radius: 0.,
 848                                });
 849                                cx.scene.push_quad(gpui::Quad {
 850                                    bounds: RectF::from_points(
 851                                        vec2f(start_x, end_y),
 852                                        vec2f(end_x, end_y + tree_branch.width),
 853                                    ),
 854                                    background: Some(tree_branch.color),
 855                                    border: gpui::Border::default(),
 856                                    corner_radius: 0.,
 857                                });
 858                            })
 859                            .boxed(),
 860                        )
 861                        .constrained()
 862                        .with_width(host_avatar_height)
 863                        .boxed(),
 864                )
 865                .with_child(
 866                    Label::new(project_name, row.name.text.clone())
 867                        .aligned()
 868                        .left()
 869                        .contained()
 870                        .with_style(row.name.container)
 871                        .flex(1., false)
 872                        .boxed(),
 873                )
 874                .constrained()
 875                .with_height(theme.row_height)
 876                .contained()
 877                .with_style(row.container)
 878                .boxed()
 879        })
 880        .with_cursor_style(if !is_current {
 881            CursorStyle::PointingHand
 882        } else {
 883            CursorStyle::Arrow
 884        })
 885        .on_click(MouseButton::Left, move |_, cx| {
 886            if !is_current {
 887                cx.dispatch_global_action(JoinProject {
 888                    project_id,
 889                    follow_user_id: host_user_id,
 890                });
 891            }
 892        })
 893        .boxed()
 894    }
 895
 896    fn render_participant_screen(
 897        peer_id: PeerId,
 898        is_last: bool,
 899        is_selected: bool,
 900        theme: &theme::ContactList,
 901        cx: &mut RenderContext<Self>,
 902    ) -> ElementBox {
 903        let font_cache = cx.font_cache();
 904        let host_avatar_height = theme
 905            .contact_avatar
 906            .width
 907            .or(theme.contact_avatar.height)
 908            .unwrap_or(0.);
 909        let row = &theme.project_row.default;
 910        let tree_branch = theme.tree_branch;
 911        let line_height = row.name.text.line_height(font_cache);
 912        let cap_height = row.name.text.cap_height(font_cache);
 913        let baseline_offset =
 914            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
 915
 916        MouseEventHandler::<OpenSharedScreen>::new(
 917            peer_id.as_u64() as usize,
 918            cx,
 919            |mouse_state, _| {
 920                let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
 921                let row = theme.project_row.style_for(mouse_state, is_selected);
 922
 923                Flex::row()
 924                    .with_child(
 925                        Stack::new()
 926                            .with_child(
 927                                Canvas::new(move |bounds, _, cx| {
 928                                    let start_x = bounds.min_x() + (bounds.width() / 2.)
 929                                        - (tree_branch.width / 2.);
 930                                    let end_x = bounds.max_x();
 931                                    let start_y = bounds.min_y();
 932                                    let end_y =
 933                                        bounds.min_y() + baseline_offset - (cap_height / 2.);
 934
 935                                    cx.scene.push_quad(gpui::Quad {
 936                                        bounds: RectF::from_points(
 937                                            vec2f(start_x, start_y),
 938                                            vec2f(
 939                                                start_x + tree_branch.width,
 940                                                if is_last { end_y } else { bounds.max_y() },
 941                                            ),
 942                                        ),
 943                                        background: Some(tree_branch.color),
 944                                        border: gpui::Border::default(),
 945                                        corner_radius: 0.,
 946                                    });
 947                                    cx.scene.push_quad(gpui::Quad {
 948                                        bounds: RectF::from_points(
 949                                            vec2f(start_x, end_y),
 950                                            vec2f(end_x, end_y + tree_branch.width),
 951                                        ),
 952                                        background: Some(tree_branch.color),
 953                                        border: gpui::Border::default(),
 954                                        corner_radius: 0.,
 955                                    });
 956                                })
 957                                .boxed(),
 958                            )
 959                            .constrained()
 960                            .with_width(host_avatar_height)
 961                            .boxed(),
 962                    )
 963                    .with_child(
 964                        Svg::new("icons/disable_screen_sharing_12.svg")
 965                            .with_color(row.icon.color)
 966                            .constrained()
 967                            .with_width(row.icon.width)
 968                            .aligned()
 969                            .left()
 970                            .contained()
 971                            .with_style(row.icon.container)
 972                            .boxed(),
 973                    )
 974                    .with_child(
 975                        Label::new("Screen", row.name.text.clone())
 976                            .aligned()
 977                            .left()
 978                            .contained()
 979                            .with_style(row.name.container)
 980                            .flex(1., false)
 981                            .boxed(),
 982                    )
 983                    .constrained()
 984                    .with_height(theme.row_height)
 985                    .contained()
 986                    .with_style(row.container)
 987                    .boxed()
 988            },
 989        )
 990        .with_cursor_style(CursorStyle::PointingHand)
 991        .on_click(MouseButton::Left, move |_, cx| {
 992            cx.dispatch_action(OpenSharedScreen { peer_id });
 993        })
 994        .boxed()
 995    }
 996
 997    fn render_header(
 998        section: Section,
 999        theme: &theme::ContactList,
1000        is_selected: bool,
1001        is_collapsed: bool,
1002        cx: &mut RenderContext<Self>,
1003    ) -> ElementBox {
1004        enum Header {}
1005        enum LeaveCallContactList {}
1006
1007        let header_style = theme
1008            .header_row
1009            .style_for(&mut Default::default(), is_selected);
1010        let text = match section {
1011            Section::ActiveCall => "Collaborators",
1012            Section::Requests => "Contact Requests",
1013            Section::Online => "Online",
1014            Section::Offline => "Offline",
1015        };
1016        let leave_call = if section == Section::ActiveCall {
1017            Some(
1018                MouseEventHandler::<LeaveCallContactList>::new(0, cx, |state, _| {
1019                    let style = theme.leave_call.style_for(state, false);
1020                    Label::new("Leave Call", style.text.clone())
1021                        .contained()
1022                        .with_style(style.container)
1023                        .boxed()
1024                })
1025                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
1026                .aligned()
1027                .boxed(),
1028            )
1029        } else {
1030            None
1031        };
1032
1033        let icon_size = theme.section_icon_size;
1034        MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
1035            Flex::row()
1036                .with_child(
1037                    Svg::new(if is_collapsed {
1038                        "icons/chevron_right_8.svg"
1039                    } else {
1040                        "icons/chevron_down_8.svg"
1041                    })
1042                    .with_color(header_style.text.color)
1043                    .constrained()
1044                    .with_max_width(icon_size)
1045                    .with_max_height(icon_size)
1046                    .aligned()
1047                    .constrained()
1048                    .with_width(icon_size)
1049                    .boxed(),
1050                )
1051                .with_child(
1052                    Label::new(text, header_style.text.clone())
1053                        .aligned()
1054                        .left()
1055                        .contained()
1056                        .with_margin_left(theme.contact_username.container.margin.left)
1057                        .flex(1., true)
1058                        .boxed(),
1059                )
1060                .with_children(leave_call)
1061                .constrained()
1062                .with_height(theme.row_height)
1063                .contained()
1064                .with_style(header_style.container)
1065                .boxed()
1066        })
1067        .with_cursor_style(CursorStyle::PointingHand)
1068        .on_click(MouseButton::Left, move |_, cx| {
1069            cx.dispatch_action(ToggleExpanded(section))
1070        })
1071        .boxed()
1072    }
1073
1074    fn render_contact(
1075        contact: &Contact,
1076        calling: bool,
1077        project: &ModelHandle<Project>,
1078        theme: &theme::ContactList,
1079        is_selected: bool,
1080        cx: &mut RenderContext<Self>,
1081    ) -> ElementBox {
1082        let online = contact.online;
1083        let busy = contact.busy || calling;
1084        let user_id = contact.user.id;
1085        let github_login = contact.user.github_login.clone();
1086        let initial_project = project.clone();
1087        let mut element =
1088            MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, cx| {
1089                Flex::row()
1090                    .with_children(contact.user.avatar.clone().map(|avatar| {
1091                        let status_badge = if contact.online {
1092                            Some(
1093                                Empty::new()
1094                                    .collapsed()
1095                                    .contained()
1096                                    .with_style(if busy {
1097                                        theme.contact_status_busy
1098                                    } else {
1099                                        theme.contact_status_free
1100                                    })
1101                                    .aligned()
1102                                    .boxed(),
1103                            )
1104                        } else {
1105                            None
1106                        };
1107                        Stack::new()
1108                            .with_child(
1109                                Image::from_data(avatar)
1110                                    .with_style(theme.contact_avatar)
1111                                    .aligned()
1112                                    .left()
1113                                    .boxed(),
1114                            )
1115                            .with_children(status_badge)
1116                            .boxed()
1117                    }))
1118                    .with_child(
1119                        Label::new(
1120                            contact.user.github_login.clone(),
1121                            theme.contact_username.text.clone(),
1122                        )
1123                        .contained()
1124                        .with_style(theme.contact_username.container)
1125                        .aligned()
1126                        .left()
1127                        .flex(1., true)
1128                        .boxed(),
1129                    )
1130                    .with_child(
1131                        MouseEventHandler::<Cancel>::new(
1132                            contact.user.id as usize,
1133                            cx,
1134                            |mouse_state, _| {
1135                                let button_style =
1136                                    theme.contact_button.style_for(mouse_state, false);
1137                                render_icon_button(button_style, "icons/x_mark_8.svg")
1138                                    .aligned()
1139                                    .flex_float()
1140                                    .boxed()
1141                            },
1142                        )
1143                        .with_padding(Padding::uniform(2.))
1144                        .with_cursor_style(CursorStyle::PointingHand)
1145                        .on_click(MouseButton::Left, move |_, cx| {
1146                            cx.dispatch_action(RemoveContact {
1147                                user_id,
1148                                github_login: github_login.clone(),
1149                            })
1150                        })
1151                        .flex_float()
1152                        .boxed(),
1153                    )
1154                    .with_children(if calling {
1155                        Some(
1156                            Label::new("Calling", theme.calling_indicator.text.clone())
1157                                .contained()
1158                                .with_style(theme.calling_indicator.container)
1159                                .aligned()
1160                                .boxed(),
1161                        )
1162                    } else {
1163                        None
1164                    })
1165                    .constrained()
1166                    .with_height(theme.row_height)
1167                    .contained()
1168                    .with_style(
1169                        *theme
1170                            .contact_row
1171                            .style_for(&mut Default::default(), is_selected),
1172                    )
1173                    .boxed()
1174            })
1175            .on_click(MouseButton::Left, move |_, cx| {
1176                if online && !busy {
1177                    cx.dispatch_action(Call {
1178                        recipient_user_id: user_id,
1179                        initial_project: Some(initial_project.clone()),
1180                    });
1181                }
1182            });
1183
1184        if online {
1185            element = element.with_cursor_style(CursorStyle::PointingHand);
1186        }
1187
1188        element.boxed()
1189    }
1190
1191    fn render_contact_request(
1192        user: Arc<User>,
1193        user_store: ModelHandle<UserStore>,
1194        theme: &theme::ContactList,
1195        is_incoming: bool,
1196        is_selected: bool,
1197        cx: &mut RenderContext<Self>,
1198    ) -> ElementBox {
1199        enum Decline {}
1200        enum Accept {}
1201        enum Cancel {}
1202
1203        let mut row = Flex::row()
1204            .with_children(user.avatar.clone().map(|avatar| {
1205                Image::from_data(avatar)
1206                    .with_style(theme.contact_avatar)
1207                    .aligned()
1208                    .left()
1209                    .boxed()
1210            }))
1211            .with_child(
1212                Label::new(
1213                    user.github_login.clone(),
1214                    theme.contact_username.text.clone(),
1215                )
1216                .contained()
1217                .with_style(theme.contact_username.container)
1218                .aligned()
1219                .left()
1220                .flex(1., true)
1221                .boxed(),
1222            );
1223
1224        let user_id = user.id;
1225        let github_login = user.github_login.clone();
1226        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1227        let button_spacing = theme.contact_button_spacing;
1228
1229        if is_incoming {
1230            row.add_children([
1231                MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
1232                    let button_style = if is_contact_request_pending {
1233                        &theme.disabled_button
1234                    } else {
1235                        theme.contact_button.style_for(mouse_state, false)
1236                    };
1237                    render_icon_button(button_style, "icons/x_mark_8.svg")
1238                        .aligned()
1239                        .boxed()
1240                })
1241                .with_cursor_style(CursorStyle::PointingHand)
1242                .on_click(MouseButton::Left, move |_, cx| {
1243                    cx.dispatch_action(RespondToContactRequest {
1244                        user_id,
1245                        accept: false,
1246                    })
1247                })
1248                .contained()
1249                .with_margin_right(button_spacing)
1250                .boxed(),
1251                MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
1252                    let button_style = if is_contact_request_pending {
1253                        &theme.disabled_button
1254                    } else {
1255                        theme.contact_button.style_for(mouse_state, false)
1256                    };
1257                    render_icon_button(button_style, "icons/check_8.svg")
1258                        .aligned()
1259                        .flex_float()
1260                        .boxed()
1261                })
1262                .with_cursor_style(CursorStyle::PointingHand)
1263                .on_click(MouseButton::Left, move |_, cx| {
1264                    cx.dispatch_action(RespondToContactRequest {
1265                        user_id,
1266                        accept: true,
1267                    })
1268                })
1269                .boxed(),
1270            ]);
1271        } else {
1272            row.add_child(
1273                MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
1274                    let button_style = if is_contact_request_pending {
1275                        &theme.disabled_button
1276                    } else {
1277                        theme.contact_button.style_for(mouse_state, false)
1278                    };
1279                    render_icon_button(button_style, "icons/x_mark_8.svg")
1280                        .aligned()
1281                        .flex_float()
1282                        .boxed()
1283                })
1284                .with_padding(Padding::uniform(2.))
1285                .with_cursor_style(CursorStyle::PointingHand)
1286                .on_click(MouseButton::Left, move |_, cx| {
1287                    cx.dispatch_action(RemoveContact {
1288                        user_id,
1289                        github_login: github_login.clone(),
1290                    })
1291                })
1292                .flex_float()
1293                .boxed(),
1294            );
1295        }
1296
1297        row.constrained()
1298            .with_height(theme.row_height)
1299            .contained()
1300            .with_style(
1301                *theme
1302                    .contact_row
1303                    .style_for(&mut Default::default(), is_selected),
1304            )
1305            .boxed()
1306    }
1307
1308    fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
1309        let recipient_user_id = action.recipient_user_id;
1310        let initial_project = action.initial_project.clone();
1311        ActiveCall::global(cx)
1312            .update(cx, |call, cx| {
1313                call.invite(recipient_user_id, initial_project, cx)
1314            })
1315            .detach_and_log_err(cx);
1316    }
1317}
1318
1319impl Entity for ContactList {
1320    type Event = Event;
1321}
1322
1323impl View for ContactList {
1324    fn ui_name() -> &'static str {
1325        "ContactList"
1326    }
1327
1328    fn keymap_context(&self, _: &AppContext) -> KeymapContext {
1329        let mut cx = Self::default_keymap_context();
1330        cx.add_identifier("menu");
1331        cx
1332    }
1333
1334    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
1335        enum AddContact {}
1336        let theme = cx.global::<Settings>().theme.clone();
1337
1338        Flex::column()
1339            .with_child(
1340                Flex::row()
1341                    .with_child(
1342                        ChildView::new(self.filter_editor.clone(), cx)
1343                            .contained()
1344                            .with_style(theme.contact_list.user_query_editor.container)
1345                            .flex(1., true)
1346                            .boxed(),
1347                    )
1348                    .with_child(
1349                        MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
1350                            render_icon_button(
1351                                &theme.contact_list.add_contact_button,
1352                                "icons/user_plus_16.svg",
1353                            )
1354                            .boxed()
1355                        })
1356                        .with_cursor_style(CursorStyle::PointingHand)
1357                        .on_click(MouseButton::Left, |_, cx| {
1358                            cx.dispatch_action(contacts_popover::ToggleContactFinder)
1359                        })
1360                        .with_tooltip::<AddContact, _>(
1361                            0,
1362                            "Search for new contact".into(),
1363                            None,
1364                            theme.tooltip.clone(),
1365                            cx,
1366                        )
1367                        .boxed(),
1368                    )
1369                    .constrained()
1370                    .with_height(theme.contact_list.user_query_editor_height)
1371                    .boxed(),
1372            )
1373            .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
1374            .boxed()
1375    }
1376
1377    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1378        if !self.filter_editor.is_focused(cx) {
1379            cx.focus(&self.filter_editor);
1380        }
1381    }
1382
1383    fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1384        if !self.filter_editor.is_focused(cx) {
1385            cx.emit(Event::Dismissed);
1386        }
1387    }
1388}
1389
1390fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
1391    Svg::new(svg_path)
1392        .with_color(style.color)
1393        .constrained()
1394        .with_width(style.icon_width)
1395        .aligned()
1396        .contained()
1397        .with_style(style.container)
1398        .constrained()
1399        .with_width(style.button_width)
1400        .with_height(style.button_width)
1401}