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