📘

React用のマストドンウィジェットを作った話

2023/05/24に公開

なぜ作ったか

以前、twitter でスポーツの試合速報 bot を運用していると書いたことがある
https://zenn.dev/ckoshien/articles/259004637d4476

のですが、twitter API の有料化で自動投稿ができなくなり、マストドンに移住しました。
移住して自動トゥート(と言うらしいです)できるようになったのは良いのですが、やはりウィジェット化するのはロマンの一つかと思いまして。

技術的な話

masto.js

マストドンAPIクライアントはこちらのライブラリを使用しています。
https://github.com/neet/masto.js

useSWR を使うことでリアルタイムで追従する

当初は useSWR を使わずに useEffect でコンポーネントのマウント時に一回だけ読み込む仕様にしていたのですが、ページ全体をリロードしないとリアルタイムに追えないウィジェットなんて、ウィジェットらしくないと思うので useSWR で実装し直しました。

ローカル ID は事前に取得しておくのが良さそう

マストドンの API アクセスでタイムラインを取得する際、スクリーンネームではなくローカル ID という連番を使います。ローカル ID 自体はプロフィール API を叩けば取得できたと記憶していますが、特にプロフィール API を何回も叩く必要がなかったので、今回は一回取得しておいたローカル ID をそのまま記述しています。

const masto = await login({
  url: "https://mstdn.jp/",
  accessToken: TOKEN,
});
const result = await masto.v1.accounts.listStatuses(LOCAL_ID);

OGP の生成が非同期で行われている間、API レスポンスから OGP の値が返却されない

これが今回一番の嵌りどころだったと思いますが、twitter と同じように OGP で指定した画像を描画する機能がマストドンにもあります。
ただし、OGP で指定されている URL を格納するのではなく、独自にキャッシュしているのか、生成中の場合は API レスポンスから OGP カード自体の項目が消えてしまい、それに気づかず本番反映した時は見事にページ全体が落ちたという想い出があります。

status.contentは本文を指します。
その後の status?.card... が optional であることに注意してください。

<div
    className="innerHTML"
        dangerouslySetInnerHTML={{ __html: status.content }}
/>
    <a href={status?.card?.url}>
    <img
        style={{
        objectFit: "cover",
        width: 300,
        paddingLeft: 25,
        }}
        src={status?.card?.image}
    />
    </a>
</div>

ソース全体

MastdonWidget.tsx

import { login } from "masto";
import moment from "moment";
import styles from "./MastdonWiget.module.scss";
import useSWR from "swr";
const TOKEN = "***";
const LOCAL_ID = "****";
moment().locale("ja");
const MastdonWidget = () => {
  const { data: statusList } = useSWR("mstdn", async () => await fetcher());
  return (
    <div className={styles["main-wrapper"]}>
      <div
        style={{
          height: 40,
          fontWeight: "bold",
          fontSize: 18,
          borderBottom: "1px solid white",
        }}
      >
        Toots from {"@cap_scorebook"}
      </div>
      <div className={styles["main"]}>
        {statusList?.map((status) => (
          <div
            style={{
              borderBottom: "1px solid #393f4f",
              padding: 8,
            }}
          >
            <div
              style={{
                display: "flex",
              }}
            >
              <div>
                <img
                  src={status.account.avatar}
                  style={{
                    width: 48,
                  }}
                />
              </div>

              <div
                style={{
                  marginLeft: 10,
                }}
              >
                <a
                  style={{
                    fontWeight: "bold",
                    color: "white",
                  }}
                  href={status.account.url}
                >
                  {status.account.displayName}
                </a>
                <br />
                <span
                  style={{
                    fontSize: 14,
                  }}
                >
                  {"@cap_scorebook@mstdn.jp"}
                </span>
              </div>
              <div
                style={{
                  fontSize: 13,
                  textAlign: "right",
                  width: 95,
                }}
              >
                {moment(status.createdAt).locale("ja").fromNow()}
              </div>
            </div>
            <div
              className="innerHTML"
              dangerouslySetInnerHTML={{ __html: status.content }}
            />
            <a href={status?.card?.url}>
              <img
                style={{
                  objectFit: "cover",
                  width: 300,
                  paddingLeft: 25,
                }}
                src={status?.card?.image}
              />
            </a>
          </div>
        ))}
      </div>
    </div>
  );
};
export default MastdonWidget;

export const fetcher = async () => {
  const masto = await login({
    url: "https://mstdn.jp/",
    accessToken: TOKEN,
  });
  const result = await masto.v1.accounts.listStatuses(LOCAL_ID);
  return result;
};

MastdonWiget.module.scss

.main {
  width: 350px;
  background-color: #1f232b;
  font-size: 15px;
  padding: 14px 10px;
  height: 370px;
  overflow-y: scroll;
  color: white;
  p {
    color: white;
    padding: 8px;
    a {
      color: #4e79df;
    }
  }
}
.main-wrapper {
  border-radius: 10px;
  background-color: #1f232b;
  color: white;
  padding: 4px;
}

参考

動作デモ

こちらのページの下部に実際に動いているウィジェットがあります。
https://cap-scorebook.com/

jQuery を使用したマストドンウィジェット

https://github.com/AzetJP/mastodon-timeline-widget

xtone tech blog

Discussion