diff --git a/ui/hooks/src/use-posts.ts b/ui/hooks/src/use-posts.ts index 39331d428e..53c56fd2e8 100644 --- a/ui/hooks/src/use-posts.ts +++ b/ui/hooks/src/use-posts.ts @@ -296,6 +296,7 @@ const usePosts = (props: UsePostsProps): [PostsState, PostsActions] => { // remove the entry from posts object delete posts[res.contentId]; } else { + // update entry in posts object posts[res.contentId] = { ...target, delisted: res.delisted, @@ -474,7 +475,7 @@ const usePosts = (props: UsePostsProps): [PostsState, PostsActions] => { const userPostsCall = postsService.entries.entriesByAuthor(req); const ipfsGatewayCall = ipfsService.getSettings({}); - combineLatest([ipfsGatewayCall, userPostsCall]).subscribe((responses: [any, any]) => { + combineLatest([ipfsGatewayCall, userPostsCall]).subscribe(async (responses: [any, any]) => { const [ipfsGatewayResp, userPostsResp] = responses; const { results, @@ -486,22 +487,78 @@ const usePosts = (props: UsePostsProps): [PostsState, PostsActions] => { total: number; } = userPostsResp.data.getPostsByAuthor; const newIds: string[] = []; + const newQuoteIds: string[] = []; const posts = results .filter(excludeNonSlateContent) .map(entry => { newIds.push(entry._id); + // check if entry has quote and id of such quote is not yet in the list + if (entry.quotes?.length > 0 && newQuoteIds.indexOf(entry.quotes[0]._id) === -1) { + newQuoteIds.push(entry.quotes[0]._id); + } return mapEntry(entry, ipfsGatewayResp.data, logger); }) .reduce((obj, post) => ({ ...obj, [post.entryId]: post }), {}); - setPostsState(prev => ({ - ...prev, - postIds: prev.postIds.concat(newIds), - postsData: { ...prev.postsData, ...posts }, - nextPostIndex: nextIndex, - isFetchingPosts: false, - totalItems: total, - })); + try { + const status = await moderationRequest.checkStatus(true, { user, contentIds: newIds }); + const quotestatus = + !!newQuoteIds.length && + (await moderationRequest.checkStatus(true, { user, contentIds: newQuoteIds })); + if (status && status.constructor === Array) { + status.forEach((res: any) => { + const target = posts[res.contentId]; + let quote: any; + + if (target.quote) { + const { reported, delisted, moderated } = quotestatus.find( + (el: any) => el.contentId === target.quote.entryId, + ); + quote = { + ...target.quote, + // if moderated, bypass value of reported for the user + reported: moderated ? false : reported, + delisted: delisted, + }; + } + + if (res.delisted) { + const index = newIds.indexOf(res.contentId); + if (index > -1) { + // remove the entry id from newIds + newIds.splice(index, 1); + } + // remove the entry from posts object + delete posts[res.contentId]; + } else { + // update entry in posts object + posts[res.contentId] = { + ...target, + delisted: res.delisted, + // if moderated, bypass value of reported for the user + reported: res.moderated ? false : res.reported, + quote: quote, + }; + } + }); + } + setPostsState(prev => ({ + ...prev, + nextPostIndex: nextIndex, + postsData: { ...prev.postsData, ...posts }, + postIds: prev.postIds.concat(newIds), + isFetchingPosts: false, + totalItems: total, + })); + } catch (err) { + newIds.forEach(id => { + createErrorHandler( + `${id}`, + false, + onError, + )(new Error(`Failed to fetch moderated content. ${err.message}`)); + }); + } }, createErrorHandler('usePosts.getUserPosts', false, onError)); }, updatePostsState: (updatedEntry: any) => { diff --git a/ui/plugins/profile/src/components/routes/index.tsx b/ui/plugins/profile/src/components/routes/index.tsx index d2e73b6890..9eb4886bc4 100644 --- a/ui/plugins/profile/src/components/routes/index.tsx +++ b/ui/plugins/profile/src/components/routes/index.tsx @@ -8,7 +8,7 @@ import { RootComponentProps } from '@akashaproject/ui-awf-typings'; import { useLoginState, useModalState, useErrors, useProfile } from '@akashaproject/ui-awf-hooks'; import { MODAL_NAMES } from '@akashaproject/ui-awf-hooks/lib/use-modal-state'; -const { Box, LoginModal } = DS; +const { Box, LoginModal, ViewportSizeProvider } = DS; const Routes: React.FC = props => { const { activeWhen, logger } = props; @@ -30,6 +30,22 @@ const Routes: React.FC = props => { onError: errorActions.createError, }); + const [reportModalOpen, setReportModalOpen] = React.useState(false); + const [flagged, setFlagged] = React.useState(''); + + const showLoginModal = () => { + modalStateActions.show(MODAL_NAMES.LOGIN); + }; + + React.useEffect(() => { + if (loginState.ethAddress) { + hideLoginModal(); + if (!!flagged.length) { + setReportModalOpen(true); + } + } + }, [loginState.ethAddress]); + React.useEffect(() => { if (loginState.pubKey) { loggedProfileActions.getProfileData({ pubKey: loginState.pubKey }); @@ -59,34 +75,41 @@ const Routes: React.FC = props => { }; return ( - - - - <>A list of profiles} /> - - - -
{t('Oops, Profile not found!')}
} /> -
-
- -
+ + + + + <>A list of profiles} /> + + + +
{t('Oops, Profile not found!')}
} /> +
+
+ +
+
); }; diff --git a/ui/plugins/profile/src/components/routes/profile-page.tsx b/ui/plugins/profile/src/components/routes/profile-page.tsx index b40fc2e41e..d63e9aba60 100644 --- a/ui/plugins/profile/src/components/routes/profile-page.tsx +++ b/ui/plugins/profile/src/components/routes/profile-page.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; -import { ProfilePageCard } from '../profile-cards/profile-card'; +import { useTranslation } from 'react-i18next'; +import { useParams, useLocation } from 'react-router-dom'; import DS from '@akashaproject/design-system'; -import { useErrors, usePosts, useProfile } from '@akashaproject/ui-awf-hooks'; +import { constants, useErrors, usePosts, useProfile } from '@akashaproject/ui-awf-hooks'; import { RootComponentProps } from '@akashaproject/ui-awf-typings/src'; -import { useParams, useLocation } from 'react-router-dom'; -import menuRoute, { MY_PROFILE } from '../../routes'; import { ModalState, ModalStateActions, @@ -15,17 +14,35 @@ import FeedWidget, { ItemTypes } from '@akashaproject/ui-widget-feed/lib/compone import { ILoadItemsPayload } from '@akashaproject/design-system/lib/components/VirtualList/interfaces'; import { IContentClickDetails } from '@akashaproject/design-system/lib/components/Cards/entry-cards/entry-box'; -const { Box, Helmet } = DS; +import { ProfilePageCard } from '../profile-cards/profile-card'; +import menuRoute, { MY_PROFILE } from '../../routes'; + +const { Box, Helmet, ReportModal, ToastProvider, ModalRenderer, useViewportSize } = DS; + export interface ProfilePageProps extends RootComponentProps { modalActions: ModalStateActions; modalState: ModalState; ethAddress: string | null; loginActions: UseLoginActions; loggedProfileData: any; + flagged: string; + reportModalOpen: boolean; + showLoginModal: () => void; + setFlagged: React.Dispatch>; + setReportModalOpen: React.Dispatch>; } const ProfilePage = (props: ProfilePageProps) => { - const { ethAddress, loginActions, loggedProfileData } = props; + const { + ethAddress, + loginActions, + loggedProfileData, + flagged, + reportModalOpen, + setFlagged, + showLoginModal, + setReportModalOpen, + } = props; const location = useLocation(); let { pubKey } = useParams() as any; @@ -49,23 +66,54 @@ const ProfilePage = (props: ProfilePageProps) => { user: ethAddress, }); + const virtualListRef = React.useRef(null); + + React.useEffect(() => { + // reset post ids and virtual list, if user logs in + if (ethAddress && virtualListRef.current) { + postsActions.resetPostIds(); + virtualListRef.current.reset(); + } + }, [ethAddress]); + + React.useEffect(() => { + // if post ids array is reset, get user posts + if ( + !!postsState.postIds.length && + !postsState.isFetchingPosts && + postsState.totalItems === null + ) { + postsActions.getUserPosts({ pubKey, limit: 5 }); + } + }, [postsState.postIds, postsState.isFetchingPosts]); + React.useEffect(() => { if (pubKey) { profileActions.getProfileData({ pubKey }); postsActions.resetPostIds(); + if (virtualListRef.current) { + virtualListRef.current.reset(); + } } }, [pubKey]); React.useEffect(() => { if ( - props.loggedProfileData.pubKey && + loggedProfileData.pubKey && pubKey === loggedProfileData.pubKey && !postsState.postIds.length && !postsState.isFetchingPosts ) { - postsActions.getUserPosts({ pubKey: props.loggedProfileData.pubKey, limit: 5 }); + postsActions.getUserPosts({ pubKey: loggedProfileData.pubKey, limit: 5 }); } - }, [props.loggedProfileData.pubKey]); + }, [loggedProfileData.pubKey]); + + const { t } = useTranslation(); + + const { + size, + dimensions: { width }, + } = useViewportSize(); const handleLoadMore = (payload: ILoadItemsPayload) => { const req: { limit: number; offset?: string } = { @@ -123,11 +171,75 @@ const ProfilePage = (props: ProfilePageProps) => { return pubKey; }, [profileState, pubKey]); + const handleEntryFlag = (entryId: string, user?: string | null) => { + if (!user) { + // setting entryId to state first, if not logged in + setFlagged(entryId); + return showLoginModal(); + } + setFlagged(entryId); + setReportModalOpen(true); + }; + + const handleFlipCard = (entry: any, isQuote: boolean) => () => { + const modifiedEntry = isQuote + ? { ...entry, quote: { ...entry.quote, reported: false } } + : { ...entry, reported: false }; + postsActions.updatePostsState(modifiedEntry); + }; + + const updateEntry = (entryId: string) => { + const modifiedEntry = { ...postsState.postsData[entryId], reported: true }; + postsActions.updatePostsState(modifiedEntry); + }; + return ( Profile | {`${profileUserName}`}'s Page + + {reportModalOpen && ( + + { + setReportModalOpen(false); + }} + /> + + )} + { { loggedProfile={loggedProfileData} onRepostPublish={handleRepostPublish} contentClickable={true} + onReport={handleEntryFlag} + handleFlipCard={handleFlipCard} /> ); diff --git a/ui/widgets/feed/src/components/App.tsx b/ui/widgets/feed/src/components/App.tsx index 346ce5f448..71d285e313 100644 --- a/ui/widgets/feed/src/components/App.tsx +++ b/ui/widgets/feed/src/components/App.tsx @@ -18,6 +18,7 @@ export const enum ItemTypes { export interface IFeedWidgetProps { logger: any; i18n: i18n; + virtualListRef: any; globalChannel?: any; sdkModules: any; layout: any; @@ -39,6 +40,8 @@ export interface IFeedWidgetProps { loggedProfile?: any; onRepostPublish?: (entryData: any, embeddedEntry: any) => void; contentClickable?: boolean; + onReport: (entryId: string, user?: string | null) => void; + handleFlipCard?: (entry: any, isQuote: boolean) => () => void; } export default class FeedWidgetRoot extends PureComponent { diff --git a/ui/widgets/feed/src/components/entry-feed.tsx b/ui/widgets/feed/src/components/entry-feed.tsx index 63d5d8023f..a7f0be6d71 100644 --- a/ui/widgets/feed/src/components/entry-feed.tsx +++ b/ui/widgets/feed/src/components/entry-feed.tsx @@ -167,6 +167,7 @@ const EntryFeed = (props: IFeedWidgetProps) => { )} {!hasCriticalErrors && ( { onUnfollow={followActions.unfollow} onBookmark={handleBookmark} onNavigate={props.onNavigate} - onReport={() => { - /* reporting */ - }} + onReport={props.onReport} onRepost={handleRepost} contentClickable={props.contentClickable} + awaitingModerationLabel={t( + 'You have reported this post. It is awaiting moderation.', + )} + moderatedContentLabel={t('This content has been moderated')} + ctaLabel={t('See it anyway')} + handleFlipCard={props.handleFlipCard} /> } /> diff --git a/ui/widgets/feed/src/components/entry-renderer.tsx b/ui/widgets/feed/src/components/entry-renderer.tsx index 82ddd33b5f..a4d51fa89a 100644 --- a/ui/widgets/feed/src/components/entry-renderer.tsx +++ b/ui/widgets/feed/src/components/entry-renderer.tsx @@ -6,7 +6,7 @@ import { IContentClickDetails } from '@akashaproject/design-system/src/component import { useTranslation } from 'react-i18next'; import { ItemTypes } from './App'; -const { ErrorInfoCard, ErrorLoader, EntryCardLoading, EntryCard } = DS; +const { ErrorInfoCard, ErrorLoader, EntryCardLoading, EntryCard, EntryCardHidden } = DS; export interface IEntryRenderer { itemId?: string; @@ -21,12 +21,16 @@ export interface IEntryRenderer { onFollow: (ethAddress: string) => void; onUnfollow: (ethAddress: string) => void; onBookmark: (isBookmarked: boolean, entryId: string) => void; - onReport: (itemId: string, reporterEthAddress: string) => void; + onReport: (entryId?: string, reporterEthAddress?: string | null) => void; onRepost: (withComment: boolean, entryData: any) => void; onNavigate: (itemType: ItemTypes, details: IContentClickDetails) => void; checkIsFollowing: (viewerEthAddress: string, targetEthAddress: string) => void; contentClickable?: boolean; itemType: ItemTypes; + moderatedContentLabel?: string; + awaitingModerationLabel?: string; + ctaLabel?: string; + handleFlipCard?: (entry: any, isQuote: boolean) => () => void; } const EntryRenderer = (props: IEntryRenderer) => { @@ -47,6 +51,10 @@ const EntryRenderer = (props: IEntryRenderer) => { sharePostUrl, onRepost, contentClickable, + moderatedContentLabel, + awaitingModerationLabel, + ctaLabel, + handleFlipCard, } = props; const isBookmarked = React.useMemo(() => { @@ -90,11 +98,10 @@ const EntryRenderer = (props: IEntryRenderer) => { }); }; - const handleEntryReport = () => { - if (onReport && props.ethAddress) { - onReport(itemData.entryId, props.ethAddress); - } + const handleEntryFlag = (entryId: string) => { + onReport(entryId, props.ethAddress); }; + const handleNavigation = (details: IContentClickDetails) => { onNavigate(props.itemType, details); }; @@ -118,6 +125,16 @@ const EntryRenderer = (props: IEntryRenderer) => { followedProfiles, ]); + if (itemData.reported) { + return ( + + ); + } + return ( {(errorMessages: any, hasCriticalErrors: boolean) => ( @@ -155,13 +172,17 @@ const EntryRenderer = (props: IEntryRenderer) => { bookmarkLabel={t('Save')} bookmarkedLabel={t('Saved')} onRepost={onRepost} - onEntryFlag={handleEntryReport} + onEntryFlag={handleEntryFlag} handleFollowAuthor={handleFollow} handleUnfollowAuthor={handleUnfollow} isFollowingAuthor={isFollowing} onContentClick={handleContentClick} onMentionClick={handleMentionClick} contentClickable={contentClickable} + moderatedContentLabel={moderatedContentLabel} + awaitingModerationLabel={awaitingModerationLabel} + ctaLabel={ctaLabel} + handleFlipCard={handleFlipCard} /> )}