6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

nem / symbolAdvent Calendar 2024

Day 12

Symbol SDK v3 で XYM Thread を作成してみた。

Last updated at Posted at 2024-12-11

はじめに

XYM Thread という 暗号通貨XYMで投稿するSNS を作成しました。

SNEMS という NEM(XEM)で投稿するSNS の

 SYMBOLバージョン となります。

Symbol SDK v3 を使って作成してみたので簡単に解説してみようと思います。


XYM Thread の使い方

環境

使用言語 : javascript
使用ブラウザ:Chrome

相棒 :ChatGPT 4o

ChatGPT君と相談しながら XYM Thread を作成しました。

Symbol SDK v3版 速習Symbol

だーりんピさんによる 速習Symbolの Symbol SDK v3版です。
v2と比較しながら解説されています。

今回はこちらを参考にさせていただきました。

v3って何がいいの?

ポイント1

各言語SDKの作成が容易

ポイント2

新たなプラグイントランザクションへの対応が容易

新しいプラグイントランザクションは誰でも作成可能で、さらにそれを開発者が容易に使うためのSDKが簡単に生成できるそうです。


将来的な汎用性がとてつもなく高い のが魅力との事👀✨


詳しくは、Toshi さんが解説されているこちらの記事をご覧になってみてください。


ChatGPT にv3の優れた点を聞いてみました。

スクリーンショット 2024-12-05 22.37.29.png


Symbolの次期バージョンと言われる(KASANE)とも互換性があるそうなので、今後はv3を使用していくのが良さそうです。


XYM Thread の全コードはこんな感じで構成されています。

index.html
index.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <title>XYM Thread (ジムスレッド)</title>
    <link rel="stylesheet" href="style.css">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="XYM Thread">
    <meta name="twitter:description" content="暗号通貨 XYM で投稿するSNS">
    <meta name="twitter:image" content="https://xym-thread.com/src/xymthread2.png">
</head>

<body>
    <div id="loading-spinner" style="display:none;">
        <div class="spinner"></div>
    </div>

    <header id="header">
        <img src="src/xymthread.png" alt="XYM Thread Banner">
    </header>

    <div class="hamburger-menu">
        <input type="checkbox" id="menu-toggle">
        <label for="menu-toggle" class="menu-icon">
            <span></span>
            <span></span>
            <span></span>
        </label>
        <nav class="menu">
            <ul>
                <li><a href="ToS.html" target="_blank">&nbsp;利用規約</a></li>
                <li><a href="https://note.com/mikun_nem/n/na87cc657591e" target="_blank">&nbsp;&nbsp;&nbsp;使い方</a></li>
                <li><a href="#" id="open-metadata-modal">&nbsp;SMD登録</a></li>
            </ul>
        </nav>
    </div>

    <div id="metadata-modal" class="social-metadata-modal">
        <div class="modal-content">
            <span class="close">&times;</span>
            <h2>ソーシャルメタデータ</h2>
            <div class="avatar-container">
                <img id="avatar-preview" src="" alt="Avatar" class="avatar" style="display:none;">
            </div>
            <label for="imageUrl">Image URL</label>
            <input type="text" id="imageUrl" placeholder="https://image.url">

            <label for="url">SNS URL</label>
            <input type="text" id="url" placeholder="https://x.com/yourprofile">

            <label for="namespace">ネームスペース (ある場合)</label>
            <input type="text" id="namespace" placeholder="namespace">

            <label for="name">名前</label>
            <input type="text" id="name" placeholder="name">

            <button id="submit-metadata">登録 / 更新 </button>
            <button id="cancel-metadata">キャンセル</button>
        </div>
    </div>

    <!-- スレッド作成ボタン -->
    <button id="open-modal" class="create-thread">スレッドを作成📝</button>

    <!-- モーダルのHTML -->
    <div id="modal" class="modal">
        <div class="modal-content">
            <span class="close">&times;</span>
            <h2>スレッドを作成</h2>
            <div id="thread_address"></div>
            <textarea id="thread-message" placeholder="メッセージを入力"></textarea>
            <input type="number" id="xym-amount" placeholder="送金するXYMを入力" step="0.000001" min="0">
            <button id="create-thread-button" class="create-thread">作成</button>
        </div>
    </div>
    <!-- スレッド一覧を表示する場所 -->
    <div id="thread-list"></div>

    <!-- スタイルの適用 -->
    <link rel="stylesheet" href="style.css">


    <!--  V2  <script type="text/javascript" src="https://xembook.github.io/nem2-browserify/symbol-sdk-pack-2.0.5.js"></script> -->
    <!-- <script type="text/javascript" src="https://bundle.run/buffer@6.0.3"></script> -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
    <script src="script.js"></script>
</body>

</html>

Topページです。

script.js
script.js
let scopedMetadataKey;
let sdkCore;
let sdkSymbol;
let NODE;
let epochAdjustment;
let facade;
let PublicAccount;

// 非表示リスト
const badThreadList = ['88A695491C7BDFCAF9857E02FB82A91C392C927F837B3259877E667BE21BEB96',
    '',
    '']; 

async function loadSDK() {
    const SDK_VERSION = "3.2.2";
    const sdk = await import(`https://www.unpkg.com/symbol-sdk@${SDK_VERSION}/dist/bundle.web.js`);
    sdkCore = sdk.core;
    sdkSymbol = sdk.symbol;

    NODE = 'https://symbol-mikun.net:3001'; // ノードURL

    // ネットワークプロパティを取得
    const networkProperties = await fetch(
        new URL('/network/properties', NODE),
        {
            method: 'GET',
            headers: { 'Content-Type': 'application/json' },
        }
    ).then((res) => res.json());

    const e = networkProperties.network.epochAdjustment;
    epochAdjustment = Number(e.substring(0, e.length - 1));
    const identifier = networkProperties.network.identifier;
    console.log("identifier=", identifier);

    // facadeを作成
    facade = new sdkSymbol.SymbolFacade(identifier);

    scopedMetadataKey = sdkSymbol.metadataGenerateKey("social_meta_data");  // メタデータキー生成

    displayThreads(); // スレッドを表示

    document.getElementById('thread_address').innerHTML = `<span style="font-size: 13px;">メッセージとXYMの送金が完了するとスレッドが作成されます。<br><p style="color:blue;">NB2TFCNBOXNG6FU2JZ7IA3SLYOYZ24BBZAUPAOA</p>メッセージはブロックチェーンに刻まれますので半永久的に残ります。</span>`; // スレッドモーダルにアドレス表示

}

loadSDK();  // 非同期関数を呼び出す


// URLパラメータからpubkeyを取得
const urlParams = new URLSearchParams(window.location.search);
let pubkey = urlParams.get('pubkey');


// スレッド作成モーダルを開く
document.getElementById('open-modal').addEventListener('click', function () {
    document.getElementById('modal').style.display = 'flex'; // flexで中央配置
});

// メタデータモーダルを開く
document.getElementById('open-metadata-modal').addEventListener('click', function () {
    document.getElementById('metadata-modal').style.display = 'flex'; // flexで中央配置
});

// モーダルを閉じる処理
document.querySelectorAll('.close').forEach(closeButton => {
    closeButton.addEventListener('click', function () {
        // メタデータモーダルも対象にする
        const parentModal = closeButton.closest('.modal, .social-metadata-modal');
        if (parentModal) {
            parentModal.style.display = 'none';
        }
    });
});

// モーダル外をクリックした場合も閉じる
window.addEventListener('click', function (event) {
    const modals = document.querySelectorAll('.modal, .social-metadata-modal');
    modals.forEach(modal => {
        if (event.target == modal) {
            modal.style.display = 'none';
        }
    });
});

// 作成ボタンを押すとaLiceでトランザクションを送信
document.getElementById('create-thread-button').addEventListener('click', createThread);


// マウスオーバーでアイコンの右にアドレスを表示する

// 動的に生成された要素に対してイベントリスナーを追加する
function addHoverEffect() {

    if (window.innerWidth >= 768) {
        // デスクトップ環境
        document.querySelectorAll('.avatar-container_2').forEach(container => {
            const tooltip = container.querySelector('.address-tooltip');

            // マウスオーバーでアドレスを表示
            container.addEventListener('mouseenter', () => {
                tooltip.style.display = 'block';
            });

            // マウスが離れたらアドレスを非表示
            container.addEventListener('mouseleave', () => {
                tooltip.style.display = 'none';
            });
        });

    } else {
        // モバイル環境
        document.querySelectorAll('.avatar-container_2').forEach(container => {
            let tapped = false;
            const tooltip = container.querySelector('.address-tooltip');

            // タップでアドレスを表示
            container.addEventListener('click', function (e) {
                e.preventDefault(); // デフォルトのリンク動作を無効化
                if (!tapped) {
                    // 最初のタップでアドレス表示
                    tooltip.style.display = 'block';
                    tapped = true;
                } else {
                    // 2回目のタップでリンク先へ移動
                    window.location.href = this.querySelector('a').href;
                    tapped = false;
                }
            });

            // 別の場所をタップしたときにアドレスを非表示にする
            document.addEventListener('click', function (event) {
                if (!container.contains(event.target)) {
                    tooltip.style.display = 'none';
                    tapped = false;
                }
            });
        });
    }

}


// ハンバーガーメニューのリンクをクリックした際にモーダルを開く
document.getElementById('open-metadata-modal').addEventListener('click', async () => {
    document.getElementById('metadata-modal').style.display = 'flex';

    let accountAddress;
    if (pubkey) {
        PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(pubkey));  // アカウントのアドレス
        accountAddress = PublicAccount.address;
    } else {
        if (window.SSS && window.SSS.activePublicKey) {
            PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(window.SSS.activePublicKey));
            accountAddress = PublicAccount.address;
        }
        if (window.innerWidth <= 768) {
            // モバイル環境の場合、aLiceで公開鍵を取得する
            const arrayBuffer = new TextEncoder().encode('https://xym-thread.com/index.html');
            const callback = Array.from(new Uint8Array(arrayBuffer), byte => byte.toString(16).padStart(2, '0')).join('').toUpperCase();
            const encodedUrl = `alice://sign?type=request_pubkey&callback=${callback}`;
            window.location.href = encodedUrl;
        }
    }

    // メタデータを取得してフォームに反映
    async function fetchAndApplyMetadata(accountAddress, scopedMetadataKey) {
        try {
            // メタデータを取得するためのクエリパラメータを設定
            const params = new URLSearchParams({
                targetAddress: accountAddress.toString(),  // アカウントアドレス
                scopedMetadataKey: scopedMetadataKey.toString(16).toUpperCase(),  // メタデータキー
                metadataType: 0,  // 0 = アカウントメタデータ
                "pageSize": 100, // 1ページあたりの最大取得数
            });

            // メタデータ取得エンドポイントを呼び出し
            const response = await fetch(new URL(`/metadata?${params.toString()}`, NODE), {
                method: 'GET',
                headers: { 'Content-Type': 'application/json' }
            });

            const metadataEntries = await response.json();
            const metadataArray = metadataEntries.data;

            if (metadataArray.length > 0) {

                metadataArray.forEach(entry => {
                    if (entry.metadataEntry.scopedMetadataKey === scopedMetadataKey.toString(16).toUpperCase()) {
                        const rawValue = entry.metadataEntry.value;

                        try {
                            // メタデータの値をデコードし、JSONパース

                            // 16進数文字列からバイト配列に変換
                            const byteArray = hexToBytes(rawValue);

                            // バイト配列をデコード
                            const decodedValue = new TextDecoder().decode(byteArray);

                            const cleanedValue = decodedValue.replace(/\\\"/g, '"');  // バックスラッシュ解除
                            const parsedData = JSON.parse(cleanedValue);

                            // フォームにデータを反映
                            document.getElementById('imageUrl').value = parsedData.imageUrl || '';
                            document.getElementById('url').value = parsedData.url || '';
                            document.getElementById('namespace').value = parsedData.namespace || '';
                            document.getElementById('name').value = parsedData.name || '';

                            // アバター画像をプレビュー表示
                            if (parsedData.imageUrl) {
                                document.getElementById('avatar-preview').src = parsedData.imageUrl;
                                document.getElementById('avatar-preview').style.display = 'block';
                            }
                        } catch (err) {
                            console.error('JSONパースエラー:', err);
                        }
                    } else {
                        console.log("指定されたスコープメタデータキーのメタデータが見つかりませんでした。");
                    }
                })
            } else {
                console.log("メタデータが存在しません。");
            }
        } catch (error) {
            console.error("メタデータの取得に失敗しました:", error);
        }
    }

    // メタデータ取得を実行
    fetchAndApplyMetadata(accountAddress, scopedMetadataKey);

});

// メタデータ更新ボタンの処理
document.getElementById('submit-metadata').addEventListener('click', async () => {
    const imageUrl = document.getElementById('imageUrl').value;
    const url = document.getElementById('url').value;
    const namespace = document.getElementById('namespace').value;
    const name = document.getElementById('name').value;

    const metadataValue = JSON.stringify({ imageUrl, url, namespace, name });

    // キーと値の設定
    const key = scopedMetadataKey;
    const value = new TextEncoder().encode(metadataValue);

    // アカウントアドレスの取得
    let accountAddress;
    if (window.SSS && window.SSS.activePublicKey) {
        PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(window.SSS.activePublicKey));  // アカウントのアドレス
        accountAddress = PublicAccount.address;
        pubkey = window.SSS.activePublicKey;
    } else if (window.innerWidth <= 768) {
        // モバイル環境の場合、aLiceで公開鍵を取得してから処理を行う
        //      const publicKeyFromAlice = await getPublicKeyFromCallback(); // コールバックURLから公開鍵を取得する関数
        PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(pubkey));  // アカウントのアドレス
        accountAddress = PublicAccount.address;
    }

    // メタデータトランザクションのセット (V3対応)
    let tx, aggregateTx;
    try {
        // ターゲットと作成者アドレスの設定
        const targetAddress = accountAddress;  // メタデータ記録先アドレス
        const sourceAddress = accountAddress;  // メタデータ作成者アドレス

        // 同じキーのメタデータが登録されているか確認
        const query = new URLSearchParams({
            "targetAddress": targetAddress.toString(),
            "sourceAddress": sourceAddress.toString(),
            "scopedMetadataKey": key.toString(16).toUpperCase(),
            "metadataType": 0
        });
        const metadataInfo = await fetch(
            new URL(`/metadata?${query.toString()}`, NODE),
            {
                method: 'GET',
                headers: { 'Content-Type': 'application/json' },
            }
        ).then(res => res.json()).then(json => json.data);

        // 登録済の場合は差分データを作成する
        let sizeDelta = value.length;
        let updatedValue = value;
        if (metadataInfo.length > 0) {
            sizeDelta -= metadataInfo[0].metadataEntry.valueSize;
            updatedValue = sdkSymbol.metadataUpdateValue(
                sdkCore.utils.hexToUint8(metadataInfo[0].metadataEntry.value),
                value
            );
        }

        // アカウントメタデータ登録Tx作成
        const descriptor = new sdkSymbol.descriptors.AccountMetadataTransactionV1Descriptor(
            targetAddress,  // ターゲットアドレス
            key,            // キー
            sizeDelta,      // サイズ差分
            updatedValue    // 値
        );

        // 埋め込みトランザクション作成
        tx = facade.createEmbeddedTransactionFromTypedDescriptor(
            descriptor,       // トランザクション Descriptor 設定
            pubkey            // 署名者公開鍵
        );

        const embeddedTransactions = [tx];

        // アグリゲートTx作成
        const aggregateDescriptor = new sdkSymbol.descriptors.AggregateCompleteTransactionV2Descriptor(
            facade.constructor.hashEmbeddedTransactions(embeddedTransactions),
            embeddedTransactions
        );

        aggregateTx = facade.createTransactionFromTypedDescriptor(
            aggregateDescriptor,  // トランザクション Descriptor 設定
            pubkey,               // 署名者公開鍵
            100,                  // 手数料乗数
            60 * 60 * 2,          // Deadline:有効期限(秒単位)
            0                     // 連署者数
        );

        console.log("aggregateTx=", aggregateTx);

    } catch (error) {
        console.error("トランザクションのセットに失敗しました:", error);
    }


    // ウィンドウ幅でaLice / SSSを切り替える
    if (window.innerWidth <= 768) {
        // モバイル環境の場合はaLiceを使用
        const transactionPayload = sdkCore.utils.uint8ToHex(aggregateTx.serialize());  // V3
        const aliceUrl = `alice://sign?data=${transactionPayload}&type=request_sign_transaction&node=${utf8ToHex(NODE)}&method=announce`;
        window.location.href = aliceUrl;
    } else {
        // デスクトップ環境の場合はSSSを使用
        const payload = sdkCore.utils.uint8ToHex(aggregateTx.serialize());
        window.SSS.setTransactionByPayload(payload);
        window.SSS.requestSign().then(signedPayload => {   // SSSを用いた署名をユーザーに要求
            console.log('signedPayload', signedPayload);
            jsonPayload = `{"payload": "${signedPayload.payload}"}`
            // SSSで署名されたトランザクションの送信
            fetch(`${NODE}/transactions`, {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: jsonPayload
            })
                .then(response => {
                    if (response.ok) {
                        console.log('トランザクションが送信されました!');
                        Swal.fire({
                            title: 'Success!',
                            text: 'アナウンスが送信されました!',
                            icon: 'success',
                            confirmButtonText: 'OK'
                        });
                    } else {
                        console.error('トランザクションの送信に失敗しました:', response);
                    }
                })
                .catch(error => {
                    console.error('エラー:', error);
                });
        }).catch((error) => {
            console.error('署名のエラー:', error);
        })
    }
});

// キャンセルボタンを押したときにソーシャルメタデータモーダルを閉じる
document.getElementById('cancel-metadata').addEventListener('click', function () {
    document.getElementById('metadata-modal').style.display = 'none';
});


// モバイル環境で公開鍵を取得するためのコールバック処理
function getPublicKeyFromCallback() {
    // コールバックURLで公開鍵を受け取る処理
    const urlParams = new URLSearchParams(window.location.search);
    const publicKey = urlParams.get('publicKey');
    return publicKey;
}


function createThread() {

    const message = document.getElementById('thread-message').value;
    const amount = document.getElementById('xym-amount').value;

    if (!message || amount < 0.000001) {
        Swal.fire('メッセージと送金するXYMを確認してください');
        return;
    }

    if (byteLengthUTF8(message) > 1023) {
        Swal.fire(`メッセージのサイズが${bytelength(message)}バイトです!!          
                   1023バイト 以下にしてください。`);
        return;
    }

    // スレッドメッセージをSymbolのトランザクションとして送信
    sendThreadTransaction(message, amount);
}

// スレッドメッセージとXYMをSymbolブロックチェーンに送信する
function sendThreadTransaction(message, amount) {

    const recipientAddress = new sdkSymbol.Address("NB2TFCNBOXNG6FU2JZ7IA3SLYOYZ24BBZAUPAOA");
    const plainMessage = new Uint8Array([0x00, ...new TextEncoder().encode(message)]);

    // トランザクション Descriptor 設定
    descriptor = new sdkSymbol.descriptors.TransferTransactionV1Descriptor(  // Txタイプ:転送Tx
        recipientAddress,      // 受取アドレス
        [
            //// XYM送金
            new sdkSymbol.descriptors.UnresolvedMosaicDescriptor(
                new sdkSymbol.models.UnresolvedMosaicId(0x6BED913FA20223F8n),
                new sdkSymbol.models.Amount(BigInt(amount * Math.pow(10, 6)))
            )
        ],
        plainMessage       // メッセージ
    );

    if (window.innerWidth >= 768) {   // モバイルではない場合、SSSから公開鍵を取得
        if (window.SSS.activePublicKey) {
            pubkey = window.SSS.activePublicKey;
        } else {
            Swal.fire({
                title: 'Error!!',
                text: 'SSSとリンクしていません!',
                icon: 'error',
                confirmButtonText: 'OK'
            });
        }
    } else {  // モバイルの場合
        pubkey = new sdkCore.PublicKey('0000000000000000000000000000000000000000000000000000000000000000'); // 公開鍵は aLice で設定する
    }

    tx = facade.createTransactionFromTypedDescriptor(
        descriptor,       // トランザクション Descriptor 設定
        pubkey,  // 署名者公開鍵
        100,              // 手数料乗数
        60 * 60 * 2       // Deadline:有効期限(秒単位)
    );


    if (window.innerWidth <= 768) {  // ウィンドウサイズで aLice / SSS を切り替える。
        const transactionPayload = sdkCore.utils.uint8ToHex(tx.serialize());  // V3
        const aliceUrl = `alice://sign?data=${transactionPayload}&type=request_sign_transaction&node=${utf8ToHex(NODE)}&method=announce`; //&deadline=3600&callback=${callback}`;
        window.location.href = aliceUrl;
    } else {
        const payload = sdkCore.utils.uint8ToHex(tx.serialize());

        window.SSS.setTransactionByPayload(payload);
        window.SSS.requestSign().then(signedPayload => {   // SSSを用いた署名をユーザーに要求
            console.log('signedPayload', signedPayload);
            jsonPayload = `{"payload": "${signedPayload.payload}"}`
            // SSSで署名されたトランザクションの送信
            fetch(`${NODE}/transactions`, {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: jsonPayload
            })
                .then(response => {
                    if (response.ok) {
                        console.log('トランザクションが送信されました!');
                        Swal.fire({
                            title: 'Success!',
                            text: 'アナウンスが送信されました!',
                            icon: 'success',
                            confirmButtonText: 'OK'
                        });
                    } else {
                        console.error('トランザクションの送信に失敗しました:', response);
                    }
                })
                .catch(error => {
                    console.error('エラー:', error);
                });
        }).catch((error) => {
            console.error('署名のエラー:', error);
        })
    }
}

// メタデータを取得し、特定のキーが存在する場合にアイコンを設定
function fetchMetadataAndSetIcon(publicKey, threadElement) {

    PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(publicKey));  // アカウントのアドレス
    accountAddress = PublicAccount.address;

    // メタデータの取得 (V3)
    async function fetchMetadataV3(address, scopedMetadataKey, threadElement, publicKey) {
        try {
            // クエリパラメータの設定
            const params = new URLSearchParams({
                targetAddress: address.toString(),  // アカウントアドレス
                scopedMetadataKey: scopedMetadataKey.toString(16).toUpperCase(),  // スコープメタデータキー
                metadataType: 0,  // アカウントメタデータ (0)
                "pageSize": 100,           // 必要に応じて調整
                order: "desc"            // 降順で取得
            });

            // メタデータエンドポイントに対してGETリクエストを送信
            const response = await fetch(new URL(`/metadata?${params.toString()}`, NODE), {
                method: 'GET',
                headers: { 'Content-Type': 'application/json' }
            });

            const metadataEntries = await response.json();
            const metadataArray = metadataEntries.data;

            // メタデータエントリの中から対象のスコープキーを検索
            if (metadataArray.length > 0) {

                metadataArray.forEach(entry => {

                    if (entry.metadataEntry.scopedMetadataKey === scopedMetadataKey.toString(16).toUpperCase()) {
                        // メタデータが存在する場合、JSONデータを解析

                        const rawValue = entry.metadataEntry.value;

                        // 16進数文字列からバイト配列に変換
                        const byteArray = hexToBytes(rawValue);

                        // バイト配列をデコード
                        const decodedValue = new TextDecoder().decode(byteArray);

                        // エスケープ解除とJSONパース
                        // const decodedValue = new TextDecoder().decode(Uint8Array.from(rawValue));
                        const cleanedValue = decodedValue.replace(/\\\"/g, '"');  // バックスラッシュでエスケープされたダブルクォーテーションを解除
                        const metadataJson = JSON.parse(cleanedValue);

                        // アイコン画像とリンクを設定
                        const imageUrl = metadataJson.imageUrl;
                        const url = metadataJson.url;

                        if (imageUrl && url) {
                            // アバター画像とリンクを設定
                            threadElement.querySelector('.avatar').outerHTML = `
                        <a href="${url}" target="_blank">
                            <img src="${imageUrl}" alt="Avatar" class="avatar" style="cursor:pointer; width:50px; height:50px; border-radius:50%;">
                        </a>
                    `;
                        }
                    } else {
                        // メタデータが存在しない場合、ランダムな画像を設定
                        threadElement.querySelector('.avatar').outerHTML = `
                    <img src="${getRandomImage(publicKey)}" alt="Avatar" class="avatar" style="cursor:pointer; width:50px; height:50px; border-radius:50%;">
                `;
                    }
                })
            } else {
                // メタデータが存在しない場合、ランダムな画像を設定
                threadElement.querySelector('.avatar').outerHTML = `
                <img src="${getRandomImage(publicKey)}" alt="Avatar" class="avatar" style="cursor:pointer; width:50px; height:50px; border-radius:50%;">
            `;

            }

        } catch (err) {
            console.error('メタデータの取得エラー:', err);
        }
    }

    // メタデータ取得を実行
    fetchMetadataV3(accountAddress, scopedMetadataKey, threadElement, publicKey);

}

function displayThreads() {
    document.getElementById('loading-spinner').style.display = 'block'; // スピナーを表示
    const recipientAddress = new sdkSymbol.Address('NB2TFCNBOXNG6FU2JZ7IA3SLYOYZ24BBZAUPAOA');
    console.log("recipientAddress=", recipientAddress.toString());
    let pageNumber = 1;
    const threadContainer = document.getElementById('thread-list');
    threadContainer.innerHTML = ''; // コンテナをクリア

    async function fetchTransactions(pageNumber) {
        const params = new URLSearchParams({
            "address": recipientAddress.toString(),  // 対象のアドレスを指定
            "embedded": true,
            "pageSize": 100, // 1ページあたりの最大取得数
            "pageNumber": pageNumber, // 現在のページ
            "order": "desc" // 降順で取得
        });
        const result = await fetch(
            new URL('/transactions/confirmed?' + params.toString(), NODE),
            {
                method: 'GET',
                headers: { 'Content-Type': 'application/json' },
            }
        ).then((res) => res.json())
            .catch(err => {
                console.error('トランザクション取得エラー:', err);
                document.getElementById('loading-spinner').style.display = 'none'; // エラー時もスピナーを非表示
            });

        if (result && result.data.length === 0) {
            document.getElementById('loading-spinner').style.display = 'none'; // データがない場合は終了
            return;
        }

        const txes = result.data;

        txes.forEach(tx => {
            // 非表示対象かどうかを確認
            if (badThreadList.includes(tx.meta.hash)) {
                return;
            }
            if (tx.transaction.message) {
                const threadHash = tx.meta.hash;
                const threadOwner = tx.transaction.signerPublicKey;
                const threadTimestamp = epochAdjustment + (tx.meta.timestamp / 1000);

                PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(threadOwner));

                const threadElement = document.createElement('div');
                threadElement.className = 'thread-item';
                threadElement.setAttribute('data-hash', threadHash);

                const timestamp = epochAdjustment + (tx.meta.timestamp / 1000);
                const date = new Date(timestamp * 1000);

                const yyyy = `${date.getFullYear()}`;
                const MM = `0${date.getMonth() + 1}`.slice(-2);
                const dd = `0${date.getDate()}`.slice(-2);
                const HH = `0${date.getHours()}`.slice(-2);
                const mm = `0${date.getMinutes()}`.slice(-2);
                const ss = `0${date.getSeconds()}`.slice(-2);

                const ymdhms = `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`;

                const xymAmount = tx.transaction.mosaics[0].amount / Math.pow(10, 6);
                const fontSize = Math.min(10 + xymAmount * 10, 40);

                const linkedMessage = convertURLsToLinks(hexToUtf8(tx.transaction.message));

                threadElement.innerHTML = `
                        <div class="thread-header">
                        <div class="avatar-container_2" style="position:relative;">
                            <div class="avatar"></div>
                            <div class="address-tooltip" style="display:none; position:absolute; top:50%; left:-10%; transform:translate(0%, 100%); background-color:#333; color:#fff; padding:5px; border-radius:5px; white-space:nowrap; font-size:12px; z-index:10;">
                                 ${PublicAccount.address.toString()}
                            </div>
                        </div>
                            <div class="thread-info">
                                <div class="thread-date">${ymdhms}</div>
                                <div class="thread-amount"><img src="src/fontsize2.png" alt="fontsize" class="fontsize-icon"> ${xymAmount} XYM</div>
                            </div>
                        </div>
                        <div class="thread-message" style="font-size: ${fontSize}px;">
                            ${linkedMessage}
                        </div>
                        <br>
                        <div class="thread-footer">
                          <a class="x-share-button_th" data-url="${window.location.origin}/thread.html?id=${tx.meta.hash}">
                            <img src="src/x.png" alt="x" class="x-icon_th">
                          </a>
                          <button class="open-thread-icon-button" data-hash="${tx.meta.hash}">
                            <img src="src/thread.png" alt="open" class="open-icon">
                          </button>
                        </div>
                    `;

                threadContainer.appendChild(threadElement);

                fetchMetadataAndSetIcon(tx.transaction.signerPublicKey, threadElement);

                addHoverEffect();  // リスナーを再設定

                // イベントリスナーを一度だけ登録
                document.querySelectorAll('.open-thread-icon-button').forEach(button => {
                    button.removeEventListener('click', openThread);
                    button.addEventListener('click', openThread);
                });

                document.querySelectorAll('.x-share-button_th').forEach(button => {
                    button.removeEventListener('click', handleShareClick);
                    button.addEventListener('click', handleShareClick);
                });

                // コメント数の取得
                fetchThreadCommentsV3(threadHash, threadOwner, threadTimestamp);

            }
        });

        // 次のページのトランザクションを取得
        pageNumber++;
        fetchTransactions(pageNumber)
    }

    // 最初のページの取得を開始
    fetchTransactions(pageNumber)
}

function getRandomImage(publicKey) {
    const hash = CryptoJS.SHA256(publicKey).toString();
    const index = parseInt(hash.slice(0, 8), 16) % 16 + 1; // ランダムに1-16の範囲の数値を生成
    return `https://xym-thread.com/avatar/${index}.png`; // ランダムな画像URLを返す
}

function byteLengthUTF8(s) {
    return new TextEncoder().encode(s).length;
}

function convertURLsToLinks(text) {
    const urlRegex = /(https?:\/\/[^\s]+)/g;
    return text.replace(urlRegex, '<br><br><a href="$1" target="_blank">➡️詳細はこちらから</a>');
}


async function fetchThreadCommentsV3(threadHash, threadOwner, threadTimestamp) {
    let commentCount = 0;
    let totalXYM = 0;
    let pageNumber = 1;

    PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(threadOwner));
    const recipientAddress = PublicAccount.address;

    // 次のページのデータを取得する関数
    async function fetchNextPage(pageNumber) {
        const params = new URLSearchParams({
            "address": recipientAddress.toString(),
            "pageSize": 100,
            "pageNumber": pageNumber,
            "order": "desc"
        });

        // トランザクションを取得するエンドポイントへのリクエスト
        const response = await fetch(
            new URL(`/transactions/confirmed?${params.toString()}`, NODE), {
            method: 'GET',
            headers: { 'Content-Type': 'application/json' }
        });

        const transactions = await response.json();

        if (transactions.data.length === 0) {
            updateCommentCountDisplay(threadHash, commentCount, totalXYM); // コメント数と合計XYMの表示更新
            return;
        }

        let shouldContinue = true; // 処理を続けるかどうかを判断するフラグ
        // let decodedMessage;
        for (const tx of transactions.data) {
            const transactionTimestamp = epochAdjustment + (tx.meta.timestamp / 1000);

            // スレッドのタイムスタンプ以前のトランザクションは処理しない
            if (Number(transactionTimestamp) <= Number(threadTimestamp)) {
                shouldContinue = false; // タイムスタンプに到達したら処理を終了
                break;
            }

            //console.log("tx=",tx);
            // コメントのカウント処理
            if (tx.transaction.message) {
                const decodedMessage = hexToUtf8(tx.transaction.message);
                if (decodedMessage.substring(2, 7) === threadHash.substring(0, 5)) { // デコードした先頭にヌル文字が入っているので substring(2, 7) としている
                    commentCount++;
                    // 送金されたXYMの量を加算
                    const mosaicAmount = tx.transaction.mosaics[0].amount;
                    const xymAmount = parseInt(mosaicAmount) / Math.pow(10, 6); // XYM量を計算
                    totalXYM += xymAmount;
                }
            }

        }

        // 次のページを取得するかどうかを判断
        if (shouldContinue) {
            pageNumber++;
            await fetchNextPage(pageNumber); // 次のページを取得
        } else {
            updateCommentCountDisplay(threadHash, commentCount, totalXYM); // コメント数と合計XYMを更新
        }
    }

    // 最初のページの取得を開始
    await fetchNextPage(pageNumber);
} 

function updateCommentCountDisplay(threadHash, commentCount, totalXYM) {
    const threadElement = document.querySelector(`.thread-item[data-hash="${threadHash}"]`);
    if (threadElement) {
        // .thread-footerを作成して、info-displayとボタンをまとめる
        const threadFooter = threadElement.querySelector('.thread-footer') || document.createElement('div');
        threadFooter.className = 'thread-footer';

        // info-displayのコンテナを作成
        const infoContainer = document.createElement('div');
        infoContainer.className = 'info-display';
        infoContainer.innerHTML = `  
            <img src="src/xym.png" alt="XYMアイコン" class="xym-icon">
            <span class="total-xym">${totalXYM.toLocaleString()}</span>
            <img src="src/comment.png" alt="コメントアイコン" class="comment-icon">
            <span class="comment-count">${commentCount}</span>
        `;

        // スレッドを開くボタンの作成
        const openThreadButton = document.createElement('button');
        openThreadButton.className = 'open-thread-icon-button';
        openThreadButton.setAttribute('data-hash', threadHash);
        openThreadButton.innerHTML = `<img src="src/thread.png" alt="open" class="open-icon">`;

        // info-displayとスレッドを開くボタンをthread-footerに追加
        if (!threadFooter.querySelector('.info-display')) {
            threadFooter.appendChild(infoContainer);
        }
        if (!threadFooter.querySelector('.open-thread-icon-button')) {
            threadFooter.appendChild(openThreadButton);
        }

        // thread-footerをスレッドの最後に追加
        if (!threadElement.querySelector('.thread-footer')) {
            threadElement.appendChild(threadFooter);
        }
    }
}


function hexToUtf8(hex) {
    // 16進数の文字列をバイト配列に変換
    const bytes = [];
    for (let i = 0; i < hex.length; i += 2) {
        bytes.push(parseInt(hex.substr(i, 2), 16));
    }

    // バイト配列をUTF-8文字列に変換
    const utf8String = new TextDecoder('utf-8').decode(new Uint8Array(bytes));
    return utf8String;
}

function hexToBytes(hex) {
    const bytes = [];
    for (let c = 0; c < hex.length; c += 2) {
        bytes.push(parseInt(hex.substr(c, 2), 16));
    }
    return new Uint8Array(bytes);
}

function utf8ToHex(str) {
    const encoder = new TextEncoder(); // UTF-8エンコーダ
    const bytes = encoder.encode(str); // UTF-8バイト配列に変換
    return Array.from(bytes)
        .map(byte => byte.toString(16).padStart(2, '0')) // 各バイトを16進数に変換
        .join(''); // 配列を文字列に結合
}

function handleShareClick(event) {
    event.preventDefault();
    const pageUrl = event.currentTarget.getAttribute('data-url');
    const message = `#Symbol #XYM_Thread ${pageUrl}`;
    const x_Url = `https://x.com/intent/post?text=${encodeURIComponent(message)}`;
    window.open(x_Url, '_blank');
}

function openThread(event) {
    const transactionHash = event.currentTarget.getAttribute('data-hash');
    window.location.href = `thread.html?id=${transactionHash}`;
}

スレッド一覧表示、スレッドの作成、SMD(ソーシャルメタデータ)設定など

thread.html
thread.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>スレッド詳細</title>
    <link rel="stylesheet" href="style.css">
    <meta name="twitter:card" content="summary">
    <meta name="twitter:title" content="XYM Thread">
    <meta name="twitter:description" content="暗号通貨 XYM で投稿するSNS">
    <meta name="twitter:image" content="https://xym-thread.com/src/xymthread_small.png">
</head>

<body>
    <div id="loading-spinner" style="display:none;">
        <div class="spinner"></div>
    </div>

    <a href="index.html" id="home-button">
        <img src="src/home.png" alt="ホーム" id="home-icon">
    </a>

    <a id="x-share-button">
        <img src="src/x.png" alt="x" id="x-icon">
    </a>
    
    <div id="thread-container"></div>

    <div id="comment-modal" class="modal">
        <div class="modal-content">
            <span class="close">&times;</span>
            <h2>コメントを追加</h2>
            <div id="thread_address"></div>
            <textarea id="comment-message" placeholder="コメントを入力"></textarea>
            <input type="number" id="xym-amount" placeholder="送金するXYMを入力" step="0.000001" min="0">
            <button id="submit-comment" class="create-thread">送信</button>
        </div>
    </div>
    <button id="comment-button" class="reply-comment">コメントする</button>

    <div id="thread-list"></div>

    <!--    <script type="text/javascript" src="https://xembook.github.io/nem2-browserify/symbol-sdk-pack-2.0.5.js"></script>  -->
    <!-- <script type="text/javascript" src="https://bundle.run/buffer@6.0.3"></script> -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>

    <script>

        let scopedMetadataKey;
        let sdkCore;
        let sdkSymbol;
        let NODE;
        let epochAdjustment;
        let facade;
        let PublicAccount;
        let pubkey;

        // 非表示リスト
        const badThreadList = ['88A695491C7BDFCAF9857E02FB82A91C392C927F837B3259877E667BE21BEB96',
            '',
            ''];


        async function loadSDK() {
            const SDK_VERSION = "3.2.2";
            const sdk = await import(`https://www.unpkg.com/symbol-sdk@${SDK_VERSION}/dist/bundle.web.js`);
            sdkCore = sdk.core;
            sdkSymbol = sdk.symbol;

            NODE = 'https://symbol-mikun.net:3001'; // ノードURL

            // ネットワークプロパティを取得
            const networkProperties = await fetch(
                new URL('/network/properties', NODE),
                {
                    method: 'GET',
                    headers: { 'Content-Type': 'application/json' },
                }
            ).then((res) => res.json());

            const e = networkProperties.network.epochAdjustment;
            epochAdjustment = Number(e.substring(0, e.length - 1));
            const identifier = networkProperties.network.identifier;
            console.log("identifier=", identifier);

            // facadeを作成
            facade = new sdkSymbol.SymbolFacade(identifier);

            scopedMetadataKey = sdkSymbol.metadataGenerateKey("social_meta_data");  // メタデータキー生成

            getTransactionDetails(transactionHash); // トップにスレッドのメッセージを表示

        }

        loadSDK();  // 非同期関数を呼び出す

        // URLからスレッドID(トランザクションハッシュ)を取得
        const urlParams = new URLSearchParams(window.location.search);
        const transactionHash = urlParams.get('id');
        const shortTag = `#${transactionHash.substring(0, 5)}`;

        // ページロード時にスレッドを表示する

        // トランザクションの詳細を取得してメッセージを表示
        async function getTransactionDetails(transactionHash) {
            try {
                // ふさわしくない投稿かどうかを確認
                if (badThreadList.includes(transactionHash)) {
                    document.getElementById('thread-container').innerHTML = `<h2>このメッセージは表示出来ません</h2>`;
                    return; // ふさわしくない投稿は表示しない
                }

                // トランザクションのエンドポイントにGETリクエストを送信
                const response = await fetch(`${NODE}/transactions/confirmed/${transactionHash}`, {
                    method: 'GET',
                    headers: {
                        'Content-Type': 'application/json'
                    }
                });

                // レスポンスをJSONとしてパース
                if (response.ok) {
                    const tx = await response.json();
                    console.log("tx=", tx);

                    const threadOwner = tx.transaction.signerPublicKey;
                    const threadTimestamp = epochAdjustment + (tx.meta.timestamp / 1000);

                    PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(threadOwner));

                    document.getElementById('thread_address').innerHTML = `<span style="font-size: 13px;">スレッド主のアカウントへXYMを送金します。<br><p style="color:blue;">${PublicAccount.address.toString()}</p></span>`; // コメントモーダルにアドレス表示

                    // メッセージのデコードとクリーンアップ
                    let message = cleanMessage(tx.transaction.message); // メッセージ先頭のヌル文字を削除

                    let thread_title;
                    if (message.startsWith("#")) {
                        // #タグがある場合、7文字目以降を取得
                        thread_title = message.slice(7).trim();
                    } else {
                        // #タグがない場合、そのまま使用
                        thread_title = message.trim();
                    }

                    const thread_title2 = convertURLsToLinks(thread_title); // URLがある場合のリンク適用

                    const threadElement = document.createElement('div');
                    threadElement.setAttribute('data-hash', transactionHash);

                    const date = new Date(threadTimestamp * 1000);
                    const ymdhms = `${date.getFullYear()}-${('0' + (date.getMonth() + 1)).slice(-2)}-${('0' + date.getDate()).slice(-2)} ${('0' + date.getHours()).slice(-2)}:${('0' + date.getMinutes()).slice(-2)}:${('0' + date.getSeconds()).slice(-2)}`;


                    // トップスレッドの内容を挿入
                    threadElement.innerHTML = `
                        <div class="thread-info">
                          <div class="thread-date">${ymdhms}</div>
                        </div>
                        <br>
                         <div style="text-align: center">
                          <div class="avatar-container_2" style="position:relative;">
                            <div class="avatar"></div>
                                <div class="address-tooltip" style="display:none; position:absolute; top:80px; left:50%; transform:translate(-50%, -50%); background-color:#333; color:#fff; padding:5px; border-radius:5px; white-space:nowrap; font-size:13px; z-index:10;">
                                        ${PublicAccount.address.toString()}
                                </div>
                            </div>
                          </div>
                         <h3>${thread_title2}</h3>

                         <div class="thread-footer"></div>
                         `;

                    // 追加する場所にthreadElementを挿入
                    document.getElementById('thread-container').appendChild(threadElement);

                    fetchMetadataAndSetIcon(tx.transaction.signerPublicKey, threadElement);

                    addHoverEffect();  // リスナーを再設定

                    // コメント数とXYM合計を表示
                    fetchThreadCommentsV3(transactionHash, threadOwner, threadTimestamp);


                    document.getElementById('submit-comment').addEventListener('click', function () {
                        const message = document.getElementById('comment-message').value;
                        const amount = document.getElementById('xym-amount').value;

                        if (!message || amount < 0.000001) {
                            Swal.fire('コメントと送金するXYMを確認してください');
                            return;
                        }

                        if (byteLengthUTF8(message) > 1023) {
                            Swal.fire(`メッセージのサイズが${bytelength(message)}バイトです!!          
                              1023バイト 以下にしてください。`);
                            return;
                        }

                        // コメントをSymbolブロックチェーンに送信する処理をここに追加
                        sendCommentTransaction(threadOwner, message, amount, shortTag);

                        // モーダルを閉じる
                        document.getElementById('comment-modal').style.display = 'none';
                    });

                    window.addEventListener('load', displayThreads(threadOwner, shortTag, threadTimestamp));

                } else {
                    console.error('トランザクションの取得に失敗しました:', response.status, response.statusText);
                }

            } catch (error) {
                console.error('エラーが発生しました:', error);
            }
        }


        // メタデータを取得し、特定のキーが存在する場合にアイコンを設定
        function fetchMetadataAndSetIcon(publicKey, threadElement) {

            PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(publicKey));  // アカウントのアドレス
            accountAddress = PublicAccount.address;

            // メタデータの取得 (V3)
            async function fetchMetadataV3(address, scopedMetadataKey, threadElement, publicKey) {
                try {
                    // クエリパラメータの設定
                    const params = new URLSearchParams({
                        targetAddress: address.toString(),  // アカウントアドレス
                        scopedMetadataKey: scopedMetadataKey.toString(16).toUpperCase(),  // スコープメタデータキー
                        metadataType: 0,  // アカウントメタデータ (0)
                        "pageSize": 100,           // 必要に応じて調整
                        order: "desc"            // 降順で取得
                    });

                    // メタデータエンドポイントに対してGETリクエストを送信
                    const response = await fetch(new URL(`/metadata?${params.toString()}`, NODE), {
                        method: 'GET',
                        headers: { 'Content-Type': 'application/json' }
                    });

                    const metadataEntries = await response.json();
                    const metadataArray = metadataEntries.data;

                    // メタデータエントリの中から対象のスコープキーを検索
                    if (metadataArray.length > 0) {

                        metadataArray.forEach(entry => {

                            if (entry.metadataEntry.scopedMetadataKey === scopedMetadataKey.toString(16).toUpperCase()) {
                                // メタデータが存在する場合、JSONデータを解析

                                const rawValue = entry.metadataEntry.value;

                                // 16進数文字列からバイト配列に変換
                                const byteArray = hexToBytes(rawValue);

                                // バイト配列をデコード
                                const decodedValue = new TextDecoder().decode(byteArray);

                                // エスケープ解除とJSONパース
                                // const decodedValue = new TextDecoder().decode(Uint8Array.from(rawValue));
                                const cleanedValue = decodedValue.replace(/\\\"/g, '"');  // バックスラッシュでエスケープされたダブルクォーテーションを解除
                                const metadataJson = JSON.parse(cleanedValue);

                                // アイコン画像とリンクを設定
                                const imageUrl = metadataJson.imageUrl;
                                const url = metadataJson.url;

                                if (imageUrl && url) {
                                    // アバター画像とリンクを設定
                                    threadElement.querySelector('.avatar').outerHTML = `
                    <a href="${url}" target="_blank">
                        <img src="${imageUrl}" alt="Avatar" class="avatar" style="cursor:pointer; width:50px; height:50px; border-radius:50%;">
                    </a>
                `;
                                }
                            } else {
                                // メタデータが存在しない場合、ランダムな画像を設定
                                threadElement.querySelector('.avatar').outerHTML = `
                <img src="${getRandomImage(publicKey)}" alt="Avatar" class="avatar" style="cursor:pointer; width:50px; height:50px; border-radius:50%;">
            `;
                            }
                        })
                    } else {
                        // メタデータが存在しない場合、ランダムな画像を設定
                        threadElement.querySelector('.avatar').outerHTML = `
            <img src="${getRandomImage(publicKey)}" alt="Avatar" class="avatar" style="cursor:pointer; width:50px; height:50px; border-radius:50%;">
        `;

                    }

                } catch (err) {
                    console.error('メタデータの取得エラー:', err);
                }
            }

            // メタデータ取得を実行
            fetchMetadataV3(accountAddress, scopedMetadataKey, threadElement, publicKey);

        }

        document.addEventListener('DOMContentLoaded', function () {
            // モーダルを開く
            document.getElementById('comment-button').addEventListener('click', function () {
                document.getElementById('comment-modal').style.display = 'flex';
            });

            // モーダルを閉じる
            document.querySelector('.close').addEventListener('click', function () {
                document.getElementById('comment-modal').style.display = 'none';
            });

            // モーダル外をクリックした場合も閉じる
            window.addEventListener('click', function (event) {
                if (event.target == document.getElementById('comment-modal')) {
                    document.getElementById('comment-modal').style.display = 'none';
                }
            });
        });

        function sendCommentTransaction(threadOwner, message, amount, shortTag) {

            const taggedMessage = `${shortTag} ${message}`;  // タグをメッセージの先頭に追加

            console.log("Message=", taggedMessage);
            PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(threadOwner));
            const recipientAddress = PublicAccount.address;
            const plainMessage = new Uint8Array([0x00, ...new TextEncoder().encode(taggedMessage)]);

            // トランザクション Descriptor 設定
            descriptor = new sdkSymbol.descriptors.TransferTransactionV1Descriptor(  // Txタイプ:転送Tx
                recipientAddress,      // 受取アドレス
                [
                    //// XYM送金
                    new sdkSymbol.descriptors.UnresolvedMosaicDescriptor(
                        new sdkSymbol.models.UnresolvedMosaicId(0x6BED913FA20223F8n),
                        new sdkSymbol.models.Amount(BigInt(amount * Math.pow(10, 6)))
                    )
                ],
                plainMessage       // メッセージ
            );

            if (window.innerWidth >= 768) {   // モバイルではない場合、SSSから公開鍵を取得
                if (window.SSS.activePublicKey) {
                    pubkey = window.SSS.activePublicKey;
                } else {
                    Swal.fire({
                        title: 'Error!!',
                        text: 'SSSとリンクしていません!',
                        icon: 'error',
                        confirmButtonText: 'OK'
                    });
                }
            } else {  // モバイルの場合
                pubkey = new sdkCore.PublicKey('0000000000000000000000000000000000000000000000000000000000000000'); // 公開鍵は aLice で設定する
            }

            tx = facade.createTransactionFromTypedDescriptor(
                descriptor,       // トランザクション Descriptor 設定
                pubkey,  // 署名者公開鍵
                100,              // 手数料乗数
                60 * 60 * 2       // Deadline:有効期限(秒単位)
            );

            if (window.innerWidth <= 768) {  // ウィンドウサイズで aLice / SSS を切り替える。
                const transactionPayload = sdkCore.utils.uint8ToHex(tx.serialize());  // V3
                const aliceUrl = `alice://sign?data=${transactionPayload}&type=request_sign_transaction&node=${utf8ToHex(NODE)}&method=announce`; //&deadline=3600&callback=${callback}`;
                window.location.href = aliceUrl;
            } else {
                const payload = sdkCore.utils.uint8ToHex(tx.serialize());

                window.SSS.setTransactionByPayload(payload);
                window.SSS.requestSign().then(signedPayload => {   // SSSを用いた署名をユーザーに要求
                    console.log('signedPayload', signedPayload);
                    jsonPayload = `{"payload": "${signedPayload.payload}"}`
                    // SSSで署名されたトランザクションの送信
                    fetch(`${NODE}/transactions`, {
                        method: 'PUT',
                        headers: { 'Content-Type': 'application/json' },
                        body: jsonPayload
                    })
                        .then(response => {
                            if (response.ok) {
                                console.log('トランザクションが送信されました!');
                                Swal.fire({
                                    title: 'Success!',
                                    text: 'アナウンスが送信されました!',
                                    icon: 'success',
                                    confirmButtonText: 'OK'
                                });
                            } else {
                                console.error('トランザクションの送信に失敗しました:', response);
                            }
                        })
                        .catch(error => {
                            console.error('エラー:', error);
                        });
                }).catch((error) => {
                    console.error('署名のエラー:', error);
                })
            }
        }


        function displayThreads(publicKey, shortTag, threadTimestamp) { // threadTimestampを引数に追加
            document.getElementById('loading-spinner').style.display = 'block';
            PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(publicKey));
            const recipientAddress = PublicAccount.address;
            let pageNumber = 1;
            let hasComments = false;

            const threadContainer = document.getElementById('thread-list');
            threadContainer.innerHTML = '';

            async function fetchTransactions(pageNumber) {
                const params = new URLSearchParams({
                    "address": recipientAddress.toString(),  // 対象のアドレスを指定
                    "embedded": true,
                    "pageSize": 100, // 1ページあたりの最大取得数
                    "pageNumber": pageNumber, // 現在のページ
                    "order": "desc" // 降順で取得
                });
                const result = await fetch(
                    new URL('/transactions/confirmed?' + params.toString(), NODE),
                    {
                        method: 'GET',
                        headers: { 'Content-Type': 'application/json' },
                    }
                ).then((res) => res.json())
                    .catch(err => {
                        console.error('トランザクション取得エラー:', err);
                        document.getElementById('loading-spinner').style.display = 'none'; // エラー時もスピナーを非表示
                    });

                if (result && result.data.length === 0) {
                    document.getElementById('loading-spinner').style.display = 'none'; // データがない場合は終了
                    if (!hasComments) {
                        const threadElement = document.createElement('div');
                        threadElement.className = 'thread-item';
                        threadElement.innerHTML = `
                            <div class="thread-message" style="font-size: 20px;">
                                コメントはまだありません
                            </div>
                        `;
                        threadContainer.appendChild(threadElement);
                    }
                    return;
                }

                const txes = result.data;

                txes.forEach(tx => {
                    // ふさわしくない投稿かどうかを確認
                    if (badThreadList.includes(tx.meta.hash)) {
                        return; // ふさわしくない投稿は表示しない
                    }

                    const transactionTimestamp = epochAdjustment + (tx.meta.timestamp / 1000);
                    // スレッドのタイムスタンプよりも新しいトランザクションのみを処理
                    if (Number(transactionTimestamp) > Number(threadTimestamp) && tx.transaction.message) {
                        const threadHash2 = tx.meta.hash;
                        const threadOwner2 = tx.transaction.signerPublicKey;
                        const threadTimestamp2 = epochAdjustment + (tx.meta.timestamp / 1000);

                        PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(threadOwner2));

                        const message = hexToUtf8(tx.transaction.message);

                        if (message.substring(1, 7) === shortTag) {
                            console.count("カウント");
                            hasComments = true;
                            const commentContent = message.replace(`${shortTag} `, '');
                            const linkedMessage = convertURLsToLinks(commentContent);
                            const threadElement = document.createElement('div');
                            threadElement.className = 'thread-item';
                            threadElement.setAttribute('data-hash', threadHash2);

                            const date = new Date(transactionTimestamp * 1000);
                            const ymdhms = `${date.getFullYear()}-${('0' + (date.getMonth() + 1)).slice(-2)}-${('0' + date.getDate()).slice(-2)} ${('0' + date.getHours()).slice(-2)}:${('0' + date.getMinutes()).slice(-2)}:${('0' + date.getSeconds()).slice(-2)}`;
                            const xymAmount = tx.transaction.mosaics[0].amount / Math.pow(10, 6);
                            const fontSize = Math.min(10 + xymAmount * 10, 40);

                            threadElement.innerHTML = `
                                                                    <div class="thread-header">
                                                                        <div class="avatar-container_2" style="position:relative;">
                                                                        <div class="avatar"></div>
                                                                            <div class="address-tooltip" style="display:none; position:absolute; top:50%; left:0%; transform:translate(0%, 100%); background-color:#333; color:#fff; padding:5px; border-radius:5px; white-space:nowrap; font-size:12px; z-index:10;">
                                                                             ${PublicAccount.address.toString()}
                                                                            </div>
                                                                        </div>
                
                                                                        <div class="thread-info">
                                                                            <div class="thread-date">${ymdhms}</div>
                                                                            <div class="thread-amount"><img src="src/fontsize2.png" alt="fontsize" class="fontsize-icon"> ${xymAmount} XYM</div>
                                                                        </div>
                                                                    </div>
                                                                    <div class="thread-message" style="font-size: ${fontSize}px;">
                                                                        ${linkedMessage}
                                                                    </div>
                                                                    <br>
                                                                    <!-- <button class="open-thread-button" data-hash="${tx.meta.hash}">スレッドを開く</button> -->
                                                                    <div class="thread-footer">
                                                                          <a class="x-share-button_th" data-url="${window.location.origin}/thread.html?id=${tx.meta.hash}">
                                                                            <img src="src/x.png" alt="x" class="x-icon_th">
                                                                          </a>
                                                                          <button class="open-thread-icon-button" data-hash="${tx.meta.hash}">
                                                                            <img src="src/thread.png" alt="open" class="open-icon">
                                                                          </button>
                                                                    </div>
                                                                `;

                            threadContainer.appendChild(threadElement);

                            fetchMetadataAndSetIcon(tx.transaction.signerPublicKey, threadElement);

                            addHoverEffect();  // リスナーを再設定

                            // イベントリスナーを一度だけ登録
                            document.querySelectorAll('.open-thread-icon-button').forEach(button => {
                                button.removeEventListener('click', openThread);
                                button.addEventListener('click', openThread);
                            });

                            document.querySelectorAll('.x-share-button_th').forEach(button => {
                                button.removeEventListener('click', handleShareClick);
                                button.addEventListener('click', handleShareClick);
                            });

                            //  コメント数の取得
                            fetchThreadCommentsV3(threadHash2, threadOwner2, threadTimestamp2);
                        }


                    } else {
                        document.getElementById('loading-spinner').style.display = 'none'; // スピナー非表示
                        return;
                    }
                });

                pageNumber++;
                fetchTransactions(pageNumber);

            }

            fetchTransactions(pageNumber);

        }


        function getRandomImage(publicKey) {   // アイコン画像を設定
            const hash = CryptoJS.SHA256(publicKey).toString();
            const index = parseInt(hash.slice(0, 8), 16) % 16 + 1; // ランダムに1-16の範囲の数値を生成
            return `https://xym-thread.com/avatar/${index}.png`; // ランダムな画像URLを返す
        }

        document.getElementById('x-share-button').addEventListener('click', function () {   // Xへのシェアボタンの動作設定
            const pageUrl = window.location.href;
            const message = `#Symbol #XYM_Thread ${pageUrl}`;  // タグを含めたメッセージ
            const x_Url = `https://x.com/intent/post?text=${encodeURIComponent(message)}`;
            window.open(x_Url);
        });

        function byteLengthUTF8(s) {
            return new TextEncoder().encode(s).length;
        }

        function convertURLsToLinks(text) {   // メッセージにリンクがある場合に 詳細はこちらから と表示
            const urlRegex = /(https?:\/\/[^\s]+)/g;
            return text.replace(urlRegex, '<br><br><a href="$1" target="_blank">➡️詳細はこちらから</a>');
        }

        async function fetchThreadCommentsV3(threadHash, threadOwner, threadTimestamp) {
            let commentCount = 0;
            let totalXYM = 0;
            let pageNumber = 1;

            PublicAccount = facade.createPublicAccount(new sdkCore.PublicKey(threadOwner));
            const recipientAddress = PublicAccount.address;

            // 次のページのデータを取得する関数
            async function fetchNextPage(pageNumber) {
                const params = new URLSearchParams({
                    "address": recipientAddress.toString(),
                    "pageSize": 100,
                    "pageNumber": pageNumber,
                    "order": "desc"
                });

                // トランザクションを取得するエンドポイントへのリクエスト
                const response = await fetch(
                    new URL(`/transactions/confirmed?${params.toString()}`, NODE), {
                    method: 'GET',
                    headers: { 'Content-Type': 'application/json' }
                });

                const transactions = await response.json();

                if (transactions.data.length === 0) {
                    updateCommentCountDisplay(threadHash, commentCount, totalXYM); // コメント数と合計XYMの表示更新
                    return;
                }

                let shouldContinue = true; // 処理を続けるかどうかを判断するフラグ
                // let decodedMessage;
                for (const tx of transactions.data) {
                    const transactionTimestamp = epochAdjustment + (tx.meta.timestamp / 1000);

                    // スレッドのタイムスタンプ以前のトランザクションは処理しない
                    if (Number(transactionTimestamp) <= Number(threadTimestamp)) {
                        shouldContinue = false; // タイムスタンプに到達したら処理を終了
                        break;
                    }

                    //console.log("tx=",tx);
                    // コメントのカウント処理
                    if (tx.transaction.message) {
                        const decodedMessage = hexToUtf8(tx.transaction.message);
                        if (decodedMessage.substring(2, 7) === threadHash.substring(0, 5)) { // デコードした先頭に空白の文字が入っているので substring(2, 7) としている
                            commentCount++;
                            // 送金されたXYMの量を加算
                            const mosaicAmount = tx.transaction.mosaics[0].amount;
                            const xymAmount = parseInt(mosaicAmount) / Math.pow(10, 6); // XYM量を計算
                            totalXYM += xymAmount;
                        }
                    }

                }

                // 次のページを取得するかどうかを判断
                if (shouldContinue) {
                    pageNumber++;
                    await fetchNextPage(pageNumber); // 次のページを取得
                } else {
                    updateCommentCountDisplay(threadHash, commentCount, totalXYM); // コメント数と合計XYMを更新
                }
            }

            // 最初のページの取得を開始
            await fetchNextPage(pageNumber);
        }


        // updateCommentCountDisplay 関数
        function updateCommentCountDisplay(threadHash, commentCount, totalXYM) {
            const threadElement = document.querySelector(`[data-hash="${threadHash}"]`);
            if (threadElement) {
                const threadFooter = threadElement.querySelector('.thread-footer') || document.createElement('div');
                threadFooter.className = 'thread-footer';

                const infoContainer = document.createElement('div');
                infoContainer.className = 'info-display';
                infoContainer.innerHTML = `  
            <img src="src/xym.png" alt="XYMアイコン" class="xym-icon">
            <span class="total-xym">${totalXYM.toLocaleString()}</span>
            <img src="src/comment.png" alt="コメントアイコン" class="comment-icon">
            <span class="comment-count">${commentCount}</span>
        `;

                if (!threadFooter.querySelector('.info-display')) {
                    threadFooter.appendChild(infoContainer);
                }

                // トップスレッドの場合は `open-thread-icon-button` を追加しない
                if (threadHash === transactionHash) {
                    // トップスレッドにはシェアボタンが既にあるため、新しいボタンは追加しない
                } else {
                    const openThreadButton = document.createElement('button');
                    openThreadButton.className = 'open-thread-icon-button';
                    openThreadButton.setAttribute('data-hash', threadHash);
                    openThreadButton.innerHTML = `<img src="src/thread.png" alt="open" class="open-icon">`;

                    if (!threadFooter.querySelector('.open-thread-icon-button')) {
                        threadFooter.appendChild(openThreadButton);
                    }
                }

                if (!threadElement.querySelector('.thread-footer')) {
                    threadElement.appendChild(threadFooter);
                }
            }
        }

        // 動的に生成された要素に対してイベントリスナーを追加する
        function addHoverEffect() {

            if (window.innerWidth >= 768) {
                // デスクトップ環境
                document.querySelectorAll('.avatar-container_2').forEach(container => {
                    const tooltip = container.querySelector('.address-tooltip');

                    // マウスオーバーでアドレスを表示
                    container.addEventListener('mouseenter', () => {
                        tooltip.style.display = 'block';
                    });

                    // マウスが離れたらアドレスを非表示
                    container.addEventListener('mouseleave', () => {
                        tooltip.style.display = 'none';
                    });
                });

            } else {
                // モバイル環境
                document.querySelectorAll('.avatar-container_2').forEach(container => {
                    let tapped = false;
                    const tooltip = container.querySelector('.address-tooltip');

                    // タップでアドレスを表示
                    container.addEventListener('click', function (e) {
                        e.preventDefault(); // デフォルトのリンク動作を無効化
                        if (!tapped) {
                            // 最初のタップでアドレス表示
                            tooltip.style.display = 'block';
                            tapped = true;
                        } else {
                            // 2回目のタップでリンク先へ移動
                            window.location.href = this.querySelector('a').href;
                            tapped = false;
                        }
                    });

                    // 別の場所をタップしたときにアドレスを非表示にする
                    document.addEventListener('click', function (event) {
                        if (!container.contains(event.target)) {
                            tooltip.style.display = 'none';
                            tapped = false;
                        }
                    });
                });
            }

        }

        function hexToUtf8(hex) {
            // 16進数の文字列をバイト配列に変換
            const bytes = [];
            for (let i = 0; i < hex.length; i += 2) {
                bytes.push(parseInt(hex.substr(i, 2), 16));
            }

            // バイト配列をUTF-8文字列に変換
            const utf8String = new TextDecoder('utf-8').decode(new Uint8Array(bytes));
            return utf8String;
        }

        function hexToBytes(hex) {
            const bytes = [];
            for (let c = 0; c < hex.length; c += 2) {
                bytes.push(parseInt(hex.substr(c, 2), 16));
            }
            return new Uint8Array(bytes);
        }

        function utf8ToHex(str) {
            const encoder = new TextEncoder(); // UTF-8エンコーダ
            const bytes = encoder.encode(str); // UTF-8バイト配列に変換
            return Array.from(bytes)
                .map(byte => byte.toString(16).padStart(2, '0')) // 各バイトを16進数に変換
                .join(''); // 配列を文字列に結合
        }

        // メッセージをデコードしてから不要な制御文字を取り除く関数
        function cleanMessage(rawMessage) {
            // 16進数からUTF-8にデコード
            let decodedMessage = hexToUtf8(rawMessage);
            // 先頭の制御文字や不可視の文字、ヌル文字を削除
            decodedMessage = decodedMessage.replace(/^[\u200B-\u200D\uFEFF\xA0\s\x00]*/g, ''); // 不可視文字や空白、ヌル文字を削除
            return decodedMessage;
        }


        function handleShareClick(event) {
            event.preventDefault();
            const pageUrl = event.currentTarget.getAttribute('data-url');
            const message = `#Symbol #XYM_Thread ${pageUrl}`;
            const x_Url = `https://x.com/intent/post?text=${encodeURIComponent(message)}`;
            window.open(x_Url, '_blank');
        }

        function openThread(event) {
            const transactionHash = event.currentTarget.getAttribute('data-hash');
            window.location.href = `thread.html?id=${transactionHash}`;
        }
    </script>
</body>

</html>

スレッドの詳細表示、 コメントの追加など

style.css
style.css
/* Body Styling */
body {
    background: linear-gradient(135deg, #f3f3f3, #e0e0e0); /* グラデーション背景 */
    font-family: 'Poppins', sans-serif; /* モダンフォント */
}

/* Header Styling */
#header {
    text-align: center;
    margin-top: 50px;
}

#header img {
    width: 100%; /* Adjust the width to fit the container */
    max-width: 830px; /* Limit the maximum width */
    height: auto;
}

@media screen and (max-width: 768px) {
    #header img {
        width: 100%;
    }
}

label {
    display: block;  
    text-align: left; 
    margin-bottom: 5px; 
    font-size: 14px; 
    font-weight: bold; 
    color: #333; 
}


/* Modal Base Styling */
.modal, .social-metadata-modal {
    display: none;
    position: fixed;
    z-index: 1;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    justify-content: center;
    align-items: center;
}

.modal {
    background-color: rgba(0, 0, 0, 0.7);
}

.social-metadata-modal {
    background-color: rgba(0, 0, 0, 0.6);
    z-index: 1000;
}

/* Modal Content Styling */
.modal-content {
    background-color: #fff;
    padding: 30px;
    border-radius: 10px; 
    width: 90%; 
    max-width: 400px;
    box-shadow: 0px 6px 10px rgba(0, 0, 0, 0.1); 
    color: #333;
}

.social-metadata-modal .modal-content {
    padding: 40px;
    border-radius: 15px; 
    max-width: 450px;
    box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.2); 
    text-align: center;
}

@media screen and (max-width: 768px) {
    .modal-content {
        width: 90%; 
        padding: 20px;
    }
}

/* Close Button Styling */
.close {
    color: #888;
    float: right;
    font-size: 24px;
    font-weight: bold;
    cursor: pointer;
}

.close:hover {
    color: #000;
}

/* Form Element Styling */
textarea, input[type="text"], input[type="number"] {
    width: 100%;
    padding: 10px;
    margin: 10px 0;
    border: 1px solid #ccc;
    border-radius: 5px;
    font-size: 16px;
    box-sizing: border-box;
    font-family: 'Poppins', sans-serif; /* モダンフォント */
}

/* Button Styling */
button {
    padding: 12px 20px;
    font-size: 16px;
    border-radius: 25px;
    border: none;
    cursor: pointer;
    margin: 10px;
    transition: background-color 0.3s ease;
    cursor: pointer;
}

button.create-thread, button.reply-comment {
    background: linear-gradient(to bottom, #4CAF50, #2F8F2F);
    color: white;
    border: none;
    padding: 12px 20px;
    font-size: 20px;
    border-radius: 5px;
    cursor: pointer;
    width: 280px;
    display: block;
    margin: 20px auto;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
    cursor: pointer;
}

button:hover {
    background: linear-gradient(to bottom, #3E8E41, #267A26);
    box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3);
    cursor: pointer;
}

button:active {
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
    transform: translateY(2px);
    cursor: pointer;
}

#submit-metadata {
    background-color: #4CAF50;
    color: white;
    font-weight: bold;
}

#submit-metadata:hover {
    background-color: #45a049;
}

#cancel-metadata {
    background-color: #f44336;
    color: white;
    font-weight: bold;
}

#cancel-metadata:hover {
    background-color: #d73933;
}

/* Avatar Styling */
.avatar {
    width: 50px;
    height: 50px;
    border-radius: 50%;
    margin-right: 20px;
}

.modal-content .avatar-container {
    display: flex;
    justify-content: center;
    margin-bottom: 20px;
}

.modal-content img.avatar {
    width: 80px;
    height: 80px;
    border-radius: 50%;
    object-fit: cover;
}

/* Header and Text Styling */
h2 {
    font-size: 24px;
    color: #333;
    text-align: center;
    margin-bottom: 20px;
    font-family: 'Poppins', sans-serif; /* モダンフォント */
}

h3 {
    font-size: 20px;
    color: blue;
    text-align: center;
    margin-bottom: 20px;
    font-family: 'Poppins', sans-serif;
    word-wrap: break-word;
    word-break: break-all;
    overflow-wrap: break-word;
}

/* Thread List Styling */
#thread-list {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-top: 20px;
}

.thread-item {
    display: flex;
    flex-direction: column;
    justify-content: center;
    background-color: #f9f9f9;
    margin: 10px;
    padding: 20px;
    border-radius: 15px;
    box-shadow: 0px 6px 12px rgba(0, 0, 0, 0.1);
    width: 100%;
    max-width: 700px;
    border: 2px solid rgba(100, 149, 237, 0.8);
    box-shadow: 0 0 10px rgba(100, 149, 237, 0.5);
    box-sizing: border-box;
}

@media screen and (max-width: 768px) {
    .thread-item {
        width: 100%;  /* モバイル表示では幅を少し縮めて画面に収める */
        margin: 3px;  /* モバイルでは余白を少し減らす */
    }
}


.thread-header {
    display: flex;
    align-items: center;
    width: 100%;
}

.thread-info {
    flex-grow: 1;
    text-align: right;
}

.thread-date, .thread-amount {
    margin: 5px 0;
    text-align: right;
    color: blueviolet;
    box-shadow: none;
}

.thread-message {
    word-wrap: break-word;
    word-break: break-all;
    margin-top: 20px;
    font-size: 24px;
    text-align: center;
    color: blue;
    font-family: 'Poppins', sans-serif;
}

#thread-container {
    max-width: 800px;
    margin: 70px auto 20px; 
    padding: 20px;
    border: 2px solid #ccc;
    border-radius: 10px;
    background-color: #f9f9f9;
}


/* Spinner Styling */
.spinner {
    border: 8px solid #f3f3f3;
    border-top: 8px solid #3498db;
    border-radius: 50%;
    width: 50px;
    height: 50px;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

#loading-spinner {
    display: none;
    position: fixed;
    z-index: 9999;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
}

/* Hamburger Menu Styling */
.hamburger-menu {
    position: absolute;
    top: 10px;
    right: 10px;
    z-index: 1000;
}

.menu-icon {
    display: inline-block;
    cursor: pointer;
    width: 35px;
    height: 30px;
    position: relative;
}

.menu-icon span {
    display: block;
    width: 100%;
    height: 4px;
    background-color: #333;
    margin: 5px 0;
    transition: all 0.3s ease;
}

#menu-toggle {
    display: none;
}

#menu-toggle:checked + .menu-icon span:nth-child(1) {
    transform: translateY(9px) rotate(45deg);
}

#menu-toggle:checked + .menu-icon span:nth-child(2) {
    opacity: 0;
}

#menu-toggle:checked + .menu-icon span:nth-child(3) {
    transform: translateY(-9px) rotate(-45deg);
}

.menu {
    display: none;
    position: absolute;
    top: 50px;
    right: 10px;
    background-color: #fff;
    border-radius: 8px;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    padding: 10px;
    z-index: 999;
    width: 80px;
}

.menu ul {
    list-style: none;
    padding: 0;
    margin: 0;
}

.menu ul li {
    margin: 10px 0;
}

.menu ul li a {
    text-decoration: none;
    color: blue;
    font-size: 16px;
    display: block;
}

#menu-toggle:checked + .menu-icon + .menu {
    display: block;
    animation: fadeIn 0.3s ease;
}

@keyframes fadeIn {
    from {
        opacity: 0;
        transform: translateY(-10px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}


.info-display {
    display: flex;
    align-items: center;
}

.xym-icon, .comment-icon {
    width: 24px;
    height: 24px;
    margin-right: 8px;
}

.fontsize-icon {
    width: 20px;
    height: 16px;
    margin-right: 8px;
}

.total-xym, .comment-count {
    font-size: 16px;
    margin-right: 15px;
    color: black;
}

#home-button, #x-share-button {
    position: fixed;
    top: 10px;
    text-decoration: none;
    cursor: pointer;
    z-index: 100;
}

#home-button {
    right: 10px;
    cursor: pointer;
}

#x-share-button {
    right: 70px;
    cursor: pointer;
}

#home-icon, #x-icon {
    width: 50px;
    height: 50px;
    border-radius: 50%;
    background-color: white;
    box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
    transition: transform 0.3s ease;
}

#home-icon:hover, #x-icon:hover {
    transform: scale(1.1);
}

/* Thread footer styling to align share button and open icon button */
.thread-footer {
    display: flex;
    justify-content: flex-end; /* 子要素を右寄せ */
   /* justify-content: space-between; */
    align-items: center;
    margin-top: 20px;
    margin-bottom: 50px;
    position: relative;
}

/* X share button styling */
.x-share-button_th {
    text-decoration: none;
    display: flex;
    align-items: center;
    cursor: pointer;
}

.x-icon_th {
    width: 30px;
    height: 30px;
    border-radius: 50%;
    background-color: white;
    box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
    transition: transform 0.3s ease, box-shadow 0.3s ease;
}

.x-icon_th:hover {
    transform: scale(1.1);
    box-shadow: 0px 6px 8px rgba(0, 0, 0, 0.3);
}

/* Open thread icon button styling */
.open-thread-icon-button {
    position: absolute;
    bottom: -70px;
    right: 10px;
    background: linear-gradient(to bottom, #6c8bfd, #5a7ecc);
    padding: 10px;
    border: none;
    cursor: pointer;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
    transition: background 0.3s ease, transform 0.3s ease;
}

.open-thread-icon-button:hover {
    background: linear-gradient(to bottom, #2f7bff, #1a5ebb);
    transform: scale(1.1);
}

.open-icon {
    width: 30px;
    height: 30px;
    transition: transform 0.3s ease;
}

.avatar-container_2 {
    position: relative;
   /* display: inline-block;*/
}

HTMLのデザイン調整

Symbol SDK v3 を用いたコードの解説


1. Symbol SDK の読み込み

Symbol SDK をブラウザ環境で使用するために、ライブラリを読み込みます。

script.js
const SDK_VERSION = "3.2.2";
const sdk = await import(`https://www.unpkg.com/symbol-sdk@${SDK_VERSION}/dist/bundle.web.js`);
sdkCore = sdk.core;
sdkSymbol = sdk.symbol;

sdkCore:  Symbol SDK のコア機能(暗号処理やユーティリティ)。
sdkSymbol:  Symbol ブロックチェーンに特化した機能。

2. ノード情報の取得

ブロックチェーンのノードからネットワーク設定やプロパティを取得します。

script.js
const NODE = 'https://symbol-mikun.net:3001'; // ノードURL
const networkProperties = await fetch(
    new URL('/network/properties', NODE),
    {
        method: 'GET',
        headers: { 'Content-Type': 'application/json' },
    }
).then((res) => res.json());

const e = networkProperties.network.epochAdjustment;
epochAdjustment = Number(e.substring(0, e.length - 1));
const identifier = networkProperties.network.identifier;

NODE: Symbol ノードの URL を指定。
epochAdjustment: ブロック生成時刻を調整するための値。
identifier: ネットワーク識別子。

3. SymbolFacade の利用

Symbol のトランザクション作成や署名を効率化するために SymbolFacade を使用します。

script.js
facade = new sdkSymbol.SymbolFacade(identifier); 

SymbolFacade: ネットワークに基づいて操作を抽象化するクラス。

4. メタデータキーの生成

メタデータを管理するためのキーを生成します。

script.js
scopedMetadataKey = sdkSymbol.metadataGenerateKey("social_meta_data");

これはSMD(ソーシャルメタデータ)設定用のキーです。

metadataGenerateKey: メタデータ用の一意なキーを生成。
このキーを使用してメタデータを取得・設定します。

5. トランザクション履歴の取得

特定のアドレスに関連するトランザクション履歴を取得します。

script.js
const params = new URLSearchParams({
    "address": recipientAddress.toString(),
    "embedded": true,
    "pageSize": 100,
    "pageNumber": pageNumber,
    "order": "desc"
});
const result = await fetch(
    new URL('/transactions/confirmed?' + params.toString(), NODE),
    {
        method: 'GET',
        headers: { 'Content-Type': 'application/json' },
    }
).then((res) => res.json());

embedded: true トランザクションデータを埋め込み形式に。
pageSize: 一度に取得するトランザクションの最大件数。
order: 取得するトランザクションの順序(ここでは降順)。

6. トランザクションの作成

Symbol のトランザクション作成には descriptor を使用します。

script.js
descriptor = new sdkSymbol.descriptors.TransferTransactionV1Descriptor(
    recipientAddress,
    [
        new sdkSymbol.descriptors.UnresolvedMosaicDescriptor(
            new sdkSymbol.models.UnresolvedMosaicId(0x6BED913FA20223F8n),
            new sdkSymbol.models.Amount(BigInt(amount * Math.pow(10, 6)))
        )
    ],
    plainMessage
);

TransferTransactionV1Descriptor: 転送トランザクションを記述するためのデスクリプタ。
UnresolvedMosaicDescriptor: モザイク(トークン)に関する設定

署名者の公開鍵を設定

aLiceの場合

script.js
pubkey = new sdkCore.PublicKey('0000000000000000000000000000000000000000000000000000000000000000');

pubkeyをこのように記述しておくと、aLice起動時にメインアカウントの公開鍵がセットされます。

未確認ですが、以下のように記述しても同様に動作すると思います。
pubkey = '0000000000000000000000000000000000000000000000000000000000000000';

SSSの場合

script.js
pubkey = window.SSS.activePublicKey;

トランザクションをセット

script.js
tx = facade.createTransactionFromTypedDescriptor(
    descriptor, 
    pubkey,  
    100,    
    60 * 60 * 2 
);

pubkey: トランザクションの署名者公開鍵。
100: 手数料の乗数。
60 * 60 * 2: トランザクションの有効期限(秒単位)。

7. トランザクションの署名とアナウンス

作成したトランザクションに署名して、アナウンスします。

aLiceで署名、アナウンスする場合

script.js
const transactionPayload = sdkCore.utils.uint8ToHex(tx.serialize()); 
const aliceUrl = `alice://sign?data=${transactionPayload}&type=request_sign_transaction&node=${utf8ToHex(NODE)}&method=announce`;
window.location.href = aliceUrl;

SSSで署名、アナウンスする場合

script.js
        window.SSS.setTransactionByPayload(transactionPayload);
        window.SSS.requestSign().then(signedPayload => {   // SSSを用いた署名をユーザーに要求
            console.log('signedPayload', signedPayload);
            jsonPayload = `{"payload": "${signedPayload.payload}"}`
            // SSSで署名されたトランザクションの送信
            fetch(`${NODE}/transactions`, {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: jsonPayload
            })
                .then(response => {
                //////////////
                //    省略
                //////////////
                })
        })

8. メタデータの取得

特定のアドレスとスコープキーに関連するメタデータを取得します。

script.js
const params = new URLSearchParams({
    targetAddress: address.toString(),
    scopedMetadataKey: scopedMetadataKey.toString(16).toUpperCase(),
    metadataType: 0,
    "pageSize": 100,
    order: "desc"
});
const response = await fetch(new URL(`/metadata?${params.toString()}`, NODE), {
    method: 'GET',
    headers: { 'Content-Type': 'application/json' }
});

targetAddress: メタデータの対象アドレス。
scopedMetadataKey: スコープメタデータキー。
metadataType: メタデータの種類(アカウントメタデータ)。

9. メタデータの登録(更新)

アカウントメタデータを登録(更新)します。

script.js

const descriptor = new sdkSymbol.descriptors.AccountMetadataTransactionV1Descriptor(
    targetAddress,
    key,
    sizeDelta,
    updatedValue
);
tx = facade.createEmbeddedTransactionFromTypedDescriptor(
    descriptor,         
    pubkey            
);

const embeddedTransactions = [tx];

        // アグリゲートTx作成
const aggregateDescriptor = new sdkSymbol.descriptors.AggregateCompleteTransactionV2Descriptor(
            facade.constructor.hashEmbeddedTransactions(embeddedTransactions),
            embeddedTransactions
        );

aggregateTx = facade.createTransactionFromTypedDescriptor(
            aggregateDescriptor,  // トランザクション Descriptor 設定
            pubkey,               // 署名者公開鍵
            100,                  // 手数料乗数
            60 * 60 * 2,          // Deadline:有効期限(秒単位)
            0                     // 連署者数
        );

AccountMetadataTransactionV1Descriptor: メタデータを更新するためのトランザクションデスクリプタ。
sizeDelta: 更新後の値との差分を指定。

トランザクションの署名、アナウンスのやり方は 7. と同様です。

10. コメント数と合計XYMの計算

トランザクションメッセージを解析してコメントをカウントし、送金された XYM を計算します。

script.js
if (decodedMessage.substring(2, 7) === threadHash.substring(0, 5)) {
    commentCount++;
    const mosaicAmount = tx.transaction.mosaics[0].amount;
    const xymAmount = parseInt(mosaicAmount) / Math.pow(10, 6);
    totalXYM += xymAmount;
}

コメント検出: メッセージとスレッドのハッシュを比較して関連付け。
XYM の合計: 各トランザクションで送金された XYM を合算。

まとめ

ざっくりですが Symbol SDK v3 で書いた部分を解説してみました。
まだまだ慣れていないですが、徐々に v3 に慣れていこうと思います。

今回も ChatGPT と相談しながら作成しました。
ChatGPTは、速習Symbol についても結構学習が進んでいるようなので、分からない事があったらどんどん質問してみると良いと思います。(たまに間違ったコードを返すこともありますw)

XYM Thread 全コード

参考

aLice

SSS Extension

速習Symbol

Symbol Github

6
2
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
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?