diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index 6653a3077..3acb76039 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -57,6 +57,7 @@ jobs: GH_SERVER_MNEMONIC=${{ secrets.SERVER_MNEMONIC }} GH_NEXT_PUBLIC_DATAHUB_QUERY_URL=https://sub-data-hub.subsocial.network/graphql GH_NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL=wss://sub-data-hub.subsocial.network/graphql-ws + GH_NEXT_PUBLIC_APP_ID=1 GH_DATAHUB_QUEUE_URL=https://sub-queue-data-hub.subsocial.network/graphql GH_DATAHUB_QUEUE_TOKEN=${{ secrets.DATAHUB_QUEUE_TOKEN }} # GH_NEXT_PUBLIC_ENABLE_MAINTENANCE_PAGE=true @@ -86,6 +87,7 @@ jobs: GH_SERVER_MNEMONIC=plunge pumpkin penalty segment cattle more print below fat lemon clap uniform GH_NEXT_PUBLIC_DATAHUB_QUERY_URL=https://notifications-data-hub.subsocial.network/graphql GH_NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL=wss://notifications-data-hub.subsocial.network/graphql-ws + GH_NEXT_PUBLIC_APP_ID=12364 GH_DATAHUB_QUEUE_URL=https://notifications-queue-data-hub.subsocial.network/graphql GH_DATAHUB_QUEUE_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZX0.jpXwkIJ4DpV4IvSI3eWVVXE6x89qr_GIq7IlbBv5YE0 # GH_NEXT_PUBLIC_ENABLE_MAINTENANCE_PAGE=true diff --git a/ci.env b/ci.env index 794bd3e35..6ff5bb1b2 100644 --- a/ci.env +++ b/ci.env @@ -11,6 +11,7 @@ SELLER_CLIENT_ID='$GH_SELLER_CLIENT_ID' SELLER_CLIENT_TOKEN_SIGNER='$GH_SELLER_TOKEN_SIGNER' NEXT_PUBLIC_DATAHUB_QUERY_URL='GH_NEXT_PUBLIC_DATAHUB_QUERY_URL' NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL='GH_NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL' +NEXT_PUBLIC_APP_ID='$GH_NEXT_PUBLIC_APP_ID' DATAHUB_QUEUE_URL='GH_DATAHUB_QUEUE_URL' DATAHUB_QUEUE_TOKEN='GH_DATAHUB_QUEUE_TOKEN' SERVER_MNEMONIC='GH_SERVER_MNEMONIC' diff --git a/docker/Dockerfile b/docker/Dockerfile index b1cd9f7a1..609e0401f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -11,6 +11,7 @@ ARG GH_SELLER_CLIENT_ID ARG GH_SELLER_TOKEN_SIGNER ARG GH_NEXT_PUBLIC_DATAHUB_QUERY_URL ARG GH_NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL +ARG GH_NEXT_PUBLIC_APP_ID ARG GH_DATAHUB_QUEUE_URL ARG GH_DATAHUB_QUEUE_TOKEN ARG GH_SERVER_MNEMONIC @@ -27,6 +28,7 @@ ENV NEXT_PUBLIC_GA_ID=${GH_GA_ID} \ SELLER_CLIENT_TOKEN_SIGNER=${GH_SELLER_TOKEN_SIGNER} \ NEXT_PUBLIC_DATAHUB_QUERY_URL=${GH_NEXT_PUBLIC_DATAHUB_QUERY_URL} \ NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL=${GH_NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL} \ + NEXT_PUBLIC_APP_ID=${GH_NEXT_PUBLIC_APP_ID} \ DATAHUB_QUEUE_URL=${GH_DATAHUB_QUEUE_URL} \ DATAHUB_QUEUE_TOKEN=${GH_DATAHUB_QUEUE_TOKEN} \ SERVER_MNEMONIC=${GH_SERVER_MNEMONIC} \ @@ -56,6 +58,7 @@ ARG GH_SELLER_CLIENT_ID ARG GH_SELLER_TOKEN_SIGNER ARG GH_NEXT_PUBLIC_DATAHUB_QUERY_URL ARG GH_NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL +ARG GH_NEXT_PUBLIC_APP_ID ARG GH_DATAHUB_QUEUE_URL ARG GH_DATAHUB_QUEUE_TOKEN ARG GH_SERVER_MNEMONIC @@ -63,6 +66,7 @@ ARG GH_NEXT_PUBLIC_ENABLE_MAINTENANCE_PAGE ENV NEXT_PUBLIC_GA_ID=${GH_GA_ID} \ NEXT_PUBLIC_APP_KIND=${GH_APP_KIND} \ + NEXT_PUBLIC_APP_ID=${GH_APP_ID} \ NEXT_PUBLIC_APP_BASE_URL=${GH_APP_BASE_URL} \ NEXT_PUBLIC_AMP_ID=${GH_AMP_ID} \ NEXT_PUBLIC_OFFCHAIN_SIGNER_URL=${GH_OFFCHAIN_SIGNER_URL} \ @@ -71,6 +75,7 @@ ENV NEXT_PUBLIC_GA_ID=${GH_GA_ID} \ SELLER_CLIENT_TOKEN_SIGNER=${GH_SELLER_TOKEN_SIGNER} \ NEXT_PUBLIC_DATAHUB_QUERY_URL=${GH_NEXT_PUBLIC_DATAHUB_QUERY_URL} \ NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL=${GH_NEXT_PUBLIC_DATAHUB_SUBSCRIPTION_URL} \ + NEXT_PUBLIC_APP_ID=${GH_NEXT_PUBLIC_APP_ID} \ DATAHUB_QUEUE_URL=${GH_DATAHUB_QUEUE_URL} \ DATAHUB_QUEUE_TOKEN=${GH_DATAHUB_QUEUE_TOKEN} \ SERVER_MNEMONIC=${GH_SERVER_MNEMONIC} \ diff --git a/src/components/comments/ViewComment.tsx b/src/components/comments/ViewComment.tsx index d16a8e750..ac19ea338 100644 --- a/src/components/comments/ViewComment.tsx +++ b/src/components/comments/ViewComment.tsx @@ -18,6 +18,7 @@ import { getShortTimeRelativeToNow } from 'src/utils/date' import { activeStakingLinks } from 'src/utils/links' import { useMyAddress } from '../auth/MyAccountsContext' import { FormatBalance } from '../common/balances' +import SubTeamLabel from '../moderation/SubTeamLabel' import { ShareDropdown } from '../posts/share/ShareDropdown' import { useShouldRenderMinStakeAlert } from '../posts/view-post' import { PostDropDownMenu } from '../posts/view-post/PostDropDownMenu' @@ -144,6 +145,7 @@ export const InnerViewComment: FC = props => { OP )} + · diff --git a/src/components/moderation/BlockedAlert.tsx b/src/components/moderation/BlockedAlert.tsx new file mode 100644 index 000000000..aecea397f --- /dev/null +++ b/src/components/moderation/BlockedAlert.tsx @@ -0,0 +1,17 @@ +import { Alert } from 'antd' +import CustomLink from '../referral/CustomLink' +import { CONTENT_POLICY_LINK } from './utils' + +export default function BlockedAlert({ customPrefix = 'This account' }: { customPrefix?: string }) { + return ( + + {customPrefix} was blocked due to a violation of{' '} + Grill's content policy. + + } + /> + ) +} diff --git a/src/components/moderation/BlockedModal.tsx b/src/components/moderation/BlockedModal.tsx new file mode 100644 index 000000000..91a19f8ce --- /dev/null +++ b/src/components/moderation/BlockedModal.tsx @@ -0,0 +1,32 @@ +import { Button } from 'antd' +import CustomLink from '../referral/CustomLink' +import CustomModal from '../utils/CustomModal' +import { APPEAL_LINK, CONTENT_POLICY_LINK } from './utils' + +export default function BlockedModal({ ...props }: { visible: boolean; onCancel: () => void }) { + return ( + + Your account was blocked due to a violation of{' '} + Grill's content policy. + + } + subtitle='You are now restricted from taking actions on Grill, but can still manage your locked SUB, and use other applications running on the Subsocial network.' + > +
+ + + + + + +
+
+ ) +} diff --git a/src/components/moderation/SubTeamLabel.tsx b/src/components/moderation/SubTeamLabel.tsx new file mode 100644 index 000000000..8c5338720 --- /dev/null +++ b/src/components/moderation/SubTeamLabel.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx' +import { CSSProperties } from 'react' +import { useIsAdmin } from 'src/rtk/features/moderation/hooks' + +export default function SubTeamLabel({ + address, + className, + style, +}: { + address: string + className?: string + style?: CSSProperties +}) { + const isAdmin = useIsAdmin(address) + if (!isAdmin) return null + + return ( +
+ + SUB Team + +
+ ) +} diff --git a/src/components/moderation/utils.ts b/src/components/moderation/utils.ts new file mode 100644 index 000000000..f6ff6994e --- /dev/null +++ b/src/components/moderation/utils.ts @@ -0,0 +1,2 @@ +export const CONTENT_POLICY_LINK = '/legal/content-policy' +export const APPEAL_LINK = '/c/appeal' diff --git a/src/components/posts/ModerateButton.tsx b/src/components/posts/ModerateButton.tsx new file mode 100644 index 000000000..733748156 --- /dev/null +++ b/src/components/posts/ModerateButton.tsx @@ -0,0 +1,10 @@ +import { useIsAdmin } from 'src/rtk/features/moderation/hooks' +import { useModerationContext } from './ModerationProvider' + +export default function ModerateButton({ postId }: { postId: string }) { + const isAdmin = useIsAdmin() + const { openModal } = useModerationContext() + if (!isAdmin) return null + + return
openModal(postId)}>Moderate
+} diff --git a/src/components/posts/ModerationProvider.tsx b/src/components/posts/ModerationProvider.tsx new file mode 100644 index 000000000..5df559158 --- /dev/null +++ b/src/components/posts/ModerationProvider.tsx @@ -0,0 +1,65 @@ +import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { useIsAdmin } from 'src/rtk/features/moderation/hooks' +import { parseGrillMessage } from 'src/utils/iframe' +import { useMyAddress } from '../auth/MyAccountsContext' +import GrillIframeModal from '../utils/GrillIframe' + +type State = { + openModal: (postId: string) => void +} + +const Context = React.createContext({ openModal: () => undefined }) + +export default function ModerationProvider({ children }: { children: ReactNode }) { + const [isOpenModal, setIsOpenModal] = useState(false) + const iframeRef = useRef(null) + const myAddress = useMyAddress() + const isAdmin = useIsAdmin(myAddress) + + useEffect(() => { + const listener = (event: MessageEvent) => { + const message = parseGrillMessage(event.data + '') + if (!message) return + + const { name, value } = message + if (name === 'moderation' && value === 'close') { + setIsOpenModal(false) + } + } + window.addEventListener('message', listener) + return () => window.removeEventListener('message', listener) + }, []) + + const value = useMemo(() => { + return { + openModal: (postId: string) => { + iframeRef.current?.contentWindow?.postMessage( + { + type: 'grill:moderate', + payload: postId, + }, + '*', + ) + setIsOpenModal(true) + }, + } + }, []) + + return ( + + {children} + {isAdmin && ( + + )} + + ) +} + +export function useModerationContext() { + return React.useContext(Context) +} diff --git a/src/components/posts/view-post/PostDropDownMenu.tsx b/src/components/posts/view-post/PostDropDownMenu.tsx index 2cc1e11e3..7582858cd 100644 --- a/src/components/posts/view-post/PostDropDownMenu.tsx +++ b/src/components/posts/view-post/PostDropDownMenu.tsx @@ -8,9 +8,11 @@ import { ViewOnIpfs } from 'src/components/utils' import { ButtonLink } from 'src/components/utils/CustomLinks' import { DropdownMenu } from 'src/components/utils/DropDownMenu' import { useSendEvent } from 'src/providers/AnalyticContext' +import { useIsAdmin } from 'src/rtk/features/moderation/hooks' import { PostData, SpaceStruct } from 'src/types' import { useIsMyAddress, useMyAddress } from '../../auth/MyAccountsContext' import HiddenPostButton from '../HiddenPostButton' +import ModerateButton from '../ModerateButton' import MovePostLink from '../MovePostLink' type DropdownProps = { @@ -28,6 +30,7 @@ const InnerPostDropDownMenu: FC = props => { const { struct } = post const postId = struct.id const sendEvent = useSendEvent() + const isAdmin = useIsAdmin() const { canEditPost, canHidePost, canMovePost } = useCheckCanEditAndHideSpacePermission(props) @@ -54,6 +57,11 @@ const InnerPostDropDownMenu: FC = props => { )} )} + {isAdmin && ( + + + + )} {canHidePost && ( @@ -75,13 +83,15 @@ const InnerPostDropDownMenu: FC = props => { ) - }, [myAddress, canEditPost, canHidePost, canMovePost]) + }, [myAddress, canEditPost, canHidePost, canMovePost, isAdmin]) return } const PostDropDown: FC = props => { const [stub, setStub] = useState(true) + // prefetch admin data + useIsAdmin() const closeStub = () => setStub(false) diff --git a/src/components/posts/view-post/PostPage.tsx b/src/components/posts/view-post/PostPage.tsx index 0e6ee3444..08094f1e7 100644 --- a/src/components/posts/view-post/PostPage.tsx +++ b/src/components/posts/view-post/PostPage.tsx @@ -22,12 +22,15 @@ import { return404 } from 'src/components/utils/next' import Segment from 'src/components/utils/Segment' import config from 'src/config' import { POST_VIEW_DURATION } from 'src/config/constants' +import { appId } from 'src/config/env' import { resolveIpfsUrl } from 'src/ipfs' import { getInitialPropsWithRedux, NextContextWithRedux } from 'src/rtk/app' import { useSelectProfile } from 'src/rtk/app/hooks' import { useAppDispatch, useAppSelector } from 'src/rtk/app/store' import { fetchPostRewards } from 'src/rtk/features/activeStaking/postRewardSlice' import { fetchTopUsersWithSpaces } from 'src/rtk/features/leaderboard/topUsersSlice' +import { fetchBlockedResources } from 'src/rtk/features/moderation/blockedResourcesSlice' +import { useIsPostBlocked } from 'src/rtk/features/moderation/hooks' import { fetchPost, fetchPosts, selectPost } from 'src/rtk/features/posts/postsSlice' import { fetchPostsViewCount } from 'src/rtk/features/posts/postsViewCountSlice' import { useFetchMyReactionsByPostId } from 'src/rtk/features/reactions/myPostReactionsHooks' @@ -62,6 +65,8 @@ const InnerPostPage: NextPage = props => { const id = initialPostData.id const { isNotMobile } = useResponsiveSize() useFetchMyReactionsByPostId(id) + // data prefetched + const { isBlocked } = useIsPostBlocked(initialPostData.post.struct) const postData = useAppSelector(state => selectPost(state, { id })) || initialPostData @@ -79,7 +84,7 @@ const InnerPostPage: NextPage = props => { const isUnlistedPost = useIsUnlistedPost({ post: struct, space: space?.struct }) - if (useIsUnlistedSpace(postData.space) || isUnlistedPost) return + if (useIsUnlistedSpace(postData.space) || isUnlistedPost || isBlocked) return if (!content) return null @@ -297,11 +302,12 @@ export async function loadPostOnNextReq({ if (!postId) return return404(context) - const replyIds = await blockchain.getReplyIdsByPostId(idToBn(postId)) - - const ids = replyIds.concat(postId) - - await dispatch(fetchPosts({ api: subsocial, ids, reload: true, eagerLoadHandles: true })) + async function getPost() { + const replyIds = await blockchain.getReplyIdsByPostId(idToBn(postId!)) + const ids = replyIds.concat(postId!) + await dispatch(fetchPosts({ api: subsocial, ids, reload: true, eagerLoadHandles: true })) + } + await Promise.all([getPost(), dispatch(fetchBlockedResources({ appId }))]) const postData = selectPost(reduxStore.getState(), { id: postId }) if (!postData?.space) return return404(context) diff --git a/src/components/posts/view-post/PostPreview.tsx b/src/components/posts/view-post/PostPreview.tsx index 9d4d589eb..d8bf51834 100644 --- a/src/components/posts/view-post/PostPreview.tsx +++ b/src/components/posts/view-post/PostPreview.tsx @@ -6,6 +6,7 @@ import { useMyAddress } from 'src/components/auth/MyAccountsContext' import { addPostView } from 'src/components/utils/datahub/post-view' import { Segment } from 'src/components/utils/Segment' import { POST_VIEW_DURATION } from 'src/config/constants' +import { useIsPostBlocked } from 'src/rtk/features/moderation/hooks' import { asSharedPostStruct, PostWithAllDetails, PostWithSomeDetails, SpaceData } from 'src/types' import { HiddenPostAlert, @@ -56,6 +57,8 @@ export function PostPreview(props: PreviewProps) { const isUnlisted = useIsUnlistedPost({ post, space: space?.struct }) const isHiddenChatRoom = shouldHideChatRooms && HIDE_PREVIEW_FROM_SPACE.includes(post.spaceId ?? '') + // data is prefetched from the getPosts in postsSlice + const { isBlocked } = useIsPostBlocked(post) const { inView, ref } = useInView() useEffect(() => { @@ -91,7 +94,7 @@ export function PostPreview(props: PreviewProps) { } }, [inView, myAddress]) - if (isUnlisted || isHiddenChatRoom) return null + if (isUnlisted || isHiddenChatRoom || isBlocked) return null const postContent = postDetails.post.content const isEmptyContent = !isSharedPost && !postContent?.title && !postContent?.body diff --git a/src/components/posts/view-post/helpers.tsx b/src/components/posts/view-post/helpers.tsx index f43db0481..53758eea1 100644 --- a/src/components/posts/view-post/helpers.tsx +++ b/src/components/posts/view-post/helpers.tsx @@ -8,6 +8,7 @@ import Error from 'next/error' import React, { FC, useState } from 'react' import { RiPushpin2Fill } from 'react-icons/ri' import { TbCoins, TbMessageCircle2 } from 'react-icons/tb' +import SubTeamLabel from 'src/components/moderation/SubTeamLabel' import CustomLink from 'src/components/referral/CustomLink' import { useIsMobileWidthOrDevice } from 'src/components/responsive' import { useIsMySpace } from 'src/components/spaces/helpers' @@ -169,11 +170,14 @@ export const PostCreator: FC = ({ isPadded={false} size={size} afterName={ - isPromoted ? ( - - Promoted - - ) : undefined + <> + + {isPromoted ? ( + + Promoted + + ) : undefined} + } details={
diff --git a/src/components/profile-selector/MyAccountMenu.tsx b/src/components/profile-selector/MyAccountMenu.tsx index a8db8283c..6dde85b26 100644 --- a/src/components/profile-selector/MyAccountMenu.tsx +++ b/src/components/profile-selector/MyAccountMenu.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' import React, { createContext, FC, useContext, useEffect, useRef, useState } from 'react' -import { getCurrentUrlOrigin } from 'src/utils/url' +import { parseGrillMessage } from 'src/utils/iframe' import { InfoDetails } from '../profiles/address-views' import Avatar from '../profiles/address-views/Avatar' import Address from '../profiles/address-views/Name' @@ -9,6 +9,7 @@ import { withMyProfile, withProfileByAccountId, } from '../profiles/address-views/utils/withLoadedOwner' +import GrillIframeModal from '../utils/GrillIframe' type SelectAddressType = AddressProps & { onClick?: () => void @@ -66,24 +67,14 @@ const MyAccountDrawerContext = createContext(initV export const useMyAccountDrawer = () => useContext(MyAccountDrawerContext) -function parseMessage(data: string) { - const match = data.match(/^([^:]+):([^:]+):(.+)$/) - if (!match) return null - - const origin = match[1] - const name = match[2] - const value = match[3] - if (origin !== 'grill') return null - return { name: name ?? '', value: value ?? '' } -} export const AccountMenu: React.FunctionComponent = ({ address, owner }) => { const iframeRef = useRef(null) const [isOpenProfileModal, setIsOpenProfileModal] = useState(false) const router = useRouter() useEffect(() => { - window.onmessage = event => { - const message = parseMessage(event.data + '') + function listener(event: MessageEvent) { + const message = parseGrillMessage(event.data + '') if (!message) return const { name, value } = message @@ -98,11 +89,10 @@ export const AccountMenu: React.FunctionComponent = ({ address, ow setIsOpenProfileModal(false) } } + window.addEventListener('message', listener) + return () => window.removeEventListener('message', listener) }, []) - const origin = getCurrentUrlOrigin() - const isDevMode = origin.includes('localhost') - return ( { @@ -118,23 +108,7 @@ export const AccountMenu: React.FunctionComponent = ({ address, ow className='DfCurrentAddress icon CursorPointer' > - {!isDevMode && ( -