contact_list.rs

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