diff --git a/desktop/flipper-ui-core/src/sandy-chrome/Navbar.tsx b/desktop/flipper-ui-core/src/sandy-chrome/Navbar.tsx
index 014f9a941..94daf13f2 100644
--- a/desktop/flipper-ui-core/src/sandy-chrome/Navbar.tsx
+++ b/desktop/flipper-ui-core/src/sandy-chrome/Navbar.tsx
@@ -72,6 +72,7 @@ import {TroubleshootingGuide} from './appinspect/fb-stubs/TroubleshootingGuide';
import {FlipperDevTools} from '../chrome/FlipperDevTools';
import {TroubleshootingHub} from '../chrome/TroubleshootingHub';
import {Notification} from './notification/Notification';
+import {SandyRatingButton} from './RatingButton';
export const Navbar = withTrackingScope(function Navbar() {
return (
@@ -104,6 +105,7 @@ export const Navbar = withTrackingScope(function Navbar() {
+
{getRenderHostInstance().serverConfig.environmentInfo
diff --git a/desktop/flipper-ui-core/src/sandy-chrome/RatingButton.tsx b/desktop/flipper-ui-core/src/sandy-chrome/RatingButton.tsx
new file mode 100644
index 000000000..b15a8a777
--- /dev/null
+++ b/desktop/flipper-ui-core/src/sandy-chrome/RatingButton.tsx
@@ -0,0 +1,349 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @format
+ */
+
+import React, {
+ Component,
+ ReactElement,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import {styled, Input, Link, FlexColumn, FlexRow} from '../ui';
+import * as UserFeedback from '../fb-stubs/UserFeedback';
+import {FeedbackPrompt} from '../fb-stubs/UserFeedback';
+import {StarOutlined} from '@ant-design/icons';
+import {Button, Checkbox, Popover, Rate} from 'antd';
+import {currentUser} from '../fb-stubs/user';
+import {theme, useValue} from 'flipper-plugin';
+import {reportPlatformFailures} from 'flipper-common';
+import {getRenderHostInstance} from 'flipper-frontend-core';
+import {NavbarButton} from './Navbar';
+
+type NextAction = 'select-rating' | 'leave-comment' | 'finished';
+
+class PredefinedComment extends Component<{
+ comment: string;
+ selected: boolean;
+ onClick: (_: unknown) => unknown;
+}> {
+ static Container = styled.div<{selected: boolean}>((props) => {
+ return {
+ border: '1px solid #f2f3f5',
+ cursor: 'pointer',
+ borderRadius: 24,
+ backgroundColor: props.selected ? '#ecf3ff' : '#f2f3f5',
+ marginBottom: 4,
+ marginRight: 4,
+ padding: '4px 8px',
+ color: props.selected ? 'rgb(56, 88, 152)' : undefined,
+ borderColor: props.selected ? '#3578e5' : undefined,
+ ':hover': {
+ borderColor: '#3578e5',
+ },
+ };
+ });
+ render() {
+ return (
+
+ {this.props.comment}
+
+ );
+ }
+}
+
+const Row = styled(FlexRow)({
+ marginTop: 5,
+ marginBottom: 5,
+ justifyContent: 'center',
+ textAlign: 'center',
+ color: '#9a9a9a',
+ flexWrap: 'wrap',
+});
+
+const DismissRow = styled(Row)({
+ marginBottom: 0,
+ marginTop: 10,
+});
+
+const DismissButton = styled.span({
+ '&:hover': {
+ textDecoration: 'underline',
+ cursor: 'pointer',
+ },
+});
+
+const Spacer = styled(FlexColumn)({
+ flexGrow: 1,
+});
+
+function dismissRow(dismiss: () => void) {
+ return (
+
+
+ Dismiss
+
+
+ );
+}
+
+type FeedbackComponentState = {
+ rating: number | null;
+ hoveredRating: number;
+ allowUserInfoSharing: boolean;
+ nextAction: NextAction;
+ predefinedComments: {[key: string]: boolean};
+ comment: string;
+};
+
+class FeedbackComponent extends Component<
+ {
+ submitRating: (rating: number) => void;
+ submitComment: (
+ rating: number,
+ comment: string,
+ selectedPredefinedComments: Array,
+ allowUserInfoSharing: boolean,
+ ) => void;
+ close: () => void;
+ dismiss: () => void;
+ promptData: FeedbackPrompt;
+ },
+ FeedbackComponentState
+> {
+ state: FeedbackComponentState = {
+ rating: null,
+ hoveredRating: 0,
+ allowUserInfoSharing: true,
+ nextAction: 'select-rating' as NextAction,
+ predefinedComments: this.props.promptData.predefinedComments.reduce(
+ (acc, cv) => ({...acc, [cv]: false}),
+ {},
+ ),
+ comment: '',
+ };
+ onSubmitRating(newRating: number) {
+ const nextAction = newRating <= 2 ? 'leave-comment' : 'finished';
+ this.setState({rating: newRating, nextAction: nextAction});
+ this.props.submitRating(newRating);
+ if (nextAction === 'finished') {
+ setTimeout(this.props.close, 5000);
+ }
+ }
+ onCommentSubmitted(comment: string) {
+ this.setState({nextAction: 'finished'});
+ const selectedPredefinedComments: Array = Object.entries(
+ this.state.predefinedComments,
+ )
+ .map((x) => ({comment: x[0], enabled: x[1]}))
+ .filter((x) => x.enabled)
+ .map((x) => x.comment);
+ const currentRating = this.state.rating;
+ if (currentRating) {
+ this.props.submitComment(
+ currentRating,
+ comment,
+ selectedPredefinedComments,
+ this.state.allowUserInfoSharing,
+ );
+ } else {
+ console.error('Illegal state: Submitting comment with no rating set.');
+ }
+ setTimeout(this.props.close, 1000);
+ }
+ onAllowUserSharingChanged(allowed: boolean) {
+ this.setState({allowUserInfoSharing: allowed});
+ }
+ render() {
+ let body: Array;
+ switch (this.state.nextAction) {
+ case 'select-rating':
+ body = [
+ {this.props.promptData.bodyText}
,
+
+ this.onSubmitRating(newRating)} />
+
,
+ dismissRow(this.props.dismiss),
+ ];
+ break;
+ case 'leave-comment':
+ const predefinedComments = Object.entries(
+ this.state.predefinedComments,
+ ).map((c: [string, unknown], idx: number) => (
+
+ this.setState({
+ predefinedComments: {
+ ...this.state.predefinedComments,
+ [c[0]]: !c[1],
+ },
+ })
+ }
+ />
+ ));
+ body = [
+ {predefinedComments}
,
+
+ this.setState({comment: e.target.value})}
+ onKeyDown={(e) =>
+ e.key == 'Enter' && this.onCommentSubmitted(this.state.comment)
+ }
+ autoFocus
+ />
+
,
+
+ this.onAllowUserSharingChanged(e.target.checked)}
+ />
+ {'Tool owner can contact me '}
+
,
+
+
+
,
+ dismissRow(this.props.dismiss),
+ ];
+ break;
+ case 'finished':
+ body = [
+
+ Thanks for the feedback! You can now help
+
+ prioritize bugs and features for Flipper in Papercuts
+
+
,
+ dismissRow(this.props.dismiss),
+ ];
+ break;
+ default: {
+ console.error('Illegal state: nextAction: ' + this.state.nextAction);
+ return null;
+ }
+ }
+ return (
+
+
+ {this.state.nextAction === 'finished'
+ ? this.props.promptData.postSubmitHeading
+ : this.props.promptData.preSubmitHeading}
+
+ {body}
+
+ );
+ }
+}
+
+export function SandyRatingButton() {
+ const [promptData, setPromptData] =
+ useState(null);
+ const [isShown, setIsShown] = useState(false);
+ const [hasTriggered, setHasTriggered] = useState(false);
+ const sessionId = getRenderHostInstance().serverConfig.sessionId;
+ const loggedIn = useValue(currentUser());
+
+ const triggerPopover = useCallback(() => {
+ if (!hasTriggered) {
+ setIsShown(true);
+ setHasTriggered(true);
+ }
+ }, [hasTriggered]);
+
+ useEffect(() => {
+ if (
+ getRenderHostInstance().GK('flipper_enable_star_ratiings') &&
+ !hasTriggered &&
+ loggedIn
+ ) {
+ reportPlatformFailures(
+ UserFeedback.getPrompt().then((prompt) => {
+ setPromptData(prompt);
+ setTimeout(triggerPopover, 30000);
+ }),
+ 'RatingButton:getPrompt',
+ ).catch((e) => {
+ console.warn('Failed to load ratings prompt:', e);
+ });
+ }
+ }, [triggerPopover, hasTriggered, loggedIn]);
+
+ const onClick = () => {
+ const willBeShown = !isShown;
+ setIsShown(willBeShown);
+ setHasTriggered(true);
+ if (!willBeShown) {
+ UserFeedback.dismiss(sessionId);
+ }
+ };
+
+ const submitRating = (rating: number) => {
+ UserFeedback.submitRating(rating, sessionId);
+ };
+
+ const submitComment = (
+ rating: number,
+ comment: string,
+ selectedPredefinedComments: Array,
+ allowUserInfoSharing: boolean,
+ ) => {
+ UserFeedback.submitComment(
+ rating,
+ comment,
+ selectedPredefinedComments,
+ allowUserInfoSharing,
+ sessionId,
+ );
+ };
+
+ if (!promptData) {
+ return null;
+ }
+ if (!promptData.shouldPopup || (hasTriggered && !isShown)) {
+ return null;
+ }
+ return (
+ {
+ setIsShown(false);
+ }}
+ dismiss={onClick}
+ promptData={promptData}
+ />
+ }
+ placement="right"
+ trigger="click">
+
+
+ );
+}