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