Browse Source

Merge branch 'master' into update-nicaea-from-master

Mokhtar Naamani 4 years ago
parent
commit
6087f5267e

+ 20 - 34
pioneer/packages/joy-forum/src/CategoryList.tsx

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
 import { Table, Dropdown, Button, Segment, Label } from 'semantic-ui-react';
-import { History } from 'history';
+import styled from 'styled-components';
 import orderBy from 'lodash/orderBy';
 import BN from 'bn.js';
 
@@ -11,7 +11,7 @@ import { ThreadId } from '@joystream/types/common';
 import { CategoryId, Category, Thread } from '@joystream/types/forum';
 import { ViewThread } from './ViewThread';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
-import { UrlHasIdProps, CategoryCrumbs, Pagination, ThreadsPerPage } from './utils';
+import { UrlHasIdProps, CategoryCrumbs, Pagination, ThreadsPerPage, usePagination } from './utils';
 import Section from '@polkadot/joy-utils/Section';
 import { JoyWarn } from '@polkadot/joy-utils/JoyStatus';
 import { withForumCalls } from './calls';
@@ -100,17 +100,19 @@ function CategoryActions (props: CategoryActionsProps) {
 
 type InnerViewCategoryProps = {
   category?: Category;
-  page?: number;
   preview?: boolean;
-  history?: History;
 };
 
 type ViewCategoryProps = InnerViewCategoryProps & {
   id: CategoryId;
 };
 
+const CategoryPreviewRow = styled(Table.Row)`
+  height: 55px;
+`;
+
 function InnerViewCategory (props: InnerViewCategoryProps) {
-  const { history, category, page = 1, preview = false } = props;
+  const { category, preview = false } = props;
 
   if (!category) {
     return <em>Loading...</em>;
@@ -128,7 +130,7 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
 
   if (preview) {
     return (
-      <Table.Row>
+      <CategoryPreviewRow>
         <Table.Cell>
           <Link to={`/forum/categories/${id.toString()}`}>
             {category.archived
@@ -144,19 +146,12 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
           {category.num_direct_subcategories.toString()}
         </Table.Cell>
         <Table.Cell>
-          {renderCategoryActions()}
-        </Table.Cell>
-        <Table.Cell>
-          <MemberPreview accountId={category.moderator_id} />
+          {category.description}
         </Table.Cell>
-      </Table.Row>
+      </CategoryPreviewRow>
     );
   }
 
-  if (!history) {
-    return <em>Error: <code>history</code> property was not found.</em>;
-  }
-
   const renderSubCategoriesAndThreads = () => <>
     {category.archived &&
       <JoyWarn title={'This category is archived.'}>
@@ -180,7 +175,7 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
     }
 
     <Section title={`Threads (${category.num_direct_unmoderated_threads.toString()})`}>
-      <CategoryThreads category={category} page={page} history={history} />
+      <CategoryThreads category={category} />
     </Section>
   </>;
 
@@ -204,8 +199,6 @@ const ViewCategory = withForumCalls<ViewCategoryProps>(
 
 type InnerCategoryThreadsProps = {
   category: Category;
-  page: number;
-  history: History;
 };
 
 type CategoryThreadsProps = ApiProps & InnerCategoryThreadsProps & {
@@ -213,7 +206,8 @@ type CategoryThreadsProps = ApiProps & InnerCategoryThreadsProps & {
 };
 
 function InnerCategoryThreads (props: CategoryThreadsProps) {
-  const { api, category, nextThreadId, page, history } = props;
+  const { api, category, nextThreadId } = props;
+  const [currentPage, setCurrentPage] = usePagination();
 
   if (!category.hasUnmoderatedThreads) {
     return <em>No threads in this category</em>;
@@ -272,20 +266,16 @@ function InnerCategoryThreads (props: CategoryThreadsProps) {
     return <em>No threads in this category</em>;
   }
 
-  const onPageChange = (activePage?: string | number) => {
-    history.push(`/forum/categories/${category.id.toString()}/page/${activePage}`);
-  };
-
   const itemsPerPage = ThreadsPerPage;
-  const minIdx = (page - 1) * itemsPerPage;
+  const minIdx = (currentPage - 1) * itemsPerPage;
   const maxIdx = minIdx + itemsPerPage - 1;
 
   const pagination =
     <Pagination
-      currentPage={page}
+      currentPage={currentPage}
       totalItems={threadCount}
       itemsPerPage={itemsPerPage}
-      onPageChange={onPageChange}
+      onPageChange={setCurrentPage}
     />;
 
   const pageOfItems = threads
@@ -300,6 +290,7 @@ function InnerCategoryThreads (props: CategoryThreadsProps) {
           <Table.HeaderCell>Thread</Table.HeaderCell>
           <Table.HeaderCell>Replies</Table.HeaderCell>
           <Table.HeaderCell>Creator</Table.HeaderCell>
+          <Table.HeaderCell>Created</Table.HeaderCell>
         </Table.Row>
       </Table.Header>
       <Table.Body>
@@ -319,21 +310,17 @@ export const CategoryThreads = withMulti(
 );
 
 type ViewCategoryByIdProps = UrlHasIdProps & {
-  history: History;
   match: {
     params: {
       id: string;
-      page?: string;
     };
   };
 };
 
 export function ViewCategoryById (props: ViewCategoryByIdProps) {
-  const { history, match: { params: { id, page: pageStr } } } = props;
+  const { match: { params: { id } } } = props;
   try {
-    // tslint:disable-next-line:radix
-    const page = pageStr ? parseInt(pageStr) : 1;
-    return <ViewCategory id={new CategoryId(id)} page={page} history={history} />;
+    return <ViewCategory id={new CategoryId(id)} />;
   } catch (err) {
     return <em>Invalid category ID: {id}</em>;
   }
@@ -392,8 +379,7 @@ function InnerCategoryList (props: CategoryListProps) {
           <Table.HeaderCell>Category</Table.HeaderCell>
           <Table.HeaderCell>Threads</Table.HeaderCell>
           <Table.HeaderCell>Subcategories</Table.HeaderCell>
-          <Table.HeaderCell>Actions</Table.HeaderCell>
-          <Table.HeaderCell>Creator</Table.HeaderCell>
+          <Table.HeaderCell>Description</Table.HeaderCell>
         </Table.Row>
       </Table.Header>
       <Table.Body>{categories.map((category, i) => (

+ 71 - 70
pioneer/packages/joy-forum/src/EditReply.tsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import { Button, Message } from 'semantic-ui-react';
+import styled from 'styled-components';
 import { Form, Field, withFormik, FormikProps } from 'formik';
 import * as Yup from 'yup';
-import { History } from 'history';
 
 import TxButton from '@polkadot/joy-utils/TxButton';
 import { SubmittableResult } from '@polkadot/api';
@@ -15,7 +15,6 @@ import { Post } from '@joystream/types/forum';
 import { withOnlyMembers } from '@polkadot/joy-utils/MyAccount';
 import Section from '@polkadot/joy-utils/Section';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
-import { UrlHasIdProps, CategoryCrumbs } from './utils';
 import { withForumCalls } from './calls';
 import { ValidationProps, withReplyValidation } from './validation';
 import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types';
@@ -40,11 +39,18 @@ const buildSchema = (props: ValidationProps) => {
   });
 };
 
+const FormActionsContainer = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
+
 type OuterProps = ValidationProps & {
-  history?: History;
   id?: PostId;
   struct?: Post;
   threadId: ThreadId;
+  quotedPost?: Post | null;
+  onEditSuccess?: () => void;
+  onEditCancel?: () => void;
 };
 
 type FormValues = {
@@ -57,7 +63,6 @@ const LabelledField = JoyForms.LabelledField<FormValues>();
 
 const InnerForm = (props: FormProps) => {
   const {
-    history,
     id,
     threadId,
     struct,
@@ -66,18 +71,16 @@ const InnerForm = (props: FormProps) => {
     isValid,
     isSubmitting,
     setSubmitting,
-    resetForm
+    resetForm,
+    onEditSuccess,
+    onEditCancel
   } = props;
 
   const {
     text
   } = values;
 
-  const goToThreadView = () => {
-    if (history) {
-      history.push('/forum/threads/' + threadId.toString());
-    }
-  };
+  const isNew = struct === undefined;
 
   const onSubmit = (sendTx: () => void) => {
     if (isValid) sendTx();
@@ -93,11 +96,12 @@ const InnerForm = (props: FormProps) => {
 
   const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => {
     setSubmitting(false);
-    goToThreadView();
+    resetForm();
+    if (!isNew && onEditSuccess) {
+      onEditSuccess();
+    }
   };
 
-  const isNew = struct === undefined;
-
   const buildTxParams = () => {
     if (!isValid) return [];
 
@@ -117,52 +121,77 @@ const InnerForm = (props: FormProps) => {
       </LabelledField>
 
       <LabelledField {...props}>
-        <TxButton
-          type='submit'
-          size='large'
-          label={isNew
-            ? 'Post a reply'
-            : 'Update a reply'
-          }
-          isDisabled={!dirty || isSubmitting}
-          params={buildTxParams()}
-          tx={isNew
-            ? 'forum.addPost'
-            : 'forum.editPostText'
+        <FormActionsContainer>
+          <div>
+            <TxButton
+              type='submit'
+              size='large'
+              label={isNew
+                ? 'Post a reply'
+                : 'Update a reply'
+              }
+              isDisabled={!dirty || isSubmitting}
+              params={buildTxParams()}
+              tx={isNew
+                ? 'forum.addPost'
+                : 'forum.editPostText'
+              }
+              onClick={onSubmit}
+              txFailedCb={onTxFailed}
+              txSuccessCb={onTxSuccess}
+            />
+            <Button
+              type='button'
+              size='large'
+              disabled={!dirty || isSubmitting}
+              onClick={() => resetForm()}
+              content='Reset form'
+            />
+          </div>
+          {
+            !isNew && (
+              <Button
+                type='button'
+                size='large'
+                disabled={isSubmitting}
+                content='Cancel edit'
+                onClick={() => onEditCancel && onEditCancel()}
+              />
+            )
           }
-          onClick={onSubmit}
-          txFailedCb={onTxFailed}
-          txSuccessCb={onTxSuccess}
-        />
-        <Button
-          type='button'
-          size='large'
-          disabled={!dirty || isSubmitting}
-          onClick={() => resetForm()}
-          content='Reset form'
-        />
+        </FormActionsContainer>
       </LabelledField>
     </Form>;
 
   const sectionTitle = isNew
     ? 'New reply'
-    : 'Edit my reply';
+    : `Edit my reply #${struct?.nr_in_thread}`;
 
-  return <>
-    <CategoryCrumbs threadId={threadId} />
+  return (
     <Section className='EditEntityBox' title={sectionTitle}>
       {form}
     </Section>
-  </>;
+  );
+};
+
+const getQuotedPostString = (post: Post) => {
+  const lines = post.current_text.split('\n');
+  return lines.reduce((acc, line) => {
+    return `${acc}> ${line}\n`;
+  }, '');
 };
 
 const EditForm = withFormik<OuterProps, FormValues>({
 
   // Transform outer props into form values
   mapPropsToValues: props => {
-    const { struct } = props;
+    const { struct, quotedPost } = props;
     return {
-      text: (struct && struct.current_text) || ''
+      text: struct
+        ? struct.current_text
+        : quotedPost
+          ? getQuotedPostString(quotedPost)
+          : ''
     };
   },
 
@@ -193,43 +222,15 @@ function FormOrLoading (props: OuterProps) {
   return <Message error className='JoyMainStatus' header='You are not allowed edit this reply.' />;
 }
 
-function withThreadIdFromUrl (Component: React.ComponentType<OuterProps>) {
-  return function (props: UrlHasIdProps) {
-    const { match: { params: { id } } } = props;
-    try {
-      return <Component {...props} threadId={new ThreadId(id)} />;
-    } catch (err) {
-      return <em>Invalid thread ID: {id}</em>;
-    }
-  };
-}
-
-type HasPostIdProps = {
-  id: PostId;
-};
-
-function withIdFromUrl (Component: React.ComponentType<HasPostIdProps>) {
-  return function (props: UrlHasIdProps) {
-    const { match: { params: { id } } } = props;
-    try {
-      return <Component {...props} id={new PostId(id)} />;
-    } catch (err) {
-      return <em>Invalid reply ID: {id}</em>;
-    }
-  };
-}
-
 export const NewReply = withMulti(
   EditForm,
   withOnlyMembers,
-  withThreadIdFromUrl,
   withReplyValidation
 );
 
 export const EditReply = withMulti(
   FormOrLoading,
   withOnlyMembers,
-  withIdFromUrl,
   withReplyValidation,
   withForumCalls<OuterProps>(
     ['postById', { paramName: 'id', propName: 'struct' }]

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

@@ -0,0 +1,167 @@
+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 } from '@joystream/types/common';
+import { 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 />
+      <RecentActivity />
+      <Section title="Top categories">
+        <CategoryList />
+      </Section>
+    </>
+  );
+};
+
+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;

+ 17 - 0
pioneer/packages/joy-forum/src/LegacyPagingRedirect.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+import { useLocation, Redirect } from 'react-router-dom';
+
+export const LegacyPagingRedirect: React.FC = () => {
+  const { pathname } = useLocation();
+  const parsingRegexp = /(.+)\/page\/(\d+)/;
+  const groups = parsingRegexp.exec(pathname);
+  if (!groups) {
+    return <em>Failed to parse the URL</em>;
+  }
+
+  const basePath = groups[1];
+  const page = groups[2];
+  const search = new URLSearchParams();
+  search.set('page', page);
+  return <Redirect to={{ pathname: basePath, search: search.toString() }} />;
+};

+ 107 - 38
pioneer/packages/joy-forum/src/ViewReply.tsx

@@ -1,7 +1,8 @@
 import React, { useState } from 'react';
-import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import { Link, useLocation } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
-import { Segment, Button } from 'semantic-ui-react';
+import { Button, Icon } from 'semantic-ui-react';
 
 import { Post, Category, Thread } from '@joystream/types/forum';
 import { Moderate } from './Moderate';
@@ -9,18 +10,68 @@ import { JoyWarn } from '@polkadot/joy-utils/JoyStatus';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
-import { FlexCenter } from '@polkadot/joy-utils/FlexCenter';
+import { TimeAgoDate, ReplyIdxQueryParam } from './utils';
+
+const HORIZONTAL_PADDING = '1em';
+const ReplyMarkdown = styled(ReactMarkdown)`
+  font-size: 1.15rem;
+
+  blockquote {
+    color: rgba(78, 78, 78, 0.6);
+    margin-left: 15px;
+    padding-left: 15px;
+    border-left: 2px solid rgba(78, 78, 78, 0.6);
+  }
+`;
+const ReplyContainer = styled.div<{ selected?: boolean }>`
+  && {
+    padding: 0;
+
+    outline: ${({ selected }) => selected ? '2px solid #ffc87b' : 'none'};
+  }
+  overflow: hidden;
+`;
+const ReplyHeader = styled.div`
+  background-color: #fafcfc;
+`;
+const ReplyHeaderAuthorRow = styled.div`
+  padding: 0.7em ${HORIZONTAL_PADDING};
+`;
+const ReplyHeaderDetailsRow = styled.div`
+  padding: 0.5em ${HORIZONTAL_PADDING};
+  border-top: 1px dashed rgba(34, 36, 38, .15);
+  border-bottom: 1px solid rgba(34, 36, 38, .15);
+  display: flex;
+  justify-content: space-between;
+`;
+const ReplyContent = styled.div`
+  padding: 1em ${HORIZONTAL_PADDING};
+`;
+const ReplyFooter = styled.div`
+  border-top: 1px solid rgba(34, 36, 38, .15);
+  background-color: #fafcfc;
+  padding: 0.35em ${HORIZONTAL_PADDING};
+`;
+const ReplyFooterActionsRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
 
 type ViewReplyProps = {
   reply: Post;
   thread: Thread;
   category: Category;
+  selected?: boolean;
+  onEdit: () => void;
+  onQuote: () => void;
 };
 
-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, onEdit, onQuote } = props;
   const { id } = reply;
 
   if (reply.isEmpty) {
@@ -28,7 +79,7 @@ export function ViewReply (props: ViewReplyProps) {
   }
 
   const renderReplyDetails = () => {
-    return <ReactMarkdown className='JoyMemo--full' source={reply.current_text} linkTarget='_blank' />;
+    return <ReplyMarkdown className='JoyMemo--full' source={reply.current_text} linkTarget='_blank' />;
   };
 
   const renderModerationRationale = () => {
@@ -46,43 +97,61 @@ export function ViewReply (props: ViewReplyProps) {
       return null;
     }
     const isMyPost = reply.author_id.eq(myAddress);
-    return <span className='JoyInlineActions' style={{ marginLeft: '.5rem' }}>
-      {isMyPost &&
-        <Link
-          to={`/forum/replies/${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>;
+    return <ReplyFooterActionsRow>
+      <div>
+        {isMyPost &&
+          <Button onClick={onEdit} size="mini">
+            <Icon name="pencil" />
+            Edit
+          </Button>
+        }
+
+        <IfIAmForumSudo>
+          <Button
+            size="mini"
+            onClick={() => setShowModerateForm(!showModerateForm)}
+          >
+            Moderate
+          </Button>
+        </IfIAmForumSudo>
+      </div>
+      <Button onClick={onQuote} size="mini">
+        <Icon name="quote left" />
+        Quote
+      </Button>
+    </ReplyFooterActionsRow>;
   };
 
+  const replyLinkSearch = new URLSearchParams(search);
+  replyLinkSearch.set(ReplyIdxQueryParam, reply.nr_in_thread.toString());
+
   return (
-    <Segment>
-      <FlexCenter>
-        <MemberPreview accountId={reply.author_id} />
-        {renderActions()}
-      </FlexCenter>
-      <div style={{ marginTop: '1rem' }}>
-        {showModerateForm &&
-          <Moderate id={id} onCloseForm={() => setShowModerateForm(false)} />
-        }
+    <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} />
+          <Link to={{ pathname, search: replyLinkSearch.toString() }}>
+            #{reply.nr_in_thread.toNumber()}
+          </Link>
+        </ReplyHeaderDetailsRow>
+      </ReplyHeader>
+
+      <ReplyContent>
         {reply.moderated
           ? renderModerationRationale()
           : renderReplyDetails()
         }
-      </div>
-    </Segment>
+      </ReplyContent>
+
+      <ReplyFooter>
+        {renderActions()}
+        {showModerateForm &&
+          <Moderate id={id} onCloseForm={() => setShowModerateForm(false)} />
+        }
+      </ReplyFooter>
+    </ReplyContainer>
   );
-}
+});

+ 243 - 70
pioneer/packages/joy-forum/src/ViewThread.tsx

@@ -1,13 +1,13 @@
-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 { Table, Button, Label } from 'semantic-ui-react';
-import { History } from 'history';
+import styled from 'styled-components';
+import { Table, Button, Label, Icon } from 'semantic-ui-react';
 import BN from 'bn.js';
 
 import { ThreadId, PostId } from '@joystream/types/common';
 import { Category, Thread, Post } from '@joystream/types/forum';
-import { Pagination, RepliesPerPage, CategoryCrumbs } from './utils';
+import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination, useQueryParam, ReplyIdxQueryParam, ReplyEditIdQueryParam } from './utils';
 import { ViewReply } from './ViewReply';
 import { Moderate } from './Moderate';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
@@ -19,6 +19,8 @@ import { orderBy } from 'lodash';
 import { bnToStr } from '@polkadot/joy-utils/index';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
+import { formatDate } from '@polkadot/joy-utils/functions/date';
+import { NewReply, EditReply } from './EditReply';
 
 type ThreadTitleProps = {
   thread: Thread;
@@ -37,12 +39,90 @@ function ThreadTitle (props: ThreadTitleProps) {
   </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 .2rem;
+
+    .PrefixLabel {
+      color: inherit;
+      margin-right: .2rem;
+    }
+  }
+`;
+
+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} />
+      </Table.Cell>
+      <Table.Cell>
+        {formatDate(thread.created_at.momentDate)}
+      </Table.Cell>
+    </Table.Row>
+  );
+};
+
 type InnerViewThreadProps = {
   category: Category;
   thread: Thread;
-  page?: number;
   preview?: boolean;
-  history?: History;
 };
 
 type ViewThreadProps = ApiProps & InnerViewThreadProps & {
@@ -51,7 +131,22 @@ type ViewThreadProps = ApiProps & InnerViewThreadProps & {
 
 function InnerViewThread (props: ViewThreadProps) {
   const [showModerateForm, setShowModerateForm] = useState(false);
-  const { history, category, thread, page = 1, preview = false } = props;
+  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 } = props;
+
+  const editedPostId = rawEditedPostId && new PostId(rawEditedPostId);
 
   if (!thread) {
     return <em>Loading thread details...</em>;
@@ -68,6 +163,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, [ReplyIdxQueryParam]);
+  };
+
   if (!category) {
     return <em>{'Thread\'s category was not found.'}</em>;
   } else if (category.deleted) {
@@ -75,34 +175,14 @@ function InnerViewThread (props: ViewThreadProps) {
   }
 
   if (preview) {
-    const title = <ThreadTitle thread={thread} />;
-    const repliesCount = totalPostsInThread - 1;
-    return (
-      <Table.Row>
-        <Table.Cell>
-          <Link to={`/forum/threads/${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} />
-        </Table.Cell>
-      </Table.Row>
-    );
-  }
-
-  if (!history) {
-    return <em>History propoerty is undefined</em>;
+    return <ThreadPreview thread={thread} repliesCount={totalPostsInThread - 1} />;
   }
 
   const { api, nextPostId } = props;
   const [loaded, setLoaded] = useState(false);
   const [posts, setPosts] = useState(new Array<Post>());
 
+  // fetch posts
   useEffect(() => {
     const loadPosts = async () => {
       if (!nextPostId || totalPostsInThread === 0) return;
@@ -126,6 +206,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);
     };
@@ -133,6 +220,73 @@ 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]);
+
+  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 = () => {
@@ -140,29 +294,44 @@ function InnerViewThread (props: ViewThreadProps) {
       return <em>Loading posts...</em>;
     }
 
-    const onPageChange = (activePage?: string | number) => {
-      history.push(`/forum/threads/${id.toString()}/page/${activePage}`);
-    };
-
-    const itemsPerPage = RepliesPerPage;
-    const minIdx = (page - 1) * RepliesPerPage;
-    const maxIdx = minIdx + RepliesPerPage - 1;
-
     const pagination =
       <Pagination
-        currentPage={page}
-        totalItems={totalPostsInThread}
-        itemsPerPage={itemsPerPage}
-        onPageChange={onPageChange}
+        currentPage={currentPage}
+        totalItems={posts.length}
+        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();
+
+      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}
-      {pageOfItems}
+      {renderedReplies}
       {pagination}
     </>;
   };
@@ -172,13 +341,10 @@ function InnerViewThread (props: ViewThreadProps) {
       return null;
     }
     return <span className='JoyInlineActions'>
-      <Link
-        to={`/forum/threads/${id.toString()}/reply`}
-        className='ui small button'
-      >
-        <i className='reply icon' />
+      <Button onClick={onThreadReplyClick}>
+        <Icon name="reply" />
         Reply
-      </Link>
+      </Button>
 
       {/* TODO show 'Edit' button only if I am owner */}
       {/* <Link
@@ -212,10 +378,20 @@ function InnerViewThread (props: ViewThreadProps) {
 
   return <div style={{ marginBottom: '1rem' }}>
     <CategoryCrumbs categoryId={thread.category_id} />
-    <h1 className='ForumPageTitle'>
-      <ThreadTitle thread={thread} className='TitleText' />
-      {renderActions()}
-    </h1>
+    <ThreadHeader>
+      <h1 className='ForumPageTitle'>
+        <ThreadTitle thread={thread} className='TitleText' />
+      </h1>
+      <ThreadInfoAndActions>
+        <ThreadInfo>
+          Created
+          <ThreadInfoMemberPreview accountId={thread.author_id} inline prefixLabel="by" />
+          <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.
@@ -228,6 +404,15 @@ function InnerViewThread (props: ViewThreadProps) {
       ? 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>;
 }
 
@@ -240,27 +425,15 @@ export const ViewThread = withMulti(
 );
 
 type ViewThreadByIdProps = ApiProps & {
-  history: History;
   match: {
     params: {
       id: string;
-      page?: string;
     };
   };
 };
 
 function InnerViewThreadById (props: ViewThreadByIdProps) {
-  const { api, history, match: { params: { id, page: pageStr } } } = props;
-
-  let page = 1;
-  if (pageStr) {
-    try {
-      // tslint:disable-next-line:radix
-      page = parseInt(pageStr);
-    } catch (err) {
-      console.log('Failed to parse page number form URL');
-    }
-  }
+  const { api, match: { params: { id } } } = props;
 
   let threadId: ThreadId;
   try {
@@ -287,7 +460,7 @@ function InnerViewThreadById (props: ViewThreadByIdProps) {
     };
 
     loadThreadAndCategory();
-  }, [id, page]);
+  }, [id]);
 
   // console.log({ threadId: id, page });
 
@@ -303,7 +476,7 @@ function InnerViewThreadById (props: ViewThreadByIdProps) {
     return <em>{ 'Thread\'s category was not found' }</em>;
   }
 
-  return <ViewThread id={threadId} category={category} thread={thread} page={page} history={history} />;
+  return <ViewThread id={threadId} category={category} thread={thread} />;
 }
 
 export const ViewThreadById = withApi(InnerViewThreadById);

+ 19 - 39
pioneer/packages/joy-forum/src/index.tsx

@@ -1,76 +1,56 @@
 
 import React from 'react';
 import { Route, Switch } from 'react-router';
+import styled from 'styled-components';
 
 import { AppProps, I18nProps } from '@polkadot/react-components/types';
-import Tabs, { TabItem } from '@polkadot/react-components/Tabs';
 
 import './index.css';
 
 import translate from './translate';
 import { ForumProvider } from './Context';
-import { EditForumSudo, ForumSudoProvider } from './ForumSudo';
-import { NewCategory, NewSubcategory, EditCategory } from './EditCategory';
+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;
+`;
 
 type Props = AppProps & I18nProps & {};
 
 class App extends React.PureComponent<Props> {
-  private buildTabs (): TabItem[] {
-    const { t } = this.props;
-    return [
-      {
-        isRoot: true,
-        name: 'forum',
-        text: t('Forum')
-      },
-      {
-        // TODO show this tab only if current user is the sudo:
-        name: 'categories/new',
-        text: t('New category')
-      },
-      {
-        name: 'sudo',
-        text: t('Forum sudo')
-      }
-    ];
-  }
-
   render () {
     const { basePath } = this.props;
-    const tabs = this.buildTabs();
     return (
       <ForumProvider>
         <ForumSudoProvider>
-          <main className='forum--App'>
-            <header>
-              <Tabs basePath={basePath} items={tabs} />
-            </header>
+          <ForumContentWrapper className='forum--App'>
             <Switch>
-              <Route path={`${basePath}/sudo`} component={EditForumSudo} />
+              {/* routes for handling legacy format of forum paging within the routing path */}
+              {/* translate page param to search query */}
+              <Route path={`${basePath}/categories/:id/page/:page`} component={LegacyPagingRedirect} />
+              <Route path={`${basePath}/threads/:id/page/:page`} component={LegacyPagingRedirect} />
+
+              {/* <Route path={`${basePath}/sudo`} component={EditForumSudo} /> */}
+              {/* <Route path={`${basePath}/categories/new`} component={NewCategory} /> */}
 
-              <Route path={`${basePath}/categories/new`} component={NewCategory} />
               <Route path={`${basePath}/categories/:id/newSubcategory`} component={NewSubcategory} />
               <Route path={`${basePath}/categories/:id/newThread`} component={NewThread} />
               <Route path={`${basePath}/categories/:id/edit`} component={EditCategory} />
-              <Route path={`${basePath}/categories/:id/page/:page`} component={ViewCategoryById} />
               <Route path={`${basePath}/categories/:id`} component={ViewCategoryById} />
               <Route path={`${basePath}/categories`} component={CategoryList} />
 
-              <Route path={`${basePath}/threads/:id/reply`} component={NewReply} />
               <Route path={`${basePath}/threads/:id/edit`} component={EditThread} />
-              <Route path={`${basePath}/threads/:id/page/:page`} component={ViewThreadById} />
               <Route path={`${basePath}/threads/:id`} component={ViewThreadById} />
 
-              <Route path={`${basePath}/replies/:id/edit`} component={EditReply} />
-              {/* <Route path={`${basePath}/replies/:id`} component={ViewReplyById} /> */}
-
-              <Route component={CategoryList} />
+              <Route component={ForumRoot} />
             </Switch>
-          </main>
+          </ForumContentWrapper>
         </ForumSudoProvider>
       </ForumProvider>
     );

+ 108 - 12
pioneer/packages/joy-forum/src/utils.tsx

@@ -1,6 +1,10 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
+import { useHistory, useLocation } from 'react-router';
 import { Link } from 'react-router-dom';
-import { Pagination as SuiPagination } from 'semantic-ui-react';
+import { Breadcrumb, Pagination as SuiPagination } from 'semantic-ui-react';
+import styled from 'styled-components';
+import moment from 'moment';
+import Tooltip from 'react-tooltip';
 
 import { ThreadId } from '@joystream/types/common';
 import { Category, CategoryId, Thread } from '@joystream/types/forum';
@@ -9,6 +13,10 @@ 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';
 
 type PaginationProps = {
   currentPage?: number;
@@ -37,6 +45,7 @@ type CategoryCrumbsProps = {
   category?: Category;
   threadId?: ThreadId;
   thread?: Thread;
+  root?: boolean;
 };
 
 function InnerCategoryCrumb (p: CategoryCrumbsProps) {
@@ -47,8 +56,8 @@ function InnerCategoryCrumb (p: CategoryCrumbsProps) {
       const url = `/forum/categories/${category.id.toString()}`;
       return <>
         {category.parent_id ? <CategoryCrumb categoryId={category.parent_id} /> : null}
-        <i className='right angle icon divider'></i>
-        <Link className='section' to={url}>{category.title}</Link>
+        <Breadcrumb.Divider icon="right angle" />
+        <Breadcrumb.Section as={Link} to={url}>{category.title}</Breadcrumb.Section>
       </>;
     } catch (err) {
       console.log('Failed to create a category breadcrumb', err);
@@ -73,8 +82,8 @@ function InnerThreadCrumb (p: CategoryCrumbsProps) {
       const url = `/forum/threads/${thread.id.toString()}`;
       return <>
         <CategoryCrumb categoryId={thread.category_id} />
-        <i className='right angle icon divider'></i>
-        <Link className='section' to={url}>{thread.title}</Link>
+        <Breadcrumb.Divider icon="right angle" />
+        <Breadcrumb.Section as={Link} to={url}>{thread.title}</Breadcrumb.Section>
       </>;
     } catch (err) {
       console.log('Failed to create a thread breadcrumb', err);
@@ -91,16 +100,45 @@ const ThreadCrumb = withMulti(
   )
 );
 
-export const CategoryCrumbs = (p: CategoryCrumbsProps) => {
+const StyledBreadcrumbs = styled(Breadcrumb)`
+  && {
+    font-size: 1.3rem;
+    line-height: 1.2;
+  }
+`;
+
+export const CategoryCrumbs = ({ categoryId, threadId, root }: CategoryCrumbsProps) => {
   return (
-    <div className='ui breadcrumb'>
-      <Link className='section' to='/forum'>Top categories</Link>
-      <CategoryCrumb categoryId={p.categoryId} />
-      <ThreadCrumb threadId={p.threadId} />
-    </div>
+    <StyledBreadcrumbs>
+      <Breadcrumb.Section>Forum</Breadcrumb.Section>
+      {!root && (
+        <>
+          <Breadcrumb.Divider icon="right angle" />
+          <Breadcrumb.Section as={Link} to="/forum">Top categories</Breadcrumb.Section>
+          <CategoryCrumb categoryId={categoryId} />
+          <ThreadCrumb threadId={threadId} />
+        </>
+      )}
+    </StyledBreadcrumbs>
   );
 };
 
+type TimeAgoDateProps = {
+  date: moment.Moment;
+  id: any;
+};
+
+export const TimeAgoDate: React.FC<TimeAgoDateProps> = ({ date, id }) => (
+  <>
+    <span data-tip data-for={`${id}-date-tooltip`}>
+      {date.fromNow()}
+    </span>
+    <Tooltip id={`${id}-date-tooltip`} place="top" effect="solid">
+      {date.toLocaleString()}
+    </Tooltip>
+  </>
+);
+
 // It's used on such routes as:
 //   /categories/:id
 //   /categories/:id/edit
@@ -113,3 +151,61 @@ export type UrlHasIdProps = {
     };
   };
 };
+
+type QueryValueType = string | null;
+type QuerySetValueType = (value?: QueryValueType | number, paramsToReset?: string[]) => void;
+type QueryReturnType = [QueryValueType, QuerySetValueType];
+
+export const useQueryParam = (queryParam: string): QueryReturnType => {
+  const { pathname, search } = useLocation();
+  const history = useHistory();
+  const [value, setValue] = useState<QueryValueType>(null);
+
+  useEffect(() => {
+    const params = new URLSearchParams(search);
+    const paramValue = params.get(queryParam);
+    if (paramValue !== value) {
+      setValue(paramValue);
+    }
+  }, [search, setValue, queryParam]);
+
+  const setParam: QuerySetValueType = (rawValue, paramsToReset = []) => {
+    let parsedValue: string | null;
+    if (!rawValue && rawValue !== 0) {
+      parsedValue = null;
+    } else {
+      parsedValue = rawValue.toString();
+    }
+
+    const params = new URLSearchParams(search);
+    if (parsedValue) {
+      params.set(queryParam, parsedValue);
+    } else {
+      params.delete(queryParam);
+    }
+
+    paramsToReset.forEach(p => params.delete(p));
+
+    setValue(parsedValue);
+    history.push({ pathname, search: params.toString() });
+  };
+
+  return [value, setParam];
+};
+
+export const usePagination = (): [number, QuerySetValueType] => {
+  const [rawCurrentPage, setCurrentPage] = useQueryParam(PagingQueryParam);
+
+  let currentPage = 1;
+  if (rawCurrentPage) {
+    const parsedPage = Number.parseInt(rawCurrentPage);
+    if (!Number.isNaN(parsedPage)) {
+      currentPage = parsedPage;
+    } else {
+      // eslint-disable-next-line no-console
+      console.warn('Failed to parse URL page idx');
+    }
+  }
+
+  return [currentPage, setCurrentPage];
+};

+ 19 - 11
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;
@@ -24,6 +25,7 @@ type MemberPreviewProps = ApiProps & I18nProps & {
   memberProfile?: Option<any>; // TODO refactor to Option<Profile>
   activeCouncil?: Seat[];
   prefixLabel?: string;
+  inline?: boolean;
   className?: string;
   style?: React.CSSProperties;
 };
@@ -37,32 +39,38 @@ class InnerMemberPreview extends React.PureComponent<MemberPreviewProps> {
   }
 
   private renderProfile (memberProfile: Profile) {
-    const { activeCouncil = [], accountId, prefixLabel, className, style } = this.props;
+    const { activeCouncil = [], accountId, prefixLabel, inline, className, style } = this.props;
     const { handle, avatar_uri } = memberProfile;
 
     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>
         }
-        {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>
           </div>
-          <div className='Details'>
-            {isCouncilor &&
-              <b className='muted text' style={{ color: '#607d8b' }}>
-                <i className='university icon'></i>
-                Council member
-              </b>}
-          </div>
+          {!inline && (
+            <div className='Details'>
+              {isCouncilor &&
+                <b className='muted text' style={{ color: '#607d8b' }}>
+                  <i className='university icon'></i>
+                  Council member
+                </b>}
+            </div>
+          )}
         </div>
       </FlexCenter>
     </div>;

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

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

+ 1 - 4
pioneer/packages/react-components/src/styles/index.ts

@@ -124,10 +124,7 @@ export default createGlobalStyle`
     font-weight: 100;
   }
 
-  h1 {
-    text-transform: lowercase;
-
-    em {
+  h1 em {
       font-style: normal;
       text-transform: none;
     }

+ 18 - 0
types/src/common.ts

@@ -1,6 +1,7 @@
 import { Struct, Option, Text, bool, Vec, u16, u32, u64, getTypeRegistry } from "@polkadot/types";
 import { BlockNumber, Moment } from '@polkadot/types/interfaces';
 import { Codec } from "@polkadot/types/types";
+import moment from 'moment';
 import { JoyStruct } from './JoyStruct';
 export { JoyStruct } from './JoyStruct';
 
@@ -38,6 +39,23 @@ export class BlockAndTime extends Struct {
     static newEmpty (): BlockAndTime {
         return new BlockAndTime({} as BlockAndTime);
     }
+
+    get momentDate (): moment.Moment {
+        const YEAR_2000_MILLISECONDS = 946684801000;
+
+        // overflowing in ~270,000 years
+        const timestamp = this.time.toNumber();
+
+        // TODO: remove once https://github.com/Joystream/joystream/issues/705 is resolved
+        // due to a bug, timestamp can be either in seconds or milliseconds
+        let timestampInMillis = timestamp;
+        if (timestamp < YEAR_2000_MILLISECONDS) {
+          // timestamp is in seconds
+          timestampInMillis = timestamp * 1000;
+        }
+
+        return moment(timestampInMillis);
+      }
 }
 
 export function getTextPropAsString(struct: Struct, fieldName: string): string {