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