diff --git a/components/AppLayout/AppHeader.js b/components/AppLayout/AppHeader.js index 18d6ba9c..6e6a4dea 100644 --- a/components/AppLayout/AppHeader.js +++ b/components/AppLayout/AppHeader.js @@ -32,7 +32,7 @@ const useStyles = makeStyles(theme => ({ position: 'sticky', height: NAVBAR_HEIGHT + TABS_HEIGHT, top: 0, - zIndex: 10, + zIndex: theme.zIndex.appBar, [theme.breakpoints.up('md')]: { height: NAVBAR_HEIGHT, }, diff --git a/components/ArticleInfo.js b/components/ArticleInfo.js index f988c521..4e659fc0 100644 --- a/components/ArticleInfo.js +++ b/components/ArticleInfo.js @@ -52,8 +52,8 @@ export default function ArticleInfo({ article }) {
{ngettext( - msgid`${replyRequestCount} occurence`, - `${replyRequestCount} occurences`, + msgid`${replyRequestCount} occurrence`, + `${replyRequestCount} occurrences`, replyRequestCount )} diff --git a/components/ArticleReply.js b/components/ArticleReply.js deleted file mode 100644 index 7d318eee..00000000 --- a/components/ArticleReply.js +++ /dev/null @@ -1,274 +0,0 @@ -import React from 'react'; -import Link from 'next/link'; -import { t, jt } from 'ttag'; -import gql from 'graphql-tag'; - -import { format, formatDistanceToNow } from 'lib/dateWithLocale'; -import { TYPE_NAME, TYPE_DESC } from '../constants/replyType'; -import { USER_REFERENCE } from '../constants/urls'; -import ExpandableText from './ExpandableText'; -import { nl2br, linkify } from 'lib/text'; -import { sectionStyle } from './ReplyConnection.styles'; -import ReplyFeedback from './ReplyFeedback'; -import EditorName from './EditorName'; -import Hyperlinks from './Hyperlinks'; -import CopyButton from './CopyButton'; - -const ArticleReplyData = gql` - fragment ArticleReplyData on ArticleReply { - # articleId and replyId are required to identify ArticleReply instances - articleId - replyId - canUpdateStatus - createdAt - reply { - id - type - text - reference - user { - id - name - level - } - hyperlinks { - ...HyperlinkData - } - } - user { - id - name - level - } - ...ArticleReplyFeedbackData - } - ${Hyperlinks.fragments.HyperlinkData} - ${ReplyFeedback.fragments.ArticleReplyFeedbackData} -`; - -const ArticleReplyForUser = gql` - fragment ArticleReplyForUser on ArticleReply { - # articleId and replyId are required to identify ArticleReply instances - articleId - replyId - canUpdateStatus - ...ArticleReplyFeedbackForUser - } - ${ReplyFeedback.fragments.ArticleReplyFeedbackForUser} -`; - -class ArticleReply extends React.PureComponent { - static defaultProps = { - articleReply: {}, - disabled: false, - onAction() {}, - actionText: '', - showActionOnlyWhenCanUpdate: true, // If false, show action button for everyone - linkToReply: true, - showFeedback: true, - }; - - handleAction = () => { - const { articleReply, onAction } = this.props; - return onAction(articleReply); - }; - - renderHint = () => { - const { articleReply } = this.props; - const replyType = articleReply.reply.type; - - if (replyType !== 'NOT_ARTICLE') return null; - - const refLink = ( - - {t`“Editor Manual”`} - - ); - - return ( - - ); - }; - - renderFooter = () => { - const { - articleReply, - disabled, - actionText, - linkToReply, - showActionOnlyWhenCanUpdate, - showFeedback, - } = this.props; - const createdAt = new Date(articleReply.createdAt); - const timeAgoStr = formatDistanceToNow(createdAt); - const timeEl = ( - {t`${timeAgoStr} ago`} - ); - - const { reply } = articleReply; - - const copyText = - typeof window !== 'undefined' - ? `${TYPE_NAME[reply.type]} \n【${t`Reason`}】${( - reply.text || '' - ).trim()}\n↓${t`Details`}↓\n${ - window.location.href - }\n↓${t`Reference`}↓\n${reply.reference}` - : ''; - - return ( - - ); - }; - - renderAuthor = () => { - const { articleReply } = this.props; - const reply = articleReply.reply; - const articleReplyAuthor = articleReply.user; - const replyAuthor = reply.user; - - const articleReplyAuthorName = - ( - - ) || t`Someone`; - - const originalAuthorElem = ( - - ); - const originalAuthorsReply = ( - - {jt`${originalAuthorElem}'s reply`} - - ); - - if (replyAuthor?.name && articleReplyAuthor?.id !== replyAuthor?.id) { - return ( - - {jt`${articleReplyAuthorName} uses ${originalAuthorsReply} to`} - - ); - } - - return articleReplyAuthorName; - }; - - renderReference = () => { - const { articleReply } = this.props; - const replyType = articleReply.reply.type; - if (replyType === 'NOT_ARTICLE') return null; - - const reference = articleReply.reply.reference; - return ( -
-

- {replyType === 'OPINIONATED' ? t`Different opinion` : t`Reference`} -

- {reference - ? nl2br(linkify(reference)) - : `⚠️️ ${t`There is no reference for this reply. Its truthfulness may be doubtful.`}`} - - - -
- ); - }; - - render() { - const { articleReply } = this.props; - const reply = articleReply.reply; - const replyType = reply.type; - - const authorElem = this.renderAuthor(); - const typeElem = ( - - {TYPE_NAME[replyType]} - - ); - - return ( -
  • -
    - {jt`${authorElem} mark the message as ${typeElem}`} - {this.renderHint()} -
    -
    -

    {t`Reason`}

    - {nl2br(linkify(reply.text))} -
    - - {this.renderReference()} - {this.renderFooter()} - - - -
  • - ); - } -} - -ArticleReply.fragments = { - ArticleReplyData, - ArticleReplyForUser, -}; - -export default ArticleReply; diff --git a/components/ArticleReply/CopyButton.js b/components/ArticleReply/CopyButton.js new file mode 100644 index 00000000..f507bf11 --- /dev/null +++ b/components/ArticleReply/CopyButton.js @@ -0,0 +1,22 @@ +import React, { useRef, useEffect } from 'react'; +import { Button } from '@material-ui/core'; +import ClipboardJS from 'clipboard'; +import { t } from 'ttag'; + +const CopyButton = React.memo(({ content = '', onClick = () => {} }) => { + const copyBtnRef = useRef(null); + + useEffect(() => { + const clipboard = new ClipboardJS(copyBtnRef.current, { + text: () => content, + }); + clipboard.on('success', () => onClick()); + return () => clipboard.destroy(); + }, [copyBtnRef.current, content, onClick]); + + return ; +}); + +CopyButton.displayName = 'CopyButton'; + +export default CopyButton; diff --git a/components/ArticleReply/ReplyActions.js b/components/ArticleReply/ReplyActions.js new file mode 100644 index 00000000..09459772 --- /dev/null +++ b/components/ArticleReply/ReplyActions.js @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { Button, Menu, MenuItem } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; + +const useStyles = makeStyles(theme => ({ + button: { + minWidth: 0, + padding: '8px 14px', + color: ({ open }) => + open ? theme.palette.primary[500] : theme.palette.secondary[500], + }, + menu: { + marginTop: 40, + }, +})); + +const ReplyActions = ({ disabled, handleAction, actionText }) => { + const [anchorEl, setAnchorEl] = useState(null); + + const classes = useStyles({ open: !!anchorEl }); + + const handleClick = event => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + + {actionText} + + + + ); +}; + +export default ReplyActions; diff --git a/components/ArticleReply/ReplyShare.js b/components/ArticleReply/ReplyShare.js new file mode 100644 index 00000000..c58fa4fd --- /dev/null +++ b/components/ArticleReply/ReplyShare.js @@ -0,0 +1,101 @@ +import { useState, useCallback } from 'react'; +import { + Popper, + Fade, + Paper, + ClickAwayListener, + Snackbar, +} from '@material-ui/core'; +import { t } from 'ttag'; +import { makeStyles } from '@material-ui/core/styles'; +import CopyButton from './CopyButton'; + +const useStyles = makeStyles(theme => ({ + button: ({ open }) => ({ + borderRadius: 45, + padding: '1px 8px', + outline: 'none', + cursor: 'pointer', + marginLeft: 2, + border: `1px solid ${ + open ? theme.palette.primary[500] : theme.palette.secondary[100] + }`, + color: open ? theme.palette.primary[500] : theme.palette.secondary[200], + background: theme.palette.common.white, + [theme.breakpoints.up('md')]: { + padding: '4px 18px', + marginRight: 10, + marginLeft: 12, + }, + '&:hover': { + border: `1px solid ${theme.palette.secondary[300]}`, + color: theme.palette.secondary[300], + }, + }), + menu: { + marginTop: 40, + }, +})); + +const SUCCESS = 'SUCCESS'; + +const ReplyShare = ({ copyText }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [message, setMessage] = useState(''); + const [status, setStatus] = useState(null); + + const classes = useStyles({ open: !!anchorEl }); + + const closeMenu = () => { + setAnchorEl(null); + }; + + const onSuccess = text => { + setMessage(text); + setTimeout(() => setStatus('SUCCESS'), 0); + closeMenu(); + }; + + const onCopied = useCallback(() => onSuccess('Copied to clipboard!')); + + const handleShare = event => { + if (window.navigator && window.navigator.share) { + navigator + .share({ text: copyText }) + .then(() => onSuccess(t`Successfylly Shared!`)) + .catch(() => {}); + } else { + setAnchorEl(event.currentTarget); + } + }; + + return ( + <> + + + {({ TransitionProps }) => ( + + + + {t`Copy`} + + + + )} + + setStatus(null)} + open={status === SUCCESS} + message={message} + autoHideDuration={3000} + /> + + ); +}; + +export default ReplyShare; diff --git a/components/ArticleReply/index.js b/components/ArticleReply/index.js new file mode 100644 index 00000000..0ac629b1 --- /dev/null +++ b/components/ArticleReply/index.js @@ -0,0 +1,239 @@ +import React, { useCallback } from 'react'; +import { t, jt } from 'ttag'; +import gql from 'graphql-tag'; +import { Box, Divider } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; + +import { nl2br, linkify } from 'lib/text'; +import { TYPE_NAME } from 'constants/replyType'; +import ExpandableText from 'components/ExpandableText'; +import ReplyFeedback from 'components/ReplyFeedback'; +import EditorName from 'components/EditorName'; +import Hyperlinks from 'components/Hyperlinks'; +import Avatar from 'components/AppLayout/Widgets/Avatar'; +import ReplyInfo from 'components/ReplyInfo'; +import ReplyActions from './ReplyActions'; +import ReplyShare from './ReplyShare'; + +const useStyles = makeStyles(theme => ({ + root: { + marginBottom: 16, + '&:not(:first)': { + borderTop: `1px solid ${theme.palette.secondary[100]}`, + }, + overflow: 'hidden', + textOverflow: 'ellipsis', + [theme.breakpoints.up('md')]: { + marginBottom: 20, + }, + }, + replyType: { + color: ({ replyType }) => { + switch (replyType) { + case 'OPINIONATED': + return '#2079F0'; + case 'NOT_RUMOR': + return '#00B172'; + case 'RUMOR': + return '#FB5959'; + default: + return theme.palette.common.black; + } + }, + }, + content: { + padding: '17px 0', + borderBottom: `1px dashed ${theme.palette.secondary[100]}`, + }, + avatar: { + width: 30, + height: 30, + marginRight: theme.spacing(1), + [theme.breakpoints.up('md')]: { + width: 42, + height: 42, + marginRight: theme.spacing(2), + }, + }, +})); + +const ArticleReplyData = gql` + fragment ArticleReplyData on ArticleReply { + # articleId and replyId are required to identify ArticleReply instances + articleId + replyId + canUpdateStatus + createdAt + reply { + id + type + text + reference + user { + id + name + level + avatarUrl + } + hyperlinks { + ...HyperlinkData + } + ...ReplyInfo + } + user { + id + name + level + avatarUrl + } + ...ArticleReplyFeedbackData + } + ${Hyperlinks.fragments.HyperlinkData} + ${ReplyFeedback.fragments.ArticleReplyFeedbackData} + ${ReplyInfo.fragments.replyInfo} +`; + +const ArticleReplyForUser = gql` + fragment ArticleReplyForUser on ArticleReply { + # articleId and replyId are required to identify ArticleReply instances + articleId + replyId + canUpdateStatus + ...ArticleReplyFeedbackForUser + } + ${ReplyFeedback.fragments.ArticleReplyFeedbackForUser} +`; + +const ArticleReply = React.memo( + ({ + articleReply = {}, + disabled = false, + onAction = () => {}, + actionText = '', + showActionOnlyWhenCanUpdate = true, // If false, show action button for everyone + showFeedback = true, + }) => { + const { + createdAt, + articleId, + positiveFeedbackCount, + negativeFeedbackCount, + feedbacks, + reply, + replyId, + ownVote, + user: articleReplyAuthor, + } = articleReply; + + const { type: replyType } = reply; + + const replyAuthor = reply.user; + const user = articleReplyAuthor || replyAuthor; + + const classes = useStyles({ replyType }); + + const renderFooter = useCallback(() => { + const copyText = + typeof window !== 'undefined' + ? `${TYPE_NAME[reply.type]} \n【${t`Reason`}】${( + reply.text || '' + ).trim()}\n↓${t`Details`}↓\n${ + window.location.href + }\n↓${t`Reference`}↓\n${reply.reference}` + : ''; + + return ( + + {showFeedback && ( + + )} + + + ); + }, [reply, articleReply]); + + const renderAuthor = useCallback( + () => + articleReplyAuthor ? ( + + ) : ( + t`Someone` + ), + [articleReplyAuthor] + ); + + const renderReference = useCallback(() => { + if (replyType === 'NOT_ARTICLE') return null; + + const reference = reply.reference; + return ( +
    +

    + {replyType === 'OPINIONATED' ? t`Different opinion` : t`Reference`} +

    + {reference + ? nl2br(linkify(reference)) + : `⚠️️ ${t`There is no reference for this reply. Its truthfulness may be doubtful.`}`} + + +
    + ); + }, [replyType, reply]); + + const authorElem = renderAuthor(); + + return ( +
  • + + {user && } + +
    + {jt`${authorElem} mark this message ${TYPE_NAME[replyType]}`} +
    + +
    + {(articleReply.canUpdateStatus || !showActionOnlyWhenCanUpdate) && ( + onAction(articleReply)} + /> + )} +
    +
    + {nl2br(linkify(reply.text))} +
    + + {renderReference()} + {renderFooter()} + +
  • + ); + } +); + +ArticleReply.fragments = { + ArticleReplyData, + ArticleReplyForUser, +}; + +ArticleReply.displayName = 'ArticleReply'; + +export default ArticleReply; diff --git a/components/CopyButton.js b/components/CopyButton.js deleted file mode 100644 index e435aded..00000000 --- a/components/CopyButton.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import ClipboardJS from 'clipboard'; -import { t } from 'ttag'; -import Snackbar from '@material-ui/core/Snackbar'; - -class CopyButton extends React.PureComponent { - constructor(props) { - super(props); - this.copyBtnRef = React.createRef(); - } - - static defaultProps = { - content: '', - }; - state = { - showCopySnack: false, - }; - - componentDidMount() { - const clipboard = new ClipboardJS(this.copyBtnRef.current, { - text: () => this.props.content, - }); - clipboard.on('success', () => { - if (window.navigator && window.navigator.share) { - const text = clipboard.text(); - navigator.share({ text }).catch(() => {}); - } else { - this.setState({ showCopySnack: true }); - } - }); - } - - handleClose = () => { - this.setState({ showCopySnack: false }); - }; - - render() { - const { showCopySnack } = this.state; - - return ( - - ); - } -} - -export default CopyButton; diff --git a/components/CreateReplyRequestDialog/CreateReplyRequestDialog.js b/components/CreateReplyRequestDialog/CreateReplyRequestDialog.js deleted file mode 100644 index 0be34a37..00000000 --- a/components/CreateReplyRequestDialog/CreateReplyRequestDialog.js +++ /dev/null @@ -1,136 +0,0 @@ -import React from 'react'; -import { useMutation } from '@apollo/react-hooks'; -import gql from 'graphql-tag'; - -const localStorage = typeof window === 'undefined' ? {} : window.localStorage; -const formInitialState = { - visible: false, - disabled: true, - text: '', -}; - -const CREATE_REPLY_REQUEST = gql` - mutation CreateReplyRequestFromForm($articleId: String!, $reason: String!) { - CreateReplyRequest(articleId: $articleId, reason: $reason) { - id - } - } -`; -const MIN_REASON_LENGTH = 80; - -function SubmitButton({ articleId, text, disabled, onFinish }) { - const [createReplyRequest] = useMutation(CREATE_REPLY_REQUEST, { - refetchQueries: ['LoadArticlePage'], - }); - - const handleSubmit = e => { - e.preventDefault(); // prevent reload - if (disabled) return; - createReplyRequest({ variables: { articleId, reason: text } }); - onFinish(); - }; - - return ( - - ); -} - -class CreateReplyRequestDialog extends React.PureComponent { - static defaultProps = {}; - - constructor() { - super(); - this.state = { - ...formInitialState, - }; - } - - componentDidMount() { - const { text } = this.state; - - // restore from localStorage if applicable. - // We don't do this in constructor to avoid server/client render mismatch. - // - this.setState({ - text: localStorage.text || text, - }); - } - - handleTextChange = ({ target: { value } }) => { - this.setState({ - text: value, - disabled: !value || value.length < MIN_REASON_LENGTH, - }); - - // Backup to localStorage - requestAnimationFrame(() => (localStorage.text = value)); - }; - - handleReasonSubmitted = () => { - this.setState({ - text: '', - visible: false, - }); - - requestAnimationFrame(() => (localStorage.text = '')); - }; - - showForm = () => { - this.setState({ visible: true }); - }; - - onCancel = () => { - this.setState({ visible: false }); - }; - - render = () => { - const { text, visible, disabled } = this.state; - - return ( -
    - {visible ? ( -
    -

    - 請告訴其他編輯:您為何覺得這是一則謠言? -

    - - -
    - 送出理由小撇步 -
      -
    • 闡述更多想法
    • -
    • 去 google 查查看
    • -
    • 把全文複製貼上到 Facebook 搜尋框看看
    • -
    • 把你的結果傳給其他編輯參考吧!
    • -
    -
    - - - - - - ) : ( - - )} -
    - ); - }; -} - -export default CreateReplyRequestDialog; diff --git a/components/CreateReplyRequestDialog/index.js b/components/CreateReplyRequestDialog/index.js deleted file mode 100644 index 7051bdf3..00000000 --- a/components/CreateReplyRequestDialog/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import CreateReplyRequestDialog from './CreateReplyRequestDialog.js'; - -export default CreateReplyRequestDialog; diff --git a/components/CreateReplyRequestForm/CreateReplyRequestForm.js b/components/CreateReplyRequestForm/CreateReplyRequestForm.js new file mode 100644 index 00000000..001faf07 --- /dev/null +++ b/components/CreateReplyRequestForm/CreateReplyRequestForm.js @@ -0,0 +1,259 @@ +import React, { useState, useEffect } from 'react'; +import { t } from 'ttag'; +import { useMutation } from '@apollo/react-hooks'; +import { Box, Menu, MenuItem } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import gql from 'graphql-tag'; +import Avatar from 'components/AppLayout/Widgets/Avatar'; +import Hint from 'components/NewReplySection/ReplyForm/Hint'; +import useCurrentUser from 'lib/useCurrentUser'; +import cx from 'clsx'; + +const localStorage = typeof window === 'undefined' ? {} : window.localStorage; + +const useStyles = makeStyles(theme => ({ + button: { + backgroundColor: theme.palette.primary[500], + color: theme.palette.common.white, + cursor: 'pointer', + border: 'none', + outline: 'none', + borderRadius: 30, + padding: '10px 13px', + }, + buttonGroup: { + display: 'flex', + alignItems: 'center', + whiteSpace: 'nowrap', + width: '100%', + marginBottom: 30, + [theme.breakpoints.up('md')]: { + flex: 3, + paddingLeft: 8, + width: 'auto', + marginBottom: 0, + }, + '& button': { + flex: 1, + marginRight: 0, + color: theme.palette.secondary[300], + background: theme.palette.common.white, + outline: 'none', + cursor: 'pointer', + fontSize: 16, + padding: '8px 24px', + border: `1px solid ${theme.palette.secondary[100]}`, + '&:first-child': { + borderTopLeftRadius: 30, + borderBottomLeftRadius: 30, + paddingLeft: 24, + }, + '&:last-child': { + borderTopRightRadius: 30, + borderBottomRightRadius: 30, + paddingRight: 24, + }, + '&:not(:first-child):not(:last-child)': { + borderLeft: 0, + borderRight: 0, + }, + '&:hover': { + background: theme.palette.secondary[50], + }, + '&.active': { + color: theme.palette.primary[500], + }, + }, + }, + form: { + position: 'relative', + flex: 1, + }, + textarea: { + borderRadius: 24, + width: '100%', + border: `1px solid ${theme.palette.secondary[100]}`, + padding: '17px 14px', + '&:focus': { + paddingBottom: 17 + 37, + outline: 'none', + }, + }, + submit: { + position: 'absolute', + bottom: 12, + right: 8, + }, + replyButton: { + flex: 1, + width: '100%', + position: 'absolute', + bottom: 0, + left: 0, + borderRadius: 0, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + [theme.breakpoints.up('md')]: { + position: 'static', + borderRadius: 30, + }, + }, + menu: { + marginTop: 40, + marginLeft: 30, + }, +})); + +const CREATE_REPLY_REQUEST = gql` + mutation CreateReplyRequestFromForm($articleId: String!, $reason: String!) { + CreateReplyRequest(articleId: $articleId, reason: $reason) { + id + } + } +`; +const MIN_REASON_LENGTH = 80; + +const SubmitButton = ({ className, articleId, text, disabled, onFinish }) => { + const [createReplyRequest] = useMutation(CREATE_REPLY_REQUEST, { + refetchQueries: ['LoadArticlePage'], + }); + + const handleSubmit = e => { + e.preventDefault(); // prevent reload + if (disabled) return; + createReplyRequest({ variables: { articleId, reason: text } }); + onFinish(); + }; + + return ( + + ); +}; + +const CreateReplyRequestForm = React.memo( + ({ articleId, requestedForReply, onNewReplyButtonClick }) => { + const [disabled, setDisabled] = useState(false); + const [showForm, setShowForm] = useState(false); + const [text, setText] = useState(''); + + const [shareAnchor, setShareAnchor] = useState(null); + + useEffect(() => { + // restore from localStorage if applicable. + // We don't do this in constructor to avoid server/client render mismatch. + // + setText(localStorage.text || text); + }, []); + + const handleTextChange = ({ target: { value } }) => { + setText(value); + setDisabled(!value || value.length < MIN_REASON_LENGTH); + // Backup to localStorage + requestAnimationFrame(() => (localStorage.text = value)); + }; + + const handleReasonSubmitted = () => { + setText(''); + setDisabled(false); + requestAnimationFrame(() => (localStorage.text = '')); + }; + + const classes = useStyles(); + const user = useCurrentUser(); + + return ( +
    + {showForm && ( + <> + + {t`Did you find anything suspicious about the message?`} + + + + + + +
    +