14
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

nemAdvent Calendar 2021

Day 14

Symbol × SSS Extension で作る dApps 入門 ~Reactを添えて~

Posted at

はじめに

GW明けましたね!5月病になっちゃダメですよ!もう一歩踏み出してつよつよ(?)になっちゃいましょう!

GW明けのコンテンツのお時間です。

どうも、いなたつです。SSS Extension作ってる人です。Symbol Draw作ってる人です。

簡単にdAppsが作れると巷で噂のSymbolを使って前回の記事よりもうすこしナウい感じにdApps開発していきましょうか。

普段から僕がdAppsを開発する際の形式に沿ってやっている(少し簡易化はしています)ので、覗き見していってください。

つかうもの

  • React
  • TypeScript
  • Symbol
  • SSS Extension

前提知識

推奨知識

以下の知識を持っていると読みやすいです。無くても読めるとは思います。

  • Reactの基礎 (書いてるプログラムの解説はします)
  • TypeScript (JavaScriptになんか型が表示されてらぁくらいに思っていただければ)

本記事の目標

  • ReactでSymbol dAppsを作ってみる
  • Symbolを用いた投稿アプリの開発

作成するものの概要

オーナーとゲストがいます。あなたがオーナーでそれ以外の人(アカウント)はゲストですね。

オーナーは投稿を行い、投稿の一覧と応援メッセージの一覧を確認することができます。そして、その投稿に対してゲストは応援メッセージを送ることができます。

完成物 : https://inatatsu-tatsuhiro.github.io/SSS-dApps-React/
リポジトリ : https://github.com/inatatsu-tatsuhiro/SSS-dApps-React

完成図

image.png
image.png

前準備

まずはプロジェクトのセットアップをしていきましょう。
今回は作業ディレクトリは SSS-React とします。

SSS-React内でcreate-react-app (以下CRA)を使ってTypeScriptの雛形を作りましょう。これは結構時間がかかります。
nodeとnpmが入っていない or 古い と実行できないです。

$ npx create-react-app . --template typescript

最近のCRA(v5)はwebpack5とかいうやつをつかってるらしいです。ちょっとこいつがSymbol SDK使う際にめんどくさいのでバージョンを落とします

package.jsonを開いてください。そこにreact-scriptsというのがあると思います。このバージョンを変更します。
赤くハイライトされた行を緑にハイライトされた行のように5.0.1から4.0.3に変更してください

{
  "name": "app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.16.4",
    "@testing-library/react": "^13.1.1",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^27.4.1",
    "@types/node": "^16.11.33",
    "@types/react": "^18.0.8",
    "@types/react-dom": "^18.0.3",
    "react": "^18.1.0",
    "react-dom": "^18.1.0",
-    "react-scripts": "5.0.1",
+    "react-scripts": "4.0.3",
    "typescript": "^4.6.4",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

これを変更したら一旦パッケージを読み込みましょう

$ npm install

そしたらReactを起動します。

$ npm start

localhost:3000とブラウザのアドレス欄に入力してみてください。下図のような画面が表示されればReactさんにこんにちはです。
image.png

いろいろ使うパッケージをインストールしていきます

まずはSymbol関係

$ npm i symbol-sdk rxjs

UIコンポーネント

$ npm i @mui/material @emotion/react @emotion/styled

Hello Symbol

今日もSymbolさんにごあいさつしましょう。localhost:3000を開いた際に表示されている内容はApp.tsに記述されているので、ここを変更します。

App.ts
import React from 'react'
import { Address } from 'symbol-sdk'
import './App.css'

function App() {
  const addr = Address.createFromRawAddress(
    'TAD7Q3FEN5CZRZFE3WX6TWEESATVTMJDS2ETVTY'
  )
  return (
    <div>
      <h1>Hello Symbol {addr.pretty()}</h1>
    </div>
  )
}

export default App

今回も画像の範囲がわかりやすいように背景色つけたものにしときます。もともとのcssはいらないので消しちゃってOK

App.css
body {
  background: rgb(242, 242, 242);
}

こんな感じになります
image.png

プログラム解説

前回の記事とほとんど同じですが、解説しときます。

変数addrにSymbolSDKを用いてアドレスの文字列を読み込んでいます。

  const addr = Address.createFromRawAddress(
    'TAD7Q3FEN5CZRZFE3WX6TWEESATVTMJDS2ETVTY'
  )

Reactのレンダー部分です。一つのHTML要素を返すことができます。

「{」と「}」で囲まれている部分にはJavaScript(TypeScript)のプログラムが入ります。
今回はaddr.pretty()が入っているので先程変数に入れたアドレスをハイフンで区切った物が表示されます。

  return (
    <div>
      <h1>Hello Symbol 「{addr.pretty()}</h1>
    </div>
  )

SSS Extension と連携する

今回はさっさとSSSと連携しちゃいます。

SSSと連携するためにReactの機能useStateとuseEffectを使用するのでその説明を簡単に

useState

React上で状態を管理するために使用する機能で、変数とそれを設定・変更する関数で構成されるReactフックと呼ばれるものの一つです。

以下のような形で使用されます。

const [addr, setAddr] = useState('')

参考 https://qiita.com/seira/items/f063e262b1d57d7e78b4

useEffect

副作用を扱う際に~~~みたいな難しい説明は置いといて、とりあえず画面描画後に値を変更したり・外部からデータを取得するときに使うやつです

以下のような形で使用されます。

  useEffect(() => {
    // プログラム
  }, [])

[]の中には値が入る場合と入らない場合があります。値が入らない場合、初回の画面描画時のみこの中のプログラムが実行されます。
値が入っている場合は、指定した値が変更された時に中のプログラムが実行されます。

参考 https://qiita.com/seira/items/e62890f11e91f6b9653f

SSS Extensionからデータ取得

まずSSS Extensionと連携しましょう。やり方を忘れた方は前回の記事を見てきてください。

ではSSS Extensionからデータを取得していきます。

App.tsx
import React, { useEffect, useState } from 'react'
import { Address } from 'symbol-sdk'
import './App.css'

interface SSSWindow extends Window {
  SSS: any
}
declare const window: SSSWindow

function App() {
  const [addr, setAddr] = useState<Address | null>(null)

  useEffect(() => {
    setTimeout(() => {
      const activeAddress = window.SSS.activeAddress
      setAddr(Address.createFromRawAddress(activeAddress))
    }, 500)
  }, [])

  if (addr === null) {
    return (
      <div>
        <h1>Hello Symbol not set address</h1>
      </div>
    )
  }
  return (
    <div>
      <h1>Hello Symbol {addr.pretty()}</h1>
    </div>
  )
}

export default App

プログラム解説

TypeScriptの場合はこれを書かないとエラーが出ちゃいます。本来windowには無いSSSというエリアにアクセスするため、こんなものがあるよ~って定義してあげます。SSSが提供するisAllowedSSS()等を使用したい場合はSSSと併記してあげてください

参考 https://inatatsu-tatsuhiro.github.io/SSS-Demo/demo0

interface SSSWindow extends Window {
  SSS: any
}
declare const window: SSSWindow

先程登場したuseStateです、なんかAddress | nullが括弧に囲われていますね、TypeScriptの場合ここにはAddressが入るか何も入らないか(null)のどっちかですよ~って教えてあげる必要があります。

const [addr, setAddr] = useState<Address | null>(null)

続いてuseEffectです。前回記事で解説したようにsetTimeoutでSSSが読み込まれるのを待ってからSSSからデータを取得します。
取得したactiveAddresssetAddrします。こうすることで、SSSから取得したアドレスをaddr変数に入れることができました。

  useEffect(() => {
    setTimeout(() => {
      const activeAddress = window.SSS.activeAddress
      setAddr(Address.createFromRawAddress(activeAddress))
    }, 500)
  }, [])

では表示です。

なんかふえました。

もし、アドレスがなんにも入ってない(null)場合はアドレスがないよ~って表示してください。ってはじめに書いてます。

アドレスを読み込めた場合はaddr === nullfalseなのでこれは実行されずに下に進みます。

ここは先程と同じでアドレスをハイフン区切りで表示ですね。

  if (addr === null) {
    return (
      <div>
        <h1>Hello Symbol not set address</h1>
      </div>
    )
  }
  return (
    <div>
      <h1>Hello Symbol 「{addr.pretty()}</h1>
    </div>
  )

アドレスを取得する部分を消して画面を開くとaddrがnullなので下図のような画面になります
image.png

とりあえずこれで、SSS Extensionからデータ取得ができましたね!

SSS Extensionで管理者ログイン

SSS ExtensionのactiveAddressで得られるアドレスは開いているユーザーが誰であるかを示します。

つまり、サイトの所有者であるか、訪問者であるかを判別できるということです。

OWNER_ADDRを定義します。このアドレスでログインしている場合、サイトオーナーということを判別できます。

const OWNER_ADDR = Address.createFromRawAddress(
  'TAD7Q3FEN5CZRZFE3WX6TWEESATVTMJDS2ETVTY'
)

そして、オーナーアドレスでログインした場合の描画を定義しましょう

  if (OWNER_ADDR.plain() === addr.plain()) {
    return (
      <div>
        <h1>Hello Symbol 管理者ログイン</h1>
      </div>
    )
  }

オーナーアドレスでログインするとこんな感じですね

image.png

SSSのアクティブアカウントを変更するとさっきと同じようにアドレスが表示されます
image.png

コンポーネントを分割する

全部1ファイルに書くととても長くなるのでコンポーネント(部品)に分けます。

srcディレクトリ以下にOwnerPage.tsxGuestPage.tsxを作成してください。

App.tsx
import React, { useEffect, useState } from 'react'
import { Address } from 'symbol-sdk'
import './App.css'
import GuestPage from './GuestPage'
import OwnerPage from './OwnerPage'

interface SSSWindow extends Window {
  SSS: any
}
declare const window: SSSWindow

const OWNER_ADDR = Address.createFromRawAddress(
  'TAD7Q3FEN5CZRZFE3WX6TWEESATVTMJDS2ETVTY'
)

function App() {
  const [addr, setAddr] = useState<Address | null>(null)

  useEffect(() => {
    setTimeout(() => {
      const activeAddress = window.SSS.activeAddress
      setAddr(Address.createFromRawAddress(activeAddress))
    }, 500)
  }, [])

  if (addr === null) {
    return (
      <div>
        <h1>Hello Symbol not set address</h1>
      </div>
    )
  }

  if (OWNER_ADDR.plain() === addr.plain()) {
    return <OwnerPage />
  }
  return <GuestPage address={addr.plain()} />
}

export default App
OwnerPage.tsx
function OwnerPage() {
  return (
    <div>
      <h1>Hello Symbol 管理者ログイン</h1>
    </div>
  )
}

export default OwnerPage

GuestPage.tsx
type Props = {
  address: string
}

function GuestPage(props: Props) {
  return (
    <div>
      <h1>Hello Symbol {props.address}</h1>
    </div>
  )
}

export default GuestPage


propsくんの登場です。

propsくんは、作ったコンポーネントに値を渡す架け橋になってくれる存在です

<GuestPage address={addr.plain()} />

こんな感じで作成したコンポーネントに値を渡します。

すると、コンポーネント側で値を扱うことができるようになります。

管理者ページを作る

管理者ページではメッセージを投稿することができます。そして、他のユーザーからの応援メッセージを見ることができます。

こんなページを作っていきます
image.png

本ページは大きく3つの要素(コンポーネント)で構成されています。

  • Create Component
  • PostList Component
  • CheerList Component

Create Component

本コンポーネントは投稿を作成するための要素になります。
まず、Create.tsxを作成してください。

プログラムは以下の様になります。

Create.tsx
import { Button, TextField } from '@mui/material'
import styled from '@emotion/styled'
import { useEffect, useState } from 'react'
import {
  Address,
  Deadline,
  NetworkType,
  PlainMessage,
  SignedTransaction,
  TransactionHttp,
  TransferTransaction,
  UInt64,
} from 'symbol-sdk'

const EPOCH = 1637848847
const NODE_URL = 'https://sym-test.opening-line.jp:3001'
const NET_TYPE = NetworkType.TEST_NET

type Props = {
  address: string
}

interface SSSWindow extends Window {
  SSS: any
}
declare const window: SSSWindow

function Create(props: Props) {
  const [contents, setContents] = useState('')
  const [isRequest, setIsRequest] = useState<boolean>(false)
  const address = Address.createFromRawAddress(props.address)

  useEffect(() => {
    if (isRequest) {
      window.SSS.requestSign().then((signedTx: SignedTransaction) => {
        new TransactionHttp(NODE_URL).announce(signedTx)
      })
    }
  }, [isRequest])

  const submit = () => {
    const message = `::CREATE::${contents}`
    const tx = TransferTransaction.create(
      Deadline.create(EPOCH),
      address,
      [],
      PlainMessage.create(message),
      NET_TYPE,
      UInt64.fromUint(2000000)
    )
    window.SSS.setTransaction(tx)

    setIsRequest(true)
  }

  return (
    <Wrapper>
      <TextField
        label="contents"
        value={contents}
        fullWidth
        onChange={(e) => setContents(e.target.value)}
      />
      <Button onClick={submit}>ボタン</Button>
    </Wrapper>
  )
}

export default Create

const Wrapper = styled('div')({
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'flex-end',
  flexDirection: 'column',
})

本コンポーネントの引数の型です。
アドレスを受け取ります。

type Props = {
  address: string
}

コンポーネントの状態を示します。投稿する内容であるcontentsと、署名要求を行うisRequestがあります。

const [contents, setContents] = useState('')
const [isRequest, setIsRequest] = useState(false)

引数(props)のaddressは文字列(string型)なのでSymbol SDKで扱うためにAddress.createFromAddressでAddressにします。

const address = Address.createFromRawAddress(props.address)

isRequestが変更され、trueになった場合、SSSのrequestSignを実行します。

  useEffect(() => {
    if (isRequest) {
      window.SSS.requestSign().then((signedTx: SignedTransaction) => {
        new TransactionHttp(NODE_URL).announce(signedTx)
      })
    }
  }, [isRequest])

トランザクションの作成になります。

送信先のアドレスは引数で与えたアドレス(自分のアドレス)になります。自分から自分へのトランザクションを発生させています。

また、メッセージは::CREATE::を頭につけることで、本サービスで作成したトランザクションであるかを判別しやすくしています。

ここで発生させたトランザクションは送信先、送信元がオーナーであり、:::CREATE:::で始まるトランザクションを探せばいいということになります。

  const submit = () => {
    const message = `::CREATE::${contents}`
    const tx = TransferTransaction.create(
      Deadline.create(EPOCH),
      address,
      [],
      PlainMessage.create(message),
      NET_TYPE,
      UInt64.fromUint(2000000)
    )
    window.SSS.setTransaction(tx)

    setIsRequest(true)
  }

muiのテキストフィールドとボタンを配置しています。

テキストフィールドの入力値がコンポーネントの状態contentsとリンクしています。
ボタンを押すとsubmit関数が実行されます。

  return (
    <Wrapper>
      <TextField
        label="contents"
        value={contents}
        fullWidth
        onChange={(e) => setContents(e.target.value)}
      />
      <Button onClick={submit}>ボタン</Button>
    </Wrapper>
  )

emotionでスタイリング

const Wrapper = styled('div')({
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'flex-end',
  flexDirection: 'column',
})

PostList Component

本コンポーネントは自分の投稿を一覧で表示します。画面左側ですね。
PostList.tsxを作成します。
プログラムは以下の様になります。

PostList.tsx
/* eslint-disable react-hooks/exhaustive-deps */
import styled from '@emotion/styled'
import { useEffect, useState } from 'react'
import {
  Address,
  Order,
  RepositoryFactoryHttp,
  TransactionGroup,
  TransactionSearchCriteria,
  TransactionType,
  TransferTransaction,
} from 'symbol-sdk'

const NODE_URL = 'https://sym-test.opening-line.jp:3001'

type Props = {
  address: string
}

const POST_REG = /^::CREATE::/

function PostList(props: Props) {
  const [transactions, setTransactions] = useState<TransferTransaction[]>([])

  const address = Address.createFromRawAddress(props.address)

  useEffect(() => {
    const repositoryFactory = new RepositoryFactoryHttp(NODE_URL)
    const transactionHttp = repositoryFactory.createTransactionRepository()
    const searchCriteria: TransactionSearchCriteria = {
      group: TransactionGroup.Confirmed,
      recipientAddress: address,
      order: Order.Desc,
      type: [TransactionType.TRANSFER],
    }
    transactionHttp
      .search(searchCriteria)
      .toPromise()
      .then((txs) => {
        if (txs === undefined) return

        setTransactions(getPostTxs(txs.data as TransferTransaction[]))
      })
  }, [])

  const getPostTxs = (txs: TransferTransaction[]): TransferTransaction[] => {
    const postTxs: TransferTransaction[] = []

    for (const tx of txs) {
      if (POST_REG.test(tx.message.payload)) {
        postTxs.push(tx)
      }
    }

    return postTxs
  }

  const getPostMsg = (tx: TransferTransaction): string => {
    return tx.message.payload.split(POST_REG)[1]
  }

  return (
    <Root>
      <h1>POST</h1>
      {transactions.map((tx) => {
        const hash = !!tx.transactionInfo ? tx.transactionInfo.hash : ''
        return (
          <div key={tx.signature}>
            <h3>POST : {getPostMsg(tx)}</h3>
            <h3>HASH : {hash}</h3>
            <hr />
          </div>
        )
      })}
    </Root>
  )
}

export default PostList

const Root = styled('div')({
  margin: '32px',
})

正規表現で::CREATE::から始まるって指定をしてます。

参考 https://zenn.dev/hinoshin/articles/470ce8e10caccc

const POST_REG = /^::CREATE::/

このコンポーネントでは転送トランザクション(TransferTransaction)のリストを扱います

const [transactions, setTransactions] = useState<TransferTransaction[]>([])

RepositoryFactoryからTransactionRepositoryを作成しトランザクションを検索します。

検索条件は

  • group : 承認済みのトランザクション
  • recipientAddress : トランザクション受信者が自分であること
  • order : 降順にならべる
  • type : 転送トランザクションであること

で検索した結果をgetPostTxs関数に通します。

取得するトランザクションはtypeで転送トランザクションのみを指定しているのですが、transactionHttp.searchはTransaction[]型を返すため、 txs.data as TransferTransaction[]とし、asを用いてTransferTransaction[]に変換しています。

  useEffect(() => {
    const repositoryFactory = new RepositoryFactoryHttp(NODE_URL)
    const transactionHttp = repositoryFactory.createTransactionRepository()
    const searchCriteria: TransactionSearchCriteria = {
      group: TransactionGroup.Confirmed,
      recipientAddress: address,
      order: Order.Desc,
      type: [TransactionType.TRANSFER],
    }
    transactionHttp
      .search(searchCriteria)
      .toPromise()
      .then((txs) => {
        if (txs === undefined) return

        setTransactions(getPostTxs(txs.data as TransferTransaction[]))
      })
  }, [])

getPostTxs関数は先程定義したPOST_REGと一致するかをチェックし、一致するもののみを返却します。

  const getPostTxs = (txs: TransferTransaction[]): TransferTransaction[] => {
    const postTxs: TransferTransaction[] = []

    for (const tx of txs) {
      if (POST_REG.test(tx.message.payload)) {
        postTxs.push(tx)
      }
    }

    return postTxs
  }

getPostMsg関数は、split関数を用いてPOST_REGで転送トランザクションのメッセージを分割します。
分割した、後ろ側が、投稿の内容となるので、それを返却します。

  const getPostMsg = (tx: TransferTransaction): string => {
    return tx.message.payload.split(POST_REG)[1]
  }

配列transactionsをmap関数を用いてHTMLの要素にします。transactions配列のそれぞれの要素をtxとし、getPostMsg関数の結果とトランザクションのハッシュを表示します。

  return (
    <Root>
      <h1>POST</h1>
      {transactions.map((tx) => {
        const hash = !!tx.transactionInfo ? tx.transactionInfo.hash : ''
        return (
          <div key={tx.signature}>
            <h3>POST : {getPostMsg(tx)}</h3>
            <h3>HASH : {hash}</h3>
            <hr />
          </div>
        )
      })}
    </Root>
  )

CheerList Component

本コンポーネントは自分の投稿に対する応援メッセージを確認することができます。画面右側ですね。
CheerList.tsxを作成します。
プログラムは以下の様になります。

CheerList.tsx
/* eslint-disable react-hooks/exhaustive-deps */
import styled from '@emotion/styled'
import { useEffect, useState } from 'react'
import {
  Address,
  NetworkType,
  Order,
  RepositoryFactoryHttp,
  TransactionGroup,
  TransactionSearchCriteria,
  TransactionType,
  TransferTransaction,
} from 'symbol-sdk'

const NODE_URL = 'https://sym-test.opening-line.jp:3001'
const NET_TYPE = NetworkType.TEST_NET

type Props = {
  address: string
  pubkey: string
}

const CHEER_REG = /^::CHEER/

function CheerList(props: Props) {
  const [transactions, setTransactions] = useState<TransferTransaction[]>([])

  const address = Address.createFromRawAddress(props.address)

  useEffect(() => {
    const repositoryFactory = new RepositoryFactoryHttp(NODE_URL)
    const transactionHttp = repositoryFactory.createTransactionRepository()
    const searchCriteria: TransactionSearchCriteria = {
      group: TransactionGroup.Confirmed,
      recipientAddress: address,
      order: Order.Desc,
      type: [TransactionType.TRANSFER],
    }
    transactionHttp
      .search(searchCriteria)
      .toPromise()
      .then((txs) => {
        if (txs === undefined) return
        setTransactions(getCheerTxs(txs.data as TransferTransaction[]))
      })
  }, [])

  const getCheerTxs = (txs: TransferTransaction[]): TransferTransaction[] => {
    const cheerTxs: TransferTransaction[] = []

    for (const tx of txs) {
      if (CHEER_REG.test(tx.message.payload)) {
        cheerTxs.push(tx)
      }
    }

    return cheerTxs
  }

  const getCheerMsg = (tx: TransferTransaction): string => {
    const tmp = tx.message.payload.split('|')[1]
    return tmp.split('::')[1]
  }
  const getCheerHash = (tx: TransferTransaction): string => {
    const tmp = tx.message.payload.split('|')[1]
    return tmp.split('::')[0]
  }

  return (
    <Root>
      <h1>CHEER</h1>
      {transactions.map((tx) => {
        const addr =
          tx.signer !== undefined
            ? Address.createFromPublicKey(
                tx.signer?.publicKey,
                NET_TYPE
              ).pretty()
            : 'NOT FOUND'
        return (
          <div key={tx.signature}>
            <h3>TO: {getCheerHash(tx)}</h3>
            <h3>MSG: {getCheerMsg(tx)}</h3>
            <h3>FROM: {addr}</h3>
            <hr />
          </div>
        )
      })}
    </Root>
  )
}

export default CheerList

const Root = styled('div')({
  margin: '32px',
})

正規表現で::CHEERから始まるって指定をしてます。

const CHEER_REG = /^::CHEER/

PostListと同様に検索し、応援メッセージのみを取得しています。

  useEffect(() => {
    const repositoryFactory = new RepositoryFactoryHttp(NODE_URL)
    const transactionHttp = repositoryFactory.createTransactionRepository()
    const searchCriteria: TransactionSearchCriteria = {
      group: TransactionGroup.Confirmed,
      recipientAddress: address,
      order: Order.Desc,
      type: [TransactionType.TRANSFER],
    }
    transactionHttp
      .search(searchCriteria)
      .toPromise()
      .then((txs) => {
        if (txs === undefined) return
        setTransactions(getCheerTxs(txs.data as TransferTransaction[]))
      })
  }, [])

  const getCheerTxs = (txs: TransferTransaction[]): TransferTransaction[] => {
    const cheerTxs: TransferTransaction[] = []

    for (const tx of txs) {
      if (CHEER_REG.test(tx.message.payload)) {
        cheerTxs.push(tx)
      }
    }

    return cheerTxs
  }

getCheerMsgは応援メッセージ(<Cheer message>)、getCheerHashは応援する投稿のハッシュ(<Transaction hash>)を取得します。

以下のような形式で応援メッセージは構成されます。

::CHEER|<Transaction hash>::
<Cheer message>

const tmp = tx.message.payload.split('|')でCHEERと<Transaction hash>の部分を分割し、その後ろ側を変数tmpに代入しています。

そして、tmpをさらに::で分割した前側はTransaction hash後ろ側はCheer messageとしてそれぞれ返却しています。

  const getCheerMsg = (tx: TransferTransaction): string => {
    const tmp = tx.message.payload.split('|')[1]
    return tmp.split('::')[1]
  }
  const getCheerHash = (tx: TransferTransaction): string => {
    const tmp = tx.message.payload.split('|')[1]
    return tmp.split('::')[0]
  }

表示部分ですね。PostListとほとんど同じです。送信者のアドレス、宛先のハッシュ、メッセージを表示しています。

  return (
    <Root>
      <h1>CHEER</h1>
      {transactions.map((tx) => {
        const addr =
          tx.signer !== undefined
            ? Address.createFromPublicKey(
                tx.signer?.publicKey,
                NET_TYPE
              ).pretty()
            : 'NOT FOUND'
        return (
          <div key={tx.signature}>
            <h3>TO: {getCheerHash(tx)}</h3>
            <h3>MSG: {getCheerMsg(tx)}</h3>
            <h3>FROM: {addr}</h3>
            <hr />
          </div>
        )
      })}
    </Root>
  )

OwnerPage Component

作成したコンポーネントを呼び出します。

OwnerPage.tsx
import styled from '@emotion/styled'
import Create from './Create'
import CheerList from './CheerList'
import PostList from './PostList'

type Props = {
  address: string
  pubkey: string
}

function OwnerPage(props: Props) {
  return (
    <Wrapper>
      <Create address={props.address} />
      <Flex>
        <PostList address={props.address} />
        <CheerList address={props.address} pubkey={props.pubkey} />
      </Flex>
    </Wrapper>
  )
}

export default OwnerPage

const Wrapper = styled('div')({
  display: 'flex',
  justifyContent: 'center',
  flexDirection: 'column',
  width: '80vw',
  margin: '10vw',
})
const Flex = styled('div')({
  display: 'flex',
  justifyContent: 'center',
  width: '100%',
})

ゲストページを作る

ゲストページでは、オーナーの投稿の一覧を確認と、応援メッセージを送ることができます。

こんな画面になります
image.png

本ページのコンポーネントは以下の2つです

  • PostList Component
  • Cheer Component

PostListはオーナーページと同じ物を使うため割愛します。

Cheer Component

Cheer.tsx
import { Button, TextField } from '@mui/material'
import styled from '@emotion/styled'
import { useEffect, useState } from 'react'
import {
  Address,
  Deadline,
  NetworkType,
  PlainMessage,
  SignedTransaction,
  TransactionHttp,
  TransferTransaction,
  UInt64,
} from 'symbol-sdk'

const EPOCH = 1637848847
const NODE_URL = 'https://sym-test.opening-line.jp:3001'
const NET_TYPE = NetworkType.TEST_NET

type Props = {
  address: string
}

interface SSSWindow extends Window {
  SSS: any
}
declare const window: SSSWindow

function Cheer(props: Props) {
  const [hash, setHash] = useState('')
  const [cheer, setCheer] = useState('')
  const [isRequest, setIsRequest] = useState<boolean>(false)
  const address = Address.createFromRawAddress(props.address)

  useEffect(() => {
    if (isRequest) {
      window.SSS.requestSign().then((signedTx: SignedTransaction) => {
        new TransactionHttp(NODE_URL).announce(signedTx)
      })
    }
  }, [isRequest])

  const submit = () => {
    const message = `::CHEER|${hash}::${cheer}`
    const tx = TransferTransaction.create(
      Deadline.create(EPOCH),
      address,
      [],
      PlainMessage.create(message),
      NET_TYPE,
      UInt64.fromUint(2000000)
    )
    window.SSS.setTransaction(tx)

    setIsRequest(true)
  }

  return (
    <Wrapper>
      <TextField
        label="hash"
        value={hash}
        fullWidth
        onChange={(e) => setHash(e.target.value)}
      />
      <TextField
        label="cheer"
        value={cheer}
        fullWidth
        onChange={(e) => setCheer(e.target.value)}
      />
      <Button onClick={submit}>ボタン</Button>
    </Wrapper>
  )
}

export default Cheer

const Wrapper = styled('div')({
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'flex-end',
  flexDirection: 'column',
})

応援メッセージを送るトランザクションです。

入力された、ハッシュと応援メッセージを転送トランザクションのメッセージに設定しています。

  const submit = () => {
    const message = `::CHEER|${hash}::${cheer}`
    const tx = TransferTransaction.create(
      Deadline.create(EPOCH),
      address,
      [],
      PlainMessage.create(message),
      NET_TYPE,
      UInt64.fromUint(2000000)
    )
    window.SSS.setTransaction(tx)
    setIsRequest(true)
  }

GuestPage Component

OwnerPage Componentと同様で並べてるだけですね

OwnerPage.tsx
import styled from '@emotion/styled'
import { Address } from 'symbol-sdk'
import Cheer from './Cheer'
import PostList from './PostList'

type Props = {
  address: string
}
const OWNER_ADDR = Address.createFromRawAddress(
  'TDHLRYXKIT4QOEEL3PRBP4PWLJ6NWU3LSGB56BY'
)

function GuestPage(props: Props) {
  return (
    <Wrapper>
      <h1>Hello Symbol {props.address}</h1>
      <Cheer address={OWNER_ADDR.plain()} />
      <PostList address={OWNER_ADDR.plain()} />
    </Wrapper>
  )
}

export default GuestPage

const Wrapper = styled('div')({
  display: 'flex',
  justifyContent: 'center',
  flexDirection: 'column',
  width: '80vw',
  margin: '10vw',
})

ユーザーによってページを変える

activeAddressとactivePublicKeyを取得してコンポーネントに流すだけですね

App.tsx
import React, { useEffect, useState } from 'react'
import { Address } from 'symbol-sdk'
import './App.css'
import GuestPage from './GuestPage'
import OwnerPage from './OwnerPage'

interface SSSWindow extends Window {
  SSS: any
}
declare const window: SSSWindow

const OWNER_ADDR = Address.createFromRawAddress(
  'TDHLRYXKIT4QOEEL3PRBP4PWLJ6NWU3LSGB56BY'
)

function App() {
  const [addr, setAddr] = useState<Address | null>(null)
  const [pubkey, setPubkey] = useState<string>('')

  console.log('render')

  useEffect(() => {
    console.log('hello')
    setTimeout(() => {
      const activeAddress = window.SSS.activeAddress
      const activePublicKey = window.SSS.activePublicKey
      setAddr(Address.createFromRawAddress(activeAddress))
      setPubkey(activePublicKey)
    }, 500)
  }, [])

  if (addr === null) {
    return (
      <div>
        <h1>Hello Symbol not set address</h1>
      </div>
    )
  }

  if (OWNER_ADDR.plain() === addr.plain()) {
    return <OwnerPage address={addr.plain()} pubkey={pubkey} />
  }
  return <GuestPage address={addr.plain()} />
}

export default App

dAppsを進化させる

ここまできたら、あとはあなたのアイデア次第でブログとコメントにもなりますし、ツイートとリプライにもなりますし、Todoと投げ銭にもなります。あしらいの工夫次第ですね。
最後に進化させるためのアイデアを共有して記事を終わりたいと思います。

  • URLにアドレスを入れるとそのユーザーのオーナーページになるようにする
  • 応援メッセージのハッシュを入力ではなく選択するできるようにする
  • オーナーページのUIで投稿と応援メッセージを紐付けるようにする
  • 投稿内容をリッチにして長いコンテンツに対応させる
    • トランザクションのメッセージは1024バイト程なのでアグリゲートトランザクションを使うことになると思う
  • 投稿の詳細を見れるページを作る
  • 投げ戦機能

お疲れさまでした〜〜〜

14
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?