Quellcode durchsuchen

enable reply linking

Klaudiusz Dembler vor 4 Jahren
Ursprung
Commit
558c1926e1

+ 17 - 9
pioneer/packages/joy-forum/src/ViewReply.tsx

@@ -1,8 +1,8 @@
 import React, { useState } from 'react';
 import styled from 'styled-components';
-import { Link } from 'react-router-dom';
+import { Link, useLocation } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
-import { Segment, Button, Icon } from 'semantic-ui-react';
+import { Button, Icon } from 'semantic-ui-react';
 
 import { Post, Category, Thread } from '@joystream/types/forum';
 import { Moderate } from './Moderate';
@@ -16,9 +16,11 @@ const HORIZONTAL_PADDING = '1em';
 const ReplyMarkdown = styled(ReactMarkdown)`
   font-size: 1.15rem;
 `;
-const ReplyContainer = styled(Segment)`
+const ReplyContainer = styled.div<{ selected?: boolean }>`
   && {
     padding: 0;
+
+    outline: ${({ selected }) => selected ? '2px solid #ffc87b' : 'none'};
   }
   overflow: hidden;
 `;
@@ -52,12 +54,15 @@ type ViewReplyProps = {
   reply: Post;
   thread: Thread;
   category: Category;
+  selected?: boolean;
 };
 
-export function ViewReply (props: ViewReplyProps) {
+// eslint-disable-next-line react/display-name
+export const ViewReply = React.forwardRef((props: ViewReplyProps, ref: React.Ref<HTMLDivElement>) => {
   const { state: { address: myAddress } } = useMyAccount();
   const [showModerateForm, setShowModerateForm] = useState(false);
-  const { reply, thread, category } = props;
+  const { pathname, search } = useLocation();
+  const { reply, thread, category, selected = false } = props;
   const { id } = reply;
 
   if (reply.isEmpty) {
@@ -108,17 +113,20 @@ export function ViewReply (props: ViewReplyProps) {
     </ReplyFooterActionsRow>;
   };
 
+  const replyLinkSearch = new URLSearchParams(search);
+  replyLinkSearch.set('replyIdx', reply.nr_in_thread.toString());
+
   return (
-    <ReplyContainer>
+    <ReplyContainer className="ui segment" ref={ref} selected={selected}>
       <ReplyHeader>
         <ReplyHeaderAuthorRow>
           <MemberPreview accountId={reply.author_id} />
         </ReplyHeaderAuthorRow>
         <ReplyHeaderDetailsRow>
           <TimeAgoDate date={reply.created_at.momentDate} id={reply.id} />
-          <a>
+          <Link to={{ pathname, search: replyLinkSearch.toString() }}>
             #{reply.nr_in_thread.toNumber()}
-          </a>
+          </Link>
         </ReplyHeaderDetailsRow>
       </ReplyHeader>
 
@@ -137,4 +145,4 @@ export function ViewReply (props: ViewReplyProps) {
       </ReplyFooter>
     </ReplyContainer>
   );
-}
+});

+ 74 - 12
pioneer/packages/joy-forum/src/ViewThread.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
 import { Link } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
 import styled from 'styled-components';
@@ -6,7 +6,7 @@ import { Table, Button, Label } from 'semantic-ui-react';
 import BN from 'bn.js';
 
 import { Category, Thread, ThreadId, Post, PostId } from '@joystream/types/forum';
-import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination } from './utils';
+import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination, useQueryParam } from './utils';
 import { ViewReply } from './ViewReply';
 import { Moderate } from './Moderate';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
@@ -88,8 +88,14 @@ type ViewThreadProps = ApiProps & InnerViewThreadProps & {
 
 function InnerViewThread (props: ViewThreadProps) {
   const [showModerateForm, setShowModerateForm] = useState(false);
+  const [displayedPosts, setDisplayedPosts] = useState<Post[]>([]);
+  const postsRefs = useRef<Record<number, React.RefObject<HTMLDivElement>>>({});
   const { category, thread, preview = false } = props;
   const [currentPage, setCurrentPage] = usePagination();
+  const [rawSelectedPostIdx, setSelectedPostIdx] = useQueryParam('replyIdx');
+
+  const parsedSelectedPostIdx = rawSelectedPostIdx ? parseInt(rawSelectedPostIdx) : null;
+  const selectedPostIdx = (parsedSelectedPostIdx && !Number.isNaN(parsedSelectedPostIdx)) ? parsedSelectedPostIdx : null;
 
   if (!thread) {
     return <em>Loading thread details...</em>;
@@ -106,6 +112,11 @@ function InnerViewThread (props: ViewThreadProps) {
   const { id } = thread;
   const totalPostsInThread = thread.num_posts_ever_created.toNumber();
 
+  const changePageAndClearSelectedPost = (page?: number | string) => {
+    setSelectedPostIdx(null);
+    setCurrentPage(page, true);
+  };
+
   if (!category) {
     return <em>{'Thread\'s category was not found.'}</em>;
   } else if (category.deleted) {
@@ -163,6 +174,13 @@ function InnerViewThread (props: ViewThreadProps) {
         ['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);
     };
@@ -170,6 +188,44 @@ function InnerViewThread (props: ViewThreadProps) {
     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]);
+
   // console.log({ nextPostId: bnToStr(nextPostId), loaded, posts });
 
   const renderPageOfPosts = () => {
@@ -177,25 +233,31 @@ function InnerViewThread (props: ViewThreadProps) {
       return <em>Loading posts...</em>;
     }
 
-    const itemsPerPage = RepliesPerPage;
-    const minIdx = (currentPage - 1) * RepliesPerPage;
-    const maxIdx = minIdx + RepliesPerPage - 1;
-
     const pagination =
       <Pagination
         currentPage={currentPage}
         totalItems={totalPostsInThread}
-        itemsPerPage={itemsPerPage}
-        onPageChange={setCurrentPage}
+        itemsPerPage={RepliesPerPage}
+        onPageChange={changePageAndClearSelectedPost}
       />;
 
-    const pageOfItems = posts
-      .filter((_id, i) => i >= minIdx && i <= maxIdx)
-      .map((reply, i) => <ViewReply key={i} category={category} thread={thread} reply={reply} />);
+    const renderedReplies = displayedPosts.map((reply) => {
+      const replyIdx = reply.nr_in_thread.toNumber();
+      return (
+        <ViewReply
+          ref={postsRefs.current[replyIdx]}
+          key={replyIdx}
+          category={category}
+          thread={thread}
+          reply={reply}
+          selected={selectedPostIdx === replyIdx}
+        />
+      );
+    });
 
     return <>
       {pagination}
-      {pageOfItems}
+      {renderedReplies}
       {pagination}
     </>;
   };

+ 3 - 3
pioneer/packages/joy-forum/src/utils.tsx

@@ -148,7 +148,7 @@ export type UrlHasIdProps = {
 };
 
 type QueryValueType = string | null;
-type QuerySetValueType = (value?: QueryValueType | number) => void;
+type QuerySetValueType = (value?: QueryValueType | number, clear?: boolean) => void;
 type QueryReturnType = [QueryValueType, QuerySetValueType];
 
 export const useQueryParam = (queryParam: string): QueryReturnType => {
@@ -164,7 +164,7 @@ export const useQueryParam = (queryParam: string): QueryReturnType => {
     }
   }, [search, setValue, queryParam]);
 
-  const setParam: QuerySetValueType = (rawValue) => {
+  const setParam: QuerySetValueType = (rawValue, clear = false) => {
     let parsedValue: string | null;
     if (!rawValue && rawValue !== 0) {
       parsedValue = null;
@@ -172,7 +172,7 @@ export const useQueryParam = (queryParam: string): QueryReturnType => {
       parsedValue = rawValue.toString();
     }
 
-    const params = new URLSearchParams(search);
+    const params = new URLSearchParams(!clear ? search : '');
     if (parsedValue) {
       params.set(queryParam, parsedValue);
     } else {