contact_list.rs

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