ViewThread.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. import React, { useState, useEffect, useRef } from 'react';
  2. import { Link, RouteComponentProps } from 'react-router-dom';
  3. import ReactMarkdown from 'react-markdown';
  4. import styled from 'styled-components';
  5. import { Table, Button, Label, Icon } from 'semantic-ui-react';
  6. import BN from 'bn.js';
  7. import { PostId, ThreadId } from '@joystream/types/common';
  8. import { Category, Thread, Post } from '@joystream/types/forum';
  9. import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination, useQueryParam, ReplyIdxQueryParam, ReplyEditIdQueryParam } from './utils';
  10. import { ViewReply } from './ViewReply';
  11. import { Moderate } from './Moderate';
  12. import { MutedSpan, JoyWarn } from '@polkadot/joy-utils/react/components';
  13. import { withForumCalls } from './calls';
  14. import { withApi, withMulti } from '@polkadot/react-api';
  15. import { ApiProps } from '@polkadot/react-api/types';
  16. import { orderBy } from 'lodash';
  17. import { bnToStr } from '@polkadot/joy-utils/functions/misc';
  18. import { IfIAmForumSudo } from './ForumSudo';
  19. import MemberPreview from '@polkadot/joy-utils/react/components/MemberByAccountPreview';
  20. import { formatDate } from '@polkadot/joy-utils/functions/date';
  21. import { NewReply, EditReply } from './EditReply';
  22. import { useApi } from '@polkadot/react-hooks';
  23. import { ApiPromise } from '@polkadot/api/promise';
  24. type ThreadTitleProps = {
  25. thread: Thread;
  26. className?: string;
  27. };
  28. function ThreadTitle (props: ThreadTitleProps) {
  29. const { thread, className } = props;
  30. return <span className={className}>
  31. {/* {thread.pinned && <i
  32. className='star icon'
  33. title='This post is pinned by moderator'
  34. style={{ marginRight: '.5rem' }}
  35. />} */}
  36. {thread.title}
  37. </span>;
  38. }
  39. const ThreadHeader = styled.div`
  40. margin: 1rem 0;
  41. h1 {
  42. margin: 0;
  43. }
  44. `;
  45. const ThreadInfoAndActions = styled.div`
  46. display: flex;
  47. justify-content: space-between;
  48. align-items: center;
  49. margin-top: .3rem;
  50. h1 {
  51. margin: 0;
  52. }
  53. `;
  54. const ThreadInfo = styled.span`
  55. display: inline-flex;
  56. align-items: center;
  57. font-size: .85rem;
  58. color: rgba(0, 0, 0, 0.5);
  59. `;
  60. const ThreadInfoMemberPreview = styled(MemberPreview)`
  61. margin: 0 .5rem;
  62. `;
  63. const ReplyEditContainer = styled.div`
  64. margin-top: 30px;
  65. padding-bottom: 60px;
  66. `;
  67. type ThreadPreviewProps = {
  68. thread: Thread;
  69. repliesCount: number;
  70. }
  71. const ThreadPreview: React.FC<ThreadPreviewProps> = ({ thread, repliesCount }) => {
  72. const title = <ThreadTitle thread={thread} />;
  73. return (
  74. <Table.Row>
  75. <Table.Cell>
  76. <Link to={`/forum/threads/${thread.id.toString()}`}>
  77. {
  78. thread.moderated
  79. ? (
  80. <MutedSpan>
  81. <Label color='orange'>Moderated</Label> {title}
  82. </MutedSpan>
  83. )
  84. : title
  85. }
  86. </Link>
  87. </Table.Cell>
  88. <Table.Cell>
  89. {repliesCount}
  90. </Table.Cell>
  91. <Table.Cell>
  92. <MemberPreview accountId={thread.author_id} showCouncilBadge showId={false}/>
  93. </Table.Cell>
  94. <Table.Cell>
  95. {formatDate(thread.created_at.momentDate)}
  96. </Table.Cell>
  97. </Table.Row>
  98. );
  99. };
  100. type InnerViewThreadProps = {
  101. category: Category;
  102. thread: Thread;
  103. preview?: boolean;
  104. };
  105. type ViewThreadProps = ApiProps & InnerViewThreadProps & {
  106. nextPostId?: ThreadId;
  107. };
  108. const POSTS_THREAD_MAP_CACHE_KEY = 'postsThreadMap';
  109. async function refreshPostsInThreadCache (nextPostId: PostId, api: ApiPromise) {
  110. const newId = (id: number | BN) => api.createType('PostId', id);
  111. const apiCalls: Promise<Post>[] = [];
  112. let idToFetch = newId(1);
  113. let postsToThread = getPostsIdsInThreadCache();
  114. const nextThreadId = await api.query.forum.nextThreadId() as ThreadId;
  115. if (postsToThread.size >= nextThreadId.toNumber()) { // invalid cache
  116. postsToThread = new Map<number, number[]>();
  117. }
  118. if (postsToThread.size > 0) {
  119. const lastPostIdInCache = Math.max(...Array.from(postsToThread.values()).flat());
  120. idToFetch = newId(lastPostIdInCache + 1);
  121. const lastPost = await api.query.forum.postById(lastPostIdInCache) as Post;
  122. if (lastPost) {
  123. const postsInThread = postsToThread.get(lastPost.thread_id.toNumber());
  124. if (!postsInThread || !postsInThread.includes(lastPostIdInCache)) { // cache doesn't match the data in chain
  125. postsToThread = new Map<number, number[]>();
  126. }
  127. } else {
  128. postsToThread = new Map<number, number[]>();
  129. }
  130. }
  131. const lastPostId = nextPostId.sub(new BN(1));
  132. while (lastPostId.gte(idToFetch)) {
  133. apiCalls.push(api.query.forum.postById(idToFetch) as Promise<Post>);
  134. idToFetch = newId(idToFetch.add(newId(1)));
  135. }
  136. const newPosts = await Promise.all<Post>(apiCalls);
  137. const newPostsToThread = new Map<number, number[]>();
  138. newPosts.forEach((newPost) => {
  139. const previousNewPostIds = newPostsToThread.get(newPost.thread_id.toNumber()) ?? [];
  140. newPostsToThread.set(newPost.thread_id.toNumber(), [...previousNewPostIds, newPost.id.toNumber()]);
  141. });
  142. if (postsToThread.size > 0) {
  143. newPostsToThread.forEach((postIds, threadId) => {
  144. const existingPostIds = postsToThread.get(threadId) ?? [];
  145. postsToThread.set(threadId, [...existingPostIds, ...postIds]);
  146. });
  147. } else {
  148. postsToThread = newPostsToThread;
  149. }
  150. localStorage.setItem(POSTS_THREAD_MAP_CACHE_KEY, JSON.stringify([...postsToThread]));
  151. }
  152. function getPostsIdsInThreadCache (): Map<number, number[]> {
  153. const serializedMap = localStorage.getItem(POSTS_THREAD_MAP_CACHE_KEY);
  154. if (!serializedMap) {
  155. return new Map<number, number[]>();
  156. }
  157. return new Map<number, number[]>(JSON.parse(serializedMap));
  158. }
  159. function InnerViewThread (props: ViewThreadProps) {
  160. const [showModerateForm, setShowModerateForm] = useState(false);
  161. const [displayedPosts, setDisplayedPosts] = useState<Post[]>([]);
  162. const [quotedPost, setQuotedPost] = useState<Post | null>(null);
  163. const postsRefs = useRef<Record<number, React.RefObject<HTMLDivElement>>>({});
  164. const replyFormRef = useRef<HTMLDivElement>(null);
  165. const [rawSelectedPostIdx, setSelectedPostIdx] = useQueryParam(ReplyIdxQueryParam);
  166. const [rawEditedPostId, setEditedPostId] = useQueryParam(ReplyEditIdQueryParam);
  167. const [currentPage, setCurrentPage] = usePagination();
  168. const parsedSelectedPostIdx = rawSelectedPostIdx ? parseInt(rawSelectedPostIdx) : null;
  169. const selectedPostIdx = (parsedSelectedPostIdx && !Number.isNaN(parsedSelectedPostIdx)) ? parsedSelectedPostIdx : null;
  170. const { category, thread, preview = false, api, nextPostId } = props;
  171. const editedPostId = rawEditedPostId && api.createType('PostId', rawEditedPostId);
  172. const { id } = thread;
  173. const totalPostsInThread = thread.num_posts_ever_created.toNumber();
  174. const [loaded, setLoaded] = useState(false);
  175. const [posts, setPosts] = useState(new Array<Post>());
  176. // fetch posts
  177. useEffect(() => {
  178. const loadPosts = async () => {
  179. if (!nextPostId || totalPostsInThread === 0 || thread.isEmpty) return;
  180. await refreshPostsInThreadCache(nextPostId, api);
  181. const mapPostToThread = getPostsIdsInThreadCache();
  182. const postIdsInThread = mapPostToThread.get(thread.id.toNumber()) as number[];
  183. const postsInThisThread = await Promise.all(postIdsInThread
  184. ? postIdsInThread.map((postId: number) => api.query.forum.postById(postId)) : []) as Post[];
  185. const sortedPosts = orderBy(
  186. postsInThisThread,
  187. [(x) => x.nr_in_thread.toNumber()],
  188. ['asc']
  189. );
  190. // initialize refs for posts
  191. postsRefs.current = sortedPosts.reduce((acc, reply) => {
  192. const refKey = reply.nr_in_thread.toNumber();
  193. acc[refKey] = React.createRef();
  194. return acc;
  195. }, postsRefs.current);
  196. setPosts(sortedPosts);
  197. setLoaded(true);
  198. };
  199. void loadPosts();
  200. }, [bnToStr(thread.id), bnToStr(nextPostId)]);
  201. // handle selected post
  202. useEffect(() => {
  203. if (!selectedPostIdx) return;
  204. const selectedPostPage = Math.ceil(selectedPostIdx / RepliesPerPage);
  205. if (currentPage !== selectedPostPage) {
  206. setCurrentPage(selectedPostPage);
  207. }
  208. if (!loaded) return;
  209. if (selectedPostIdx > posts.length) {
  210. // eslint-disable-next-line no-console
  211. console.warn(`Tried to open nonexistent reply with idx: ${selectedPostIdx}`);
  212. return;
  213. }
  214. const postRef = postsRefs.current[selectedPostIdx];
  215. // postpone scrolling for one render to make sure the ref is set
  216. setTimeout(() => {
  217. if (postRef.current) {
  218. postRef.current.scrollIntoView();
  219. } else {
  220. // eslint-disable-next-line no-console
  221. console.warn('Ref for selected post empty');
  222. }
  223. });
  224. }, [loaded, selectedPostIdx, currentPage]);
  225. // handle displayed posts based on pagination
  226. useEffect(() => {
  227. if (!loaded) return;
  228. const minIdx = (currentPage - 1) * RepliesPerPage;
  229. const maxIdx = minIdx + RepliesPerPage - 1;
  230. const postsToDisplay = posts.filter((_id, i) => i >= minIdx && i <= maxIdx);
  231. setDisplayedPosts(postsToDisplay);
  232. }, [loaded, posts, currentPage]);
  233. const renderThreadNotFound = () => (
  234. preview ? null : <em>Thread not found</em>
  235. );
  236. if (thread.isEmpty) {
  237. return renderThreadNotFound();
  238. }
  239. if (!category) {
  240. return <em>{'Thread\'s category was not found.'}</em>;
  241. } else if (category.deleted) {
  242. return renderThreadNotFound();
  243. }
  244. if (preview) {
  245. return <ThreadPreview thread={thread} repliesCount={totalPostsInThread - 1} />;
  246. }
  247. const changePageAndClearSelectedPost = (page?: number | string) => {
  248. setSelectedPostIdx(null);
  249. setCurrentPage(page, [ReplyIdxQueryParam]);
  250. };
  251. const scrollToReplyForm = () => {
  252. if (!replyFormRef.current) return;
  253. replyFormRef.current.scrollIntoView();
  254. };
  255. const clearEditedPost = () => {
  256. setEditedPostId(null);
  257. };
  258. const onThreadReplyClick = () => {
  259. clearEditedPost();
  260. setQuotedPost(null);
  261. scrollToReplyForm();
  262. };
  263. const onPostEditSuccess = async () => {
  264. if (!editedPostId) {
  265. // eslint-disable-next-line no-console
  266. console.error('editedPostId not set!');
  267. return;
  268. }
  269. const updatedPost = await api.query.forum.postById(editedPostId) as Post;
  270. const updatedPosts = posts.map((post) => post.id.eq(editedPostId) ? updatedPost : post);
  271. setPosts(updatedPosts);
  272. clearEditedPost();
  273. };
  274. // console.log({ nextPostId: bnToStr(nextPostId), loaded, posts });
  275. const renderPageOfPosts = () => {
  276. if (!loaded) {
  277. return <em>Loading posts...</em>;
  278. }
  279. const pagination =
  280. <Pagination
  281. currentPage={currentPage}
  282. totalItems={posts.length}
  283. itemsPerPage={RepliesPerPage}
  284. onPageChange={changePageAndClearSelectedPost}
  285. />;
  286. const renderedReplies = displayedPosts.map((reply) => {
  287. const replyIdx = reply.nr_in_thread.toNumber();
  288. const onReplyEditClick = () => {
  289. setEditedPostId(reply.id.toString());
  290. scrollToReplyForm();
  291. };
  292. const onReplyQuoteClick = () => {
  293. setQuotedPost(reply);
  294. scrollToReplyForm();
  295. };
  296. return (
  297. <ViewReply
  298. ref={postsRefs.current[replyIdx]}
  299. key={replyIdx}
  300. category={category}
  301. thread={thread}
  302. reply={reply}
  303. selected={selectedPostIdx === replyIdx}
  304. onEdit={onReplyEditClick}
  305. onQuote={onReplyQuoteClick}
  306. />
  307. );
  308. });
  309. return <>
  310. {pagination}
  311. {renderedReplies}
  312. {pagination}
  313. </>;
  314. };
  315. const renderActions = () => {
  316. if (thread.moderated || category.archived || category.deleted) {
  317. return null;
  318. }
  319. return <span className='JoyInlineActions'>
  320. <Button onClick={onThreadReplyClick}>
  321. <Icon name='reply' />
  322. Reply
  323. </Button>
  324. {/* TODO show 'Edit' button only if I am owner */}
  325. {/* <Link
  326. to={`/forum/threads/${id.toString()}/edit`}
  327. className='ui small button'
  328. >
  329. <i className='pencil alternate icon' />
  330. Edit
  331. </Link> */}
  332. <IfIAmForumSudo>
  333. <Button
  334. type='button'
  335. size='small'
  336. content={'Moderate'}
  337. onClick={() => setShowModerateForm(!showModerateForm)}
  338. />
  339. </IfIAmForumSudo>
  340. </span>;
  341. };
  342. const renderModerationRationale = () => {
  343. if (!thread.moderation) return null;
  344. return <>
  345. <JoyWarn title={'This thread is moderated. Rationale:'}>
  346. <ReactMarkdown className='JoyMemo--full' source={thread.moderation.rationale} linkTarget='_blank' />
  347. </JoyWarn>
  348. </>;
  349. };
  350. return <div style={{ marginBottom: '1rem' }}>
  351. <CategoryCrumbs categoryId={thread.category_id} />
  352. <ThreadHeader>
  353. <h1 className='ForumPageTitle'>
  354. <ThreadTitle thread={thread} className='TitleText' />
  355. </h1>
  356. <ThreadInfoAndActions>
  357. <ThreadInfo>
  358. Created by
  359. <ThreadInfoMemberPreview accountId={thread.author_id} size='small' showId={false}/>
  360. <TimeAgoDate date={thread.created_at.momentDate} id='thread' />
  361. </ThreadInfo>
  362. {renderActions()}
  363. </ThreadInfoAndActions>
  364. </ThreadHeader>
  365. {category.archived &&
  366. <JoyWarn title={'This thread is in archived category.'}>
  367. No new replies can be posted.
  368. </JoyWarn>
  369. }
  370. {showModerateForm &&
  371. <Moderate id={id} onCloseForm={() => setShowModerateForm(false)} />
  372. }
  373. {thread.moderated
  374. ? renderModerationRationale()
  375. : renderPageOfPosts()
  376. }
  377. <ReplyEditContainer ref={replyFormRef}>
  378. {
  379. editedPostId ? (
  380. <EditReply id={editedPostId} key={editedPostId.toString()} onEditSuccess={onPostEditSuccess} onEditCancel={clearEditedPost} />
  381. ) : (
  382. <NewReply threadId={thread.id} key={quotedPost?.id.toString()} quotedPost={quotedPost} />
  383. )
  384. }
  385. </ReplyEditContainer>
  386. </div>;
  387. }
  388. export const ViewThread = withMulti(
  389. InnerViewThread,
  390. withApi,
  391. withForumCalls<ViewThreadProps>(
  392. ['nextPostId', { propName: 'nextPostId' }]
  393. )
  394. );
  395. type ViewThreadByIdProps = RouteComponentProps<{ id: string }>;
  396. export function ViewThreadById (props: ViewThreadByIdProps) {
  397. const { api } = useApi();
  398. const { match: { params: { id } } } = props;
  399. const [loaded, setLoaded] = useState(false);
  400. const [thread, setThread] = useState(api.createType('Thread', {}));
  401. const [category, setCategory] = useState(api.createType('Category', {}));
  402. let threadId: ThreadId | undefined;
  403. try {
  404. threadId = api.createType('ThreadId', id);
  405. } catch (err) {
  406. console.log('Failed to parse thread id form URL');
  407. }
  408. useEffect(() => {
  409. const loadThreadAndCategory = async () => {
  410. if (!threadId) return;
  411. const thread = await api.query.forum.threadById(threadId) as Thread;
  412. const category = await api.query.forum.categoryById(thread.category_id) as Category;
  413. setThread(thread);
  414. setCategory(category);
  415. setLoaded(true);
  416. };
  417. void loadThreadAndCategory();
  418. }, [id]);
  419. if (threadId === undefined) {
  420. return <em>Invalid thread ID: {id}</em>;
  421. }
  422. // console.log({ threadId: id, page });
  423. if (!loaded) {
  424. return <em>Loading thread details...</em>;
  425. }
  426. if (thread.isEmpty) {
  427. return <em>Thread was not found by id</em>;
  428. }
  429. if (category.isEmpty) {
  430. return <em>{ 'Thread\'s category was not found' }</em>;
  431. }
  432. return <ViewThread id={threadId} category={category} thread={thread} />;
  433. }