diff --git a/components/ListPageControls/ArticleTypeFilter.js b/components/ListPageControls/ArticleTypeFilter.js new file mode 100644 index 00000000..1fe09d6c --- /dev/null +++ b/components/ListPageControls/ArticleTypeFilter.js @@ -0,0 +1,48 @@ +import { memo } from 'react'; +import { useRouter } from 'next/router'; +import { t } from 'ttag'; +import BaseFilter from './BaseFilter'; +import { goToUrlQueryAndResetPagination } from 'lib/listPage'; + +/** + * URL param name to read from and write to + */ +const PARAM_NAME = 'articleTypes'; + +const OPTIONS = [ + { value: 'TEXT', label: t`Text` }, + { value: 'IMAGE', label: t`Image` }, + { value: 'VIDEO', label: t`Video` }, + { value: 'AUDIO', label: t`Audio` }, +]; + +/** + * @param {object} query - query from router + * @returns {Arary} list of selected reply types; see constants/replyType for all possible values + */ +function getValues(query) { + return query[PARAM_NAME] ? query[PARAM_NAME].split(',') : []; +} + +function ArticleTypeFilter() { + const { query } = useRouter(); + const selectedValues = getValues(query); + + return ( + + goToUrlQueryAndResetPagination({ + ...query, + [PARAM_NAME]: selected.join(','), + }) + } + /> + ); +} + +const MemoizedArticleTypeFilter = memo(ArticleTypeFilter); +MemoizedArticleTypeFilter.getValues = getValues; +export default MemoizedArticleTypeFilter; diff --git a/components/ListPageControls/index.js b/components/ListPageControls/index.js index b90a433f..e311e7a5 100644 --- a/components/ListPageControls/index.js +++ b/components/ListPageControls/index.js @@ -3,6 +3,7 @@ import Filters from './Filters'; import BaseFilter from './BaseFilter'; import ArticleStatusFilter from './ArticleStatusFilter'; import CategoryFilter from './CategoryFilter'; +import ArticleTypeFilter from './ArticleTypeFilter'; import ReplyTypeFilter from './ReplyTypeFilter'; import TimeRange from './TimeRange'; import SortInput from './SortInput'; @@ -14,6 +15,7 @@ export { BaseFilter, ArticleStatusFilter, CategoryFilter, + ArticleTypeFilter, ReplyTypeFilter, TimeRange, SortInput, diff --git a/components/ListPageDisplays/ArticleCard.js b/components/ListPageDisplays/ArticleCard.js index 5648e0a9..61824277 100644 --- a/components/ListPageDisplays/ArticleCard.js +++ b/components/ListPageDisplays/ArticleCard.js @@ -4,6 +4,7 @@ import { c, t } from 'ttag'; import { makeStyles } from '@material-ui/core/styles'; import Infos, { TimeInfo } from 'components/Infos'; import ExpandableText from 'components/ExpandableText'; +import Thumbnail from 'components/Thumbnail'; import ListPageCard from './ListPageCard'; import { highlightSections } from 'lib/text'; import { useHighlightStyles } from './utils'; @@ -84,7 +85,7 @@ const useStyles = makeStyles(theme => ({ highlight: { color: theme.palette.primary[500], }, - attachmentImage: { + attachment: { minWidth: 0, // Don't use intrinsic image width as flex item min-size maxHeight: '10em', // Don't let image rows take too much vertical space }, @@ -97,14 +98,7 @@ const useStyles = makeStyles(theme => ({ * @param {Highlights?} props.highlight - If given, display search snippet instead of reply text */ function ArticleCard({ article, highlight = '' }) { - const { - id, - text, - attachmentUrl, - replyCount, - replyRequestCount, - createdAt, - } = article; + const { id, text, replyCount, replyRequestCount, createdAt } = article; const classes = useStyles(); const highlightClasses = useHighlightStyles(); @@ -135,13 +129,7 @@ function ArticleCard({ article, highlight = '' }) { : text} )} - {attachmentUrl && ( - image - )} + @@ -154,11 +142,12 @@ ArticleCard.fragments = { fragment ArticleCard on Article { id text - attachmentUrl(variant: THUMBNAIL) replyCount replyRequestCount createdAt + ...ThumbnailArticleData } + ${Thumbnail.fragments.ThumbnailArticleData} `, Highlight: highlightSections.fragments.HighlightFields, }; diff --git a/components/ListPageDisplays/ListPageCards.stories.js b/components/ListPageDisplays/ListPageCards.stories.js index 6c42c8b2..fa71360b 100644 --- a/components/ListPageDisplays/ListPageCards.stories.js +++ b/components/ListPageDisplays/ListPageCards.stories.js @@ -33,6 +33,7 @@ export const ArticleCards = () => ( replyCount: 3, replyRequestCount: 4, createdAt: '2020-01-01T00:00:00Z', + articleType: 'TEXT', }} /> ( replyCount: 0, replyRequestCount: 999, createdAt: '2019-01-01T00:00:00Z', + articleType: 'TEXT', }} highlight={{ text: @@ -59,19 +61,64 @@ export const ArticleCards = () => ( + + + + diff --git a/components/ListPageDisplays/ReplySearchItem.js b/components/ListPageDisplays/ReplySearchItem.js index c03d401f..65a8e25a 100644 --- a/components/ListPageDisplays/ReplySearchItem.js +++ b/components/ListPageDisplays/ReplySearchItem.js @@ -13,6 +13,7 @@ import { import ExpandableText from 'components/ExpandableText'; import Infos from 'components/Infos'; import TimeInfo from 'components/Infos/TimeInfo'; +import Thumbnail from 'components/Thumbnail'; import ReplyItem from './ReplyItem'; import { nl2br } from 'lib/text'; import VisibilityIcon from '@material-ui/icons/Visibility'; @@ -92,10 +93,6 @@ const useStyles = makeStyles(theme => ({ marginBottom: 0, }, }, - attachmentImage: { - maxWidth: '100%', - maxHeight: '8em', // So that image don't take too much space (more than replies) - }, })); function RepliedArticleInfo({ article }) { @@ -146,13 +143,7 @@ export default function ReplySearchItem({
- {articleReply.article.attachmentUrl && ( - image - )} + {articleReply.article.text && ( {nl2br(articleReply.article.text)} @@ -196,13 +187,7 @@ export default function ReplySearchItem({ >
- {article.attachmentUrl && ( - image - )} + {article.text && ( {article.text} @@ -229,9 +214,9 @@ ReplySearchItem.fragments = { article { id text - attachmentUrl(variant: THUMBNAIL) replyRequestCount createdAt + ...ThumbnailArticleData } ...ReplyItemArticleReplyData } @@ -239,6 +224,7 @@ ReplySearchItem.fragments = { } ${ReplyItem.fragments.ReplyItem} ${ReplyItem.fragments.ReplyItemArticleReplyData} + ${Thumbnail.fragments.ThumbnailArticleData} `, Highlight: ReplyItem.fragments.Highlight, }; diff --git a/components/ListPageDisplays/__snapshots__/ListPageCards.stories.storyshot b/components/ListPageDisplays/__snapshots__/ListPageCards.stories.storyshot index a5f20045..514413c1 100644 --- a/components/ListPageDisplays/__snapshots__/ListPageCards.stories.storyshot +++ b/components/ListPageDisplays/__snapshots__/ListPageCards.stories.storyshot @@ -8,6 +8,7 @@ exports[`Storyshots ListPageDisplays/ListPageCards Article Cards 1`] = `
+
@@ -110,6 +124,7 @@ exports[`Storyshots ListPageDisplays/ListPageCards Article Cards 1`] = ` + @@ -255,11 +283,12 @@ exports[`Storyshots ListPageDisplays/ListPageCards Article Cards 1`] = ` @@ -329,11 +358,25 @@ exports[`Storyshots ListPageDisplays/ListPageCards Article Cards 1`] = ` - image + + + @@ -343,11 +386,12 @@ exports[`Storyshots ListPageDisplays/ListPageCards Article Cards 1`] = ` @@ -417,11 +461,436 @@ exports[`Storyshots ListPageDisplays/ListPageCards Article Cards 1`] = ` - image + + + + + + + + + + + + + +
+ +
+ + + + + +
+
+
+
+
+

+ 0 +

+ + replies + +
+
+

+ 0 +

+ + reports + +
+
+ + A video (Preview not supported yet) + +
+
+
+
+ +
+ + + + +
+ +
+ + + + + +
+
+
+
+
+

+ 0 +

+ + replies + +
+
+

+ 0 +

+ + reports + +
+
+ + +
+
+
+
+ +
+ + + + +
+ +
+ + + + + +
+
+
+
+
+

+ 0 +

+ + replies + +
+
+

+ 0 +

+ + reports + +
+
+ + +
+
+
+
+ +
+ + + + +
+ +
+ + + + + +
+
+
+
+
+

+ 0 +

+ + replies + +
+
+

+ 0 +

+ + reports + +
+
+ +
diff --git a/components/NewReplySection/Mobile.js b/components/NewReplySection/Mobile.js index 69162a3a..3d750671 100644 --- a/components/NewReplySection/Mobile.js +++ b/components/NewReplySection/Mobile.js @@ -69,7 +69,7 @@ const useStyles = makeStyles(theme => ({ }, }, }, - attachmentImage: { maxWidth: '100%' }, + attachment: { maxWidth: '100%' }, })); const CustomSelectInput = withStyles(theme => ({ @@ -178,6 +178,31 @@ export default function Mobile({ {selectedTab === 0 && ( + {(() => { + switch (article.articleType) { + case 'IMAGE': + return ( + image + ); + case 'VIDEO': + return ( + )} {selectedTab === 1 && ( diff --git a/components/ProfilePage/CommentTab.js b/components/ProfilePage/CommentTab.js new file mode 100644 index 00000000..bf6ee79c --- /dev/null +++ b/components/ProfilePage/CommentTab.js @@ -0,0 +1,237 @@ +import gql from 'graphql-tag'; +import { useQuery } from '@apollo/react-hooks'; +import { useRouter } from 'next/router'; +import Link from 'next/link'; +import { t, ngettext, msgid } from 'ttag'; +import { makeStyles } from '@material-ui/core/styles'; +import { + Tools, + TimeRange, + SortInput, + LoadMore, +} from 'components/ListPageControls'; +import { CardHeader, CardContent } from 'components/Card'; +import Infos from 'components/Infos'; +import TimeInfo from 'components/Infos/TimeInfo'; +import ExpandableText from 'components/ExpandableText'; +import ReplyRequestReason from 'components/ReplyRequestReason'; +import Thumbnail from 'components/Thumbnail'; + +const COMMENTS_ORDER = [ + { + value: 'createdAt', + label: t`Commented at`, + }, +]; +const DEFAULT_ORDER = COMMENTS_ORDER[0].value; + +const LOAD_USER_COMMENTS = gql` + query LoadUserComments( + $filter: ListReplyRequestFilter! + $orderBy: [ListReplyRequestOrderBy] + $after: String + ) { + ListReplyRequests( + filter: $filter + orderBy: $orderBy + after: $after + first: 15 + ) { + edges { + node { + id + ...ReplyRequestInfo + article { + id + replyRequestCount + createdAt + text + ...ThumbnailArticleData + } + } + ...LoadMoreEdge + } + } + } + ${ReplyRequestReason.fragments.ReplyRequestInfo} + ${LoadMore.fragments.LoadMoreEdge} + ${Thumbnail.fragments.ThumbnailArticleData} +`; + +const LOAD_USER_COMMENTS_STAT = gql` + query LoadUserCommentsStat( + $filter: ListReplyRequestFilter! + $orderBy: [ListReplyRequestOrderBy] + ) { + ListReplyRequests(filter: $filter, orderBy: $orderBy) { + totalCount + ...LoadMoreConnectionForStats + } + } + ${LoadMore.fragments.LoadMoreConnectionForStats} +`; + +const useStyles = makeStyles(theme => ({ + tools: { + [theme.breakpoints.up('sm')]: { + marginLeft: 'var(--card-px)', + marginRight: 'var(--card-px)', + }, + }, + divider: { + border: 0, + margin: '16px 0 0', + borderBottom: `1px dashed ${theme.palette.secondary[100]}`, + }, + infos: { + marginBottom: 4, + [theme.breakpoints.up('md')]: { + marginBottom: 12, + }, + }, +})); + +/** + * @param {object} urlQuery - URL query object and urserId + * @param {string} userId - The author ID of article reply to look for + * @returns {object} ListArticleFilter + */ +function urlQuery2Filter(query = {}, userId) { + const filterObj = { userId }; + + const [start, end] = TimeRange.getValues(query); + + if (start) { + filterObj.createdAt = { + ...filterObj.createdAt, + GTE: start, + }; + } + if (end) { + filterObj.createdAt = { + ...filterObj.createdAt, + LTE: end, + }; + } + + return filterObj; +} + +function CommentTab({ userId }) { + const classes = useStyles(); + const { query } = useRouter(); + + const listQueryVars = { + filter: urlQuery2Filter(query, userId), + orderBy: [{ [SortInput.getValue(query) || DEFAULT_ORDER]: 'DESC' }], + }; + + const { + loading, + fetchMore, + data: listCommentsData, + error: listCommentsError, + } = useQuery(LOAD_USER_COMMENTS, { + skip: !userId, + variables: listQueryVars, + notifyOnNetworkStatusChange: true, // Make loading true on `fetchMore` + }); + + // Separate these stats query so that it will be cached by apollo-client and sends no network request + // on page change, but still works when filter options are updated. + // + const { data: listStatData } = useQuery(LOAD_USER_COMMENTS_STAT, { + skip: !userId, + variables: listQueryVars, + }); + + // List data + const commentEdges = listCommentsData?.ListReplyRequests?.edges || []; + const statsData = listStatData?.ListReplyRequests || {}; + const totalCount = statsData?.totalCount; + + if (!userId) { + return null; + } + + return ( + <> + + + + + {loading && !totalCount ? ( + {t`Loading...`} + ) : listCommentsError ? ( + {listCommentsError.toString()} + ) : totalCount === 0 ? ( + {t`This user does not provide comments to any message in the specified date range.`} + ) : ( + <> + + {ngettext( + msgid`${totalCount} comment matching criteria`, + `${totalCount} comments matching criteria`, + totalCount + )} + + {commentEdges.map(({ node: { article, ...comment } }) => ( + + + <> + {ngettext( + msgid`${article.replyRequestCount} occurrence`, + `${article.replyRequestCount} occurrences`, + article.replyRequestCount + )} + + + {timeAgo => ( + + {t`First reported ${timeAgo}`} + + )} + + + + {article.text && ( + {article.text} + )} + +
+ + +
+ ))} + + + fetchMore({ + variables: args, + updateQuery(prev, { fetchMoreResult }) { + if (!fetchMoreResult) return prev; + const newCommentData = fetchMoreResult?.ListReplyRequests; + return { + ...prev, + ListArticles: { + ...newCommentData, + edges: [...commentEdges, ...newCommentData.edges], + }, + }; + }, + }) + } + /> + + )} + + ); +} + +export default CommentTab; diff --git a/components/ProfilePage/ProfilePage.js b/components/ProfilePage/ProfilePage.js index e1a3917b..4f1c4b52 100644 --- a/components/ProfilePage/ProfilePage.js +++ b/components/ProfilePage/ProfilePage.js @@ -14,6 +14,7 @@ import AppLayout from 'components/AppLayout'; import { Card } from 'components/Card'; import UserPageHeader from './UserPageHeader'; import RepliedArticleTab from './RepliedArticleTab'; +import CommentTab from './CommentTab'; import ContributionChart from 'components/ContributionChart'; import { startOfWeek, subDays, format } from 'date-fns'; @@ -63,6 +64,9 @@ const LOAD_CONTRIBUTION = gql` commentedReplies: ListArticleReplyFeedbacks(filter: { userId: $id }) { totalCount } + comments: ListReplyRequests(filter: { userId: $id }) { + totalCount + } } `; @@ -75,6 +79,7 @@ function ProfilePage({ id, slug }) { const { data: contributionData } = useQuery(LOAD_CONTRIBUTION, { variables: { id: data?.GetUser?.id }, skip: !data?.GetUser?.id, + ssr: false, // Speed up SSR }); const isSelf = currentUser && data?.GetUser?.id === currentUser.id; @@ -123,8 +128,11 @@ function ProfilePage({ id, slug }) { let contentElem = null; switch (tab) { case 'replies': - default: contentElem = ; + break; + case 'comments': + contentElem = ; + break; } const today = format(new Date(), 'yyyy-MM-dd'); const aYearAgo = format( @@ -144,6 +152,7 @@ function ProfilePage({ id, slug }) { stats={{ repliedArticles: contributionData?.repliedArticles?.totalCount, commentedReplies: contributionData?.commentedReplies?.totalCount, + comments: contributionData?.comments?.totalCount, }} /> { - router.push({ query: { tab } }); + router.push({ query: { tab, id, slug } }); }} > + {contentElem} diff --git a/components/ProfilePage/RepliedArticleTab.js b/components/ProfilePage/RepliedArticleTab.js index 8f5833e3..ef9ce286 100644 --- a/components/ProfilePage/RepliedArticleTab.js +++ b/components/ProfilePage/RepliedArticleTab.js @@ -1,6 +1,7 @@ import gql from 'graphql-tag'; import { useQuery } from '@apollo/react-hooks'; import { useRouter } from 'next/router'; +import Link from 'next/link'; import { t, ngettext, msgid } from 'ttag'; import { Box } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; @@ -8,6 +9,7 @@ import { Tools, Filters, CategoryFilter, + ArticleTypeFilter, ReplyTypeFilter, TimeRange, SortInput, @@ -21,6 +23,7 @@ import ArticleReplyFeedbackControl from 'components/ArticleReplyFeedbackControl' import ArticleReplySummary from 'components/ArticleReplySummary'; import Avatar from 'components/AppLayout/Widgets/Avatar'; import ReplyInfo from 'components/ReplyInfo'; +import Thumbnail from 'components/Thumbnail'; import { nl2br, linkify } from 'lib/text'; @@ -48,7 +51,7 @@ const LOAD_REPLIED_ARTICLES = gql` replyRequestCount createdAt text - attachmentUrl(variant: THUMBNAIL) + ...ThumbnailArticleData articleReplies(status: NORMAL) { replyId createdAt @@ -74,6 +77,7 @@ const LOAD_REPLIED_ARTICLES = gql` ${ReplyInfo.fragments.replyInfo} ${Avatar.fragments.AvatarData} ${ArticleReplySummary.fragments.ArticleReplySummaryData} + ${Thumbnail.fragments.ThumbnailArticleData} `; const LOAD_REPLIED_ARTICLES_STAT = gql` @@ -116,10 +120,6 @@ const useStyles = makeStyles(theme => ({ }, reply: { marginLeft: 56 }, replyControl: { marginTop: 16 }, - attachmentImage: { - maxWidth: '100%', - maxHeight: '8em', // So that image don't take too much space (more than replies) - }, })); function ArticleReply({ articleReply }) { @@ -189,6 +189,9 @@ function urlQuery2Filter(query = {}, userId) { }; } + const articleTypes = ArticleTypeFilter.getValues(query); + if (articleTypes.length) filterObj.articleTypes = articleTypes; + const selectedReplyTypes = ReplyTypeFilter.getValues(query); if (selectedReplyTypes.length) filterObj.articleReply.replyTypes = selectedReplyTypes; @@ -240,6 +243,7 @@ function RepliedArticleTab({ userId }) { + @@ -269,16 +273,14 @@ function RepliedArticleTab({ userId }) { )} - {timeAgo => t`First reported ${timeAgo}`} + {timeAgo => ( + + {t`First reported ${timeAgo}`} + + )} - {article.attachmentUrl && ( - image - )} + {article.text && ( {article.text} )} diff --git a/components/ProfilePage/Stats.js b/components/ProfilePage/Stats.js index c00e4ba1..8e3e7826 100644 --- a/components/ProfilePage/Stats.js +++ b/components/ProfilePage/Stats.js @@ -58,6 +58,7 @@ function Stats({ stats }) { return (
    +
); diff --git a/components/ProfilePage/UserPageHeader.js b/components/ProfilePage/UserPageHeader.js index a31f70b2..6b6fe4c4 100644 --- a/components/ProfilePage/UserPageHeader.js +++ b/components/ProfilePage/UserPageHeader.js @@ -179,7 +179,7 @@ const useStyles = makeStyles(theme => ({ * * @param {object} props.user * @param {boolean} props.isSelf - If the current user is the one in `user` prop - * @param {{repliedArticles: number, commentedReplies: number}} props.stats + * @param {{repliedArticles: number, commentedReplies: number, comments: number}} props.stats */ function UserPageHeader({ user, isSelf, stats }) { const classes = useStyles(); diff --git a/components/RelatedReplies.js b/components/RelatedReplies.js index 79702089..199a5ffc 100644 --- a/components/RelatedReplies.js +++ b/components/RelatedReplies.js @@ -9,6 +9,7 @@ import ExpandableText from './ExpandableText'; import { linkify, nl2br } from 'lib/text'; import Link from 'next/link'; import PlainList from 'components/PlainList'; +import Thumbnail from 'components/Thumbnail'; const useStyles = makeStyles(theme => ({ root: { @@ -50,7 +51,7 @@ const useStyles = makeStyles(theme => ({ width: '100%', padding: 12, }, - attachmentImage: { + attachment: { maxWidth: '100%', maxHeight: '10em', // 10 lines height }, @@ -80,9 +81,10 @@ const RelatedArticleReplyData = gql` article { id text - attachmentUrl(variant: THUMBNAIL) + ...ThumbnailArticleData } } + ${Thumbnail.fragments.ThumbnailArticleData} `; /** @@ -115,13 +117,7 @@ function RelatedReplyItem({ article, reply, onConnect, disabled, actionText }) {

{t`Related article`}

- {article.attachmentUrl && ( - image - )} + {article.text && ( {/* diff --git a/components/ReportPage/__snapshots__/ActionButton.stories.storyshot b/components/ReportPage/__snapshots__/ActionButton.stories.storyshot index 3bbcd0c9..bbb04341 100644 --- a/components/ReportPage/__snapshots__/ActionButton.stories.storyshot +++ b/components/ReportPage/__snapshots__/ActionButton.stories.storyshot @@ -9,7 +9,7 @@ exports[`Storyshots ReportPage/ActionButton Default 1`] = ` } >