😊

NostrでSNSを作ってみた

2024/11/22に公開

はじめに

現在、弊社の社内開発でNostrを利用しています。
https://zenn.dev/astrskcojp/articles/355fe1162962c2

備忘録を兼ねてNostrについてまとめてみようと思います。

内容が多かったので、3つの記事に分けてまとめました。

  1. 用語解説
  2. リレーサーバーを建てる
  3. 簡易SNSを作る(今回)

今回は簡易SNSを作ってみます。Nostr用語での「クライアント」を作ります。

  • 投稿の作成
  • 投稿の取得
  • プロフィールの作成
  • プロフィールの取得

対象読者

  • Nostrに興味がある人
  • NostrでSNSを作ってみたい
  • Nostrを触ってみたい人

環境

  • react
  • vite
  • typescript

補助ツール

Nostrにはオープンソースの補助ツールがいくつかあります。

今回はこの2つを利用しています。

Nos2x

Nos2xはChromeの拡張機能です。

  • 秘密鍵の管理
  • 秘密鍵・公開鍵の生成

この2つを行ってくれます。クライアント側に秘密鍵を保持させることもないし、複数のクライアントで共有も簡単になります。

セットアップなどはこちらを参照してください。

拡張機能以外にもプログラム側からもNos2xを利用できます。

const publicKey = await window.nostr.getPublicKey();

このようにwindowオブジェクトにnostrというプロパティが追加されて、そこからメソッドを利用できます。

window-nostr.d.ts
import type { Event as NostrEvent, UnsignedEvent } from "nostr-tools/pure";

type NostrAPI = {
  /** returns a public key as hex */
  getPublicKey(): Promise<string>;
  /** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
  signEvent(event: UnsignedEvent): Promise<NostrEvent>;

  // Optional

  /** returns a basic map of relay urls to relay policies */
  getRelays(): Promise<{ [url: string]: { read: boolean; write: boolean } }>;

  /** NIP-04: Encrypted Direct Messages */
  nip04?: {
    /** returns ciphertext and iv as specified in nip-04 */
    encrypt(pubkey: string, plaintext: string): Promise<string>;
    /** takes ciphertext and iv as specified in nip-04 */
    decrypt(pubkey: string, ciphertext: string): Promise<string>;
  };

  nip44: {
    encrypt(peer: string, plaintext: string): Promise<string>;

    decrypt(peer: string, ciphertext: string): Promise<string>;
  };
};

declare global {
  interface Window {
    nostr?: NostrAPI;
  }
}

typescriptの場合は、このような型ファイルを用意すると楽です。
https://gist.github.com/syusui-s/cd5482ddfc83792b54a756759acbda55 をお借りしました。ありがとうございます🙏)

nostr-tools

nostr-toolsはNostr関連で利用する機能をメソッドで提供してくれます。

  • リレーサーバーへの接続
  • イベントへの署名

などの処理がまとめられています。

Gitプロジェクト

全体のソースを先に参照したい方はこちらをご参照ください。

https://github.com/fujibayashi-ast/nostr-sample-client

準備

  • リレーサーバーを建てる or 既存のリレーサーバーを利用
  • react + vite + typescriptのプロジェクトを作成

リレーサーバーを建てる or 既存のリレーサーバーを利用

前回、リレーサーバーを建てた方はそちらを利用してください。

リレーサーバーを自分で建てていない方は、こちらから好きなリレーサーバーを選択してURLをメモしておいてください。

react + vite+ typescriptのプロジェクトを作成

npm create vite@latestを実行して、React, typescriptを選択してください。
(これ以外の方法で環境構築をしても大丈夫です)

クライアントの作成

  • Nos2xの利用確認
  • ルーティングの設定
  • 投稿の取得・表示
  • 投稿の作成
  • プロフィールの取得・表示
  • プロフィールの作成

Nos2xの利用確認

まずは、ユーザーがNos2xを利用しているかを確認します。

src/App.tsx
import { useCallback, useEffect, useState } from "react";

function App() {
  const [isChecking, setIsChecking] = useState(true);
  const [canNos2x, setCanNos2x] = useState(false);

  // nos2xの確認
  const checkNos2x = useCallback(() => {
    // windowオブジェクトへのアクセスが可能になるまで待つ、上限を設定
    let maxWait = 20;

    setIsChecking(true);

    const interval = setInterval(() => {
      // nos2xが使えない
      if (maxWait <= 0) {
        alert("nos2xを追加してください");
        clearInterval(interval);
        setIsChecking(false);
        setCanNos2x(false);
        return;
      }

      // nos2xが使える
      if (window.nostr) {
        clearInterval(interval);
        setIsChecking(false);
        setCanNos2x(true);
        return;
      }

      maxWait--;
    }, 200);

    return interval;
  }, []);

  useEffect(() => {
    checkNos2x();
  }, [checkNos2x]);

  if (isChecking) {
    return <p>nos2x利用確認中です…</p>;
  }

  if (!canNos2x) {
    return <p>nos2xが使えません</p>;
  }

  return <>Sample Nostr Client</>;
}

export default App;

ユーザーがNos2xを利用している場合、windowオブジェクトからnostrを参照することができます。逆を言えば、window.nostrを参照できるかどうかでNos2xを利用しているかどうか判定できます。

// nos2xの確認
const checkNos2x = useCallback(() => {
// windowオブジェクトへのアクセスが可能になるまで待つ、上限を設定して
let maxWait = 20;

setIsChecking(true);

const interval = setInterval(() => {
  // nos2xが使えない
  if (maxWait <= 0) {
    alert("nos2xを追加してください");
    clearInterval(interval);
    setIsChecking(false);
    setCanNos2x(false);
    return;
  }

  // nos2xが使える
  if (window.nostr) {
    clearInterval(interval);
    setIsChecking(false);
    setCanNos2x(true);
    return;
  }

  maxWait--;
}, 200);

return interval;
}, []);

windowオブジェクトにアクセスできるようになるまでに、ややラグがあるのでsetIntervalを利用してwindow.nostrを参照し続けています。

Nos2xが設定できていれば、このように表示されます。

利用できていない場合は、アラートが出ます。

ルーティングの設定

Nostrとは関係ないですが、わかりやすいようにルーティングの設定も記載しておきます。

src/App.tsx
import { useCallback, useEffect, useState } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { IndexPage } from "./pages";
import { ProfilePage } from "./pages/profile";
import { CommonLayout } from "./layouts/common-layout";

function App() {
  const [isChecking, setIsChecking] = useState(true);
  const [canNos2x, setCanNos2x] = useState(false);

  // nos2xの確認
  const checkNos2x = useCallback(() => {
    // 省略
  }, []);

  useEffect(() => {
    checkNos2x();
  }, [checkNos2x]);

  if (isChecking) {
    return <p>nos2x利用確認中です…</p>;
  }

  if (!canNos2x) {
    return <p>nos2xが使えません</p>;
  }

  const router = createBrowserRouter([
    {
      element: <CommonLayout />,
      children: [
        {
          path: "/",
          element: <IndexPage />,
        },
        {
          path: "/profile",
          element: <ProfilePage />,
        },
      ],
    },
  ]);

  return <RouterProvider router={router} />;
}

export default App;

2ページだけ用意しました。

  • /:IndexPage: タイムラインを表示。投稿フォームを表示
  • /profile:ProfilePage: プロフィールを表示。プロフィール編集フォームを表示

それぞれのページの内容は以降で解説します。

投稿の取得・表示

まずは、投稿の取得と表示を行っていきます。
(リレーサーバーを自分で建てている場合はデータが何も無いので何も表示されません)

src/components/timeline.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { SimplePool, Event } from "nostr-tools";
import { ShortTextNote } from "nostr-tools/kinds";
import { SubCloser } from "nostr-tools/abstract-pool";
import { TimelineItem } from "./timeline-item";

export const Timeline = () => {
  const [timeline, setTimeline] = useState<Event[]>([]);
  const subCloser = useRef<SubCloser>();

  const setTimelineSubscribe = useCallback(() => {
    const pool = new SimplePool();

    subCloser.current = pool.subscribeMany(
      RELAY_SERVERS,
      [
        {
          authors: undefined,
          kinds: [ShortTextNote],
        },
      ],
      {
        onevent(event) {
          setTimeline((state) => {
            return [...state, event];
          });
        },
        onclose() {
          subCloser.current?.close();
        },
      }
    );
  }, []);

  useEffect(() => {
    if (!subCloser.current) {
      setTimelineSubscribe();
    }
  }, [setTimelineSubscribe]);

  return (
    <div>
      {timeline
        .sort((a, b) => b.created_at - a.created_at)
        .map((item) => (
          <TimelineItem key={item.id} {...item} />
        ))}
    </div>
  );
};

リレーサーバーに対して購読(サブスクリプション)を行い、「既に投稿されているイベント」と「現在に投稿されたイベント」を取得しています。

const setTimelineSubscribe = useCallback(() => {
    const pool = new SimplePool();
    
    subCloser.current = pool.subscribeMany(
      ["http://localhost:8080/"],
      [
        {
          authors: undefined,
          kinds: [ShortTextNote],
        },
      ],
      {
        onevent(event) {
          setTimeline((state) => {
            return [...state, event];
          });
        },
        onclose() {
          subCloser.current?.close();
        },
      }
    );
}, []);

nostr-toolsSimplePoolクラスを利用しています。
subscribeManyというメソッドを使って複数のリレーサーバーへ接続して、イベントの購読が行えます。
3つ引数を指定できます。

  • リレーサーバーのURLの配列
  • イベント取得のフィルターの配列
  • 各種ハンドラー

返り値は、購読を止めるためのインスタンスです。

const pool = new SimplePool();

subCloser.current = pool.subscribeMany(
  ["http://localhost:8080/"],
  [
    {
      authors: undefined,
      kinds: [ShortTextNote],
    },
  ],
  {
    onevent(event) {
      setTimeline((state) => {
        return [...state, event];
      });
    },
    onclose() {
      subCloser.current?.close();
    },
  }
);

リレーサーバーは、ローカルを指定しています。["http://localhost:8080/"]

フィルターは、authorsundefinedとすることで、全ユーザーのイベントを取得できます。(公開鍵を配列で指定すると、ユーザーを絞って取得できます)
kindsShortTextNoteを指定して、テキストメッセージのみにしぼっています。(イベントの種類はこちらを参照)
oneventではイベント取得時の処理を指定しています。
oncloseで終了時の処理を指定しています、

<div>
  {timeline
    .sort((a, b) => b.created_at - a.created_at)
    .map((item) => (
      <TimelineItem key={item.id} {...item} />
    ))}
</div>

取得したイベントをtimelineというstateで管理して、ソートして表示させています。

TimelineItem側では、eventをそのままpropsとして渡しています。

src/components/timeline-item.tsx
import { FC, useCallback, useEffect, useState } from "react";
import { SimplePool } from "nostr-tools";
import { Metadata } from "nostr-tools/kinds";
import {
  Avatar,
  Card,
  CardHeader,
  CardContent,
  CardActions,
  Typography,
} from "@mui/material";
import { Profile } from "../entities";
import dayjs from "dayjs";

type TimelineItemProps = {
  pubkey: string;
  content: string;
  created_at: number;
};

export const TimelineItem: FC<TimelineItemProps> = ({
  pubkey,
  content,
  created_at,
}) => {
  const [profile, setProfile] = useState<Profile>();

  // 日付
  const createdAt = useMemo(() => dayjs(created_at * 1000), [created_at]);

  // プロフィールを取得
  const getProfile = useCallback(async () => {
    const pool = new SimplePool();

    const event = await pool.get(["http://localhost:8080/"], {
      kinds: [Metadata],
      authors: [pubkey],
    });

    if (event) {
      const profile = JSON.parse(event.content) as Profile;

      setProfile(profile);
    }
  }, [pubkey]);

  useEffect(() => {
    getProfile();
  }, [getProfile]);

  return (
    <Card sx={{ marginBottom: 5 }}>
      {profile && (
        <CardHeader
          title={
            <div style={{ display: "flex" }}>
              <Avatar src={profile?.picture} sx={{ marginRight: 1 }} />

              <Typography alignContent="center">{profile.name}</Typography>
            </div>
          }
        />
      )}

      <CardContent>
        <Typography>{content}</Typography>
      </CardContent>

      <CardActions>
        <Typography>{createdAt.format("YYYY/MM/DD (ddd) HH:mm")}</Typography>
      </CardActions>
    </Card>
  );
};
  • content: 投稿内容
  • created_at: 投稿日時

を表示させています。

また、プロフィールの取得・表示も行っています。

// プロフィールを取得
const getProfile = useCallback(async () => {
    const pool = new SimplePool();

    const event = await pool.get(["http://localhost:8080/"], {
      kinds: [Metadata],
      authors: [pubkey],
    });
    
    if (event) {
      const profile = JSON.parse(event.content) as Profile;
    
      setProfile(profile);
    }
}, [pool, pubkey]);

投稿の取得の時は購読にしていましたが、プロフィールなので取得だけに留めています。

  • リレーサーバーのURLの配列
  • イベント取得のフィルター
    が引数となります。
const event = await pool.get(["http://localhost:8080/"], {
  kinds: [Metadata],
  authors: [pubkey],
});

subscribeと同様リレーサーバーはローカルのリレーサーバーのURLを指定しています。["http://localhost:8080/"]

フィルターは、kindsMetadataに指定して、プロフィールのみにしぼっています。(イベントの種類はこちらを参照)
authorsを特定の公開鍵を単体指定しています。
こうすることで、指定した公開鍵のプロフィール情報を取得できます。

if (event) {
  const profile = JSON.parse(event.content) as Profile;

  setProfile(profile);
}

プロフィール情報はevent.contentにJSON形式の文字列になっているので、パースしています。

投稿の作成

次に投稿を行えるようにします。

src/components/post-form.tsx
import { Button, TextareaAutosize } from "@mui/material";
import dayjs from "dayjs";
import { SimplePool, UnsignedEvent, verifyEvent } from "nostr-tools";
import { ShortTextNote } from "nostr-tools/kinds";
import { ChangeEvent, useState } from "react";

export const PostForm = () => {
  const [content, setContent] = useState("");

  const handleTextAreaChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
    setContent(event.target.value);
  };

  const pool = new SimplePool();
  const handleClickButton = async () => {
    if (!window.nostr) {
      alert("nos2xを追加してください");
      return;
    }

    const publicKey = await window.nostr.getPublicKey();

    const replyEvent: UnsignedEvent = {
      kind: ShortTextNote,
      created_at: dayjs().unix(),
      tags: [],
      content: content,
      pubkey: publicKey,
    };

    const event = await window.nostr.signEvent(replyEvent);

    const isGood = verifyEvent(event);

    if (isGood) {
      await Promise.all(pool.publish(["http://localhost:8080/"], event));

      setContent("");
    } else {
      throw new Error("投稿に失敗しました");
    }
  };

  return (
    <div>
      <TextareaAutosize
        minRows={5}
        value={content}
        onChange={handleTextAreaChange}
      />

      <Button onClick={handleClickButton}>送信</Button>
    </div>
  );
};

テキストフォームに入力した内容をボタン押下で投稿するシンプルなものです。

const handleClickButton = async () => {
    if (!window.nostr) {
      alert("nos2xを追加してください");
      return;
    }

    const publicKey = await window.nostr.getPublicKey();
    
    const unsignedEvent: UnsignedEvent = {
      kind: ShortTextNote,
      created_at: dayjs().unix(),
      tags: [],
      content: content,
      pubkey: publicKey,
    };
    
    const event = await window.nostr.signEvent(unsignedEvent);
    
    const isGood = verifyEvent(event);
    
    if (isGood) {
      await Promise.all(pool.publish(["http://localhost:8080/"], event));
    
      setContent("");
    } else {
      throw new Error("投稿に失敗しました");
    }
};

公開鍵を使ってイベントを作成して、リレーサーバーに投稿しています。

const publicKey = await window.nostr.getPublicKey();

getPublicKeyを利用することで、nos2xに保存されている公開鍵を取得できます。

const unsignedEvent: UnsignedEvent = {
  kind: ShortTextNote,
  created_at: dayjs().unix(),
  tags: [],
  content: content,
  pubkey: publicKey,
};
  • テキストの投稿なのでkinds1は指定
  • contentはフォームの内容を指定
  • pubkeyは公開鍵を指定
const event = await window.nostr.signEvent(unsignedEvent);

nos2xsignEventで、イベントに署名を行えます。

const isGood = verifyEvent(event);

nostr-toolsverifyEventで、イベントの検証も行えます。

if (isGood) {
  pool.publish(RELAY_SERVERS, event);

  setContent("");
} else {
  throw new Error("投稿に失敗しました");
}

nostr-toolsSimplePoolpublishでイベントの投稿を行えます。
第一引数には投稿先のリレーサーバーのURL、第二引数には投稿するイベントを指定します。

投稿してタイムラインに表示されればOKです。(もし不具合などあればコメントください!)

プロフィールの取得・表示

次に自身のプロフィールの取得と表示を行います。

src/pages/profile.tsx
import { Avatar, Box, Divider, Input } from "@mui/material";
import { SimplePool } from "nostr-tools";
import { ChangeEvent, useCallback, useEffect, useState } from "react";
import { Metadata } from "nostr-tools/kinds";
import { Profile } from "../entities";

export const ProfilePage = () => {
  const [profile, setProfile] = useState<Profile | null>();

  const getProfile = useCallback(async () => {
    if (!window.nostr) {
      alert("nos2xを追加してください");
      return;
    }

    const publicKey = await window.nostr.getPublicKey();

    const pool = new SimplePool();

    const event = await pool.get(["http://localhost:8080/"], {
      kinds: [Metadata],
      authors: [publicKey],
    });

    if (event) {
      const profile = JSON.parse(event.content) as Profile;

      setProfile(profile);
    }
  }, [pool]);

  useEffect(() => {
    getProfile();
  }, [getProfile]);

  return (
    <Box sx={{ maxWidth: "600px", marginTop: 5 }}>
      <h1>Profile</h1>

      <Divider sx={{ bgcolor: "white" }} />

      <h2>Icon</h2>
      <Avatar src={profile?.picture} sx={{ marginBottom: 2 }} />
      <Input
        sx={{ color: "white" }}
        placeholder={profile?.picture ?? "No Link"}
        value={profile?.picture ?? ""}
      />

      <h2>Name</h2>
      <Input
        sx={{ color: "white" }}
        placeholder={profile?.name ?? "No Name"}
        value={profile?.name ?? ""}
      />
    </Box>
  );
};

前述でイベントの投稿者のプロフィールを取得していますが、それとほとんど同じです。

const getProfile = useCallback(async () => {
    if (!window.nostr) {
      alert("nos2xを追加してください");
      return;
    }
    
    const publicKey = await window.nostr.getPublicKey();
    
    const event = await pool.get(["http://localhost:8080/"], {
      kinds: [Metadata],
      authors: [publicKey],
    });
    
    if (event) {
      const profile = JSON.parse(event.content) as Profile;
    
      setProfile(profile);
    }
}, [pool]);

authorsで自身の公開鍵を指定することで、自身のプロフィールを取得できます。

プロフィールの作成

最後にプロフィールを作成できるよう、Inputにハンドラーを渡していきます。
アイコンの画像は、画像自体ではなくてホスティング先のURLを入力します。

src/pages/profile.tsx
import { Avatar, Box, Divider, Input } from "@mui/material";
import { SimplePool, verifyEvent } from "nostr-tools";
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Metadata } from "nostr-tools/kinds";
import dayjs from "dayjs";
import { Profile } from "../entities";

export const ProfilePage = () => {
  const [profile, setProfile] = useState<Profile | null>();
  const [loading, setLoading] = useState(false);

  const pool = useMemo(() => new SimplePool(), []);

  const getProfile = useCallback(async () => {
    省略
  }, [pool]);

  useEffect(() => {
    getProfile();
  }, [getProfile]);

  const handleAvatarChange = (event: ChangeEvent<HTMLInputElement>) => {
    setProfile((state) => ({
      ...state,
      picture: event.target.value,
    }));
  };

  const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {
    setProfile((state) => ({
      ...state,
      name: event.target.value,
    }));
  };

  const handleInputBlur = async () => {
    if (!window.nostr) {
      return;
    }

    setLoading(true);

    const publicKey = await window.nostr.getPublicKey();

    const event = await window.nostr.signEvent({
      kind: Metadata,
      content: JSON.stringify(profile),
      pubkey: publicKey,
      tags: [],
      created_at: dayjs().unix(),
    });

    if (verifyEvent(event)) {
      await pool.publish(["http://localhost:8080/"], event);
    }

    setLoading(false);
  };

  return (
    <Box sx={{ maxWidth: "600px", marginTop: 5 }}>
      <h1>Profile</h1>

      <Divider sx={{ bgcolor: "white" }} />

      <h2>Icon</h2>
      <Avatar src={profile?.picture} sx={{ marginBottom: 2 }} />
      <Input
        sx={{ color: "white" }}
        placeholder={profile?.picture ?? "No Link"}
        value={profile?.picture ?? ""}
        onChange={handleAvatarChange}
        onBlur={handleInputBlur}
      />

      <h2>Name</h2>
      <Input
        sx={{ color: "white" }}
        placeholder={profile?.name ?? "No Name"}
        value={profile?.name ?? ""}
        onChange={handleNameChange}
        onBlur={handleInputBlur}
      />

      {loading && <p>Saving...</p>}
    </Box>
  );
};

入力を監視して、blurのタイミングでプロフィールを更新するようにしています。

  const handleInputBlur = async () => {
    if (!window.nostr) {
      return;
    }

    setLoading(true);

    const publicKey = await window.nostr.getPublicKey();

    const event = await window.nostr.signEvent({
      kind: Metadata,
      content: JSON.stringify(profile),
      pubkey: publicKey,
      tags: [],
      created_at: dayjs().unix(),
    });

    if (verifyEvent(event)) {
      await pool.publish(["http://localhost:8080/"], event);
    }

    setLoading(false);
  };

投稿の作成と同じ要領で、イベントを作成します。

const event = await window.nostr.signEvent({
  kind: Metadata,
  content: JSON.stringify(profile),
  pubkey: publicKey,
  tags: [],
  created_at: dayjs().unix(),
});

投稿と違うところは、kindcontentです。

  • kind: Metadataを指定(0のことです)
  • content: json形式のプロフィールを文字列に変換して指定

入力するとこんな感じでプロフィールが保存されるかと思います。

投稿側にも反映されてるかと思います。

まとめ

簡易的にですが、SNSをNostrに則って作ってみました。
SNS以外のものにも利用できるので、何か思いついたらまた作ってみようかなと思います。

ASTRSK

Discussion