contact_list.rs

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