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