Sfoglia il codice sorgente

Merge pull request #530 from Lezek123/proposals-discussion

Proposals discussion - initial implementation
Mokhtar Naamani 4 anni fa
parent
commit
703f4496a6

+ 23 - 4
pioneer/packages/joy-proposals/src/Proposal/Body.tsx

@@ -13,6 +13,7 @@ import { useTransport, usePromise } from '@polkadot/joy-utils/react/hooks';
 import { Option } from '@polkadot/types/';
 import { formatBalance } from '@polkadot/util';
 import { PromiseComponent } from '@polkadot/joy-utils/react/components';
+import ReactMarkdown from 'react-markdown';
 
 type BodyProps = {
   title: string;
@@ -68,7 +69,7 @@ function ProposedMember (props: { memberId?: MemberId | number | null }) {
 // They take the params as array and return { LABEL: VALUE } object.
 const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: string | number | JSX.Element } } = {
   Text: ([content]) => ({
-    Content: content
+    Content: <ReactMarkdown className='TextProposalContent' source={content} linkTarget='_blank' />
   }),
   RuntimeUpgrade: ([wasm]) => {
     const buffer: Buffer = Buffer.from(wasm.replace('0x', ''), 'hex');
@@ -123,10 +124,23 @@ const ProposalParams = styled.div`
   font-weight: bold;
   grid-template-columns: min-content 1fr;
   grid-row-gap: 0.5rem;
+  border: 1px solid rgba(0,0,0,.2);
+  padding: 1.5rem 1.5rem 1rem 1.25rem;
+  position: relative;
+  margin-top: 1.5rem;
   @media screen and (max-width: 767px) {
     grid-template-columns: 1fr;
   }
 `;
+const ParamsHeader = styled.h4`
+  position: absolute;
+  top: 0;
+  transform: translateY(-50%);
+  background: #fff;
+  font-weight: normal;
+  padding: 0.3rem;
+  left: 0.5rem;
+`;
 const ProposalParamName = styled.div`
   margin-right: 1rem;
   white-space: nowrap;
@@ -134,7 +148,10 @@ const ProposalParamName = styled.div`
 const ProposalParamValue = styled.div`
   color: black;
   word-wrap: break-word;
-  word-break: break-all;
+  word-break: break-word;
+  & .TextProposalContent {
+    font-weight: normal;
+  }
   @media screen and (max-width: 767px) {
     margin-top: -0.25rem;
   }
@@ -159,9 +176,11 @@ export default function Body ({
         <Card.Header>
           <Header as="h1">{title}</Header>
         </Card.Header>
-        <Card.Description>{description}</Card.Description>
-        <Header as="h4">Parameters:</Header>
+        <Card.Description>
+          <ReactMarkdown source={description} linkTarget='_blank' />
+        </Card.Description>
         <ProposalParams>
+          <ParamsHeader>Parameters:</ParamsHeader>
           { Object.entries(parsedParams).map(([paramName, paramValue]) => (
             <React.Fragment key={paramName}>
               <ProposalParamName>{paramName}:</ProposalParamName>

+ 39 - 2
pioneer/packages/joy-proposals/src/Proposal/Details.tsx

@@ -6,6 +6,43 @@ import styled from 'styled-components';
 
 import ProfilePreview from '@polkadot/joy-utils/MemberProfilePreview';
 
+const DetailsContainer = styled(Item.Group)`
+  display: grid;
+  width: auto;
+  grid-template-columns: repeat(5, auto) 1fr;
+  grid-column-gap: 5rem;
+
+  & .item .extra {
+    margin-bottom: 0.5em !important;
+  }
+
+  @media screen and (max-width: 1199px) {
+    grid-template-columns: repeat(3, auto);
+    grid-template-rows: repeat(2, auto);
+
+    & .item:first-child {
+      grid-row: 1/3;
+    }
+
+    & .item {
+      margin: 0.5em 0 !important;
+    }
+  }
+
+  @media screen and (max-width: 767px) {
+    grid-template-columns: repeat(2, auto);
+    grid-template-rows: repeat(3, auto);
+
+    & .item:first-child {
+      grid-column: 1/3;
+    }
+
+    & .item {
+      margin: 0.5em 0 !important;
+    }
+  }
+`;
+
 const BlockInfo = styled.div`
   font-size: 0.9em;
 `;
@@ -36,7 +73,7 @@ export default function Details ({ proposal, extendedStatus, proposerLink = fals
   const { displayStatus, periodStatus, expiresIn, finalizedAtBlock, executedAtBlock, executionFailReason } = extendedStatus;
   console.log(proposal);
   return (
-    <Item.Group className="details-container">
+    <DetailsContainer>
       <Detail name="Proposed By">
         <ProfilePreview
           avatar_uri={proposer.avatar_uri}
@@ -66,6 +103,6 @@ export default function Details ({ proposal, extendedStatus, proposerLink = fals
           value={`${expiresIn.toLocaleString('en-US')} blocks`} />
       ) }
       {executionFailReason && <Detail name="Execution error" value={ executionFailReason } /> }
-    </Item.Group>
+    </DetailsContainer>
   );
 }

+ 1 - 28
pioneer/packages/joy-proposals/src/Proposal/Proposal.css

@@ -3,7 +3,7 @@
 
   .description {
     word-wrap: break-word;
-    word-break: break-all;
+    word-break: break-word;
   }
 
   /* Ovverrides Semantic UI for the details page.*/
@@ -11,20 +11,6 @@
     margin: 1em 0;
   }
 
-  .details-container {
-    display: grid;
-    grid-template-columns: repeat(5, auto);
-  }
-
-  .details-container .item .extra {
-    margin-bottom: 0.5em !important;
-  }
-
-  .ui.items > .item .extra.proposed-by {
-    /* This is to ensure Proposed By: is above the name of the creator. The image is 50x50 and has 14pxs of margin right*/
-    padding-left: 64px;
-  }
-
   .center-content {
     justify-content: center;
   }
@@ -40,17 +26,4 @@
   .ui.tabular.list-menu {
     margin-bottom: 2rem;
   }
-
-  @media screen and (max-width: 767px) {
-    .details-container {
-      grid-template-columns: repeat(2, auto);
-      grid-template-rows: repeat(3, auto);
-    }
-    .details-container .item:first-child {
-      grid-column: 1/3;
-    }
-    .details-container .item {
-      margin: 0.5em 0 !important;
-    }
-  }
 }

+ 63 - 25
pioneer/packages/joy-proposals/src/Proposal/ProposalDetails.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 
-import { Container } from 'semantic-ui-react';
 import Details from './Details';
 import Body from './Body';
 import VotingSection from './VotingSection';
@@ -16,7 +15,37 @@ import { BlockNumber } from '@polkadot/types/interfaces';
 import { MemberId } from '@joystream/types/members';
 import { Seat } from '@joystream/types/';
 import { PromiseComponent } from '@polkadot/joy-utils/react/components';
+import ProposalDiscussion from './discussion/ProposalDiscussion';
 
+import styled from 'styled-components';
+
+const ProposalDetailsMain = styled.div`
+  display: flex;
+  @media screen and (max-width: 1199px) {
+    flex-direction: column;
+  }
+`;
+
+const ProposalDetailsVoting = styled.div`
+  min-width: 30%;
+  margin-left: 3%;
+  @media screen and (max-width: 1399px) {
+    min-width: 40%;
+  }
+  @media screen and (max-width: 1199px) {
+    margin-left: 0;
+  }
+`;
+
+const ProposalDetailsDiscussion = styled.div`
+  margin-top: 1rem;
+  max-width: 67%;
+  @media screen and (max-width: 1399px) {
+    max-width: none;
+  }
+`;
+
+// TODO: That should probably be moved to joy-utils/functions/proposals (or transport)
 type BasicProposalStatus = 'Active' | 'Finalized';
 type ProposalPeriodStatus = 'Voting period' | 'Grace period';
 type ProposalDisplayStatus = BasicProposalStatus | ProposalDecisionStatuses | ApprovedProposalStatuses;
@@ -106,32 +135,41 @@ function ProposalDetails ({
   const extendedStatus = getExtendedStatus(proposal, bestNumber);
   const isVotingPeriod = extendedStatus.periodStatus === 'Voting period';
   return (
-    <Container className="Proposal">
+    <div className="Proposal">
       <Details proposal={proposal} extendedStatus={extendedStatus} proposerLink={ true }/>
-      <Body
-        type={ proposal.type }
-        title={ proposal.title }
-        description={ proposal.description }
-        params={ proposal.details }
-        iAmProposer={ iAmProposer }
-        proposalId={ proposalId }
-        proposerId={ proposal.proposerId }
-        isCancellable={ isVotingPeriod }
-        cancellationFee={ proposal.cancellationFee }
-      />
-      { iAmCouncilMember && (
-        <VotingSection
+      <ProposalDetailsMain>
+        <Body
+          type={ proposal.type }
+          title={ proposal.title }
+          description={ proposal.description }
+          params={ proposal.details }
+          iAmProposer={ iAmProposer }
+          proposalId={ proposalId }
+          proposerId={ proposal.proposerId }
+          isCancellable={ isVotingPeriod }
+          cancellationFee={ proposal.cancellationFee }
+        />
+        <ProposalDetailsVoting>
+          { iAmCouncilMember && (
+            <VotingSection
+              proposalId={proposalId}
+              memberId={ myMemberId as MemberId }
+              isVotingPeriod={ isVotingPeriod }/>
+          ) }
+          <PromiseComponent
+            error={votesListState.error}
+            loading={votesListState.loading}
+            message="Fetching the votes...">
+            <Votes votes={votesListState.data} />
+          </PromiseComponent>
+        </ProposalDetailsVoting>
+      </ProposalDetailsMain>
+      <ProposalDetailsDiscussion>
+        <ProposalDiscussion
           proposalId={proposalId}
-          memberId={ myMemberId as MemberId }
-          isVotingPeriod={ isVotingPeriod }/>
-      ) }
-      <PromiseComponent
-        error={votesListState.error}
-        loading={votesListState.loading}
-        message="Fetching the votes...">
-        <Votes votes={votesListState.data} />
-      </PromiseComponent>
-    </Container>
+          memberId={ iAmMember ? myMemberId : undefined }/>
+      </ProposalDetailsDiscussion>
+    </div>
   );
 }
 

+ 1 - 1
pioneer/packages/joy-proposals/src/Proposal/Votes.tsx

@@ -14,7 +14,7 @@ export default function Votes ({ votes }: VotesProps) {
   const nonEmptyVotes = votes.filter(proposalVote => proposalVote.vote !== null);
 
   if (!nonEmptyVotes.length) {
-    return <Header as="h3">No votes submitted yet!</Header>;
+    return <Header as="h4">No votes has been submitted!</Header>;
   }
 
   return (

+ 27 - 8
pioneer/packages/joy-proposals/src/Proposal/VotingSection.tsx

@@ -7,6 +7,23 @@ import { MemberId } from '@joystream/types/members';
 import { ProposalId, VoteKind, VoteKinds } from '@joystream/types/proposals';
 import { useTransport, usePromise } from '@polkadot/joy-utils/react/hooks';
 
+import styled from 'styled-components';
+
+const VoteButtons = styled.div`
+  display: grid;
+  grid-gap: 0.5rem;
+  grid-template-rows: 1fr 1fr;
+  grid-template-columns: 1fr 1fr;
+  @media screen and (max-width: 1199px) {
+    grid-template-rows: auto;
+    grid-template-columns: repeat(4, 1fr);
+  }
+  @media screen and (max-width: 767px) {
+    grid-template-rows: 1fr 1fr;
+    grid-template-columns: 1fr 1fr;
+  }
+`;
+
 export type VoteKindStr = typeof VoteKinds[number];
 
 type VoteButtonProps = {
@@ -83,14 +100,16 @@ export default function VotingSection ({
     <>
       <Header as="h3">Sumbit your vote</Header>
       <Divider />
-      { VoteKinds.map((voteKind) =>
-        <VoteButton
-          voteKind={voteKind}
-          memberId={memberId}
-          proposalId={proposalId}
-          key={voteKind}
-          onSuccess={ () => setVoted(voteKind) }/>
-      ) }
+      <VoteButtons>
+        { VoteKinds.map((voteKind) =>
+          <VoteButton
+            voteKind={voteKind}
+            memberId={memberId}
+            proposalId={proposalId}
+            key={voteKind}
+            onSuccess={ () => setVoted(voteKind) }/>
+        ) }
+      </VoteButtons>
     </>
   );
 }

+ 111 - 0
pioneer/packages/joy-proposals/src/Proposal/discussion/DiscussionPost.tsx

@@ -0,0 +1,111 @@
+import React, { useState } from 'react';
+import { Button, Icon } from 'semantic-ui-react';
+import { ParsedPost } from '@polkadot/joy-utils/types/proposals';
+import MemberProfilePreview from '@polkadot/joy-utils/MemberProfilePreview';
+import DiscussionPostForm from './DiscussionPostForm';
+import { MemberId } from '@joystream/types/members';
+import { useTransport } from '@polkadot/joy-utils/react/hooks';
+import styled from 'styled-components';
+import ReactMarkdown from 'react-markdown';
+
+const StyledComment = styled.div`
+  display: flex;
+  margin-bottom: 1rem;
+  flex-direction: column;
+`;
+const AuthorAndDate = styled.div`
+  display: flex;
+  justify-content: space-between;
+  width: 100%;
+  @media screen and (max-width: 767px) {
+    flex-direction: column;
+  }
+`;
+const Author = styled.div`
+  margin-bottom: 0.5rem;
+`;
+const CreationDate = styled.div`
+  color: rgba(0,0,0,.4);
+`;
+const ContentAndActions = styled.div`
+  display: flex;
+  flex-grow: 1;
+`;
+const CommentContent = styled.div`
+  flex-grow: 1;
+  padding: 0.5rem;
+  padding-left: 1rem;
+`;
+const CommentActions = styled.div`
+`;
+const CommentAction = styled(Button)`
+`;
+
+type ProposalDiscussionPostProps = {
+  post: ParsedPost;
+  memberId?: MemberId;
+  refreshDiscussion: () => void;
+}
+
+export default function DiscussionPost ({
+  post,
+  memberId,
+  refreshDiscussion
+}: ProposalDiscussionPostProps) {
+  const { author, authorId, text, createdAt, editsCount } = post;
+  const [editing, setEditing] = useState(false);
+  const constraints = useTransport().proposals.discussionContraints();
+  const canEdit = (
+    memberId &&
+    post.postId &&
+    authorId.toNumber() === memberId.toNumber() &&
+    editsCount < constraints.maxPostEdits
+  );
+  const onEditSuccess = () => {
+    setEditing(false);
+    refreshDiscussion();
+  };
+
+  return (
+    (memberId && editing) ? (
+      <DiscussionPostForm
+        memberId={memberId}
+        threadId={post.threadId}
+        post={post}
+        onSuccess={onEditSuccess}
+        constraints={constraints}/>
+    ) : (
+      <StyledComment>
+        <AuthorAndDate>
+          { author && (
+            <Author>
+              <MemberProfilePreview
+                avatar_uri={author.avatar_uri.toString()}
+                handle={author.handle.toString()}
+                root_account={author.root_account.toString()}/>
+            </Author>
+          ) }
+          <CreationDate>
+            <span>{ createdAt.toLocaleString() }</span>
+          </CreationDate>
+        </AuthorAndDate>
+        <ContentAndActions>
+          <CommentContent>
+            <ReactMarkdown source={text} linkTarget='_blank' />
+          </CommentContent>
+          { canEdit && (
+            <CommentActions>
+              <CommentAction
+                onClick={() => setEditing(true)}
+                primary
+                size="tiny"
+                icon>
+                <Icon name="pencil" />
+              </CommentAction>
+            </CommentActions>
+          ) }
+        </ContentAndActions>
+      </StyledComment>
+    )
+  );
+}

+ 141 - 0
pioneer/packages/joy-proposals/src/Proposal/discussion/DiscussionPostForm.tsx

@@ -0,0 +1,141 @@
+import React from 'react';
+import { Form, Field, withFormik, FormikProps } from 'formik';
+import * as Yup from 'yup';
+
+import TxButton from '@polkadot/joy-utils/TxButton';
+import * as JoyForms from '@polkadot/joy-utils/forms';
+import { SubmittableResult } from '@polkadot/api';
+import { Button } from 'semantic-ui-react';
+import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types';
+import { ParsedPost, DiscussionContraints } from '@polkadot/joy-utils/types/proposals';
+import { ThreadId } from '@joystream/types/forum';
+import { MemberId } from '@joystream/types/members';
+
+type OuterProps = {
+  post?: ParsedPost;
+  threadId: ThreadId;
+  memberId: MemberId;
+  onSuccess: () => void;
+  constraints: DiscussionContraints;
+};
+
+type FormValues = {
+  text: string;
+};
+
+type InnerProps = OuterProps & FormikProps<FormValues>;
+
+const LabelledField = JoyForms.LabelledField<FormValues>();
+
+const DiscussionPostFormInner = (props: InnerProps) => {
+  const {
+    isValid,
+    isSubmitting,
+    setSubmitting,
+    resetForm,
+    values,
+    post,
+    memberId,
+    threadId,
+    onSuccess
+  } = props;
+
+  const isEditForm = post && post.postId;
+
+  const onSubmit = (sendTx: () => void) => {
+    if (isValid) sendTx();
+  };
+
+  const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => {
+    setSubmitting(false);
+  };
+
+  const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => {
+    setSubmitting(false);
+    resetForm();
+    onSuccess();
+  };
+
+  const buildTxParams = () => {
+    if (!isValid) return [];
+
+    if (isEditForm) {
+      return [
+        memberId,
+        threadId,
+        post?.postId,
+        values.text
+      ];
+    }
+
+    return [
+      memberId,
+      threadId,
+      values.text
+    ];
+  };
+
+  return (
+    <Form className="ui form JoyForm">
+      <LabelledField name='text' {...props}>
+        <Field
+          component='textarea'
+          id='text'
+          name='text'
+          disabled={isSubmitting}
+          rows={5}
+          placeholder='Content of the post...' />
+      </LabelledField>
+      <LabelledField invisibleLabel {...props}>
+        <TxButton
+          type="submit"
+          size="large"
+          label={isEditForm ? 'Update' : 'Add Post'}
+          isDisabled={isSubmitting || !isValid}
+          params={buildTxParams()}
+          tx={isEditForm ? 'proposalsDiscussion.updatePost' : 'proposalsDiscussion.addPost'}
+          onClick={onSubmit}
+          txFailedCb={onTxFailed}
+          txSuccessCb={onTxSuccess}
+        />
+        { isEditForm ? (
+          <Button
+            type="button"
+            size="large"
+            disabled={isSubmitting}
+            color="red"
+            onClick={() => onSuccess()}
+            content="Cancel"
+          />
+        ) : (
+          <Button
+            type="button"
+            size="large"
+            disabled={isSubmitting}
+            onClick={() => resetForm()}
+            content="Clear"
+          />
+        ) }
+      </LabelledField>
+    </Form>
+  );
+};
+
+const DiscussionPostFormOuter = withFormik<OuterProps, FormValues>({
+  // Transform outer props into form values
+  mapPropsToValues: props => {
+    const { post } = props;
+    return { text: post && post.postId ? post.text : '' };
+  },
+  validationSchema: ({ constraints: c }: OuterProps) => (Yup.object().shape({
+    text: Yup
+      .string()
+      .required('Post content is required')
+      .max(c.maxPostLength, `The content cannot be longer than ${c.maxPostLength} characters`)
+  })),
+  handleSubmit: values => {
+    // do submitting things
+  }
+})(DiscussionPostFormInner);
+
+export default DiscussionPostFormOuter;

+ 57 - 0
pioneer/packages/joy-proposals/src/Proposal/discussion/ProposalDiscussion.tsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import { Divider, Header } from 'semantic-ui-react';
+import { useTransport, usePromise } from '@polkadot/joy-utils/react/hooks';
+import { ProposalId } from '@joystream/types/proposals';
+import { ParsedDiscussion } from '@polkadot/joy-utils/types/proposals';
+import { PromiseComponent } from '@polkadot/joy-utils/react/components';
+import DiscussionPost from './DiscussionPost';
+import DiscussionPostForm from './DiscussionPostForm';
+import { MemberId } from '@joystream/types/members';
+
+type ProposalDiscussionProps = {
+  proposalId: ProposalId;
+  memberId?: MemberId;
+};
+
+export default function ProposalDiscussion ({
+  proposalId,
+  memberId
+}: ProposalDiscussionProps) {
+  const transport = useTransport();
+  const [discussion, error, loading, refreshDiscussion] = usePromise<ParsedDiscussion | null | undefined>(
+    () => transport.proposals.discussion(proposalId),
+    undefined
+  );
+  const constraints = transport.proposals.discussionContraints();
+
+  return (
+    <PromiseComponent error={error} loading={loading} message={'Fetching discussion posts...'}>
+      { discussion && (
+        <>
+          <Header as="h3">Discussion ({ discussion.posts.length})</Header>
+          <Divider />
+          { discussion.posts.length ? (
+            discussion.posts.map((post, key) => (
+              <DiscussionPost
+                key={post.postId ? post.postId.toNumber() : `k-${key}`}
+                post={post}
+                memberId={memberId}
+                refreshDiscussion={refreshDiscussion}/>
+            ))
+          )
+            : (
+              <Header as="h4" style={{ margin: '1rem 0' }}>Nothing has been posted here yet!</Header>
+            )
+          }
+          { memberId && (
+            <DiscussionPostForm
+              threadId={discussion.threadId}
+              memberId={memberId}
+              onSuccess={refreshDiscussion}
+              constraints={constraints}/>
+          ) }
+        </>
+      ) }
+    </PromiseComponent>
+  );
+}

+ 6 - 0
pioneer/packages/joy-utils/src/functions/misc.ts

@@ -1,3 +1,5 @@
+import { Bytes } from '@polkadot/types/primitive';
+
 export function includeKeys<T extends { [k: string]: any }> (obj: T, ...allowedKeys: string[]) {
   return Object.keys(obj).filter(objKey => {
     return allowedKeys.reduce(
@@ -6,3 +8,7 @@ export function includeKeys<T extends { [k: string]: any }> (obj: T, ...allowedK
     );
   });
 }
+
+export function bytesToString (bytes: Bytes) {
+  return Buffer.from(bytes.toString().substr(2), 'hex').toString();
+}

+ 51 - 0
pioneer/packages/joy-utils/src/transport/base.ts

@@ -1,4 +1,5 @@
 import { ApiPromise } from '@polkadot/api';
+import { Codec } from '@polkadot/types/types';
 
 export default abstract class BaseTransport {
   protected api: ApiPromise;
@@ -15,6 +16,10 @@ export default abstract class BaseTransport {
     return this.api.query.proposalsCodex;
   }
 
+  protected get proposalsDiscussion () {
+    return this.api.query.proposalsDiscussion;
+  }
+
   protected get members () {
     return this.api.query.members;
   }
@@ -38,4 +43,50 @@ export default abstract class BaseTransport {
   protected get minting () {
     return this.api.query.minting;
   }
+
+  protected queryMethodByName (name: string) {
+    const [module, method] = name.split('.');
+    return this.api.query[module][method];
+  }
+
+  // Fetch all double map entries using only the first key
+  //
+  // TODO: FIXME: This may be a risky implementation, because it relies on a few assumptions about how the data is stored etc.
+  // With the current runtime version we can rely on the fact that all storage keys for double-map values start with the same
+  // 32-bytes prefix assuming a given (fixed) value of the first key (ie. for all values like map[x][y], the storage key starts
+  // with the same prefix as long as x remains the same. Changing y will not affect this prefix)
+  protected async doubleMapEntries<T extends Codec> (
+    methodName: string,
+    firstKey: Codec,
+    valueConverter: (hex: string) => T,
+    getEntriesCount: () => Promise<number>,
+    secondKeyStart = 1
+  ): Promise<{ secondKey: number; value: T}[]> {
+    // Get prefix and storage keys of all entries
+    const firstEntryStorageKey = this.queryMethodByName(methodName).key(firstKey, secondKeyStart);
+    const entryStorageKeyPrefix = firstEntryStorageKey.substr(0, 66); // "0x" + 64 hex characters (32 bytes)
+    const allEntriesStorageKeys = await this.api.rpc.state.getKeys(entryStorageKeyPrefix);
+
+    // Create storageKey-to-secondKey map
+    const maxSecondKey = (await getEntriesCount()) - 1 + secondKeyStart;
+    const storageKeyToSecondKey: { [key: string]: number } = {};
+    for (let secondKey = secondKeyStart; secondKey <= maxSecondKey; ++secondKey) {
+      const storageKey = this.queryMethodByName(methodName).key(firstKey, secondKey);
+      storageKeyToSecondKey[storageKey] = secondKey;
+    }
+
+    // Create the resulting entries array
+    const entries: { secondKey: number; value: T }[] = [];
+    for (const key of allEntriesStorageKeys) {
+      const value: any = await this.api.rpc.state.getStorage(key);
+      if (typeof value === 'object' && value !== null && value.raw) {
+        entries.push({
+          secondKey: storageKeyToSecondKey[key.toString()],
+          value: valueConverter(value.raw.toString())
+        });
+      }
+    }
+
+    return entries;
+  }
 }

+ 54 - 4
pioneer/packages/joy-utils/src/transport/proposals.ts

@@ -2,18 +2,22 @@ import {
   ParsedProposal,
   ProposalType,
   ProposalTypes,
-  ProposalVote
+  ProposalVote,
+  ParsedPost,
+  ParsedDiscussion,
+  DiscussionContraints
 } from '../types/proposals';
 import { ParsedMember } from '../types/members';
 
 import BaseTransport from './base';
 
-import { Proposal, ProposalId, VoteKind } from '@joystream/types/proposals';
+import { ThreadId, PostId } from '@joystream/types/forum';
+import { Proposal, ProposalId, VoteKind, DiscussionThread, DiscussionPost } from '@joystream/types/proposals';
 import { MemberId } from '@joystream/types/members';
-import { u32 } from '@polkadot/types/';
+import { u32, u64 } from '@polkadot/types/';
 import { BalanceOf } from '@polkadot/types/interfaces';
 
-import { includeKeys } from '../functions/misc';
+import { includeKeys, bytesToString } from '../functions/misc';
 import _ from 'lodash';
 import proposalsConsts from '../consts/proposals';
 
@@ -174,4 +178,50 @@ export default class ProposalsTransport extends BaseTransport {
   async subscribeProposal (id: number|ProposalId, callback: () => void) {
     return this.proposalsEngine.proposals(id, callback);
   }
+
+  async discussion (id: number|ProposalId): Promise<ParsedDiscussion | null> {
+    const threadId = (await this.proposalsCodex.threadIdByProposalId(id)) as ThreadId;
+    if (!threadId.toNumber()) {
+      return null;
+    }
+    const thread = (await this.proposalsDiscussion.threadById(threadId)) as DiscussionThread;
+    const postEntries = await this.doubleMapEntries(
+      'proposalsDiscussion.postThreadIdByPostId',
+      threadId,
+      (v) => new DiscussionPost(v),
+      async () => ((await this.proposalsDiscussion.postCount()) as u64).toNumber()
+    );
+
+    const parsedPosts: ParsedPost[] = [];
+    for (const { secondKey: postId, value: post } of postEntries) {
+      parsedPosts.push({
+        postId: new PostId(postId),
+        threadId: post.thread_id,
+        text: bytesToString(post.text),
+        createdAt: await this.chainT.blockTimestamp(post.created_at.toNumber()),
+        createdAtBlock: post.created_at.toNumber(),
+        updatedAt: await this.chainT.blockTimestamp(post.updated_at.toNumber()),
+        updatedAtBlock: post.updated_at.toNumber(),
+        authorId: post.author_id,
+        author: (await this.membersT.memberProfile(post.author_id)).unwrapOr(null),
+        editsCount: post.edition_number.toNumber()
+      });
+    }
+
+    // Sort by creation block asc
+    parsedPosts.sort((a, b) => a.createdAtBlock - b.createdAtBlock);
+
+    return {
+      title: bytesToString(thread.title),
+      threadId: threadId,
+      posts: parsedPosts
+    };
+  }
+
+  discussionContraints (): DiscussionContraints {
+    return {
+      maxPostEdits: (this.api.consts.proposalsDiscussion.maxPostEditionNumber as u32).toNumber(),
+      maxPostLength: (this.api.consts.proposalsDiscussion.postLengthLimit as u32).toNumber()
+    };
+  }
 }

+ 26 - 1
pioneer/packages/joy-utils/src/types/proposals.ts

@@ -1,5 +1,6 @@
 import { ProposalId, VoteKind } from '@joystream/types/proposals';
-import { MemberId } from '@joystream/types/members';
+import { MemberId, Profile } from '@joystream/types/members';
+import { ThreadId, PostId } from '@joystream/types/forum';
 import { ParsedMember } from './members';
 
 export const ProposalTypes = [
@@ -64,3 +65,27 @@ export type ProposalMeta = {
   slashingQuorum: number;
   slashingThreshold: number;
 }
+
+export type ParsedPost = {
+  postId: PostId | null;
+  threadId: ThreadId;
+  text: string;
+  createdAt: Date;
+  createdAtBlock: number;
+  updatedAt: Date;
+  updatedAtBlock: number;
+  author: Profile | null;
+  authorId: MemberId;
+  editsCount: number;
+};
+
+export type ParsedDiscussion = {
+  title: string;
+  threadId: ThreadId;
+  posts: ParsedPost[];
+};
+
+export type DiscussionContraints = {
+  maxPostLength: number;
+  maxPostEdits: number;
+}

+ 75 - 2
types/src/proposals.ts

@@ -1,6 +1,7 @@
-import { Text, u32, Enum, getTypeRegistry, Tuple, GenericAccountId, u8, Vec, Option, Struct, Null } from "@polkadot/types";
+import { Text, u32, Enum, getTypeRegistry, Tuple, GenericAccountId, u8, Vec, Option, Struct, Null, Bytes } from "@polkadot/types";
 import { BlockNumber, Balance } from "@polkadot/types/interfaces";
 import { MemberId } from "./members";
+import { ThreadId } from "./forum";
 import { StakeId } from "./stake";
 import AccountId from "@polkadot/types/primitive/Generic/AccountId";
 import { JoyStruct } from "./JoyStruct";
@@ -471,6 +472,76 @@ export class ThreadCounter extends Struct {
   }
 }
 
+export class DiscussionThread extends Struct {
+  constructor(value?: any) {
+    super(
+    {
+      title: Bytes,
+      'created_at': "BlockNumber",
+      'author_id': MemberId
+    },
+    value
+    );
+  }
+
+  get title(): Bytes {
+	  return this.get('title') as Bytes;
+  }
+
+  get created_at(): BlockNumber {
+	  return this.get('created_ad') as BlockNumber;
+  }
+
+  get author_id(): MemberId {
+	  return this.get('author_id') as MemberId;
+  }
+}
+
+export class DiscussionPost extends Struct {
+  constructor(value?: any) {
+    super(
+      {
+        text: Bytes,
+        /// When post was added.
+        created_at: "BlockNumber",
+        /// When post was updated last time.
+        updated_at: "BlockNumber",
+        /// Author of the post.
+        author_id: MemberId,
+        /// Parent thread id for this post
+        thread_id: ThreadId,
+        /// Defines how many times this post was edited. Zero on creation.
+        edition_number: u32,
+      },
+      value
+    );
+  }
+
+  get text(): Bytes {
+    return this.get('text') as Bytes;
+  }
+
+  get created_at(): BlockNumber {
+    return this.get('created_at') as BlockNumber;
+  }
+
+  get updated_at(): BlockNumber {
+    return this.get('updated_at') as BlockNumber;
+  }
+
+  get author_id(): MemberId {
+    return this.get('author_id') as MemberId;
+  }
+
+  get thread_id(): ThreadId {
+    return this.get('thread_id') as ThreadId;
+  }
+
+  get edition_number(): u32 {
+    return this.get('edition_number') as u32;
+  }
+}
+
 // export default proposalTypes;
 export function registerProposalTypes() {
   try {
@@ -486,7 +557,9 @@ export function registerProposalTypes() {
       Seats,
       Backer,
       Backers,
-      ThreadCounter
+      ThreadCounter,
+      DiscussionThread,
+      DiscussionPost
     });
   } catch (err) {
     console.error("Failed to register custom types of proposals module", err);