contacts_panel.rs

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