diff --git a/src/components/views/All.jsx b/src/components/views/All.jsx index 48d2d0c6..2967867c 100755 --- a/src/components/views/All.jsx +++ b/src/components/views/All.jsx @@ -1,20 +1,26 @@ -import React, { Fragment, useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react'; +import React, { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react'; +import { confirmAlert } from 'react-confirm-alert'; import { createPortal } from 'react-dom'; import { Helmet } from 'react-helmet-async'; import { Link, useNavigate } from 'react-router-dom'; import { Tooltip } from 'react-tooltip'; import { Virtuoso } from 'react-virtuoso'; -import { useAccount, useAccountComments, useFeed, useSubplebbits } from '@plebbit/plebbit-react-hooks'; +import { useAccount, useAccountComments, useFeed, usePublishCommentEdit, useSubplebbits } from '@plebbit/plebbit-react-hooks'; import { flattenCommentsPages } from '@plebbit/plebbit-react-hooks/dist/lib/utils' import { debounce } from 'lodash'; import useGeneralStore from '../../hooks/stores/useGeneralStore'; import { Container, NavBar, Header, Break, TopBar, BoardForm, PostMenu } from '../styled/views/Board.styled'; -import { Footer } from '../styled/views/Thread.styled'; +import { AuthorDeleteAlert, Footer } from '../styled/views/Thread.styled'; +import { PostMenuCatalog } from '../styled/views/Catalog.styled'; +import EditModal from '../modals/EditModal'; +import EditLabel from '../EditLabel'; import ImageBanner from '../ImageBanner'; +import ModerationModal from '../modals/ModerationModal'; import OfflineIndicator from '../OfflineIndicator'; import Post from '../Post'; import PostLoader from '../PostLoader'; import PostOnHover from '../PostOnHover'; +import StateLabel from '../StateLabel'; import ReplyModal from '../modals/ReplyModal'; import SettingsModal from '../modals/SettingsModal'; import findShortParentCid from '../../utils/findShortParentCid'; @@ -28,6 +34,7 @@ import handleStyleChange from '../../utils/handleStyleChange'; import removeHighlight from '../../utils/removeHighlight'; import useError from '../../hooks/useError'; import useFeedStateString from '../../hooks/useFeedStateString'; +import useSuccess from '../../hooks/useSuccess'; import packageJson from '../../../package.json' const {version} = packageJson @@ -35,7 +42,17 @@ const {version} = packageJson const All = () => { const { defaultSubplebbits, + editedComment, isSettingsOpen, setIsSettingsOpen, + setModeratingCommentCid, + selectedAddress, + setCaptchaResponse, + setChallengesArray, + setIsAuthorDelete, + setIsAuthorEdit, + setIsCaptchaOpen, + isModerationOpen, setIsModerationOpen, + setResolveCaptchaPromise, setSelectedAddress, setSelectedParentCid, setSelectedShortCid, @@ -47,22 +64,33 @@ const All = () => { const account = useAccount(); const navigate = useNavigate(); const [errorMessage, setErrorMessage] = useError(); + const [, setSuccessMessage] = useSuccess(); const threadMenuRefs = useRef({}); const replyMenuRefs = useRef({}); + const postMenuCatalogRef = useRef(null); const backlinkRefs = useRef({}); const quoteRefs = useRef({}); + const backlinkRefsMobile = useRef({}); + const quoteRefsMobile = useRef({}); const postOnHoverRef = useRef(null); const [isReplyOpen, setIsReplyOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [originalCommentContent, setOriginalCommentContent] = useState(null); const [prevScrollPos, setPrevScrollPos] = useState(0); const [visible, setVisible] = useState(true); - const [rotatedStates, setRotatedStates] = useState({}); const [isImageSearchOpen, setIsImageSearchOpen] = useState(false); + const [isModerator, setIsModerator] = useState(false); + const [commentCid, setCommentCid] = useState(null); + const [menuPosition, setMenuPosition] = useState({top: 0, left: 0}); + const [triggerPublishCommentEdit, setTriggerPublishCommentEdit] = useState(false); + const [openMenuCid, setOpenMenuCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const [outOfViewPosition, setOutOfViewPosition] = useState({top: 0, left: 0}); const [postOnHoverHeight, setPostOnHoverHeight] = useState(0); - + const [deletePost, setDeletePost] = useState(false); + const [moderatorPermissions, setModeratorPermissions] = useState({}); const addresses = defaultSubplebbits.map(subplebbit => subplebbit.address); const { feed, hasMore, loadMore } = useFeed({subplebbitAddresses: addresses, sortType: 'new'}); @@ -71,6 +99,53 @@ const All = () => { const stateString = useFeedStateString(subplebbits); + + useEffect(() => { + let permissions = {}; + + selectedFeed.forEach(thread => { + const subplebbit = subplebbits.find(s => s.address === thread.subplebbitAddress); + + if (subplebbit && subplebbit.roles !== undefined) { + const role = subplebbit.roles[account?.author.address]?.role; + + if (role === 'moderator' || role === 'admin' || role === 'owner') { + permissions[thread.subplebbitAddress] = true; + } else { + permissions[thread.subplebbitAddress] = false; + } + } + }); + + setModeratorPermissions(permissions); + }, [account?.author.address, selectedFeed, subplebbits]); + + + const handleOptionClick = () => { + setOpenMenuCid(null); + }; + + + const handleOutsideClick = useCallback((e) => { + if (openMenuCid !== null && !postMenuCatalogRef.current.contains(e.target)) { + setOpenMenuCid(null); + } + }, [openMenuCid, postMenuCatalogRef]); + + + useEffect(() => { + if (openMenuCid !== null) { + document.addEventListener('click', handleOutsideClick); + } else { + document.removeEventListener('click', handleOutsideClick); + } + + return () => { + document.removeEventListener('click', handleOutsideClick); + }; + }, [openMenuCid, handleOutsideClick]); + + const errorString = useMemo(() => { for (const subplebbit of subplebbits) { if (subplebbit?.updatingState !== 'failed') { @@ -170,6 +245,154 @@ const All = () => { } }; + const onChallengeVerification = (challengeVerification) => { + if (challengeVerification.challengeSuccess === true) { + setSuccessMessage('Challenge Success'); + } + else if (challengeVerification.challengeSuccess === false) { + setErrorMessage('Challenge Failed', {reason: challengeVerification.reason, errors: challengeVerification.errors}); + } + }; + + + const onChallenge = async (challenges, comment) => { + let challengeAnswers = []; + try { + challengeAnswers = await getChallengeAnswersFromUser(challenges) + } + catch (error) { + setErrorMessage(error); + } + if (challengeAnswers) { + await comment.publishChallengeAnswers(challengeAnswers) + } + }; + + + const getChallengeAnswersFromUser = async (challenges) => { + setChallengesArray(challenges); + + return new Promise((resolve, reject) => { + const imageString = challenges?.challenges[0].challenge; + const imageSource = `data:image/png;base64,${imageString}`; + const challengeImg = new Image(); + challengeImg.src = imageSource; + + challengeImg.onload = () => { + setIsCaptchaOpen(true); + + const handleKeyDown = async (event) => { + if (event.key === 'Enter') { + const currentCaptchaResponse = useGeneralStore.getState().captchaResponse; + resolve(currentCaptchaResponse); + setIsCaptchaOpen(false); + document.removeEventListener('keydown', handleKeyDown); + event.preventDefault(); + } + }; + + setCaptchaResponse(''); + document.addEventListener('keydown', handleKeyDown); + + setResolveCaptchaPromise(resolve); + }; + + challengeImg.onerror = () => { + reject(setErrorMessage('Could not load challenges')); + }; + }); + }; + + const [publishCommentEditOptions, setPublishCommentEditOptions] = useState({ + commentCid: commentCid, + content: editedComment || undefined, + subplebbitAddress: selectedAddress, + onChallenge, + onChallengeVerification, + onError: (error) => { + setErrorMessage(error); + }, + }); + + + const {error, publishCommentEdit } = usePublishCommentEdit(publishCommentEditOptions); + + useEffect(() => { + if (error && error !== errorMessage) { + setErrorMessage(error); + } + }, [error, setErrorMessage, errorMessage]); + + + const handleAuthorDeleteClick = (commentCid) => { + handleOptionClick(commentCid); + + confirmAlert({ + customUI: ({ onClose }) => { + return ( + +
+

Are you sure you want to delete this post?

+
+ + +
+
+
+ ); + } + }); + }; + + const handleAuthorEditClick = (comment) => { + handleOptionClick(comment.cid); + setIsAuthorEdit(true); + setIsAuthorDelete(false); + setCommentCid(comment.cid); + setOriginalCommentContent(comment.content); + setIsEditModalOpen(true); + } + + + useEffect(() => { + setPublishCommentEditOptions((prevOptions) => ({ + ...prevOptions, + commentCid: commentCid, + content: editedComment || undefined, + })); + }, [commentCid, editedComment]); + + + useEffect(() => { + if (editedComment !== '') { + setTriggerPublishCommentEdit(true); + } + }, [editedComment, setIsAuthorEdit]); + + + useEffect(() => { + if (publishCommentEditOptions && triggerPublishCommentEdit) { + (async () => { + await publishCommentEdit(); + setTriggerPublishCommentEdit(false); + })(); + } + }, [publishCommentEditOptions, triggerPublishCommentEdit, publishCommentEdit]); + // desktop navbar board select functionality const handleClickTitle = (title, address) => { setSelectedTitle(title); @@ -218,6 +441,16 @@ const All = () => { selectedStyle={selectedStyle} isOpen={isSettingsOpen} closeModal={() => setIsSettingsOpen(false)} /> + {setIsModerationOpen(false); setDeletePost(false)}} + deletePost={deletePost} /> + setIsEditModalOpen(false)} + originalCommentContent={originalCommentContent} /> <> @@ -336,6 +569,7 @@ const All = () => { const { displayedReplies, omittedCount } = filteredRepliesByThread[thread.cid] || {}; const commentMediaInfo = getCommentMediaInfo(thread); const fallbackImgUrl = "assets/filedeleted-res.gif"; + setIsModerator(moderatorPermissions[thread.subplebbitAddress]); return (
@@ -469,63 +703,100 @@ const All = () => { threadMenuRefs.current[thread.cid] = el} + ref={el => { + threadMenuRefs.current[thread.cid] = el; + }} className='post-menu-button' - rotated={rotatedStates[thread.cid]} - onClick={() => { + rotated={openMenuCid === thread.cid} + onClick={(event) => { + event.stopPropagation(); const rect = threadMenuRefs.current[thread.cid].getBoundingClientRect(); - const menu = document.querySelector(`.post-menu-thread-${thread.cid}`); - const scrollY = window.scrollY || window.pageYOffset; - menu.style.top = `calc(${rect.top + scrollY}px - 225px)`; - menu.style.left = `${rect.left}px`; - - setRotatedStates(prevState => ({ - ...prevState, - [thread.cid]: !prevState[thread.cid] - })); + setMenuPosition({top: rect.top + window.scrollY, left: rect.left}); + setOpenMenuCid(prevCid => (prevCid === thread.cid ? null : thread.cid)); }} > ▶ -
-
    - {/*
  • Edit post
  • */} -
  • Hide thread
  • - {(commentMediaInfo && ( - commentMediaInfo.type === 'image' || - (commentMediaInfo.type === 'webpage' && - commentMediaInfo.thumbnail))) ? ( -
  • {setIsImageSearchOpen(true)}} - onMouseLeave={() => {setIsImageSearchOpen(false)}}> - Image search » -
      -
    • - Google + {createPortal( + {postMenuCatalogRef.current = el}} + onClick={(event) => event.stopPropagation()} + style={{position: "absolute", + top: menuPosition.top + 7, + left: menuPosition.left}}> +
      +
        +
      • handleOptionClick(thread.cid)}>Hide thread
      • + {thread.author.shortAddress === account?.author.shortAddress ? ( + <> +
      • {handleAuthorEditClick(thread); setSelectedAddress(thread.subplebbitAddress);}}>Edit post
      • +
      • {handleAuthorDeleteClick(thread.cid); setSelectedAddress(thread.subplebbitAddress);}}>Delete post
      • + + ) : null} + {isModerator ? ( + <> + {thread.author.shortAddress === account?.author.shortAddress ? ( + null + ) : ( +
      • { + setSelectedAddress(thread.subplebbitAddress); + setModeratingCommentCid(thread.cid) + setIsModerationOpen(true); + handleOptionClick(thread.cid); + setDeletePost(true); + }}> + Delete post
      • -
      • - Yandex -
      • -
      • - SauceNAO -
      • -
      -
    • - ) : null - } -
    -
+ )} +
  • { + setSelectedAddress(thread.subplebbitAddress); + setModeratingCommentCid(thread.cid) + setIsModerationOpen(true); + handleOptionClick(thread.cid); + }}> + Mod tools +
  • + + ) : null} + {(commentMediaInfo && ( + commentMediaInfo.type === 'image' || + (commentMediaInfo.type === 'webpage' && + commentMediaInfo.thumbnail))) ? ( +
  • {setIsImageSearchOpen(true)}} + onMouseLeave={() => {setIsImageSearchOpen(false)}}> + Image search » +
      +
    • handleOptionClick(thread.cid)}> + Google +
    • +
    • handleOptionClick(thread.cid)}> + Yandex +
    • +
    • handleOptionClick(thread.cid)}> + SauceNAO +
    • +
    +
  • + ) : null + } + +
    + , document.body + )} @@ -695,64 +980,102 @@ const All = () => { className="offline-reply" />
      replyMenuRefs.current[reply.cid] = el} - className='post-menu-button' - rotated={rotatedStates[reply.cid]} - onClick={() => { - const rect = replyMenuRefs.current[reply.cid].getBoundingClientRect(); - const menu = document.querySelector(`.post-menu-reply-${reply.cid}`); - const scrollY = window.scrollY || window.pageYOffset; - menu.style.top = `calc(${rect.top + scrollY}px - 225px)`; - menu.style.left = `${rect.left}px`; - - setRotatedStates(prevState => ({ - ...prevState, - [reply.cid]: !prevState[reply.cid] - })); - }} - > - ▶ + key={`pmb-${index}`} + title="Post menu" + ref={el => { + replyMenuRefs.current[reply.cid] = el; + }} + className='post-menu-button' + rotated={openMenuCid === reply.cid} + onClick={(event) => { + event.stopPropagation(); + const rect = replyMenuRefs.current[reply.cid].getBoundingClientRect(); + setMenuPosition({top: rect.top + window.scrollY, left: rect.left}); + setOpenMenuCid(prevCid => (prevCid === reply.cid ? null : reply.cid)); + }} + > + ▶ -
    -
      - {/*
    • Edit post
    • */} -
    • Hide post
    • + {createPortal( + {postMenuCatalogRef.current = el}} + onClick={(event) => event.stopPropagation()} + style={{position: "absolute", + top: menuPosition.top + 7, + left: menuPosition.left}}> +
      +
        +
      • handleOptionClick(reply.cid)}>Hide post
      • + {reply.author.shortAddress === account?.author.shortAddress ? ( + <> +
      • {handleAuthorEditClick(reply); setSelectedAddress(thread.subplebbitAddress);}}>Edit post
      • +
      • {handleAuthorDeleteClick(reply.cid); setSelectedAddress(thread.subplebbitAddress);}}>Delete post
      • + + ) : null} + {isModerator ? ( + <> + {reply.author.shortAddress === account?.author.shortAddress ? ( + null + ) : ( +
      • { + setSelectedAddress(thread.subplebbitAddress); + setModeratingCommentCid(reply.cid) + setIsModerationOpen(true); + handleOptionClick(reply.cid); + setDeletePost(true); + }}> + Delete post +
      • + )} +
      • { + setSelectedAddress(thread.subplebbitAddress); + setModeratingCommentCid(reply.cid) + setIsModerationOpen(true); + handleOptionClick(reply.cid); + }}> + Mod tools +
      • + + ) : null} {(replyMediaInfo && ( replyMediaInfo.type === 'image' || (replyMediaInfo.type === 'webpage' && replyMediaInfo.thumbnail))) ? ( -
      • {setIsImageSearchOpen(true)}} - onMouseLeave={() => {setIsImageSearchOpen(false)}}> - Image search » - -
      • - ) : null} +
      • {setIsImageSearchOpen(true)}} + onMouseLeave={() => {setIsImageSearchOpen(false)}}> + Image search » +
          +
        • handleOptionClick(reply.cid)}> + Google +
        • +
        • handleOptionClick(reply.cid)}> + Yandex +
        • +
        • handleOptionClick(reply.cid)}> + SauceNAO +
        • +
        +
      • + ) : null + }
      +
      , document.body + )} -
    + }); + }} + onMouseLeave={() => { + removeHighlight(); + setOutOfViewCid(null); + }}> + {`c/${shortParentCid}`}{shortParentCid === thread.shortCid ? " (OP)" : null} + + + + + ) + : null} + + ) })} @@ -1110,14 +1446,27 @@ const All = () => {
    (...) -

    +
    + + +
    Post too long.  - setSelectedThread(thread.cid)} className="ttl-link">Click here + setSelectedThread(thread.cid)} className="ttl-link">Click here  to view.
    :
    + +
    ) : null} @@ -1271,27 +1620,116 @@ const All = () => {
    {}} key={`mob-r-pm-${index}`} className="quotelink" - onClick={(event) => handleQuoteClick(reply, shortParentCid, thread.shortCid, event)} - onMouseOver={(event) => handleQuoteHover(reply, shortParentCid, thread.shortCid, event)} - onMouseLeave={removeHighlight}> + ref={el => { + quoteRefsMobile.current[reply.cid] = el; + }} + onClick={(event) => handleQuoteClick(reply, shortParentCid, event)} + onMouseOver={(event) => { + event.stopPropagation(); + handleQuoteHover(reply, shortParentCid, () => { + setOutOfViewCid(reply.parentCid); + const rect = quoteRefsMobile.current[reply.cid].getBoundingClientRect(); + const distanceToRight = window.innerWidth - rect.right; + const distanceToTop = rect.top; + const distanceToBottom = window.innerHeight - rect.bottom; + let top; + + if (distanceToTop < postOnHoverHeight / 2) { + top = window.scrollY - 5; + } else if (distanceToBottom < postOnHoverHeight / 2) { + top = window.scrollY - postOnHoverHeight + window.innerHeight - 10; + } else { + top = rect.top + window.scrollY - postOnHoverHeight / 2; + } + + if (distanceToRight < 200) { + setOutOfViewPosition({ + top, + right: window.innerWidth - rect.left - 10, + maxWidth: rect.left - 5 + }); + } else { + setOutOfViewPosition({ + top, + left: rect.left + rect.width + 5, + maxWidth: window.innerWidth - rect.left - rect.width - 5 + }); + } + }); + }} + onMouseLeave={() => { + removeHighlight(); + setOutOfViewCid(null); + }}> {`c/${shortParentCid}`}{shortParentCid === thread.shortCid ? " (OP)" : null} (...) -

    +
    + + +
    Comment too long.  - setSelectedThread(thread.cid)} className="ttl-link">Click here + setSelectedThread(thread.cid)} className="ttl-link">Click here  to view.
    :
    {}} key={`mob-r-pm-${index}`} className="quotelink" - onClick={(event) => handleQuoteClick(reply, shortParentCid, thread.shortCid, event)} - onMouseOver={(event) => handleQuoteHover(reply, shortParentCid, thread.shortCid, event)} - onMouseLeave={removeHighlight}> + ref={el => { + quoteRefsMobile.current[reply.cid] = el; + }} + onClick={(event) => handleQuoteClick(reply, shortParentCid, event)} + onMouseOver={(event) => { + event.stopPropagation(); + handleQuoteHover(reply, shortParentCid, () => { + setOutOfViewCid(reply.parentCid); + const rect = quoteRefsMobile.current[reply.cid].getBoundingClientRect(); + const distanceToRight = window.innerWidth - rect.right; + const distanceToTop = rect.top; + const distanceToBottom = window.innerHeight - rect.bottom; + let top; + + if (distanceToTop < postOnHoverHeight / 2) { + top = window.scrollY - 5; + } else if (distanceToBottom < postOnHoverHeight / 2) { + top = window.scrollY - postOnHoverHeight + window.innerHeight - 10; + } else { + top = rect.top + window.scrollY - postOnHoverHeight / 2; + } + + if (distanceToRight < 200) { + setOutOfViewPosition({ + top, + right: window.innerWidth - rect.left - 10, + maxWidth: rect.left - 5 + }); + } else { + setOutOfViewPosition({ + top, + left: rect.left + rect.width + 5, + maxWidth: window.innerWidth - rect.left - rect.width - 5 + }); + } + }); + }} + onMouseLeave={() => { + removeHighlight(); + setOutOfViewCid(null); + }}> {`c/${shortParentCid}`}{shortParentCid === thread.shortCid ? " (OP)" : null} + +
    ) : null} {reply.replyCount > 0 ? ( @@ -1299,11 +1737,49 @@ const All = () => { {reply.replies?.pages?.topAll.comments .sort((a, b) => a.timestamp - b.timestamp) .map((reply, index) => ( -
    +
    { + backlinkRefsMobile.current[reply.cid] = el; + }}> {}} onClick={(event) => handleQuoteClick(reply, reply.shortCid, event)} - onMouseOver={(event) => handleQuoteHover(reply, reply.shortCid, event)} - onMouseLeave={removeHighlight} + onMouseOver={(event) => { + event.stopPropagation(); + handleQuoteHover(reply, reply.shortCid, () => { + setOutOfViewCid(reply.cid) + const rect = backlinkRefsMobile.current[reply.cid].getBoundingClientRect(); + const distanceToRight = window.innerWidth - rect.right; + const distanceToTop = rect.top; + const distanceToBottom = window.innerHeight - rect.bottom; + let top; + + if (distanceToTop < postOnHoverHeight / 2) { + top = window.scrollY - 5; + } else if (distanceToBottom < postOnHoverHeight / 2) { + top = window.scrollY - postOnHoverHeight + window.innerHeight - 10; + } else { + top = rect.top + window.scrollY - postOnHoverHeight / 2; + } + + if (distanceToRight < 200) { + setOutOfViewPosition({ + top, + right: window.innerWidth - rect.left - 10, + maxWidth: rect.left - 5 + }); + } else { + setOutOfViewPosition({ + top, + left: rect.left + rect.width + 5, + maxWidth: window.innerWidth - rect.left - rect.width - 5 + }); + } + }); + }} + onMouseLeave={() => { + removeHighlight(); + setOutOfViewCid(null); + }} className="quote-link"> c/{reply.shortCid}   diff --git a/src/components/views/Subscriptions.jsx b/src/components/views/Subscriptions.jsx index 7cb72830..ddf8758e 100755 --- a/src/components/views/Subscriptions.jsx +++ b/src/components/views/Subscriptions.jsx @@ -1,20 +1,26 @@ -import React, { Fragment, useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react'; +import React, { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react'; +import { confirmAlert } from 'react-confirm-alert'; import { createPortal } from 'react-dom'; import { Helmet } from 'react-helmet-async'; import { Link, useNavigate } from 'react-router-dom'; import { Tooltip } from 'react-tooltip'; import { Virtuoso } from 'react-virtuoso'; -import { useAccount, useAccountComments, useFeed, useSubplebbits } from '@plebbit/plebbit-react-hooks'; +import { useAccount, useAccountComments, useFeed, usePublishCommentEdit, useSubplebbits } from '@plebbit/plebbit-react-hooks'; import { flattenCommentsPages } from '@plebbit/plebbit-react-hooks/dist/lib/utils' import { debounce } from 'lodash'; import useGeneralStore from '../../hooks/stores/useGeneralStore'; import { Container, NavBar, Header, Break, TopBar, BoardForm, PostMenu } from '../styled/views/Board.styled'; -import { Footer } from '../styled/views/Thread.styled'; +import { AuthorDeleteAlert, Footer } from '../styled/views/Thread.styled'; +import { PostMenuCatalog } from '../styled/views/Catalog.styled'; +import EditModal from '../modals/EditModal'; +import EditLabel from '../EditLabel'; import ImageBanner from '../ImageBanner'; +import ModerationModal from '../modals/ModerationModal'; import OfflineIndicator from '../OfflineIndicator'; import Post from '../Post'; import PostLoader from '../PostLoader'; import PostOnHover from '../PostOnHover'; +import StateLabel from '../StateLabel'; import ReplyModal from '../modals/ReplyModal'; import SettingsModal from '../modals/SettingsModal'; import findShortParentCid from '../../utils/findShortParentCid'; @@ -28,6 +34,7 @@ import handleStyleChange from '../../utils/handleStyleChange'; import removeHighlight from '../../utils/removeHighlight'; import useError from '../../hooks/useError'; import useFeedStateString from '../../hooks/useFeedStateString'; +import useSuccess from '../../hooks/useSuccess'; import packageJson from '../../../package.json' const {version} = packageJson @@ -35,7 +42,17 @@ const {version} = packageJson const Subscriptions = () => { const { defaultSubplebbits, + editedComment, isSettingsOpen, setIsSettingsOpen, + setModeratingCommentCid, + selectedAddress, + setCaptchaResponse, + setChallengesArray, + setIsAuthorDelete, + setIsAuthorEdit, + setIsCaptchaOpen, + isModerationOpen, setIsModerationOpen, + setResolveCaptchaPromise, setSelectedAddress, setSelectedParentCid, setSelectedShortCid, @@ -46,23 +63,34 @@ const Subscriptions = () => { const account = useAccount(); const navigate = useNavigate(); - const [errorMessage, setErrorMessage] = useError(); + const [, setSuccessMessage] = useSuccess(); const threadMenuRefs = useRef({}); const replyMenuRefs = useRef({}); + const postMenuCatalogRef = useRef(null); const backlinkRefs = useRef({}); const quoteRefs = useRef({}); + const backlinkRefsMobile = useRef({}); + const quoteRefsMobile = useRef({}); const postOnHoverRef = useRef(null); const [isReplyOpen, setIsReplyOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [originalCommentContent, setOriginalCommentContent] = useState(null); const [prevScrollPos, setPrevScrollPos] = useState(0); const [visible, setVisible] = useState(true); - const [rotatedStates, setRotatedStates] = useState({}); const [isImageSearchOpen, setIsImageSearchOpen] = useState(false); + const [isModerator, setIsModerator] = useState(false); + const [commentCid, setCommentCid] = useState(null); + const [menuPosition, setMenuPosition] = useState({top: 0, left: 0}); + const [triggerPublishCommentEdit, setTriggerPublishCommentEdit] = useState(false); + const [openMenuCid, setOpenMenuCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const [outOfViewPosition, setOutOfViewPosition] = useState({top: 0, left: 0}); const [postOnHoverHeight, setPostOnHoverHeight] = useState(0); + const [deletePost, setDeletePost] = useState(false); + const [moderatorPermissions, setModeratorPermissions] = useState({}); const { feed, hasMore, loadMore } = useFeed({subplebbitAddresses: account?.subscriptions, sortType: 'new'}); const [selectedFeed, setSelectedFeed] = useState(feed.sort((a, b) => b.timestamp - a.timestamp)); @@ -70,6 +98,53 @@ const Subscriptions = () => { const stateString = useFeedStateString(subplebbits); + + useEffect(() => { + let permissions = {}; + + selectedFeed.forEach(thread => { + const subplebbit = subplebbits.find(s => s.address === thread.subplebbitAddress); + + if (subplebbit && subplebbit.roles !== undefined) { + const role = subplebbit.roles[account?.author.address]?.role; + + if (role === 'moderator' || role === 'admin' || role === 'owner') { + permissions[thread.subplebbitAddress] = true; + } else { + permissions[thread.subplebbitAddress] = false; + } + } + }); + + setModeratorPermissions(permissions); + }, [account?.author.address, selectedFeed, subplebbits]); + + + const handleOptionClick = () => { + setOpenMenuCid(null); + }; + + + const handleOutsideClick = useCallback((e) => { + if (openMenuCid !== null && !postMenuCatalogRef.current.contains(e.target)) { + setOpenMenuCid(null); + } + }, [openMenuCid, postMenuCatalogRef]); + + + useEffect(() => { + if (openMenuCid !== null) { + document.addEventListener('click', handleOutsideClick); + } else { + document.removeEventListener('click', handleOutsideClick); + } + + return () => { + document.removeEventListener('click', handleOutsideClick); + }; + }, [openMenuCid, handleOutsideClick]); + + const errorString = useMemo(() => { for (const subplebbit of subplebbits) { if (subplebbit?.updatingState !== 'failed') { @@ -169,6 +244,157 @@ const Subscriptions = () => { } }; + + const onChallengeVerification = (challengeVerification) => { + if (challengeVerification.challengeSuccess === true) { + setSuccessMessage('Challenge Success'); + } + else if (challengeVerification.challengeSuccess === false) { + setErrorMessage('Challenge Failed', {reason: challengeVerification.reason, errors: challengeVerification.errors}); + } + }; + + + const onChallenge = async (challenges, comment) => { + let challengeAnswers = []; + try { + challengeAnswers = await getChallengeAnswersFromUser(challenges) + } + catch (error) { + setErrorMessage(error); + } + if (challengeAnswers) { + await comment.publishChallengeAnswers(challengeAnswers) + } + }; + + + const getChallengeAnswersFromUser = async (challenges) => { + setChallengesArray(challenges); + + return new Promise((resolve, reject) => { + const imageString = challenges?.challenges[0].challenge; + const imageSource = `data:image/png;base64,${imageString}`; + const challengeImg = new Image(); + challengeImg.src = imageSource; + + challengeImg.onload = () => { + setIsCaptchaOpen(true); + + const handleKeyDown = async (event) => { + if (event.key === 'Enter') { + const currentCaptchaResponse = useGeneralStore.getState().captchaResponse; + resolve(currentCaptchaResponse); + setIsCaptchaOpen(false); + document.removeEventListener('keydown', handleKeyDown); + event.preventDefault(); + } + }; + + setCaptchaResponse(''); + document.addEventListener('keydown', handleKeyDown); + + setResolveCaptchaPromise(resolve); + }; + + challengeImg.onerror = () => { + reject(setErrorMessage('Could not load challenges')); + }; + }); + }; + + + const [publishCommentEditOptions, setPublishCommentEditOptions] = useState({ + commentCid: commentCid, + content: editedComment || undefined, + subplebbitAddress: selectedAddress, + onChallenge, + onChallengeVerification, + onError: (error) => { + setErrorMessage(error); + }, + }); + + + const {error, publishCommentEdit } = usePublishCommentEdit(publishCommentEditOptions); + + useEffect(() => { + if (error && error !== errorMessage) { + setErrorMessage(error); + } + }, [error, setErrorMessage, errorMessage]); + + + const handleAuthorDeleteClick = (commentCid) => { + handleOptionClick(commentCid); + + confirmAlert({ + customUI: ({ onClose }) => { + return ( + +
    +

    Are you sure you want to delete this post?

    +
    + + +
    +
    +
    + ); + } + }); + }; + + + const handleAuthorEditClick = (comment) => { + handleOptionClick(comment.cid); + setIsAuthorEdit(true); + setIsAuthorDelete(false); + setCommentCid(comment.cid); + setOriginalCommentContent(comment.content); + setIsEditModalOpen(true); + } + + + useEffect(() => { + setPublishCommentEditOptions((prevOptions) => ({ + ...prevOptions, + commentCid: commentCid, + content: editedComment || undefined, + })); + }, [commentCid, editedComment]); + + + useEffect(() => { + if (editedComment !== '') { + setTriggerPublishCommentEdit(true); + } + }, [editedComment, setIsAuthorEdit]); + + + useEffect(() => { + if (publishCommentEditOptions && triggerPublishCommentEdit) { + (async () => { + await publishCommentEdit(); + setTriggerPublishCommentEdit(false); + })(); + } + }, [publishCommentEditOptions, triggerPublishCommentEdit, publishCommentEdit]); + // desktop navbar board select functionality const handleClickTitle = (title, address) => { setSelectedTitle(title); @@ -217,6 +443,16 @@ const Subscriptions = () => { selectedStyle={selectedStyle} isOpen={isSettingsOpen} closeModal={() => setIsSettingsOpen(false)} /> + {setIsModerationOpen(false); setDeletePost(false)}} + deletePost={deletePost} /> + setIsEditModalOpen(false)} + originalCommentContent={originalCommentContent} /> <> @@ -343,6 +579,7 @@ const Subscriptions = () => { const { displayedReplies, omittedCount } = filteredRepliesByThread[thread.cid] || {}; const commentMediaInfo = getCommentMediaInfo(thread); const fallbackImgUrl = "assets/filedeleted-res.gif"; + setIsModerator(moderatorPermissions[thread.subplebbitAddress]); return (
    @@ -476,63 +713,100 @@ const Subscriptions = () => { threadMenuRefs.current[thread.cid] = el} + ref={el => { + threadMenuRefs.current[thread.cid] = el; + }} className='post-menu-button' - rotated={rotatedStates[thread.cid]} - onClick={() => { + rotated={openMenuCid === thread.cid} + onClick={(event) => { + event.stopPropagation(); const rect = threadMenuRefs.current[thread.cid].getBoundingClientRect(); - const menu = document.querySelector(`.post-menu-thread-${thread.cid}`); - const scrollY = window.scrollY || window.pageYOffset; - menu.style.top = `calc(${rect.top + scrollY}px - 225px)`; - menu.style.left = `${rect.left}px`; - - setRotatedStates(prevState => ({ - ...prevState, - [thread.cid]: !prevState[thread.cid] - })); + setMenuPosition({top: rect.top + window.scrollY, left: rect.left}); + setOpenMenuCid(prevCid => (prevCid === thread.cid ? null : thread.cid)); }} > ▶ -
    -
      - {/*
    • Edit post
    • */} -
    • Hide thread
    • - {(commentMediaInfo && ( - commentMediaInfo.type === 'image' || - (commentMediaInfo.type === 'webpage' && - commentMediaInfo.thumbnail))) ? ( -
    • {setIsImageSearchOpen(true)}} - onMouseLeave={() => {setIsImageSearchOpen(false)}}> - Image search » -
        -
      • - Google + {createPortal( + {postMenuCatalogRef.current = el}} + onClick={(event) => event.stopPropagation()} + style={{position: "absolute", + top: menuPosition.top + 7, + left: menuPosition.left}}> +
        +
          +
        • handleOptionClick(thread.cid)}>Hide thread
        • + {thread.author.shortAddress === account?.author.shortAddress ? ( + <> +
        • {handleAuthorEditClick(thread); setSelectedAddress(thread.subplebbitAddress);}}>Edit post
        • +
        • {handleAuthorDeleteClick(thread.cid); setSelectedAddress(thread.subplebbitAddress);}}>Delete post
        • + + ) : null} + {isModerator ? ( + <> + {thread.author.shortAddress === account?.author.shortAddress ? ( + null + ) : ( +
        • { + setSelectedAddress(thread.subplebbitAddress); + setModeratingCommentCid(thread.cid) + setIsModerationOpen(true); + handleOptionClick(thread.cid); + setDeletePost(true); + }}> + Delete post
        • -
        • - Yandex -
        • -
        • - SauceNAO -
        • -
        -
      • - ) : null - } -
      -
    + )} +
  • { + setSelectedAddress(thread.subplebbitAddress); + setModeratingCommentCid(thread.cid) + setIsModerationOpen(true); + handleOptionClick(thread.cid); + }}> + Mod tools +
  • + + ) : null} + {(commentMediaInfo && ( + commentMediaInfo.type === 'image' || + (commentMediaInfo.type === 'webpage' && + commentMediaInfo.thumbnail))) ? ( +
  • {setIsImageSearchOpen(true)}} + onMouseLeave={() => {setIsImageSearchOpen(false)}}> + Image search » +
      +
    • handleOptionClick(thread.cid)}> + Google +
    • +
    • handleOptionClick(thread.cid)}> + Yandex +
    • +
    • handleOptionClick(thread.cid)}> + SauceNAO +
    • +
    +
  • + ) : null + } + +
    + , document.body + )} @@ -702,64 +990,102 @@ const Subscriptions = () => { className="offline-reply" />
      replyMenuRefs.current[reply.cid] = el} - className='post-menu-button' - rotated={rotatedStates[reply.cid]} - onClick={() => { - const rect = replyMenuRefs.current[reply.cid].getBoundingClientRect(); - const menu = document.querySelector(`.post-menu-reply-${reply.cid}`); - const scrollY = window.scrollY || window.pageYOffset; - menu.style.top = `calc(${rect.top + scrollY}px - 225px)`; - menu.style.left = `${rect.left}px`; - - setRotatedStates(prevState => ({ - ...prevState, - [reply.cid]: !prevState[reply.cid] - })); - }} - > - ▶ + key={`pmb-${index}`} + title="Post menu" + ref={el => { + replyMenuRefs.current[reply.cid] = el; + }} + className='post-menu-button' + rotated={openMenuCid === reply.cid} + onClick={(event) => { + event.stopPropagation(); + const rect = replyMenuRefs.current[reply.cid].getBoundingClientRect(); + setMenuPosition({top: rect.top + window.scrollY, left: rect.left}); + setOpenMenuCid(prevCid => (prevCid === reply.cid ? null : reply.cid)); + }} + > + ▶ -
    -
      - {/*
    • Edit post
    • */} -
    • Hide post
    • + {createPortal( + {postMenuCatalogRef.current = el}} + onClick={(event) => event.stopPropagation()} + style={{position: "absolute", + top: menuPosition.top + 7, + left: menuPosition.left}}> +
      +
        +
      • handleOptionClick(reply.cid)}>Hide post
      • + {reply.author.shortAddress === account?.author.shortAddress ? ( + <> +
      • {handleAuthorEditClick(reply); setSelectedAddress(thread.subplebbitAddress);}}>Edit post
      • +
      • {handleAuthorDeleteClick(reply.cid); setSelectedAddress(thread.subplebbitAddress);}}>Delete post
      • + + ) : null} + {isModerator ? ( + <> + {reply.author.shortAddress === account?.author.shortAddress ? ( + null + ) : ( +
      • { + setSelectedAddress(thread.subplebbitAddress); + setModeratingCommentCid(reply.cid) + setIsModerationOpen(true); + handleOptionClick(reply.cid); + setDeletePost(true); + }}> + Delete post +
      • + )} +
      • { + setSelectedAddress(thread.subplebbitAddress); + setModeratingCommentCid(reply.cid) + setIsModerationOpen(true); + handleOptionClick(reply.cid); + }}> + Mod tools +
      • + + ) : null} {(replyMediaInfo && ( replyMediaInfo.type === 'image' || (replyMediaInfo.type === 'webpage' && replyMediaInfo.thumbnail))) ? (
      • {setIsImageSearchOpen(true)}} - onMouseLeave={() => {setIsImageSearchOpen(false)}}> - Image search » - -
      • - ) : null} + onMouseOver={() => {setIsImageSearchOpen(true)}} + onMouseLeave={() => {setIsImageSearchOpen(false)}}> + Image search » +
          +
        • handleOptionClick(reply.cid)}> + Google +
        • +
        • handleOptionClick(reply.cid)}> + Yandex +
        • +
        • handleOptionClick(reply.cid)}> + SauceNAO +
        • +
        + + ) : null + }
      +
      , document.body + )}
    - ) - })} + ) + })}
    {index === 0 ? ( @@ -1117,14 +1456,27 @@ const Subscriptions = () => {
    (...) -

    +
    + + +
    Post too long.  - setSelectedThread(thread.cid)} className="ttl-link">Click here + setSelectedThread(thread.cid)} className="ttl-link">Click here  to view.
    :
    + +
    ) : null}
    @@ -1278,27 +1630,116 @@ const Subscriptions = () => {
    {}} key={`mob-r-pm-${index}`} className="quotelink" - onClick={(event) => handleQuoteClick(reply, shortParentCid, thread.shortCid, event)} - onMouseOver={(event) => handleQuoteHover(reply, shortParentCid, thread.shortCid, event)} - onMouseLeave={removeHighlight}> + ref={el => { + quoteRefsMobile.current[reply.cid] = el; + }} + onClick={(event) => handleQuoteClick(reply, shortParentCid, event)} + onMouseOver={(event) => { + event.stopPropagation(); + handleQuoteHover(reply, shortParentCid, () => { + setOutOfViewCid(reply.parentCid); + const rect = quoteRefsMobile.current[reply.cid].getBoundingClientRect(); + const distanceToRight = window.innerWidth - rect.right; + const distanceToTop = rect.top; + const distanceToBottom = window.innerHeight - rect.bottom; + let top; + + if (distanceToTop < postOnHoverHeight / 2) { + top = window.scrollY - 5; + } else if (distanceToBottom < postOnHoverHeight / 2) { + top = window.scrollY - postOnHoverHeight + window.innerHeight - 10; + } else { + top = rect.top + window.scrollY - postOnHoverHeight / 2; + } + + if (distanceToRight < 200) { + setOutOfViewPosition({ + top, + right: window.innerWidth - rect.left - 10, + maxWidth: rect.left - 5 + }); + } else { + setOutOfViewPosition({ + top, + left: rect.left + rect.width + 5, + maxWidth: window.innerWidth - rect.left - rect.width - 5 + }); + } + }); + }} + onMouseLeave={() => { + removeHighlight(); + setOutOfViewCid(null); + }}> {`c/${shortParentCid}`}{shortParentCid === thread.shortCid ? " (OP)" : null} (...) -

    +
    + + +
    Comment too long.  - setSelectedThread(thread.cid)} className="ttl-link">Click here + setSelectedThread(thread.cid)} className="ttl-link">Click here  to view.
    :
    {}} key={`mob-r-pm-${index}`} className="quotelink" - onClick={(event) => handleQuoteClick(reply, shortParentCid, thread.shortCid, event)} - onMouseOver={(event) => handleQuoteHover(reply, shortParentCid, thread.shortCid, event)} - onMouseLeave={removeHighlight}> + ref={el => { + quoteRefsMobile.current[reply.cid] = el; + }} + onClick={(event) => handleQuoteClick(reply, shortParentCid, event)} + onMouseOver={(event) => { + event.stopPropagation(); + handleQuoteHover(reply, shortParentCid, () => { + setOutOfViewCid(reply.parentCid); + const rect = quoteRefsMobile.current[reply.cid].getBoundingClientRect(); + const distanceToRight = window.innerWidth - rect.right; + const distanceToTop = rect.top; + const distanceToBottom = window.innerHeight - rect.bottom; + let top; + + if (distanceToTop < postOnHoverHeight / 2) { + top = window.scrollY - 5; + } else if (distanceToBottom < postOnHoverHeight / 2) { + top = window.scrollY - postOnHoverHeight + window.innerHeight - 10; + } else { + top = rect.top + window.scrollY - postOnHoverHeight / 2; + } + + if (distanceToRight < 200) { + setOutOfViewPosition({ + top, + right: window.innerWidth - rect.left - 10, + maxWidth: rect.left - 5 + }); + } else { + setOutOfViewPosition({ + top, + left: rect.left + rect.width + 5, + maxWidth: window.innerWidth - rect.left - rect.width - 5 + }); + } + }); + }} + onMouseLeave={() => { + removeHighlight(); + setOutOfViewCid(null); + }}> {`c/${shortParentCid}`}{shortParentCid === thread.shortCid ? " (OP)" : null} + +
    ) : null} {reply.replyCount > 0 ? ( @@ -1306,11 +1747,49 @@ const Subscriptions = () => { {reply.replies?.pages?.topAll.comments .sort((a, b) => a.timestamp - b.timestamp) .map((reply, index) => ( -
    +
    { + backlinkRefsMobile.current[reply.cid] = el; + }}> {}} onClick={(event) => handleQuoteClick(reply, reply.shortCid, event)} - onMouseOver={(event) => handleQuoteHover(reply, reply.shortCid, event)} - onMouseLeave={removeHighlight} + onMouseOver={(event) => { + event.stopPropagation(); + handleQuoteHover(reply, reply.shortCid, () => { + setOutOfViewCid(reply.cid) + const rect = backlinkRefsMobile.current[reply.cid].getBoundingClientRect(); + const distanceToRight = window.innerWidth - rect.right; + const distanceToTop = rect.top; + const distanceToBottom = window.innerHeight - rect.bottom; + let top; + + if (distanceToTop < postOnHoverHeight / 2) { + top = window.scrollY - 5; + } else if (distanceToBottom < postOnHoverHeight / 2) { + top = window.scrollY - postOnHoverHeight + window.innerHeight - 10; + } else { + top = rect.top + window.scrollY - postOnHoverHeight / 2; + } + + if (distanceToRight < 200) { + setOutOfViewPosition({ + top, + right: window.innerWidth - rect.left - 10, + maxWidth: rect.left - 5 + }); + } else { + setOutOfViewPosition({ + top, + left: rect.left + rect.width + 5, + maxWidth: window.innerWidth - rect.left - rect.width - 5 + }); + } + }); + }} + onMouseLeave={() => { + removeHighlight(); + setOutOfViewCid(null); + }} className="quote-link"> c/{reply.shortCid}