import React, { useState, useEffect, useRef } from 'react'; import { Link, RouteComponentProps } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import styled from 'styled-components'; import { Table, Button, Label, Icon } from 'semantic-ui-react'; import BN from 'bn.js'; import { PostId, ThreadId } from '@joystream/types/common'; import { Category, Thread, Post } from '@joystream/types/forum'; import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination, useQueryParam, ReplyIdxQueryParam, ReplyEditIdQueryParam } from './utils'; import { ViewReply } from './ViewReply'; import { Moderate } from './Moderate'; import { MutedSpan, JoyWarn } from '@polkadot/joy-utils/react/components'; import { withForumCalls } from './calls'; import { withApi, withMulti } from '@polkadot/react-api'; import { ApiProps } from '@polkadot/react-api/types'; import { orderBy } from 'lodash'; import { bnToStr } from '@polkadot/joy-utils/functions/misc'; import { IfIAmForumSudo } from './ForumSudo'; import MemberPreview from '@polkadot/joy-utils/react/components/MemberByAccountPreview'; import { formatDate } from '@polkadot/joy-utils/functions/date'; import { NewReply, EditReply } from './EditReply'; import { useApi } from '@polkadot/react-hooks'; import { ApiPromise } from '@polkadot/api/promise'; type ThreadTitleProps = { thread: Thread; className?: string; }; function ThreadTitle (props: ThreadTitleProps) { const { thread, className } = props; return {/* {thread.pinned && } */} {thread.title} ; } const ThreadHeader = styled.div` margin: 1rem 0; h1 { margin: 0; } `; const ThreadInfoAndActions = styled.div` display: flex; justify-content: space-between; align-items: center; margin-top: .3rem; h1 { margin: 0; } `; const ThreadInfo = styled.span` display: inline-flex; align-items: center; font-size: .85rem; color: rgba(0, 0, 0, 0.5); `; const ThreadInfoMemberPreview = styled(MemberPreview)` margin: 0 .5rem; `; const ReplyEditContainer = styled.div` margin-top: 30px; padding-bottom: 60px; `; type ThreadPreviewProps = { thread: Thread; repliesCount: number; } const ThreadPreview: React.FC = ({ thread, repliesCount }) => { const title = ; return ( { thread.moderated ? ( {title} ) : title } {repliesCount} {formatDate(thread.created_at.momentDate)} ); }; type InnerViewThreadProps = { category: Category; thread: Thread; preview?: boolean; }; type ViewThreadProps = ApiProps & InnerViewThreadProps & { nextPostId?: ThreadId; }; const POSTS_THREAD_MAP_CACHE_KEY = 'postsThreadMap'; async function refreshPostsInThreadCache (nextPostId: PostId, api: ApiPromise) { const newId = (id: number | BN) => api.createType('PostId', id); const apiCalls: Promise[] = []; let idToFetch = newId(1); let postsToThread = getPostsIdsInThreadCache(); const nextThreadId = await api.query.forum.nextThreadId() as ThreadId; if (postsToThread.size >= nextThreadId.toNumber()) { // invalid cache postsToThread = new Map(); } if (postsToThread.size > 0) { const lastPostIdInCache = Math.max(...Array.from(postsToThread.values()).flat()); idToFetch = newId(lastPostIdInCache + 1); const lastPost = await api.query.forum.postById(lastPostIdInCache) as Post; if (lastPost) { const postsInThread = postsToThread.get(lastPost.thread_id.toNumber()); if (!postsInThread || !postsInThread.includes(lastPostIdInCache)) { // cache doesn't match the data in chain postsToThread = new Map(); } } else { postsToThread = new Map(); } } const lastPostId = nextPostId.sub(new BN(1)); while (lastPostId.gte(idToFetch)) { apiCalls.push(api.query.forum.postById(idToFetch) as Promise); idToFetch = newId(idToFetch.add(newId(1))); } const newPosts = await Promise.all(apiCalls); const newPostsToThread = new Map(); newPosts.forEach((newPost) => { const previousNewPostIds = newPostsToThread.get(newPost.thread_id.toNumber()) ?? []; newPostsToThread.set(newPost.thread_id.toNumber(), [...previousNewPostIds, newPost.id.toNumber()]); }); if (postsToThread.size > 0) { newPostsToThread.forEach((postIds, threadId) => { const existingPostIds = postsToThread.get(threadId) ?? []; postsToThread.set(threadId, [...existingPostIds, ...postIds]); }); } else { postsToThread = newPostsToThread; } localStorage.setItem(POSTS_THREAD_MAP_CACHE_KEY, JSON.stringify([...postsToThread])); } function getPostsIdsInThreadCache (): Map { const serializedMap = localStorage.getItem(POSTS_THREAD_MAP_CACHE_KEY); if (!serializedMap) { return new Map(); } return new Map(JSON.parse(serializedMap)); } function InnerViewThread (props: ViewThreadProps) { const [showModerateForm, setShowModerateForm] = useState(false); const [displayedPosts, setDisplayedPosts] = useState([]); const [quotedPost, setQuotedPost] = useState(null); const postsRefs = useRef>>({}); const replyFormRef = useRef(null); const [rawSelectedPostIdx, setSelectedPostIdx] = useQueryParam(ReplyIdxQueryParam); const [rawEditedPostId, setEditedPostId] = useQueryParam(ReplyEditIdQueryParam); const [currentPage, setCurrentPage] = usePagination(); const parsedSelectedPostIdx = rawSelectedPostIdx ? parseInt(rawSelectedPostIdx) : null; const selectedPostIdx = (parsedSelectedPostIdx && !Number.isNaN(parsedSelectedPostIdx)) ? parsedSelectedPostIdx : null; const { category, thread, preview = false, api, nextPostId } = props; const editedPostId = rawEditedPostId && api.createType('PostId', rawEditedPostId); const { id } = thread; const totalPostsInThread = thread.num_posts_ever_created.toNumber(); const [loaded, setLoaded] = useState(false); const [posts, setPosts] = useState(new Array()); // fetch posts useEffect(() => { const loadPosts = async () => { if (!nextPostId || totalPostsInThread === 0 || thread.isEmpty) return; await refreshPostsInThreadCache(nextPostId, api); const mapPostToThread = getPostsIdsInThreadCache(); const postIdsInThread = mapPostToThread.get(thread.id.toNumber()) as number[]; const postsInThisThread = await Promise.all(postIdsInThread ? postIdsInThread.map((postId: number) => api.query.forum.postById(postId)) : []) as Post[]; const sortedPosts = orderBy( postsInThisThread, [(x) => x.nr_in_thread.toNumber()], ['asc'] ); // initialize refs for posts postsRefs.current = sortedPosts.reduce((acc, reply) => { const refKey = reply.nr_in_thread.toNumber(); acc[refKey] = React.createRef(); return acc; }, postsRefs.current); setPosts(sortedPosts); setLoaded(true); }; void loadPosts(); }, [bnToStr(thread.id), bnToStr(nextPostId)]); // handle selected post useEffect(() => { if (!selectedPostIdx) return; const selectedPostPage = Math.ceil(selectedPostIdx / RepliesPerPage); if (currentPage !== selectedPostPage) { setCurrentPage(selectedPostPage); } if (!loaded) return; if (selectedPostIdx > posts.length) { // eslint-disable-next-line no-console console.warn(`Tried to open nonexistent reply with idx: ${selectedPostIdx}`); return; } const postRef = postsRefs.current[selectedPostIdx]; // postpone scrolling for one render to make sure the ref is set setTimeout(() => { if (postRef.current) { postRef.current.scrollIntoView(); } else { // eslint-disable-next-line no-console console.warn('Ref for selected post empty'); } }); }, [loaded, selectedPostIdx, currentPage]); // handle displayed posts based on pagination useEffect(() => { if (!loaded) return; const minIdx = (currentPage - 1) * RepliesPerPage; const maxIdx = minIdx + RepliesPerPage - 1; const postsToDisplay = posts.filter((_id, i) => i >= minIdx && i <= maxIdx); setDisplayedPosts(postsToDisplay); }, [loaded, posts, currentPage]); const renderThreadNotFound = () => ( preview ? null : Thread not found ); if (thread.isEmpty) { return renderThreadNotFound(); } if (!category) { return {'Thread\'s category was not found.'}; } else if (category.deleted) { return renderThreadNotFound(); } if (preview) { return ; } const changePageAndClearSelectedPost = (page?: number | string) => { setSelectedPostIdx(null); setCurrentPage(page, [ReplyIdxQueryParam]); }; const scrollToReplyForm = () => { if (!replyFormRef.current) return; replyFormRef.current.scrollIntoView(); }; const clearEditedPost = () => { setEditedPostId(null); }; const onThreadReplyClick = () => { clearEditedPost(); setQuotedPost(null); scrollToReplyForm(); }; const onPostEditSuccess = async () => { if (!editedPostId) { // eslint-disable-next-line no-console console.error('editedPostId not set!'); return; } const updatedPost = await api.query.forum.postById(editedPostId) as Post; const updatedPosts = posts.map((post) => post.id.eq(editedPostId) ? updatedPost : post); setPosts(updatedPosts); clearEditedPost(); }; // console.log({ nextPostId: bnToStr(nextPostId), loaded, posts }); const renderPageOfPosts = () => { if (!loaded) { return Loading posts...; } const pagination = ; const renderedReplies = displayedPosts.map((reply) => { const replyIdx = reply.nr_in_thread.toNumber(); const onReplyEditClick = () => { setEditedPostId(reply.id.toString()); scrollToReplyForm(); }; const onReplyQuoteClick = () => { setQuotedPost(reply); scrollToReplyForm(); }; return ( ); }); return <> {pagination} {renderedReplies} {pagination} ; }; const renderActions = () => { if (thread.moderated || category.archived || category.deleted) { return null; } return {/* TODO show 'Edit' button only if I am owner */} {/* Edit */}