NostrでSNSを作ってみた
はじめに
現在、弊社の社内開発でNostrを利用しています。
備忘録を兼ねてNostrについてまとめてみようと思います。
内容が多かったので、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
というプロパティが追加されて、そこからメソッドを利用できます。
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プロジェクト
全体のソースを先に参照したい方はこちらをご参照ください。
準備
- リレーサーバーを建てる or 既存のリレーサーバーを利用
- react + vite + typescriptのプロジェクトを作成
リレーサーバーを建てる or 既存のリレーサーバーを利用
前回、リレーサーバーを建てた方はそちらを利用してください。
リレーサーバーを自分で建てていない方は、こちらから好きなリレーサーバーを選択してURLをメモしておいてください。
react + vite+ typescriptのプロジェクトを作成
npm create vite@latest
を実行して、React
, typescript
を選択してください。
(これ以外の方法で環境構築をしても大丈夫です)
クライアントの作成
- Nos2xの利用確認
- ルーティングの設定
- 投稿の取得・表示
- 投稿の作成
- プロフィールの取得・表示
- プロフィールの作成
Nos2xの利用確認
まずは、ユーザーがNos2x
を利用しているかを確認します。
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とは関係ないですが、わかりやすいようにルーティングの設定も記載しておきます。
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
: プロフィールを表示。プロフィール編集フォームを表示
それぞれのページの内容は以降で解説します。
投稿の取得・表示
まずは、投稿の取得と表示を行っていきます。
(リレーサーバーを自分で建てている場合はデータが何も無いので何も表示されません)
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-tools
のSimplePool
クラスを利用しています。
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/"]
フィルターは、authors
をundefined
とすることで、全ユーザーのイベントを取得できます。(公開鍵を配列で指定すると、ユーザーを絞って取得できます)
kinds
はShortTextNote
を指定して、テキストメッセージのみにしぼっています。(イベントの種類はこちらを参照)
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として渡しています。
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/"]
フィルターは、kinds
をMetadata
に指定して、プロフィールのみにしぼっています。(イベントの種類はこちらを参照)
authors
を特定の公開鍵を単体指定しています。
こうすることで、指定した公開鍵のプロフィール情報を取得できます。
if (event) {
const profile = JSON.parse(event.content) as Profile;
setProfile(profile);
}
プロフィール情報はevent.content
にJSON形式の文字列になっているので、パースしています。
投稿の作成
次に投稿を行えるようにします。
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,
};
- テキストの投稿なので
kinds
は1
は指定 -
content
はフォームの内容を指定 -
pubkey
は公開鍵を指定
const event = await window.nostr.signEvent(unsignedEvent);
nos2x
のsignEvent
で、イベントに署名を行えます。
const isGood = verifyEvent(event);
nostr-tools
のverifyEvent
で、イベントの検証も行えます。
if (isGood) {
pool.publish(RELAY_SERVERS, event);
setContent("");
} else {
throw new Error("投稿に失敗しました");
}
nostr-tools
のSimplePool
のpublish
でイベントの投稿を行えます。
第一引数には投稿先のリレーサーバーのURL、第二引数には投稿するイベントを指定します。
投稿してタイムラインに表示されればOKです。(もし不具合などあればコメントください!)
プロフィールの取得・表示
次に自身のプロフィールの取得と表示を行います。
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を入力します。
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(),
});
投稿と違うところは、kind
とcontent
です。
-
kind
:Metadata
を指定(0
のことです) -
content
: json形式のプロフィールを文字列に変換して指定
入力するとこんな感じでプロフィールが保存されるかと思います。
投稿側にも反映されてるかと思います。
まとめ
簡易的にですが、SNSをNostrに則って作ってみました。
SNS以外のものにも利用できるので、何か思いついたらまた作ってみようかなと思います。
ASTRSK(astrsk.co.jp)は、スタートアップ・新規事業開発に強いシステム開発会社です。サービスやシステムの構築を得意としており、開発をはじめデザインやUXにおいても優れた経験と技術を有しています。
Discussion