contact_list.rs

   1use std::{mem, sync::Arc};
   2
   3use crate::contacts_popover;
   4use call::ActiveCall;
   5use client::{proto::PeerId, Contact, 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(Arc::new(|theme| {
 179                    theme.contact_list.user_query_editor.clone()
 180                })),
 181                cx,
 182            );
 183            editor.set_placeholder_text("Filter contacts", cx);
 184            editor
 185        });
 186
 187        cx.subscribe(&filter_editor, |this, _, event, cx| {
 188            if let editor::Event::BufferEdited = event {
 189                let query = this.filter_editor.read(cx).text(cx);
 190                if !query.is_empty() {
 191                    this.selection.take();
 192                }
 193                this.update_entries(cx);
 194                if !query.is_empty() {
 195                    this.selection = this
 196                        .entries
 197                        .iter()
 198                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
 199                }
 200            }
 201        })
 202        .detach();
 203
 204        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
 205            let theme = cx.global::<Settings>().theme.clone();
 206            let is_selected = this.selection == Some(ix);
 207            let current_project_id = this.project.read(cx).remote_id();
 208
 209            match &this.entries[ix] {
 210                ContactEntry::Header(section) => {
 211                    let is_collapsed = this.collapsed_sections.contains(section);
 212                    Self::render_header(
 213                        *section,
 214                        &theme.contact_list,
 215                        is_selected,
 216                        is_collapsed,
 217                        cx,
 218                    )
 219                }
 220                ContactEntry::CallParticipant { user, is_pending } => {
 221                    Self::render_call_participant(
 222                        user,
 223                        *is_pending,
 224                        is_selected,
 225                        &theme.contact_list,
 226                    )
 227                }
 228                ContactEntry::ParticipantProject {
 229                    project_id,
 230                    worktree_root_names,
 231                    host_user_id,
 232                    is_last,
 233                } => Self::render_participant_project(
 234                    *project_id,
 235                    worktree_root_names,
 236                    *host_user_id,
 237                    Some(*project_id) == current_project_id,
 238                    *is_last,
 239                    is_selected,
 240                    &theme.contact_list,
 241                    cx,
 242                ),
 243                ContactEntry::ParticipantScreen { peer_id, is_last } => {
 244                    Self::render_participant_screen(
 245                        *peer_id,
 246                        *is_last,
 247                        is_selected,
 248                        &theme.contact_list,
 249                        cx,
 250                    )
 251                }
 252                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
 253                    user.clone(),
 254                    this.user_store.clone(),
 255                    &theme.contact_list,
 256                    true,
 257                    is_selected,
 258                    cx,
 259                ),
 260                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
 261                    user.clone(),
 262                    this.user_store.clone(),
 263                    &theme.contact_list,
 264                    false,
 265                    is_selected,
 266                    cx,
 267                ),
 268                ContactEntry::Contact { contact, calling } => Self::render_contact(
 269                    contact,
 270                    *calling,
 271                    &this.project,
 272                    &theme.contact_list,
 273                    is_selected,
 274                    cx,
 275                ),
 276            }
 277        });
 278
 279        let active_call = ActiveCall::global(cx);
 280        let mut subscriptions = Vec::new();
 281        subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
 282        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
 283
 284        let mut this = Self {
 285            list_state,
 286            selection: None,
 287            collapsed_sections: Default::default(),
 288            entries: Default::default(),
 289            match_candidates: Default::default(),
 290            filter_editor,
 291            _subscriptions: subscriptions,
 292            project,
 293            user_store,
 294        };
 295        this.update_entries(cx);
 296        this
 297    }
 298
 299    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
 300        self.user_store
 301            .update(cx, |store, cx| store.remove_contact(request.0, cx))
 302            .detach();
 303    }
 304
 305    fn respond_to_contact_request(
 306        &mut self,
 307        action: &RespondToContactRequest,
 308        cx: &mut ViewContext<Self>,
 309    ) {
 310        self.user_store
 311            .update(cx, |store, cx| {
 312                store.respond_to_contact_request(action.user_id, action.accept, cx)
 313            })
 314            .detach();
 315    }
 316
 317    fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 318        let did_clear = self.filter_editor.update(cx, |editor, cx| {
 319            if editor.buffer().read(cx).len(cx) > 0 {
 320                editor.set_text("", cx);
 321                true
 322            } else {
 323                false
 324            }
 325        });
 326        if !did_clear {
 327            cx.emit(Event::Dismissed);
 328        }
 329    }
 330
 331    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 332        if let Some(ix) = self.selection {
 333            if self.entries.len() > ix + 1 {
 334                self.selection = Some(ix + 1);
 335            }
 336        } else if !self.entries.is_empty() {
 337            self.selection = Some(0);
 338        }
 339        self.list_state.reset(self.entries.len());
 340        if let Some(ix) = self.selection {
 341            self.list_state.scroll_to(ListOffset {
 342                item_ix: ix,
 343                offset_in_item: 0.,
 344            });
 345        }
 346        cx.notify();
 347    }
 348
 349    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 350        if let Some(ix) = self.selection {
 351            if ix > 0 {
 352                self.selection = Some(ix - 1);
 353            } else {
 354                self.selection = None;
 355            }
 356        }
 357        self.list_state.reset(self.entries.len());
 358        if let Some(ix) = self.selection {
 359            self.list_state.scroll_to(ListOffset {
 360                item_ix: ix,
 361                offset_in_item: 0.,
 362            });
 363        }
 364        cx.notify();
 365    }
 366
 367    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 368        if let Some(selection) = self.selection {
 369            if let Some(entry) = self.entries.get(selection) {
 370                match entry {
 371                    ContactEntry::Header(section) => {
 372                        let section = *section;
 373                        self.toggle_expanded(&ToggleExpanded(section), cx);
 374                    }
 375                    ContactEntry::Contact { contact, calling } => {
 376                        if contact.online && !contact.busy && !calling {
 377                            self.call(
 378                                &Call {
 379                                    recipient_user_id: contact.user.id,
 380                                    initial_project: Some(self.project.clone()),
 381                                },
 382                                cx,
 383                            );
 384                        }
 385                    }
 386                    ContactEntry::ParticipantProject {
 387                        project_id,
 388                        host_user_id,
 389                        ..
 390                    } => {
 391                        cx.dispatch_global_action(JoinProject {
 392                            project_id: *project_id,
 393                            follow_user_id: *host_user_id,
 394                        });
 395                    }
 396                    ContactEntry::ParticipantScreen { peer_id, .. } => {
 397                        cx.dispatch_action(OpenSharedScreen { peer_id: *peer_id });
 398                    }
 399                    _ => {}
 400                }
 401            }
 402        }
 403    }
 404
 405    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
 406        let section = action.0;
 407        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
 408            self.collapsed_sections.remove(ix);
 409        } else {
 410            self.collapsed_sections.push(section);
 411        }
 412        self.update_entries(cx);
 413    }
 414
 415    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
 416        let user_store = self.user_store.read(cx);
 417        let query = self.filter_editor.read(cx).text(cx);
 418        let executor = cx.background().clone();
 419
 420        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
 421        let old_entries = mem::take(&mut self.entries);
 422
 423        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
 424            let room = room.read(cx);
 425            let mut participant_entries = Vec::new();
 426
 427            // Populate the active user.
 428            if let Some(user) = user_store.current_user() {
 429                self.match_candidates.clear();
 430                self.match_candidates.push(StringMatchCandidate {
 431                    id: 0,
 432                    string: user.github_login.clone(),
 433                    char_bag: user.github_login.chars().collect(),
 434                });
 435                let matches = executor.block(match_strings(
 436                    &self.match_candidates,
 437                    &query,
 438                    true,
 439                    usize::MAX,
 440                    &Default::default(),
 441                    executor.clone(),
 442                ));
 443                if !matches.is_empty() {
 444                    let user_id = user.id;
 445                    participant_entries.push(ContactEntry::CallParticipant {
 446                        user,
 447                        is_pending: false,
 448                    });
 449                    let mut projects = room.local_participant().projects.iter().peekable();
 450                    while let Some(project) = projects.next() {
 451                        participant_entries.push(ContactEntry::ParticipantProject {
 452                            project_id: project.id,
 453                            worktree_root_names: project.worktree_root_names.clone(),
 454                            host_user_id: user_id,
 455                            is_last: projects.peek().is_none(),
 456                        });
 457                    }
 458                }
 459            }
 460
 461            // Populate remote participants.
 462            self.match_candidates.clear();
 463            self.match_candidates
 464                .extend(room.remote_participants().iter().map(|(_, participant)| {
 465                    StringMatchCandidate {
 466                        id: participant.user.id 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 user_id = mat.candidate_id as u64;
 481                let participant = &room.remote_participants()[&user_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: participant.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(
 883            peer_id.as_u64() as usize,
 884            cx,
 885            |mouse_state, _| {
 886                let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
 887                let row = theme.project_row.style_for(mouse_state, is_selected);
 888
 889                Flex::row()
 890                    .with_child(
 891                        Stack::new()
 892                            .with_child(
 893                                Canvas::new(move |bounds, _, cx| {
 894                                    let start_x = bounds.min_x() + (bounds.width() / 2.)
 895                                        - (tree_branch.width / 2.);
 896                                    let end_x = bounds.max_x();
 897                                    let start_y = bounds.min_y();
 898                                    let end_y =
 899                                        bounds.min_y() + baseline_offset - (cap_height / 2.);
 900
 901                                    cx.scene.push_quad(gpui::Quad {
 902                                        bounds: RectF::from_points(
 903                                            vec2f(start_x, start_y),
 904                                            vec2f(
 905                                                start_x + tree_branch.width,
 906                                                if is_last { end_y } else { bounds.max_y() },
 907                                            ),
 908                                        ),
 909                                        background: Some(tree_branch.color),
 910                                        border: gpui::Border::default(),
 911                                        corner_radius: 0.,
 912                                    });
 913                                    cx.scene.push_quad(gpui::Quad {
 914                                        bounds: RectF::from_points(
 915                                            vec2f(start_x, end_y),
 916                                            vec2f(end_x, end_y + tree_branch.width),
 917                                        ),
 918                                        background: Some(tree_branch.color),
 919                                        border: gpui::Border::default(),
 920                                        corner_radius: 0.,
 921                                    });
 922                                })
 923                                .boxed(),
 924                            )
 925                            .constrained()
 926                            .with_width(host_avatar_height)
 927                            .boxed(),
 928                    )
 929                    .with_child(
 930                        Svg::new("icons/disable_screen_sharing_12.svg")
 931                            .with_color(row.icon.color)
 932                            .constrained()
 933                            .with_width(row.icon.width)
 934                            .aligned()
 935                            .left()
 936                            .contained()
 937                            .with_style(row.icon.container)
 938                            .boxed(),
 939                    )
 940                    .with_child(
 941                        Label::new("Screen".into(), row.name.text.clone())
 942                            .aligned()
 943                            .left()
 944                            .contained()
 945                            .with_style(row.name.container)
 946                            .flex(1., false)
 947                            .boxed(),
 948                    )
 949                    .constrained()
 950                    .with_height(theme.row_height)
 951                    .contained()
 952                    .with_style(row.container)
 953                    .boxed()
 954            },
 955        )
 956        .with_cursor_style(CursorStyle::PointingHand)
 957        .on_click(MouseButton::Left, move |_, cx| {
 958            cx.dispatch_action(OpenSharedScreen { peer_id });
 959        })
 960        .boxed()
 961    }
 962
 963    fn render_header(
 964        section: Section,
 965        theme: &theme::ContactList,
 966        is_selected: bool,
 967        is_collapsed: bool,
 968        cx: &mut RenderContext<Self>,
 969    ) -> ElementBox {
 970        enum Header {}
 971
 972        let header_style = theme
 973            .header_row
 974            .style_for(&mut Default::default(), is_selected);
 975        let text = match section {
 976            Section::ActiveCall => "Collaborators",
 977            Section::Requests => "Contact Requests",
 978            Section::Online => "Online",
 979            Section::Offline => "Offline",
 980        };
 981        let leave_call = if section == Section::ActiveCall {
 982            Some(
 983                MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
 984                    let style = theme.leave_call.style_for(state, false);
 985                    Label::new("Leave Session".into(), style.text.clone())
 986                        .contained()
 987                        .with_style(style.container)
 988                        .boxed()
 989                })
 990                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
 991                .aligned()
 992                .boxed(),
 993            )
 994        } else {
 995            None
 996        };
 997
 998        let icon_size = theme.section_icon_size;
 999        MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
1000            Flex::row()
1001                .with_child(
1002                    Svg::new(if is_collapsed {
1003                        "icons/chevron_right_8.svg"
1004                    } else {
1005                        "icons/chevron_down_8.svg"
1006                    })
1007                    .with_color(header_style.text.color)
1008                    .constrained()
1009                    .with_max_width(icon_size)
1010                    .with_max_height(icon_size)
1011                    .aligned()
1012                    .constrained()
1013                    .with_width(icon_size)
1014                    .boxed(),
1015                )
1016                .with_child(
1017                    Label::new(text.to_string(), header_style.text.clone())
1018                        .aligned()
1019                        .left()
1020                        .contained()
1021                        .with_margin_left(theme.contact_username.container.margin.left)
1022                        .flex(1., true)
1023                        .boxed(),
1024                )
1025                .with_children(leave_call)
1026                .constrained()
1027                .with_height(theme.row_height)
1028                .contained()
1029                .with_style(header_style.container)
1030                .boxed()
1031        })
1032        .with_cursor_style(CursorStyle::PointingHand)
1033        .on_click(MouseButton::Left, move |_, cx| {
1034            cx.dispatch_action(ToggleExpanded(section))
1035        })
1036        .boxed()
1037    }
1038
1039    fn render_contact(
1040        contact: &Contact,
1041        calling: bool,
1042        project: &ModelHandle<Project>,
1043        theme: &theme::ContactList,
1044        is_selected: bool,
1045        cx: &mut RenderContext<Self>,
1046    ) -> ElementBox {
1047        let online = contact.online;
1048        let busy = contact.busy || calling;
1049        let user_id = contact.user.id;
1050        let initial_project = project.clone();
1051        let mut element =
1052            MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
1053                Flex::row()
1054                    .with_children(contact.user.avatar.clone().map(|avatar| {
1055                        let status_badge = if contact.online {
1056                            Some(
1057                                Empty::new()
1058                                    .collapsed()
1059                                    .contained()
1060                                    .with_style(if busy {
1061                                        theme.contact_status_busy
1062                                    } else {
1063                                        theme.contact_status_free
1064                                    })
1065                                    .aligned()
1066                                    .boxed(),
1067                            )
1068                        } else {
1069                            None
1070                        };
1071                        Stack::new()
1072                            .with_child(
1073                                Image::new(avatar)
1074                                    .with_style(theme.contact_avatar)
1075                                    .aligned()
1076                                    .left()
1077                                    .boxed(),
1078                            )
1079                            .with_children(status_badge)
1080                            .boxed()
1081                    }))
1082                    .with_child(
1083                        Label::new(
1084                            contact.user.github_login.clone(),
1085                            theme.contact_username.text.clone(),
1086                        )
1087                        .contained()
1088                        .with_style(theme.contact_username.container)
1089                        .aligned()
1090                        .left()
1091                        .flex(1., true)
1092                        .boxed(),
1093                    )
1094                    .with_children(if calling {
1095                        Some(
1096                            Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
1097                                .contained()
1098                                .with_style(theme.calling_indicator.container)
1099                                .aligned()
1100                                .boxed(),
1101                        )
1102                    } else {
1103                        None
1104                    })
1105                    .constrained()
1106                    .with_height(theme.row_height)
1107                    .contained()
1108                    .with_style(
1109                        *theme
1110                            .contact_row
1111                            .style_for(&mut Default::default(), is_selected),
1112                    )
1113                    .boxed()
1114            })
1115            .on_click(MouseButton::Left, move |_, cx| {
1116                if online && !busy {
1117                    cx.dispatch_action(Call {
1118                        recipient_user_id: user_id,
1119                        initial_project: Some(initial_project.clone()),
1120                    });
1121                }
1122            });
1123
1124        if online {
1125            element = element.with_cursor_style(CursorStyle::PointingHand);
1126        }
1127
1128        element.boxed()
1129    }
1130
1131    fn render_contact_request(
1132        user: Arc<User>,
1133        user_store: ModelHandle<UserStore>,
1134        theme: &theme::ContactList,
1135        is_incoming: bool,
1136        is_selected: bool,
1137        cx: &mut RenderContext<Self>,
1138    ) -> ElementBox {
1139        enum Decline {}
1140        enum Accept {}
1141        enum Cancel {}
1142
1143        let mut row = Flex::row()
1144            .with_children(user.avatar.clone().map(|avatar| {
1145                Image::new(avatar)
1146                    .with_style(theme.contact_avatar)
1147                    .aligned()
1148                    .left()
1149                    .boxed()
1150            }))
1151            .with_child(
1152                Label::new(
1153                    user.github_login.clone(),
1154                    theme.contact_username.text.clone(),
1155                )
1156                .contained()
1157                .with_style(theme.contact_username.container)
1158                .aligned()
1159                .left()
1160                .flex(1., true)
1161                .boxed(),
1162            );
1163
1164        let user_id = user.id;
1165        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1166        let button_spacing = theme.contact_button_spacing;
1167
1168        if is_incoming {
1169            row.add_children([
1170                MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
1171                    let button_style = if is_contact_request_pending {
1172                        &theme.disabled_button
1173                    } else {
1174                        theme.contact_button.style_for(mouse_state, false)
1175                    };
1176                    render_icon_button(button_style, "icons/x_mark_8.svg")
1177                        .aligned()
1178                        .boxed()
1179                })
1180                .with_cursor_style(CursorStyle::PointingHand)
1181                .on_click(MouseButton::Left, move |_, cx| {
1182                    cx.dispatch_action(RespondToContactRequest {
1183                        user_id,
1184                        accept: false,
1185                    })
1186                })
1187                .contained()
1188                .with_margin_right(button_spacing)
1189                .boxed(),
1190                MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
1191                    let button_style = if is_contact_request_pending {
1192                        &theme.disabled_button
1193                    } else {
1194                        theme.contact_button.style_for(mouse_state, false)
1195                    };
1196                    render_icon_button(button_style, "icons/check_8.svg")
1197                        .aligned()
1198                        .flex_float()
1199                        .boxed()
1200                })
1201                .with_cursor_style(CursorStyle::PointingHand)
1202                .on_click(MouseButton::Left, move |_, cx| {
1203                    cx.dispatch_action(RespondToContactRequest {
1204                        user_id,
1205                        accept: true,
1206                    })
1207                })
1208                .boxed(),
1209            ]);
1210        } else {
1211            row.add_child(
1212                MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
1213                    let button_style = if is_contact_request_pending {
1214                        &theme.disabled_button
1215                    } else {
1216                        theme.contact_button.style_for(mouse_state, false)
1217                    };
1218                    render_icon_button(button_style, "icons/x_mark_8.svg")
1219                        .aligned()
1220                        .flex_float()
1221                        .boxed()
1222                })
1223                .with_padding(Padding::uniform(2.))
1224                .with_cursor_style(CursorStyle::PointingHand)
1225                .on_click(MouseButton::Left, move |_, cx| {
1226                    cx.dispatch_action(RemoveContact(user_id))
1227                })
1228                .flex_float()
1229                .boxed(),
1230            );
1231        }
1232
1233        row.constrained()
1234            .with_height(theme.row_height)
1235            .contained()
1236            .with_style(
1237                *theme
1238                    .contact_row
1239                    .style_for(&mut Default::default(), is_selected),
1240            )
1241            .boxed()
1242    }
1243
1244    fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
1245        let recipient_user_id = action.recipient_user_id;
1246        let initial_project = action.initial_project.clone();
1247        ActiveCall::global(cx)
1248            .update(cx, |call, cx| {
1249                call.invite(recipient_user_id, initial_project, cx)
1250            })
1251            .detach_and_log_err(cx);
1252    }
1253
1254    fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
1255        ActiveCall::global(cx)
1256            .update(cx, |call, cx| call.hang_up(cx))
1257            .log_err();
1258    }
1259}
1260
1261impl Entity for ContactList {
1262    type Event = Event;
1263}
1264
1265impl View for ContactList {
1266    fn ui_name() -> &'static str {
1267        "ContactList"
1268    }
1269
1270    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
1271        let mut cx = Self::default_keymap_context();
1272        cx.set.insert("menu".into());
1273        cx
1274    }
1275
1276    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
1277        enum AddContact {}
1278        let theme = cx.global::<Settings>().theme.clone();
1279
1280        Flex::column()
1281            .with_child(
1282                Flex::row()
1283                    .with_child(
1284                        ChildView::new(self.filter_editor.clone(), cx)
1285                            .contained()
1286                            .with_style(theme.contact_list.user_query_editor.container)
1287                            .flex(1., true)
1288                            .boxed(),
1289                    )
1290                    .with_child(
1291                        MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
1292                            render_icon_button(
1293                                &theme.contact_list.add_contact_button,
1294                                "icons/user_plus_16.svg",
1295                            )
1296                            .boxed()
1297                        })
1298                        .with_cursor_style(CursorStyle::PointingHand)
1299                        .on_click(MouseButton::Left, |_, cx| {
1300                            cx.dispatch_action(contacts_popover::ToggleContactFinder)
1301                        })
1302                        .with_tooltip::<AddContact, _>(
1303                            0,
1304                            "Add contact".into(),
1305                            None,
1306                            theme.tooltip.clone(),
1307                            cx,
1308                        )
1309                        .boxed(),
1310                    )
1311                    .constrained()
1312                    .with_height(theme.contact_list.user_query_editor_height)
1313                    .boxed(),
1314            )
1315            .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
1316            .boxed()
1317    }
1318
1319    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1320        if !self.filter_editor.is_focused(cx) {
1321            cx.focus(&self.filter_editor);
1322        }
1323    }
1324
1325    fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1326        if !self.filter_editor.is_focused(cx) {
1327            cx.emit(Event::Dismissed);
1328        }
1329    }
1330}
1331
1332fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
1333    Svg::new(svg_path)
1334        .with_color(style.color)
1335        .constrained()
1336        .with_width(style.icon_width)
1337        .aligned()
1338        .contained()
1339        .with_style(style.container)
1340        .constrained()
1341        .with_width(style.button_width)
1342        .with_height(style.button_width)
1343}