import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { Tooltip } from 'react-tooltip'; import { useAccount, useAccountComments, useComment, usePublishComment } 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, PostForm, PostFormTable } from '../styled/Board.styled'; import { ReplyFormLink, TopBar, BottomBar, BoardForm, Footer } from '../styled/Thread.styled'; import ImageBanner from '../ImageBanner'; import Post from '../Post'; import PostLoader from '../PostLoader'; import ReplyModal from '../ReplyModal'; import SettingsModal from '../SettingsModal'; import findShortParentCid from '../../utils/findShortParentCid'; import formatState from '../../utils/formatState'; import getCommentMediaInfo from '../../utils/getCommentMediaInfo'; import getDate from '../../utils/getDate'; import handleAddressClick from '../../utils/handleAddressClick'; import handleImageClick from '../../utils/handleImageClick'; import handleQuoteClick from '../../utils/handleQuoteClick'; import handleStyleChange from '../../utils/handleStyleChange'; import useClickForm from '../../hooks/useClickForm'; import useError from '../../hooks/useError'; import packageJson from '../../../package.json' const {version} = packageJson const Thread = () => { const { captchaResponse, setCaptchaResponse, setChallengesArray, defaultSubplebbits, setIsCaptchaOpen, isSettingsOpen, setIsSettingsOpen, setResolveCaptchaPromise, setPendingComment, setPendingCommentIndex, selectedAddress, setSelectedAddress, setSelectedParentCid, setSelectedShortCid, selectedStyle, selectedThread, setSelectedThread, selectedTitle, setSelectedTitle, showPostForm, showPostFormLink, } = useGeneralStore(state => state); const nameRef = useRef(); const commentRef = useRef(); const linkRef = useRef(); const [isReplyOpen, setIsReplyOpen] = useState(false); const navigate = useNavigate(); const [prevScrollPos, setPrevScrollPos] = useState(0); const [visible, setVisible] = useState(true); const account = useAccount(); const comment = useComment({commentCid: selectedThread}); const { subplebbitAddress, threadCid } = useParams(); const handleClickForm = useClickForm(); const commentMediaInfo = getCommentMediaInfo(comment); const fallbackImgUrl = "assets/filedeleted-res.gif"; const [errorMessage, setErrorMessage] = useState(null); useError(errorMessage, [errorMessage]); const [triggerPublishComment, setTriggerPublishComment] = useState(false); const flattenedReplies = useMemo(() => flattenCommentsPages(comment.replies), [comment.replies] ); const filter = useMemo(() => ({ parentCids: [ selectedThread || 'n/a', ...flattenedReplies.map(reply => reply.cid) ] }), [flattenedReplies, selectedThread]); const { accountComments } = useAccountComments({filter}); const accountRepliesNotYetInCommentReplies = useMemo(() => { const commentReplyCids = new Set(flattenedReplies.map(reply => reply.cid)) return accountComments.filter(accountReply => !commentReplyCids.has(accountReply.cid)) }, [flattenedReplies, accountComments]); const sortedReplies = useMemo(() => [ ...accountRepliesNotYetInCommentReplies, ...flattenedReplies ].sort((a, b) => a.timestamp - b.timestamp ), [accountRepliesNotYetInCommentReplies, flattenedReplies]); // temporary title from JSON, gets subplebbitAddress and threadCid from URL useEffect(() => { setSelectedAddress(subplebbitAddress); setSelectedThread(threadCid); const selectedSubplebbit = defaultSubplebbits.find((subplebbit) => subplebbit.address === subplebbitAddress); if (selectedSubplebbit) { setSelectedTitle(selectedSubplebbit.title); } }, [subplebbitAddress, setSelectedAddress, setSelectedTitle, defaultSubplebbits, setSelectedThread, threadCid]); // mobile navbar scroll effect useEffect(() => { const debouncedHandleScroll = debounce(() => { const currentScrollPos = window.pageYOffset; setVisible(prevScrollPos > currentScrollPos || currentScrollPos < 10); setPrevScrollPos(currentScrollPos); }, 50); window.addEventListener('scroll', debouncedHandleScroll); return () => window.removeEventListener('scroll', debouncedHandleScroll); }, [prevScrollPos, visible]); const onChallengeVerification = (challengeVerification) => { if (challengeVerification.challengeSuccess === true) { console.log('challenge success'); } else if (challengeVerification.challengeSuccess === false) { setErrorMessage('challenge failed', {reason: challengeVerification.reason, errors: challengeVerification.errors}); } }; const onChallenge = async (challenges, comment) => { setPendingComment(comment); let challengeAnswers = []; try { challengeAnswers = await getChallengeAnswersFromUser(challenges) } catch (error) { setErrorMessage(error); } if (challengeAnswers) { await comment.publishChallengeAnswers(challengeAnswers) } }; useEffect(() => { setPublishCommentOptions((prevPublishCommentOptions) => ({ ...prevPublishCommentOptions, subplebbitAddress: selectedAddress, })); }, [selectedAddress]); const [publishCommentOptions, setPublishCommentOptions] = useState({ subplebbitAddress: selectedAddress, onChallenge, onChallengeVerification, onError: (error) => { setErrorMessage(error); }, }); const { publishComment, index } = usePublishComment(publishCommentOptions); useEffect(() => { if (index !== undefined) { setPendingCommentIndex(index); } }, [index, setPendingCommentIndex]); const resetFields = useCallback(() => { if (nameRef.current) { nameRef.current.value = ''; } if (commentRef.current) { commentRef.current.value = ''; } if (linkRef.current) { linkRef.current.value = ''; } }, []); const handleSubmit = async (event) => { event.preventDefault(); setPublishCommentOptions((prevPublishCommentOptions) => ({ ...prevPublishCommentOptions, author: { displayName: nameRef.current.value || undefined, }, content: commentRef.current.value || undefined, link: linkRef.current.value || undefined, parentCid: selectedThread, })); setTriggerPublishComment(true); }; useEffect(() => { if (publishCommentOptions.content && triggerPublishComment) { (async () => { await publishComment(); resetFields(); })(); } }, [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 = 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')); }; }); }; // mobile navbar board select functionality const handleSelectChange = (event) => { const selected = event.target.value; const selectedTitle = defaultSubplebbits.find((subplebbit) => subplebbit.address === selected).title; setSelectedTitle(selectedTitle); setSelectedAddress(selected); navigate(`/p/${selected}`); }; return ( <> {( (comment.content?.slice(0, 40) ?? comment.title?.slice(0, 40) ?? "Thread") + " - " + (selectedTitle ? selectedTitle : selectedAddress) + " - plebchan" )} setIsReplyOpen(false)} /> setIsSettingsOpen(false)} /> <> [ {}}>Subscriptions ] [ {defaultSubplebbits.map((subplebbit, index) => ( {index === 0 ? null : "\u00a0"} { setSelectedTitle(subplebbit.title); setSelectedAddress(subplebbit.address); }} >{subplebbit.title ? subplebbit.title : subplebbit.address} {index !== defaultSubplebbits.length - 1 ? " /" : null} ))} ] [ alert( 'To create a board, first you have to run a full node.\nYou can run a full node by simply browsing with the plebbit desktop app. After you download it, open it, wait for it loading, then click on "Home" in the top left, then "Create Community".\n\nAfter you create the community, you can go back to plebchan at any time to see it as a board by pasting its address (begins with p/12D3KooW...) in the search bar, which is in the Home.\n\nNote:\n\n- Your community will be online for as long as you leave the app open, because it functions like a server for the community.\n- The longer you leave the app open, the more data you are seeding to the protocol, which helps performance for everybody.\n - All the data in the plebbit protocol is just text, which is extremely lightweight. All media is generated by links, which is text, embedded by the clients.\n\nDownload the plebbit app here: https://github.com/plebbit/plebbit-react/releases\n\nRunning boards in the plebchan app is a planned feature.\n\n' ) }>Create Board ] [ setIsSettingsOpen(true)}>Settings ] [ handleStyleChange({target: {value: "Yotsuba"}} )}>Home ] Board {defaultSubplebbits.map(subplebbit => ( {subplebbit.title ? subplebbit.title : subplebbit.address} ))} alert( 'To create a board, first you have to run a full node.\nYou can run a full node by simply browsing with the plebbit desktop app. After you download it, open it, wait for it loading, then click on "Home" in the top left, then "Create Community".\n\nAfter you create the community, you can go back to plebchan at any time to see it as a board by pasting its address (begins with p/12D3KooW...) in the search bar, which is in the Home.\n\nNote:\n\n- Your community will be online for as long as you leave the app open, because it functions like a server for the community.\n- The longer you leave the app open, the more data you are seeding to the protocol, which helps performance for everybody.\n - All the data in the plebbit protocol is just text, which is extremely lightweight. All media is generated by links, which is text, embedded by the clients.\n\nDownload the plebbit app here: https://github.com/plebbit/plebbit-react/releases\n\nRunning boards in the plebchan app is a planned feature.\n\n' ) }>Create Board setIsSettingsOpen(true)}>Settings handleStyleChange({target: {value: "Yotsuba"}} )}>Home > <> <> {selectedTitle} p/{selectedAddress} > > Return Catalog window.scrollTo(0, document.body.scrollHeight)} onMouseOver={(event) => event.target.style.cursor='pointer'}>Bottom [ {handleClickForm(); setSelectedShortCid(comment.shortCid)}} onMouseOver={(event) => event.target.style.cursor='pointer'}>Post a Reply ] {handleClickForm(); setSelectedShortCid(comment.shortCid)}} onMouseOver={(event) => event.target.style.cursor='pointer'}>Post a Reply Name Comment Embed File alert("- Embedding media is optional, posts can be text-only. \n- A CAPTCHA challenge will appear after posting. \n- The CAPTCHA is case-sensitive.")} data-tip="Help" >? Style: Yotsuba Yotsuba B Futaba Burichan Tomorrow Photon [ Return ] [ Catalog ] [ window.scrollTo(0, document.body.scrollHeight)} onMouseOver={(event) => event.target.style.cursor='pointer'} onTouchStart={() => window.scrollTo(0, document.body.scrollHeight)}>Bottom ] {comment ? ( comment.replyCount !== undefined ? ( comment.replyCount > 0 ? ( comment.replyCount === 1 ? ( {comment.replyCount} reply ) : ( {comment.replyCount} replies ) ) : ( No replies yet ) ) : ( {formatState(comment.state)} ) ) : ( {formatState(comment.state)} )} {comment !== undefined ? ( comment.state === "fetching-ipfs" ? ( ) : ( <> {commentMediaInfo?.url ? ( Link: { commentMediaInfo?.url.length > 30 ? commentMediaInfo?.url.slice(0, 30) + "(...)" : commentMediaInfo?.url } ({commentMediaInfo?.type}) {commentMediaInfo?.type === "webpage" ? ( {comment.thumbnailUrl ? ( e.target.src = fallbackImgUrl} /> ) : null} ) : null} {commentMediaInfo?.type === "image" ? ( e.target.src = fallbackImgUrl} /> ) : null} {commentMediaInfo?.type === "video" ? ( e.target.src = fallbackImgUrl} /> ) : null} {commentMediaInfo?.type === "audio" ? ( e.target.src = fallbackImgUrl} /> ) : null} ) : null} {comment.title ? ( comment.title.length > 75 ? <> {comment.title.slice(0, 75) + " (...)"} > : {comment.title} ) : null} {comment.author?.displayName ? comment.author?.displayName.length > 20 ? <> {comment.author?.displayName.slice(0, 20) + " (...)"} > : {comment.author?.displayName} : Anonymous} handleAddressClick(comment.author?.shortAddress)}> (u/ {comment.author?.shortAddress} ) {getDate(comment?.timestamp)} c/ { setIsReplyOpen(true); setSelectedParentCid(comment.cid); setSelectedShortCid(comment.shortCid); }} title="Reply to this post">{comment.shortCid} ▶ {comment?.replies?.pages?.topAll.comments .sort((a, b) => a.timestamp - b.timestamp) .map((reply, index) => ( handleQuoteClick(reply, null, event)}> c/{reply.shortCid} )) } {comment.state === "fetching-ipns" ? : null} {sortedReplies.map((reply, index) => { const replyMediaInfo = getCommentMediaInfo(reply); const fallbackImgUrl = "assets/filedeleted-res.gif"; const shortParentCid = findShortParentCid(reply.parentCid, comment); return ( {'>>'} {reply.author?.displayName ? reply.author?.displayName.length > 20 ? <> {reply.author?.displayName.slice(0, 20) + " (...)"} > : {reply.author?.displayName ?? reply.displayName} : Anonymous} handleAddressClick(reply.author?.shortAddress)} > (u/ {reply.author?.shortAddress ? ( {reply.author?.shortAddress} ) : ( {account?.author?.address.slice(0, 10) + "(...)"} ) } ) {getDate(reply?.timestamp)} c/ {reply.shortCid ? ( { setIsReplyOpen(true); setSelectedParentCid(reply.cid); setSelectedShortCid(reply.shortCid); }} title="Reply to this post">{reply.shortCid} ) : ( Pending )} ▶ {reply.replies?.pages?.topAll.comments .sort((a, b) => a.timestamp - b.timestamp) .map((reply, index) => ( handleQuoteClick(reply, reply.shortCid, event)}> c/{reply.shortCid} )) } {replyMediaInfo?.url ? ( Link: { replyMediaInfo?.url.length > 30 ? replyMediaInfo?.url.slice(0, 30) + "(...)" : replyMediaInfo?.url } ({replyMediaInfo?.type}) {replyMediaInfo?.type === "webpage" ? ( {reply.thumbnailUrl ? ( e.target.src = fallbackImgUrl} /> ) : null} ) : null} {replyMediaInfo?.type === "image" ? ( e.target.src = fallbackImgUrl} /> ) : null} {replyMediaInfo?.type === "video" ? ( e.target.src = fallbackImgUrl} /> ) : null} {replyMediaInfo?.type === "audio" ? ( e.target.src = fallbackImgUrl} /> ) : null} ) : null} { handleQuoteClick(reply, shortParentCid, comment.shortCid, event); }} > {`c/${shortParentCid}`}{shortParentCid === comment.shortCid ? " (OP)" : null} ) }) } ... {comment.author?.displayName ? comment.author?.displayName.length > 15 ? <> {comment.author?.displayName.slice(0, 15) + " (...)"} > : {comment.author?.displayName} : Anonymous} handleAddressClick(comment.author?.shortAddress)} > (u/ {comment.author?.shortAddress} ) {comment.title ? ( comment.title.length > 30 ? <> {comment.title.slice(0, 30) + " (...)"} > : {comment.title} ) : null} {getDate(comment?.timestamp)} { setIsReplyOpen(true); setSelectedParentCid(comment.cid); setSelectedShortCid(comment.shortCid); }} title="Link to this post">c/ {comment.shortCid} {commentMediaInfo?.url ? ( commentMediaInfo.type === "webpage" ? ( {comment.thumbnailUrl ? ( e.target.src = fallbackImgUrl} /> ) : null} {commentMediaInfo.type} ) : commentMediaInfo.type === "image" ? ( e.target.src = fallbackImgUrl} /> {commentMediaInfo.type} ) : commentMediaInfo.type === "video" ? ( e.target.src = fallbackImgUrl} /> {commentMediaInfo.type} ) : commentMediaInfo.type === "audio" ? ( e.target.src = fallbackImgUrl} /> {commentMediaInfo.type} ) : null ) : null} {comment.content ? ( <> > ) : null} {comment.replyCount === undefined ? : null} {sortedReplies.map((reply, index) => { const replyMediaInfo = getCommentMediaInfo(reply); const shortParentCid = findShortParentCid(reply.parentCid, comment); return ( ... {reply.author?.displayName ? reply.author?.displayName.length > 12 ? <> {reply.author?.displayName.slice(0, 12) + " (...)"} > : {reply.author?.displayName} : Anonymous} handleAddressClick(reply.author?.shortAddress)} > (u/ {reply.author?.shortAddress ? ( {reply.author?.shortAddress} ) : ( {account?.author?.address.slice(0, 8) + "(...)"} ) } ) {getDate(reply?.timestamp)} c/ {reply.shortCid ? ( { setIsReplyOpen(true); setSelectedParentCid(reply.cid); setSelectedShortCid(reply.shortCid); }} title="Reply to this post">{reply.shortCid} ) : ( Pending )} {reply.link ? ( {replyMediaInfo?.url ? ( replyMediaInfo.type === "webpage" ? ( {reply.thumbnailUrl ? ( e.target.src = fallbackImgUrl} /> ) : null} {replyMediaInfo.type} ) : replyMediaInfo.type === "image" ? ( e.target.src = fallbackImgUrl} /> {replyMediaInfo.type} ) : replyMediaInfo.type === "video" ? ( e.target.src = fallbackImgUrl} /> {replyMediaInfo.type} ) : replyMediaInfo.type === "audio" ? ( e.target.src = fallbackImgUrl} /> {replyMediaInfo.type} ) : null ) : null} ) : null} handleQuoteClick(reply, shortParentCid, comment.shortCid, event)}> {`c/${shortParentCid}`}{shortParentCid === comment.shortCid ? " (OP)" : null} {reply.replyCount > 0 ? ( {reply.replies?.pages?.topAll.comments .sort((a, b) => a.timestamp - b.timestamp) .map((reply, index) => ( handleQuoteClick(reply, reply.shortCid, event)}> c/{reply.shortCid} ))} ) : null} ) }) } [ Return ] [ Catalog ] [ window.scrollTo(0, 0)} onMouseOver={(event) => event.target.style.cursor='pointer'} onTouchStart={() => window.scrollTo(0, 0)}>Top ] [ {setIsReplyOpen(true); setSelectedParentCid(comment.cid); setSelectedShortCid(comment.shortCid);}} onMouseOver={(event) => event.target.style.cursor='pointer'}>Post a Reply ] {comment ? ( comment.replyCount !== undefined ? ( comment.replyCount > 0 ? ( comment.replyCount === 1 ? ( {comment.replyCount} reply ) : ( {comment.replyCount} replies ) ) : ( No replies yet ) ) : ( {formatState(comment.state)} ) ) : ( {formatState(comment.state)} )} {comment.replyCount > 2 ? ( Style: Yotsuba Yotsuba B Futaba Burichan Tomorrow Photon {comment ? ( comment.replyCount !== undefined ? ( comment.replyCount > 0 ? ( comment.replyCount === 1 ? ( {comment.replyCount} reply ) : ( {comment.replyCount} replies ) ) : ( No replies yet ) ) : ( Loading... ) ) : ( null )} {setIsReplyOpen(true); setSelectedParentCid(comment.cid); setSelectedShortCid(comment.shortCid);}} onMouseOver={(event) => event.target.style.cursor='pointer'}>Post a Reply Return Catalog window.scrollTo(0, 0)} onMouseOver={(event) => event.target.style.cursor='pointer'} onTouchStart={() => window.scrollTo(0, 0)} style={{cursor: 'pointer', marginRight: "10px", marginLeft: "10px"}} >Top ) : (null)} > )) : null} > ); } export default Thread;
{ handleQuoteClick(reply, shortParentCid, comment.shortCid, event); }} > {`c/${shortParentCid}`}{shortParentCid === comment.shortCid ? " (OP)" : null}
{comment.content ? ( <> > ) : null}
handleQuoteClick(reply, shortParentCid, comment.shortCid, event)}> {`c/${shortParentCid}`}{shortParentCid === comment.shortCid ? " (OP)" : null}