プロフィール

arismmn timeline blog

← ブログ一覧に戻る

タイムラインに絵文字リアクション機能を実装した

機能実装AstroCloudflareセキュリティJavaScript

どんな機能を作ったか

タイムラインのつぶやき・祝電・ブログカードに、Slackにあるような絵文字リアクションをつけられるようにした。

image

各投稿カードの下に「😊 +」ボタンが表示されて、クリックすると絵文字ピッカーが開く。気に入った絵文字を選ぶとリアクションがカウントされ、もう一度同じ絵文字をクリックするとリアクションが取り消せる(トグル動作)。

管理画面から使える絵文字の種類・ラベル・並び順を自由に変更できる。Unicode絵文字だけでなく、emoji-gen.ninja などで作成した画像絵文字もアップロードして登録できる。

リアクションがついたとき、Slackの指定チャンネルへ通知が届く。ブログ記事へのリアクションの場合は記事タイトルも通知される。

技術的な話

データベース設計

Cloudflare D1(SQLite)に2つのテーブルを追加した。

reaction_emojis(絵文字マスタ)

CREATE TABLE reaction_emojis (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  emoji      TEXT    NOT NULL UNIQUE,
  label      TEXT    NOT NULL,
  sort_order INTEGER NOT NULL DEFAULT 0,
  image_url  TEXT,
  created_at TEXT    NOT NULL DEFAULT (datetime('now'))
);

管理者が設定できる絵文字の一覧。image_url カラムは画像絵文字(Cloudinary URL)を格納するために後から追加した。

post_reactions(リアクション記録)

CREATE TABLE post_reactions (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  post_id    TEXT    NOT NULL,
  emoji      TEXT    NOT NULL,
  browser_id TEXT    NOT NULL,
  created_at TEXT    NOT NULL DEFAULT (datetime('now')),
  UNIQUE(post_id, emoji, browser_id)
);

誰がどの投稿にどの絵文字でリアクションしたかを記録する。UNIQUE 制約で同じブラウザから同じ絵文字への二重リアクションをDB側でも防いでいる。

投稿ID(post_id)の設計

各エントリの種類によってIDの形式が違う。

エントリ種別 post_id の形式
note(つぶやき) note_{DB上のrowid} note_42
telegram(祝電) telegram_{DB上のID} telegram_456
ブログ(内部) blog_rss_internal_{URLハッシュ} blog_rss_internal_a1b2c3d4
ブログ(外部RSS) blog_rss_{ホスト名}__{URLハッシュ} blog_rss_example_com_a1b2c3d4

note の post_idid カラムではなく rowid を使っているのには理由がある。後述のバグ修正の節を参照。

ユーザー識別(browser_id)

ログインなしでリアクションできる設計なので、誰がリアクションしたかを「ブラウザID」で管理している。

ブラウザIDは crypto.randomUUID() で生成した UUID を localStorage に保存するだけ。サーバーには送るが、DBに保存するのは匿名の UUID のみで個人情報は一切含まない。

別ブラウザ・別端末・シークレットモードは「別人扱い」になる設計で、同一人物の重複カウント防止は保証しない。気軽につけてもらうことを優先したシンプルな実装。

function getBrowserId() {
  let id = localStorage.getItem('reaction_browser_id');
  if (!id || !/^[0-9a-f]{8}-[0-9a-f]{4}-...$/.test(id)) {
    id = crypto.randomUUID();
    localStorage.setItem('reaction_browser_id', id);
  }
  return id;
}

API設計

3本のエンドポイントを作った。

エンドポイント 説明
GET /api/reactions?post_ids=...&browser_id=... 複数投稿のリアクション数を一括取得
POST /api/reactions/toggle リアクションの追加または削除
GET /api/reactions/emojis 使用可能な絵文字一覧

バッチAPIにしたのはパフォーマンス上の理由で、タイムラインに表示されている全投稿のリアクションを1回のHTTPリクエストでまとめて取得できる。

セキュリティの工夫

絵文字ホワイトリスト検証

リアクションのPOSTを受け取るとき、送られてきた絵文字が絵文字マスタテーブルに存在するかをDBで検証している。登録されていない絵文字は弾く。

const validEmoji = await db
  .prepare('SELECT id, label FROM reaction_emojis WHERE emoji = ?')
  .bind(emoji)
  .first();
if (!validEmoji) {
  return errorResponse(400, '使用できない絵文字です');
}

入力値の正規表現検証

post_id は英小文字・アンダースコア・英数字・ハイフンの組み合わせのみ許可。browser_id は UUID v4 形式のみ許可。これによって不正な文字列がDBに入り込むのを防いでいる。

if (!/^[a-z][a-z_]*_[\w-]{1,50}$/.test(postId)) {
  return errorResponse(400, '無効な post_id です');
}
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(browserId)) {
  return errorResponse(400, '無効な browser_id です');
}

DB の UNIQUE 制約

アプリ側のチェックを通過した場合でも、DB の UNIQUE(post_id, emoji, browser_id) 制約が最終的な二重登録の防衛線になっている。

ReactionBarコンポーネントの設計

src/components/ReactionBar.astro がUI部分を担当している。

SSR(サーバーサイドレンダリング)側は軽量なコンテナDIVのみ出力して、実際のリアクションボタンの描画はクライアントJavaScriptが行う。リアクション数はリアルタイムで変わる動的なデータなので、SSRで固定化してしまうと古いデータが表示されてしまうため。

<!-- SSR が出力するのはこれだけ -->
<div class="reaction-bar" data-post-id="note_42"></div>

クライアントJSが DOMContentLoaded でこのDIVを検出して、バッチAPIを呼び出してリアクションボタンを描画する。

CSSスコープの注意点

Astro<style> はビルド時に自動でハッシュ属性が付与されてスコープされる。しかしReactionBarのリアクションボタンはJavaScriptでDOMを生成するため、サーバー側で出力したHTML要素ではなくJS生成要素にはハッシュ属性がない。

そのままではスタイルが当たらないので、<style is:global> に変更することで対応した。

無限スクロール対応

このサイトのタイムラインは無限スクロールで追加ロードされる仕組みになっている。動的に追加されたカードにもリアクションバーを表示する必要があるため、MutationObserver でタイムラインコンテナへの要素追加を監視して、新しいカードが追加されたときに自動でリアクションをロードするようにした。

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.querySelector && node.querySelector('.reaction-bar[data-post-id]')) {
        loadReactions(node);
      }
    }
  }
});
observer.observe(timelineContainer, { childList: true });

後から追加した機能

カスタム画像絵文字のアップロード

Unicode絵文字だけでなく、emoji-gen.ninja などの絵文字ジェネレーターで作成した画像ファイル(PNG/GIF/WebP)を管理画面からアップロードして絵文字として登録できるようにした。

アップロードした画像はCloudinaryに保存され、URLが reaction_emojis テーブルの image_url カラムに記録される。

セキュリティ面では、image_url はCloudinaryのドメインで始まるURLのみ受け入れる検証を入れて、SSRF(サーバーサイドリクエストフォージェリ)攻撃を防いでいる。

if (image_url && !image_url.startsWith('https://res.cloudinary.com/')) {
  return errorResponse(400, '無効な image_url です');
}

リアクションバーのJSでは、絵文字ごとに image_url があるかどうかを判定して、画像絵文字の場合は <img> タグで表示する。

Slack通知機能

リアクションが追加されたとき、Slackの指定チャンネルへ通知を送る機能を実装した。

Cloudflare WorkersでのSlack通知の落とし穴

最初の実装ではSlack通知のfetchを await せず「fire-and-forget」で呼び出していた。これが原因でSlack通知が一切届かないバグが発生した。

Cloudflare Workersは return new Response() を返した瞬間にWorkerプロセスが終了する仕様になっている。awaitしていないPromise(未完了の非同期処理)はその時点で強制キャンセルされるため、Slackへのfetchが実行される前に処理が打ち切られていた。

// ❌ ダメな書き方(fire-and-forget)
notifyReaction({ ... }).catch(console.error);

// ✅ 正しい書き方(awaitしてから return する)
try {
  await notifyReaction({ ... });
} catch (e) {
  console.error(e);
}
// ← ここで return new Response() する

Cloudflare Workersには ctx.waitUntil() というAPIもあるが、ページルートからは context を取得しにくいため、シンプルに await する方式にした。

管理画面の機能拡張

ラベルのインライン編集

管理画面の絵文字一覧テーブルに「編集」ボタンを追加して、ラベルをその場で編集できるようにした。「編集」ボタンを押すとラベルセルが入力フィールドに切り替わり、「保存」または Enter キーで更新、「キャンセル」または Esc キーで元に戻る。

削除時のタイムライン完全消去

当初の削除処理では絵文字マスタテーブルのレコードを削除するだけで、投稿ごとのリアクション記録は残ったままだった。削除した絵文字がタイムラインに残り続けるという問題があったため、削除時に両テーブルを連動して削除するよう修正した。

-- 絵文字マスタを削除する前に、タイムラインのリアクション記録も削除
DELETE FROM post_reactions WHERE emoji = ?;
DELETE FROM reaction_emojis WHERE id = ?;

対応範囲の拡大

個別ページ(/notes/YYYYMMDDHHMMSS)への対応

最初の実装ではタイムライン(トップページ)のみにリアクションバーを表示していた。つぶやきの個別ページでもリアクションをつけられるよう対応した。

タイムラインと同じ post_idnote_<rowid> 形式)を使っているため、タイムラインでつけたリアクションが個別ページでもそのまま引き継がれる。

ブログカードへの対応

タイムラインに表示されるブログカード(内部ブログ・外部RSSフィード)にもリアクション機能を追加した。

ブログカードはリアクションボタンをカード枠の外側下部に配置する。タイムラインの他のカードとは異なり、ブログのコンテンツ枠はタイトルやサムネイルが綺麗にレイアウトされているため、枠内に押し込むと見た目が崩れるため。

ブログへのリアクション通知にはブログタイトルも含まれる。ブラウザ側でタイトルとURLを data-* 属性経由でReactionBarに渡し、toggle APIに送信する方式にした。


バグ修正で学んだこと

notes の ID が NULL問題

実装当初、リアクションをページリロードすると消えるバグがあった。調べてみると、すべてのリアクションが note_null という post_id で保存されていた。

原因は、つぶやきを保存するテーブルの id カラムが TEXT 型で、実際のデータがすべて NULL になっていたこと。SQLiteには rowid という暗黙の整数IDが全テーブルに存在するため、SELECT *, rowid FROM notes にしてフォールバックとして使うことで解決した。

// id が NULL の場合は rowid を使う
id: (note as any).id ?? (note as any).rowid

削除済み絵文字がタイムラインに残る問題

絵文字を削除してもタイムライン上でリアクションボタンが消えないことがあった。

原因は、リアクション一覧を取得するAPIがリアクション記録テーブルを単独でクエリしており、絵文字マスタとの照合を行っていなかったこと。INNER JOIN に変更して、絵文字マスタに存在する絵文字のリアクションのみを返すようにした。

-- 修正前: 削除済み絵文字のリアクションも返してしまう
SELECT post_id, emoji, COUNT(*) as count FROM post_reactions
WHERE post_id IN (...) GROUP BY post_id, emoji

-- 修正後: 絵文字マスタに存在するものだけ返す
SELECT pr.post_id, pr.emoji, COUNT(*) as count
FROM post_reactions pr
INNER JOIN reaction_emojis re ON re.emoji = pr.emoji
WHERE pr.post_id IN (...) GROUP BY pr.post_id, pr.emoji

外部RSSフィードのreactionが動作しない問題

外部RSSフィード(はてなブログ等)のブログカードにリアクションボタンを追加したが、ボタンを押しても無反応という問題があった。

原因は、外部フィードの post_id にドット(.)が含まれていたこと。フィードのホスト名(例: arismmn.hatenablog.com)をそのままIDに使っていたため、post_id の正規表現バリデーション([\w-]{1,50}. は含まない)で弾かれていた。

ID生成時にホスト名をサニタイズして ./_ に置換することで解決した。

// 修正前: "blog_rss_arismmn.hatenablog.com_a1b2c3d4" → バリデーション失敗
id: `blog_rss_${config.name}_${fnv1a(link)}`

// 修正後: "blog_rss_arismmn_hatenablog_com_a1b2c3d4" → バリデーション通過
id: `blog_rss_${config.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${fnv1a(link)}`

❤️ などの合字絵文字について

❤️ のような合字絵文字は JavaScript の string.length で 2 以上になる場合がある(UTF-16 のサロゲートペアのため)。バリデーションで emoji.length > 30 としているのはその余裕を持たせるため。SQLite(D1)はUTF-8で正しく絵文字を保存できるので問題ない。

Claude Code
Powered by
Claude Code
(使用モデル Sonnet 4.6)
← ブログ一覧に戻る