Browse Source

Merge pull request #303 from mnaamani/channel-curation-panel

Channel curation panel
Mokhtar Naamani 5 years ago
parent
commit
5bb9321386

+ 8 - 1
packages/joy-media/src/channels/ChannelHelpers.ts

@@ -21,8 +21,15 @@ export const isAccountAChannelOwner = (channel?: ChannelEntity, account?: Accoun
 
 export function isPublicChannel(channel: ChannelType): boolean {
   return (
-    channel.verified === true && // TODO uncomment
     channel.publicationStatus === 'Public' &&
     channel.curationStatus !== 'Censored'
   );
+}
+
+export function isCensoredChannel(channel: ChannelEntity) : boolean {
+  return channel.curationStatus == 'Censored'
+}
+
+export function isVerifiedChannel(channel: ChannelEntity) : boolean {
+  return channel.verified
 }

+ 13 - 3
packages/joy-media/src/channels/ChannelPreview.tsx

@@ -5,9 +5,10 @@ import { Icon, Label, SemanticICONS, SemanticCOLORS } from 'semantic-ui-react';
 import { ChannelEntity } from '../entities/ChannelEntity';
 import { ChannelAvatar, ChannelAvatarSize } from './ChannelAvatar';
 import { isPublicChannel } from './ChannelHelpers';
-import { isMusicChannel, isVideoChannel, isAccountAChannelOwner } from './ChannelHelpers';
+import { isMusicChannel, isVideoChannel, isAccountAChannelOwner, isVerifiedChannel } from './ChannelHelpers';
 import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext';
 import { nonEmptyStr } from '@polkadot/joy-utils/';
+import { CurationPanel } from './CurationPanel';
 import { ChannelNameAsLink } from './ChannelNameAsLink';
 
 type ChannelPreviewProps = {
@@ -49,9 +50,10 @@ export const ChannelPreview = (props: ChannelPreviewProps) => {
 
       <div className='ChannelDetails'>
         <h3 className='ChannelTitle' style={{ display: 'block' }}>
-          
           <ChannelNameAsLink channel={channel} style={{ marginRight: '1rem' }} />
 
+          <CurationPanel channel={channel} />
+
           {isAccountAChannelOwner(channel, myAccountId) &&
             <div style={{ float: 'right' }}>
 
@@ -59,11 +61,12 @@ export const ChannelPreview = (props: ChannelPreviewProps) => {
                 <i className='icon pencil' />
                 Edit
               </Link>
-              
+
               <Link to={`/media/channels/${channel.id}/upload`} className='ui button basic primary'>
                 <i className='icon upload' />
                 Upload {channel.content}
               </Link>
+
             </div>
           }
         </h3>
@@ -89,6 +92,13 @@ export const ChannelPreview = (props: ChannelPreviewProps) => {
               {' '}<Icon name='question circle outline' size='small' />
             </Label>
           }
+
+          {isVerifiedChannel(channel) &&
+            <Label basic color='blue'>
+              <i className='icon checkmark'/>
+              Verified
+            </Label>
+          }
         </div>
 
         {withDescription && nonEmptyStr(channel.description) &&

+ 88 - 0
packages/joy-media/src/channels/CurationPanel.tsx

@@ -0,0 +1,88 @@
+import React from 'react';
+import { ChannelEntity } from '../entities/ChannelEntity';
+import { isVerifiedChannel, isCensoredChannel } from './ChannelHelpers';
+import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext';
+import TxButton from '@polkadot/joy-utils/TxButton';
+import { ChannelCurationStatus } from '@joystream/types/content-working-group';
+import { AccountId } from '@polkadot/types/interfaces';
+
+type ChannelCurationPanelProps = {
+  channel: ChannelEntity
+};
+
+export const CurationPanel = (props: ChannelCurationPanelProps) => {
+  const { curationActor, allAccounts } = useMyMembership();
+  const { channel } = props;
+
+  const canUseAccount = (account: AccountId) => {
+    if (!allAccounts || !Object.keys(allAccounts).length) {
+      return false
+    }
+
+    const ix = Object.keys(allAccounts).findIndex((key) => {
+      return account.eq(allAccounts[key].json.address)
+    });
+
+    return ix != -1
+  }
+
+  const renderToggleCensorshipButton = () => {
+    if (!curationActor) { return null }
+
+    const [curation_actor, role_account] = curationActor;
+    const accountAvailable = canUseAccount(role_account);
+
+    const isCensored = isCensoredChannel(channel);
+
+    const new_curation_status = new ChannelCurationStatus(
+      isCensored ? 'Normal' : 'Censored'
+    );
+
+    return <TxButton
+      accountId={role_account.toString()}
+      type='submit'
+      size='medium'
+      icon={isCensored ? 'x' : 'warning'}
+      isDisabled={!accountAvailable}
+      label={isCensored ? 'Un-Censor' : 'Censor'}
+      params={[
+        curation_actor,
+        channel.id,
+        null, // not changing verified status
+        new_curation_status // toggled curation status
+      ]}
+      tx={'contentWorkingGroup.updateChannelAsCurationActor'}
+    />
+  }
+
+  const renderToggleVerifiedButton = () => {
+    if (!curationActor) { return null }
+
+    const [curation_actor, role_account] = curationActor;
+    const accountAvailable = canUseAccount(role_account);
+    const isVerified = isVerifiedChannel(channel);
+
+    return <TxButton
+      accountId={role_account.toString()}
+      type='submit'
+      size='medium'
+      icon={isVerified ? 'x' : 'checkmark'}
+      isDisabled={!accountAvailable}
+      label={isVerified ? 'Remove Verification' : 'Verify'}
+      params={[
+        curation_actor,
+        channel.id,
+        !isVerified, // toggle verified
+        null // not changing curation status
+      ]}
+      tx={'contentWorkingGroup.updateChannelAsCurationActor'}
+    />
+  }
+
+  return <>
+    <div style={{ float: 'right' }}>
+    {renderToggleCensorshipButton()}
+    {renderToggleVerifiedButton()}
+    </div>
+  </>
+}

+ 5 - 2
packages/joy-media/src/channels/EditChannel.tsx

@@ -57,6 +57,9 @@ const InnerForm = (props: MediaFormProps<OuterProps, FormValues>) => {
   const { avatar } = values;
   const isNew = !entity;
 
+  // if user is not the channel owner don't render the edit form
+  // return null
+
   const onTxSuccess: TxCallback = (txResult: SubmittableResult) => {
     setSubmitting(false)
     if (!history) return
@@ -133,7 +136,7 @@ const InnerForm = (props: MediaFormProps<OuterProps, FormValues>) => {
     }
   };
 
-  const formFields = () => <>    
+  const formFields = () => <>
     <MediaText field={Fields.handle} {...props} />
     <MediaText field={Fields.title} {...props} />
     <MediaText field={Fields.avatar} {...props} />
@@ -172,7 +175,7 @@ const InnerForm = (props: MediaFormProps<OuterProps, FormValues>) => {
     </div>
 
     <Form className='ui form JoyForm EditMetaForm'>
-      
+
       {formFields()}
 
       <LabelledField style={{ marginTop: '1rem' }} {...props}>

+ 17 - 1
packages/joy-types/src/members.ts

@@ -26,7 +26,7 @@ export enum RoleKeys {
   StorageProvider = 'StorageProvider',
     ChannelOwner = 'ChannelOwner',
     CuratorLead = 'CuratorLead',
-    Curator = 'Curator', 
+    Curator = 'Curator',
 }
 
 export class Role extends Enum {
@@ -122,6 +122,22 @@ export class ActorInRole extends Struct {
       actor_id: ActorId,
     }, value);
   }
+
+  get role (): Role {
+    return this.get('role') as Role;
+  }
+
+  get actor_id(): ActorId {
+    return this.get('actor_id') as ActorId;
+  }
+
+  get isContentLead() : boolean {
+    return this.role.eq(RoleKeys.CuratorLead);
+  }
+
+  get isCurator() : boolean {
+    return this.role.eq(RoleKeys.Curator);
+  }
 };
 
 export class UserInfo extends Struct {

+ 220 - 6
packages/joy-utils/src/MyAccount.tsx

@@ -3,12 +3,17 @@ import { Message } from 'semantic-ui-react';
 import { Link } from 'react-router-dom';
 
 import { AccountId } from '@polkadot/types/interfaces';
-import { Vec, GenericAccountId } from '@polkadot/types';
-import { withCalls, withMulti } from '@polkadot/react-api/with';
+import { Vec, GenericAccountId, Option } from '@polkadot/types';
+import accountObservable from '@polkadot/ui-keyring/observable/accounts';
+import { withCalls, withMulti, withObservable } from '@polkadot/react-api/index';
+import { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
+
+import { MemberId, Profile } from '@joystream/types/members';
+import { CuratorId, LeadId, Lead, CurationActor, Curator } from '@joystream/types/content-working-group';
 
-import { MemberId } from '@joystream/types/members';
 import { queryMembershipToProp } from '@polkadot/joy-members/utils';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
+import { queryToProp, MultipleLinkedMapEntry, SingleLinkedMapEntry } from '@polkadot/joy-utils/index';
 
 export type MyAddressProps = {
   myAddress?: string
@@ -20,7 +25,23 @@ export type MyAccountProps = MyAddressProps & {
   memberIdsByRootAccountId?: Vec<MemberId>,
   memberIdsByControllerAccountId?: Vec<MemberId>,
   myMemberIdChecked?: boolean,
-  iAmMember?: boolean
+  iAmMember?: boolean,
+  memberProfile?: Option<any>,
+
+  // Content Working Group
+  curatorEntries?: any, //entire linked_map: CuratorId => Curator
+  isLeadSet?: Option<LeadId>
+  contentLeadId? : LeadId
+  contentLeadEntry?: any // linked_map value
+
+  // From member's roles
+  myContentLeadId?: LeadId,
+  myCuratorIds?: CuratorId[],
+  memberIsCurator?: boolean,
+  memberIsContentLead?: boolean,
+
+  curationActor?: any,
+  allAccounts?: SubjectInfo,
 };
 
 function withMyAddress<P extends MyAccountProps> (Component: React.ComponentType<P>) {
@@ -55,19 +76,212 @@ function withMyMembership<P extends MyAccountProps> (Component: React.ComponentT
     const newProps = {
       myMemberIdChecked,
       myMemberId,
-      iAmMember
+      iAmMember,
     };
 
     return <Component {...props} {...newProps} />;
   };
 }
 
+const withMyProfile = withCalls<MyAccountProps>(
+  queryMembershipToProp('memberProfile', 'myMemberId'),
+);
+
+const withContentWorkingGroupDetails = withCalls<MyAccountProps>(
+  queryToProp('query.contentWorkingGroup.currentLeadId', { propName: 'isLeadSet'}),
+  queryToProp('query.contentWorkingGroup.curatorById', { propName: 'curatorEntries' }),
+);
+
+function resolveLead<P extends MyAccountProps> (Component: React.ComponentType<P>) {
+  return function (props: P) {
+    const { isLeadSet } = props;
+
+    let contentLeadId;
+
+    if (isLeadSet && isLeadSet.isSome) {
+      contentLeadId = isLeadSet.unwrap()
+    }
+
+    let newProps = {
+      contentLeadId
+    }
+
+    return <Component {...props} {...newProps} />;
+  }
+}
+
+const resolveLeadEntry = withCalls<MyAccountProps>(
+  queryToProp('query.contentWorkingGroup.leadById', { propName: 'contentLeadEntry',  paramName: 'contentLeadId' }),
+);
+
+const withContentWorkingGroup = <P extends MyAccountProps> (Component: React.ComponentType<P>) =>
+withMulti(
+  Component,
+  withContentWorkingGroupDetails,
+  resolveLead,
+  resolveLeadEntry,
+);
+
+function withMyRoles<P extends MyAccountProps> (Component: React.ComponentType<P>) {
+  return function (props: P) {
+
+    const { iAmMember, memberProfile } = props;
+
+    let myContentLeadId;
+    let myCuratorIds: Array<CuratorId> = [];
+
+    if (iAmMember && memberProfile && memberProfile.isSome) {
+      const profile = memberProfile.unwrap() as Profile;
+      profile.roles.forEach((role) => {
+        if (role.isContentLead) {
+          myContentLeadId = role.actor_id;
+        } else if (role.isCurator) {
+          myCuratorIds.push(role.actor_id);
+        }
+      });
+    }
+
+    const memberIsContentLead = myContentLeadId !== undefined;
+    const memberIsCurator = myCuratorIds.length > 0;
+
+    const newProps = {
+      memberIsContentLead,
+      memberIsCurator,
+      myContentLeadId,
+      myCuratorIds,
+    };
+
+    return <Component {...props} {...newProps} />;
+  }
+}
+
+const canUseAccount = (account: AccountId, allAccounts: SubjectInfo | undefined) => {
+  if (!allAccounts || !Object.keys(allAccounts).length) {
+    return false
+  }
+
+  const ix = Object.keys(allAccounts).findIndex((key) => {
+    return account.eq(allAccounts[key].json.address)
+  });
+
+  return ix != -1
+}
+
+function withCurationActor<P extends MyAccountProps> (Component: React.ComponentType<P>) {
+  return function (props: P) {
+
+    const {
+      myAccountId, isLeadSet, contentLeadEntry,
+      myCuratorIds, curatorEntries, allAccounts,
+      memberIsContentLead, memberIsCurator
+    } = props;
+
+    if (!myAccountId || !isLeadSet || !contentLeadEntry || !curatorEntries || !allAccounts) {
+      return <Component {...props} />;
+    }
+
+    const leadRoleAccount = isLeadSet.isSome ?
+      new SingleLinkedMapEntry<Lead>(Lead, contentLeadEntry).value.role_account : null;
+
+    // Is current key the content lead key?
+    if (leadRoleAccount && leadRoleAccount.eq(myAccountId)) {
+      return <Component {...props} curationActor={[
+        new CurationActor('Lead'),
+        myAccountId
+      ]} />
+    }
+
+    const curators = new MultipleLinkedMapEntry<CuratorId, Curator>(
+      CuratorId,
+      Curator,
+      curatorEntries
+    );
+
+    const correspondingCurationActor = (accountId: AccountId, curators: MultipleLinkedMapEntry<CuratorId, Curator>) => {
+      const ix = curators.linked_values.findIndex(
+        curator => myAccountId.eq(curator.role_account) && curator.is_active
+      );
+
+      return ix >= 0 ? new CurationActor({
+          'Curator':  curators.linked_keys[ix]
+        }) : null;
+    }
+
+    const firstMatchingCurationActor = correspondingCurationActor(myAccountId, curators);
+
+    // Is the current key corresponding to an active curator role key?
+    if (firstMatchingCurationActor) {
+      return <Component {...props} curationActor={[
+        firstMatchingCurationActor,
+        myAccountId
+      ]} />;
+    }
+
+    // See if we have the member's lead role account
+    if(leadRoleAccount && memberIsContentLead && canUseAccount(leadRoleAccount, allAccounts)) {
+      return <Component {...props} curationActor={[
+        new CurationActor('Lead'),
+        leadRoleAccount
+      ]} />
+    }
+
+    // See if we have one of the member's curator role accounts
+    if(memberIsCurator && myCuratorIds && curators.linked_keys.length) {
+      for(let i = 0; i < myCuratorIds.length; i++) {
+        const curator_id = myCuratorIds[i];
+        const ix = curators.linked_keys.findIndex((id) => id.eq(curator_id));
+
+        if (ix >= 0) {
+          const curator = curators.linked_values[ix];
+          if (curator.is_active && canUseAccount(curator.role_account, allAccounts)) {
+            return <Component {...props} curationActor={[
+              new CurationActor({ 'Curator': curator_id }),
+              curator.role_account
+            ]} />;
+          }
+        }
+      }
+    }
+
+    // selected key doesn't have any special role, check other available keys..
+
+    // Use lead role key if available
+    if (leadRoleAccount && canUseAccount(leadRoleAccount, allAccounts)) {
+      return <Component {...props} curationActor={[
+        new CurationActor('Lead'),
+        leadRoleAccount
+      ]} />
+    }
+
+    // Use first available active curator role key if available
+    if(curators.linked_keys.length) {
+      for (let i = 0; i < curators.linked_keys.length; i++) {
+        let curator = curators.linked_values[i];
+        if (curator.is_active && canUseAccount(curator.role_account, allAccounts)) {
+          return <Component {...props} curationActor={[
+            new CurationActor({ 'Curator':  curators.linked_keys[i] }),
+            curator.role_account
+          ]} />
+        }
+      }
+    }
+
+    // we don't have any key that can fulfill a curation action
+    return <Component {...props} />;
+  }
+}
+
 export const withMyAccount = <P extends MyAccountProps> (Component: React.ComponentType<P>) =>
 withMulti(
   Component,
+  withObservable(accountObservable.subject, { propName: 'allAccounts' }),
   withMyAddress,
   withMyMemberIds,
-  withMyMembership
+  withMyMembership,
+  withMyProfile,
+  withContentWorkingGroup,
+  withMyRoles,
+  withCurationActor,
 );
 
 function OnlyMembers<P extends MyAccountProps> (Component: React.ComponentType<P>) {