Summary: This diff moves a lot of stuff from the client to the server. This diff is fairly large, as a lot of concept closely relate, although some things have split off to the earlier diffs in the stack, or are still to follow (like making intern requests). This diff primarily moves reading and storing settings and GKs from client to server (both flipper and launcher settings). This means that settings are no longer persisted by Redux (which only exists on client). Most other changes are fallout from that. For now settings are just one big object, although we might need to separate settings that are only make sense in an Electron context. For example launcher settings. Reviewed By: passy, aigoncharov Differential Revision: D32498649 fbshipit-source-id: d842faf7a7f03774b621c7656e53a9127afc6192
360 lines
9.4 KiB
TypeScript
360 lines
9.4 KiB
TypeScript
/**
|
|
* Copyright (c) Facebook, Inc. and its 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 {
|
|
FlexColumn,
|
|
FlexRow,
|
|
Button,
|
|
Checkbox,
|
|
styled,
|
|
Input,
|
|
Link,
|
|
} from '../ui';
|
|
import {LeftRailButton} from '../sandy-chrome/LeftRail';
|
|
import * as UserFeedback from '../fb-stubs/UserFeedback';
|
|
import {FeedbackPrompt} from '../fb-stubs/UserFeedback';
|
|
import {StarOutlined} from '@ant-design/icons';
|
|
import {Popover, Rate} from 'antd';
|
|
import {useStore} from '../utils/useStore';
|
|
import {isLoggedIn} from '../fb-stubs/user';
|
|
import {useValue} from 'flipper-plugin';
|
|
import {reportPlatformFailures} from 'flipper-common';
|
|
import {getRenderHostInstance} from '../RenderHost';
|
|
|
|
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 (
|
|
<PredefinedComment.Container
|
|
onClick={this.props.onClick}
|
|
selected={this.props.selected}>
|
|
{this.props.comment}
|
|
</PredefinedComment.Container>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<DismissRow key="dismiss">
|
|
<Spacer />
|
|
<DismissButton onClick={dismiss}>Dismiss</DismissButton>
|
|
<Spacer />
|
|
</DismissRow>
|
|
);
|
|
}
|
|
|
|
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<string>,
|
|
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<string> = 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<ReactElement>;
|
|
switch (this.state.nextAction) {
|
|
case 'select-rating':
|
|
body = [
|
|
<Row key="bodyText">{this.props.promptData.bodyText}</Row>,
|
|
<Row key="stars" style={{margin: 'auto'}}>
|
|
<Rate onChange={(newRating) => this.onSubmitRating(newRating)} />
|
|
</Row>,
|
|
dismissRow(this.props.dismiss),
|
|
];
|
|
break;
|
|
case 'leave-comment':
|
|
const predefinedComments = Object.entries(
|
|
this.state.predefinedComments,
|
|
).map((c: [string, unknown], idx: number) => (
|
|
<PredefinedComment
|
|
key={idx}
|
|
comment={c[0]}
|
|
selected={Boolean(c[1])}
|
|
onClick={() =>
|
|
this.setState({
|
|
predefinedComments: {
|
|
...this.state.predefinedComments,
|
|
[c[0]]: !c[1],
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
));
|
|
body = [
|
|
<Row key="predefinedComments">{predefinedComments}</Row>,
|
|
<Row key="inputRow">
|
|
<Input
|
|
style={{height: 30, width: '100%'}}
|
|
placeholder={this.props.promptData.commentPlaceholder}
|
|
value={this.state.comment}
|
|
onChange={(e) => this.setState({comment: e.target.value})}
|
|
onKeyDown={(e) =>
|
|
e.key == 'Enter' && this.onCommentSubmitted(this.state.comment)
|
|
}
|
|
autoFocus
|
|
/>
|
|
</Row>,
|
|
<Row key="contactCheckbox">
|
|
<Checkbox
|
|
checked={this.state.allowUserInfoSharing}
|
|
onChange={this.onAllowUserSharingChanged.bind(this)}
|
|
/>
|
|
{'Tool owner can contact me '}
|
|
</Row>,
|
|
<Row key="submit">
|
|
<Button onClick={() => this.onCommentSubmitted(this.state.comment)}>
|
|
Submit
|
|
</Button>
|
|
</Row>,
|
|
dismissRow(this.props.dismiss),
|
|
];
|
|
break;
|
|
case 'finished':
|
|
body = [
|
|
<Row key="thanks">
|
|
Thanks for the feedback! You can now help
|
|
<Link href="https://www.internalfb.com/intern/papercuts/?application=flipper">
|
|
prioritize bugs and features for Flipper in Papercuts
|
|
</Link>
|
|
</Row>,
|
|
dismissRow(this.props.dismiss),
|
|
];
|
|
break;
|
|
default: {
|
|
console.error('Illegal state: nextAction: ' + this.state.nextAction);
|
|
return null;
|
|
}
|
|
}
|
|
return (
|
|
<FlexColumn
|
|
style={{
|
|
width: 400,
|
|
paddingLeft: 20,
|
|
paddingRight: 20,
|
|
paddingTop: 10,
|
|
paddingBottom: 10,
|
|
}}>
|
|
<Row key="heading" style={{color: 'black', fontSize: 20}}>
|
|
{this.state.nextAction === 'finished'
|
|
? this.props.promptData.postSubmitHeading
|
|
: this.props.promptData.preSubmitHeading}
|
|
</Row>
|
|
{body}
|
|
</FlexColumn>
|
|
);
|
|
}
|
|
}
|
|
|
|
export function SandyRatingButton() {
|
|
const [promptData, setPromptData] =
|
|
useState<UserFeedback.FeedbackPrompt | null>(null);
|
|
const [isShown, setIsShown] = useState(false);
|
|
const [hasTriggered, setHasTriggered] = useState(false);
|
|
const sessionId = useStore((store) => store.application.sessionId);
|
|
const loggedIn = useValue(isLoggedIn());
|
|
|
|
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<string>,
|
|
allowUserInfoSharing: boolean,
|
|
) => {
|
|
UserFeedback.submitComment(
|
|
rating,
|
|
comment,
|
|
selectedPredefinedComments,
|
|
allowUserInfoSharing,
|
|
sessionId,
|
|
);
|
|
};
|
|
|
|
if (!promptData) {
|
|
return null;
|
|
}
|
|
if (!promptData.shouldPopup || (hasTriggered && !isShown)) {
|
|
return null;
|
|
}
|
|
return (
|
|
<Popover
|
|
visible={isShown}
|
|
content={
|
|
<FeedbackComponent
|
|
submitRating={submitRating}
|
|
submitComment={submitComment}
|
|
close={() => {
|
|
setIsShown(false);
|
|
}}
|
|
dismiss={onClick}
|
|
promptData={promptData}
|
|
/>
|
|
}
|
|
placement="right"
|
|
trigger="click">
|
|
<LeftRailButton
|
|
icon={<StarOutlined />}
|
|
title="Rate Flipper"
|
|
onClick={onClick}
|
|
small
|
|
/>
|
|
</Popover>
|
|
);
|
|
}
|