Browse Source

add recent activity to forum root

Klaudiusz Dembler 4 years ago
parent
commit
cf78837773

+ 13 - 21
pioneer/packages/joy-forum/src/CategoryList.tsx

@@ -372,27 +372,19 @@ function InnerCategoryList (props: CategoryListProps) {
   }
 
   return (
-    <>
-      {!parentId && (
-        <>
-          <CategoryCrumbs root />
-          <h1 className="ForumPageTitle">Top categories</h1>
-        </>
-      )}
-      <Table celled selectable compact>
-        <Table.Header>
-          <Table.Row>
-            <Table.HeaderCell>Category</Table.HeaderCell>
-            <Table.HeaderCell>Threads</Table.HeaderCell>
-            <Table.HeaderCell>Subcategories</Table.HeaderCell>
-            <Table.HeaderCell>Description</Table.HeaderCell>
-          </Table.Row>
-        </Table.Header>
-        <Table.Body>{categories.map((category, i) => (
-          <InnerViewCategory key={i} preview category={category} />
-        ))}</Table.Body>
-      </Table>
-    </>
+    <Table celled selectable compact>
+      <Table.Header>
+        <Table.Row>
+          <Table.HeaderCell>Category</Table.HeaderCell>
+          <Table.HeaderCell>Threads</Table.HeaderCell>
+          <Table.HeaderCell>Subcategories</Table.HeaderCell>
+          <Table.HeaderCell>Description</Table.HeaderCell>
+        </Table.Row>
+      </Table.Header>
+      <Table.Body>{categories.map((category, i) => (
+        <InnerViewCategory key={i} preview category={category} />
+      ))}</Table.Body>
+    </Table>
   );
 }
 

+ 166 - 0
pioneer/packages/joy-forum/src/ForumRoot.tsx

@@ -0,0 +1,166 @@
+import React, { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import { orderBy } from 'lodash';
+import BN from 'bn.js';
+
+import Section from '@polkadot/joy-utils/Section';
+import { withMulti, withApi } from '@polkadot/react-api';
+import { PostId, Post, Thread } from '@joystream/types/forum';
+import { bnToStr } from '@polkadot/joy-utils/';
+import { ApiProps } from '@polkadot/react-api/types';
+import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
+
+import { CategoryCrumbs, RecentActivityPostsCount, ReplyIdxQueryParam, TimeAgoDate } from './utils';
+import { withForumCalls } from './calls';
+import { CategoryList } from './CategoryList';
+
+const ForumRoot: React.FC = () => {
+  return (
+    <>
+      <CategoryCrumbs root />
+      <Section title="Top categories">
+        <CategoryList />
+      </Section>
+      <RecentActivity />
+    </>
+  );
+};
+
+const RecentActivityEntry = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 10px 0;
+
+  &:not(:last-child) {
+    border-bottom: 1px solid #ddd;
+  }
+`;
+
+const StyledMemberPreview = styled(MemberPreview)`
+  && {
+    margin-right: .3rem;
+  }
+`;
+
+const StyledPostLink = styled(Link)`
+  margin: 0 .3rem;
+  font-weight: 700;
+`;
+
+type RecentActivityProps = ApiProps & {
+  nextPostId?: PostId;
+};
+
+const InnerRecentActivity: React.FC<RecentActivityProps> = ({ nextPostId, api }) => {
+  const [recentPosts, setRecentPosts] = useState<Post[]>([]);
+  const [loaded, setLoaded] = useState(false);
+  const [threadsLookup, setThreadsLookup] = useState<Record<number, Thread>>({});
+
+  useEffect(() => {
+    const loadPosts = async () => {
+      if (!nextPostId) return;
+
+      const newId = (id: number | BN) => new PostId(id);
+      const apiCalls: Promise<Post>[] = [];
+      let id = newId(1);
+      while (nextPostId.gt(id)) {
+        apiCalls.push(api.query.forum.postById(id) as Promise<Post>);
+        id = newId(id.add(newId(1)));
+      }
+
+      const allPosts = await Promise.all(apiCalls);
+      const sortedPosts = orderBy(
+        allPosts,
+        [x => x.id.toNumber()],
+        ['desc']
+      );
+
+      const threadsIdsLookup = {} as Record<number, boolean>;
+      const postsWithUniqueThreads = sortedPosts.reduce((acc, post) => {
+        const threadId = post.thread_id.toNumber();
+        if (threadsIdsLookup[threadId]) return acc;
+
+        threadsIdsLookup[threadId] = true;
+        return [
+          ...acc,
+          post
+        ];
+      }, [] as Post[]);
+
+      const recentUniquePosts = postsWithUniqueThreads.slice(0, RecentActivityPostsCount);
+      setRecentPosts(recentUniquePosts);
+      setLoaded(true);
+    };
+
+    loadPosts();
+  }, [bnToStr(nextPostId)]);
+
+  useEffect(() => {
+    const loadThreads = async () => {
+      const apiCalls: Promise<Thread>[] = recentPosts
+        .filter(p => !threadsLookup[p.thread_id.toNumber()])
+        .map(p => api.query.forum.threadById(p.thread_id) as Promise<Thread>);
+
+      const threads = await Promise.all(apiCalls);
+      const newThreadsLookup = threads.reduce((acc, thread) => {
+        acc[thread.id.toNumber()] = thread;
+        return acc;
+      }, {} as Record<number, Thread>);
+      const newLookup = {
+        ...threadsLookup,
+        ...newThreadsLookup
+      };
+
+      setThreadsLookup(newLookup);
+    };
+
+    loadThreads();
+  }, [recentPosts]);
+
+  const renderSectionContent = () => {
+    if (!loaded) {
+      return <i>Loading recent activity...</i>;
+    }
+    if (loaded && !recentPosts.length) {
+      return <span>No recent activity</span>;
+    }
+
+    return recentPosts.map(p => {
+      const threadId = p.thread_id.toNumber();
+
+      const postLinkSearch = new URLSearchParams();
+      postLinkSearch.set(ReplyIdxQueryParam, p.nr_in_thread.toString());
+      const postLinkPathname = `/forum/threads/${threadId}`;
+
+      const thread = threadsLookup[threadId];
+
+      return (
+        <RecentActivityEntry key={p.id.toNumber()}>
+          <StyledMemberPreview accountId={p.author_id} inline />
+          posted in
+          {thread && (
+            <StyledPostLink to={{ pathname: postLinkPathname, search: postLinkSearch.toString() }}>{thread.title}</StyledPostLink>
+          )}
+          <TimeAgoDate date={p.created_at.momentDate} id={p.id.toNumber()} />
+        </RecentActivityEntry>
+      );
+    });
+  };
+
+  return (
+    <Section title="Recent activity">
+      {renderSectionContent()}
+    </Section>
+  );
+};
+
+const RecentActivity = withMulti<RecentActivityProps>(
+  InnerRecentActivity,
+  withApi,
+  withForumCalls(
+    ['nextPostId', { propName: 'nextPostId' }]
+  )
+);
+
+export default ForumRoot;

+ 2 - 2
pioneer/packages/joy-forum/src/index.tsx

@@ -12,10 +12,10 @@ import { ForumProvider } from './Context';
 import { ForumSudoProvider } from './ForumSudo';
 import { NewSubcategory, EditCategory } from './EditCategory';
 import { NewThread, EditThread } from './EditThread';
-import { NewReply, EditReply } from './EditReply';
 import { CategoryList, ViewCategoryById } from './CategoryList';
 import { ViewThreadById } from './ViewThread';
 import { LegacyPagingRedirect } from './LegacyPagingRedirect';
+import ForumRoot from './ForumRoot';
 
 const ForumContentWrapper = styled.main`
   padding-top: 1.5rem;
@@ -48,7 +48,7 @@ class App extends React.PureComponent<Props> {
               <Route path={`${basePath}/threads/:id/edit`} component={EditThread} />
               <Route path={`${basePath}/threads/:id`} component={ViewThreadById} />
 
-              <Route component={CategoryList} />
+              <Route component={ForumRoot} />
             </Switch>
           </ForumContentWrapper>
         </ForumSudoProvider>

+ 1 - 0
pioneer/packages/joy-forum/src/utils.tsx

@@ -12,6 +12,7 @@ import { withMulti } from '@polkadot/react-api';
 
 export const ThreadsPerPage = 10;
 export const RepliesPerPage = 10;
+export const RecentActivityPostsCount = 7;
 export const ReplyIdxQueryParam = 'replyIdx';
 export const ReplyEditIdQueryParam = 'editReplyId';
 export const PagingQueryParam = 'page';

+ 9 - 7
pioneer/packages/joy-members/src/MemberPreview.tsx

@@ -17,6 +17,7 @@ import { FlexCenter } from '@polkadot/joy-utils/FlexCenter';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
 
 const AvatarSizePx = 36;
+const InlineAvatarSizePx = 24;
 
 type MemberPreviewProps = ApiProps & I18nProps & {
   accountId: AccountId;
@@ -44,18 +45,19 @@ class InnerMemberPreview extends React.PureComponent<MemberPreviewProps> {
     const hasAvatar = avatar_uri && nonEmptyStr(avatar_uri.toString());
     const isCouncilor: boolean = accountId !== undefined && activeCouncil.find(x => accountId.eq(x.member)) !== undefined;
 
+    const avatarSize = inline ? InlineAvatarSizePx : AvatarSizePx;
+
     return <div className={`JoyMemberPreview ${className}`} style={style}>
       <FlexCenter>
         {prefixLabel &&
           <MutedSpan className='PrefixLabel'>{prefixLabel}</MutedSpan>
         }
-        {!inline && (
-          hasAvatar ? (
-            <img className="Avatar" src={avatar_uri.toString()} width={AvatarSizePx} height={AvatarSizePx} />
-          ) : (
-            <IdentityIcon className="Avatar" value={accountId} size={AvatarSizePx} />
-          )
-        )}
+        {hasAvatar ? (
+          <img className="Avatar" src={avatar_uri.toString()} width={avatarSize} height={avatarSize} />
+        ) : (
+          <IdentityIcon className="Avatar" value={accountId} size={avatarSize} />
+        )
+        }
         <div className='Content'>
           <div className='Username'>
             <Link to={`/members/${handle.toString()}`} className='handle'>{handle.toString()}</Link>

+ 1 - 1
pioneer/packages/joy-utils/src/functions/date.ts

@@ -1,5 +1,5 @@
 import moment from 'moment';
 
 export function formatDate (date: moment.Moment): string {
-  return date.format('YYYY/MM/DD LT');
+  return date.format('DD/MM/YYYY LT');
 }