contact_list.rs

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