Why Nostr? What is Njump?
2025-03-19 11:04:11

Ian on Nostr: #GM my #Nostriches Here's a great tutorial to get you started building #Nostr clients ...

#GM my #Nostriches
Here's a great tutorial to get you started building #Nostr clients in #JavaScript

How to create a nostr app quickly using applesauce

In this guide we are going to build a nostr app that lets users follow and unfollow fiatjaf)

1. Setup new project

Start by setting up a new vite app using pnpm create vite, then set the name and select Solid and Typescript

➜  pnpm create vite
│
◇  Project name:
│  followjaf
│
◇  Select a framework:
│  Solid
│
◇  Select a variant:
│  TypeScript
│
◇  Scaffolding project in ./followjaf...
│
└  Done. Now run:

  cd followjaf
  pnpm install
  pnpm run dev

2. Adding nostr dependencies

There are a few useful nostr dependencies we are going to need. nostr-tools for the types and small methods, and rx-nostr for making relay connections

pnpm install nostr-tools rx-nostr

3. Setup rx-nostr

Next we need to setup rxNostr so we can make connections to relays. create a new src/nostr.ts file with

import { createRxNostr, noopVerifier } from "rx-nostr";

export const rxNostr = createRxNostr({
  // skip verification here because we are going to verify events at the event store
  skipVerify: true,
  verifier: noopVerifier,
});

4. Setup the event store

Now that we have a way to connect to relays, we need a place to store events. We will use the EventStore class from applesauce-core for this. create a new src/stores.ts file with

The event store does not store any events in the browsers local storage or anywhere else. It’s in-memory only and provides a model for the UI

import { EventStore } from "applesauce-core";
import { verifyEvent } from "nostr-tools";

export const eventStore = new EventStore();

// verify the events when they are added to the store
eventStore.verifyEvent = verifyEvent;

5. Create the query store

The event store is where we store all the events, but we need a way for the UI to query them. We can use the QueryStore class from applesauce-core for this.

Create a query store in src/stores.ts

import { QueryStore } from "applesauce-core";

// ...

// the query store needs the event store to subscribe to it
export const queryStore = new QueryStore(eventStore);

6. Setup the profile loader

Next we need a way to fetch user profiles. We are going to use the ReplaceableLoader class from applesauce-loaders for this.

applesauce-loaders is a package that contains a few loader classes that can be used to fetch different types of data from relays.

First install the package

pnpm install applesauce-loaders

Then create a src/loaders.ts file with

import { ReplaceableLoader } from "applesauce-loaders";
import { rxNostr } from "./nostr";
import { eventStore } from "./stores";

export const replaceableLoader = new ReplaceableLoader(rxNostr);

// Start the loader and send any events to the event store
replaceableLoader.subscribe((packet) => {
  eventStore.add(packet.event, packet.from);
});

7. Fetch fiatjaf’s profile

Now that we have a way to store events, and a loader to help with fetching them, we should update the src/App.tsx component to fetch the profile.

We can do this by calling the next method on the loader and passing a pubkey, kind and relays to it

function App() {
  // ...

  onMount(() => {
    // fetch fiatjaf's profile on load
    replaceableLoader.next({
      pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
      kind: 0,
      relays: ["wss://pyramid.fiatjaf.com/"],
    });
  });

  // ...
}

8. Display the profile

Now that we have a way to fetch the profile, we need to display it in the UI.

We can do this by using the ProfileQuery which gives us a stream of updates to a pubkey’s profile.

Create the profile using queryStore.createQuery and pass in the ProfileQuery and the pubkey.

const fiatjaf = queryStore.createQuery(
  ProfileQuery,
  "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
);

But this just gives us an observable, we need to subscribe to it to get the profile.

Luckily SolidJS profiles a simple from method to subscribe to any observable.

To make things reactive SolidJS uses accessors, so to get the profile we need to call fiatjaf()

function App() {
  // ...

  // Subscribe to fiatjaf's profile from the query store
  const fiatjaf = from(
    queryStore.createQuery(ProfileQuery, "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")
  );

  return (
    <>
      {/* replace the vite and solid logos with the profile picture */}
      <div>
        <img src={fiatjaf()?.picture} class="logo" />
      </div>
      <h1>{fiatjaf()?.name}</h1>

      {/* ... */}
    </>
  );
}

9. Letting the user signin

Now we should let the user signin to the app. We can do this by creating a AccountManager class from applesauce-accounts

First we need to install the packages

pnpm install applesauce-accounts applesauce-signers

Then create a new src/accounts.ts file with

import { AccountManager } from "applesauce-accounts";
import { registerCommonAccountTypes } from "applesauce-accounts/accounts";

// create an account manager instance
export const accounts = new AccountManager();

// Adds the common account types to the manager
registerCommonAccountTypes(accounts);

Next lets presume the user has a NIP-07 browser extension installed and add a signin button.

function App() {
  const signin = async () => {
    // do nothing if the user is already signed in
    if (accounts.active) return;

    // create a new nip-07 signer and try to get the pubkey
    const signer = new ExtensionSigner();
    const pubkey = await signer.getPublicKey();

    // create a new extension account, add it, and make it the active account
    const account = new ExtensionAccount(pubkey, signer);
    accounts.addAccount(account);
    accounts.setActive(account);
  };

  return (
    <>
      {/* ... */}

      <div class="card">
        <p>Are you following the fiatjaf? the creator of "The nostr"</p>
        <button onClick={signin}>Check</button>
      </div>
    </>
  );
}

Now when the user clicks the button the app will ask for the users pubkey, then do nothing… but it’s a start.

We are not persisting the accounts, so when the page reloads the user will NOT be signed in. you can learn about persisting the accounts in the docs

10. Showing the signed-in state

We should show some indication to the user that they are signed in. We can do this by modifying the signin button if the user is signed in and giving them a way to sign-out

function App() {
  // subscribe to the currently active account (make sure to use the account$ observable)
  const account = from(accounts.active$);

  // ...

  const signout = () => {
    // do nothing if the user is not signed in
    if (!accounts.active) return;

    // signout the user
    const account = accounts.active;
    accounts.removeAccount(account);
    accounts.clearActive();
  };

  return (
    <>
      {/* ... */}

      <div class="card">
        <p>Are you following the fiatjaf? ( creator of "The nostr" )</p>
        {account() === undefined ? <button onClick={signin}>Check</button> : <button onClick={signout}>Signout</button>}
      </div>
    </>
  );
}

11. Fetching the user’s profile

Now that we have a way to sign in and out of the app, we should fetch the user’s profile when they sign in.

function App() {
  // ...

  // fetch the user's profile when they sign in
  createEffect(async () => {
    const active = account();

    if (active) {
      // get the user's relays or fallback to some default relays
      const usersRelays = await active.getRelays?.();
      const relays = usersRelays ? Object.keys(usersRelays) : ["wss://relay.damus.io", "wss://nos.lol"];

      // tell the loader to fetch the users profile event
      replaceableLoader.next({
        pubkey: active.pubkey,
        kind: 0,
        relays,
      });

      // tell the loader to fetch the users contacts
      replaceableLoader.next({
        pubkey: active.pubkey,
        kind: 3,
        relays,
      });

      // tell the loader to fetch the users mailboxes
      replaceableLoader.next({
        pubkey: active.pubkey,
        kind: 10002,
        relays,
      });
    }
  });

  // ...
}

Next we need to subscribe to the users profile, to do this we can use some rxjs operators to chain the observables together.

import { Match, Switch } from "solid-js";
import { of, switchMap } from "rxjs";

function App() {
  // ...

  // subscribe to the active account, then subscribe to the users profile or undefined
  const profile = from(
    accounts.active$.pipe(
      switchMap((account) => (account ? queryStore.createQuery(ProfileQuery, account!.pubkey) : of(undefined)))
    )
  );

  // ...

  return (
    <>
      {/* ... */}

      <div class="card">
        <Switch>
          <Match when={account() && !profile()}>
            <p>Loading profile...</p>
          </Match>
          <Match when={profile()}>
            <p style="font-size: 1.2rem; font-weight: bold;">Welcome {profile()?.name}</p>
          </Match>
        </Switch>

        {/* ... */}
      </div>
    </>
  );
}

12. Showing if the user is following fiatjaf

Now that the app is fetching the users profile and contacts we should show if the user is following fiatjaf.

function App() {
  // ...

  // subscribe to the active account, then subscribe to the users contacts or undefined
  const contacts = from(
    accounts.active$.pipe(
      switchMap((account) => (account ? queryStore.createQuery(UserContactsQuery, account!.pubkey) : of(undefined)))
    )
  );

  const isFollowing = createMemo(() => {
    return contacts()?.some((c) => c.pubkey === "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d");
  });

  // ...

  return (
    <>
      {/* ... */}

      <div class="card">
        {/* ... */}

        <Switch
          fallback={
            <p style="font-size: 1.2rem;">
              Sign in to check if you are a follower of the fiatjaf ( creator of "The nostr" )
            </p>
          }
        >
          <Match when={contacts() && isFollowing() === undefined}>
            <p>checking...</p>
          </Match>
          <Match when={contacts() && isFollowing() === true}>
            <p style="color: green; font-weight: bold; font-size: 2rem;">
              Congratulations! You are a follower of the fiatjaf
            </p>
          </Match>
          <Match when={contacts() && isFollowing() === false}>
            <p style="color: red; font-weight: bold; font-size: 2rem;">
              Why don't you follow the fiatjaf? do you even like nostr?
            </p>
          </Match>
        </Switch>

        {/* ... */}
      </div>
    </>
  );
}

13. Adding the follow button

Now that we have a way to check if the user is following fiatjaf, we should add a button to follow him. We can do this with Actions which are pre-built methods to modify nostr events for a user.

First we need to install the applesauce-actions and applesauce-factory package

pnpm install applesauce-actions applesauce-factory

Then create a src/actions.ts file with

import { EventFactory } from "applesauce-factory";
import { ActionHub } from "applesauce-actions";
import { eventStore } from "./stores";
import { accounts } from "./accounts";

// The event factory is used to build and modify nostr events
export const factory = new EventFactory({
  // accounts.signer is a NIP-07 signer that signs with the currently active account
  signer: accounts.signer,
});

// The action hub is used to run Actions against the event store
export const actions = new ActionHub(eventStore, factory);

Then create a toggleFollow method that will add or remove fiatjaf from the users contacts.

We are using the exec method to run the action, and the forEach method from RxJS allows us to await for all the events to be published

function App() {
  // ...

  const toggleFollow = async () => {
    // send any created events to rxNostr and the event store
    const publish = (event: NostrEvent) => {
      eventStore.add(event);
      rxNostr.send(event);
    };

    if (isFollowing()) {
      await actions
        .exec(UnfollowUser, "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")
        .forEach(publish);
    } else {
      await actions
        .exec(
          FollowUser,
          "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
          "wss://pyramid.fiatjaf.com/"
        )
        .forEach(publish);
    }
  };

  // ...

  return (
    <>
      {/* ... */}

      <div class="card">
        {/* ... */}

        {contacts() && <button onClick={toggleFollow}>{isFollowing() ? "Unfollow" : "Follow"}</button>}
      </div>
    </>
  );
}

14. Adding outbox support

The app looks like it works now but if the user reloads the page they will still see an the old version of their contacts list. we need to make sure rxNostr is publishing the events to the users outbox relays.

To do this we can subscribe to the signed in users mailboxes using the query store in src/nostr.ts

import { MailboxesQuery } from "applesauce-core/queries";
import { accounts } from "./accounts";
import { of, switchMap } from "rxjs";
import { queryStore } from "./stores";

// ...

// subscribe to the active account, then subscribe to the users mailboxes and update rxNostr
accounts.active$
  .pipe(switchMap((account) => (account ? queryStore.createQuery(MailboxesQuery, account.pubkey) : of(undefined))))
  .subscribe((mailboxes) => {
    if (mailboxes) rxNostr.setDefaultRelays(mailboxes.outboxes);
    else rxNostr.setDefaultRelays([]);
  });

And that’s it! we have a working nostr app that lets users follow and unfollow fiatjaf.

Author Public Key
npub1pmhevxtlt3478pvmdqt7dftnv6zc2mzpdc569yjm6ks4k2jhezcs53uksr