Browse Source

Initial implementation

Leszek Wiesner 4 years ago
parent
commit
169a5ac979

+ 4 - 0
pioneer/packages/joy-proposals/src/Proposal/ProposalDetails.tsx

@@ -16,6 +16,7 @@ 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";
 
 type BasicProposalStatus = 'Active' | 'Finalized';
 type ProposalPeriodStatus = 'Voting period' | 'Grace period';
@@ -133,6 +134,9 @@ function ProposalDetails({
         message="Fetching the votes...">
         <Votes votes={votesListState.data} />
       </PromiseComponent>
+      <ProposalDiscussion
+        proposalId={proposalId}
+        memberId={ iAmMember ? myMemberId : undefined }/>
     </Container>
   );
 }

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

@@ -0,0 +1,110 @@
+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';
+
+const StyledComment = styled.div`
+  display: flex;
+  margin-bottom: 1rem;
+  @media screen and (max-width: 767px) {
+    flex-direction: column;
+  }
+`;
+const AuthorAndDate = styled.div`
+  width: 250px;
+  min-width: 250px;
+  @media screen and (max-width: 767px) {
+    width: 100%;
+  }
+`;
+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;
+`;
+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>
+              <p>{text}</p>
+            </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;

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

@@ -0,0 +1,51 @@
+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.map((post, key) => (
+            <DiscussionPost
+              key={post.postId ? post.postId.toNumber() : `k-${key}`}
+              post={post}
+              memberId={memberId}
+              refreshDiscussion={refreshDiscussion}/>
+          )) }
+          { 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, ...allowedKe
     );
   });
 }
+
+export function bytesToString(bytes: Bytes) {
+  return Buffer.from(bytes.toString().substr(2), 'hex').toString();
+}

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

@@ -1,4 +1,9 @@
 import { ApiPromise } from "@polkadot/api";
+import { Observable } from 'rxjs';
+import { StorageEntryBase } from '@polkadot/api/types';
+import { CodecArg, Codec } from '@polkadot/types/types';
+
+type ApiMethod = StorageEntryBase<"promise", (arg1?: CodecArg, arg2?: CodecArg) => Observable<Codec>>;
 
 export default abstract class BaseTransport {
   protected api: ApiPromise;
@@ -15,6 +20,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 +47,32 @@ export default abstract class BaseTransport {
   protected get minting() {
     return this.api.query.minting;
   }
+
+  // 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> (
+    method: ApiMethod,
+    firstKey: Codec,
+    valueConverter: (hex: string) => T
+  ): Promise<{ storageKey: string, value: T}[]> {
+    const entryKey = method.key(firstKey, 0);
+    const entryKeyPrefix = entryKey.toString().substr(0, 66); // "0x" + 64 hex characters (32 bytes)
+    const allEntryKeys = await this.api.rpc.state.getKeys(entryKeyPrefix);
+    let entries: { storageKey: string, value: T }[] = [];
+    for (let key of allEntryKeys) {
+      const value: any = await this.api.rpc.state.getStorage(key);
+      if (typeof value === 'object' && value !== null && value.raw) {
+        entries.push({
+          storageKey: key.toString(),
+          value: valueConverter(value.raw.toString())
+        })
+      }
+    }
+
+    return entries;
+  }
 }

+ 74 - 3
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 { BalanceOf } from "@polkadot/types/interfaces";
 
-import { includeKeys } from "../functions/misc";
+import { includeKeys, bytesToString } from "../functions/misc";
 import _ from 'lodash';
 import proposalsConsts from "../consts/proposals"
 
@@ -22,6 +26,9 @@ import MembersTransport from "./members";
 import ChainTransport from "./chain";
 import CouncilTransport from "./council";
 
+import { Vec } from '@polkadot/types/codec';
+import { EventRecord } from '@polkadot/types/interfaces';
+
 export default class ProposalsTransport extends BaseTransport {
   private membersT: MembersTransport;
   private chainT: ChainTransport;
@@ -174,4 +181,68 @@ export default class ProposalsTransport extends BaseTransport {
   async subscribeProposal(id: number|ProposalId, callback: () => void) {
     return this.proposalsEngine.proposals(id, callback);
   }
+
+  // Find postId having only the object and storage key
+  // FIXME: TODO: This is necessary because of the "hacky" workaround described in ./base.ts
+  // (in order to avoid fetching all posts ever created)
+  async findPostId(post: DiscussionPost, storageKey: string): Promise<PostId | null> {
+    const blockHash = await this.api.rpc.chain.getBlockHash(post.created_at);
+    const events = await this.api.query.system.events.at(blockHash) as Vec<EventRecord>;
+    const postIds: PostId[] = events
+      .filter(({event}) => event.section === 'proposalsDiscussion' && event.method === 'PostCreated')
+      .map(({event}) => event.data[0] as PostId);
+
+    // Just in case there were multiple posts created in this block...
+    for (let postId of postIds) {
+      const foundPostKey = await this.proposalsDiscussion.postThreadIdByPostId.key(post.thread_id, postId);
+      if (foundPostKey === storageKey) return postId;
+    }
+
+    return null;
+  }
+
+  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(
+      this.proposalsDiscussion.postThreadIdByPostId,
+      threadId,
+      (v) => new DiscussionPost(v)
+    );
+
+    let parsedPosts: ParsedPost[] = [];
+    for (let { storageKey, value: post } of postEntries) {
+      parsedPosts.push({
+        postId: await this.findPostId(post, storageKey),
+        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);