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