contact_list.rs

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