import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Helmet } from 'react-helmet-async'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { confirmAlert } from 'react-confirm-alert'; import { Tooltip } from 'react-tooltip'; import { Virtuoso } from 'react-virtuoso'; import { useAccount, useAccountComments, useFeed, usePublishComment, usePublishCommentEdit, useSubplebbit, useSubscribe } from '@plebbit/plebbit-react-hooks'; import { flattenCommentsPages } from '@plebbit/plebbit-react-hooks/dist/lib/utils'; import { debounce } from 'lodash'; import { Container, NavBar, Header, Break, PostFormLink, PostFormTable, PostForm, TopBar, BoardForm, PostMenu, PostMenuMobile } from '../styled/views/Board.styled'; import { Footer } from '../styled/views/Thread.styled'; import { AlertModal } from '../styled/modals/AlertModal.styled'; import { PostMenuCatalog } from '../styled/views/Catalog.styled'; import EditModal from '../modals/EditModal'; import AdminListModal from '../modals/AdminListModal'; import ModerationModal from '../modals/ModerationModal'; import BoardSettings from '../BoardSettings'; import BoardStats from '../BoardStats'; import EditLabel from '../EditLabel'; import Embed from '../Embed'; import ImageBanner from '../ImageBanner'; import OfflineIndicator from '../OfflineIndicator'; import PendingLabel from '../PendingLabel'; import Post from '../Post'; import PostLoader from '../PostLoader'; import PostOnHover from '../PostOnHover'; import StateLabel from '../StateLabel'; import VerifiedAuthor from '../VerifiedAuthor'; import CreateBoardModal from '../modals/CreateBoardModal'; import ReplyModal from '../modals/ReplyModal'; import SettingsModal from '../modals/SettingsModal'; import findShortParentCid from '../../utils/findShortParentCid'; import getCommentMediaInfo from '../../utils/getCommentMediaInfo'; import getDate from '../../utils/getDate'; import getFormattedTime from '../../utils/getFormattedTime'; import handleAddressClick from '../../utils/handleAddressClick'; import handleImageClick from '../../utils/handleImageClick'; import handleQuoteClick from '../../utils/handleQuoteClick'; import handleQuoteHover from '../../utils/handleQuoteHover'; import handleShareClick from '../../utils/handleShareClick'; import handleStyleChange from '../../utils/handleStyleChange'; import removeHighlight from '../../utils/removeHighlight'; import useAnonModeRef from '../../hooks/useAnonModeRef'; import useClickForm from '../../hooks/useClickForm'; import useError from '../../hooks/useError'; import useStateString from '../../hooks/useStateString'; import useSuccess from '../../hooks/useSuccess'; import useAnonModeStore from '../../hooks/stores/useAnonModeStore'; import useGeneralStore from '../../hooks/stores/useGeneralStore'; import packageJson from '../../../package.json'; const { version } = packageJson; let lastVirtuosoStates = {}; const commitRef = process?.env?.REACT_APP_COMMIT_REF ? ` ${process.env.REACT_APP_COMMIT_REF.slice(0, 7)}` : ''; const Board = () => { const { setCaptchaResponse, setChallengesArray, defaultSubplebbits, editedComment, editedComments, feedCacheStates, setFeedCacheState, setIsAuthorDelete, setIsAuthorEdit, setIsCaptchaOpen, isModerationOpen, setIsModerationOpen, isSettingsOpen, setIsSettingsOpen, setModeratingCommentCid, setPendingComment, setPendingCommentIndex, setReplyQuoteCid, setResolveCaptchaPromise, selectedAddress, setSelectedAddress, setSelectedParentCid, setSelectedShortCid, selectedStyle, setSelectedThread, setSelectedText, setSelectedTitle, showPostForm, showPostFormLink, setTriggerInsertion, } = useGeneralStore((state) => state); const { anonymousMode } = useAnonModeStore(); // temporary hardcode const plebtokenRules = [ 'This community is strictly SFW. Any NSFW language or content will be removed.', Only post about the plebbit token ($PLEB). For general cryptocurrency discussion, go to{' '} p/business-and-finance.eth . , 'FUD is allowed unless blatantly meaningless and spammy.', ]; const account = useAccount(); const navigate = useNavigate(); const { subplebbitAddress } = useParams(); const [, setNewErrorMessage] = useError(); const [, setNewSuccessMessage] = useSuccess(); const nameRef = useRef(); const subjectRef = useRef(); const commentRef = useRef(); const linkRef = useRef(); const threadMenuRefs = useRef({}); const threadMenuRefsMobile = useRef({}); const replyMenuRefs = useRef({}); const replyMenuRefsMobile = useRef({}); const postMenuCatalogRef = useRef(null); const postMenuMobileRef = useRef(null); const backlinkRefs = useRef({}); const quoteRefs = useRef({}); const postRefs = useRef({}); const backlinkRefsMobile = useRef({}); const quoteRefsMobile = useRef({}); const postOnHoverRef = useRef(null); const selectedThreadCidRef = useRef(null); const virtuosoRef = useRef(); const { feed, loadMore } = useFeed({ subplebbitAddresses: [`${selectedAddress}`], sortType: 'active' }); const subplebbit = useSubplebbit({ subplebbitAddress: selectedAddress }); const { subscribed, subscribe, unsubscribe } = useSubscribe({ subplebbitAddress: selectedAddress }); const stateString = useStateString(subplebbit); const [isAdminListOpen, setIsAdminListOpen] = useState(false); 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 [triggerPublishComment, setTriggerPublishComment] = useState(false); const [triggerPublishCommentEdit, setTriggerPublishCommentEdit] = useState(false); const [selectedFeed, setSelectedFeed] = useState(feed); const [deletePost, setDeletePost] = useState(false); const [isImageSearchOpen, setIsImageSearchOpen] = useState(false); const [isClientRedirectMenuOpen, setIsClientRedirectMenuOpen] = useState(false); const [isModerator, setIsModerator] = useState(false); const [commentCid, setCommentCid] = useState(null); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [mobileMenuPosition, setMobileMenuPosition] = useState({ top: 0, left: 0 }); const [openMenuCid, setOpenMenuCid] = useState(null); const [openMobileMenuCid, setOpenMobileMenuCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const [outOfViewPosition, setOutOfViewPosition] = useState({ top: 0, left: 0 }); const [postOnHoverHeight, setPostOnHoverHeight] = useState(0); const [executeAnonMode, setExecuteAnonMode] = useState(false); const [cidTracker, setCidTracker] = useState({}); const [isThreadThumbnailClicked, setIsThreadThumbnailClicked] = useState({}); const [isReplyThumbnailClicked, setIsReplyThumbnailClicked] = useState({}); const [isMobileThreadThumbnailClicked, setIsMobileThreadThumbnailClicked] = useState({}); const [isMobileReplyThumbnailClicked, setIsMobileReplyThumbnailClicked] = useState({}); const [isCreateBoardOpen, setIsCreateBoardOpen] = useState(false); const setSelectedThreadCid = (cid) => { selectedThreadCidRef.current = cid; }; const isFeedCached = feedCacheStates[selectedAddress]; useAnonModeRef(selectedThreadCidRef, anonymousMode && executeAnonMode); useEffect(() => { if (feed) { setFeedCacheState(selectedAddress, true); } }, [selectedAddress, setFeedCacheState, feed]); useEffect(() => { if (subplebbit?.roles !== undefined) { const role = subplebbit?.roles[account?.author.address]?.role; if (role === 'moderator' || role === 'admin' || role === 'owner') { setIsModerator(true); } else { setIsModerator(false); } } }, [account?.author.address, subplebbit?.roles]); const handleThumbnailClick = (index, type) => { switch (type) { case 'thread': setIsThreadThumbnailClicked((prevState) => ({ ...prevState, [index]: !prevState[index], })); break; case 'reply': setIsReplyThumbnailClicked((prevState) => ({ ...prevState, [index]: !prevState[index], })); break; case 'mobileThread': setIsMobileThreadThumbnailClicked((prevState) => ({ ...prevState, [index]: !prevState[index], })); break; case 'mobileReply': setIsMobileReplyThumbnailClicked((prevState) => ({ ...prevState, [index]: !prevState[index], })); break; default: break; } }; const handleOptionClick = () => { setOpenMenuCid(null); }; const handleMobileOptionClick = () => { setOpenMobileMenuCid(null); }; const handleOutsideClick = useCallback( (e) => { if (openMenuCid !== null && !postMenuCatalogRef.current.contains(e.target)) { setOpenMenuCid(null); } }, [openMenuCid, postMenuCatalogRef], ); const handleMobileOutsideClick = useCallback( (e) => { if (openMobileMenuCid !== null && !postMenuMobileRef.current.contains(e.target)) { setOpenMobileMenuCid(null); } }, [openMobileMenuCid, postMenuMobileRef], ); useEffect(() => { if (openMenuCid !== null) { document.addEventListener('click', handleOutsideClick); } else { document.removeEventListener('click', handleOutsideClick); } return () => { document.removeEventListener('click', handleOutsideClick); }; }, [openMenuCid, handleOutsideClick]); useEffect(() => { if (openMobileMenuCid !== null) { document.addEventListener('click', handleMobileOutsideClick); } else { document.removeEventListener('click', handleMobileOutsideClick); } return () => { document.removeEventListener('click', handleMobileOutsideClick); }; }, [openMobileMenuCid, handleMobileOutsideClick]); useEffect(() => { if (postOnHoverRef.current) { const rect = postOnHoverRef.current.getBoundingClientRect(); setPostOnHoverHeight(rect.height); } }, [outOfViewCid]); const errorString = useMemo(() => { if (subplebbit?.state === 'failed') { let errorString = 'Failed fetching board "' + selectedAddress + '".'; if (subplebbit.error) { errorString += `: ${subplebbit.error.toString().slice(0, 300)}`; } return errorString; } }, [subplebbit?.state, subplebbit?.error, selectedAddress]); useEffect(() => { if (errorString) { setNewErrorMessage(errorString); } }, [errorString, setNewErrorMessage]); const flattenedRepliesByThread = useMemo(() => { return selectedFeed.reduce((acc, thread) => { const replies = flattenCommentsPages(thread.replies); acc[thread.cid] = replies; return acc; }, {}); }, [selectedFeed]); const allParentCids = useMemo(() => { const allRepliesCids = Object.values(flattenedRepliesByThread).flatMap((replies) => replies.map((reply) => reply.cid)); const allThreadCids = selectedFeed.map((thread) => thread.cid); return new Set([...allThreadCids, ...allRepliesCids]); }, [flattenedRepliesByThread, selectedFeed]); const filter = useCallback((accountComment) => allParentCids.has(accountComment.parentCid), [allParentCids]); const { accountComments } = useAccountComments({ filter }); const filteredRepliesByThread = useMemo(() => { const maxRepliesPerThread = 5; const accountRepliesNotYetInCommentReplies = selectedFeed.reduce((acc, thread) => { const replyCids = new Set(flattenedRepliesByThread[thread.cid].map((reply) => reply.cid)); acc[thread.cid] = accountComments.filter((accountReply) => !replyCids.has(accountReply.cid) && accountReply.parentCid === thread.cid); return acc; }, {}); return selectedFeed.reduce((acc, thread) => { const combinedReplies = [...flattenedRepliesByThread[thread.cid], ...accountRepliesNotYetInCommentReplies[thread.cid]].sort((a, b) => a.timestamp - b.timestamp); acc[thread.cid] = { displayedReplies: combinedReplies.slice(0, maxRepliesPerThread), omittedCount: Math.max(combinedReplies.length - maxRepliesPerThread, 0), }; return acc; }, {}); }, [flattenedRepliesByThread, accountComments, selectedFeed]); const pendingReplyCounts = useMemo(() => { return selectedFeed.reduce((acc, thread) => { const replyCids = new Set(flattenedRepliesByThread[thread.cid].map((reply) => reply.cid)); acc[thread.cid] = accountComments.filter((accountReply) => !replyCids.has(accountReply.cid) && accountReply.parentCid === thread.cid).length; return acc; }, {}); }, [flattenedRepliesByThread, accountComments, selectedFeed]); const allReplies = useMemo(() => { return selectedFeed.flatMap((thread) => filteredRepliesByThread[thread.cid]?.displayedReplies || []); }, [selectedFeed, filteredRepliesByThread]); // let post.jsx access full cid of user-typed short cid useEffect(() => { const newCidTracker = {}; allReplies.forEach((reply) => { newCidTracker[reply.shortCid] = reply.cid; }); setCidTracker(newCidTracker); }, [allReplies]); useEffect(() => { const selectedSubplebbit = defaultSubplebbits.find((subplebbit) => subplebbit.address === subplebbitAddress); if (subplebbitAddress) { setSelectedAddress(subplebbitAddress); } else if (subplebbit?.address) { setSelectedAddress(subplebbit.address); } if (selectedSubplebbit) { setSelectedTitle(selectedSubplebbit.title); } else if (subplebbit?.title) { setSelectedTitle(subplebbit.title); } }, [subplebbitAddress, setSelectedAddress, setSelectedTitle, defaultSubplebbits, subplebbit?.address, subplebbit?.title]); // sets useFeed to address from URL useEffect(() => { setSelectedFeed(feed); }, [feed]); // mobile navbar scroll effect useEffect(() => { const debouncedHandleScroll = debounce(() => { const currentScrollPos = window.scrollY; setVisible(prevScrollPos > currentScrollPos || currentScrollPos < 10); setPrevScrollPos(currentScrollPos); }, 50); window.addEventListener('scroll', debouncedHandleScroll); return () => window.removeEventListener('scroll', debouncedHandleScroll); }, [prevScrollPos, visible]); const tryLoadMore = async () => { try { await loadMore(); } catch (e) { await new Promise((resolve) => setTimeout(resolve, 1000)); } }; const onChallengeVerification = (challengeVerification) => { if (challengeVerification.challengeSuccess === true) { if (challengeVerification.publication?.cid !== undefined) { navigate(`/p/${subplebbitAddress}/c/${challengeVerification.publication?.cid}`); console.log('challenge success'); } else { setNewSuccessMessage('Challenge Success'); console.log('challenge success', challengeVerification); } } else if (challengeVerification.challengeSuccess === false) { setNewErrorMessage(`Challenge Failed, reason: ${challengeVerification.reason}. Errors: ${challengeVerification.errors}`); console.log('challenge failed', challengeVerification); } }; const onChallenge = async (challenges, comment) => { setPendingComment(comment); let challengeAnswers = []; try { challengeAnswers = await getChallengeAnswersFromUser(challenges); } catch (error) { setNewErrorMessage(error.message); console.log(error); } if (challengeAnswers) { await comment.publishChallengeAnswers(challengeAnswers); } }; useEffect(() => { setPublishCommentOptions((prevPublishCommentOptions) => ({ ...prevPublishCommentOptions, subplebbitAddress: selectedAddress, })); }, [selectedAddress]); const [publishCommentOptions, setPublishCommentOptions] = useState({ subplebbitAddress: selectedAddress, onChallenge, onChallengeVerification, onError: (error) => { setNewErrorMessage(error.message); console.log(error); }, }); const { publishComment, index } = usePublishComment(publishCommentOptions); useEffect(() => { if (index !== undefined) { setPendingCommentIndex(index); navigate(`/profile/c/${index}`); } }, [index, navigate, setPendingCommentIndex]); const resetFields = useCallback(() => { if (nameRef.current) { nameRef.current.value = ''; } if (subjectRef.current) { subjectRef.current.value = ''; } if (commentRef.current) { commentRef.current.value = ''; } if (linkRef.current) { linkRef.current.value = ''; } }, []); const handleSubmit = async (event) => { event.preventDefault(); if (subjectRef.current.value === '') { setNewErrorMessage('Subject field is mandatory'); return; } setPublishCommentOptions((prevPublishCommentOptions) => ({ ...prevPublishCommentOptions, author: { displayName: nameRef.current.value || undefined, ...(anonymousMode ? {} : { address: account?.author.address }), }, title: subjectRef.current.value || undefined, content: commentRef.current.value || undefined, link: linkRef.current.value || undefined, })); setTriggerPublishComment(true); }; const updateSigner = useCallback(async () => { if (anonymousMode) { setExecuteAnonMode(true); let storedSigners = JSON.parse(localStorage.getItem('storedSigners')) || {}; let signer; if (!storedSigners[selectedThreadCidRef]) { signer = await account?.plebbit.createSigner(); storedSigners[selectedThreadCidRef] = { privateKey: signer?.privateKey, address: signer?.address }; localStorage.setItem('storedSigners', JSON.stringify(storedSigners)); } else { const signerPrivateKey = storedSigners[selectedThreadCidRef].privateKey; try { signer = await account?.plebbit.createSigner({ type: 'ed25519', privateKey: signerPrivateKey }); } catch (error) { console.log(error); } } setPublishCommentOptions((prevPublishCommentOptions) => { const newPublishCommentOptions = { ...prevPublishCommentOptions, signer, author: { ...prevPublishCommentOptions.author, address: signer?.address, }, }; if (JSON.stringify(prevPublishCommentOptions) !== JSON.stringify(newPublishCommentOptions)) { return newPublishCommentOptions; } return prevPublishCommentOptions; }); } }, [selectedThreadCidRef, anonymousMode, account]); useEffect(() => { if (anonymousMode) { updateSigner(); } }, [updateSigner, anonymousMode]); useEffect(() => { if (publishCommentOptions && triggerPublishComment) { (async () => { await publishComment(); resetFields(); })(); setTriggerPublishComment(false); setExecuteAnonMode(false); } }, [publishCommentOptions, triggerPublishComment, publishComment, resetFields]); 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(setNewErrorMessage('Could not load challenges')); }; }); }; const [publishCommentEditOptions, setPublishCommentEditOptions] = useState({ commentCid: commentCid, content: editedComment || undefined, subplebbitAddress, onChallenge, onChallengeVerification, onError: (error) => { setNewErrorMessage(error.message); console.log(error); }, }); const { publishCommentEdit } = usePublishCommentEdit(publishCommentEditOptions); const handleAuthorDeleteClick = (comment) => { handleOptionClick(comment.cid); 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(() => { let isActive = true; if (publishCommentEditOptions && triggerPublishCommentEdit) { (async () => { await publishCommentEdit(); if (isActive) { setTriggerPublishCommentEdit(false); } })(); } return () => { isActive = false; }; }, [triggerPublishCommentEdit, publishCommentEdit, publishCommentEditOptions]); // desktop navbar board select functionality const handleClickTitle = (title, address) => { setSelectedTitle(title); setSelectedAddress(address); setSelectedFeed(feed.filter((feed) => feed.title === title)); if (subplebbitAddress === address) { window.location.reload(); } }; // mobile navbar board select functionality const handleSelectChange = (event) => { const selected = event.target.value; if (selected === 'subscriptions') { navigate(`/p/subscriptions`); return; } else if (selected === 'all') { navigate(`/p/all`); return; } const selectedTitle = defaultSubplebbits.find((subplebbit) => subplebbit.address === selected).title; setSelectedTitle(selectedTitle); setSelectedAddress(selected); navigate(`/p/${selected}`); }; const handleSubscribe = async () => { try { if (subscribed === false) { await subscribe(selectedAddress); } else if (subscribed === true) { await unsubscribe(selectedAddress); } } catch (error) { setNewErrorMessage(error.message); console.log(error); } }; useEffect(() => { const setLastVirtuosoState = () => { virtuosoRef.current?.getState((snapshot) => { if (snapshot?.scrollTop === 0 || snapshot?.ranges?.length) { lastVirtuosoStates[selectedAddress] = snapshot; } }); }; window.addEventListener('scroll', setLastVirtuosoState); return () => window.removeEventListener('scroll', setLastVirtuosoState); }, [selectedAddress]); const lastVirtuosoState = lastVirtuosoStates[selectedAddress]; return ( <> {(subplebbit.title ? subplebbit.title : subplebbit.address) + ' - plebchan'} setIsAdminListOpen(false)} roles={subplebbit?.roles} /> setIsCreateBoardOpen(false)} /> setIsEditModalOpen(false)} originalCommentContent={originalCommentContent} /> { setIsModerationOpen(false); setDeletePost(false); }} deletePost={deletePost} /> setIsReplyOpen(false)} /> setIsSettingsOpen(false)} /> <> [ window.scrollTo(0, 0)}> All  /  window.scrollTo(0, 0)}> Subscriptions ] [ {defaultSubplebbits.map((subplebbit, index) => ( {index === 0 ? null : '\u00a0'} handleClickTitle(subplebbit.title, subplebbit.address)}> {subplebbit.title ? subplebbit.title : subplebbit.address} {index !== defaultSubplebbits.length - 1 ? ' /' : null} ))} ] [ { window.electron && window.electron.isElectron ? setIsCreateBoardOpen(true) : alert( 'You can create a board with the desktop version of plebchan:\nhttps://github.com/plebbit/plebchan/releases/latest\n\nIf you are comfortable with the command line, use plebbit-cli:\nhttps://github.com/plebbit/plebbit-cli\n\n', ); }} > Create Board ] [ setIsSettingsOpen(true)}> Settings ] [Home]
Board     { window.electron && window.electron.isElectron ? setIsCreateBoardOpen(true) : alert( 'You can create a board with the desktop version of plebchan:\nhttps://github.com/plebbit/plebchan/releases/latest\n\nIf you are comfortable with the command line, use plebbit-cli:\nhttps://github.com/plebbit/plebbit-cli\n\n', ); }} > Create Board
setIsSettingsOpen(true)}> Settings   { handleStyleChange({ target: { value: 'Yotsuba' } }); window.scrollTo(0, 0); }} > Home
 
 
<>
{subplebbit.title ?? null}
p/{subplebbit.address}
[ Start a New Thread ]
Start a New Thread
Name {account?.author.displayName ? ( ) : ( )} Subject Comment