この記事はNostr Advent Calendar 2025 の 22 日目の記事です。
<< 前回 mono さん | 次回 penpenpeng さん >>
誰でもお好みの Nostr クライアントが作れるサイト mojimoji(もじもじ)を作ってみました。
上記は初期画面ですが、wss://yabu.me に投稿された投稿、たとえば「ぽわ~」が線を流れていってタイムラインに入るので、左のタイムラインに表示されるわけですね。
こんな感じでパーツとパーツを繋いで回路を作るようなスタイルを「モジュラー」といいます。ギターのコンパクトエフェクターとか、VSTPlugin とか Max/Msp とか、楽器はモジュラー型になっていることが多いですね。あとは Blender とか Unity のシェーダーとか、3D モデリングのソフトウェアはモジュラー型になっていることが多いです。
この箱の形をしたパーツのことを、このアプリではノードと呼びます。上記の画面だとリレーノードとタイムラインノードがあります。各ノードは端子として入力ソケットと出力ソケットを持っています。入力ソケットからは別の入力ソケットへパイプが繋げます。この繋がり全体のことをグラフと呼びます。
いろんなフィルタ
mojimoji にはいろいろなフィルタがあって、組み合わせることができます。
例: ロシア語フィルタ
例えば、リレーを wss://relay.damus.io にして、左上の「+フィルタ」→「+言語」を選んで言語ノードを作って、"Russian" を選択すると、wss://relay.damus.io に投稿されたロシア語の投稿だけがフィルタされてタイムラインに見えてきます。
例: yabu.me にあって relay.damus.io にない投稿を取り出す
もうちょっと複雑なのだと、wss://yabu.me の投稿から wss://relay.damus.io の投稿を引き算すると、「yabu.me にはあるけど relay.damus.io にはない投稿」だけが抽出できます。
グラフの保存と共有
作ったグラフは auto save によって勝手に localStorage に保存されているので、サイトを閉じても閉じる前に開いていたグラフが戻ってきます。
複数のグラフを保存したいときは、保存ボタンを用いて、localStorage / Nostr Relay / File の3つの保存先を選ぶことができます。フォルダも作れちゃったりなんかしてます。

Nostr Relay を選ぶと、kind:30078 の上書き可能イベントとしてリレーに放流されます。
Nostr Relay で保存したときは共有ボタンが出てきて、パーマリンク URL をコピーすることができます。例えばさっきの「yabu.me にあって relay.damus.io にない投稿を取り出すグラフ」のアドレスは
https://koteitan.github.io/mojimoji/?e=nevent1qqs9up775g30e28wge75s42sl46udkzxqk5v7hrdsm4jc7a6ks5dqhg63lukt
です。クリックすると開けますね。(読込で選択した他人のグラフのパーマリンクを取得することもできます)
保存仕様の全体はここに載っています。
アプリ固有データは今回初めて使ったので勉強になりました。
実装の話
今回は observable パンクです。
複数のリレーから同じようなイベントがわちゃわちゃと流れてきて「observable で実装して下さい」と言わんばかりの分散プロトコル nostr。そんなものをさばくライブラリはもちろん rx-nostr。 RxJS で実装された nostr ライブラリですね。線を繋ぐ UI は rete.js。余りある適材適所感。
全体的には下記を使っています:
| モジュラー型 UI | rete.js |
| UI | React |
| Nostr ライブラリ | rx-nostr |
| 多言語化(英語・日本語) | react-i18next |
| 言語判定 | franc |
| プログラミング言語 | TypeScript |
| ビルドツール | Vite |
演算ノード
演算ノードはちょっと面白いので解説します。
AND ノード
AND ノードは入力 A, B を持っていて、両方から流れてきたイベントだけが OUT に出ていきます。集合論でいうところの積集合(∩)ですね。
でもこれは非同期なシステムなので、全員揃った A と全員揃った B で積集合を計算して~とかができません。A からは来てるけど B にはあるけどまだ来てない、みたいなこともありえます。
これから「まだ片側しか来ていないイベントは AND ノードで待たされる」という実装になっています。
引き算ノード
集合論でいうところの差集合(/)です。仕様としては、A にあって B にないものだけが通れます。
まずリレーから来たイベントは一旦すべて「+」の符号が付けられます。
そして、この引き算ノードを通ると、B の入り口から入ってきたものの符号が反転します。

上の図では「ぽわ~」は「+」の符号を持った状態で B の入り口から入ってきたので、その後は「-」の符号に反転しています。「うわ~」は A から入ってきたので「+」のまま出て行ってますね。
ここで、「ぽわ~」は A のみから、「うわ~」は A と B の両方からやってきたとしましょう。

そのあと、これら3つはタイムラインに貯まるのですが、タイムラインでは、同じイベントの「+」と「-」があった場合にはそれらは対消滅するように出来ています。
かくしてぷらすぽわだけが残りました。よかったですね。
Yahoo! Pipes
試験的にこの UI を nostr で人に見せてみたら、「Yahoo! Pipes みたい」という声がいくつかありました。
Yahoo! Pipes(ヤフー パイプス)は 2007年2月から2015年9月まで Yahoo! が提供していたアプリで、Pipes Editor というアプリ上でモジュールをドラッグしてパイプで接続し、異なるソースから取得した情報をどのように加工(例えばフィルタリング)すべきかのルールを設定して、RSS フィード や JSON、KML などで出力する、というものだったらしいです。
twitter のローンチが2006年7月なので、そこから半年くらいでもう作られていたんですねぇ。
今後
関数ノード
関数定義入力ノードと関数定義出力ノードがあって、それらの間に好きなノードを挟んでテンプレートを作ったら、そのインスタンスを小さなノードとして表示できて、再帰的に構造化できると巨大なものが作りやすいなと思いました。
Bluesky のモデレーションフィルター化
もともと Bluesky のモデレーションフィルターとして、こういう自由に組み合わせて誰でも作れるものが作りたいなぁと思っていたのが発端でした。あれなんかサーバーとか起動しとかないといけないんでしたっけ。面倒そうで止めてたところ claude code のトークンが余ってたので適当に Nostr で作り始めたらできました。なので、Bluesky 化も時間があればやりたいですね
プラグイン化
Rabbit (Nostr クライアント)はいろいろなカラムを作れるマルチカラムクライアントなので、その1カラムを作れるように出来たらいいなと思っていました。グラフの json を与えると json2json な関数が動く、みたいなプラグインを作ればいいのかも。
フォロータイムライン完全再現
ここまでやっても実は通常のクライアントがやっているフォロータイムラインは得られません。なぜなら、あれを得るためには
- 購読1: bootstrap リレーからユーザの kind:10002 イベントを取得
- 購読2: kind:10002 にある r タグの値をリレーリストとして抽出してユーザーの kind:3 イベントを取得
- 購読3: kind:3 にある p タグの値をフォローリストとして抽出してフォロイーの投稿を取得
と3段階の購読を行わないといけないからです。
現在の mojimoji には下記の機能が足りません。
- タグの値を抽出するタグ値抽出ノード
- タグ値抽出ノードの出力はもはやイベントではなく relay URL だったり pubkey だったりするので流れるものに関して型の概念が必要になる(各ノードは複数の型に対応したテンプレートとなる)
- リレーノードに購読スタートを指令するトリガーソケット
- リレーの EOSE を出力してそれによって別の購読を開始してもらうためのリレー状態ソケット
今現在これのデバッグ中で、標準的タイムラインを得るグラフは下記になります(正しく動いてないけど)

もじゃもじゃですね。
- 黄色: kind:10002 を購読して relay list を抽出
- 緑色: kind:3 を購読してフォローリストを抽出
- 青色: authors にそのフォローリストを突っ込んでタイムライン形成
いやぁ、クライアントってすごいですね。





