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(
 657                *theme
 658                    .contact_row
 659                    .style_for(&mut Default::default(), is_selected),
 660            )
 661            .boxed()
 662    }
 663
 664    fn render_participant_project(
 665        project_id: u64,
 666        worktree_root_names: &[String],
 667        host_user_id: u64,
 668        is_current: bool,
 669        is_last: bool,
 670        is_selected: bool,
 671        theme: &theme::ContactList,
 672        cx: &mut RenderContext<Self>,
 673    ) -> ElementBox {
 674        let font_cache = cx.font_cache();
 675        let host_avatar_height = theme
 676            .contact_avatar
 677            .width
 678            .or(theme.contact_avatar.height)
 679            .unwrap_or(0.);
 680        let row = &theme.project_row.default;
 681        let tree_branch = theme.tree_branch;
 682        let line_height = row.name.text.line_height(font_cache);
 683        let cap_height = row.name.text.cap_height(font_cache);
 684        let baseline_offset =
 685            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
 686        let project_name = if worktree_root_names.is_empty() {
 687            "untitled".to_string()
 688        } else {
 689            worktree_root_names.join(", ")
 690        };
 691
 692        MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, _| {
 693            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
 694            let row = theme.project_row.style_for(mouse_state, is_selected);
 695
 696            Flex::row()
 697                .with_child(
 698                    Stack::new()
 699                        .with_child(
 700                            Canvas::new(move |bounds, _, cx| {
 701                                let start_x = bounds.min_x() + (bounds.width() / 2.)
 702                                    - (tree_branch.width / 2.);
 703                                let end_x = bounds.max_x();
 704                                let start_y = bounds.min_y();
 705                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
 706
 707                                cx.scene.push_quad(gpui::Quad {
 708                                    bounds: RectF::from_points(
 709                                        vec2f(start_x, start_y),
 710                                        vec2f(
 711                                            start_x + tree_branch.width,
 712                                            if is_last { end_y } else { bounds.max_y() },
 713                                        ),
 714                                    ),
 715                                    background: Some(tree_branch.color),
 716                                    border: gpui::Border::default(),
 717                                    corner_radius: 0.,
 718                                });
 719                                cx.scene.push_quad(gpui::Quad {
 720                                    bounds: RectF::from_points(
 721                                        vec2f(start_x, end_y),
 722                                        vec2f(end_x, end_y + tree_branch.width),
 723                                    ),
 724                                    background: Some(tree_branch.color),
 725                                    border: gpui::Border::default(),
 726                                    corner_radius: 0.,
 727                                });
 728                            })
 729                            .boxed(),
 730                        )
 731                        .constrained()
 732                        .with_width(host_avatar_height)
 733                        .boxed(),
 734                )
 735                .with_child(
 736                    Label::new(project_name, row.name.text.clone())
 737                        .aligned()
 738                        .left()
 739                        .contained()
 740                        .with_style(row.name.container)
 741                        .flex(1., false)
 742                        .boxed(),
 743                )
 744                .constrained()
 745                .with_height(theme.row_height)
 746                .contained()
 747                .with_style(row.container)
 748                .boxed()
 749        })
 750        .with_cursor_style(if !is_current {
 751            CursorStyle::PointingHand
 752        } else {
 753            CursorStyle::Arrow
 754        })
 755        .on_click(MouseButton::Left, move |_, cx| {
 756            if !is_current {
 757                cx.dispatch_global_action(JoinProject {
 758                    project_id,
 759                    follow_user_id: host_user_id,
 760                });
 761            }
 762        })
 763        .boxed()
 764    }
 765
 766    fn render_header(
 767        section: Section,
 768        theme: &theme::ContactList,
 769        is_selected: bool,
 770        is_collapsed: bool,
 771        cx: &mut RenderContext<Self>,
 772    ) -> ElementBox {
 773        enum Header {}
 774
 775        let header_style = theme
 776            .header_row
 777            .style_for(&mut Default::default(), is_selected);
 778        let text = match section {
 779            Section::ActiveCall => "Collaborators",
 780            Section::Requests => "Contact Requests",
 781            Section::Online => "Online",
 782            Section::Offline => "Offline",
 783        };
 784        let leave_call = if section == Section::ActiveCall {
 785            Some(
 786                MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
 787                    let style = theme.leave_call.style_for(state, false);
 788                    Label::new("Leave Session".into(), style.text.clone())
 789                        .contained()
 790                        .with_style(style.container)
 791                        .boxed()
 792                })
 793                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
 794                .aligned()
 795                .boxed(),
 796            )
 797        } else {
 798            None
 799        };
 800
 801        let icon_size = theme.section_icon_size;
 802        MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
 803            Flex::row()
 804                .with_child(
 805                    Svg::new(if is_collapsed {
 806                        "icons/chevron_right_8.svg"
 807                    } else {
 808                        "icons/chevron_down_8.svg"
 809                    })
 810                    .with_color(header_style.text.color)
 811                    .constrained()
 812                    .with_max_width(icon_size)
 813                    .with_max_height(icon_size)
 814                    .aligned()
 815                    .constrained()
 816                    .with_width(icon_size)
 817                    .boxed(),
 818                )
 819                .with_child(
 820                    Label::new(text.to_string(), header_style.text.clone())
 821                        .aligned()
 822                        .left()
 823                        .contained()
 824                        .with_margin_left(theme.contact_username.container.margin.left)
 825                        .flex(1., true)
 826                        .boxed(),
 827                )
 828                .with_children(leave_call)
 829                .constrained()
 830                .with_height(theme.row_height)
 831                .contained()
 832                .with_style(header_style.container)
 833                .boxed()
 834        })
 835        .with_cursor_style(CursorStyle::PointingHand)
 836        .on_click(MouseButton::Left, move |_, cx| {
 837            cx.dispatch_action(ToggleExpanded(section))
 838        })
 839        .boxed()
 840    }
 841
 842    fn render_contact(
 843        contact: &Contact,
 844        project: &ModelHandle<Project>,
 845        theme: &theme::ContactList,
 846        is_selected: bool,
 847        cx: &mut RenderContext<Self>,
 848    ) -> ElementBox {
 849        let online = contact.online;
 850        let busy = contact.busy;
 851        let user_id = contact.user.id;
 852        let initial_project = project.clone();
 853        let mut element =
 854            MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
 855                Flex::row()
 856                    .with_children(contact.user.avatar.clone().map(|avatar| {
 857                        let status_badge = if contact.online {
 858                            Some(
 859                                Empty::new()
 860                                    .collapsed()
 861                                    .contained()
 862                                    .with_style(if contact.busy {
 863                                        theme.contact_status_busy
 864                                    } else {
 865                                        theme.contact_status_free
 866                                    })
 867                                    .aligned()
 868                                    .boxed(),
 869                            )
 870                        } else {
 871                            None
 872                        };
 873                        Stack::new()
 874                            .with_child(
 875                                Image::new(avatar)
 876                                    .with_style(theme.contact_avatar)
 877                                    .aligned()
 878                                    .left()
 879                                    .boxed(),
 880                            )
 881                            .with_children(status_badge)
 882                            .boxed()
 883                    }))
 884                    .with_child(
 885                        Label::new(
 886                            contact.user.github_login.clone(),
 887                            theme.contact_username.text.clone(),
 888                        )
 889                        .contained()
 890                        .with_style(theme.contact_username.container)
 891                        .aligned()
 892                        .left()
 893                        .flex(1., true)
 894                        .boxed(),
 895                    )
 896                    .constrained()
 897                    .with_height(theme.row_height)
 898                    .contained()
 899                    .with_style(
 900                        *theme
 901                            .contact_row
 902                            .style_for(&mut Default::default(), is_selected),
 903                    )
 904                    .boxed()
 905            })
 906            .on_click(MouseButton::Left, move |_, cx| {
 907                if online && !busy {
 908                    cx.dispatch_action(Call {
 909                        recipient_user_id: user_id,
 910                        initial_project: Some(initial_project.clone()),
 911                    });
 912                }
 913            });
 914
 915        if online {
 916            element = element.with_cursor_style(CursorStyle::PointingHand);
 917        }
 918
 919        element.boxed()
 920    }
 921
 922    fn render_contact_request(
 923        user: Arc<User>,
 924        user_store: ModelHandle<UserStore>,
 925        theme: &theme::ContactList,
 926        is_incoming: bool,
 927        is_selected: bool,
 928        cx: &mut RenderContext<Self>,
 929    ) -> ElementBox {
 930        enum Decline {}
 931        enum Accept {}
 932        enum Cancel {}
 933
 934        let mut row = Flex::row()
 935            .with_children(user.avatar.clone().map(|avatar| {
 936                Image::new(avatar)
 937                    .with_style(theme.contact_avatar)
 938                    .aligned()
 939                    .left()
 940                    .boxed()
 941            }))
 942            .with_child(
 943                Label::new(
 944                    user.github_login.clone(),
 945                    theme.contact_username.text.clone(),
 946                )
 947                .contained()
 948                .with_style(theme.contact_username.container)
 949                .aligned()
 950                .left()
 951                .flex(1., true)
 952                .boxed(),
 953            );
 954
 955        let user_id = user.id;
 956        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
 957        let button_spacing = theme.contact_button_spacing;
 958
 959        if is_incoming {
 960            row.add_children([
 961                MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
 962                    let button_style = if is_contact_request_pending {
 963                        &theme.disabled_button
 964                    } else {
 965                        theme.contact_button.style_for(mouse_state, false)
 966                    };
 967                    render_icon_button(button_style, "icons/x_mark_8.svg")
 968                        .aligned()
 969                        .boxed()
 970                })
 971                .with_cursor_style(CursorStyle::PointingHand)
 972                .on_click(MouseButton::Left, move |_, cx| {
 973                    cx.dispatch_action(RespondToContactRequest {
 974                        user_id,
 975                        accept: false,
 976                    })
 977                })
 978                .contained()
 979                .with_margin_right(button_spacing)
 980                .boxed(),
 981                MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
 982                    let button_style = if is_contact_request_pending {
 983                        &theme.disabled_button
 984                    } else {
 985                        theme.contact_button.style_for(mouse_state, false)
 986                    };
 987                    render_icon_button(button_style, "icons/check_8.svg")
 988                        .aligned()
 989                        .flex_float()
 990                        .boxed()
 991                })
 992                .with_cursor_style(CursorStyle::PointingHand)
 993                .on_click(MouseButton::Left, move |_, cx| {
 994                    cx.dispatch_action(RespondToContactRequest {
 995                        user_id,
 996                        accept: true,
 997                    })
 998                })
 999                .boxed(),
1000            ]);
1001        } else {
1002            row.add_child(
1003                MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
1004                    let button_style = if is_contact_request_pending {
1005                        &theme.disabled_button
1006                    } else {
1007                        theme.contact_button.style_for(mouse_state, false)
1008                    };
1009                    render_icon_button(button_style, "icons/x_mark_8.svg")
1010                        .aligned()
1011                        .flex_float()
1012                        .boxed()
1013                })
1014                .with_padding(Padding::uniform(2.))
1015                .with_cursor_style(CursorStyle::PointingHand)
1016                .on_click(MouseButton::Left, move |_, cx| {
1017                    cx.dispatch_action(RemoveContact(user_id))
1018                })
1019                .flex_float()
1020                .boxed(),
1021            );
1022        }
1023
1024        row.constrained()
1025            .with_height(theme.row_height)
1026            .contained()
1027            .with_style(
1028                *theme
1029                    .contact_row
1030                    .style_for(&mut Default::default(), is_selected),
1031            )
1032            .boxed()
1033    }
1034
1035    fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
1036        let recipient_user_id = action.recipient_user_id;
1037        let initial_project = action.initial_project.clone();
1038        let window_id = cx.window_id();
1039
1040        let active_call = ActiveCall::global(cx);
1041        cx.spawn_weak(|_, mut cx| async move {
1042            active_call
1043                .update(&mut cx, |active_call, cx| {
1044                    active_call.invite(recipient_user_id, initial_project.clone(), cx)
1045                })
1046                .await?;
1047            if cx.update(|cx| cx.window_is_active(window_id)) {
1048                active_call
1049                    .update(&mut cx, |call, cx| {
1050                        call.set_location(initial_project.as_ref(), cx)
1051                    })
1052                    .await?;
1053            }
1054            anyhow::Ok(())
1055        })
1056        .detach_and_log_err(cx);
1057    }
1058
1059    fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
1060        ActiveCall::global(cx)
1061            .update(cx, |call, cx| call.hang_up(cx))
1062            .log_err();
1063    }
1064}
1065
1066impl Entity for ContactList {
1067    type Event = Event;
1068}
1069
1070impl View for ContactList {
1071    fn ui_name() -> &'static str {
1072        "ContactList"
1073    }
1074
1075    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
1076        let mut cx = Self::default_keymap_context();
1077        cx.set.insert("menu".into());
1078        cx
1079    }
1080
1081    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
1082        enum AddContact {}
1083        let theme = cx.global::<Settings>().theme.clone();
1084
1085        Flex::column()
1086            .with_child(
1087                Flex::row()
1088                    .with_child(
1089                        ChildView::new(self.filter_editor.clone(), cx)
1090                            .contained()
1091                            .with_style(theme.contact_list.user_query_editor.container)
1092                            .flex(1., true)
1093                            .boxed(),
1094                    )
1095                    .with_child(
1096                        MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
1097                            render_icon_button(
1098                                &theme.contact_list.add_contact_button,
1099                                "icons/user_plus_16.svg",
1100                            )
1101                            .boxed()
1102                        })
1103                        .with_cursor_style(CursorStyle::PointingHand)
1104                        .on_click(MouseButton::Left, |_, cx| {
1105                            cx.dispatch_action(contacts_popover::ToggleContactFinder)
1106                        })
1107                        .with_tooltip::<AddContact, _>(
1108                            0,
1109                            "Add contact".into(),
1110                            None,
1111                            theme.tooltip.clone(),
1112                            cx,
1113                        )
1114                        .boxed(),
1115                    )
1116                    .constrained()
1117                    .with_height(theme.contact_list.user_query_editor_height)
1118                    .boxed(),
1119            )
1120            .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
1121            .boxed()
1122    }
1123
1124    fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1125        if !self.filter_editor.is_focused(cx) {
1126            cx.focus(&self.filter_editor);
1127        }
1128    }
1129
1130    fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1131        if !self.filter_editor.is_focused(cx) {
1132            cx.emit(Event::Dismissed);
1133        }
1134    }
1135}
1136
1137fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
1138    Svg::new(svg_path)
1139        .with_color(style.color)
1140        .constrained()
1141        .with_width(style.icon_width)
1142        .aligned()
1143        .contained()
1144        .with_style(style.container)
1145        .constrained()
1146        .with_width(style.button_width)
1147        .with_height(style.button_width)
1148}