contacts_panel.rs

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