utils#slugify TypeScript Examples

The following examples show how to use utils#slugify. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: summary_row.tsx    From commonwealth with GNU General Public License v3.0 6 votes vote down vote up
getThreadCells = (sortedThreads: OffchainThread[]) => {
  return sortedThreads.slice(0, 3).map((thread) => {
    const discussionLink = getProposalUrlPath(
      thread.slug,
      `${thread.identifier}-${slugify(thread.title)}`
    );

    return (
      <div class="thread-summary">
        {link('a.thread-title', discussionLink, thread.title)}
        <div class="last-updated">
          {formatTimestampAsDate(getLastUpdated(thread))}
          {isHot(thread) && <span>?</span>}
        </div>
      </div>
    );
  });
}
Example #2
Source File: header.tsx    From commonwealth with GNU General Public License v3.0 5 votes vote down vote up
ProposalTitleSaveEdit: m.Component<{
  proposal: AnyProposal;
  getSetGlobalEditingStatus;
  parentState;
}> = {
  view: (vnode) => {
    const { proposal, getSetGlobalEditingStatus, parentState } = vnode.attrs;
    if (!proposal) return;
    const proposalLink = getProposalUrlPath(
      proposal.slug,
      `${proposal.identifier}-${slugify(proposal.title)}`
    );

    return m('.ProposalTitleSaveEdit', [
      m(
        Button,
        {
          class: 'save-editing',
          label: 'Save',
          disabled: parentState.saving,
          intent: 'primary',
          rounded: true,
          onclick: (e) => {
            e.preventDefault();
            parentState.saving = true;
            app.chain.chainEntities
              .updateEntityTitle(
                proposal.uniqueIdentifier,
                parentState.updatedTitle
              )
              .then((response) => {
                m.route.set(proposalLink);
                parentState.editing = false;
                parentState.saving = false;
                getSetGlobalEditingStatus(GlobalStatus.Set, false);
                proposal.title = parentState.updatedTitle;
                m.redraw();
                notifySuccess('Thread successfully edited');
              });
          },
        },
        'Save'
      ),
    ]);
  },
}
Example #3
Source File: proposal_card.tsx    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
view(vnode) {
    const { proposal, injectedContent } = vnode.attrs;

    return (
      <CWCard
        elevation="elevation-2"
        interactive={true}
        className="ProposalCard"
        onclick={(e) => {
          e.stopPropagation();
          e.preventDefault();
          localStorage[`${app.activeChainId()}-proposals-scrollY`] =
            window.scrollY;
          const path = getProposalUrlPath(
            proposal.slug,
            `${proposal.identifier}-${slugify(proposal.title)}`,
            true
          );
          navigateToSubpage(path); // avoid resetting scroll point
        }}
      >
        <div class="proposal-card-metadata">
          <Tag
            label={[
              chainEntityTypeToProposalShortName(
                proposalSlugToChainEntityType(proposal.slug)
              ),
              ' ',
              proposal.shortIdentifier,
            ]}
            intent="primary"
            rounded={true}
            size="xs"
          />
          {(proposal instanceof SubstrateDemocracyProposal ||
            proposal instanceof SubstrateCollectiveProposal) &&
            proposal.getReferendum() && (
              <Tag
                label={`REF #${proposal.getReferendum().identifier}`}
                intent="primary"
                rounded={true}
                size="xs"
                class="proposal-became-tag"
              />
            )}
          {proposal instanceof SubstrateDemocracyReferendum &&
            (() => {
              const originatingProposalOrMotion = proposal.getProposalOrMotion(
                proposal.preimage
              );
              return (
                <Tag
                  label={
                    originatingProposalOrMotion instanceof
                    SubstrateDemocracyProposal
                      ? `PROP #${originatingProposalOrMotion.identifier}`
                      : originatingProposalOrMotion instanceof
                        SubstrateCollectiveProposal
                      ? `MOT #${originatingProposalOrMotion.identifier}`
                      : 'MISSING PROP'
                  }
                  intent="primary"
                  rounded={true}
                  size="xs"
                  class="proposal-became-tag"
                />
              );
            })()}
          {proposal instanceof SubstrateTreasuryProposal &&
            !proposal.data.index && (
              <Tag
                label="MISSING DATA"
                intent="primary"
                rounded={true}
                size="xs"
                class="proposal-became-tag"
              />
            )}
          <div class="proposal-title" title={proposal.title}>
            {proposal.title}
          </div>
          {proposal instanceof SubstrateTreasuryProposal && (
            <div class="proposal-amount">{proposal.value?.format(true)}</div>
          )}
          {proposal instanceof SubstrateDemocracyReferendum && (
            <div class="proposal-amount">{proposal.threshold}</div>
          )}
          {proposal instanceof AaveProposal && (
            <p class="card-subheader">
              {proposal.ipfsData?.shortDescription || 'Proposal'}
            </p>
          )}
          {/* linked treasury proposals
            proposal instanceof SubstrateDemocracyReferendum && proposal.preimage?.section === 'treasury'
               && proposal.preimage?.method === 'approveProposal'
              && m('.proposal-action', [ 'Approves TRES-', proposal.preimage?.args[0] ]),
             proposal instanceof SubstrateDemocracyProposal && proposal.preimage?.section === 'treasury'
              && proposal.preimage?.method === 'approveProposal'
              && m('.proposal-action', [ 'Approves TRES-', proposal.preimage?.args[0] ]),
             proposal instanceof SubstrateCollectiveProposal && proposal.call?.section === 'treasury'
               && proposal.call?.method === 'approveProposal'
               && m('.proposal-action', [ 'Approves TRES-', proposal.call?.args[0] ]),
             linked referenda */}
        </div>
        {injectedContent ? (
          <div class="proposal-injected">
            {m(injectedContent, {
              proposal,
              statusClass: getStatusClass(proposal),
              statusText: getStatusText(proposal),
            })}
          </div>
        ) : proposal.isPassing !== 'none' ? (
          <div class={`proposal-status ${getStatusClass(proposal)}`}>
            {getStatusText(proposal)}
          </div>
        ) : null}
        {proposal.threadId && (
          <div class="proposal-thread-link">
            <a
              href={getProposalUrlPath(
                ProposalType.OffchainThread,
                `${proposal.threadId}`
              )}
              onclick={(e) => {
                e.stopPropagation();
                e.preventDefault();
                localStorage[`${app.activeChainId()}-proposals-scrollY`] =
                  window.scrollY;
                navigateToSubpage(
                  getProposalUrlPath(
                    ProposalType.OffchainThread,
                    `${proposal.threadId}`,
                    true
                  )
                );
                // avoid resetting scroll point
              }}
            >
              <CWIcon iconName="expand" iconSize="small" />
              <span>
                {proposal.threadTitle ? proposal.threadTitle : 'Go to thread'}
              </span>
            </a>
          </div>
        )}
      </CWCard>
    );
  }
Example #4
Source File: erc20_form.tsx    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
view(vnode) {
    const validAddress = isAddress(this.state.form.address);
    const disableField = !validAddress || !this.state.loaded;

    const updateTokenForum = async () => {
      if (!this.state.form.address || !this.state.form.ethChainId) return;
      this.state.status = '';
      this.state.error = '';
      this.state.loading = true;
      const args = {
        address: this.state.form.address,
        chain_id: this.state.form.ethChainId,
        chain_network: ChainNetwork.ERC20,
        url: this.state.form.nodeUrl,
        allowUncached: true,
      };
      try {
        console.log('Querying backend for token data');
        const res = await $.get(`${app.serverUrl()}/getTokenForum`, args);
        if (res.status === 'Success') {
          if (res?.token?.name) {
            this.state.form.name = res.token.name || '';
            this.state.form.id = res.token.id && slugify(res.token.id);
            this.state.form.symbol = res.token.symbol || '';
            this.state.form.decimals = +res.token.decimals || 18;
            this.state.form.iconUrl = res.token.icon_url || '';
            if (this.state.form.iconUrl.startsWith('/')) {
              this.state.form.iconUrl = `https://commonwealth.im${this.state.form.iconUrl}`;
            }
            this.state.form.description = res.token.description || '';
            this.state.form.website = res.token.website || '';
            this.state.form.discord = res.token.discord || '';
            this.state.form.element = res.token.element || '';
            this.state.form.telegram = res.token.telegram || '';
            this.state.form.github = res.token.github || '';
            this.state.status = 'Success!';
          } else {
            // attempt to query ERC20Detailed token info from chain
            console.log('Querying chain for ERC info');
            const provider = new Web3.providers.WebsocketProvider(args.url);
            try {
              const ethersProvider = new providers.Web3Provider(provider);
              const contract = IERC20Metadata__factory.connect(
                args.address,
                ethersProvider
              );
              const name = await contract.name();
              const symbol = await contract.symbol();
              const decimals = await contract.decimals();
              this.state.form.name = name || '';
              this.state.form.id = name && slugify(name);
              this.state.form.symbol = symbol || '';
              this.state.form.decimals = decimals || 18;
              this.state.status = 'Success!';
            } catch (e) {
              this.state.form.name = '';
              this.state.form.id = '';
              this.state.form.symbol = '';
              this.state.form.decimals = 18;
              this.state.status = 'Verified token but could not load metadata.';
            }
            this.state.form.iconUrl = '';
            this.state.form.description = '';
            this.state.form.website = '';
            this.state.form.discord = '';
            this.state.form.element = '';
            this.state.form.telegram = '';
            this.state.form.github = '';
            provider.disconnect(1000, 'finished');
          }
          this.state.loaded = true;
        } else {
          this.state.error = res.message || 'Failed to load Token Information';
        }
      } catch (err) {
        this.state.error =
          err.responseJSON?.error || 'Failed to load Token Information';
      }
      this.state.loading = false;
      m.redraw();
    };

    return (
      <div class="CreateCommunityForm">
        {...ethChainRows(vnode.attrs, this.state.form)}
        <CWButton
          label="Populate fields"
          disabled={
            this.state.saving ||
            !validAddress ||
            !this.state.form.ethChainId ||
            this.state.loading
          }
          onclick={async () => {
            await updateTokenForum();
          }}
        />
        <ValidationRow error={this.state.error} status={this.state.status} />
        <InputRow
          title="Name"
          defaultValue={this.state.form.name}
          disabled={disableField}
          onChangeHandler={(v) => {
            this.state.form.name = v;
            this.state.form.id = slugifyPreserveDashes(v);
          }}
        />
        <IdRow id={this.state.form.id} />
        <InputRow
          title="Symbol"
          disabled={disableField}
          defaultValue={this.state.form.symbol}
          placeholder="XYZ"
          onChangeHandler={(v) => {
            this.state.form.symbol = v;
          }}
        />
        {...defaultChainRows(this.state.form, disableField)}
        <CWButton
          label="Save changes"
          disabled={this.state.saving || !validAddress || !this.state.loaded}
          onclick={async () => {
            const { altWalletUrl, chainString, ethChainId, nodeUrl } =
              this.state.form;
            this.state.saving = true;
            mixpanelBrowserTrack({
              event: MixpanelCommunityCreationEvent.CREATE_COMMUNITY_ATTEMPTED,
              chainBase: null,
              isCustomDomain: app.isCustomDomain(),
              communityType: null,
            });
            try {
              const res = await $.post(`${app.serverUrl()}/createChain`, {
                alt_wallet_url: altWalletUrl,
                base: ChainBase.Ethereum,
                chain_string: chainString,
                eth_chain_id: ethChainId,
                jwt: app.user.jwt,
                network: ChainNetwork.ERC20,
                node_url: nodeUrl,
                type: ChainType.Token,
                ...this.state.form,
              });
              await initAppState(false);
              m.route.set(`/${res.result.chain?.id}`);
            } catch (err) {
              notifyError(
                err.responseJSON?.error || 'Creating new ERC20 community failed'
              );
            } finally {
              this.state.saving = false;
            }
          }}
        />
      </div>
    );
  }
Example #5
Source File: erc721_form.tsx    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
view(vnode) {
    const validAddress = isAddress(this.state.form.address);
    const disableField = !validAddress || !this.state.loaded;

    const updateTokenForum = async () => {
      if (!this.state.form.address || !this.state.form.ethChainId) return;
      this.state.status = '';
      this.state.error = '';
      this.state.loading = true;
      const args = {
        address: this.state.form.address,
        chain_id: this.state.form.ethChainId,
        chain_network: ChainNetwork.ERC721,
        url: this.state.form.nodeUrl,
        allowUncached: true,
      };
      try {
        console.log('Querying backend for token data');
        const res = await $.get(`${app.serverUrl()}/getTokenForum`, args);
        if (res.status === 'Success') {
          if (res?.token?.name) {
            this.state.form.name = res.token.name || '';
            this.state.form.id = res.token.id && slugify(res.token.id);
            this.state.form.symbol = res.token.symbol || '';
            this.state.form.iconUrl = res.token.icon_url || '';
            if (this.state.form.iconUrl.startsWith('/')) {
              this.state.form.iconUrl = `https://commonwealth.im${this.state.form.iconUrl}`;
            }
            this.state.form.description = res.token.description || '';
            this.state.form.website = res.token.website || '';
            this.state.form.discord = res.token.discord || '';
            this.state.form.element = res.token.element || '';
            this.state.form.telegram = res.token.telegram || '';
            this.state.form.github = res.token.github || '';
            this.state.status = 'Success!';
          } else {
            // attempt to query ERC721Detailed token info from chain
            console.log('Querying chain for ERC info');
            const provider = new Web3.providers.WebsocketProvider(args.url);
            try {
              const ethersProvider = new providers.Web3Provider(provider);
              const contract = IERC721Metadata__factory.connect(
                args.address,
                ethersProvider
              );
              const name = await contract.name();
              const symbol = await contract.symbol();
              const decimals = await contract.decimals();
              this.state.form.name = name || '';
              this.state.form.id = name && slugify(name);
              this.state.form.symbol = symbol || '';
              this.state.status = 'Success!';
            } catch (e) {
              this.state.form.name = '';
              this.state.form.id = '';
              this.state.form.symbol = '';
              this.state.status = 'Verified token but could not load metadata.';
            }
            this.state.form.iconUrl = '';
            this.state.form.description = '';
            this.state.form.website = '';
            this.state.form.discord = '';
            this.state.form.element = '';
            this.state.form.telegram = '';
            this.state.form.github = '';
            provider.disconnect(1000, 'finished');
          }
          this.state.loaded = true;
        } else {
          this.state.error = res.message || 'Failed to load Token Information';
        }
      } catch (err) {
        this.state.error =
          err.responseJSON?.error || 'Failed to load Token Information';
      }
      this.state.loading = false;
      m.redraw();
    };

    return (
      <div class="CreateCommunityForm">
        {...ethChainRows(vnode.attrs, this.state.form)}
        <CWButton
          label="Populate fields"
          disabled={
            this.state.saving ||
            !validAddress ||
            !this.state.form.ethChainId ||
            this.state.loading
          }
          onclick={async () => {
            await updateTokenForum();
          }}
        />
        <ValidationRow error={this.state.error} status={this.state.status} />
        <InputRow
          title="Name"
          defaultValue={this.state.form.name}
          disabled={disableField}
          onChangeHandler={(v) => {
            this.state.form.name = v;
            this.state.form.id = slugifyPreserveDashes(v);
          }}
        />
        <IdRow id={this.state.form.id} />
        <InputRow
          title="Symbol"
          disabled={disableField}
          defaultValue={this.state.form.symbol}
          placeholder="XYZ"
          onChangeHandler={(v) => {
            this.state.form.symbol = v;
          }}
        />
        {...defaultChainRows(this.state.form, disableField)}
        <CWButton
          label="Save changes"
          disabled={this.state.saving || !validAddress || !this.state.loaded}
          onclick={async () => {
            const { altWalletUrl, chainString, ethChainId, nodeUrl } =
              this.state.form;
            this.state.saving = true;
            mixpanelBrowserTrack({
              event: MixpanelCommunityCreationEvent.CREATE_COMMUNITY_ATTEMPTED,
              chainBase: null,
              isCustomDomain: app.isCustomDomain(),
              communityType: null,
            });

            try {
              const res = await $.post(`${app.serverUrl()}/createChain`, {
                alt_wallet_url: altWalletUrl,
                base: ChainBase.Ethereum,
                chain_string: chainString,
                eth_chain_id: ethChainId,
                jwt: app.user.jwt,
                network: ChainNetwork.ERC721,
                node_url: nodeUrl,
                type: ChainType.Token,
                ...this.state.form,
              });
              await initAppState(false);
              m.route.set(`/${res.result.chain?.id}`);
            } catch (err) {
              notifyError(
                err.responseJSON?.error ||
                  'Creating new ERC721 community failed'
              );
            } finally {
              this.state.saving = false;
            }
          }}
        />
      </div>
    );
  }
Example #6
Source File: substrate_form.tsx    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
view() {
    return (
      <div class="CreateCommunityForm">
        <InputRow
          title="Name"
          defaultValue={this.state.form.name}
          onChangeHandler={(v) => {
            this.state.form.name = v;
          }}
        />
        <InputRow
          title="Node URL"
          defaultValue={this.state.form.nodeUrl}
          placeholder="wss://"
          onChangeHandler={(v) => {
            this.state.form.nodeUrl = v;
          }}
        />
        <InputRow
          title="Symbol"
          defaultValue={this.state.form.symbol}
          placeholder="XYZ"
          onChangeHandler={(v) => {
            this.state.form.symbol = v;
          }}
        />
        <InputRow
          title="Spec (JSON)"
          defaultValue={this.state.form.substrateSpec}
          // TODO: how to make this resizable vertically?
          //   looks like CUI specifies an !important height tag, which prevents this
          textarea={true}
          placeholder='{"types": {"Address": "MultiAddress", "ChainId": "u8", "Reveals": "Vec<(AccountId, Vec<VoteOutcome>)>", "Balance2": "u128", "VoteData": {"stage": "VoteStage", "initiator": "AccountId", "vote_type": "VoteType", "tally_type": "TallyType", "is_commit_reveal": "bool"}, "VoteType": {"_enum": ["Binary", "MultiOption", "RankedChoice"]}, "TallyType": {"_enum": ["OnePerson", "OneCoin"]}, "VoteStage": {"_enum": ["PreVoting", "Commit", "Voting", "Completed"]}, "ResourceId": "[u8; 32]", "VoteRecord": {"id": "u64", "data": "VoteData", "reveals": "Reveals", "outcomes": "Vec<VoteOutcome>", "commitments": "Commitments"}, "AccountInfo": "AccountInfoWithRefCount", "Commitments": "Vec<(AccountId, VoteOutcome)>", "VoteOutcome": "[u8; 32]", "VotingTally": "Option<Vec<(VoteOutcome, u128)>>", "DepositNonce": "u64", "LookupSource": "MultiAddress", "ProposalTitle": "Bytes", "ProposalVotes": {"staus": "ProposalStatus", "expiry": "BlockNumber", "votes_for": "Vec<AccountId>", "votes_against": "Vec<AccountId>"}, "ProposalRecord": {"index": "u32", "stage": "VoteStage", "title": "Text", "author": "AccountId", "vote_id": "u64", "contents": "Text", "transition_time": "u32"}, "ProposalStatus": {"_enum": ["Initiated", "Approved", "Rejected"]}, "ProposalContents": "Bytes"}}'
          onChangeHandler={(v) => {
            this.state.form.substrateSpec = v;
          }}
        />
        <CWButton
          label="Test Connection"
          className="button-margin-bottom"
          onclick={async () => {
            // deinit substrate API if one exists
            if (app.chain?.apiInitialized) {
              await app.chain.deinit();
            }

            // create new API
            const provider = new WsProvider(
              constructSubstrateUrl(this.state.form.nodeUrl),
              false
            );
            try {
              await provider.connect();
              const api = await ApiPromise.create({
                throwOnConnect: true,
                provider,
                ...JSON.parse(this.state.form.substrateSpec),
              });
              await api.disconnect();
              notifySuccess('Test has passed');
            } catch (err) {
              console.error(err.message);
              notifyError('Test API initialization failed');
            }
          }}
        />
        {...defaultChainRows(this.state.form)}
        <CWButton
          label="Save changes"
          disabled={this.state.saving}
          onclick={async () => {
            const { name, nodeUrl, iconUrl, substrateSpec } = this.state.form;
            mixpanelBrowserTrack({
              event: MixpanelCommunityCreationEvent.CREATE_COMMUNITY_ATTEMPTED,
              chainBase: null,
              isCustomDomain: app.isCustomDomain(),
              communityType: null,
            });
            try {
              JSON.parse(substrateSpec);
            } catch (err) {
              notifyError('Spec provided has invalid JSON');
              return;
            }
            this.state.saving = true;
            $.post(`${app.serverUrl()}/createChain`, {
              base: ChainBase.Substrate,
              icon_url: iconUrl,
              id: slugify(name),
              jwt: app.user.jwt,
              network: slugify(name),
              node_url: nodeUrl,
              substrate_spec: substrateSpec,
              type: ChainType.Chain,
              ...this.state.form,
            })
              .then(async (res) => {
                await initAppState(false);
                m.route.set(`/${res.result.chain.id}`);
              })
              .catch((err: any) => {
                notifyError(
                  err.responseJSON?.error || 'Creating new community failed'
                );
              })
              .always(() => {
                this.state.saving = false;
              });
          }}
        />
      </div>
    );
  }
Example #7
Source File: discussion_row.tsx    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
view(vnode: m.VnodeDOM<DiscussionRowAttrs, this>) {
    const { proposal } = vnode.attrs;

    const discussionLink = getProposalUrlPath(
      proposal.slug,
      `${proposal.identifier}-${slugify(proposal.title)}`
    );
    return (
      <div
        class="DiscussionRow"
        onclick={(e) => {
          if (vnode.attrs.onSelect) {
            return vnode.attrs.onSelect();
          }
          if ($(e.target).hasClass('cui-tag')) return;
          if (e.metaKey || e.altKey || e.shiftKey || e.ctrlKey) {
            window.open(discussionLink, '_blank');
            return;
          }
          e.preventDefault();
          const scrollEle = document.getElementsByClassName('Body')[0];
          localStorage[`${app.activeChainId()}-discussions-scrollY`] =
            scrollEle.scrollTop;
          m.route.set(discussionLink);
        }}
        key={proposal.id}
      >
        {proposal.pinned ? (
          <div class="pinned">
            <CWIcon iconName="pin" iconSize="small" />
          </div>
        ) : (
          <DiscussionRowReactionButton thread={proposal} />
        )}
        <div class="title-container">
          <div class="row-header">{proposal.title}</div>
          <div class="row-subheader">
            {proposal.readOnly && (
              <div class="discussion-locked">
                <Tag
                  size="xs"
                  label={<CWIcon iconName="lock" iconSize="small" />}
                />
              </div>
            )}
            {proposal.hasPoll && (
              <Button label="Poll" intent="warning" size="xs" compact={true} />
            )}
            {proposal.chainEntities?.length > 0 &&
              proposal.chainEntities
                .sort((a, b) => {
                  return +a.typeId - +b.typeId;
                })
                .map((ce) => {
                  if (!chainEntityTypeToProposalShortName(ce.type)) return;
                  return (
                    <Button
                      label={[
                        chainEntityTypeToProposalShortName(ce.type),
                        Number.isNaN(parseInt(ce.typeId, 10))
                          ? ''
                          : ` #${ce.typeId}`,
                      ]}
                      intent="primary"
                      class="proposal-button"
                      size="xs"
                      compact={true}
                    />
                  );
                })}
            {proposal.snapshotProposal && (
              <Button
                label={['Snap ', `${proposal.snapshotProposal.slice(0, 4)}…`]}
                intent="primary"
                class="proposal-button"
                size="xs"
                compact={true}
              />
            )}
            {proposal.stage !== OffchainThreadStage.Discussion && (
              <Button
                intent={
                  proposal.stage === OffchainThreadStage.ProposalInReview
                    ? 'positive'
                    : proposal.stage === OffchainThreadStage.Voting
                    ? 'positive'
                    : proposal.stage === OffchainThreadStage.Passed
                    ? 'positive'
                    : proposal.stage === OffchainThreadStage.Failed
                    ? 'negative'
                    : 'positive'
                }
                size="xs"
                compact={true}
                label={offchainThreadStageToLabel(proposal.stage)}
              />
            )}
            {proposal.kind === OffchainThreadKind.Link &&
              proposal.url &&
              externalLink(
                'a.external-discussion-link',
                proposal.url,
                `Link: ${extractDomain(proposal.url)}`
              )}
            {proposal.topic &&
              link(
                'a.proposal-topic',
                `/${app.activeChainId()}/discussions/${proposal.topic.name}`,
                <span class="proposal-topic-name">{proposal.topic.name}</span>
              )}
            {m(User, {
              user: new AddressInfo(
                null,
                proposal.author,
                proposal.authorChain,
                null
              ),
              linkify: true,
              popover: false,
              hideAvatar: true,
              showAddressWithDisplayName: true,
              hideIdentityIcon: true,
            })}
            {proposal.collaborators && proposal.collaborators.length > 0 && (
              <span class="proposal-collaborators">
                +{proposal.collaborators.length}
              </span>
            )}
            <div class="last-active created-at">
              {link(
                'a',
                discussionLink,
                `Last active ${formatLastUpdated(getLastUpdated(proposal))}`
              )}
            </div>
            {isHot(proposal) && (
              <div class="activity-icons">
                <span>?</span>
              </div>
            )}
          </div>
        </div>
        <div class="content-right-container">
          {m(UserGallery, {
            avatarSize: 36,
            popover: true,
            maxUsers: 2,
            addressesCount:
              app.threadUniqueAddressesCount.getAddressesCountRootId(
                `${proposal.slug}_${proposal.id}`
              ),
            users:
              app.threadUniqueAddressesCount.getUniqueAddressesByRootId(
                proposal
              ),
          })}
          {app.isLoggedIn() && <DiscussionRowMenu proposal={proposal} />}
        </div>
      </div>
    );
  }
Example #8
Source File: profile_proposal.ts    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
ProfileProposal: m.Component<
  { proposal: OffchainThread },
  { revealThread: boolean }
> = {
  view: (vnode) => {
    const proposal = vnode.attrs.proposal;
    const { slug, identifier } = proposal;
    const { attachments, author, body, title, createdAt, chain } = proposal;

    // hide rows from communities that don't match
    if (app.isCustomDomain() && chain !== app.customDomainId()) return;

    return m('.ProfileProposal', [
      m('.summary', [
        m('', [
          proposal.kind === OffchainThreadKind.Question
            ? 'Added a question'
            : proposal.kind === OffchainThreadKind.Request
            ? 'added a task'
            : [
                'Created a new ',
                link(
                  'a.link-bold',
                  `/${chain}${getProposalUrlPath(
                    slug,
                    `${identifier}-${slugify(title)}`,
                    true
                  )}`,
                  'thread',
                  {},
                  `profile-${author}-${proposal.authorChain}-${proposal.chain}-scrollY`
                ),
                ' in ',
                link('a.link-bold', `/${chain}`, `${chain}`),
              ],
        ]),
        createdAt && createdAt.fromNow(),
      ]),
      m('.activity.proposal', [
        proposal.kind === OffchainThreadKind.Forum ||
        proposal.kind === OffchainThreadKind.Link
          ? link(
              'a.proposal-title',
              `/${chain}${getProposalUrlPath(
                slug,
                `${identifier}-${slugify(title)}`,
                true
              )}`,
              title,
              {},
              `profile-${author}-${proposal.authorChain}-${proposal.chain}-scrollY`
            )
          : m('a.proposal-title', title),
        // TODO: show a truncated thread once we have a good formatting stripping helper
        attachments &&
          attachments.length > 0 &&
          m('.proposal-attachments', [
            m('p', `Attachments (${attachments.length})`),
            attachments.map((attachment) =>
              m(
                'a.attachment-item',
                {
                  href: attachment.url,
                  title: attachment.description,
                  target: '_blank',
                  noopener: 'noopener',
                  noreferrer: 'noreferrer',
                  onclick: (e) => {
                    e.preventDefault();
                    lity(attachment.url);
                  },
                },
                [
                  m('img', {
                    src: attachment.url,
                  }),
                ]
              )
            ),
          ]),
      ]),
    ]);
  },
}
Example #9
Source File: index.ts    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
ProposalHeader: m.Component<
  {
    commentCount: number;
    viewCount: number;
    getSetGlobalEditingStatus: CallableFunction;
    proposalPageState: IProposalPageState;
    proposal: AnyProposal | OffchainThread;
    isAuthor: boolean;
    isEditor: boolean;
    isAdmin: boolean;
    stageEditorIsOpen: boolean;
    pollEditorIsOpen: boolean;
    closePollEditor: Function;
    closeStageEditor: Function;
  },
  {
    savedEdit: string;
    editing: boolean;
    saving: boolean;
    quillEditorState: any;
    currentText: any;
    topicEditorIsOpen: boolean;
    editPermissionsIsOpen: boolean;
    updatedTitle: string;
    updatedUrl: string;
  }
> = {
  view: (vnode) => {
    const {
      commentCount,
      proposal,
      getSetGlobalEditingStatus,
      proposalPageState,
      viewCount,
      isAuthor,
      isEditor,
      isAdmin,
    } = vnode.attrs;
    const attachments =
      proposal instanceof OffchainThread
        ? (proposal as OffchainThread).attachments
        : false;
    const proposalLink = getProposalUrlPath(
      proposal.slug,
      `${proposal.identifier}-${slugify(proposal.title)}`
    );
    const proposalTitleIsEditable =
      proposal instanceof SubstrateDemocracyProposal ||
      proposal instanceof SubstrateCollectiveProposal ||
      proposal instanceof SubstrateTreasuryTip ||
      proposal instanceof SubstrateTreasuryProposal;

    const hasBody = !!(proposal as AnyProposal).description;

    return m(
      '.ProposalHeader',
      {
        class: `proposal-${proposal.slug}`,
      },
      [
        m('.proposal-top', [
          m('.proposal-top-left', [
            !(proposal instanceof OffchainThread) &&
              m('.proposal-meta-top', [
                m('.proposal-meta-top-left', [
                  m(ProposalHeaderOnchainId, { proposal }),
                ]),
                m('.proposal-meta-top-right', [
                  m(QueueButton, { proposal }),
                  m(ExecuteButton, { proposal }),
                  m(CancelButton, { proposal }),
                ]),
              ]),
            !vnode.state.editing &&
              m('.proposal-title', [m(ProposalHeaderTitle, { proposal })]),
            vnode.state.editing &&
              m(ProposalTitleEditor, {
                item: proposal,
                getSetGlobalEditingStatus,
                parentState: vnode.state,
              }),
            m(
              '.proposal-body-meta',
              proposal instanceof OffchainThread
                ? [
                    m(ProposalHeaderStage, { proposal }),
                    m(ProposalHeaderTopics, { proposal }),
                    m(ProposalBodyCreated, {
                      item: proposal,
                      link: proposalLink,
                    }),
                    m(ProposalBodyLastEdited, { item: proposal }),
                    m(ProposalBodyAuthor, { item: proposal }),
                    m(ProposalHeaderViewCount, { viewCount }),
                    app.isLoggedIn() &&
                      !getSetGlobalEditingStatus(GlobalStatus.Get) &&
                      m(PopoverMenu, {
                        transitionDuration: 0,
                        closeOnOutsideClick: true,
                        closeOnContentClick: true,
                        menuAttrs: { size: 'default' },
                        content: [
                          (isEditor || isAuthor || isAdmin) &&
                            m(ProposalBodyEditMenuItem, {
                              item: proposal,
                              proposalPageState: vnode.attrs.proposalPageState,
                              getSetGlobalEditingStatus,
                              parentState: vnode.state,
                            }),
                          isAuthor &&
                            m(EditPermissionsButton, {
                              openEditPermissions: () => {
                                vnode.state.editPermissionsIsOpen = true;
                              },
                            }),
                          isAdmin &&
                            proposal instanceof OffchainThread &&
                            m(TopicEditorMenuItem, {
                              openTopicEditor: () => {
                                vnode.state.topicEditorIsOpen = true;
                              },
                            }),
                          (isAuthor || isAdmin || app.user.isSiteAdmin) &&
                            m(ProposalBodyDeleteMenuItem, { item: proposal }),
                          (isAuthor || isAdmin) &&
                            m(ProposalHeaderPrivacyMenuItems, {
                              proposal,
                              getSetGlobalEditingStatus,
                            }),
                          (isAuthor || isAdmin) &&
                            app.chain?.meta.snapshot.length > 0 &&
                            m(MenuItem, {
                              onclick: () => {
                                const snapshotSpaces =
                                  app.chain.meta.snapshot;
                                if (snapshotSpaces.length > 1) {
                                  navigateToSubpage('/multiple-snapshots', {
                                    action: 'create-from-thread',
                                    proposal,
                                  });
                                } else {
                                  navigateToSubpage(
                                    `/snapshot/${snapshotSpaces}`
                                  );
                                }
                              },
                              label: 'Snapshot proposal from thread',
                            }),
                          // (isAuthor || isAdmin) &&
                          //   m(ProposalHeaderLinkThreadsMenuItem, {
                          //     item: proposal,
                          //   }),
                          (isAuthor || isAdmin) && m(MenuDivider),
                          m(ThreadSubscriptionMenuItem, {
                            proposal: proposal as OffchainThread,
                          }),
                        ],
                        inline: true,
                        trigger: m(CWIcon, {
                          iconName: 'chevronDown',
                          iconSize: 'small',
                        }),
                      }),
                    !app.isCustomDomain() &&
                      m('.CommentSocialHeader', [m(SocialSharingCarat)]),
                    vnode.state.editPermissionsIsOpen &&
                      proposal instanceof OffchainThread &&
                      m(ProposalEditorPermissions, {
                        thread: vnode.attrs.proposal as OffchainThread,
                        popoverMenu: true,
                        openStateHandler: (v) => {
                          vnode.state.editPermissionsIsOpen = v;
                        },
                        // TODO: Onchange logic
                        onChangeHandler: () => {},
                      }),
                    vnode.state.topicEditorIsOpen &&
                      proposal instanceof OffchainThread &&
                      m(TopicEditor, {
                        thread: vnode.attrs.proposal as OffchainThread,
                        popoverMenu: true,
                        onChangeHandler: (topic: OffchainTopic) => {
                          proposal.topic = topic;
                          m.redraw();
                        },
                        openStateHandler: (v) => {
                          vnode.state.topicEditorIsOpen = v;
                          m.redraw();
                        },
                      }),
                    vnode.attrs.stageEditorIsOpen &&
                      proposal instanceof OffchainThread &&
                      m(StageEditor, {
                        thread: vnode.attrs.proposal as OffchainThread,
                        popoverMenu: true,
                        onChangeHandler: (
                          stage: OffchainThreadStage,
                          chainEntities: ChainEntity[],
                          snapshotProposal: SnapshotProposal[]
                        ) => {
                          proposal.stage = stage;
                          proposal.chainEntities = chainEntities;
                          if (app.chain?.meta.snapshot) {
                            proposal.snapshotProposal = snapshotProposal[0]?.id;
                          }
                          app.threads.fetchThreadsFromId([proposal.identifier]);
                          m.redraw();
                        },
                        openStateHandler: (v) => {
                          if (!v) vnode.attrs.closeStageEditor();
                          m.redraw();
                        },
                      }),
                    vnode.attrs.pollEditorIsOpen &&
                      proposal instanceof OffchainThread &&
                      m(PollEditor, {
                        thread: vnode.attrs.proposal as OffchainThread,
                        onChangeHandler: () => {
                          vnode.attrs.closePollEditor();
                          m.redraw();
                        },
                      }),
                  ]
                : [
                    m(ProposalBodyAuthor, { item: proposal }),
                    m(ProposalHeaderOnchainStatus, { proposal }),
                    app.isLoggedIn() &&
                      (isAdmin || isAuthor) &&
                      !getSetGlobalEditingStatus(GlobalStatus.Get) &&
                      proposalTitleIsEditable &&
                      m(PopoverMenu, {
                        transitionDuration: 0,
                        closeOnOutsideClick: true,
                        closeOnContentClick: true,
                        menuAttrs: { size: 'default' },
                        content: [
                          m(ProposalTitleEditMenuItem, {
                            item: proposal,
                            proposalPageState,
                            getSetGlobalEditingStatus,
                            parentState: vnode.state,
                          }),
                        ],
                        inline: true,
                        trigger: m(CWIcon, {
                          iconName: 'chevronDown',
                          iconSize: 'small',
                        }),
                      }),
                  ]
            ),
            m('.proposal-body-link', [
              proposal instanceof OffchainThread &&
                proposal.kind === OffchainThreadKind.Link && [
                  vnode.state.editing
                    ? m(ProposalLinkEditor, {
                        item: proposal,
                        parentState: vnode.state,
                      })
                    : m(ProposalHeaderExternalLink, { proposal }),
                ],
              !(proposal instanceof OffchainThread) &&
                (proposal['blockExplorerLink'] ||
                  proposal['votingInterfaceLink'] ||
                  proposal.threadId) &&
                m('.proposal-body-link', [
                  proposal.threadId &&
                    m(ProposalHeaderThreadLink, { proposal }),
                  proposal['blockExplorerLink'] &&
                    m(ProposalHeaderBlockExplorerLink, { proposal }),
                  proposal['votingInterfaceLink'] &&
                    m(ProposalHeaderVotingInterfaceLink, { proposal }),
                ]),
            ]),
          ]),
        ]),
        proposal instanceof OffchainThread &&
          m('.proposal-content', [
            (commentCount > 0 || app.user.activeAccount) &&
              m('.thread-connector'),
            m('.proposal-content-left', [
              m(ProposalBodyAvatar, { item: proposal }),
            ]),
            m('.proposal-content-right', [
              !vnode.state.editing && m(ProposalBodyText, { item: proposal }),
              !vnode.state.editing &&
                attachments &&
                attachments.length > 0 &&
                m(ProposalBodyAttachments, { item: proposal }),
              vnode.state.editing &&
                m(ProposalBodyEditor, {
                  item: proposal,
                  parentState: vnode.state,
                }),
              m('.proposal-body-bottom', [
                vnode.state.editing &&
                  m('.proposal-body-button-group', [
                    m(ProposalBodySaveEdit, {
                      item: proposal,
                      getSetGlobalEditingStatus,
                      parentState: vnode.state,
                    }),
                    m(ProposalBodyCancelEdit, {
                      item: proposal,
                      getSetGlobalEditingStatus,
                      parentState: vnode.state,
                    }),
                  ]),
                !vnode.state.editing &&
                  m('.proposal-response-row', [
                    m(ThreadReactionButton, {
                      thread: proposal,
                    }),
                    m(InlineReplyButton, {
                      commentReplyCount: commentCount,
                      onclick: () => {
                        if (!proposalPageState.replying) {
                          proposalPageState.replying = true;
                          scrollToForm();
                        } else if (!proposalPageState.parentCommentId) {
                          // If user is already replying to top-level, cancel reply
                          proposalPageState.replying = false;
                        }
                        proposalPageState.parentCommentId = null;
                      },
                    }),
                  ]),
              ]),
            ]),
          ]),
        !(proposal instanceof OffchainThread) &&
          hasBody &&
          m('.proposal-content', [m(ProposalBodyText, { item: proposal })]),
      ]
    );
  },
}
Example #10
Source File: index.ts    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
ProposalComment: m.Component<
  {
    comment: OffchainComment<any>;
    getSetGlobalEditingStatus: CallableFunction;
    proposalPageState: IProposalPageState;
    parent: AnyProposal | OffchainComment<any> | OffchainThread;
    proposal: AnyProposal | OffchainThread;
    callback?: Function;
    isAdmin?: boolean;
    isLast: boolean;
  },
  {
    editing: boolean;
    saving: boolean;
    replying: boolean;
    quillEditorState: any;
  }
> = {
  view: (vnode) => {
    const {
      comment,
      getSetGlobalEditingStatus,
      proposalPageState,
      proposal,
      callback,
      isAdmin,
      isLast,
    } = vnode.attrs;

    if (!comment) return;
    const parentType = comment.parentComment
      ? CommentParent.Comment
      : CommentParent.Proposal;

    const commentLink = getProposalUrlPath(
      proposal.slug,
      `${proposal.identifier}-${slugify(proposal.title)}?comment=${comment.id}`
    );
    const commentReplyCount = app.comments
      .getByProposal(proposal)
      .filter((c) => c.parentComment === comment.id && !c.deleted).length;
    return m(
      '.ProposalComment',
      {
        class: `${parentType}-child comment-${comment.id}`,
        onchange: () => m.redraw(), // TODO: avoid catching bubbled input events
      },
      [
        (!isLast || app.user.activeAccount) && m('.thread-connector'),
        m('.comment-avatar', [m(ProposalBodyAvatar, { item: comment })]),
        m('.comment-body', [
          m('.comment-body-top', [
            m(ProposalBodyAuthor, { item: comment }),
            m(ProposalBodyCreated, { item: comment, link: commentLink }),
            m(ProposalBodyLastEdited, { item: comment }),

            ((!vnode.state.editing &&
              app.user.activeAccount &&
              !getSetGlobalEditingStatus(GlobalStatus.Get) &&
              app.user.activeAccount?.chain.id === comment.authorChain &&
              app.user.activeAccount?.address === comment.author) ||
              isAdmin) && [
              m(PopoverMenu, {
                closeOnContentClick: true,
                content: [
                  app.user.activeAccount?.address === comment.author &&
                    m(ProposalBodyEditMenuItem, {
                      item: comment,
                      proposalPageState,
                      getSetGlobalEditingStatus,
                      parentState: vnode.state,
                    }),
                  m(ProposalBodyDeleteMenuItem, {
                    item: comment,
                    refresh: () => callback(),
                  }),
                ],
                transitionDuration: 0,
                trigger: m(CWIcon, {
                  iconName: 'chevronDown',
                  iconSize: 'small',
                }),
              }),
            ],
            !app.isCustomDomain() &&
              m('.CommentSocialHeader', [
                m(SocialSharingCarat, { commentID: comment.id }),
              ]),
            // For now, we are limiting threading to 1 level deep
            // Comments whose parents are other comments should not display the reply option
            // !vnode.state.editing
            //   && app.user.activeAccount
            //   && !getSetGlobalEditingStatus(GlobalStatus.Get)
            //   && parentType === CommentParent.Proposal
            //   && [
            //     m(ProposalBodyReply, {
            //       item: comment,
            //       getSetGlobalReplyStatus,
            //       parentType,
            //       parentState: vnode.state,
            //     }),
            //   ],
          ]),
          m('.comment-body-content', [
            !vnode.state.editing && m(ProposalBodyText, { item: comment }),

            !vnode.state.editing &&
              comment.attachments &&
              comment.attachments.length > 0 &&
              m(ProposalBodyAttachments, { item: comment }),

            vnode.state.editing &&
              m(ProposalBodyEditor, {
                item: comment,
                parentState: vnode.state,
              }),
          ]),
          m('.comment-body-bottom', [
            vnode.state.editing &&
              m('.comment-edit-buttons', [
                m(ProposalBodySaveEdit, {
                  item: comment,
                  getSetGlobalEditingStatus,
                  parentState: vnode.state,
                  callback,
                }),
                m(ProposalBodyCancelEdit, {
                  item: comment,
                  getSetGlobalEditingStatus,
                  parentState: vnode.state,
                }),
              ]),
            !vnode.state.editing &&
              !comment.deleted &&
              m('.comment-response-row', [
                m(CommentReactionButton, {
                  comment,
                }),
                m(InlineReplyButton, {
                  commentReplyCount,
                  onclick: () => {
                    if (
                      !proposalPageState.replying ||
                      proposalPageState.parentCommentId !== comment.id
                    ) {
                      proposalPageState.replying = true;
                      proposalPageState.parentCommentId = comment.id;
                      scrollToForm(comment.id);
                    } else {
                      proposalPageState.replying = false;
                    }
                  },
                }),
              ]),
          ]),
        ]),
      ]
    );
  },
}
Example #11
Source File: index.ts    From commonwealth with GNU General Public License v3.0 4 votes vote down vote up
ViewProposalPage: m.Component<
  {
    identifier: string;
    type?: string;
  },
  IProposalPageState
> = {
  oncreate: (vnode) => {
    // writes type field if accessed as /proposal/XXX (shortcut for non-substrate chains)

    if (!vnode.state.editing) {
      vnode.state.editing = false;
    }
  },
  view: (vnode) => {
    const { identifier } = vnode.attrs;
    const isDiscussion = pathIsDiscussion(app.activeChainId(), m.route.get());
    if (!app.chain?.meta && !isDiscussion) {
      return m(PageLoading, {
        narrow: true,
        showNewProposalButton: true,
        title: 'Loading...',
      });
    }
    const type =
      vnode.attrs.type ||
      (isDiscussion
        ? ProposalType.OffchainThread
        : chainToProposalSlug(app.chain.meta));
    const headerTitle = isDiscussion ? 'Discussions' : 'Proposals';
    if (typeof identifier !== 'string')
      return m(PageNotFound, { title: headerTitle });
    const proposalId = identifier.split('-')[0];
    const proposalType = type;
    const proposalIdAndType = `${proposalId}-${proposalType}`;

    // we will want to prefetch comments, profiles, and viewCount on the page before rendering anything
    if (!vnode.state.prefetch || !vnode.state.prefetch[proposalIdAndType]) {
      vnode.state.prefetch = {};
      vnode.state.prefetch[proposalIdAndType] = {
        commentsStarted: false,
        pollsStarted: false,
        viewCountStarted: false,
        profilesStarted: false,
        profilesFinished: false,
      };
    }

    if (vnode.state.threadFetchFailed) {
      return m(PageNotFound, { title: headerTitle });
    }

    // load app controller
    if (!app.threads.initialized) {
      return m(PageLoading, {
        narrow: true,
        showNewProposalButton: true,
        title: headerTitle,
      });
    }

    const proposalRecentlyEdited = vnode.state.recentlyEdited;
    const proposalDoesNotMatch =
      vnode.state.proposal &&
      (+vnode.state.proposal.identifier !== +proposalId ||
        vnode.state.proposal.slug !== proposalType);
    if (proposalDoesNotMatch) {
      vnode.state.proposal = undefined;
      vnode.state.recentlyEdited = false;
      vnode.state.threadFetched = false;
    }
    // load proposal, and return m(PageLoading)
    if (!vnode.state.proposal || proposalRecentlyEdited) {
      try {
        vnode.state.proposal = idToProposal(proposalType, proposalId);
      } catch (e) {
        // proposal might be loading, if it's not an offchain thread
        if (proposalType === ProposalType.OffchainThread) {
          if (!vnode.state.threadFetched) {
            app.threads
              .fetchThreadsFromId([+proposalId])
              .then((res) => {
                vnode.state.proposal = res[0];
                m.redraw();
              })
              .catch((err) => {
                notifyError('Thread not found');
                vnode.state.threadFetchFailed = true;
              });
            vnode.state.threadFetched = true;
          }
          return m(PageLoading, {
            narrow: true,
            showNewProposalButton: true,
            title: headerTitle,
          });
        } else {
          if (!app.chain.loaded) {
            return m(PageLoading, {
              narrow: true,
              showNewProposalButton: true,
              title: headerTitle,
            });
          }
          // check if module is still initializing
          const c = proposalSlugToClass().get(proposalType) as ProposalModule<
            any,
            any,
            any
          >;
          if (!c) {
            return m(PageNotFound, { message: 'Invalid proposal type' });
          }
          if (!c.ready) {
            // TODO: perhaps we should be able to load here without fetching ALL proposal data
            // load sibling modules too
            if (app.chain.base === ChainBase.Substrate) {
              const chain = app.chain as Substrate;
              app.chain.loadModules([
                chain.council,
                chain.technicalCommittee,
                chain.treasury,
                chain.democracyProposals,
                chain.democracy,
                chain.tips,
              ]);
            } else {
              app.chain.loadModules([c]);
            }
            return m(PageLoading, {
              narrow: true,
              showNewProposalButton: true,
              title: headerTitle,
            });
          }
        }
        // proposal does not exist, 404
        return m(PageNotFound, { message: 'Proposal not found' });
      }
    }
    const { proposal } = vnode.state;
    if (proposalRecentlyEdited) vnode.state.recentlyEdited = false;
    if (identifier !== `${proposalId}-${slugify(proposal.title)}`) {
      navigateToSubpage(
        getProposalUrlPath(
          proposal.slug,
          `${proposalId}-${slugify(proposal.title)}`,
          true
        ),
        {},
        { replace: true }
      );
    }

    // load proposal
    if (!vnode.state.prefetch[proposalIdAndType]['threadReactionsStarted']) {
      app.threads.fetchReactionsCount([proposal]).then(() => m.redraw);
      vnode.state.prefetch[proposalIdAndType]['threadReactionsStarted'] = true;
    }

    // load comments
    if (!vnode.state.prefetch[proposalIdAndType]['commentsStarted']) {
      app.comments
        .refresh(proposal, app.activeChainId())
        .then(async () => {
          vnode.state.comments = app.comments
            .getByProposal(proposal)
            .filter((c) => c.parentComment === null);
          // fetch reactions
          const { result: reactionCounts } = await $.ajax({
            type: 'POST',
            url: `${app.serverUrl()}/reactionsCounts`,
            headers: {
              'content-type': 'application/json',
            },
            data: JSON.stringify({
              proposal_ids: [proposalId],
              comment_ids: app.comments
                .getByProposal(proposal)
                .map((comment) => comment.id),
              active_address: app.user.activeAccount?.address,
            }),
          });
          // app.reactionCounts.deinit()
          for (const rc of reactionCounts) {
            const id = app.reactionCounts.store.getIdentifier({
              threadId: rc.thread_id,
              proposalId: rc.proposal_id,
              commentId: rc.comment_id,
            });
            app.reactionCounts.store.add(
              modelReactionCountFromServer({ ...rc, id })
            );
          }
          m.redraw();
        })
        .catch(() => {
          notifyError('Failed to load comments');
          vnode.state.comments = [];
          m.redraw();
        });
      vnode.state.prefetch[proposalIdAndType]['commentsStarted'] = true;
    }

    if (vnode.state.comments?.length) {
      const mismatchedComments = vnode.state.comments.filter((c) => {
        return c.rootProposal !== `${type}_${proposalId}`;
      });
      if (mismatchedComments.length) {
        vnode.state.prefetch[proposalIdAndType]['commentsStarted'] = false;
      }
    }

    const createdCommentCallback = () => {
      vnode.state.comments = app.comments
        .getByProposal(proposal)
        .filter((c) => c.parentComment === null);
      m.redraw();
    };

    // load polls
    if (
      proposal instanceof OffchainThread &&
      !vnode.state.prefetch[proposalIdAndType]['pollsStarted']
    ) {
      app.polls.fetchPolls(app.activeChainId(), proposal.id).catch(() => {
        notifyError('Failed to load comments');
        vnode.state.comments = [];
        m.redraw();
      });
      vnode.state.prefetch[proposalIdAndType]['pollsStarted'] = true;
    } else if (proposal instanceof OffchainThread) {
      vnode.state.polls = app.polls.getByThreadId(proposal.id);
    }

    // load view count
    if (
      !vnode.state.prefetch[proposalIdAndType]['viewCountStarted'] &&
      proposal instanceof OffchainThread
    ) {
      $.post(`${app.serverUrl()}/viewCount`, {
        chain: app.activeChainId(),
        object_id: proposal.id, // (proposal instanceof OffchainThread) ? proposal.id : proposal.slug,
      })
        .then((response) => {
          if (response.status !== 'Success') {
            vnode.state.viewCount = 0;
            throw new Error(`got unsuccessful status: ${response.status}`);
          } else {
            vnode.state.viewCount = response.result.view_count;
            m.redraw();
          }
        })
        .catch(() => {
          vnode.state.viewCount = 0;
          throw new Error('could not load view count');
        });
      vnode.state.prefetch[proposalIdAndType]['viewCountStarted'] = true;
    } else if (!vnode.state.prefetch[proposalIdAndType]['viewCountStarted']) {
      // view counts currently not supported for proposals
      vnode.state.prefetch[proposalIdAndType]['viewCountStarted'] = true;
      vnode.state.viewCount = 0;
    }

    if (vnode.state.comments === undefined) {
      return m(PageLoading, {
        narrow: true,
        showNewProposalButton: true,
        title: headerTitle,
      });
    }
    if (vnode.state.viewCount === undefined) {
      return m(PageLoading, {
        narrow: true,
        showNewProposalButton: true,
        title: headerTitle,
      });
    }

    // load profiles
    if (
      vnode.state.prefetch[proposalIdAndType]['profilesStarted'] === undefined
    ) {
      if (proposal instanceof OffchainThread) {
        app.profiles.getProfile(proposal.authorChain, proposal.author);
      } else if (proposal.author instanceof Account) {
        // AnyProposal
        app.profiles.getProfile(
          proposal.author.chain.id,
          proposal.author.address
        );
      }
      vnode.state.comments.forEach((comment) => {
        app.profiles.getProfile(comment.authorChain, comment.author);
      });
      vnode.state.prefetch[proposalIdAndType]['profilesStarted'] = true;
    }
    if (
      !app.profiles.allLoaded() &&
      !vnode.state.prefetch[proposalIdAndType]['profilesFinished']
    ) {
      return m(PageLoading, {
        narrow: true,
        showNewProposalButton: true,
        title: headerTitle,
      });
    }
    vnode.state.prefetch[proposalIdAndType]['profilesFinished'] = true;

    const windowListener = (e) => {
      if (vnode.state.editing || activeQuillEditorHasText()) {
        e.preventDefault();
        e.returnValue = '';
      }
    };
    window.addEventListener('beforeunload', windowListener);

    const comments = vnode.state.comments;
    const viewCount: number = vnode.state.viewCount;
    const commentCount: number = app.comments.nComments(proposal);
    const voterCount: number =
      proposal instanceof OffchainThread ? 0 : proposal.getVotes().length;

    const getSetGlobalEditingStatus = (call: string, status?: boolean) => {
      if (call === GlobalStatus.Get) return vnode.state.editing;
      if (call === GlobalStatus.Set && status !== undefined) {
        vnode.state.editing = status;
        if (status === false) {
          vnode.state.recentlyEdited = true;
        }
        m.redraw();
      }
    };

    // Original posters have full editorial control, while added collaborators
    // merely have access to the body and title
    const { activeAccount } = app.user;

    const authorChain =
      proposal instanceof OffchainThread
        ? proposal.authorChain
        : app.activeChainId();
    const authorAddress =
      proposal instanceof OffchainThread
        ? proposal.author
        : proposal.author?.address;
    const isAuthor =
      activeAccount?.address === authorAddress &&
      activeAccount?.chain.id === authorChain;
    const isEditor =
      (proposal as OffchainThread).collaborators?.filter((c) => {
        return (
          c.address === activeAccount?.address &&
          c.chain === activeAccount?.chain.id
        );
      }).length > 0;
    const isAdminOrMod =
      app.user.isRoleOfCommunity({
        role: 'admin',
        chain: app.activeChainId(),
      }) ||
      app.user.isRoleOfCommunity({
        role: 'moderator',
        chain: app.activeChainId(),
      });
    const isAdmin = app.user.isRoleOfCommunity({
      role: 'admin',
      chain: app.activeChainId(),
    });

    if (proposal instanceof SubstrateTreasuryTip) {
      const {
        author,
        title,
        data: { who, reason },
      } = proposal;
      const contributors = proposal.getVotes();
      return m(
        Sublayout,
        {
          showNewProposalButton: true,
          title: headerTitle,
        },
        [
          m('.TipDetailPage', [
            m('.tip-details', [
              m('.title', title),
              m('.proposal-page-row', [
                m('.label', 'Finder'),
                m(User, {
                  user: author,
                  linkify: true,
                  popover: true,
                  showAddressWithDisplayName: true,
                }),
              ]),
              m('.proposal-page-row', [
                m('.label', 'Beneficiary'),
                m(User, {
                  user: app.profiles.getProfile(proposal.author.chain.id, who),
                  linkify: true,
                  popover: true,
                  showAddressWithDisplayName: true,
                }),
              ]),
              m('.proposal-page-row', [
                m('.label', 'Reason'),
                m('.tip-reason', [m(MarkdownFormattedText, { doc: reason })]),
              ]),
              m('.proposal-page-row', [
                m('.label', 'Amount'),
                m('.amount', [
                  m('.denominator', proposal.support.denom),
                  m('', proposal.support.inDollars),
                ]),
              ]),
            ]),
            m('.tip-contributions', [
              proposal.canVoteFrom(
                app.user.activeAccount as SubstrateAccount
              ) &&
                m('.contribute', [
                  m('.title', 'Contribute'),
                  m('.mb-12', [
                    m('.label', 'Amount'),
                    m(Input, {
                      name: 'amount',
                      placeholder: 'Enter tip amount',
                      autocomplete: 'off',
                      fluid: true,
                      oninput: (e) => {
                        const result = (e.target as any).value;
                        vnode.state.tipAmount =
                          result.length > 0
                            ? app.chain.chain.coins(parseFloat(result), true)
                            : undefined;
                        m.redraw();
                      },
                    }),
                  ]),
                  m(Button, {
                    disabled: vnode.state.tipAmount === undefined,
                    intent: 'primary',
                    rounded: true,
                    label: 'Submit Transaction',
                    onclick: (e) => {
                      e.preventDefault();
                      createTXModal(
                        proposal.submitVoteTx(
                          new DepositVote(
                            app.user.activeAccount,
                            vnode.state.tipAmount
                          )
                        )
                      );
                    },
                    tabindex: 4,
                    type: 'submit',
                  }),
                ]),
              contributors.length > 0 && [
                m('.contributors .title', 'Contributors'),
                contributors.map(({ account, deposit }) =>
                  m('.contributors-row', [
                    m('.amount', [
                      m('.denominator', deposit.denom),
                      m('', deposit.inDollars),
                    ]),
                    m(User, {
                      user: account,
                      linkify: true,
                      popover: true,
                      showAddressWithDisplayName: true,
                    }),
                  ])
                ),
              ],
            ]),
          ]),
        ]
      );
    }
    const showLinkedSnapshotOptions =
      (proposal as OffchainThread).snapshotProposal?.length > 0 ||
      (proposal as OffchainThread).chainEntities?.length > 0 ||
      isAuthor ||
      isAdminOrMod;
    const showLinkedThreadOptions =
      (proposal as OffchainThread).linkedThreads?.length > 0 ||
      isAuthor ||
      isAdminOrMod;

    return m(
      Sublayout,
      {
        showNewProposalButton: true,
        title: headerTitle,
      },
      m('.ViewProposalPage', [
        m('.view-proposal-page-container', [
          [
            m(ProposalHeader, {
              proposal,
              commentCount,
              viewCount,
              getSetGlobalEditingStatus,
              proposalPageState: vnode.state,
              isAuthor,
              isEditor,
              isAdmin: isAdminOrMod,
              stageEditorIsOpen: vnode.state.stageEditorIsOpen,
              pollEditorIsOpen: vnode.state.pollEditorIsOpen,
              closeStageEditor: () => {
                vnode.state.stageEditorIsOpen = false;
                m.redraw();
              },
              closePollEditor: () => {
                vnode.state.pollEditorIsOpen = false;
                m.redraw();
              },
            }),
            !(proposal instanceof OffchainThread) &&
              m(LinkedProposalsEmbed, { proposal }),
            proposal instanceof AaveProposal && [
              m(AaveViewProposalSummary, { proposal }),
              m(AaveViewProposalDetail, { proposal }),
            ],
            !(proposal instanceof OffchainThread) &&
              m(ProposalVotingResults, { proposal }),
            !(proposal instanceof OffchainThread) &&
              m(ProposalVotingActions, { proposal }),
            m(ProposalComments, {
              proposal,
              comments,
              createdCommentCallback,
              getSetGlobalEditingStatus,
              proposalPageState: vnode.state,
              recentlySubmitted: vnode.state.recentlySubmitted,
              isAdmin: isAdminOrMod,
            }),
            !vnode.state.editing &&
              !vnode.state.parentCommentId &&
              m(CreateComment, {
                callback: createdCommentCallback,
                cancellable: true,
                getSetGlobalEditingStatus,
                proposalPageState: vnode.state,
                parentComment: null,
                rootProposal: proposal,
              }),
          ],
        ]),
        m('.right-content-container', [
          [
            showLinkedSnapshotOptions &&
              proposal instanceof OffchainThread &&
              m(LinkedProposalsCard, {
                proposal,
                openStageEditor: () => {
                  vnode.state.stageEditorIsOpen = true;
                },
                showAddProposalButton: isAuthor || isAdminOrMod,
              }),
            showLinkedThreadOptions &&
              proposal instanceof OffchainThread &&
              m(LinkedThreadsCard, {
                proposalId: proposal.id,
                allowLinking: isAuthor || isAdminOrMod,
              }),
            proposal instanceof OffchainThread &&
              isAuthor &&
              (!app.chain?.meta?.adminOnlyPolling || isAdmin) &&
              m(PollEditorCard, {
                proposal,
                proposalAlreadyHasPolling: !vnode.state.polls?.length,
                openPollEditor: () => {
                  vnode.state.pollEditorIsOpen = true;
                },
              }),
            proposal instanceof OffchainThread &&
              [
                ...new Map(
                  vnode.state.polls?.map((poll) => [poll.id, poll])
                ).values(),
              ].map((poll) => {
                return m(ProposalPoll, { poll, thread: proposal });
              }),
          ],
        ]),
      ])
    );
  },
}