123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541 |
- 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 <span className={className}>
- {/* {thread.pinned && <i
- className='star icon'
- title='This post is pinned by moderator'
- style={{ marginRight: '.5rem' }}
- />} */}
- {thread.title}
- </span>;
- }
- 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<ThreadPreviewProps> = ({ thread, repliesCount }) => {
- const title = <ThreadTitle thread={thread} />;
- return (
- <Table.Row>
- <Table.Cell>
- <Link to={`/forum/threads/${thread.id.toString()}`}>
- {
- thread.moderated
- ? (
- <MutedSpan>
- <Label color='orange'>Moderated</Label> {title}
- </MutedSpan>
- )
- : title
- }
- </Link>
- </Table.Cell>
- <Table.Cell>
- {repliesCount}
- </Table.Cell>
- <Table.Cell>
- <MemberPreview accountId={thread.author_id} showCouncilBadge showId={false}/>
- </Table.Cell>
- <Table.Cell>
- {formatDate(thread.created_at.momentDate)}
- </Table.Cell>
- </Table.Row>
- );
- };
- 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<Post>[] = [];
- 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<number, number[]>();
- }
- 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<number, number[]>();
- }
- } else {
- postsToThread = new Map<number, number[]>();
- }
- }
- const lastPostId = nextPostId.sub(new BN(1));
- while (lastPostId.gte(idToFetch)) {
- apiCalls.push(api.query.forum.postById(idToFetch) as Promise<Post>);
- idToFetch = newId(idToFetch.add(newId(1)));
- }
- const newPosts = await Promise.all<Post>(apiCalls);
- const newPostsToThread = new Map<number, number[]>();
- 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<number, number[]> {
- const serializedMap = localStorage.getItem(POSTS_THREAD_MAP_CACHE_KEY);
- if (!serializedMap) {
- return new Map<number, number[]>();
- }
- return new Map<number, number[]>(JSON.parse(serializedMap));
- }
- function InnerViewThread (props: ViewThreadProps) {
- const [showModerateForm, setShowModerateForm] = useState(false);
- const [displayedPosts, setDisplayedPosts] = useState<Post[]>([]);
- const [quotedPost, setQuotedPost] = useState<Post | null>(null);
- const postsRefs = useRef<Record<number, React.RefObject<HTMLDivElement>>>({});
- const replyFormRef = useRef<HTMLDivElement>(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<Post>());
- // 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 : <em>Thread not found</em>
- );
- if (thread.isEmpty) {
- return renderThreadNotFound();
- }
- if (!category) {
- return <em>{'Thread\'s category was not found.'}</em>;
- } else if (category.deleted) {
- return renderThreadNotFound();
- }
- if (preview) {
- return <ThreadPreview thread={thread} repliesCount={totalPostsInThread - 1} />;
- }
- 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 <em>Loading posts...</em>;
- }
- const pagination =
- <Pagination
- currentPage={currentPage}
- totalItems={posts.length}
- itemsPerPage={RepliesPerPage}
- onPageChange={changePageAndClearSelectedPost}
- />;
- 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 (
- <ViewReply
- ref={postsRefs.current[replyIdx]}
- key={replyIdx}
- category={category}
- thread={thread}
- reply={reply}
- selected={selectedPostIdx === replyIdx}
- onEdit={onReplyEditClick}
- onQuote={onReplyQuoteClick}
- />
- );
- });
- return <>
- {pagination}
- {renderedReplies}
- {pagination}
- </>;
- };
- const renderActions = () => {
- if (thread.moderated || category.archived || category.deleted) {
- return null;
- }
- return <span className='JoyInlineActions'>
- <Button onClick={onThreadReplyClick}>
- <Icon name='reply' />
- Reply
- </Button>
- {/* TODO show 'Edit' button only if I am owner */}
- {/* <Link
- to={`/forum/threads/${id.toString()}/edit`}
- className='ui small button'
- >
- <i className='pencil alternate icon' />
- Edit
- </Link> */}
- <IfIAmForumSudo>
- <Button
- type='button'
- size='small'
- content={'Moderate'}
- onClick={() => setShowModerateForm(!showModerateForm)}
- />
- </IfIAmForumSudo>
- </span>;
- };
- const renderModerationRationale = () => {
- if (!thread.moderation) return null;
- return <>
- <JoyWarn title={'This thread is moderated. Rationale:'}>
- <ReactMarkdown className='JoyMemo--full' source={thread.moderation.rationale} linkTarget='_blank' />
- </JoyWarn>
- </>;
- };
- return <div style={{ marginBottom: '1rem' }}>
- <CategoryCrumbs categoryId={thread.category_id} />
- <ThreadHeader>
- <h1 className='ForumPageTitle'>
- <ThreadTitle thread={thread} className='TitleText' />
- </h1>
- <ThreadInfoAndActions>
- <ThreadInfo>
- Created by
- <ThreadInfoMemberPreview accountId={thread.author_id} size='small' showId={false}/>
- <TimeAgoDate date={thread.created_at.momentDate} id='thread' />
- </ThreadInfo>
- {renderActions()}
- </ThreadInfoAndActions>
- </ThreadHeader>
- {category.archived &&
- <JoyWarn title={'This thread is in archived category.'}>
- No new replies can be posted.
- </JoyWarn>
- }
- {showModerateForm &&
- <Moderate id={id} onCloseForm={() => setShowModerateForm(false)} />
- }
- {thread.moderated
- ? renderModerationRationale()
- : renderPageOfPosts()
- }
- <ReplyEditContainer ref={replyFormRef}>
- {
- editedPostId ? (
- <EditReply id={editedPostId} key={editedPostId.toString()} onEditSuccess={onPostEditSuccess} onEditCancel={clearEditedPost} />
- ) : (
- <NewReply threadId={thread.id} key={quotedPost?.id.toString()} quotedPost={quotedPost} />
- )
- }
- </ReplyEditContainer>
- </div>;
- }
- export const ViewThread = withMulti(
- InnerViewThread,
- withApi,
- withForumCalls<ViewThreadProps>(
- ['nextPostId', { propName: 'nextPostId' }]
- )
- );
- type ViewThreadByIdProps = RouteComponentProps<{ id: string }>;
- export function ViewThreadById (props: ViewThreadByIdProps) {
- const { api } = useApi();
- const { match: { params: { id } } } = props;
- const [loaded, setLoaded] = useState(false);
- const [thread, setThread] = useState(api.createType('Thread', {}));
- const [category, setCategory] = useState(api.createType('Category', {}));
- let threadId: ThreadId | undefined;
- try {
- threadId = api.createType('ThreadId', id);
- } catch (err) {
- console.log('Failed to parse thread id form URL');
- }
- useEffect(() => {
- const loadThreadAndCategory = async () => {
- if (!threadId) return;
- const thread = await api.query.forum.threadById(threadId) as Thread;
- const category = await api.query.forum.categoryById(thread.category_id) as Category;
- setThread(thread);
- setCategory(category);
- setLoaded(true);
- };
- void loadThreadAndCategory();
- }, [id]);
- if (threadId === undefined) {
- return <em>Invalid thread ID: {id}</em>;
- }
- // console.log({ threadId: id, page });
- if (!loaded) {
- return <em>Loading thread details...</em>;
- }
- if (thread.isEmpty) {
- return <em>Thread was not found by id</em>;
- }
- if (category.isEmpty) {
- return <em>{ 'Thread\'s category was not found' }</em>;
- }
- return <ViewThread id={threadId} category={category} thread={thread} />;
- }
|