Github.ioでも作れる、旧端末対応Mastodonクライアントを自作した話

# はじめに
皆さんは、サポート切れ端末でSNSに投稿、閲覧したいと思ったことありませんか？<br>
今回はそんなあなたに向けた記事となっております<br>
# 目次
<!-- タイトルとアンカー名を編集 -->
1. [作ろうとしたきっかけ](#Chapter1)
1. [cors鯖用プログラムの改造](#Chapter2)
1. [各種html,css,jsファイルの作成](#Chapter3)
1. [画像を含む投稿フォームの作成](#Chapter4)
1. [作ってみた感想](Reference)
<!-- 各チャプター -->
<a id="#Chapter1"></a>
# 作ろうとしたきっかけ
作ろうとしたきっかけは、たまたま箪笥の肥やしになっていた4sを引っ張り出したことがきっかけです<br>
4sブームもあったので作ることにしました<br>
そうはいってもバニラ4s(ios6.1.3)ではtls1.2化されたサイトを閲覧することは難しかったのでこちらのプロファイルを入れました<br>
<a href="https://cydia.invoxiplaygames.uk/certificates/">リンク</a><br>
＊こちらのプロファイルはios6にてhttps
化されたサイトを読み込むためのプロファイルです<br>
＊ios6以外の方は読み飛ばして下さい

<a id="#Chapter2"></a>
# cors鯖用プログラムの改造
元のプログラムは下記url参照<br>
<a href="https://github.com/Rob--W/cors-anywhere">リンク<br>
```javascript:server.js
const fs = require('fs');
const https = require('https');
const cors_proxy = require('cors-anywhere');

// SSL証明書の読み込み
const privateKey = fs.readFileSync('/etc/letsencrypt/live/cors-0x10.online/privkey.pem', 'utf8');
const certificate = fs.readFileSync('/etc/letsencrypt/live/cors-0x10.online/fullchain.pem', 'utf8');
const credentials = { key: privateKey, cert: certificate };

// ホワイトリストの設定
const originWhitelist = ['https://kami-0x10.github.io']; // 許可するオリジンを指定

// CORSプロキシサーバーを作成
const proxy = cors_proxy.createServer({
  originWhitelist: originWhitelist,
  requireHeader: ['origin', 'x-requested-with'],
  removeHeaders: ['cookie', 'cookie2'],
  setHeaders: { 'Access-Control-Allow-Credentials': 'true' },
});

// HTTPSサーバーでCORSプロキシを起動
const port = 443;
https.createServer(credentials, (req, res) => {
  proxy.emit('request', req, res);
}).listen(port, () => {
  console.log(`HTTPS CORS Anywhere server is running on port ${port}`);
});
```
<a id="#Chapter3"></a>
# 各種html,css,jsファイルの作成
```html:login.html
<!DOCTYPE html> 
<html lang="ja">
<head> 
    <meta charset="UTF-8">
    <link rel="apple-touch-icon" href="./ico/simpdon.ico">
    <link rel="icon" type="image/png" sizes="192x192" href="./ico/simpdon.ico">
    <meta name="msapplication-TileImage" content="./ico/simpdon.ico">
    <meta name="msapplication-TileColor" content="#ffffff">
    <link rel="icon" href="./ico/simpdon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0"> 
    <title>ログイン - Simpdon クライアント</title> 
    <link rel="stylesheet" href="./css/styles.css"> 
</head> 
<body> 
    <div class="container"> 
        <h1>ログイン</h1> 
        <div class="form"> 
            <label for="instance-url">インスタンスURL</label> 
            <input type="text" id="instance-url" placeholder="例: https://mastodon.social" />   
 
            <label for="access-token">アクセストークン</label>  
            <input type="text" id="access-token" placeholder="アクセストークンを入力" />   
 
            <a href="javascript:void(0);" id="login-btn" class="button">ログイン</a> 
        </div> 
    </div> 

    <script> 
        function setCookie(name, value) { 
            var expires = new Date(); 
            expires.setTime(expires.getTime() + (365 * 24 * 60 * 60 * 1000)); 
            document.cookie = name + "=" + value + ";expires=" + expires.toUTCString() + ";path=/"; 
        } 

        document.getElementById('login-btn').onclick = function() { 
            const instanceUrl = document.getElementById('instance-url').value.trim(); 
            const accessToken = document.getElementById('access-token').value.trim(); 

            if (instanceUrl && accessToken) { 
                setCookie('instanceUrl', instanceUrl); 
                setCookie('accessToken', accessToken); 
                window.location.href = 'timeline.html'; 
            } else { 
                alert('インスタンスURLとアクセストークンを入力してください'); 
            } 
        }; 
    </script> 
</body> 
</html>
```

```html:timeline.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <link rel="apple-touch-icon" href="./ico/simpdon.ico">
    <link rel="icon" type="image/png" sizes="192x192" href="./ico/simpdon.ico">
    <meta name="msapplication-TileImage" content="./ico/simpdon.ico">
    <meta name="msapplication-TileColor" content="#ffffff">
    <link rel="icon" href="./ico/simpdon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>投稿一覧 - Simpdon クライアント</title>
    <link rel="stylesheet" href="./css/styles.css">
</head>
<body>
    <div class="container">
        <h1>投稿一覧</h1>

        <div id="posts" class="posts"></div>
        
        <div class="menu">
            <a href="javascript:void(0);" onclick="setTimeline('home')">Home TL</a> |
            <a href="javascript:void(0);" onclick="setTimeline('public')">Public TL</a>
        </div>
        
        <div class="actions">
            <a href="post.html" class="button">新しい投稿</a>
            <a href="login.html" class="logout-link">ログアウト</a>
        </div>
    </div>

    <script>
        // CookieからインスタンスURLとアクセストークンを取得する関数
        function getCookie(name) {
            var nameEq = name + "=";
            var ca = document.cookie.split(';');
            for (var i = 0; i < ca.length; i++) {
                var c = ca[i].trim();
                if (c.indexOf(nameEq) === 0) return c.substring(nameEq.length, c.length);
            }
            return "";
        }

        // ログイン状態を確認
        var instanceUrl = getCookie('instanceUrl');
        var accessToken = getCookie('accessToken');

        if (!instanceUrl || !accessToken) {
            window.location.href = 'login.html'; // ログインしていなければログインページに遷移
        }

        // 投稿を取得する関数 (XMLHttpRequestを使用)
        function fetchPosts(timelineType) {
            var apiUrl = "";

            // タイムラインタイプに応じてURLを変更
            if (timelineType === 'public') {
                apiUrl = "/api/v1/timelines/public";
            } else if (timelineType === 'home') {
                apiUrl = "/api/v1/timelines/home";
            }

            var proxyUrl = "https://cors-0x10.online:4443/"; // プロキシURLを使ってCORS対策

            var xhr = new XMLHttpRequest();
            xhr.open("GET", proxyUrl + instanceUrl + apiUrl, true);
            xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken);
            xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');

            xhr.onreadystatechange = function() {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        try {
                            var data = JSON.parse(xhr.responseText);
                            displayPosts(data);
                        } catch (e) {
                            console.error("JSON解析エラー:", e);
                            document.getElementById('posts').innerHTML = '<p>投稿の読み込みに失敗しました。</p>';
                        }
                    } else {
                        console.error('ネットワークエラー:', xhr.status, xhr.statusText);
                        document.getElementById('posts').innerHTML = '<p>投稿の読み込みに失敗しました。</p>';
                    }
                }
            };

            xhr.onerror = function() {
                console.error('リクエストエラーが発生しました');
                document.getElementById('posts').innerHTML = '<p>投稿の読み込みに失敗しました。</p>';
            };

            xhr.send();
        }

        // 投稿を表示する関数
        function displayPosts(posts) {
            var postsContainer = document.getElementById('posts');
            postsContainer.innerHTML = ''; // 既存の投稿をクリア

            posts.forEach(function (post) {
                var postElement = document.createElement('div');
                postElement.classList.add('post');

                var username = post.account.acct;
                var content = post.content;

                // ユーザーのアイコンを取得
                var avatarUrl = post.account.avatar;
                var avatarImg = avatarUrl ? `<img src="${avatarUrl}" alt="@${username}のアイコン" class="user-avatar" />` : '';

                // 画像がある場合、その画像を表示
                var images = '';
                if (post.media_attachments && post.media_attachments.length > 0) {
                    images = post.media_attachments.map(function (attachment) {
                        return '<img src="' + attachment.url + '" alt="画像" class="post-image" />';
                    }).join('');
                }

                var emojiContent = content.replace(/:([a-zA-Z0-9_+]+):/g, function (match, emoji) {
                    return '<span class="emoji">' + emoji + '</span>';
                });

                postElement.innerHTML = `
                    <div class="post-header">
                        ${avatarImg}
                        <h2>@${username}</h2>
                    </div>
                    <p>${emojiContent}</p>
                    ${images}
                `;
                postsContainer.appendChild(postElement);
            });
        }

        // 初期表示はHome TLを表示
        window.onload = function() {
            setTimeline('home');
        };

        // タイムラインを設定する関数
        function setTimeline(timelineType) {
            fetchPosts(timelineType);
        }
    </script>
</body>
</html>
```

```css:styles.css
/* 基本のスタイル */
body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* よりモダンなフォントに */
    margin: 0;
    padding: 0;
    background-color: #f4f4f9;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    box-sizing: border-box;
}

/* コンテナ */
.container {
    background-color: white;
    padding: 30px; /* 内側の余白を少し増やして快適に */
    border-radius: 12px; /* 丸みを増して現代的に */
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
    width: 100%;
    max-width: 450px; /* 少し広めにして視覚的に余裕を持たせる */
    text-align: center;
    box-sizing: border-box;
    margin: 0 20px;  /* スマホ画面での余白調整 */
}

/* 見出し */
h1 {
    font-size: 28px;
    margin-bottom: 25px;
    color: #333;
}

/* フォーム */
.form {
    display: flex;
    flex-direction: column;
    gap: 20px; /* フォーム要素間の隙間を少し広く */
}

/* ラベル */
label {
    font-size: 16px;
    text-align: left;
    color: #555;
    margin-bottom: 8px;
}

/* 入力フィールド */
input[type="text"], textarea {
    padding: 14px;
    font-size: 16px;
    border: 1px solid #ddd;
    border-radius: 8px; /* 入力フィールドの角を丸めて、柔らかい印象に */
    margin-top: 5px;
    width: 100%;
    box-sizing: border-box;
    transition: border-color 0.3s ease; /* フォーカス時の色変更 */
}

input[type="text"]:focus, textarea:focus {
    border-color: #007bff; /* フォーカス時の青色強調 */
    outline: none;
}

/* テキストエリア */
textarea {
    height: 120px; /* もう少し高くして、快適に */
}

/* ボタン */
.button {
    background-color: #007bff;
    color: white;
    padding: 15px 0;
    font-size: 18px;
    border-radius: 8px;
    text-decoration: none;
    display: inline-block;
    text-align: center;
    width: 100%;
    box-sizing: border-box;
    cursor: pointer;
    margin-top: 15px;
    transition: background-color 0.3s ease;
}

.button:hover {
    background-color: #0056b3;
}

/* リンク */
a {
    text-decoration: none;
    color: #007bff;
    font-size: 14px;
}

a:hover {
    text-decoration: underline;
}

/* レスポンシブ対応 */
@media (max-width: 480px) {
    .container {
        padding: 20px;  /* スマホ画面での余白調整 */
        margin: 0 10px;
    }
    h1 {
        font-size: 24px;
    }
    input[type="text"], textarea, .button {
        font-size: 16px;
    }
}

/* 投稿のカード */
.post {
    background-color: #fff;
    border-radius: 12px; /* 角を丸めて柔らかい印象に */
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
    padding: 20px;
    margin-bottom: 20px;
    word-wrap: break-word;
    overflow: hidden;
}

/* 投稿画像のスタイル */
.post img {
    max-width: 100%;  /* 親要素に合わせて最大幅を100%に */
    height: auto;     /* アスペクト比を保ちながら高さを自動調整 */
    border-radius: 8px; /* 画像の角を丸めてカードとの一貫性を持たせる */
    object-fit: cover;  /* 画像が枠に収まるように調整 */
}

```
jsはhtml内に埋め込んでいますがライブラリなどはjson2.jsを使用しています<br>
<a id="#Chapter4"></a>
# 画像を含む投稿フォームの作成
```html:post.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <link rel="apple-touch-icon" href="./ico/simpdon.ico">
    <link rel="icon" type="image/png" sizes="192x192" href="./ico/simpdon.ico">
    <meta name="msapplication-TileImage" content="./ico/simpdon.ico">
    <meta name="msapplication-TileColor" content="#ffffff">
    <link rel="icon" href="./ico/simpdon.ico">
    <script src="./js/json2.js"></script>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>新しい投稿 - Simpdon クライアント</title>
    <link rel="stylesheet" href="./css/styles.css">
</head>

<body>
    <div class="container">
        <h1>新しい投稿</h1>
        <div class="form">
            <textarea id="content" placeholder="投稿内容を入力してください"></textarea>
            <a href="javascript:void(0);" id="post-btn" class="button">投稿</a>
            <!-- 追加: timeline.htmlに戻るボタン -->
            <a href="timeline.html" class="button">タイムラインに戻る</a>
        </div>
    </div>

    <!-- iframeを追加 -->
    <iframe id="hidden-frame" name="hidden-frame" style="display:none;"></iframe>

    <script>
        function getCookie(name) {
            var nameEq = name + "=";
            var ca = document.cookie.split(';');
            for (var i = 0; i < ca.length; i++) {
                var c = ca[i].replace(/^\s+/, ''); // 前後の空白を削除
                if (c.indexOf(nameEq) === 0) return c.substring(nameEq.length, c.length);
            }
            return "";
        }

        // 投稿ボタンがクリックされたとき
        document.getElementById('post-btn').onclick = function () {
            var content = document.getElementById('content').value.replace(/^\s+|\s+$/g, '');

            if (!content) {
                alert('投稿内容を入力してください');
                return;
            }

            var instanceUrl = getCookie('instanceUrl');
            var accessToken = getCookie('accessToken');

            if (!instanceUrl || !accessToken) {
                alert('ログイン情報が見つかりません');
                window.location.href = 'login.html';
                return;
            }

            // CORSプロキシを通してAPIリクエストを送信
            var proxyUrl = "https://cors-0x10.online:4443/"; 
            var apiUrl = instanceUrl + '/api/v1/statuses';  // 投稿用APIのURL

            // リクエストを作成
            var xhr = new XMLHttpRequest();
            xhr.open("POST", proxyUrl + apiUrl, true);
            xhr.setRequestHeader("Authorization", "Bearer " + accessToken);
            xhr.setRequestHeader("Content-Type", "application/json"); // JSON形式に変更

            // リクエスト送信時の処理
            xhr.onreadystatechange = function () {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        var response = JSON.parse(xhr.responseText); // レスポンスをJSONとして解析
                        if (response.id) {
                            alert('投稿が送信されました');
                        } else {
                            alert('投稿の送信に失敗しました');
                        }
                    } else {
                        alert('投稿の送信に失敗しました。エラーコード: ' + xhr.status);
                    }
                }
            };

            // リクエストボディのデータをJSON形式で送信
            var data = JSON.stringify({ status: content });

            // リクエストを送信
            xhr.send(data);
        };
    </script>
</body>
</html>
```
<a id="#Reference"></a>
# 作ってみた感想
ちょっと時間なくてほぼソースコードのみ貼り付けてる記事になってしまいましたが、
また時間あるときにこの記事の続編として、
解説する記事書きますので、ご期待していただけたらいいなと思います<br>
- [simpdon repo](https://github.com/Kami-0x10/simpdon/blob/main/post.html)
- [cors anywhere](https://github.com/Rob--W/cors-anywhere)
- [certificate](https://cydia.invoxiplaygames.uk/certificates/)