React用のマストドンウィジェットを作った話
なぜ作ったか
以前、twitter でスポーツの試合速報 bot を運用していると書いたことがある
のですが、twitter API の有料化で自動投稿ができなくなり、マストドンに移住しました。
移住して自動トゥート(と言うらしいです)できるようになったのは良いのですが、やはりウィジェット化するのはロマンの一つかと思いまして。
技術的な話
masto.js
マストドンAPIクライアントはこちらのライブラリを使用しています。
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;
}
参考
動作デモ
こちらのページの下部に実際に動いているウィジェットがあります。
jQuery を使用したマストドンウィジェット
Discussion