diff --git a/.changeset/README.md b/.changeset/README.md
new file mode 100644
index 00000000..e5b6d8d6
--- /dev/null
+++ b/.changeset/README.md
@@ -0,0 +1,8 @@
+# Changesets
+
+Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
+with multi-package repos, or single-package repos to help you version and publish your code. You can
+find the full documentation for it [in our repository](https://github.com/changesets/changesets)
+
+We have a quick list of common questions to get you started engaging with this project in
+[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
diff --git a/.changeset/cold-pianos-tickle.md b/.changeset/cold-pianos-tickle.md
new file mode 100644
index 00000000..e7993579
--- /dev/null
+++ b/.changeset/cold-pianos-tickle.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk": minor
+---
+
+deprecate user.zap/ndk.zap -- use new NDKZapper instead
diff --git a/.changeset/config.json b/.changeset/config.json
new file mode 100644
index 00000000..77b04199
--- /dev/null
+++ b/.changeset/config.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
+ "changelog": "@changesets/cli/changelog",
+ "commit": false,
+ "fixed": [],
+ "linked": [],
+ "access": "public",
+ "baseBranch": "master",
+ "updateInternalDependencies": "patch",
+ "ignore": []
+}
diff --git a/.changeset/eighty-toes-refuse.md b/.changeset/eighty-toes-refuse.md
new file mode 100644
index 00000000..21a099c7
--- /dev/null
+++ b/.changeset/eighty-toes-refuse.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk": patch
+---
+
+move NWC to ndk-wallet
diff --git a/.changeset/fifty-llamas-boil.md b/.changeset/fifty-llamas-boil.md
new file mode 100644
index 00000000..657b902d
--- /dev/null
+++ b/.changeset/fifty-llamas-boil.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk": patch
+---
+
+add pubkey hint to e tags
diff --git a/.changeset/flat-garlics-taste.md b/.changeset/flat-garlics-taste.md
new file mode 100644
index 00000000..e59d34d0
--- /dev/null
+++ b/.changeset/flat-garlics-taste.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk-mobile": patch
+---
+
+add useUserProfile hook
diff --git a/.changeset/fresh-oranges-guess.md b/.changeset/fresh-oranges-guess.md
new file mode 100644
index 00000000..a56d009c
--- /dev/null
+++ b/.changeset/fresh-oranges-guess.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk-svelte": minor
+---
+
+add support for Svelte 5's runes
diff --git a/.changeset/great-keys-knock.md b/.changeset/great-keys-knock.md
new file mode 100644
index 00000000..11cf8620
--- /dev/null
+++ b/.changeset/great-keys-knock.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk-mobile": patch
+---
+
+add LRU cache for profiles
diff --git a/.changeset/khaki-pets-smell.md b/.changeset/khaki-pets-smell.md
new file mode 100644
index 00000000..4638c43b
--- /dev/null
+++ b/.changeset/khaki-pets-smell.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk": patch
+---
+
+fix bug where both a and e tags were going in zap requests
diff --git a/.changeset/pretty-berries-talk.md b/.changeset/pretty-berries-talk.md
new file mode 100644
index 00000000..76efd29c
--- /dev/null
+++ b/.changeset/pretty-berries-talk.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk-wallet": patch
+---
+
+NWC support
diff --git a/.changeset/purple-fireants-invite.md b/.changeset/purple-fireants-invite.md
new file mode 100644
index 00000000..5e7433de
--- /dev/null
+++ b/.changeset/purple-fireants-invite.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk-mobile": patch
+---
+
+add sync profile fetching from cache
diff --git a/.changeset/silly-cows-boil.md b/.changeset/silly-cows-boil.md
new file mode 100644
index 00000000..f809c2d9
--- /dev/null
+++ b/.changeset/silly-cows-boil.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk-cache-dexie": patch
+---
+
+add support in dexie cache to retrieve profile info synchronously
diff --git a/.changeset/strong-files-fly.md b/.changeset/strong-files-fly.md
new file mode 100644
index 00000000..2fef01c2
--- /dev/null
+++ b/.changeset/strong-files-fly.md
@@ -0,0 +1,5 @@
+---
+"@nostr-dev-kit/ndk": patch
+---
+
+add NIP-22 support
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 00000000..0ccefc48
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,13 @@
+# FIXME: Hardcoded node version
+# FIXME: Use prod and dev stages!
+FROM node:20-slim
+
+RUN corepack enable
+
+# FIXME: We need to use a WORKDIR because of
+# https://stackoverflow.com/a/65443098 but
+# the choice of the actual dir is quite arbitrary.
+COPY . /app
+WORKDIR /app
+
+RUN pnpm install
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..709d82c2
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,29 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
+// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
+{
+ "name": "NDK devcontainer",
+ "build": {
+ // Path is relative to the devcontainer.json file.
+ "dockerfile": "Dockerfile",
+ "context": "../"
+ },
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "terminal.integrated.shell.linux": "/bin/bash"
+ }
+ }
+ },
+ // Add pnpm bin to path, so we have turbo available
+ "postStartCommand": "echo 'export PATH=$(pnpm bin):$PATH' >> ~/.bashrc && . ~/.bashrc"
+ // Features to add to the dev container. More info: https://containers.dev/features.
+ // "features": {},
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
+ // "forwardPorts": [],
+ // Use 'postCreateCommand' to run commands after the container is created.
+ // "postCreateCommand": "yarn install",
+ // Configure tool-specific properties.
+ // "customizations": {},
+ // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
+ // "remoteUser": "root"
+}
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 00000000..e5dc490a
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,13 @@
+.DS_Store
+node_modules
+build
+dist
+package
+.env
+.env.*
+!.env.example
+
+# Ignore files for PNPM, NPM and YARN
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 00000000..8b05a81c
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ["@nostr-dev-kit/custom"],
+};
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..605f05db
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,66 @@
+# Sample workflow for building and deploying a VitePress site to GitHub Pages
+#
+name: Deploy VitePress site to Pages
+
+on:
+ # Runs on pushes targeting the `main` branch. Change this to `master` if you're
+ # using the `master` branch as the default branch.
+ push:
+ branches:
+ - '*'
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ # Build job
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Not needed if lastUpdated is not enabled
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ - name: Setup PNPM
+ uses: pnpm/action-setup@v4
+ - name: Setup Pages
+ uses: actions/configure-pages@v4
+ - name: Install dependencies
+ run: pnpm install # or pnpm install / yarn install / bun install
+ - name: Build with VitePress
+ run: pnpm docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build
+ - name: Build typedoc
+ run: cd ndk && pnpm typedoc --out ../docs/.vitepress/dist/api
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: docs/.vitepress/dist
+
+ # Deployment job
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ needs: build
+ runs-on: ubuntu-latest
+ name: Deploy
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..f757754f
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,5 @@
+dist
+docs
+coverage
+**/.changeset
+**/.svelte-kit
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..4431ccbf
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,16 @@
+{
+ "useTabs": false,
+ "tabWidth": 4,
+ "singleQuote": false,
+ "semi": true,
+ "trailingComma": "es5",
+ "printWidth": 100,
+ "overrides": [
+ {
+ "files": "*.svelte",
+ "options": {
+ "parser": "svelte"
+ }
+ }
+ ]
+}
diff --git a/BUILD.md b/BUILD.md
new file mode 100644
index 00000000..741a89e4
--- /dev/null
+++ b/BUILD.md
@@ -0,0 +1,20 @@
+# Build NDK
+
+NDK is structured as a monorepo using `pnpm` as the package manager.
+
+```
+git clone https://github.com/nostr-dev-kit/ndk
+cd ndk
+pnpm install
+pnpm build
+```
+
+If you only care about building ndk core and not the family of packages you can just
+
+```
+git clone https://github.com/nostr-dev-kit/ndk
+cd ndk
+pnpm install
+cd ndk
+pnpm build
+```
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..2a6ea523
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Pablo Fernandez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/REFERENCES.md b/REFERENCES.md
new file mode 100644
index 00000000..48274ac7
--- /dev/null
+++ b/REFERENCES.md
@@ -0,0 +1,45 @@
+# Open source Nostr apps using NDK
+
+This is a running list of applications using NDK that are open source. Use these codebases to understand
+how others handle Nostr things using NDK.
+
+If you are the author of an application that uses NDK, send a pull-request to this repo adding your application
+to this list.
+
+- [Highlighter](https://github.com/kind-0/highlighter) - By [@pablof7z](https://njump.me/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
+ - Svelte, frontend
+- [nsecBunker](https://github.com/kind-0/nsecbunkerd) - By [@pablof7z](https://njump.me/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
+ - Typescript, backend
+- [Highlighter Chrome Extension](https://github.com/pablof7z/highlighter-chrome-extension/) - By [@pablof7z](https://njump.me/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
+ - Typescript, Chrome extension
+- [Nostr Data Vending Machine](https://github.com/pablof7z/nostr-data-vending-machine) - By [@pablof7z](https://njump.me/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
+ - Typescript, backend
+- [Nostr Chat Widget](https://github.com/pablof7z/nostr-chat-widget) - By [@pablof7z](https://njump.me/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
+ - Svelte, Rollup, embeddable widget
+- [Zapstr](https://github.com/zapstr/zapstr) - By [@pablof7z](https://njump.me/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
+ - Svelte, Frontend
+- [Ostrich.work](https://github.com/erskingardner/ostrich.work) - By [@jeffg](https://njump.me/npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc)
+ - Svelte, frontend
+- [Listr.lol](https://github.com/erskingardner/listr) - By [@jeffg](https://njump.me/npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc)
+ - Svelte, frontend
+- [Lume](https://github.com/luminous-devs/lume) - By [@reya](https://njump.me/npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445)
+ - Tauri, Desktop app
+- [Nostr App Manager](https://github.com/nostrband/nostr-app-manager) - By [@nostrband](https://njump.me/npub1wc4rc9wxl2gfzxl384g0cw3f79nrms0sfdpe02y7aasy7c3we4sqd0qywr)
+ - React, Frontend
+- [Stemstr](https://github.com/stemstr/Client) - By [@stemstr](https://njump.me/npub1stemstrls4f5plqeqkeq43gtjhtycuqd9w25v5r5z5ygaq2n2sjsd6mul5)
+- [Audgit.ai](https://github.com/ArcadeLabsInc/audgit.ai) - By [@ArcadeLabsInc](https://njump.me/npub1tlv67m7xvlyplzexuynmfpguvyet0sjffce3y8vu0suuyuwgzauqjk7fdm)
+- [Swarmstr](https://github.com/ptrio42/swarmstr.com) - By [@pitiunited](https://njump.me/npub178umpxtdflcm7a08nexvs4mu384kx0ngg9w8ltm5eut6q7lcp0vq05qrg4)
+- [zapddit](https://github.com/vivganes/zapddit) - By [@vivganes](https://njump.me/npub1ltx67888tz7lqnxlrg06x234vjnq349tcfyp52r0lstclp548mcqnuz40t)
+- [Nuxstr](https://github.com/Sebastix/nuxstr) - By [@Sebastix](https://njump.me/sebastian@sebastix.dev)
+ - Nuxt (Vue), frontend
+- [Flockstr](https://github.com/zmeyer44/flockstr) - By [@zach](https://njump.me/npub1zach44xjpc4yyhx6pgse2cj2pf98838kja03dv2e8ly8lfr094vqvm5dy5)
+- [Flare.pub](https://github.com/zmeyer44/flare) - By [@zach](https://njump.me/npub1zach44xjpc4yyhx6pgse2cj2pf98838kja03dv2e8ly8lfr094vqvm5dy5)
+- [Pinstr.app](https://github.com/sepehr-safari/pinstr) - By [@sepehr](https://njump.me/nprofile1qqsru22d9lfnnwck54qr4phrvey50h2q33xc0gqxv5j03ftn4efu4rspr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjwdekccte9ehx7um5wghx6mm99uq36amnwvaz7tmwdaehgu3wd46hg6tw09mkzmrvv46zucm0d5hsv6ffvh)
+ - React, Nostr Web Client
+- [Nostr-Hooks](https://github.com/ostyjs/nostr-hooks) - By [@sepehr](https://njump.me/nprofile1qqsru22d9lfnnwck54qr4phrvey50h2q33xc0gqxv5j03ftn4efu4rspr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjwdekccte9ehx7um5wghx6mm99uq36amnwvaz7tmwdaehgu3wd46hg6tw09mkzmrvv46zucm0d5hsv6ffvh)
+ - Stateful wrapper library of React hooks around NDK.
+- [magicCity h=n](https://github.com/tezosmiami/hicetnunc) - By [@hicetnunc2000](https://github.com/hicetnunc2000/), [@tezosmiami](https://njump.me/npub190rqwj0nud4uhvmaeg7cgn0gypu0s09j87vqjluhfhju0req2khsskh9w7)
+- [notepress](https://github.com/utxo-one/notepress) - By [@utxo](httsp://njump.me/_@utxo.one)
+ - No frameworks! A very simple long-form (NIP-23) reader
+- [Olas 🌊](https://github.com/pablof7z/snapstr) - By [@pablof7z](https://njump.me/f7z.io)
+ - React Native, mobile-only app
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
new file mode 100644
index 00000000..75669e0d
--- /dev/null
+++ b/docs/.vitepress/config.mts
@@ -0,0 +1,78 @@
+import { defineConfig } from 'vitepress'
+import { withMermaid } from "vitepress-plugin-mermaid";
+
+// https://vitepress.dev/reference/site-config
+export default withMermaid(defineConfig({
+ title: "NDK",
+ description: "NDK Docs",
+ base: "/ndk/",
+ ignoreDeadLinks: true,
+ themeConfig: {
+ // https://vitepress.dev/reference/default-theme-config
+ nav: [
+ { text: 'Home', link: '/' },
+ { text: 'API Reference', link: '/api/', target: '_blank' },
+ { text: 'Wiki', link: 'https://wikifreedia.xyz/?c=NDK', target: '_blank' },
+ ],
+
+ sidebar: [
+ {
+ text: "Getting Started",
+ items: [
+ { text: 'Introduction', link: '/getting-started/introduction' },
+ { text: 'Usage', link: '/getting-started/usage' },
+ { text: 'Signers', link: '/getting-started/signers' },
+ ]
+ },
+ {
+ text: 'Tutorial',
+ items: [
+ { text: 'Local-first', link: '/tutorial/local-first' },
+ { text: 'Publishing', link: '/tutorial/publishing' },
+ { text: "Subscription Management", link: '/tutorial/subscription-management' },
+ { text: "Speed", link: '/tutorial/speed' },
+ { text: 'Zaps', link: '/tutorial/zaps' },
+ ]
+ },
+ {
+ text: "Cache Adapters",
+ items: [
+ { text: 'In-memory + dexie', link: '/cache/dexie' },
+ { text: 'Local Nostr Relay', link: '/cache/nostr' },
+ ]
+ },
+ {
+ text: "Wallet",
+ items: [
+ { text: 'Introduction', link: '/wallet/index' },
+ { text: 'Nutsack (NIP-60)', link: '/wallet/nutsack' },
+ { text: 'Nutzaps', link: '/wallet/nutzaps' },
+ ]
+ },
+ {
+ text: "Wrappers",
+ items: [
+ { text: 'NDK Svelte', link: '/wrappers/svelte' },
+ ]
+ },
+ {
+ text: "Mobile",
+ items: [
+ { text: 'Introduction', link: '/mobile/index' },
+ { text: 'Session', link: '/mobile/session' },
+ { text: 'Wallet', link: '/mobile/wallet' },
+ ]
+ },
+ {
+ text: "Internals",
+ items: [
+ { text: "Subscription Lifecycle", link: '/internals/subscriptions' },
+ ]
+ }
+ ],
+
+ socialLinks: [
+ { icon: 'github', link: 'https://github.com/nostr-dev-kit/ndk' }
+ ]
+ }
+}))
\ No newline at end of file
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
new file mode 100644
index 00000000..def4cfc8
--- /dev/null
+++ b/docs/.vitepress/theme/index.ts
@@ -0,0 +1,17 @@
+// https://vitepress.dev/guide/custom-theme
+import { h } from 'vue'
+import type { Theme } from 'vitepress'
+import DefaultTheme from 'vitepress/theme'
+import './style.css'
+
+export default {
+ extends: DefaultTheme,
+ Layout: () => {
+ return h(DefaultTheme.Layout, null, {
+ // https://vitepress.dev/guide/extending-default-theme#layout-slots
+ })
+ },
+ enhanceApp({ app, router, siteData }) {
+ // ...
+ }
+} satisfies Theme
diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css
new file mode 100644
index 00000000..d63aee82
--- /dev/null
+++ b/docs/.vitepress/theme/style.css
@@ -0,0 +1,139 @@
+/**
+ * Customize default theme styling by overriding CSS variables:
+ * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
+ */
+
+/**
+ * Colors
+ *
+ * Each colors have exact same color scale system with 3 levels of solid
+ * colors with different brightness, and 1 soft color.
+ *
+ * - `XXX-1`: The most solid color used mainly for colored text. It must
+ * satisfy the contrast ratio against when used on top of `XXX-soft`.
+ *
+ * - `XXX-2`: The color used mainly for hover state of the button.
+ *
+ * - `XXX-3`: The color for solid background, such as bg color of the button.
+ * It must satisfy the contrast ratio with pure white (#ffffff) text on
+ * top of it.
+ *
+ * - `XXX-soft`: The color used for subtle background such as custom container
+ * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
+ * on top of it.
+ *
+ * The soft color must be semi transparent alpha channel. This is crucial
+ * because it allows adding multiple "soft" colors on top of each other
+ * to create a accent, such as when having inline code block inside
+ * custom containers.
+ *
+ * - `default`: The color used purely for subtle indication without any
+ * special meanings attched to it such as bg color for menu hover state.
+ *
+ * - `brand`: Used for primary brand colors, such as link text, button with
+ * brand theme, etc.
+ *
+ * - `tip`: Used to indicate useful information. The default theme uses the
+ * brand color for this by default.
+ *
+ * - `warning`: Used to indicate warning to the users. Used in custom
+ * container, badges, etc.
+ *
+ * - `danger`: Used to show error, or dangerous message to the users. Used
+ * in custom container, badges, etc.
+ * -------------------------------------------------------------------------- */
+
+ :root {
+ --vp-c-default-1: var(--vp-c-gray-1);
+ --vp-c-default-2: var(--vp-c-gray-2);
+ --vp-c-default-3: var(--vp-c-gray-3);
+ --vp-c-default-soft: var(--vp-c-gray-soft);
+
+ --vp-c-brand-1: var(--vp-c-indigo-1);
+ --vp-c-brand-2: var(--vp-c-indigo-2);
+ --vp-c-brand-3: var(--vp-c-indigo-3);
+ --vp-c-brand-soft: var(--vp-c-indigo-soft);
+
+ --vp-c-tip-1: var(--vp-c-brand-1);
+ --vp-c-tip-2: var(--vp-c-brand-2);
+ --vp-c-tip-3: var(--vp-c-brand-3);
+ --vp-c-tip-soft: var(--vp-c-brand-soft);
+
+ --vp-c-warning-1: var(--vp-c-yellow-1);
+ --vp-c-warning-2: var(--vp-c-yellow-2);
+ --vp-c-warning-3: var(--vp-c-yellow-3);
+ --vp-c-warning-soft: var(--vp-c-yellow-soft);
+
+ --vp-c-danger-1: var(--vp-c-red-1);
+ --vp-c-danger-2: var(--vp-c-red-2);
+ --vp-c-danger-3: var(--vp-c-red-3);
+ --vp-c-danger-soft: var(--vp-c-red-soft);
+}
+
+/**
+ * Component: Button
+ * -------------------------------------------------------------------------- */
+
+:root {
+ --vp-button-brand-border: transparent;
+ --vp-button-brand-text: var(--vp-c-white);
+ --vp-button-brand-bg: var(--vp-c-brand-3);
+ --vp-button-brand-hover-border: transparent;
+ --vp-button-brand-hover-text: var(--vp-c-white);
+ --vp-button-brand-hover-bg: var(--vp-c-brand-2);
+ --vp-button-brand-active-border: transparent;
+ --vp-button-brand-active-text: var(--vp-c-white);
+ --vp-button-brand-active-bg: var(--vp-c-brand-1);
+}
+
+/**
+ * Component: Home
+ * -------------------------------------------------------------------------- */
+
+:root {
+ --vp-home-hero-name-color: transparent;
+ --vp-home-hero-name-background: -webkit-linear-gradient(
+ 120deg,
+ #bd34fe 30%,
+ #41d1ff
+ );
+
+ --vp-home-hero-image-background-image: linear-gradient(
+ -45deg,
+ #bd34fe 50%,
+ #47caff 50%
+ );
+ --vp-home-hero-image-filter: blur(44px);
+}
+
+@media (min-width: 640px) {
+ :root {
+ --vp-home-hero-image-filter: blur(56px);
+ }
+}
+
+@media (min-width: 960px) {
+ :root {
+ --vp-home-hero-image-filter: blur(68px);
+ }
+}
+
+/**
+ * Component: Custom Block
+ * -------------------------------------------------------------------------- */
+
+:root {
+ --vp-custom-block-tip-border: transparent;
+ --vp-custom-block-tip-text: var(--vp-c-text-1);
+ --vp-custom-block-tip-bg: var(--vp-c-brand-soft);
+ --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
+}
+
+/**
+ * Component: Algolia
+ * -------------------------------------------------------------------------- */
+
+.DocSearch {
+ --docsearch-primary-color: var(--vp-c-brand-1) !important;
+}
+
diff --git a/docs/api-examples.md b/docs/api-examples.md
new file mode 100644
index 00000000..6bd8bb5c
--- /dev/null
+++ b/docs/api-examples.md
@@ -0,0 +1,49 @@
+---
+outline: deep
+---
+
+# Runtime API Examples
+
+This page demonstrates usage of some of the runtime APIs provided by VitePress.
+
+The main `useData()` API can be used to access site, theme, and page data for the current page. It works in both `.md` and `.vue` files:
+
+```md
+
+
+## Results
+
+### Theme Data
+
{{ theme }}
+
+### Page Data
+{{ page }}
+
+### Page Frontmatter
+{{ frontmatter }}
+```
+
+
+
+## Results
+
+### Theme Data
+{{ theme }}
+
+### Page Data
+{{ page }}
+
+### Page Frontmatter
+{{ frontmatter }}
+
+## More
+
+Check out the documentation for the [full list of runtime APIs](https://vitepress.dev/reference/runtime-api#usedata).
diff --git a/docs/cache/dexie.md b/docs/cache/dexie.md
new file mode 100644
index 00000000..114b53bd
--- /dev/null
+++ b/docs/cache/dexie.md
@@ -0,0 +1,153 @@
+# Dexie Cache
+
+Meant to be used client-side within a browser context. This is a cache adapter for [Dexie](https://dexie.org/), a wrapper around IndexedDB.
+
+## Usage
+
+NDK will attempt to use the Dexie adapter to store users, events, and tags. The default behaviour is to always check the cache first and then hit relays, replacing older cached events as needed.
+
+## Support
+
+- [x] Events
+- [x] User profiles
+- [x] Event<>Tag indexes
+- [x] NIP-05 lookups
+- [x] Unpublished events
+
+### Install
+
+```
+pnpm add @nostr-dev-kit/ndk-cache-dexie
+```
+
+### Add as a cache adapter
+
+```ts
+import NDKCacheAdapterDexie from "@nostr-dev-kit/ndk-cache-dexie";
+
+const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'your-db-name' });
+const ndk = new NDK({cacheAdapter: dexieAdapter, ...other config options});
+```
+
+🚨 Because Dexie only exists client-side, this cache adapter will not work in pure node.js environments. You'll need to make sure that you're using the right cache adapter in the right place (e.g. Redis on the backend, Dexie on the frontend).
+
+## Slowness
+
+Because IndexDB is painfully slow, this adapter will primarly act via an LRU cache that periodically flushes to the database. Individual read/writes don't directly hit the database.
+
+## Options
+
+[**NDK Dexie Cache Adapter**](../README.md) • **Docs**
+
+***
+
+[NDK Dexie Cache Adapter](../globals.md) / NDKCacheAdapterDexieOptions
+
+# Interface: NDKCacheAdapterDexieOptions
+
+## Properties
+
+### dbName?
+
+> `optional` **dbName**: `string`
+
+The name of the database to use
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:34](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L34)
+
+***
+
+### debug?
+
+> `optional` **debug**: `Debugger`
+
+Debug instance to use for logging
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:39](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L39)
+
+***
+
+### eventCacheSize?
+
+> `optional` **eventCacheSize**: `number`
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:53](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L53)
+
+***
+
+### eventTagsCacheSize?
+
+> `optional` **eventTagsCacheSize**: `number`
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:54](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L54)
+
+***
+
+### expirationTime?
+
+> `optional` **expirationTime**: `number`
+
+The number of seconds to store events in Dexie (IndexedDB) before they expire
+Defaults to 3600 seconds (1 hour)
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:45](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L45)
+
+***
+
+### indexableKinds?
+
+> `optional` **indexableKinds**: `number`[] \| `"all"` \| `"none"`
+
+The kinds of events that should be indexed
+
+#### Default
+
+```ts
+"all"
+```
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:60](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L60)
+
+***
+
+### nip05CacheSize?
+
+> `optional` **nip05CacheSize**: `number`
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:52](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L52)
+
+***
+
+### profileCacheSize?
+
+> `optional` **profileCacheSize**: `number`
+
+Number of profiles to keep in an LRU cache
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:50](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L50)
+
+***
+
+### zapperCacheSize?
+
+> `optional` **zapperCacheSize**: `number`
+
+#### Defined in
+
+[ndk-cache-dexie/src/index.ts:51](https://github.com/nostr-dev-kit/ndk/blob/26ea669eeeadbc93b894cac1f29829e9a41694cb/ndk-cache-dexie/src/index.ts#L51)
diff --git a/docs/cache/nostr.md b/docs/cache/nostr.md
new file mode 100644
index 00000000..ec166e2a
--- /dev/null
+++ b/docs/cache/nostr.md
@@ -0,0 +1,34 @@
+# Nostr Cache Adapter
+
+NDK cache adapter using a nostr relay as the database.
+
+This cache adapter is meant to be run against a local relay. This adapter will generate two NDK instances:
+
+`ndk` -- This talks exclusively to the local relay, with outbox model disabled.
+`fallbackNdk` -- This is used to hydrate the cache and uses the outbox model -- each query the cache receives is placed in a queue in the background so that subsequent requests can be served from the cache. All events from other relays
+
+## Usage
+
+### Install
+
+```
+npm add @nostr-dev-kit/ndk-cache-nostr
+
+```
+
+### Add as a cache adapter
+
+```ts
+import NDKCacheAdapterNostr from "@nostr-dev-kit/ndk-cache-nostr";
+
+const cacheAdapter = new NDKCacheAdapterNostr({
+ relayUrl: 'ws://localhost:5577',
+});
+const ndk = new NDK({ cacheAdapter });
+```
+
+If running server-side in a NodeJS environment, you should make sure to polyfill `WebSocket`.
+
+# License
+
+MIT
diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md
new file mode 100644
index 00000000..50c63b8f
--- /dev/null
+++ b/docs/getting-started/introduction.md
@@ -0,0 +1,35 @@
+# Getting started
+
+## Installation
+
+```sh
+npm add @nostr-dev-kit/ndk
+```
+
+## Debugging
+
+NDK uses the `debug` package to assist in understanding what's happening behind the hood. If you are building a package
+that runs on the server define the `DEBUG` envionment variable like
+
+```sh
+export DEBUG='ndk:*'
+```
+
+or in the browser enable it by writing in the DevTools console
+
+```sh
+localStorage.debug = 'ndk:*'
+```
+
+## Network Debugging
+
+You can construct NDK passing a netDebug callback to receive network traffic events, particularly useful for debugging applications not running in a browser.
+
+```ts
+const netDebug = (msg: string, relay: NDKRelay, direction?: "send" | "recv") = {
+ const hostname = new URL(relay.url).hostname;
+ netDebug(hostname, msg, direction);
+}
+
+ndk = new NDK({ netDebug });
+```
diff --git a/docs/getting-started/signers.md b/docs/getting-started/signers.md
new file mode 100644
index 00000000..47346bef
--- /dev/null
+++ b/docs/getting-started/signers.md
@@ -0,0 +1,38 @@
+# Signers
+
+NDK uses signers _optionally_ passed in to sign events. Note that it is possible to use NDK without signing events (e.g. [to get someone's profile](https://github.com/nostr-dev-kit/ndk-cli/blob/master/src/commands/profile.ts)).
+
+Signing adapters can be passed in when NDK is instantiated or later during runtime.
+
+### Using a NIP-07 browser extension (e.g. Alby, nos2x)
+
+Instatiate NDK with a NIP-07 signer
+
+```ts
+// Import the package, NIP-07 signer and NDK event
+import NDK, { NDKEvent, NDKNip07Signer } from "@nostr-dev-kit/ndk";
+
+const nip07signer = new NDKNip07Signer();
+const ndk = new NDK({ signer: nip07signer });
+```
+
+NDK can now ask for permission, via their NIP-07 extension, to...
+
+**Read the user's public key**
+
+```ts
+nip07signer.user().then(async (user) => {
+ if (!!user.npub) {
+ console.log("Permission granted to read their public key:", user.npub);
+ }
+});
+```
+
+**Sign & publish events**
+
+```ts
+const ndkEvent = new NDKEvent(ndk);
+ndkEvent.kind = 1;
+ndkEvent.content = "Hello, world!";
+ndkEvent.publish(); // This will trigger the extension to ask the user to confirm signing.
+```
\ No newline at end of file
diff --git a/docs/getting-started/usage.md b/docs/getting-started/usage.md
new file mode 100644
index 00000000..068f985c
--- /dev/null
+++ b/docs/getting-started/usage.md
@@ -0,0 +1,57 @@
+# Usage
+
+## Instantiate an NDK instance
+
+You can pass an object with several options to a newly created instance of NDK.
+
+- `explicitRelayUrls` – an array of relay URLs.
+- `signer` - an instance of a [signer](#signers).
+- `cacheAdapter` - an instance of a [Cache Adapter](#caching)
+- `debug` - Debug instance to use for logging. Defaults to `debug("ndk")`.
+
+```ts
+// Import the package
+import NDK from "@nostr-dev-kit/ndk";
+
+// Create a new NDK instance with explicit relays
+const ndk = new NDK({
+ explicitRelayUrls: ["wss://a.relay", "wss://another.relay"],
+});
+```
+
+If the signer implements the `getRelays()` method, NDK will use the relays returned by that method as the explicit relays.
+
+```ts
+// Import the package
+import NDK, { NDKNip07Signer } from "@nostr-dev-kit/ndk";
+
+// Create a new NDK instance with just a signer (provided the signer implements the getRelays() method)
+const nip07signer = new NDKNip07Signer();
+const ndk = new NDK({ signer: nip07signer });
+```
+
+Note: In normal client use, it's best practice to instantiate NDK as a singleton class. [See more below](#architecture-decisions--suggestions).
+
+## Connecting
+
+After you've instatiated NDK, you need to tell it to connect before you'll be able to interact with any relays.
+
+```ts
+// Import the package
+import NDK from "@nostr-dev-kit/ndk";
+
+// Create a new NDK instance with explicit relays
+const ndk = new NDK({
+ explicitRelayUrls: ["wss://a.relay", "wss://another.relay"],
+});
+// Now connect to specified relays
+await ndk.connect();
+```
+
+## Architecture decisions & suggestions
+
+- Users of NDK should instantiate a single NDK instance.
+- That instance tracks state with all relays connected, explicit and otherwise.
+- All relays are tracked in a single pool that handles connection errors/reconnection logic.
+- RelaySets are assembled ad-hoc as needed depending on the queries set, although some RelaySets might be long-lasting, like the `explicitRelayUrls` specified by the user.
+- RelaySets are always a subset of the pool of all available relays.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 00000000..52fac990
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,18 @@
+---
+# https://vitepress.dev/reference/default-theme-home-page
+layout: home
+
+hero:
+ name: "NDK Documentation"
+ tagline: "Nostr Development Kit Docs"
+ actions:
+ - theme: brand
+ text: Getting Started
+ link: /getting-started/introduction.html
+ - theme: secondary
+ text: References
+ link: https://github.com/nostr-dev-kit/ndk/blob/master/REFERENCES.md
+
+---
+
+NDK is a nostr development kit that makes the experience of building Nostr-related applications, whether they are relays, clients, or anything in between, better, more reliable and overall nicer to work with than existing solutions.
\ No newline at end of file
diff --git a/docs/internals/subscriptions.md b/docs/internals/subscriptions.md
new file mode 100644
index 00000000..ab328ce8
--- /dev/null
+++ b/docs/internals/subscriptions.md
@@ -0,0 +1,205 @@
+# Subscriptions Lifecycle
+When an application creates a subscription a lot of things happen under the hood.
+
+Say we want to see `kind:1` events from pubkeys `123`, `456`, and `678`.
+
+```ts
+const subscription = ndk.subscribe({ kinds: [1], authors: [ "123", "456", "678" ]})
+```
+
+Since the application level didn't explicitly provide a relay-set, which is the most common use case, NDK will calculate a relay set based on the outbox model plus a variety of some other factors.
+
+So the first thing we'll do before talking to relays is, decide to *which* relays we should talk to.
+
+The `calculateRelaySetsFromFilters` function will take care of this and provide us with a map of relay URLs and filters for each relay.
+
+This means that the query, as specified by the client might be broken into distinct queries specialized for the different relays.
+
+For example, if we have 3 relays, and the query is for `kind:1` events from pubkeys `a` and `b`, the `calculateRelaySetsFromFilters` function might return something like this:
+
+```ts
+{
+ "wss://relay1": { kinds: [1], authors: [ "a" ] },
+ "wss://relay2": { kinds: [1], authors: [ "b" ] },
+}
+```
+
+```mermaid
+flowchart TD
+ Client -->|"kinds: [1], authors: [a, b]"| Subscription1
+ Subscription1 -->|"kinds: [1], authors: [a]"| wss://relay1
+ Subscription1 -->|"kinds: [1], authors: [b]"| wss://relay2
+```
+
+## Subscription bundling
+Once the subscription has been split into the filters each relay should receive, the filters are sent to the individual `NDKRelay`'s `NDKRelaySubscriptionManager` instances.
+
+`NDKRelaySubscriptionManager` is responsible for keeping track of the active and scheduled subscriptions that are pending to be executed within an individual relay.
+
+This is an important aspect to consider:
+
+> `NDKSubscription` have a different lifecycle than `NDKRelaySubscription`. For example, a subscription that is set to close after EOSE might still be active within the `NDKSubscription` lifecycle, but it might have been already been closed within the `NDKRelaySubscription` lifecycle, since NDK attempts to keep the minimum amount of open subscriptions at any given time.
+
+## NDKRelaySubscription
+Most NDK subscriptions (by default) are set to be executed with a grouping delay. Will cover what this looks like in practice later, but for now, let's understand than when the `NDKRelaySubscriptionManager` receives an order, it might not execute it right away.
+
+The different filters that can be grouped together (thus executed as a single `REQ` within a relay) are grouped within the same `NDKRelaySubscription` instance and the execution scheduler is computed respecting what each individual `NDKSubscription` has requested.
+
+(For example, if a subscription with a `groupingDelay` of `at-least` 500 millisecond has been grouped with another subscription with a `groupingDelay` of `at-least` 1000 milliseconds, the `NDKRelaySubscriptionManager` will wait 1000 ms before sending the `REQ` to this particular relay).
+
+### Execution
+Once the filter is executed at the relay level, the `REQ` is submitted into that relay's `NDKRelayConnectivity` instance, which will take care of monitoring for responses for this particular REQ and communicate them back into the `NDKRelaySubscription` instance.
+
+Each `EVENT` that comes back as a response to our `REQ` within this `NDKRelaySubscription` instance is then compared with the filters of each `NDKSubscription` that has been grouped and if it matches, it is sent back to the `NDKSubscription` instance.
+
+
+# Example
+
+If an application requests `kind:1` of pubkeys `123`, `456`, and `789`. It creates an `NDKSubscription`:
+
+```ts
+ndk.subscribe({ kinds: [1], authors: [ "123", "456", "789" ]}, { groupableDelay: 500, groupableDelayType: 'at-least' })
+// results in NDKSubscription1 with filters { kinds: [1], authors: [ "123", "456", "789" ] }
+```
+
+Some other part of the application requests a kind:7 from pubkey `123` at the same time.
+
+```ts
+ndk.subscribe({ kinds: [7], authors: [ "123" ]}, { groupableDelay: 500, groupableDelayType: 'at-most' })
+// results in NDKSubscription2 with filters { kinds: [7], authors: [ "123" ] }
+```
+
+```mermaid
+flowchart TD
+ subgraph Subscriptions Lifecycle
+ A[Application] -->|"kinds: [1], authors: [123, 456, 678], groupingDelay: at-least 500ms"| B[NDKSubscription1]
+
+ A2[Application] -->|"kinds: [7], authors: [123], groupingDelay: at-most 1000ms"| B2[NDKSubscription2]
+ end
+```
+
+Both subscriptions have their relayset calculated by NDK and, the resulting filters are sent into the `NDKRelaySubscriptionManager`, which will decide what, and how filters can be grouped.
+
+```mermaid
+flowchart TD
+ subgraph Subscriptions Lifecycle
+ A[Application] -->|"kinds: [1], authors: [123, 456, 678], groupingDelay: at-least 500ms"| B[NDKSubscription1]
+ B --> C{Calculate Relay Sets}
+
+ A2[Application] -->|"kinds: [7], authors: [123], groupingDelay: at-most 1000ms"| B2[NDKSubscription2]
+ B2 --> C2{Calculate Relay Sets}
+ end
+
+ subgraph Subscription Bundling
+ C -->|"kinds: [1], authors: [123]"| E1[wss://relay1 NDKRelaySubscriptionManager]
+ C -->|"kinds: [1], authors: [456]"| E2[wss://relay2 NDKRelaySubscriptionManager]
+ C -->|"kinds: [1], authors: [678]"| E3[wss://relay3 NDKRelaySubscriptionManager]
+
+ C2 -->|"kinds: [7], authors: [123]"| E1
+ end
+```
+
+The `NDKRelaySubscriptionManager` will create `NDKRelaySubscription` instances, or add filters to them if `NDKRelaySubscription` with the same filter fingerprint exists.
+
+```mermaid
+flowchart TD
+ subgraph Subscriptions Lifecycle
+ A[Application] -->|"kinds: [1], authors: [123, 456, 678], groupingDelay: at-least 500ms"| B[NDKSubscription1]
+ B --> C{Calculate Relay Sets}
+
+ A2[Application] -->|"kinds: [7], authors: [123], groupingDelay: at-most 1000ms"| B2[NDKSubscription2]
+ B2 --> C2{Calculate Relay Sets}
+ end
+
+ subgraph Subscription Bundling
+ C -->|"kinds: [1], authors: [123]"| E1[wss://relay1 NDKRelaySubscriptionManager]
+ C -->|"kinds: [1], authors: [456]"| E2[wss://relay2 NDKRelaySubscriptionManager]
+ C -->|"kinds: [1], authors: [678]"| E3[wss://relay3 NDKRelaySubscriptionManager]
+
+ C2 -->|"kinds: [7], authors: [123]"| E1
+
+ E1 -->|"Grouping Delay: at-most 1000ms"| F1[NDKRelaySubscription]
+ E2 -->|"Grouping Delay: at-least 500ms"| F2[NDKRelaySubscription]
+ E3 -->|"Grouping Delay: at-least 500ms"| F3[NDKRelaySubscription]
+ end
+```
+
+Each individual `NDKRelaySubscription` computes the execution schedule of the filters it has received and sends them to the `NDKRelayConnectivity` instance, which in turns sends the `REQ` to the relay.
+
+```mermaid
+flowchart TD
+ subgraph Subscriptions Lifecycle
+ A[Application] -->|"kinds: [1], authors: [123, 456, 678], groupingDelay: at-least 500ms"| B[NDKSubscription1]
+ B --> C{Calculate Relay Sets}
+
+ A2[Application] -->|"kinds: [7], authors: [123], groupingDelay: at-most 1000ms"| B2[NDKSubscription2]
+ B2 --> C2{Calculate Relay Sets}
+ end
+
+ subgraph Subscription Bundling
+ C -->|"kinds: [1], authors: [123]"| E1[wss://relay1 NDKRelaySubscriptionManager]
+ C -->|"kinds: [1], authors: [456]"| E2[wss://relay2 NDKRelaySubscriptionManager]
+ C -->|"kinds: [1], authors: [678]"| E3[wss://relay3 NDKRelaySubscriptionManager]
+
+ C2 -->|"kinds: [7], authors: [123]"| E1
+
+ E1 -->|"Grouping Delay: at-most 1000ms"| F1[NDKRelaySubscription]
+ E2 -->|"Grouping Delay: at-least 500ms"| F2[NDKRelaySubscription]
+ E3 -->|"Grouping Delay: at-least 500ms"| F3[NDKRelaySubscription]
+
+ F1 -->|"REQ: kinds: [1, 7], authors: [123]"| G1[NDKRelayConnectivity]
+ F2 -->|"REQ: kinds: [1], authors: [456]"| G2[NDKRelayConnectivity]
+ F3 -->|"REQ: kinds: [1], authors: [678]"| G3[NDKRelayConnectivity]
+ end
+
+ subgraph Execution
+ G1 -->|"Send REQ to wss://relay1 after 1000ms"| R1[Relay1]
+ G2 -->|"Send REQ to wss://relay2 after 500ms"| R2[Relay2]
+ G3 -->|"Send REQ to wss://relay3 after 500ms"| R3[Relay3]
+ end
+```
+
+As the events come from the relays, `NDKRelayConnectivity` will send them back to the `NDKRelaySubscription` instance, which will compare the event with the filters of the `NDKSubscription` instances that have been grouped together and send the received event back to the correct `NDKSubscription` instance.
+
+```mermaid
+flowchart TD
+ subgraph Subscriptions Lifecycle
+ A[Application] -->|"kinds: [1], authors: [123, 456, 678], groupingDelay: at-least 500ms"| B[NDKSubscription1]
+ B --> C{Calculate Relay Sets}
+
+ A2[Application] -->|"kinds: [7], authors: [123], groupingDelay: at-most 1000ms"| B2[NDKSubscription2]
+ B2 --> C2{Calculate Relay Sets}
+ end
+
+ subgraph Subscription Bundling
+ C -->|"kinds: [1], authors: [123]"| E1[wss://relay1 NDKRelaySubscriptionManager]
+ C -->|"kinds: [1], authors: [456]"| E2[wss://relay2 NDKRelaySubscriptionManager]
+ C -->|"kinds: [1], authors: [678]"| E3[wss://relay3 NDKRelaySubscriptionManager]
+
+ C2 -->|"kinds: [7], authors: [123]"| E1
+
+ E1 -->|"Grouping Delay: at-most 1000ms"| F1[NDKRelaySubscription]
+ E2 -->|"Grouping Delay: at-least 500ms"| F2[NDKRelaySubscription]
+ E3 -->|"Grouping Delay: at-least 500ms"| F3[NDKRelaySubscription]
+
+ F1 -->|"REQ: kinds: [1, 7], authors: [123]"| G1[NDKRelayConnectivity]
+ F2 -->|"REQ: kinds: [1], authors: [456]"| G2[NDKRelayConnectivity]
+ F3 -->|"REQ: kinds: [1], authors: [678]"| G3[NDKRelayConnectivity]
+ end
+
+ subgraph Execution
+ G1 -->|"Send REQ to wss://relay1 after 1000ms"| R1[Relay1]
+ G2 -->|"Send REQ to wss://relay2 after 500ms"| R2[Relay2]
+ G3 -->|"Send REQ to wss://relay3 after 500ms"| R3[Relay3]
+
+ R1 -->|"EVENT: kinds: [1]"| H1[NDKRelaySubscription]
+ R1 -->|"EVENT: kinds: [7]"| H2[NDKRelaySubscription]
+ R2 -->|"EVENT"| H3[NDKRelaySubscription]
+ R3 -->|"EVENT"| H4[NDKRelaySubscription]
+
+ H1 -->|"Matched Filters: kinds: [1]"| I1[NDKSubscription1]
+ H2 -->|"Matched Filters: kinds: [7]"| I2[NDKSubscription2]
+ H3 -->|"Matched Filters: kinds: [1]"| I1
+ H4 -->|"Matched Filters: kinds: [1]"| I1
+ end
+```
\ No newline at end of file
diff --git a/docs/markdown-examples.md b/docs/markdown-examples.md
new file mode 100644
index 00000000..f9258a55
--- /dev/null
+++ b/docs/markdown-examples.md
@@ -0,0 +1,85 @@
+# Markdown Extension Examples
+
+This page demonstrates some of the built-in markdown extensions provided by VitePress.
+
+## Syntax Highlighting
+
+VitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting:
+
+**Input**
+
+````md
+```js{4}
+export default {
+ data () {
+ return {
+ msg: 'Highlighted!'
+ }
+ }
+}
+```
+````
+
+**Output**
+
+```js{4}
+export default {
+ data () {
+ return {
+ msg: 'Highlighted!'
+ }
+ }
+}
+```
+
+## Custom Containers
+
+**Input**
+
+```md
+::: info
+This is an info box.
+:::
+
+::: tip
+This is a tip.
+:::
+
+::: warning
+This is a warning.
+:::
+
+::: danger
+This is a dangerous warning.
+:::
+
+::: details
+This is a details block.
+:::
+```
+
+**Output**
+
+::: info
+This is an info box.
+:::
+
+::: tip
+This is a tip.
+:::
+
+::: warning
+This is a warning.
+:::
+
+::: danger
+This is a dangerous warning.
+:::
+
+::: details
+This is a details block.
+:::
+
+## More
+
+Check out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown).
diff --git a/docs/mobile/index.md b/docs/mobile/index.md
new file mode 100644
index 00000000..e78b9544
--- /dev/null
+++ b/docs/mobile/index.md
@@ -0,0 +1,53 @@
+# NDK Mobile
+
+A React Native/Expo implementation of [NDK (Nostr Development Kit)](https://github.com/nostr-dev-kit/ndk) that provides a complete toolkit for building Nostr applications on mobile platforms.
+
+## Features
+
+- 🔐 Multiple signer implementations (NIP-07, NIP-46, Private Key)
+- 💾 SQLite-based caching for offline support
+- 🔄 Subscription management with automatic reconnection
+- 📱 React Native and Expo compatibility
+- 🪝 React hooks for easy state management
+- 👛 Integrated wallet support
+
+## Installation
+
+```sh
+npm install @nostr-dev-kit/ndk-mobile
+```
+
+## Usage
+
+When using this library don't import `@nostr-dev-kit/ndk` directly, instead import `@nostr-dev-kit/ndk-mobile`. `ndk-mobile` exports the same classes as `ndk`, so you can just swap the import.
+
+Once you have imported the library, you can use the `NDKProvider` to wrap your application. Pass as props all the typical arguments you would pass to an `new NDK()` call.
+
+```tsx
+function App() {
+ return {/* your app here */} ;
+}
+```
+
+## useNDK()
+
+`useNDK()` provides access to the `ndk` instance and some other useful information.
+
+```tsx
+function LoginScreen() {
+ const { ndk, currentUser, login } = useNDK();
+
+ useEffect(() => {
+ if (currentUser) alert("you are now logged in as ", +currentUser.pubkey);
+ }, [currentUser]);
+
+ return login(withPayload("nsec1..."))} title="Login" />;
+}
+```
+
+## Example
+
+There is a barebones repository showing how to use this library:
+[ndk-mobile-sample](https://github.com/pablof7z/ndk-mobile-sample).
+
+For a real application using this look at [Olas](https://github.com/pablof7z/snapstr).
diff --git a/docs/mobile/profile-hook.md b/docs/mobile/profile-hook.md
new file mode 100644
index 00000000..b7f3b0f4
--- /dev/null
+++ b/docs/mobile/profile-hook.md
@@ -0,0 +1,20 @@
+# Using `useUserProfile` in `ndk-mobile`
+
+Efficiently managing user profiles is crucial for mobile applications. The `useUserProfile` hook helps streamline this process by using the SQLite cache adapter, reducing unnecessary re-renderings when the profile can be loaded synchronously.
+
+Here's a simple example of how to use the `useUserProfile` hook in a component:
+
+```tsx
+import React from "react";
+import { useUserProfile } from "ndk-mobile";
+
+function UserProfile({ pubkey }) {
+ const { userProfile, loading } = useUserProfile(pubkey);
+
+ if (loading) return Loading... ;
+
+ return {userProfile.displayName} ;
+}
+
+export default UserProfile;
+```
diff --git a/docs/mobile/session.md b/docs/mobile/session.md
new file mode 100644
index 00000000..718478f1
--- /dev/null
+++ b/docs/mobile/session.md
@@ -0,0 +1,36 @@
+# Session Management
+
+`ndk-mobile` provides a way to manage session events that are typically necessary to have available throughout an entire app.
+
+Wrapping your application with `` provides access to the session context.
+
+Say for example you want to allow your user to interface with their bookmarks, you want to have access to their bookmarks anywhere in the app both for reading and writing.
+
+```ts
+
+const kinds = new Map([
+ [NDKKind.ImageCurationSet, { wrapper: NDKList }],
+]);
+
+
+
+
+```
+
+Now say you want to allow the user to bookmark something with the click of a button:
+
+```tsx
+const { imageCurationSet } = useNDKSessionEventKind(NDKList, NDKKind.ImageCurationSet, {
+ create: true,
+});
+
+const bookmark = async () => {
+ await imageCurationSet.addItem(event);
+};
+```
+
+Now, when your app calls the `bookmark` function, it will add the event to the user's image curation set, if none exists it will create one for you.
+
+```
+
+```
diff --git a/docs/mobile/subscriptions.md b/docs/mobile/subscriptions.md
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/mobile/wallet.md b/docs/mobile/wallet.md
new file mode 100644
index 00000000..63ec5d1d
--- /dev/null
+++ b/docs/mobile/wallet.md
@@ -0,0 +1,47 @@
+# Wallet
+
+ndk-mobile makes operating with nostr wallets as seamless as possible.
+
+## Initialize
+
+When setting up your NDKSession provider, make sure to activate the wallet prop, to indicate you want to enable this feature.
+
+```ts
+
+```
+
+If your user has implicitly or explicitly enabled a wallet in your app you can also pass the configuration into the provider to immediately make the wallet available throughout your app.
+
+```ts
+const walletConfig = {
+ // type of wallet to enable
+ type: 'nip-60',
+
+ // some wallet Id this user can use
+ walletId: 'naddr1qvzqqqy3lupzq8mxja28nlfd6269zzzsm8feqxrl5eapf7g5fretnpucjh0xlannqythwumn8ghj7un9d3shjtnswf5k6ctv9ehx2ap0qqgx5un0weuxsmp5w4ax2e3cwe382kngsvc'
+
+
+```
+
+## Using the wallet
+
+Now from within your app you can easily zap via the `NDKZapper`, check balance, receive payments, and any other interaction you can use `ndk-wallet`.
+
+```ts
+const { activeWallet, balances } = useNDKSession();
+
+async function generateDeposit() {
+ const deposit = activeWallet.deposit(10, activeWallet.mints[0], 'sat');
+ deposit.on('success', () => console.log('✅ deposit'))
+ const bolt11 = await deposit.start();
+ console.log('pay this LN invoice', bolt11);
+}
+
+return (
+
+ Wallet balances = {JSON.stringify(balances)}
+
+
+
+)
+```
diff --git a/docs/tutorial/auth.md b/docs/tutorial/auth.md
new file mode 100644
index 00000000..664c6cdd
--- /dev/null
+++ b/docs/tutorial/auth.md
@@ -0,0 +1,29 @@
+# Relay Authentication
+
+NIP-42 defines that relays can request authentication from clients.
+
+NDK makes working with NIP-42 very simple. NDK uses an `NDKAuthPolicy` callback to provide a way to handle authentication requests.
+
+* Relays can have specific `NDKAuthPolicy` functions.
+* NDK can be configured with a default `relayAuthDefaultPolicy` function.
+* NDK provides some generic policies:
+ * `NDKAuthPolicies.signIn`: Authenticate to the relay (using the `ndk.signer` signer).
+ * `NDKAuthPolicies.disconnect`: Immediately disconnect from the relay if asked to authenticate.
+
+```ts
+import { NDK, NDKRelayAuthPolicies } from "@nostr-dev-kit/ndk";
+
+const ndk = new NDK();
+ndk.addExplicitRelay("wss://relay.f7z.io", NDKRelayAuthPolicies.signIn({ndk}));
+```
+
+Clients should typically allow their users to choose where to authenticate. This can be accomplished by returning the decision the user made from the `NDKAuthPolicy` function.
+
+```ts
+import { NDK, NDKRelayAuthPolicies } from "@nostr-dev-kit/ndk";
+
+const ndk = new NDK();
+ndk.relayAuthDefaultPolicy = (relay: NDKRelay) => {
+ return confirm(`Authenticate to ${relay.url}?`);
+};
+```
diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md
new file mode 100644
index 00000000..5570c7ff
--- /dev/null
+++ b/docs/tutorial/index.md
@@ -0,0 +1 @@
+# Hello
\ No newline at end of file
diff --git a/docs/tutorial/local-first.md b/docs/tutorial/local-first.md
new file mode 100644
index 00000000..aa9efa93
--- /dev/null
+++ b/docs/tutorial/local-first.md
@@ -0,0 +1,63 @@
+# Local first
+
+NDK allows for local-first software.
+
+This mode of operation depends on a few key things:
+* A cache adapter must be present
+* Events `event:publish-failed` should be handled by the application
+
+A few considerations:
+* Failed events are automatically retried by NDK
+* Handling of failed events is up to the application; handling here exclusively refers to notifying the user and updating the UI accordingly
+
+## Blocked publishing
+The default behavior when publishing an event will make `event.publish()` block
+until the event has been published or a failure has ocurred.
+```ts
+const event = new NDKEvent(ndk, { kind: 1, content: 'Blocking event' });
+const publishedToRelays = await event.publish();
+console.log(publishedToRelays); // relays where the event has published to
+```
+
+## Optimistic publishing
+If you want to publish an event without waiting for it to be published, you can use the `event.publish()` method.
+```ts
+const event = new NDKEvent(ndk, { kind: 1, content: 'Optimistic event' });
+event.publish();
+```
+
+When using a cache adapter that supports unpublished event tracking (like `NDKCacheAdapterDexie`), the event will be first
+written to the cache and then published to relays. When a minimal amount of relays have successfully received the event, the event will be removed from the cache.
+
+With this technique you can fire and forget event publshing.
+
+## Handling persistent failures
+When an event fails to publish, you can handle the failure by listening to the `event.failed` event.
+
+You should handle this event to notify the user that the event has failed to publish and update the UI accordingly.
+
+```ts
+// application-wide
+function handlePublishingFailures(event: NDKEvent, error: NDKPublishError) {
+ console.log(`Event ${event.id} failed to publish`, { publishedToRelays: error.publishedToRelays });
+}
+
+ndk.on("event:publish-failed", handlePublishingFailures);
+const event = new NDKEvent(ndk, { kind: 1, content: 'Failing event' });
+event.publish();
+```
+
+## Querying cached failed events
+Cache adapters with support for failed publish tracking can be queried via the `getUnpublishedEvents` interface.
+
+```ts
+const failedEvents = ndk.cachedAdapter.getUnpublishedEvents()
+
+console.log(failedEvents.length + " events have not published before; trying now");
+failedEvents.forEach((event) => event.publish());
+```
+
+When an event successfully publishes, the event will emit `published`.
+
+## Handling retries
+When booting up your application, NDK will automatically reattempt to publish any events that have failed to publish in the past.
diff --git a/docs/tutorial/publishing.md b/docs/tutorial/publishing.md
new file mode 100644
index 00000000..4298ebe2
--- /dev/null
+++ b/docs/tutorial/publishing.md
@@ -0,0 +1,38 @@
+# Publishing Events
+
+## Optimistic publish lifecycle
+
+Read more about the [local-first](./local-first.md) mode of operation.
+
+## Publishing Replaceable Events
+
+Some events in Nostr allow for replacement.
+
+Kinds `0`, `3`, range `10000-19999`.
+
+Range `30000-39999` is parameterized replaceable events, which means that multiple events of the same kind under the same pubkey can exist and are differentiated via their `d` tag.
+
+Since replaceable events depend on having a newer `created_at`, NDK provides a convenience method to reset `id`, `sig`, and `created_at` to allow for easy replacement.
+
+```ts
+const existingEvent = await ndk.fetchEvent({ kinds: [0], authors: []}); // fetch the event to replace
+existingEvent.tags.push(
+ [ "p", "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" ] // follow a new user
+);
+existingEvent.publish();
+```
+
+Since this is a replaceable event but the id+sig+created_at hasn't been changed, this won't work.
+
+```ts
+existingEvent.id = "";
+existingEvent.sig = "";
+existingEvent.created_at = undefined;
+existingEvent.publish(); // this will work
+```
+
+or what's equivalent:
+
+```ts
+existingEvent.publishReplaceable();
+```
\ No newline at end of file
diff --git a/docs/tutorial/speed.md b/docs/tutorial/speed.md
new file mode 100644
index 00000000..80cde51b
--- /dev/null
+++ b/docs/tutorial/speed.md
@@ -0,0 +1,48 @@
+# Built for speed
+
+NDK makes multiple optimizations possible to create a performant client.
+
+## Signature Verifications
+Signature validation is typically the most computationally expensive operation in a nostr client. Thus, NDK attempts to reduce the number of signature verifications that need to be done as much as possible.
+
+### Service Worker signature validation
+In order to create performant clients, it's very useful to offload this computation to a service worker, to avoid blocking the main thread.
+
+```ts
+// Using with vite
+const sigWorker = import.meta.env.DEV ?
+ new Worker(new URL('@nostr-dev-kit/ndk/workers/sig-verification?worker', import.meta.url), { type: 'module' }) : new NDKSigVerificationWorker();
+
+const ndk = new NDK();
+ndk.signatureVerificationWorker = worker
+```
+
+Since signature verification will thus be done asynchronously, it's important to listen for invalid signatures and handle them appropriately; you should
+always warn your users when they are receiving invalid signatures from a relay and/or immediately disconnect from an evil relay.
+
+```ts
+ndk.on("event:invalid-sig", (event) => {
+ const { relay } = event;
+ console.error("Invalid signature coming from relay", relay.url);
+});
+```
+
+### Signature verification sampling
+Another parameter we can tweak is how many signatures we verify. By default, NDK will verify every signature, but you can change this by setting a per-relay verification rate.
+
+```ts
+ndk.initialValidationRatio = 0.5; // Only verify 50% of the signatures for each relay
+ndk.lowestValidationRatio = 0.01; // Never verify less than 1% of the signatures for each relay
+```
+
+NDK will then begin verifying signatures from each relay and, as signatures as verified, it will reduce the verification rate for that relay.
+
+### Custom validation ratio function
+If you need further control on how the verification rate is adjusted, you can provide a validation ratio function. This function will be called periodically and the returning value will be used to adjust the verification rate.
+
+```ts
+ndk.validationRatioFn = (relay: NDKRelay, validatedEvents: number, nonValidatedEvents: number): number => {
+ // write your own custom function here
+ return validatedEvents / (validatedEvents + nonValidatedEvents);
+}
+```
\ No newline at end of file
diff --git a/docs/tutorial/subscription-management.md b/docs/tutorial/subscription-management.md
new file mode 100644
index 00000000..4461f666
--- /dev/null
+++ b/docs/tutorial/subscription-management.md
@@ -0,0 +1,67 @@
+# Subscription Management
+
+NDK attempts to intelligently group subscriptions to avoid excessively hitting relays with too many subscriptions when similar requests are going to be created with similar requests.
+
+Take the example of an application rendering a list of events along with the authors' name.
+
+This would typically be accomplished by creating a subscription for the desired events, say, kind:1s with a `#nostr` tag.
+
+```ts
+const sub = ndk.subscribe({ kinds: [ 1 ], "#t": [ "nostr" ] })
+sub.on("event", (event: NDKEvent) => {
+ const author = event.author;
+ const profile = await author.fetchProfile();
+
+ console.log(`${profile.name}: ${event.content}`);
+})
+```
+
+Now, this seemingly simple approach would have created a kind:0 subscription (`fetchProfile()`) for each note.
+
+Not great. Most relays will start to reject subscriptions when you have around 10 or 20 active requests.
+
+In this case, NDK will automatically realize that you are requesting `kind:0` events for a lot of different pubkeys and group them into a single subscription.
+
+Without grouping:
+```ts
+[ "REQ", "", '{ "kinds": [0], pubkeys: [ "pubkey1" ] }'],
+[ "REQ", "", '{ "kinds": [0], pubkeys: [ "pubkey2" ] }'],
+[ "REQ", "", '{ "kinds": [0], pubkeys: [ "pubkey3" ] }'],
+[ "REQ", "", '{ "kinds": [0], pubkeys: [ "pubkey4" ] }'],
+[ "REQ", "", '{ "kinds": [0], pubkeys: [ "pubkey5" ] }'],
+```
+
+With grouping:
+```ts
+[ "REQ", "", '{ "kinds": [0], pubkeys: [ "pubkey1", "pubkey2", "pubkey3", "pubkey4", "pubkey5" ] }'],
+```
+
+Application code doesn't need to concern itself with checking if the event they are receiving is the one they asked for; NDK will only call the event handler with the correct event so that the grouping is transparent to the application.
+
+## Disabling Grouping
+
+Sometimes you have a specific need or are certain that you won't be requesting multiple requests of the same type, so we can safely disable grouping and enjoy a small performance boost (since we don't need to wait for grouping to happen).
+
+```ts
+const sub = ndk.subscribe({ kinds: [ 1 ], "#t": [ "nostr" ] }, { groupable: false })
+```
+
+This will make the REQ for `kind:1` events to hit the relays immediately and skip the `100ms` (default) grouping window.
+
+If you want to change the grouping delay you can do so by setting the `groupingDelay` option
+
+```ts
+const sub = ndk.subscribe({ kinds: [ 1 ], "#t": [ "nostr" ] }, { groupingDelay: 500 })
+```
+
+You can also establish how the delay should be interpreted:
+
+```ts
+const sub = ndk.subscribe({ kinds: [ 1 ], "#t": [ "nostr" ] }, { groupingDelayType: "at-least" })
+// * "at-least" means "wait at least this long before sending the subscription"
+// * "at-most" means "wait at most this long before sending the subscription"
+```
+
+When using `at-least` the subscription timer will be reset every time a new subscription is added to the group.
+
+For example, if you create two subscriptions, one at `t=0` and the other one 50ms later (`t=50ms`), with a `groupableDelay` of `200ms`, `at-least` would send the subscription at `t=250ms` and `at-most` would send it at `t=200ms`.
\ No newline at end of file
diff --git a/docs/tutorial/zaps/index.md b/docs/tutorial/zaps/index.md
new file mode 100644
index 00000000..1c388ebd
--- /dev/null
+++ b/docs/tutorial/zaps/index.md
@@ -0,0 +1,16 @@
+# Zaps
+
+NDK comes with an interface to make zapping as simple as possible.
+
+```ts
+const user = await ndk.getUserFromNip05("pablo@f7z.io");
+const lnPay = ({ pr: string }) => {
+ console.log("please pay to complete the zap", pr);
+};
+const zapper = new NDKZapper(user, 1000, { lnPay });
+zapper.zap();
+```
+
+## NDK-Wallet
+
+Refer to the Wallet section of the tutorial to learn more about zapping. NDK-wallet provides many conveniences to integrate with zaps.
diff --git a/docs/wallet/discover.md b/docs/wallet/discover.md
new file mode 100644
index 00000000..7a378678
--- /dev/null
+++ b/docs/wallet/discover.md
@@ -0,0 +1,44 @@
+# Discover wallets
+
+A user might have configured your application to use a specific wallet (e.g. via WebLN), or they could be
+interfacing with your application with a NIP-60 wallet created somewhere else.
+
+Additionally, a user might have NIP-61 nutzaps enabled.
+
+The `NDKWalletService` provides a simple interface to handle these cases:
+* Configure + Discover configured wallets
+* See NIP-61 nutzaps
+
+## Discovering configured wallets
+```typescript
+const walletService = new NDKWalletService(ndk);
+
+walletService.start();
+walletService.on("wallet", (wallet: NDKWallet, isDefault: boolean) => {
+ console.log("Found a wallet of type " + wallet.type, isDefault ? "(default)" : "");
+});
+```
+
+`walletService.start()` will do the following:
+* If you have established a client name via `ndk.clientName`, it will look for a NIP-78 configuration
+
+## Configuring a wallet for your application
+```typescript
+ndk.clientName = 'my-application';
+const walletService = new NDKWalletService(ndk);
+const wallet = new NDKWebLNWallet();
+await walletService.setDefaultWallet(wallet);
+```
+
+`walletService.setDefaultWallet(wallet)` will add to your applications's NIP-78, the configuration to be able to use this wallet next time your application starts, next time your application is initialized, `walletService.start()` will emit a `wallet` event for this wallet.
+
+## Seeing NIP-61 nutzaps
+```typescript
+const walletService = new NDKWalletService(ndk);
+walletService.start();
+walletService.on("nutzap", (nutzap: NDKNutzap) => {
+ console.log("Received a nutzap from " + nutzap.pubkey + " for " + nutzap.amount + " " + nutzap.unit + " on mint " + nutzap.mint);
+ // -> "Received a nutzap from fa98..... for 1 usd on mint https://..."
+});
+```
+
diff --git a/docs/wallet/index.md b/docs/wallet/index.md
new file mode 100644
index 00000000..421fb57f
--- /dev/null
+++ b/docs/wallet/index.md
@@ -0,0 +1,23 @@
+# Wallet
+
+NDK provides an optional `@nostr-dev-kit/ndk-wallet` package, which provides common interfaces and functionalities to interface with different wallet adapters.
+
+Currently ndk-wallet supports:
+
+- NIP-60 wallets (nutsacks)
+- NIP-47 connectors (NWC)
+- WebLN (when available)
+
+## Connecting NDK with a wallet
+
+As a developer, the first thing you need to do to use a wallet in your app is to choose how you will connect to your wallet by using one of the wallet adapters.
+
+Once you instantiate the desired wallet, you simply pass it to ndk.
+
+```ts
+const wallet = new NDKNWCWallet(ndk);
+await wallet.initWithPairingCode("nostr+walletconnect:....");
+ndk.wallet = wallet;
+```
+
+Now whenever you want to pay something, the wallet will be called. Refer to the Nutsack adapter to see more details of the interface.
diff --git a/docs/wallet/nutsack.md b/docs/wallet/nutsack.md
new file mode 100644
index 00000000..28760f65
--- /dev/null
+++ b/docs/wallet/nutsack.md
@@ -0,0 +1,95 @@
+# NIP-60 (Nutack) wallets
+
+NIP-60 provides wallets that are available to any nostr application immediately; the goal of NIP-60 is to provide the same
+seamless experience nostr users expect from their apps with regards to the immediate aailability of their data, to their money.
+
+## Creating a NIP-60 wallet
+
+```ts
+import NDKWallet from "@nostr-dev-kit/ndk-wallet";
+import NDK from "@nostr-dev-kit/ndk";
+
+// instantiate your NDK
+const ndk = new NDK({
+ explicitRelayUrls: [ ],
+ signer = NDKPrivateKeySigner.generate();
+});
+ndk.connect();
+
+// create a new NIP-60 wallet
+const unit = "sat"; // unit of the wallet
+const mints = [ 'https://testnut.cashu.space' ] // mints the wallet will use
+const relays = [ 'wss://f7z.io', 'ws://localhost:4040' ]; // relays where proofs will be stored
+const wallet = NDKCashuWallet.create(ndk, unit, mints, relays);
+await wallet.publish();
+```
+
+This will publish a wallet `kind:37376` event, which contains the wallet information.
+
+We now have a NIP-60 wallet -- this wallet will be available from any nostr client that supports NIP-60.
+
+## Deposit money
+
+```ts
+const deposit = wallet.deposit(1000, mints[0]);
+const bolt11 = deposit.start(); // get a LN PR
+deposit.on("success", () => console.log("we have money!", wallet.balance()));
+```
+
+## Configure NDK to use a wallet
+
+You can configure NDK to use some wallet, this is equivalent for whatever wallet adapter you choose to use.
+
+```ts
+ndk.wallet = wallet;
+```
+
+## Send a zap
+
+Now that we have a wallet, some funds, and we have ndk prepared to use that wallet, we'll send a zap. NDK provides a convenient `wallet` setter that allows
+
+```ts
+const user = await NDKUser.fronNip05("_@f7z.io");
+const zapper = new NDKZapper(user, 1, "sat", {
+ comment: "hello from my wallet!",
+});
+zapper.on("complete", () => console.log("pablo was zapped!"));
+zapper.zap();
+```
+
+## Zapping without a wallet
+
+If you don't connect a wallet to ndk and attempt to zap, you will receive the zapping information(s) so you can give your users the possibility of paying manually.
+
+```ts
+// no wallet
+ndk.wallet = undefined;
+
+// this function will be called when a bolt11 needs to be paid
+const lnPay = async (payment: NDKZapDetails) => {
+ console.log("please pay this invoice to complete the zap", payment.pr);
+};
+
+const zapper = new NDKZapper(user, 1, "sat", { comment: "manual zapping", lnPay });
+const paymentInfo = await zapper.zap();
+```
+
+You can also configure this at the application level, for example, to open a modal whenever a payment needs to be done
+
+```ts
+const lnPay = async (payment: NDKZapDetails) => {
+ alert("please pay this invoice: " + payment.pr);
+};
+
+ndk.wallet = { lnPay };
+```
+
+## Receiving ecash
+
+To receive ecash just call the `receiveToken` method on the wallet.
+
+```ts
+const tokenEvent = await wallet.receiveToken(token);
+```
+
+This will swap the tokens in the right mint and add them to the wallet. Note that if the mint of this token is not one of the ones in the wallet you will need to move them to the mint you want manually.
diff --git a/docs/wallet/nutzaps.md b/docs/wallet/nutzaps.md
new file mode 100644
index 00000000..d5b92826
--- /dev/null
+++ b/docs/wallet/nutzaps.md
@@ -0,0 +1,16 @@
+# Nutzaps
+
+ndk-wallet provides a simple way to automatically redeem nutzaps. You can run this periodically or you can just start it as part of the boostrap of your application
+
+# Sweeping NIP-61 nutzaps
+
+When a user receives a nutzap, they should sweep the public tokens into their wallet, the `@nostr-dev-kit/ndk-wallet` package takes care of this for you when
+the `NDKWalletService` is running by default.
+
+```ts
+const walletService = new NDKWalletService(ndk);
+walletService.start();
+walletService.on("nutzap", (nutzap: NDKNutzap) => {
+ console.log("Received a nutzap", nutzap);
+});
+```
diff --git a/docs/wrappers/svelte.md b/docs/wrappers/svelte.md
new file mode 100644
index 00000000..c3946a43
--- /dev/null
+++ b/docs/wrappers/svelte.md
@@ -0,0 +1,107 @@
+# NDK Svelte
+
+NDK Svelte is a wrapper around NDK that provides convenient accessors to use NDK in Svelte applications.
+
+## Install
+
+```
+pnpm add @nostr-dev-kit/ndk-svelte --save
+```
+
+## Store subscriptions
+
+NDK-svelte provides Svelte Store subscriptions so your components can have simple reactivity
+when events arrive.
+
+Events in the store will appear in a set ordered by `created_at`.
+
+```typescript
+import NDKSvelte from "@nostr-dev-kit/ndk-svelte";
+
+const ndk = new NDKSvelte({
+ explicitRelayUrls: ["wss://relay.f7z.io"],
+});
+```
+
+```typescript
+// in your components
+
+
+
+ {$highlights.length} highlights seen
+
+
+
+ {$nostrHighlightsAndReposts.length} nostr highlights (including reposts)
+
+```
+
+## Reference Counting with ref/unref
+
+NDK-svelte introduces a reference counting mechanism through the ref and unref methods on the stores. This system is particularly useful for optimizing the lifecycle of subscriptions in components that might be frequently mounted and unmounted.
+
+### Benefits:
+
+- **Optimized Lifecycle**: Instead of starting a new subscription every time a component mounts, and ending it when it unmounts, you can reuse an existing subscription if another component is already using it.
+
+- **Resource Efficiency**: By preventing redundant subscriptions, you save both network bandwidth and processing power.
+
+- **`Synchronization**: Ensures that multiple components referencing the same data are synchronized with a single data source.
+
+### How to use:
+
+Whenever you subscribe to a store in a component, call ref to increment the reference count:
+
+```ts
+// lib/stores/highlightsStore.ts
+const highlightsStore = $ndk.storeSubscribe(..., { autoStart: false } });
+
+// component 1
+
+
+{$highlightsStore.length} highlights seen
+```
+
+You can mount this component as many times as you want, and the subscription will only be started once. When the last component unmounts, the subscription will be terminated.
+
+## Manual access to subscriptions
+You should probably not need this, so if you are peaking into how to try to access directly to the subscriptions, you are probably doing something wrong. But, in the extremely rare case you need to access the subscriptions directly, you can do so by adding a callback with the `onEvent` option.
+
+Note that this is not recommended and the `onEvent` callback will be called immediately, without ordering events by latest version (i.e. on replace events)
+
+```ts
+const highlights = $ndk.storeSubscribe(
+ { kinds: [9802 as number] }, // Highlights
+ {
+ onEvent: (event) => console.log("Event received", event),
+ onEose: () => console.log("Subscription EOSE reached")
+ }
+);
+```
\ No newline at end of file
diff --git a/ndk-cache-dexie/.gitignore b/ndk-cache-dexie/.gitignore
new file mode 100644
index 00000000..0fd84284
--- /dev/null
+++ b/ndk-cache-dexie/.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+node_modules
+**/*.js
+dist
+docs
diff --git a/ndk-cache-dexie/.prettierignore b/ndk-cache-dexie/.prettierignore
new file mode 100644
index 00000000..53c37a16
--- /dev/null
+++ b/ndk-cache-dexie/.prettierignore
@@ -0,0 +1 @@
+dist
\ No newline at end of file
diff --git a/ndk-cache-dexie/CHANGELOG.md b/ndk-cache-dexie/CHANGELOG.md
new file mode 100644
index 00000000..ea37cb50
--- /dev/null
+++ b/ndk-cache-dexie/CHANGELOG.md
@@ -0,0 +1,389 @@
+# @nostr-dev-kit/ndk-cache-dexie
+
+## 2.5.8
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.7
+
+## 2.5.7
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.6
+
+## 2.5.6
+
+### Patch Changes
+
+- Updated dependencies [5939a3e]
+- Updated dependencies
+- Updated dependencies [f2a0cce]
+ - @nostr-dev-kit/ndk@2.10.5
+
+## 2.5.5
+
+### Patch Changes
+
+- Updated dependencies [5bed70c]
+- Updated dependencies [873ad4a]
+ - @nostr-dev-kit/ndk@2.10.4
+
+## 2.5.4
+
+### Patch Changes
+
+- Updated dependencies [0fc66c5]
+ - @nostr-dev-kit/ndk@2.10.3
+
+## 2.5.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.2
+
+## 2.5.2
+
+### Patch Changes
+
+- d6cfa8a: track at which timestamp we cached events
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [722345b]
+ - @nostr-dev-kit/ndk@2.10.1
+
+## 2.5.1
+
+### Patch Changes
+
+- apply limit filter
+- abb3cd9: add tests
+- index event kinds and add byKinds filter
+- improve profile fetching from dexie
+- 3029124: add methods to access and manage unpublished events from the cache
+- Updated dependencies [ec83ddc]
+- Updated dependencies [18c55bb]
+- Updated dependencies
+- Updated dependencies [18c55bb]
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies [3029124]
+ - @nostr-dev-kit/ndk@2.10.0
+
+## 2.5.0
+
+### Minor Changes
+
+- fix bug where we are indexing really events tags unrestricted
+- control that we don't unnecessarily load more stuff into the LRU beyond it's max size
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.9.1
+
+## 2.4.3
+
+### Patch Changes
+
+- 548f4d8: add optimistic updates
+- Updated dependencies [94018b4]
+- Updated dependencies [548f4d8]
+ - @nostr-dev-kit/ndk@2.9.0
+
+## 2.4.2
+
+### Patch Changes
+
+- cache relay reconnection status
+- Updated dependencies [0af033f]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.8.2
+
+## 2.4.1
+
+### Patch Changes
+
+- e40312b: get all profiles that match a filter function from a cahce
+- Updated dependencies [e40312b]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.8.1
+
+## 2.4.0
+
+### Minor Changes
+
+- 949d26a: Add LRU cache for zappers specs
+- ba2a206: improve LRU caches -- refactor to make adding new caches easier
+- bump
+
+### Patch Changes
+
+- a602d0c: performance improvements on cache
+- fcd41ba: fix bug where we are REQing events even if they were cached and the filter has completed
+- Updated dependencies [91d873c]
+- Updated dependencies [6fd9ddc]
+- Updated dependencies [0b8f331]
+- Updated dependencies
+- Updated dependencies [f2898ad]
+- Updated dependencies [9b92cd9]
+- Updated dependencies
+- Updated dependencies [6814f0c]
+- Updated dependencies [89b5b3f]
+- Updated dependencies [9b92cd9]
+- Updated dependencies [27b10cc]
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies [ed7cdc4]
+ - @nostr-dev-kit/ndk@2.8.0
+
+## 2.3.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.7.1
+
+## 2.3.0
+
+### Minor Changes
+
+- Cache NIP-05 and zap specs
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.7.0
+
+## 2.2.10
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.6.1
+
+## 2.2.9
+
+### Patch Changes
+
+- c2db3c1: delete events from cache
+- Updated dependencies
+- Updated dependencies [c2db3c1]
+- Updated dependencies
+- Updated dependencies [c2db3c1]
+- Updated dependencies [c2db3c1]
+ - @nostr-dev-kit/ndk@2.6.0
+
+## 2.2.8
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.5.1
+
+## 2.2.7
+
+### Patch Changes
+
+- Updated dependencies [e08fc74]
+ - @nostr-dev-kit/ndk@2.5.0
+
+## 2.2.6
+
+### Patch Changes
+
+- 15bcc10: fix profile LRU Cache
+- Updated dependencies [111c1ea]
+- Updated dependencies [5c0ae51]
+- Updated dependencies [6f5ea49]
+- Updated dependencies [3738d39]
+- Updated dependencies [d22239a]
+ - @nostr-dev-kit/ndk@2.4.1
+
+## 2.2.5
+
+### Patch Changes
+
+- Updated dependencies [b9bbf1d]
+ - @nostr-dev-kit/ndk@2.4.0
+
+## 2.2.4
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [885b6c2]
+- Updated dependencies [5666d56]
+ - @nostr-dev-kit/ndk@2.3.3
+
+## 2.2.3
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [4628481]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.3.2
+
+## 2.2.2
+
+### Patch Changes
+
+- Updated dependencies [ece965f]
+ - @nostr-dev-kit/ndk@2.3.1
+
+## 2.2.1
+
+### Patch Changes
+
+- Updated dependencies [54cec78]
+- Updated dependencies [ef61d83]
+- Updated dependencies [98b77dd]
+- Updated dependencies [46b0c77]
+- Updated dependencies [082e243]
+ - @nostr-dev-kit/ndk@2.3.0
+
+## 2.1.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.2.0
+
+## 2.0.10
+
+### Patch Changes
+
+- Updated dependencies [180d774]
+- Updated dependencies [7f00c40]
+ - @nostr-dev-kit/ndk@2.1.3
+
+## 2.0.9
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.2
+
+## 2.0.8
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.1
+
+## 2.0.7
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.0
+
+## 2.0.6
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.6
+
+## 2.0.5
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [d45d962]
+ - @nostr-dev-kit/ndk@2.0.5
+
+## 2.0.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.4
+
+## 2.0.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.3
+
+## 2.0.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.2
+
+## 1.3.6
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.0
+
+## 1.3.5
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.2
+
+## 1.3.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.1
+
+## 1.3.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.0
+
+## 1.3.3
+
+### Patch Changes
+
+- Updated dependencies [b3561af]
+ - @nostr-dev-kit/ndk@1.3.2
+
+## 1.3.2
+
+### Patch Changes
+
+- Add kind:0 to LRU cache regardless of how they are fetched
+
+## 1.3.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.3.1
+
+## 1.3.0
+
+### Minor Changes
+
+- 3440768: User profile dedicated cache
+
+### Patch Changes
+
+- Updated dependencies [88df10a]
+- Updated dependencies [c225094]
+- Updated dependencies [cf4a648]
+- Updated dependencies [3946078]
+- Updated dependencies [3440768]
+ - @nostr-dev-kit/ndk@1.3.0
diff --git a/ndk-cache-dexie/README.md b/ndk-cache-dexie/README.md
new file mode 100644
index 00000000..e79c24a6
--- /dev/null
+++ b/ndk-cache-dexie/README.md
@@ -0,0 +1,28 @@
+# ndk-cache-dexie
+
+NDK cache adapter for [Dexie](https://dexie.org/). Dexie is a wrapper around IndexedDB, an in-browser database.
+
+## Usage
+
+NDK will attempt to use the Dexie adapter to store users, events, and tags. The default behaviour is to always check the cache first and then hit relays, replacing older cached events as needed.
+
+### Install
+
+```
+pnpm add @nostr-dev-kit/ndk-cache-dexie
+```
+
+### Add as a cache adapter
+
+```ts
+import NDKCacheAdapterDexie from "@nostr-dev-kit/ndk-cache-dexie";
+
+const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'your-db-name' });
+const ndk = new NDK({cacheAdapter: dexieAdapter, ...other config options});
+```
+
+🚨 Because Dexie only exists client-side, this cache adapter will not work in pure node.js environments. You'll need to make sure that you're using the right cache adapter in the right place (e.g. Redis on the backend, Dexie on the frontend).
+
+# License
+
+MIT
diff --git a/ndk-cache-dexie/jest.config.ts b/ndk-cache-dexie/jest.config.ts
new file mode 100644
index 00000000..8cac051d
--- /dev/null
+++ b/ndk-cache-dexie/jest.config.ts
@@ -0,0 +1,16 @@
+import type { Config } from "jest";
+
+const config: Config = {
+ preset: "ts-jest",
+ verbose: true,
+ expand: true,
+ testEnvironment: "node",
+ testTimeout: 10000,
+ setupFiles: ["fake-indexeddb/auto"],
+ openHandlesTimeout: 4000,
+ moduleNameMapper: {
+ "^(\\.{1,2}/.*)\\.js$": "$1",
+ },
+};
+
+export default config;
diff --git a/ndk-cache-dexie/package.json b/ndk-cache-dexie/package.json
new file mode 100644
index 00000000..3146bd46
--- /dev/null
+++ b/ndk-cache-dexie/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "@nostr-dev-kit/ndk-cache-dexie",
+ "version": "2.5.8",
+ "description": "NDK Dexie Cache Adapter",
+ "license": "MIT",
+ "docs": "typedoc",
+ "bugs": {
+ "url": "https://github.com/nostr-dev-kit/ndk-cache-dexie/issues"
+ },
+ "homepage": "https://github.com/nostr-dev-kit/ndk-cache-dexie#readme",
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "exports": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "scripts": {
+ "dev": "pnpm build --watch",
+ "build": "tsup src/index.ts --format cjs,esm --dts",
+ "clean": "rm -rf dist",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write ."
+ },
+ "keywords": [
+ "nostr",
+ "dexie",
+ "cache"
+ ],
+ "authors": [
+ "pablof7z",
+ "erskingardner"
+ ],
+ "devDependencies": {
+ "@nostr-dev-kit/eslint-config-custom": "workspace:*",
+ "@nostr-dev-kit/tsconfig": "workspace:*",
+ "@types/debug": "^4.1.12",
+ "@types/jest": "^29.5.13",
+ "@types/node": "^22.6.1",
+ "fake-indexeddb": "^6.0.0",
+ "jest": "^29.7.0",
+ "prettier": "^3.3.3",
+ "ts-jest": "^29.2.5",
+ "tsup": "^8.3.0",
+ "typedoc": "^0.26.7",
+ "typedoc-plugin-markdown": "^4.3.0"
+ },
+ "dependencies": {
+ "@nostr-dev-kit/ndk": "workspace:*",
+ "debug": "^4.3.7",
+ "dexie": "^4.0.8",
+ "nostr-tools": "^2.4.0",
+ "typescript-lru-cache": "^2.0.0"
+ }
+}
diff --git a/ndk-cache-dexie/src/caches/event-tags.ts b/ndk-cache-dexie/src/caches/event-tags.ts
new file mode 100644
index 00000000..37495f81
--- /dev/null
+++ b/ndk-cache-dexie/src/caches/event-tags.ts
@@ -0,0 +1,37 @@
+import type { Table } from "dexie";
+import type { CacheHandler } from "../lru-cache";
+import type debug from "debug";
+import type { EventTag } from "../db";
+import type { LRUCache } from "typescript-lru-cache";
+
+export type EventTagCacheEntry = string;
+
+export async function eventTagsWarmUp(
+ cacheHandler: CacheHandler,
+ eventTags: Table
+) {
+ const array = await eventTags.limit(cacheHandler.maxSize).toArray();
+ for (const event of array) {
+ cacheHandler.add(event.tagValue, event.eventId, false);
+ }
+}
+
+export const eventTagsDump = (eventTags: Table, debug: debug.IDebugger) => {
+ return async (dirtyKeys: Set, cache: LRUCache) => {
+ const entries = [];
+
+ for (const tagValue of dirtyKeys) {
+ const eventIds = cache.get(tagValue);
+ if (eventIds) {
+ for (const eventId of eventIds) entries.push({ tagValue, eventId });
+ }
+ }
+
+ if (entries.length > 0) {
+ debug(`Saving ${entries.length} events cache entries to database`);
+ await eventTags.bulkPut(entries);
+ }
+
+ dirtyKeys.clear();
+ };
+};
diff --git a/ndk-cache-dexie/src/caches/events.ts b/ndk-cache-dexie/src/caches/events.ts
new file mode 100644
index 00000000..ebed2635
--- /dev/null
+++ b/ndk-cache-dexie/src/caches/events.ts
@@ -0,0 +1,35 @@
+import type { Table } from "dexie";
+import type { CacheHandler } from "../lru-cache";
+import type debug from "debug";
+import type { Event } from "../db";
+import type { LRUCache } from "typescript-lru-cache";
+
+export type EventCacheEntry = Event;
+
+export async function eventsWarmUp(
+ cacheHandler: CacheHandler,
+ events: Table
+) {
+ const array = await events.limit(cacheHandler.maxSize).toArray();
+ for (const event of array) {
+ cacheHandler.set(event.id, event, false);
+ }
+}
+
+export const eventsDump = (events: Table, debug: debug.IDebugger) => {
+ return async (dirtyKeys: Set, cache: LRUCache) => {
+ const entries: EventCacheEntry[] = [];
+
+ for (const event of dirtyKeys) {
+ const entry = cache.get(event);
+ if (entry) entries.push(entry);
+ }
+
+ if (entries.length > 0) {
+ debug(`Saving ${entries.length} events cache entries to database`);
+ await events.bulkPut(entries);
+ }
+
+ dirtyKeys.clear();
+ };
+};
diff --git a/ndk-cache-dexie/src/caches/nip05.ts b/ndk-cache-dexie/src/caches/nip05.ts
new file mode 100644
index 00000000..1334fc7b
--- /dev/null
+++ b/ndk-cache-dexie/src/caches/nip05.ts
@@ -0,0 +1,43 @@
+import type { Table } from "dexie";
+import type { CacheHandler } from "../lru-cache";
+import type debug from "debug";
+import type { Nip05 } from "../db";
+import type { LRUCache } from "typescript-lru-cache";
+
+export type Nip05CacheEntry = {
+ profile: string | null;
+ fetchedAt: number;
+};
+
+export async function nip05WarmUp(
+ cacheHandler: CacheHandler,
+ nip05s: Table
+) {
+ const array = await nip05s.limit(cacheHandler.maxSize).toArray();
+ for (const nip05 of array) {
+ cacheHandler.set(nip05.nip05, nip05, false);
+ }
+}
+
+export const nip05Dump = (nip05s: Table, debug: debug.IDebugger) => {
+ return async (dirtyKeys: Set, cache: LRUCache) => {
+ const entries = [];
+
+ for (const nip05 of dirtyKeys) {
+ const entry = cache.get(nip05);
+ if (entry) {
+ entries.push({
+ nip05,
+ ...entry,
+ });
+ }
+ }
+
+ if (entries.length) {
+ debug(`Saving ${entries.length} NIP-05 cache entries to database`);
+ await nip05s.bulkPut(entries);
+ }
+
+ dirtyKeys.clear();
+ };
+};
diff --git a/ndk-cache-dexie/src/caches/profiles.ts b/ndk-cache-dexie/src/caches/profiles.ts
new file mode 100644
index 00000000..96fefcc7
--- /dev/null
+++ b/ndk-cache-dexie/src/caches/profiles.ts
@@ -0,0 +1,42 @@
+import type { Table } from "dexie";
+import type { Profile } from "../db";
+import type { CacheHandler } from "../lru-cache";
+import type { LRUCache } from "typescript-lru-cache";
+export { db } from "../db.js";
+import createDebug from "debug";
+
+const d = createDebug("ndk:dexie-adapter:profiles");
+
+export async function profilesWarmUp(
+ cacheHandler: CacheHandler,
+ profiles: Table
+): Promise {
+ const array = await profiles.limit(cacheHandler.maxSize).toArray();
+ for (const user of array) {
+ const obj = user;
+ cacheHandler.set(user.pubkey, obj, false);
+ }
+
+ d("Loaded %d profiles from database", cacheHandler.size());
+}
+
+export const profilesDump = (profiles: Table, debug: debug.IDebugger) => {
+ return async (dirtyKeys: Set, cache: LRUCache) => {
+ const entries = [];
+
+ for (const pubkey of dirtyKeys) {
+ const entry = cache.get(pubkey);
+ if (entry) {
+ entries.push(entry);
+ }
+ }
+
+ if (entries.length) {
+ debug(`Saving ${entries.length} users to database`);
+
+ await profiles.bulkPut(entries);
+ }
+
+ dirtyKeys.clear();
+ };
+};
diff --git a/ndk-cache-dexie/src/caches/relay-info.ts b/ndk-cache-dexie/src/caches/relay-info.ts
new file mode 100644
index 00000000..13f72d2b
--- /dev/null
+++ b/ndk-cache-dexie/src/caches/relay-info.ts
@@ -0,0 +1,49 @@
+import type { Table } from "dexie";
+import type { CacheHandler } from "../lru-cache";
+import type debug from "debug";
+import type { LRUCache } from "typescript-lru-cache";
+import { RelayStatus } from "../db";
+
+export async function relayInfoWarmUp(
+ cacheHandler: CacheHandler,
+ relayStatus: Table
+) {
+ const array = await relayStatus.limit(cacheHandler.maxSize).toArray();
+ for (const entry of array) {
+ cacheHandler.set(
+ entry.url,
+ {
+ url: entry.url,
+ updatedAt: entry.updatedAt,
+ lastConnectedAt: entry.lastConnectedAt,
+ dontConnectBefore: entry.dontConnectBefore,
+ },
+ false
+ );
+ }
+}
+
+export const relayInfoDump = (relayStatus: Table, debug: debug.IDebugger) => {
+ return async (dirtyKeys: Set, cache: LRUCache) => {
+ const entries = [];
+
+ for (const url of dirtyKeys) {
+ const info = cache.get(url);
+ if (info) {
+ entries.push({
+ url,
+ updatedAt: info.updatedAt,
+ lastConnectedAt: info.lastConnectedAt,
+ dontConnectBefore: info.dontConnectBefore,
+ });
+ }
+ }
+
+ if (entries.length > 0) {
+ debug(`Saving ${entries.length} relay status cache entries to database`);
+ await relayStatus.bulkPut(entries);
+ }
+
+ dirtyKeys.clear();
+ };
+};
diff --git a/ndk-cache-dexie/src/caches/unpublished-events.ts b/ndk-cache-dexie/src/caches/unpublished-events.ts
new file mode 100644
index 00000000..0beaa6ef
--- /dev/null
+++ b/ndk-cache-dexie/src/caches/unpublished-events.ts
@@ -0,0 +1,104 @@
+import { NDKEvent, NDKEventId, NDKRelay } from "@nostr-dev-kit/ndk";
+import { UnpublishedEvent } from "../db";
+import type debug from "debug";
+import type { Table } from "dexie";
+import { LRUCache } from "typescript-lru-cache";
+import { CacheHandler } from "../lru-cache";
+import NDKCacheAdapterDexie from "..";
+
+/**
+ * The threshold
+ */
+const WRITE_STATUS_THRESHOLD = 3;
+
+export async function unpublishedEventsWarmUp(
+ cacheHandler: CacheHandler,
+ unpublishedEvents: Table
+) {
+ await unpublishedEvents.each((unpublishedEvent) => {
+ cacheHandler.set(unpublishedEvent.event.id!, unpublishedEvent, false);
+ });
+}
+
+export function unpublishedEventsDump(
+ unpublishedEvents: Table,
+ debug: debug.IDebugger
+) {
+ return async (dirtyKeys: Set, cache: LRUCache) => {
+ const entries: UnpublishedEvent[] = [];
+
+ for (const eventId of dirtyKeys) {
+ const entry = cache.get(eventId);
+ if (entry) {
+ entries.push(entry);
+ }
+ }
+
+ if (entries.length > 0) {
+ debug(`Saving ${entries.length} unpublished events cache entries to database`);
+ await unpublishedEvents.bulkPut(entries);
+ }
+
+ dirtyKeys.clear();
+ };
+}
+
+export async function discardUnpublishedEvent(
+ unpublishedEvents: Table,
+ eventId: NDKEventId
+): Promise {
+ await unpublishedEvents.delete(eventId);
+}
+
+export async function getUnpublishedEvents(
+ unpublishedEvents: Table
+): Promise<{ event: NDKEvent; relays: WebSocket["url"][]; lastTryAt?: number }[]> {
+ const events: { event: NDKEvent; relays: WebSocket["url"][]; lastTryAt?: number }[] = [];
+
+ await unpublishedEvents.each((unpublishedEvent) => {
+ events.push({
+ event: new NDKEvent(undefined, unpublishedEvent.event),
+ relays: Object.keys(unpublishedEvent.relays),
+ lastTryAt: unpublishedEvent.lastTryAt,
+ });
+ });
+
+ return events;
+}
+
+export function addUnpublishedEvent(
+ this: NDKCacheAdapterDexie,
+ event: NDKEvent,
+ relays: WebSocket["url"][]
+): void {
+ const r: UnpublishedEvent["relays"] = {};
+ relays.forEach((url) => (r[url] = false));
+ this.unpublishedEvents.set(event.id!, { id: event.id, event: event.rawEvent(), relays: r });
+
+ const onPublished = (relay: NDKRelay) => {
+ const url = relay.url;
+
+ const existingEntry = this.unpublishedEvents.get(event.id);
+
+ if (!existingEntry) {
+ event.off("publushed", onPublished);
+ return;
+ }
+
+ existingEntry.relays[url] = true;
+ this.unpublishedEvents.set(event.id, existingEntry);
+
+ let successWrites = Object.values(existingEntry.relays).filter((v) => v).length;
+ let unsuccessWrites = Object.values(existingEntry.relays).length - successWrites;
+
+ if (successWrites >= WRITE_STATUS_THRESHOLD || unsuccessWrites === 0) {
+ // this.debug(`Removing ${event.id} from cache`, { successWrites, unsuccessWrites });
+ this.unpublishedEvents.delete(event.id);
+ event.off("published", onPublished);
+ // } else {
+ // this.debug(`Keeping ${event.id} in cache`, { successWrites, unsuccessWrites });
+ }
+ };
+
+ event.on("published", onPublished);
+}
diff --git a/ndk-cache-dexie/src/caches/zapper.ts b/ndk-cache-dexie/src/caches/zapper.ts
new file mode 100644
index 00000000..679e30a4
--- /dev/null
+++ b/ndk-cache-dexie/src/caches/zapper.ts
@@ -0,0 +1,47 @@
+import type { Table } from "dexie";
+import type { CacheHandler } from "../lru-cache";
+import type debug from "debug";
+import type { Lnurl } from "../db";
+import type { LRUCache } from "typescript-lru-cache";
+
+export type ZapperCacheEntry = {
+ document: string | null;
+ fetchedAt: number;
+};
+
+export async function zapperWarmUp(
+ cacheHandler: CacheHandler,
+ lnurls: Table
+) {
+ const array = await lnurls.limit(cacheHandler.maxSize).toArray();
+ for (const lnurl of array) {
+ cacheHandler.set(
+ lnurl.pubkey,
+ { document: lnurl.document, fetchedAt: lnurl.fetchedAt },
+ false
+ );
+ }
+}
+
+export const zapperDump = (lnurls: Table, debug: debug.IDebugger) => {
+ return async (dirtyKeys: Set, cache: LRUCache) => {
+ const entries = [];
+
+ for (const pubkey of dirtyKeys) {
+ const entry = cache.get(pubkey);
+ if (entry) {
+ entries.push({
+ pubkey,
+ ...entry,
+ });
+ }
+ }
+
+ if (entries.length) {
+ debug(`Saving ${entries.length} zapper cache entries to database`);
+ await lnurls.bulkPut(entries);
+ }
+
+ dirtyKeys.clear();
+ };
+};
diff --git a/ndk-cache-dexie/src/db.ts b/ndk-cache-dexie/src/db.ts
new file mode 100644
index 00000000..9267431e
--- /dev/null
+++ b/ndk-cache-dexie/src/db.ts
@@ -0,0 +1,81 @@
+import type { NDKEvent, NDKEventId, NDKUser, NDKUserProfile, NostrEvent } from "@nostr-dev-kit/ndk";
+import Dexie, { type Table } from "dexie";
+
+export interface Profile extends NDKUserProfile {
+ pubkey: string;
+ cachedAt: number;
+}
+
+export interface Event {
+ id: string;
+ pubkey: string;
+ kind: number;
+ createdAt: number;
+ relay?: string;
+ event: string;
+}
+
+export interface EventTag {
+ eventId: string;
+ tagValue: string;
+}
+
+export interface Nip05 {
+ nip05: string;
+ profile: string | null;
+ fetchedAt: number;
+}
+
+export interface Lnurl {
+ pubkey: string;
+ document: string | null;
+ fetchedAt: number;
+}
+
+export interface RelayStatus {
+ url: string;
+ updatedAt: number;
+ lastConnectedAt?: number;
+ dontConnectBefore?: number;
+}
+
+export interface UnpublishedEvent {
+ id: NDKEventId;
+ event: NostrEvent;
+ relays: Record;
+ lastTryAt?: number;
+}
+
+export class Database extends Dexie {
+ profiles!: Table;
+ events!: Table;
+ eventTags!: Table;
+ nip05!: Table;
+ lnurl!: Table;
+ relayStatus!: Table;
+ unpublishedEvents!: Table;
+
+ constructor(name: string) {
+ super(name);
+ this.version(15).stores({
+ profiles: "&pubkey",
+ events: "&id, kind",
+ eventTags: "&tagValue",
+ nip05: "&nip05",
+ lnurl: "&pubkey",
+ relayStatus: "&url",
+ unpublishedEvents: "&id",
+ });
+ }
+}
+
+export let db: Database;
+
+/**
+ * Create database
+ *
+ * @param name - Database name
+ */
+export function createDatabase(name: string): void {
+ db = new Database(name);
+}
diff --git a/ndk-cache-dexie/src/index.test.ts b/ndk-cache-dexie/src/index.test.ts
new file mode 100644
index 00000000..8ec4b9bf
--- /dev/null
+++ b/ndk-cache-dexie/src/index.test.ts
@@ -0,0 +1,79 @@
+import NDK, { NDKEvent, NDKPrivateKeySigner, NDKSubscription } from "@nostr-dev-kit/ndk";
+import NDKCacheAdapterDexie from "./index.js";
+
+const ndk = new NDK();
+ndk.signer = NDKPrivateKeySigner.generate();
+ndk.cacheAdapter = new NDKCacheAdapterDexie();
+
+describe("foundEvents", () => {
+ it("applies limit filter", async () => {
+ const startTime = Math.floor(Date.now() / 1000);
+ const times = [];
+ for (let i = 0; i < 10; i++) {
+ const event = new NDKEvent(ndk);
+ event.kind = 2;
+ event.created_at = startTime - i * 60;
+ times.push(event.created_at);
+ await event.sign();
+ ndk.cacheAdapter!.setEvent(event, []);
+ }
+
+ const subscription = new NDKSubscription(ndk, [{ kinds: [2], limit: 2 }]);
+ const spy = jest.spyOn(subscription, "eventReceived");
+ await ndk.cacheAdapter!.query(subscription);
+ expect(subscription.eventReceived).toBeCalledTimes(2);
+
+ // the time of the events that were received must be the first two in the list
+ expect(spy.mock.calls[0][0].created_at).toBe(times[0]);
+ expect(spy.mock.calls[1][0].created_at).toBe(times[1]);
+ });
+});
+
+describe("foundEvent", () => {
+ beforeAll(async () => {
+ // save event
+ const event = new NDKEvent(ndk);
+ event.kind = 1;
+ event.tags.push(["a", "123"]);
+ await event.sign();
+ ndk.cacheAdapter!.setEvent(event, []);
+ });
+
+ it("correctly avoids reporting events that don't fully match NIP-01 filter", async () => {
+ const subscription = new NDKSubscription(ndk, [{ "#a": ["123"], "#t": ["hello"] }]);
+ jest.spyOn(subscription, "eventReceived");
+ await ndk.cacheAdapter!.query(subscription);
+ expect(subscription.eventReceived).toBeCalledTimes(0);
+ });
+
+ it("correctly reports events that fully match NIP-01 filter", async () => {
+ const subscription = new NDKSubscription(ndk, [{ "#a": ["123"] }]);
+ jest.spyOn(subscription, "eventReceived");
+ await ndk.cacheAdapter!.query(subscription);
+ expect(subscription.eventReceived).toBeCalledTimes(1);
+ });
+});
+
+describe("by kind filter", () => {
+ beforeAll(async () => {
+ // save event
+ const event = new NDKEvent(ndk);
+ event.kind = 10002;
+ await event.sign();
+ ndk.cacheAdapter!.setEvent(event, []);
+ });
+
+ it("returns an event when fetching by kind", async () => {
+ const subscription = new NDKSubscription(ndk, [{ kinds: [10002] }]);
+ jest.spyOn(subscription, "eventReceived");
+ await ndk.cacheAdapter!.query(subscription);
+ expect(subscription.eventReceived).toBeCalledTimes(1);
+ });
+
+ it("matches by kind even when there is a since filter", async () => {
+ const subscription = new NDKSubscription(ndk, [{ kinds: [10002], since: 1000 }]);
+ jest.spyOn(subscription, "eventReceived");
+ await ndk.cacheAdapter!.query(subscription);
+ expect(subscription.eventReceived).toBeCalledTimes(1);
+ });
+});
diff --git a/ndk-cache-dexie/src/index.ts b/ndk-cache-dexie/src/index.ts
new file mode 100644
index 00000000..8a8c0a7b
--- /dev/null
+++ b/ndk-cache-dexie/src/index.ts
@@ -0,0 +1,607 @@
+import { NDKEvent, NDKRelay, deserialize, profileFromEvent } from "@nostr-dev-kit/ndk";
+import type {
+ Hexpubkey,
+ NDKEventId,
+ NDKCacheAdapter,
+ NDKFilter,
+ NDKSubscription,
+ NDKUserProfile,
+ NDKLnUrlData,
+ ProfilePointer,
+ NostrEvent,
+ NDKCacheRelayInfo,
+ NDKTag,
+} from "@nostr-dev-kit/ndk";
+import createDebug from "debug";
+import { matchFilter } from "nostr-tools";
+import { RelayStatus, UnpublishedEvent, Profile, createDatabase, db, type Event } from "./db.js";
+import { CacheHandler } from "./lru-cache.js";
+import { profilesDump, profilesWarmUp } from "./caches/profiles.js";
+import { ZapperCacheEntry, zapperDump, zapperWarmUp } from "./caches/zapper.js";
+import { Nip05CacheEntry, nip05Dump, nip05WarmUp } from "./caches/nip05.js";
+import { EventCacheEntry, eventsDump, eventsWarmUp } from "./caches/events.js";
+import { EventTagCacheEntry, eventTagsDump, eventTagsWarmUp } from "./caches/event-tags.js";
+import { relayInfoDump, relayInfoWarmUp } from "./caches/relay-info.js";
+import {
+ addUnpublishedEvent,
+ discardUnpublishedEvent,
+ getUnpublishedEvents,
+ unpublishedEventsDump,
+ unpublishedEventsWarmUp,
+} from "./caches/unpublished-events.js";
+
+export { db } from "./db";
+
+const INDEXABLE_TAGS_LIMIT = 10;
+
+export interface NDKCacheAdapterDexieOptions {
+ /**
+ * The name of the database to use
+ */
+ dbName?: string;
+
+ /**
+ * Debug instance to use for logging
+ */
+ debug?: debug.IDebugger;
+
+ /**
+ * Number of profiles to keep in an LRU cache
+ */
+ profileCacheSize?: number;
+ zapperCacheSize?: number;
+ nip05CacheSize?: number;
+ eventCacheSize?: number;
+ eventTagsCacheSize?: number;
+}
+
+export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
+ public debug: debug.Debugger;
+ public locking = false;
+ public ready = false;
+ public profiles: CacheHandler;
+ public zappers: CacheHandler;
+ public nip05s: CacheHandler;
+ public events: CacheHandler;
+ public eventTags: CacheHandler;
+ public relayInfo: CacheHandler;
+ public unpublishedEvents: CacheHandler;
+ private warmedUp: boolean = false;
+ private warmUpPromise: Promise;
+ public devMode = false;
+ public _onReady?: () => void;
+
+ constructor(opts: NDKCacheAdapterDexieOptions = {}) {
+ createDatabase(opts.dbName || "ndk");
+ this.debug = opts.debug || createDebug("ndk:dexie-adapter");
+
+ this.profiles = new CacheHandler({
+ maxSize: opts.profileCacheSize || 100000,
+ dump: profilesDump(db.profiles, this.debug),
+ debug: this.debug,
+ });
+
+ this.zappers = new CacheHandler({
+ maxSize: opts.zapperCacheSize || 200,
+ dump: zapperDump(db.lnurl, this.debug),
+ debug: this.debug,
+ });
+
+ this.nip05s = new CacheHandler({
+ maxSize: opts.nip05CacheSize || 1000,
+ dump: nip05Dump(db.nip05, this.debug),
+ debug: this.debug,
+ });
+
+ this.events = new CacheHandler({
+ maxSize: opts.eventCacheSize || 50000,
+ dump: eventsDump(db.events, this.debug),
+ debug: this.debug,
+ });
+ this.events.addIndex("pubkey");
+
+ this.events.addIndex("kind");
+
+ this.eventTags = new CacheHandler({
+ maxSize: opts.eventTagsCacheSize || 100000,
+ dump: eventTagsDump(db.eventTags, this.debug),
+ debug: this.debug,
+ });
+
+ this.relayInfo = new CacheHandler({
+ maxSize: 500,
+ debug: this.debug,
+ dump: relayInfoDump(db.relayStatus, this.debug),
+ });
+
+ this.unpublishedEvents = new CacheHandler({
+ maxSize: 5000,
+ debug: this.debug,
+ dump: unpublishedEventsDump(db.unpublishedEvents, this.debug),
+ });
+
+ const profile = (label: string, fn: () => Promise) => {
+ const start = Date.now();
+ return fn().then(() => {
+ const end = Date.now();
+ this.debug(label, "took", end - start, "ms");
+ });
+ };
+
+ const startTime = Date.now();
+ this.warmUpPromise = Promise.allSettled([
+ profile("profilesWarmUp", () => profilesWarmUp(this.profiles, db.profiles)),
+ profile("zapperWarmUp", () => zapperWarmUp(this.zappers, db.lnurl)),
+ profile("nip05WarmUp", () => nip05WarmUp(this.nip05s, db.nip05)),
+ profile("relayInfoWarmUp", () => relayInfoWarmUp(this.relayInfo, db.relayStatus)),
+ profile("unpublishedEventsWarmUp", () =>
+ unpublishedEventsWarmUp(this.unpublishedEvents, db.unpublishedEvents)
+ ),
+ profile("eventsWarmUp", () => eventsWarmUp(this.events, db.events)),
+ profile("eventTagsWarmUp", () => eventTagsWarmUp(this.eventTags, db.eventTags)),
+ ]);
+ this.warmUpPromise.then(() => {
+ const endTime = Date.now();
+ this.warmedUp = true;
+ this.ready = true;
+ this.locking = true;
+ this.debug("Warm up completed, time", endTime - startTime, "ms");
+
+ // call the onReady callback if it's set
+ if (this._onReady) this._onReady();
+ });
+ }
+
+ public onReady(callback: () => void) {
+ this._onReady = callback;
+ }
+
+ public async query(subscription: NDKSubscription): Promise {
+ // ensure we have warmed up before processing the filter
+ if (!this.warmedUp) {
+ const startTime = Date.now();
+ await this.warmUpPromise;
+ this.debug("froze query for", Date.now() - startTime, "ms", subscription.filters);
+ }
+
+ const startTime = Date.now();
+ subscription.filters.map((filter) => this.processFilter(filter, subscription));
+ const dur = Date.now() - startTime;
+ if (dur > 100) this.debug("query took", dur, "ms", subscription.filter);
+ }
+
+ public async fetchProfile(pubkey: Hexpubkey) {
+ if (!this.profiles) return null;
+
+ let user = await this.profiles.getWithFallback(pubkey, db.profiles);
+
+ return user as NDKUserProfile | null;
+ }
+
+ public fetchProfileSync(pubkey: Hexpubkey) {
+ if (!this.profiles) return null;
+
+ let user = this.profiles.get(pubkey);
+
+ return user as NDKUserProfile | null;
+ }
+
+ public async getProfiles(
+ fn: (pubkey: Hexpubkey, profile: NDKUserProfile) => boolean
+ ): Promise | undefined> {
+ if (!this.profiles) return;
+ return this.profiles.getAllWithFilter(fn);
+ }
+
+ public saveProfile(pubkey: Hexpubkey, profile: NDKUserProfile) {
+ const existingValue = this.profiles.get(pubkey);
+ if (
+ existingValue?.created_at &&
+ profile.created_at &&
+ existingValue.created_at >= profile.created_at
+ ) {
+ return;
+ }
+ const cachedAt = Math.floor(Date.now() / 1000);
+ this.profiles.set(pubkey, { pubkey, ...profile, cachedAt });
+ this.debug("Saved profile for pubkey", pubkey, profile);
+ }
+
+ public async loadNip05(
+ nip05: string,
+ maxAgeForMissing: number = 3600
+ ): Promise {
+ const cache = this.nip05s?.get(nip05);
+
+ if (cache) {
+ if (cache.profile === null) {
+ // If the profile has been marked as missing and is older than the max age for missing, return missing
+ if (cache.fetchedAt + maxAgeForMissing * 1000 < Date.now()) return "missing";
+
+ // Otherwise, return null
+ return null;
+ }
+
+ try {
+ return JSON.parse(cache.profile);
+ } catch (e) {
+ return "missing";
+ }
+ }
+
+ const nip = await db.nip05.get({ nip05 });
+
+ if (!nip) return "missing";
+
+ const now = Date.now();
+
+ // If the document is older than the max age, return missing
+ if (nip.profile === null) {
+ // If the document has been marked as missing and is older than the max age for missing, return missing
+ if (nip.fetchedAt + maxAgeForMissing * 1000 < now) return "missing";
+
+ // Otherwise, return null
+ return null;
+ }
+
+ try {
+ return JSON.parse(nip.profile);
+ } catch (e) {
+ return "missing";
+ }
+ }
+
+ public async saveNip05(nip05: string, profile: ProfilePointer | null): Promise {
+ try {
+ const document = profile ? JSON.stringify(profile) : null;
+
+ this.nip05s.set(nip05, { profile: document, fetchedAt: Date.now() });
+ } catch (error) {
+ console.error("Failed to save NIP-05 profile for nip05:", nip05, error);
+ }
+ }
+
+ public async loadUsersLNURLDoc?(
+ pubkey: Hexpubkey,
+ maxAgeInSecs: number = 86400,
+ maxAgeForMissing: number = 3600
+ ): Promise {
+ const cache = this.zappers?.get(pubkey);
+ if (cache) {
+ if (cache.document === null) {
+ // If the document has been marked as missing and is older than the max age for missing, return missing
+ if (cache.fetchedAt + maxAgeForMissing * 1000 < Date.now()) return "missing";
+
+ // Otherwise, return null
+ return null;
+ }
+
+ try {
+ return JSON.parse(cache.document);
+ } catch (e) {
+ return "missing";
+ }
+ }
+
+ const lnurl = await db.lnurl.get({ pubkey });
+
+ if (!lnurl) return "missing";
+
+ const now = Date.now();
+
+ // If the document is older than the max age, return missing
+ if (lnurl.fetchedAt + maxAgeInSecs * 1000 < now) return "missing";
+ if (lnurl.document === null) {
+ // If the document has been marked as missing and is older than the max age for missing, return missing
+ if (lnurl.fetchedAt + maxAgeForMissing * 1000 < now) return "missing";
+
+ // Otherwise, return null
+ return null;
+ }
+
+ try {
+ return JSON.parse(lnurl.document);
+ } catch (e) {
+ return "missing";
+ }
+ }
+
+ public async saveUsersLNURLDoc(pubkey: Hexpubkey, doc: NDKLnUrlData | null): Promise {
+ try {
+ const document = doc ? JSON.stringify(doc) : null;
+ this.zappers?.set(pubkey, { document, fetchedAt: Date.now() });
+ } catch (error) {
+ console.error("Failed to save LNURL document for pubkey:", pubkey, error);
+ }
+ }
+
+ private processFilter(filter: NDKFilter, subscription: NDKSubscription): void {
+ const _filter = { ...filter };
+ delete _filter.limit;
+ const filterKeys = new Set(Object.keys(_filter || {}));
+
+ // strip always-allowed filter-keys
+ filterKeys.delete("since");
+ filterKeys.delete("limit");
+ filterKeys.delete("until");
+
+ try {
+ // start with NIP-33 query
+ if (this.byNip33Query(filterKeys, filter, subscription)) return; // exit = true;
+
+ // Continue with author
+ if (this.byAuthors(filter, subscription)) return; // exit = true;
+
+ // Continue with ids
+ if (this.byIdsQuery(filter, subscription)) return; // exit = true;
+
+ // By tags
+ if (this.byTags(filter, subscription)) return; // exit = true;
+
+ if (this.byKinds(filterKeys, filter, subscription)) return; // exit = true;
+ } catch (error) {
+ console.error(error);
+ }
+ }
+
+ public async deleteEventIds(eventIds: NDKEventId[]): Promise {
+ eventIds.forEach((id) => this.events.delete(id));
+ await db.events.where({ id: eventIds }).delete();
+ }
+
+ public addUnpublishedEvent = addUnpublishedEvent.bind(this);
+ public getUnpublishedEvents = () => getUnpublishedEvents(db.unpublishedEvents);
+ public discardUnpublishedEvent = (id: string) =>
+ discardUnpublishedEvent(db.unpublishedEvents, id);
+
+ public async setEvent(event: NDKEvent, filters: NDKFilter[], relay?: NDKRelay): Promise {
+ if (event.kind === 0) {
+ if (!this.profiles) return;
+
+ try {
+ const profile: NDKUserProfile = profileFromEvent(event);
+ this.saveProfile(event.pubkey, profile);
+ } catch {
+ this.debug(`Failed to save profile for pubkey: ${event.pubkey}`);
+ }
+ }
+ let addEvent = true;
+
+ if (event.isParamReplaceable()) {
+ const existingEvent = this.events.get(event.tagId());
+ if (existingEvent && event.created_at && existingEvent.createdAt > event.created_at) {
+ addEvent = false;
+ }
+ }
+
+ if (addEvent) {
+ this.events.set(event.tagId(), {
+ id: event.tagId(),
+ pubkey: event.pubkey,
+ kind: event.kind!,
+ createdAt: event.created_at!,
+ relay: relay?.url,
+ event: event.serialize(true, true),
+ });
+
+ // Don't cache contact lists as tags since it's expensive
+ // and there is no use case for it
+ const indexableTags = getIndexableTags(event);
+ for (const tag of indexableTags) {
+ this.eventTags.add(tag[0] + tag[1], event.tagId());
+ }
+ }
+ }
+
+ public updateRelayStatus(url: string, info: NDKCacheRelayInfo): void {
+ const val = { url, updatedAt: Date.now(), ...info };
+ this.relayInfo.set(url, val);
+ }
+
+ public getRelayStatus(url: string): NDKCacheRelayInfo | undefined {
+ const a = this.relayInfo.get(url);
+ if (a) {
+ return {
+ lastConnectedAt: a.lastConnectedAt,
+ dontConnectBefore: a.dontConnectBefore,
+ };
+ }
+ }
+
+ /**
+ * Searches by authors
+ */
+ private byAuthors(filter: NDKFilter, subscription: NDKSubscription): boolean {
+ if (!filter.authors) return false;
+
+ let total = 0;
+
+ for (const pubkey of filter.authors) {
+ // const eventsFromDb = await db.events.where({ pubkey }).toArray();
+ let events = Array.from(this.events.getFromIndex("pubkey", pubkey));
+
+ const prev = events.length;
+
+ // reduce by kind if needed
+ if (filter.kinds) events = events.filter((e) => filter.kinds!.includes(e.kind!));
+
+ foundEvents(subscription, events, filter);
+ total += events.length;
+ }
+
+ return true;
+ }
+
+ /**
+ * Searches by ids
+ */
+ private byIdsQuery(filter: NDKFilter, subscription: NDKSubscription): boolean {
+ if (filter.ids) {
+ for (const id of filter.ids) {
+ const event = this.events.get(id);
+ if (event) foundEvent(subscription, event, event.relay, filter);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Searches by NIP-33
+ */
+ private byNip33Query(
+ filterKeys: Set,
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): boolean {
+ const f = ["#d", "authors", "kinds"];
+ const hasAllKeys = filterKeys.size === f.length && f.every((k) => filterKeys.has(k));
+
+ if (hasAllKeys && filter.kinds && filter.authors) {
+ for (const kind of filter.kinds) {
+ const replaceableKind = kind >= 30000 && kind < 40000;
+
+ if (!replaceableKind) continue;
+
+ for (const author of filter.authors) {
+ for (const dTag of filter["#d"]!) {
+ const replaceableId = `${kind}:${author}:${dTag}`;
+ const event = this.events.get(replaceableId);
+ if (event) foundEvent(subscription, event, event.relay, filter);
+ }
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Searches by tags and optionally filters by tags
+ */
+ private byTags(filter: NDKFilter, subscription: NDKSubscription): boolean {
+ const tagFilters = Object.entries(filter)
+ .filter(([filter]) => filter.startsWith("#") && filter.length === 2)
+ .map(([filter, values]) => [filter[1], values]);
+ if (tagFilters.length === 0) return false;
+
+ // Go through all the tags (#e, #p, etc)
+ for (const [tag, values] of tagFilters) {
+ // Go throgh each value in the filter
+ for (const value of values as string[]) {
+ const tagValue = tag + value;
+
+ // Get all events with this tag
+ const eventIds = this.eventTags.getSet(tagValue);
+ if (!eventIds) continue;
+
+ // Go through each event that came back
+ eventIds.forEach((id) => {
+ const event = this.events.get(id);
+ if (!event) return;
+
+ if (!filter.kinds || filter.kinds.includes(event.kind!)) {
+ foundEvent(subscription, event, event.relay, filter);
+ }
+ });
+ }
+ }
+
+ return true;
+ }
+
+ private byKinds(
+ filterKeys: Set,
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): boolean {
+ if (!filter.kinds) return false;
+ const f = ["kinds"];
+ const hasAllKeys = filterKeys.size === f.length && f.every((k) => filterKeys.has(k));
+
+ let events: Event[] = [];
+
+ if (!hasAllKeys) return false;
+
+ for (const kind of filter.kinds) {
+ events = [...events, ...Array.from(this.events.getFromIndex("kind", kind))];
+ }
+
+ foundEvents(subscription, events, filter);
+
+ return true;
+ }
+}
+
+export function checkEventMatchesFilter(event: Event, filter: NDKFilter): NDKEvent | undefined {
+ let deserializedEvent: NostrEvent;
+
+ try {
+ deserializedEvent = deserialize(event.event);
+
+ // Make sure all passed filters match the event
+ if (!matchFilter(filter, deserializedEvent as any)) return;
+ } catch (e) {
+ console.log("failed to parse event", e);
+ return;
+ }
+
+ const ndkEvent = new NDKEvent(undefined, deserializedEvent);
+ const relay = event.relay ? new NDKRelay(event.relay) : undefined;
+ ndkEvent.relay = relay;
+
+ return ndkEvent;
+}
+
+export function foundEvents(subscription: NDKSubscription, events: Event[], filter?: NDKFilter) {
+ // if we have a limit, sort and slice
+ if (filter?.limit && events.length > filter.limit) {
+ events = events.sort((a, b) => b.createdAt - a.createdAt).slice(0, filter.limit);
+ }
+
+ for (const event of events) {
+ foundEvent(subscription, event, event.relay, filter);
+ }
+}
+
+export function foundEvent(
+ subscription: NDKSubscription,
+ event: Event,
+ relayUrl: WebSocket["url"] | undefined,
+ filter?: NDKFilter
+) {
+ try {
+ const deserializedEvent = deserialize(event.event);
+
+ if (filter && !matchFilter(filter, deserializedEvent as any)) return;
+
+ const ndkEvent = new NDKEvent(undefined, deserializedEvent);
+ const relay = relayUrl ? subscription.pool.getRelay(relayUrl, false) : undefined;
+ ndkEvent.relay = relay;
+ subscription.eventReceived(ndkEvent, relay, true);
+ } catch (e) {
+ console.error("failed to deserialize event", e);
+ }
+}
+
+/**
+ * Returns the tags that should be indexed, if an event has
+ * more indexable tags than the limit, none will be returned
+ */
+function getIndexableTags(event: NDKEvent): NDKTag[] {
+ let indexableTags: NDKTag[] = [];
+
+ if (event.kind === 3) return [];
+
+ for (const tag of event.tags) {
+ if (tag[0].length !== 1) continue;
+
+ indexableTags.push(tag);
+
+ if (indexableTags.length >= INDEXABLE_TAGS_LIMIT) return [];
+ }
+
+ return indexableTags;
+}
diff --git a/ndk-cache-dexie/src/lru-cache.ts b/ndk-cache-dexie/src/lru-cache.ts
new file mode 100644
index 00000000..2c099591
--- /dev/null
+++ b/ndk-cache-dexie/src/lru-cache.ts
@@ -0,0 +1,164 @@
+import { Table } from "dexie";
+import { LRUCache } from "typescript-lru-cache";
+
+export type WarmUpFunction = (
+ cacheHandler: CacheHandler,
+ debug: debug.IDebugger
+) => Promise;
+
+export interface CacheOptions {
+ maxSize: number;
+ dump: (dirtyKeys: Set, cache: LRUCache) => Promise;
+ debug: debug.IDebugger;
+}
+
+export class CacheHandler {
+ private cache?: LRUCache;
+ private dirtyKeys: Set = new Set();
+ private options: CacheOptions;
+ private debug: debug.IDebugger;
+ public indexes: Map>>;
+ public isSet = false;
+ public maxSize = 0;
+
+ constructor(options: CacheOptions) {
+ this.debug = options.debug;
+ this.options = options;
+ this.maxSize = options.maxSize;
+ if (options.maxSize > 0) {
+ this.cache = new LRUCache({ maxSize: options.maxSize });
+ setInterval(() => this.dump().catch(console.error), 1000 * 10);
+ }
+
+ this.indexes = new Map();
+ }
+
+ public getSet(key: string): Set | null {
+ return this.cache?.get(key) as Set | null;
+ }
+
+ /**
+ * Get all entries that match the filter.
+ */
+ public getAllWithFilter(filter: (key: string, val: T) => boolean): Map {
+ const ret = new Map();
+ this.cache?.forEach((val, key) => {
+ if (filter(key, val)) {
+ ret.set(key, val);
+ }
+ });
+ return ret;
+ }
+
+ public get(key: string): T | null | undefined {
+ return this.cache?.get(key);
+ }
+
+ public async getWithFallback(key: string, table: Table) {
+ let entry = this.get(key);
+ if (!entry) {
+ entry = await table.get(key);
+ if (entry) {
+ // this.debug(`Cache miss for key ${JSON.stringify(key)}`);
+ this.set(key, entry);
+ }
+ }
+ return entry;
+ }
+
+ public async getManyWithFallback(keys: string[], table: Table) {
+ const entries: T[] = [];
+ const missingKeys: string[] = [];
+ // get all entries from cache without hitting the database
+ for (const key of keys) {
+ const entry = this.get(key);
+ if (entry) entries.push(entry);
+ else missingKeys.push(key);
+ }
+
+ if (entries.length > 0) {
+ this.debug(
+ `Cache hit for keys ${entries.length} and miss for ${missingKeys.length} keys`
+ );
+ }
+
+ // get missing entries from the database
+ if (missingKeys.length > 0) {
+ const startTime = Date.now();
+ const missingEntries = await table.bulkGet(missingKeys);
+ const endTime = Date.now();
+ let foundKeys = 0;
+
+ for (const entry of missingEntries) {
+ if (entry) {
+ this.set(entry.id, entry);
+ entries.push(entry);
+ foundKeys++;
+ }
+ }
+ this.debug(
+ `Time spent querying database: ${endTime - startTime}ms for ${
+ missingKeys.length
+ } keys, which added ${foundKeys} entries to the cache`
+ );
+ }
+
+ return entries;
+ }
+
+ public add(key: string, value: K, dirty = true) {
+ const existing = this.get(key) ?? new Set();
+ (existing as Set).add(value);
+ this.cache?.set(key, existing as T);
+
+ if (dirty) this.dirtyKeys.add(key);
+ }
+
+ public set(key: string, value: T, dirty = true) {
+ this.cache?.set(key, value);
+ if (dirty) this.dirtyKeys.add(key);
+
+ // update indexes
+ for (const [attribute, index] of this.indexes.entries()) {
+ const indexKey = (value as any)[attribute] as string;
+ if (indexKey) {
+ const indexValue = index.get(indexKey) || new Set();
+ indexValue.add(key);
+ index.set(indexKey, indexValue);
+ }
+ }
+ }
+
+ public size(): number {
+ return this.cache?.size || 0;
+ }
+
+ public delete(key: string) {
+ this.cache?.delete(key);
+ this.dirtyKeys.add(key);
+ }
+
+ private async dump() {
+ if (this.dirtyKeys.size > 0) {
+ await this.options.dump(this.dirtyKeys, this.cache!);
+ this.dirtyKeys.clear();
+ }
+ }
+
+ public addIndex(attribute: string | number) {
+ this.indexes.set(attribute, new LRUCache({ maxSize: this.options.maxSize }));
+ }
+
+ public getFromIndex(attribute: string, key: string | number) {
+ const ret = new Set();
+ this.indexes
+ .get(attribute)
+ ?.get(key)
+ ?.forEach((key) => {
+ const entry = this.get(key);
+ if (entry) ret.add(entry as T);
+ });
+
+ return ret;
+ }
+}
diff --git a/ndk-cache-dexie/tsconfig.json b/ndk-cache-dexie/tsconfig.json
new file mode 100644
index 00000000..fe257ae3
--- /dev/null
+++ b/ndk-cache-dexie/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../packages/tsconfig/ndk-cache-dexie.json",
+ "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts"],
+ "exclude": ["dist", "build", "node_modules"]
+}
diff --git a/ndk-cache-dexie/typedoc.json b/ndk-cache-dexie/typedoc.json
new file mode 100644
index 00000000..b4f1de3d
--- /dev/null
+++ b/ndk-cache-dexie/typedoc.json
@@ -0,0 +1,16 @@
+{
+ "entryPoints": ["src/index.ts"],
+ "out": "docs",
+ "name": "NDK Dexie Cache Adapter",
+ "theme": "default",
+ "plugin": ["typedoc-plugin-markdown"],
+ "excludeExternals": true,
+ "excludePrivate": true,
+ "excludeProtected": true,
+ "categorizeByGroup": true,
+ "hideParameterTypesInTitle": false,
+ "navigation": {
+ "includeGroups": true
+ },
+ "customCss": "../ndk/docs-styles.css"
+}
diff --git a/ndk-cache-nostr/.gitignore b/ndk-cache-nostr/.gitignore
new file mode 100644
index 00000000..c92e9f04
--- /dev/null
+++ b/ndk-cache-nostr/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+**/*.js
+dist
diff --git a/ndk-cache-nostr/.prettierignore b/ndk-cache-nostr/.prettierignore
new file mode 100644
index 00000000..1521c8b7
--- /dev/null
+++ b/ndk-cache-nostr/.prettierignore
@@ -0,0 +1 @@
+dist
diff --git a/ndk-cache-nostr/CHANGELOG.md b/ndk-cache-nostr/CHANGELOG.md
new file mode 100644
index 00000000..c243888e
--- /dev/null
+++ b/ndk-cache-nostr/CHANGELOG.md
@@ -0,0 +1,56 @@
+# @nostr-dev-kit/ndk-cache-nostr
+
+## 0.1.7
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.7
+
+## 0.1.6
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.6
+
+## 0.1.5
+
+### Patch Changes
+
+- Updated dependencies [5939a3e]
+- Updated dependencies
+- Updated dependencies [f2a0cce]
+ - @nostr-dev-kit/ndk@2.10.5
+
+## 0.1.4
+
+### Patch Changes
+
+- Updated dependencies [5bed70c]
+- Updated dependencies [873ad4a]
+ - @nostr-dev-kit/ndk@2.10.4
+
+## 0.1.3
+
+### Patch Changes
+
+- Updated dependencies [0fc66c5]
+ - @nostr-dev-kit/ndk@2.10.3
+
+## 0.1.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.2
+
+## 0.1.1
+
+### Patch Changes
+
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [722345b]
+ - @nostr-dev-kit/ndk@2.10.1
diff --git a/ndk-cache-nostr/README.md b/ndk-cache-nostr/README.md
new file mode 100644
index 00000000..79e4d3fd
--- /dev/null
+++ b/ndk-cache-nostr/README.md
@@ -0,0 +1,34 @@
+# ndk-cache-nostr
+
+NDK cache adapter using a nostr relay as the database.
+
+This cache adapter is meant to be run against a local relay. This adapter will generate two NDK instances:
+
+`ndk` -- This talks exclusively to the local relay, with outbox model disabled.
+`fallbackNdk` -- This is used to hydrate the cache and uses the outbox model -- each query the cache receives is placed in a queue in the background so that subsequent requests can be served from the cache. All events from other relays
+
+## Usage
+
+### Install
+
+```
+npm add @nostr-dev-kit/ndk-cache-nostr
+
+```
+
+### Add as a cache adapter
+
+```ts
+import NDKCacheAdapterNostr from "@nostr-dev-kit/ndk-cache-nostr";
+
+const cacheAdapter = new NDKCacheAdapterNostr({
+ relayUrl: "ws://localhost:5577",
+});
+const ndk = new NDK({ cacheAdapter });
+```
+
+If running server-side in a NodeJS environment, you should make sure to polyfill `WebSocket`.
+
+# License
+
+MIT
diff --git a/ndk-cache-nostr/jest.config.ts b/ndk-cache-nostr/jest.config.ts
new file mode 100644
index 00000000..931e01ef
--- /dev/null
+++ b/ndk-cache-nostr/jest.config.ts
@@ -0,0 +1,11 @@
+import type { Config } from "jest";
+
+const config: Config = {
+ preset: "ts-jest",
+ testEnvironment: "node",
+ moduleNameMapper: {
+ "^(\\.{1,2}/.*)\\.js$": "$1",
+ },
+};
+
+export default config;
diff --git a/ndk-cache-nostr/package.json b/ndk-cache-nostr/package.json
new file mode 100644
index 00000000..ba8228e8
--- /dev/null
+++ b/ndk-cache-nostr/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@nostr-dev-kit/ndk-cache-nostr",
+ "version": "0.1.7",
+ "description": "NDK cache adapter that uses a local nostr relay.",
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "exports": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "dev": "pnpm build --watch",
+ "build": "tsup src/index.ts --format cjs,esm --dts",
+ "clean": "rm -rf dist",
+ "test": "jest",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write ."
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/nostr-dev-kit/ndk.git"
+ },
+ "keywords": [
+ "nostr",
+ "cache"
+ ],
+ "author": "pablof7z",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/nostr-dev-kit/ndk/issues"
+ },
+ "homepage": "https://github.com/nostr-dev-kit/ndk",
+ "dependencies": {
+ "@nostr-dev-kit/ndk": "workspace:*",
+ "debug": "^4.3.4",
+ "typescript": "^5.4.4",
+ "websocket-polyfill": "^0.0.3"
+ },
+ "devDependencies": {
+ "@nostr-dev-kit/eslint-config-custom": "workspace:*",
+ "@nostr-dev-kit/tsconfig": "workspace:*",
+ "@types/debug": "^4.1.7",
+ "@types/jest": "^29.5.5",
+ "@types/node": "^18.15.11",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.1.2",
+ "ts-node": "^10.9.2",
+ "tsup": "^7.2.0"
+ }
+}
diff --git a/ndk-cache-nostr/src/index.ts b/ndk-cache-nostr/src/index.ts
new file mode 100644
index 00000000..7cf2c464
--- /dev/null
+++ b/ndk-cache-nostr/src/index.ts
@@ -0,0 +1,272 @@
+import {
+ NDKCacheAdapter,
+ NDKCacheRelayInfo,
+ NDKFilter,
+ NDKKind,
+ NDKLnUrlData,
+ NDKRelaySet,
+ NDKUserProfile,
+ NostrEvent,
+ ProfilePointer,
+} from "@nostr-dev-kit/ndk";
+import NDK, { NDKRelay } from "@nostr-dev-kit/ndk";
+import { NDKEvent, type NDKSubscription } from "@nostr-dev-kit/ndk";
+import createDebugger from "debug";
+import { Queue } from "./queue";
+
+export interface NDKNostrCacheAdapterOptions {
+ relayUrl: string;
+}
+
+let d: debug.IDebugger;
+
+export default class NDKNostrCacheAdapter implements NDKCacheAdapter {
+ public locking: boolean;
+ public ready?: boolean | undefined;
+
+ /**
+ * The NDK instance used to interact with the local nostr relay.
+ */
+ private ndk: NDK;
+
+ /**
+ * The fallback NDK is used to gather events on the background, to hydrate the
+ * cache
+ */
+ private fallbackNdk: NDK;
+ private relaySet: NDKRelaySet;
+ private relay: NDKRelay;
+
+ /**
+ * How long it's acceptable to block until queries finish.
+ */
+ public queryTimeout = 4000;
+
+ public backgroundSubscriptionQueue: Queue = new Queue("ndk-nostr-cache-adapter", 5);
+
+ private hydratedEvents = 0;
+
+ constructor(options: NDKNostrCacheAdapterOptions) {
+ this.locking = false;
+ this.ready = true;
+ d = createDebugger("ndk:nostr-cache-adapter");
+
+ this.ndk = new NDK({
+ explicitRelayUrls: [options.relayUrl],
+ enableOutboxModel: false,
+ debug: d.extend("ndk"),
+ });
+ this.ndk.connect().then(() => this.onConnect());
+
+ this.fallbackNdk = new NDK({
+ enableOutboxModel: true,
+ debug: d.extend("fallback-ndk"),
+ });
+ this.fallbackNdk.connect().then(() => {
+ d(
+ "Connected to fallback NDK %o",
+ this.fallbackNdk.pool.connectedRelays().map((relay) => relay.url)
+ );
+ });
+
+ this.relaySet = NDKRelaySet.fromRelayUrls([options.relayUrl], this.ndk);
+ this.relay = Array.from(this.relaySet.relays)[0];
+
+ if (d.enabled) {
+ setInterval(() => {
+ d("Cache adapter has injested %d events", this.hydratedEvents);
+ }, 10000);
+ }
+ }
+
+ private onConnect() {
+ d(
+ "Connected to %o",
+ this.ndk.pool.connectedRelays().map((relay) => relay.url)
+ );
+ this.locking = true;
+ this.ready = true;
+ }
+
+ /**
+ * Processes the query locally.
+ * @param subscription
+ * @returns The number of events received.
+ */
+ private async queryLocally(subscription: NDKSubscription): Promise {
+ const subId =
+ subscription.subId ??
+ subscription.filters.map((filter) => Object.keys(filter).join(",")).join("-");
+ const _ = d.extend(subId);
+
+ return new Promise((resolve, reject) => {
+ let eventCount = 0;
+
+ _("Querying %o", subscription.filters);
+
+ // Generate a subscription
+ const sub = this.ndk.subscribe(
+ subscription.filters,
+ {
+ subId: subscription.subId,
+ closeOnEose: true,
+ },
+ this.relaySet,
+ false
+ );
+
+ // Process events
+ sub.on("event", (event) => {
+ subscription.eventReceived(event, undefined, true);
+ eventCount++;
+ _("Event received %d", event.kind);
+ });
+
+ // Finish when we EOSE
+ sub.on("eose", () => {
+ _("Eose received");
+ this.relay.off("notice", onRelayNotice);
+ resolve(eventCount);
+ });
+
+ // Handle relay notices
+ const onRelayNotice = (notice: string) => {
+ _("Notice received %s", notice);
+ reject(notice);
+ };
+
+ this.relay.once("notice", (notice) => {
+ _("Notice received %o", notice);
+ });
+
+ // Start the subscription
+ sub.start();
+ });
+ }
+
+ async query(subscription: NDKSubscription): Promise {
+ const subId =
+ subscription.subId ??
+ subscription.filters.map((filter) => Object.keys(filter).join(",")).join("-");
+ let eventCount = 0;
+
+ const _ = d.extend(subId);
+
+ await Promise.race([this.queryLocally(subscription), timeout(this.queryTimeout)])
+ .then((count: unknown) => {
+ if (typeof count === "number") {
+ eventCount = count;
+
+ _("Query finished with %d events", eventCount);
+
+ setTimeout(() => this.hydrate(subscription), 2500);
+ }
+ })
+ .catch((err) => {
+ _("Error %o", err);
+ });
+ }
+
+ private async hydrate(subscription: NDKSubscription) {
+ this.backgroundSubscriptionQueue.add({
+ id: subscription.filters.flatMap((filter) => Object.keys(filter).join(",")).join("-"),
+ func: async (): Promise => {
+ let publishedEvents = 0;
+ return new Promise((resolve, reject) => {
+ d("Hydrating %o", subscription.filters);
+ const sub = this.fallbackNdk.subscribe(
+ subscription.filters,
+ {
+ closeOnEose: true,
+ },
+ undefined,
+ false
+ );
+ sub.on("event", (event) => {
+ this.hydrateLocalRelayWithEvent(event);
+ publishedEvents++;
+ });
+ sub.on("eose", () => {
+ d("Hydrated %d events", publishedEvents);
+ resolve();
+ });
+ sub.on("close", () => {
+ d("Hydration closed");
+ reject();
+ });
+ sub.start();
+ });
+ },
+ });
+ }
+
+ private hydrateLocalRelayWithEvent(event: NDKEvent) {
+ d(`relay status %s`, this.relay.status);
+ event.ndk = this.ndk;
+ this.relay
+ .publish(event)
+ .then(() => {
+ this.hydratedEvents++;
+ })
+ .catch((err) => {
+ d("Error hydrating event %o", err);
+ });
+ }
+
+ async setEvent(
+ event: NDKEvent,
+ filters: NDKFilter[],
+ relay?: NDKRelay | undefined
+ ): Promise {
+ this.hydrateLocalRelayWithEvent(event);
+ }
+
+ // async deleteEvent?(event: NDKEvent): Promise {
+ // d("deleteEvent method not implemented.");
+ // }
+ // async fetchProfile?(pubkey: string): Promise {
+ // d("fetchProfile method not implemented.");
+ // }
+ // saveProfile?(pubkey: string, profile: NDKUserProfile): void {
+ // d("saveProfile method not implemented.");
+ // }
+ // getProfiles?: ((filter: (pubkey: string, profile: NDKUserProfile) => boolean) => Promise | undefined>) | undefined;
+ // async loadNip05?(nip05: string, maxAgeForMissing?: number | undefined): Promise {
+ // d("loadNip05 method not implemented.");
+ // }
+ // saveNip05?(nip05: string, profile: ProfilePointer | null): void {
+ // d("saveNip05 method not implemented.");
+ // }
+ // async loadUsersLNURLDoc?(pubkey: string, maxAgeInSecs?: number | undefined, maxAgeForMissing?: number | undefined): Promise<"missing" | NDKLnUrlData | null> {
+ // d("loadUsersLNURLDoc method not implemented.");
+ // }
+ // saveUsersLNURLDoc?(pubkey: string, doc: NDKLnUrlData | null): void {
+ // d("saveUsersLNURLDoc method not implemented.");
+ // }
+ // updateRelayStatus?(relayUrl: string, info: NDKCacheRelayInfo): void {
+ // d("updateRelayStatus method not implemented.");
+ // }
+ // addUnpublishedEvent?(event: NDKEvent, relayUrls: string[]): void {
+ // d("addUnpublishedEvent method not implemented.");
+ // }
+ // async getUnpublishedEvents?(): Promise<{ event: NDKEvent; relays?: string[] | undefined; lastTryAt?: number | undefined; }[]> {
+ // d("getUnpublishedEvents method not implemented.");
+ // }
+ // discardUnpublishedEvent?(eventId: string): void {
+ // d("discardUnpublishedEvent method not implemented.");
+ // }
+ // onReady?(callback: () => void): void {
+ // d("onReady method not implemented.");
+ // }
+}
+
+const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const profile = async (fn: (...args: any[]) => any) => {
+ return async (...args: any[]) => {
+ const start = Date.now();
+ const result = await fn(...args);
+ d("Function took %d ms", Date.now() - start);
+ return result;
+ };
+};
diff --git a/ndk-cache-nostr/src/queue.ts b/ndk-cache-nostr/src/queue.ts
new file mode 100644
index 00000000..8d389cdf
--- /dev/null
+++ b/ndk-cache-nostr/src/queue.ts
@@ -0,0 +1,90 @@
+type QueueItem = {
+ /**
+ * Deterministic id of the item
+ */
+ id: string;
+
+ /**
+ * A function to process the item
+ * @returns
+ */
+ func: () => Promise;
+};
+
+export class Queue {
+ private queue: QueueItem[] = [];
+ private maxConcurrency: number;
+ private processing: Set = new Set();
+ private promises: Map> = new Map();
+
+ constructor(name: string, maxConcurrency: number) {
+ this.maxConcurrency = maxConcurrency;
+ }
+
+ public add(item: QueueItem): Promise {
+ if (this.promises.has(item.id)) {
+ return this.promises.get(item.id)!;
+ } else {
+ }
+
+ const promise = new Promise((resolve, reject) => {
+ this.queue.push({
+ ...item,
+ func: () =>
+ item.func().then(
+ (result) => {
+ resolve(result);
+ return result; // Return the result to match the expected type.
+ },
+ (error) => {
+ reject(error);
+ // It's important to rethrow the error here to not accidentally resolve the promise.
+ // However, since TypeScript 4.4, you can set "useUnknownInCatchVariables" to false if this line errors.
+ throw error;
+ }
+ ),
+ });
+ this.process();
+ });
+
+ this.promises.set(item.id, promise);
+ promise.finally(() => {
+ this.promises.delete(item.id);
+ this.processing.delete(item.id);
+ this.process();
+ });
+
+ return promise;
+ }
+
+ private process() {
+ if (this.processing.size >= this.maxConcurrency || this.queue.length === 0) {
+ return;
+ }
+
+ const item = this.queue.shift();
+ if (!item || this.processing.has(item.id)) {
+ return;
+ }
+
+ this.processing.add(item.id);
+ item.func();
+ }
+
+ public clear() {
+ this.queue = [];
+ }
+
+ public clearProcessing() {
+ this.processing.clear();
+ }
+
+ public clearAll() {
+ this.clear();
+ this.clearProcessing();
+ }
+
+ public length() {
+ return this.queue.length;
+ }
+}
diff --git a/ndk-cache-nostr/tsconfig.json b/ndk-cache-nostr/tsconfig.json
new file mode 100644
index 00000000..ecc6d9be
--- /dev/null
+++ b/ndk-cache-nostr/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "@nostr-dev-kit/tsconfig/ndk-cache-redis.json",
+ "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts"],
+ "exclude": ["dist", "build", "node_modules"]
+}
diff --git a/ndk-cache-redis/.gitignore b/ndk-cache-redis/.gitignore
new file mode 100644
index 00000000..c92e9f04
--- /dev/null
+++ b/ndk-cache-redis/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+**/*.js
+dist
diff --git a/ndk-cache-redis/.prettierignore b/ndk-cache-redis/.prettierignore
new file mode 100644
index 00000000..1521c8b7
--- /dev/null
+++ b/ndk-cache-redis/.prettierignore
@@ -0,0 +1 @@
+dist
diff --git a/ndk-cache-redis/CHANGELOG.md b/ndk-cache-redis/CHANGELOG.md
new file mode 100644
index 00000000..9aa56fd0
--- /dev/null
+++ b/ndk-cache-redis/CHANGELOG.md
@@ -0,0 +1,362 @@
+# @nostr-dev-kit/ndk-cache-redis
+
+## 2.1.24
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.7
+
+## 2.1.23
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.6
+
+## 2.1.22
+
+### Patch Changes
+
+- Updated dependencies [5939a3e]
+- Updated dependencies
+- Updated dependencies [f2a0cce]
+ - @nostr-dev-kit/ndk@2.10.5
+
+## 2.1.21
+
+### Patch Changes
+
+- Updated dependencies [5bed70c]
+- Updated dependencies [873ad4a]
+ - @nostr-dev-kit/ndk@2.10.4
+
+## 2.1.20
+
+### Patch Changes
+
+- Updated dependencies [0fc66c5]
+ - @nostr-dev-kit/ndk@2.10.3
+
+## 2.1.19
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.2
+
+## 2.1.18
+
+### Patch Changes
+
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [722345b]
+ - @nostr-dev-kit/ndk@2.10.1
+
+## 2.1.17
+
+### Patch Changes
+
+- Updated dependencies [ec83ddc]
+- Updated dependencies [18c55bb]
+- Updated dependencies
+- Updated dependencies [18c55bb]
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies [3029124]
+ - @nostr-dev-kit/ndk@2.10.0
+
+## 2.1.16
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.9.1
+
+## 2.1.15
+
+### Patch Changes
+
+- Updated dependencies [94018b4]
+- Updated dependencies [548f4d8]
+ - @nostr-dev-kit/ndk@2.9.0
+
+## 2.1.14
+
+### Patch Changes
+
+- Updated dependencies [0af033f]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.8.2
+
+## 2.1.13
+
+### Patch Changes
+
+- Updated dependencies [e40312b]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.8.1
+
+## 2.1.12
+
+### Patch Changes
+
+- Updated dependencies [91d873c]
+- Updated dependencies [6fd9ddc]
+- Updated dependencies [0b8f331]
+- Updated dependencies
+- Updated dependencies [f2898ad]
+- Updated dependencies [9b92cd9]
+- Updated dependencies
+- Updated dependencies [6814f0c]
+- Updated dependencies [89b5b3f]
+- Updated dependencies [9b92cd9]
+- Updated dependencies [27b10cc]
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies [ed7cdc4]
+ - @nostr-dev-kit/ndk@2.8.0
+
+## 2.1.11
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.7.1
+
+## 2.1.10
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.7.0
+
+## 2.1.9
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.6.1
+
+## 2.1.8
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [c2db3c1]
+- Updated dependencies
+- Updated dependencies [c2db3c1]
+- Updated dependencies [c2db3c1]
+ - @nostr-dev-kit/ndk@2.6.0
+
+## 2.1.7
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.5.1
+
+## 2.1.6
+
+### Patch Changes
+
+- Updated dependencies [e08fc74]
+ - @nostr-dev-kit/ndk@2.5.0
+
+## 2.1.5
+
+### Patch Changes
+
+- Updated dependencies [111c1ea]
+- Updated dependencies [5c0ae51]
+- Updated dependencies [6f5ea49]
+- Updated dependencies [3738d39]
+- Updated dependencies [d22239a]
+ - @nostr-dev-kit/ndk@2.4.1
+
+## 2.1.4
+
+### Patch Changes
+
+- Updated dependencies [b9bbf1d]
+ - @nostr-dev-kit/ndk@2.4.0
+
+## 2.1.3
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [885b6c2]
+- Updated dependencies [5666d56]
+ - @nostr-dev-kit/ndk@2.3.3
+
+## 2.1.2
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [4628481]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.3.2
+
+## 2.1.1
+
+### Patch Changes
+
+- Updated dependencies [ece965f]
+ - @nostr-dev-kit/ndk@2.3.1
+
+## 2.1.0
+
+### Minor Changes
+
+- 06c83ea: Aggressively cache all filters and their responses so the same filter can hit the cache
+
+### Patch Changes
+
+- Updated dependencies [54cec78]
+- Updated dependencies [ef61d83]
+- Updated dependencies [98b77dd]
+- Updated dependencies [46b0c77]
+- Updated dependencies [082e243]
+ - @nostr-dev-kit/ndk@2.3.0
+
+## 2.0.11
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.2.0
+
+## 2.0.10
+
+### Patch Changes
+
+- Updated dependencies [180d774]
+- Updated dependencies [7f00c40]
+ - @nostr-dev-kit/ndk@2.1.3
+
+## 2.0.9
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.2
+
+## 2.0.8
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.1
+
+## 2.0.7
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.0
+
+## 2.0.6
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.6
+
+## 2.0.5
+
+### Patch Changes
+
+- Updated dependencies [d45d962]
+ - @nostr-dev-kit/ndk@2.0.5
+
+## 2.0.5
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [d45d962]
+ - @nostr-dev-kit/ndk@2.0.5
+
+## 2.0.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.4
+
+## 2.0.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.3
+
+## 2.0.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.2
+
+## 1.8.7
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.0
+
+## 1.8.6
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.2
+
+## 1.8.5
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.1
+
+## 1.8.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.0
+
+## 1.8.3
+
+### Patch Changes
+
+- Updated dependencies [b3561af]
+ - @nostr-dev-kit/ndk@1.3.2
+
+## 1.8.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.3.1
+
+## 1.8.1
+
+### Patch Changes
+
+- Updated dependencies [88df10a]
+- Updated dependencies [c225094]
+- Updated dependencies [cf4a648]
+- Updated dependencies [3946078]
+- Updated dependencies [3440768]
+ - @nostr-dev-kit/ndk@1.3.0
diff --git a/ndk-cache-redis/README.md b/ndk-cache-redis/README.md
new file mode 100644
index 00000000..42d65328
--- /dev/null
+++ b/ndk-cache-redis/README.md
@@ -0,0 +1,27 @@
+# ndk-cache-redis
+
+NDK cache adapter for redis.
+
+This cache is mostly a skeleton; the cache hit logic is very basic and only checks if
+a query is using precisely `kinds` and `authors` filtering.
+
+## Usage
+
+### Install
+
+```
+npm add @nostr-dev-kit/ndk-cache-redis
+```
+
+### Add as a cache adapter
+
+```ts
+import NDKRedisCacheAdapter from "@nostr-dev-kit/ndk-cache-redis";
+
+const cacheAdapter = new NDKRedisCacheAdapter();
+const ndk = new NDK({ cacheAdapter });
+```
+
+# License
+
+MIT
diff --git a/ndk-cache-redis/jest.config.ts b/ndk-cache-redis/jest.config.ts
new file mode 100644
index 00000000..931e01ef
--- /dev/null
+++ b/ndk-cache-redis/jest.config.ts
@@ -0,0 +1,11 @@
+import type { Config } from "jest";
+
+const config: Config = {
+ preset: "ts-jest",
+ testEnvironment: "node",
+ moduleNameMapper: {
+ "^(\\.{1,2}/.*)\\.js$": "$1",
+ },
+};
+
+export default config;
diff --git a/ndk-cache-redis/package.json b/ndk-cache-redis/package.json
new file mode 100644
index 00000000..60b4c9df
--- /dev/null
+++ b/ndk-cache-redis/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "@nostr-dev-kit/ndk-cache-redis",
+ "version": "2.1.24",
+ "description": "NDK cache adapter for redis.",
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "exports": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "dev": "pnpm build --watch",
+ "build": "tsup src/index.ts --format cjs,esm --dts",
+ "clean": "rm -rf dist",
+ "test": "jest",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write ."
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/nostr-dev-kit/ndk.git"
+ },
+ "keywords": [
+ "nostr",
+ "redis",
+ "cache"
+ ],
+ "author": "pablof7z",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/nostr-dev-kit/ndk/issues"
+ },
+ "homepage": "https://github.com/nostr-dev-kit/ndk",
+ "dependencies": {
+ "@nostr-dev-kit/ndk": "workspace:*",
+ "debug": "^4.3.4",
+ "ioredis": "^5.3.2",
+ "nostr-tools": "^2.4.0",
+ "typescript": "^5.4.4"
+ },
+ "devDependencies": {
+ "@nostr-dev-kit/eslint-config-custom": "workspace:*",
+ "@nostr-dev-kit/tsconfig": "workspace:*",
+ "@types/debug": "^4.1.7",
+ "@types/jest": "^29.5.5",
+ "@types/node": "^18.15.11",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.1.2",
+ "ts-node": "^10.9.2",
+ "tsup": "^7.2.0"
+ }
+}
diff --git a/ndk-cache-redis/src/index.test.ts b/ndk-cache-redis/src/index.test.ts
new file mode 100644
index 00000000..88755105
--- /dev/null
+++ b/ndk-cache-redis/src/index.test.ts
@@ -0,0 +1,83 @@
+import NDK, {
+ NDKEvent,
+ NDKKind,
+ NDKPrivateKeySigner,
+ NDKSubscription,
+ type NostrEvent,
+ type NDKUser,
+ NDKSubscriptionCacheUsage,
+ NDKRelay,
+} from "@nostr-dev-kit/ndk";
+import NDKRedisCacheAdapter from ".";
+import Redis from "ioredis";
+
+const signer = NDKPrivateKeySigner.generate();
+const ndk = new NDK({
+ cacheAdapter: new NDKRedisCacheAdapter(),
+ signer,
+});
+const redis = new Redis();
+const relay = new NDKRelay("ws://localhost");
+
+let user: NDKUser;
+
+beforeAll(async () => {
+ user = await signer.blockUntilReady();
+});
+
+async function storeEvent(sub: NDKSubscription, event?: NDKEvent) {
+ event ??= new NDKEvent(ndk, {
+ kind: NDKKind.Text,
+ content: "hello, world",
+ } as NostrEvent);
+ await event.sign();
+
+ await sub.eventReceived(event, relay);
+
+ return event;
+}
+
+describe("setEvent", () => {
+ it("stores the event", async () => {
+ const sub = new NDKSubscription(
+ ndk,
+ {
+ authors: [user.pubkey],
+ kinds: [NDKKind.Text],
+ },
+ { cacheUsage: NDKSubscriptionCacheUsage.ONLY_CACHE, closeOnEose: true }
+ );
+ const event = await storeEvent(sub);
+
+ await sleep(100); // We don't want the cache to await the event to be stored, but we need to test that it is stored
+
+ const result = await redis.get(event.id);
+ expect(result).toBeTruthy();
+ });
+
+ it("finds the event", async () => {
+ const sub = new NDKSubscription(
+ ndk,
+ {
+ authors: [user.pubkey],
+ kinds: [NDKKind.Text],
+ },
+ { cacheUsage: NDKSubscriptionCacheUsage.ONLY_CACHE, closeOnEose: true }
+ );
+ const event = await storeEvent(sub);
+
+ // sub should have an event fired to it
+ await new Promise((resolve) => {
+ sub.on("event", (event) => {
+ expect(event.id).toEqual(event.id);
+ resolve();
+ });
+
+ sub.start();
+ });
+ });
+});
+
+async function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/ndk-cache-redis/src/index.ts b/ndk-cache-redis/src/index.ts
new file mode 100644
index 00000000..6e83073a
--- /dev/null
+++ b/ndk-cache-redis/src/index.ts
@@ -0,0 +1,160 @@
+import type { NDKCacheAdapter, NDKFilter, NostrEvent, ProfilePointer } from "@nostr-dev-kit/ndk";
+import { NDKRelay } from "@nostr-dev-kit/ndk";
+import { NDKEvent, type NDKSubscription } from "@nostr-dev-kit/ndk";
+import _debug from "debug";
+import Redis from "ioredis";
+import { matchFilter } from "nostr-tools";
+
+type NostrEventWithRelay = NostrEvent & { relay?: string };
+interface RedisAdapterOptions {
+ /**
+ * Debug instance to use for logging.
+ */
+ debug?: debug.IDebugger;
+
+ /**
+ * The number of seconds to store events in redis before they expire.
+ */
+ expirationTime?: number;
+ /**
+ * Redis instance connection path
+ */
+ path?: string;
+}
+
+export default class RedisAdapter implements NDKCacheAdapter {
+ public redis;
+ public debug;
+ private expirationTime;
+ readonly locking;
+
+ constructor(opts: RedisAdapterOptions = {}) {
+ this.redis = opts.path ? new Redis(opts.path) : new Redis();
+ this.debug = opts.debug || _debug("ndk:redis-adapter");
+ this.redis.on("error", (err) => {
+ this.debug("redis error", err);
+ });
+ this.locking = true;
+ this.expirationTime = opts.expirationTime || 3600;
+ }
+
+ public async query(subscription: NDKSubscription): Promise {
+ this.debug("query redis status", this.redis.status);
+ if (this.redis.status !== "connect") return;
+ await Promise.all(
+ subscription.filters.map((filter) => this.processFilter(filter, subscription))
+ );
+ }
+
+ private async processFilter(filter: NDKFilter, subscription: NDKSubscription): Promise {
+ const filterString = JSON.stringify(filter);
+
+ const eventIds = await this.redis.smembers(filterString);
+
+ return new Promise((resolve) => {
+ Promise.all(
+ eventIds.map(async (eventId) => {
+ const event = await this.redis.get(eventId);
+ if (!event) return;
+
+ const parsedEvent = JSON.parse(event);
+ const relayUrl = parsedEvent.relay;
+ delete parsedEvent.relay;
+ const relay =
+ subscription.ndk.pool.getRelay(relayUrl, false) || new NDKRelay(relayUrl);
+
+ subscription.eventReceived(
+ new NDKEvent(subscription.ndk, parsedEvent),
+ relay,
+ true
+ );
+ })
+ ).then(() => {
+ resolve();
+ });
+ });
+ }
+
+ private storeEvent(event: NostrEventWithRelay, relay: NDKRelay) {
+ event.relay = relay.url;
+ return this.redis.set(event.id!, JSON.stringify(event), "EX", this.expirationTime);
+ }
+
+ private async storeEventWithFilter(
+ event: NostrEvent,
+ filter: NDKFilter,
+ relay: NDKRelay
+ ): Promise {
+ const filterString = JSON.stringify(filter);
+
+ // very naive quick implementation of storing the filter
+ this.redis.sadd(filterString, event.id!);
+ this.redis.expire(filterString, this.expirationTime);
+
+ // store the event if it doesn't already exist
+ const exists = await this.redis.exists(event.id!);
+
+ if (!exists) {
+ await this.storeEvent(event, relay);
+ } else {
+ // renew the expiration time
+ this.redis.expire(event.id!, this.expirationTime);
+ }
+ }
+
+ public shouldSkipFilter(filter: NDKFilter): boolean {
+ const values = Object.values(filter);
+
+ // if it has too many things tagged in an array
+ if (values.some((v) => Array.isArray(v) && v.length > 10)) {
+ this.debug("skipping filter", filter);
+ return true;
+ }
+
+ // if it has too many queries
+ if (values && values.length > 3) return true;
+
+ // if it uses since or until
+ if (filter.since || filter.until) return true;
+
+ return false;
+ }
+
+ public async setEvent(event: NDKEvent, filters: NDKFilter[], relay: NDKRelay): Promise {
+ this.debug("setEvent redis status", this.redis.status);
+ if (this.redis.status !== "connect") return;
+ const rawEvent = event.rawEvent();
+
+ if (filters.length === 1) {
+ if (this.shouldSkipFilter(filters[0])) return;
+
+ await this.storeEventWithFilter(rawEvent, filters[0], relay);
+ } else if (filters.length > 1) {
+ for (const filter of filters) {
+ if (this.shouldSkipFilter(filter)) continue;
+
+ if (matchFilter(filter, rawEvent as any)) {
+ await this.storeEventWithFilter(rawEvent, filter, relay);
+ continue;
+ }
+ }
+ }
+ }
+
+ public async loadNip05?(nip05: string): Promise {
+ this.debug("loadNip05 redis status", this.redis.status);
+ if (this.redis.status !== "connect") return null;
+ const profile = await this.redis.get(this.nip05Key(nip05));
+ return profile ? JSON.parse(profile) : null;
+ }
+
+ public saveNip05?(nip05: string, profile: ProfilePointer): void {
+ this.debug("saveNip05 redis status", this.redis.status);
+ if (this.redis.status !== "connect") return;
+ this.redis.set(this.nip05Key(nip05), JSON.stringify(profile), "EX", this.expirationTime);
+ }
+
+ private nip05Key(nip05: string): string {
+ return `nip05:${nip05}`;
+ }
+}
diff --git a/ndk-cache-redis/tsconfig.json b/ndk-cache-redis/tsconfig.json
new file mode 100644
index 00000000..ecc6d9be
--- /dev/null
+++ b/ndk-cache-redis/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "@nostr-dev-kit/tsconfig/ndk-cache-redis.json",
+ "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts"],
+ "exclude": ["dist", "build", "node_modules"]
+}
diff --git a/ndk-mobile/CHANGELOG.md b/ndk-mobile/CHANGELOG.md
new file mode 100644
index 00000000..cf774503
--- /dev/null
+++ b/ndk-mobile/CHANGELOG.md
@@ -0,0 +1,21 @@
+# @nostr-dev-kit/ndk-mobile
+
+## 0.2.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.7
+ - @nostr-dev-kit/ndk-wallet@0.3.15
+
+## 0.2.0
+
+### Minor Changes
+
+- add the very handy useNDKSessionEventKind
+
+## 0.1.5
+
+### Patch Changes
+
+- add default export
diff --git a/ndk-mobile/LICENSE b/ndk-mobile/LICENSE
new file mode 100644
index 00000000..2a6ea523
--- /dev/null
+++ b/ndk-mobile/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Pablo Fernandez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ndk-mobile/README.md b/ndk-mobile/README.md
new file mode 100644
index 00000000..71c109e0
--- /dev/null
+++ b/ndk-mobile/README.md
@@ -0,0 +1,35 @@
+# NDK Mobile
+
+A React Native/Expo implementation of [NDK (Nostr Development Kit)](https://github.com/nostr-dev-kit/ndk) that provides a complete toolkit for building Nostr applications on mobile platforms.
+
+## Features
+
+- 🔐 Multiple signer implementations (NIP-07, NIP-46, Private Key)
+- 💾 SQLite-based caching for offline support
+- 🔄 Subscription management with automatic reconnection
+- 📱 React Native and Expo compatibility
+- 🪝 React hooks for easy state management
+- 👛 Integrated wallet support
+
+## Installation
+
+npm install @nostr-dev-kit/ndk-mobile
+
+## Usage
+
+When using this library don't import `@nostr-dev-kit/ndk` directly, instead import `@nostr-dev-kit/ndk-mobile`. `ndk-mobile` exports the same classes as `ndk`, so you can just swap the import.
+
+## Example
+
+There is a barebones repository showing how to use this library:
+[ndk-mobile-sample](https://github.com/pablof7z/ndk-mobile-sample).
+
+For a real application using this look at [Olas](https://github.com/pablof7z/snapstr).
+
+## License
+
+This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
+
+## Author
+
+[@pablof7z](https://njump.me/f7z.io)
diff --git a/ndk-mobile/index.ts b/ndk-mobile/index.ts
new file mode 100644
index 00000000..faf045ad
--- /dev/null
+++ b/ndk-mobile/index.ts
@@ -0,0 +1,10 @@
+import '@bacons/text-decoder/install';
+import 'react-native-get-random-values';
+
+export * from './src/hooks';
+export * from './src/context';
+export * from './src/providers';
+export * from './src/cache-adapter/sqlite';
+export * from './src/components';
+export * from './src/components/relays';
+export * from '@nostr-dev-kit/ndk';
diff --git a/ndk-mobile/package.json b/ndk-mobile/package.json
new file mode 100644
index 00000000..01c50da3
--- /dev/null
+++ b/ndk-mobile/package.json
@@ -0,0 +1,83 @@
+{
+ "name": "@nostr-dev-kit/ndk-mobile",
+ "version": "0.2.1",
+ "description": "NDK Mobile",
+ "private": false,
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ },
+ "devDependencies": {
+ "react-native-builder-bob": "^0.35.2",
+ "typescript": "^5.7.2"
+ },
+ "keywords": [
+ "ndk",
+ "nostr",
+ "react-native",
+ "expo"
+ ],
+ "dependencies": {
+ "@bacons/text-decoder": "^0.0.0",
+ "@nostr-dev-kit/ndk": "workspace:*",
+ "@nostr-dev-kit/ndk-wallet": "workspace:*",
+ "react-native-get-random-values": "~1.11.0",
+ "typescript-lru-cache": "^2.0.0",
+ "zustand": "^5.0.2"
+ },
+ "source": "./src/index.ts",
+ "module": "./lib/module/index.js",
+ "react-native": "lib/module/index.js",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./lib/typescript/module/index.d.ts",
+ "default": "./lib/module/index.js"
+ }
+ },
+ "./components": {
+ "import": {
+ "types": "./lib/typescript/module/components/index.d.ts",
+ "default": "./lib/module/components/index.js"
+ }
+ },
+ "./components/relays": {
+ "import": {
+ "types": "./lib/typescript/module/components/relays/index.d.ts",
+ "default": "./lib/module/components/relays/index.js"
+ }
+ }
+ },
+ "scripts": {
+ "prepare": "cd ../ndk-wallet && pnpm build && cd ../ndk-mobile && bob build"
+ },
+ "files": [
+ "src",
+ "lib",
+ "!**/__tests__",
+ "!**/__fixtures__",
+ "!**/__mocks__"
+ ],
+ "react-native-builder-bob": {
+ "source": "src",
+ "output": "lib",
+ "targets": [
+ [
+ "module",
+ {
+ "esm": true
+ }
+ ],
+ [
+ "typescript",
+ {
+ "esm": true
+ }
+ ]
+ ]
+ },
+ "eslintIgnore": [
+ "node_modules/",
+ "lib/"
+ ]
+}
diff --git a/ndk-mobile/src/cache-adapter/migrations.ts b/ndk-mobile/src/cache-adapter/migrations.ts
new file mode 100644
index 00000000..b52b5280
--- /dev/null
+++ b/ndk-mobile/src/cache-adapter/migrations.ts
@@ -0,0 +1,61 @@
+import * as SQLite from 'expo-sqlite';
+
+export const migrations = [
+ {
+ version: 0,
+ up: async (db: SQLite.SQLiteDatabase) => {
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS unpublished_events (
+ id TEXT PRIMARY KEY,
+ event TEXT,
+ relays TEXT,
+ last_try_at INTEGER
+ );`
+ );
+
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS events (
+ id TEXT PRIMARY KEY,
+ created_at INTEGER,
+ pubkey TEXT,
+ event TEXT,
+ kind INTEGER,
+ relay TEXT
+ );`
+ );
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS profiles (
+ pubkey TEXT PRIMARY KEY,
+ profile TEXT,
+ catched_at INTEGER
+ );`
+ );
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS relay_status (
+ url TEXT PRIMARY KEY,
+ lastConnectedAt INTEGER,
+ dontConnectBefore INTEGER
+ );`
+ );
+ // New table for event tags
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS event_tags (
+ event_id TEXT,
+ tag TEXT,
+ value TEXT,
+ PRIMARY KEY (event_id, tag)
+ );`
+ );
+
+ await db.execAsync(`CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events (pubkey);`);
+ await db.execAsync(`CREATE INDEX IF NOT EXISTS idx_events_kind ON events (kind);`);
+ await db.execAsync(`CREATE INDEX IF NOT EXISTS idx_events_tags_tag ON event_tags (tag);`);
+ },
+ },
+ {
+ version: 1,
+ up: async (db: SQLite.SQLiteDatabase) => {
+ await db.execAsync(`ALTER TABLE profiles ADD COLUMN created_at INTEGER;`);
+ },
+ },
+];
\ No newline at end of file
diff --git a/ndk-mobile/src/cache-adapter/sqlite.ts b/ndk-mobile/src/cache-adapter/sqlite.ts
new file mode 100644
index 00000000..cb5363b0
--- /dev/null
+++ b/ndk-mobile/src/cache-adapter/sqlite.ts
@@ -0,0 +1,314 @@
+import {
+ NDKCacheAdapter,
+ NDKEvent,
+ NDKFilter,
+ NDKSubscription,
+ NDKUserProfile,
+ Hexpubkey,
+ NDKCacheEntry,
+ NDKRelay,
+ deserialize,
+ NDKEventId,
+ NDKKind,
+} from '@nostr-dev-kit/ndk';
+import { LRUCache } from 'typescript-lru-cache';
+import * as SQLite from 'expo-sqlite';
+import { matchFilter } from 'nostr-tools';
+import { migrations } from './migrations';
+
+type EventRecord = {
+ id: string;
+ created_at: number;
+ pubkey: string;
+ event: string;
+ kind: number;
+ relay: string;
+};
+
+type UnpublishedEventRecord = {
+ id: string;
+ event: string;
+ relays: string; // JSON string of {[url: string]: boolean}
+ last_try_at: number;
+};
+
+type PendingCallback = (...arg: any) => any;
+
+export class NDKCacheAdapterSqlite implements NDKCacheAdapter {
+ readonly dbName: string;
+ private db: SQLite.SQLiteDatabase;
+ locking: boolean = false;
+ ready: boolean = false;
+ private pendingCallbacks: PendingCallback[] = [];
+ private profileCache: LRUCache>;
+
+ constructor(dbName: string, maxProfiles: number = 200) {
+ this.dbName = dbName ?? 'ndk-cache';
+ this.profileCache = new LRUCache({ maxSize: maxProfiles });
+ this.initialize();
+ }
+
+ private async initialize() {
+ this.db = await SQLite.openDatabaseAsync(this.dbName);
+
+ // get current schema version
+ let { user_version: schemaVersion } = (await this.db.getFirstSync(`PRAGMA user_version;`)) as { user_version: number };
+
+ if (!schemaVersion) {
+ schemaVersion = 0;
+
+ // set the schema version
+ await this.db.execAsync(`PRAGMA user_version = ${schemaVersion};`);
+ }
+
+ if (!schemaVersion || Number(schemaVersion) < migrations.length) {
+ await this.db.withTransactionAsync(async () => {
+ for (let i = Number(schemaVersion); i < migrations.length; i++) {
+ try {
+ await migrations[i].up(this.db);
+ } catch (e) {
+ console.error('error running migration', e);
+ throw e;
+ }
+ await this.db.execAsync(`PRAGMA user_version = ${i + 1};`);
+ }
+
+ // set the schema version
+ await this.db.execAsync(`PRAGMA user_version = ${migrations.length};`);
+ });
+ }
+
+ this.ready = true;
+ this.locking = true;
+
+ Promise.all(this.pendingCallbacks.map((f) => f()));
+ }
+
+ onReady(callback: () => void) {
+ if (this.ready) {
+ callback();
+ } else {
+ this.pendingCallbacks.unshift(callback);
+ }
+ }
+
+ async query(subscription: NDKSubscription): Promise {
+ // Ensure the adapter is ready
+ if (!this.ready) return;
+
+ // Process filters from the subscription
+ for (const filter of subscription.filters) {
+ // Example: Fetch events by pubkey
+ if (filter.authors) {
+ const events = this.db.getAllSync(
+ `SELECT * FROM events WHERE pubkey IN (${filter.authors.map(() => '?').join(',')})`,
+ filter.authors
+ ) as EventRecord[];
+ if (events.length > 0) foundEvents(subscription, events, filter);
+ }
+
+ // Example: Fetch events by kind
+ if (filter.kinds) {
+ const events = this.db.getAllSync(
+ `SELECT * FROM events WHERE kind IN (${filter.kinds.map(() => '?').join(',')})`,
+ filter.kinds
+ ) as EventRecord[];
+ if (events.length > 0) foundEvents(subscription, events, filter);
+ }
+ }
+ }
+
+ async setEvent(event: NDKEvent, filters: NDKFilter[], relay?: NDKRelay): Promise {
+ const filterTags: [string, string][] = event.tags.filter((tag) => tag[0].length === 1).map((tag) => [tag[0], tag[1]]);
+ await Promise.all([
+ this.db.runAsync(`INSERT OR REPLACE INTO events (id, created_at, pubkey, event, kind, relay) VALUES (?, ?, ?, ?, ?, ?);`, [
+ event.id,
+ event.created_at!,
+ event.pubkey,
+ event.serialize(true, true),
+ event.kind!,
+ relay?.url || '',
+ ]),
+ filterTags.map((tag) =>
+ // Use INSERT OR REPLACE to avoid UNIQUE constraint violation
+ this.db.runAsync(`INSERT OR REPLACE INTO event_tags (event_id, tag, value) VALUES (?, ?, ?);`, [event.id, tag[0], tag[1]])
+ ),
+ ]);
+
+ // if this event is a delete event, see if the deleted events are in the cache and remove them
+ if (event.kind === NDKKind.EventDeletion) {
+ this.deleteEventIds(event.tags.filter((tag) => tag[0] === 'e').map((tag) => tag[1]));
+ }
+ }
+
+ async deleteEventIds(eventIds: NDKEventId[]): Promise {
+ await this.db.runAsync(`DELETE FROM events WHERE id IN (${eventIds.map(() => '?').join(',')});`, eventIds);
+ await this.db.runAsync(`DELETE FROM event_tags WHERE event_id IN (${eventIds.map(() => '?').join(',')});`, eventIds);
+ }
+
+ fetchProfileSync(pubkey: Hexpubkey): NDKCacheEntry | null {
+ if (!this.ready) return null;
+
+ const cached = this.profileCache.get(pubkey);
+ if (cached) return cached;
+
+ const result = this.db.getFirstSync(`SELECT profile, catched_at FROM profiles WHERE pubkey = ?;`, [pubkey]) as {
+ profile: string;
+ catched_at: number;
+ };
+
+ if (result) {
+ try {
+ const profile = JSON.parse(result.profile);
+ const entry = { ...profile, fetchedAt: result.catched_at };
+ this.profileCache.set(pubkey, entry);
+ return entry;
+ } catch (e) {
+ console.error('failed to parse profile', result.profile);
+ }
+ }
+ }
+
+ async fetchProfile(pubkey: Hexpubkey): Promise | null> {
+ if (!this.ready) return;
+
+ const cached = this.profileCache.get(pubkey);
+ if (cached) return cached;
+
+ const result = this.db.getFirstSync(`SELECT profile, catched_at FROM profiles WHERE pubkey = ?;`, [pubkey]) as {
+ profile: string;
+ catched_at: number;
+ };
+
+ if (result) {
+ try {
+ const profile = JSON.parse(result.profile);
+ const entry = { ...profile, fetchedAt: result.catched_at };
+ this.profileCache.set(pubkey, entry);
+ return entry;
+ } catch (e) {
+ console.error('failed to parse profile', result.profile);
+ }
+ }
+ return null;
+ }
+
+ async saveProfile(pubkey: Hexpubkey, profile: NDKUserProfile): Promise {
+ // check if the profile we have is newer based on created_at
+ const existingProfile = await this.fetchProfile(pubkey);
+ if (existingProfile?.created_at && profile.created_at && existingProfile.created_at >= profile.created_at) return;
+
+ const now = Date.now();
+ const entry = { ...profile, fetchedAt: now };
+ this.profileCache.set(pubkey, entry);
+
+ this.db.runAsync(`INSERT OR REPLACE INTO profiles (pubkey, profile, catched_at, created_at) VALUES (?, ?, ?, ?);`, [
+ pubkey,
+ JSON.stringify(profile),
+ now,
+ profile.created_at,
+ ]);
+ }
+
+ addUnpublishedEvent(event: NDKEvent, relayUrls: WebSocket['url'][]): void {
+ const relayStatus: { [key: string]: boolean } = {};
+ relayUrls.forEach(url => relayStatus[url] = false);
+
+ try {
+ this.db.runSync(`INSERT OR REPLACE INTO unpublished_events (id, event, relays, last_try_at) VALUES (?, ?, ?, ?);`, [
+ event.id,
+ event.serialize(true, true),
+ JSON.stringify(relayStatus),
+ Date.now(),
+ ]);
+
+ const onPublished = (relay: NDKRelay) => {
+ const url = relay.url;
+ const record = this.db.getFirstSync(
+ `SELECT relays FROM unpublished_events WHERE id = ?`,
+ [event.id]
+ ) as UnpublishedEventRecord | undefined;
+
+ if (!record) {
+ event.off('published', onPublished);
+ return;
+ }
+
+ const relays = JSON.parse(record.relays);
+ relays[url] = true;
+
+ const successWrites = Object.values(relays).filter(v => v).length;
+ const unsuccessWrites = Object.values(relays).length - successWrites;
+
+ if (successWrites >= 3 || unsuccessWrites === 0) {
+ this.discardUnpublishedEvent(event.id);
+ event.off('published', onPublished);
+ } else {
+ this.db.runSync(
+ `UPDATE unpublished_events SET relays = ? WHERE id = ?`,
+ [JSON.stringify(relays), event.id]
+ );
+ }
+ };
+
+ event.once('published', onPublished);
+ } catch (e) {
+ console.error('error adding unpublished event', e);
+ }
+ }
+
+ async getUnpublishedEvents(): Promise<{ event: NDKEvent; relays?: WebSocket['url'][]; lastTryAt?: number }[]> {
+ const call = () => this._getUnpublishedEvents();
+
+ if (!this.ready) {
+ return new Promise((resolve, reject) => {
+ this.pendingCallbacks.push(() => call().then(resolve).catch(reject));
+ });
+ } else {
+ return call();
+ }
+ }
+
+ async _getUnpublishedEvents(): Promise<{ event: NDKEvent; relays?: WebSocket['url'][]; lastTryAt?: number }[]> {
+ const events = (await this.db.getAllAsync(`SELECT * FROM unpublished_events`)) as UnpublishedEventRecord[];
+ return events.map((event) => {
+ const deserializedEvent = new NDKEvent(undefined, deserialize(event.event));
+ const relays = JSON.parse(event.relays);
+ return {
+ event: deserializedEvent,
+ relays: Object.keys(relays),
+ lastTryAt: event.last_try_at,
+ };
+ });
+ }
+
+ discardUnpublishedEvent(eventId: NDKEventId): void {
+ this.db.runAsync(`DELETE FROM unpublished_events WHERE id = ?;`, [eventId]);
+ }
+}
+
+export function foundEvents(subscription: NDKSubscription, events: EventRecord[], filter?: NDKFilter) {
+ // if we have a limit, sort and slice
+ if (filter?.limit && events.length > filter.limit) {
+ events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit);
+ }
+
+ for (const event of events) {
+ foundEvent(subscription, event, event.relay, filter);
+ }
+}
+
+export function foundEvent(subscription: NDKSubscription, event: EventRecord, relayUrl: WebSocket['url'] | undefined, filter?: NDKFilter) {
+ try {
+ const deserializedEvent = deserialize(event.event);
+
+ if (filter && !matchFilter(filter, deserializedEvent as any)) return;
+
+ const ndkEvent = new NDKEvent(undefined, deserializedEvent);
+ const relay = relayUrl ? subscription.pool.getRelay(relayUrl, false) : undefined;
+ ndkEvent.relay = relay;
+ subscription.eventReceived(ndkEvent, relay, true);
+ } catch (e) {
+ console.error('failed to deserialize event', e, event);
+ }
+}
diff --git a/ndk-mobile/src/components/index.ts b/ndk-mobile/src/components/index.ts
new file mode 100644
index 00000000..1e5cf076
--- /dev/null
+++ b/ndk-mobile/src/components/index.ts
@@ -0,0 +1,7 @@
+import Relays from "./relays";
+
+const Components = {
+ Relays
+};
+
+export { Components };
diff --git a/ndk-mobile/src/components/relays/index.tsx b/ndk-mobile/src/components/relays/index.tsx
new file mode 100644
index 00000000..67091c10
--- /dev/null
+++ b/ndk-mobile/src/components/relays/index.tsx
@@ -0,0 +1,3 @@
+import ConnectivityIndicator from "./indicator";
+
+export default { ConnectivityIndicator };
diff --git a/ndk-mobile/src/components/relays/indicator.tsx b/ndk-mobile/src/components/relays/indicator.tsx
new file mode 100644
index 00000000..dc04b61e
--- /dev/null
+++ b/ndk-mobile/src/components/relays/indicator.tsx
@@ -0,0 +1,45 @@
+import React from "react";
+import { NDKRelay } from "@nostr-dev-kit/ndk";
+
+import { NDKRelayStatus } from "@nostr-dev-kit/ndk";
+import { useState, useEffect } from "react";
+import { View } from "react-native";
+
+const CONNECTIVITY_STATUS_COLORS: Record = {
+ [NDKRelayStatus.RECONNECTING]: '#f1c40f',
+ [NDKRelayStatus.CONNECTING]: '#f1c40f',
+ [NDKRelayStatus.DISCONNECTED]: '#aa4240',
+ [NDKRelayStatus.DISCONNECTING]: '#aa4240',
+ [NDKRelayStatus.CONNECTED]: '#66cc66',
+ [NDKRelayStatus.FLAPPING]: '#2ecc71',
+ [NDKRelayStatus.AUTHENTICATING]: '#3498db',
+ [NDKRelayStatus.AUTHENTICATED]: '#e74c3c',
+ [NDKRelayStatus.AUTH_REQUESTED]: '#e74c3c',
+} as const;
+
+export default function RelayConnectivityIndicator({ relay }: { relay: NDKRelay }) {
+ const [ color, setColor ] = useState(CONNECTIVITY_STATUS_COLORS[relay.status]);
+
+ useEffect(() => {
+ relay.on("connect", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ relay.on("disconnect", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ relay.on("ready", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ relay.on("flapping", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ relay.on("notice", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ relay.on("auth", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ relay.on("authed", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ relay.on("auth:failed", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ relay.on("delayed-connect", () => setColor(CONNECTIVITY_STATUS_COLORS[relay.status]));
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/ndk-mobile/src/context/index.ts b/ndk-mobile/src/context/index.ts
new file mode 100644
index 00000000..4a64a434
--- /dev/null
+++ b/ndk-mobile/src/context/index.ts
@@ -0,0 +1,2 @@
+export * from './ndk';
+export * from './session';
\ No newline at end of file
diff --git a/ndk-mobile/src/context/ndk.ts b/ndk-mobile/src/context/ndk.ts
new file mode 100644
index 00000000..95cf76bb
--- /dev/null
+++ b/ndk-mobile/src/context/ndk.ts
@@ -0,0 +1,30 @@
+import NDK, { NDKFilter, NDKEvent, NDKUser, NDKNip07Signer, NDKNip46Signer, NDKPrivateKeySigner, NDKSigner } from '@nostr-dev-kit/ndk';
+import { createContext } from 'react';
+import { UnpublishedEventEntry } from '../providers/ndk';
+
+interface NDKContext {
+ ndk: NDK | undefined;
+
+ login: (promise: Promise) => Promise;
+ loginWithPayload: (payload: string, { save }: { save?: boolean }) => Promise;
+ logout: () => Promise;
+ unpublishedEvents: Map;
+
+ currentUser: NDKUser | null;
+
+ cacheInitialized: boolean | null;
+}
+
+const NDKContext = createContext({
+ ndk: undefined,
+ login: () => Promise.resolve(undefined),
+ loginWithPayload: () => Promise.resolve(undefined),
+ logout: () => Promise.resolve(undefined),
+
+ currentUser: null,
+ unpublishedEvents: new Map(),
+
+ cacheInitialized: null,
+});
+
+export default NDKContext;
diff --git a/ndk-mobile/src/context/session.ts b/ndk-mobile/src/context/session.ts
new file mode 100644
index 00000000..f00b55ca
--- /dev/null
+++ b/ndk-mobile/src/context/session.ts
@@ -0,0 +1,27 @@
+import { Hexpubkey, NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
+import { NDKCashuWallet, NDKWallet, NDKWalletBalance } from '@nostr-dev-kit/ndk-wallet';
+import { createContext } from 'react';
+
+interface NDKSessionContext {
+ follows?: Array;
+ events?: Map>;
+ mutePubkey: (pubkey: Hexpubkey) => void;
+ muteList: Set;
+
+ activeWallet?: NDKWallet;
+ setActiveWallet: (wallet: NDKWallet) => void;
+
+ balances: NDKWalletBalance[];
+}
+
+const NDKSessionContext = createContext({
+ follows: [],
+ events: new Map(),
+ mutePubkey: () => {},
+ muteList: new Set(),
+ activeWallet: undefined,
+ setActiveWallet: () => {},
+ balances: [],
+});
+
+export default NDKSessionContext;
diff --git a/ndk-mobile/src/hooks/index.ts b/ndk-mobile/src/hooks/index.ts
new file mode 100644
index 00000000..788732fb
--- /dev/null
+++ b/ndk-mobile/src/hooks/index.ts
@@ -0,0 +1,4 @@
+export * from './subscribe';
+export * from './ndk';
+export * from './session';
+export * from './user-profile';
\ No newline at end of file
diff --git a/ndk-mobile/src/hooks/ndk.ts b/ndk-mobile/src/hooks/ndk.ts
new file mode 100644
index 00000000..256a8a4d
--- /dev/null
+++ b/ndk-mobile/src/hooks/ndk.ts
@@ -0,0 +1,12 @@
+import { useContext } from 'react';
+import NDKContext from '../context/ndk';
+
+const useNDK = (): NDKContext => {
+ const context = useContext(NDKContext);
+ if (context === undefined) {
+ throw new Error('useNDK must be used within an NDKProvider');
+ }
+ return context;
+};
+
+export { useNDK };
diff --git a/ndk-mobile/src/hooks/session.ts b/ndk-mobile/src/hooks/session.ts
new file mode 100644
index 00000000..9dc5a1ff
--- /dev/null
+++ b/ndk-mobile/src/hooks/session.ts
@@ -0,0 +1,57 @@
+import { useContext } from 'react';
+import NDKSessionContext from '../context/session';
+import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
+import { useNDK } from './ndk';
+import { NDKEventWithFrom } from './subscribe';
+
+const useNDKSession = (): NDKSessionContext => {
+ const context = useContext(NDKSessionContext);
+ if (context === undefined) {
+ throw new Error('useNDK must be used within an NDKProvider');
+ }
+ return context;
+};
+
+/**
+ * This hook allows you to get a specific kind, wrapped in the event class you provide.
+ * @param EventClass
+ * @param kind
+ * @param opts.create - If true, and the event kind is not found, an unpublished event will be provided.
+ * @returns
+ */
+const useNDKSessionEventKind = (
+ EventClass: NDKEventWithFrom,
+ kind: NDKKind,
+ { create }: { create: boolean } = { create: false }
+): T | undefined => {
+ const { ndk } = useNDK();
+ const { events } = useNDKSession();
+ const kindEvents = events.get(kind) || [];
+ const firstEvent = !!kindEvents[0];
+
+ if (create && !firstEvent) {
+ const event = new EventClass(ndk);
+ event.kind = kind;
+ events.set(kind, [event]);
+ return event;
+ }
+
+ return firstEvent ? EventClass.from(firstEvent) : undefined;
+};
+
+const useNDKSessionEvents = (
+ kinds: NDKKind[],
+ eventClass?: NDKEventWithFrom,
+): T[] => {
+ const { events } = useNDKSession();
+ let allEvents = kinds.flatMap((kind) => events.get(kind) || []);
+
+ if (kinds.length > 1) allEvents = allEvents.sort((a, b) => a.created_at - b.created_at);
+
+ // remove deleted events if replaceable
+ allEvents = allEvents.filter((e) => !e.isReplaceable() || !e.hasTag('deleted'));
+
+ return allEvents.map((e) => eventClass ? eventClass.from(e) : e as T);
+};
+
+export { useNDKSession, useNDKSessionEventKind, useNDKSessionEvents };
diff --git a/ndk-mobile/src/hooks/subscribe.ts b/ndk-mobile/src/hooks/subscribe.ts
new file mode 100644
index 00000000..5dfd2b05
--- /dev/null
+++ b/ndk-mobile/src/hooks/subscribe.ts
@@ -0,0 +1,252 @@
+import '@bacons/text-decoder/install';
+import { createStore } from 'zustand/vanilla';
+import { NDKEvent, NDKFilter, NDKKind, NDKRelaySet, NDKSubscription, NDKSubscriptionOptions } from '@nostr-dev-kit/ndk';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useNDK } from './ndk';
+import { useStore } from 'zustand';
+import { useSessionStore } from '../stores/session';
+
+/**
+ * Extends NDKEvent with a 'from' method to wrap events with a kind-specific handler
+ */
+export type NDKEventWithFrom = T & { from: (event: NDKEvent) => T };
+
+/**
+ * Parameters for the useSubscribe hook
+ * @interface UseSubscribeParams
+ * @property {NDKFilter[] | null} filters - Nostr filters to subscribe to
+ * @property {Object} [opts] - Subscription options
+ * @property {NDKEventWithFrom} [opts.klass] - Class to convert events to
+ * @property {boolean} [opts.includeMuted] - Whether to include muted events
+ * @property {boolean} [opts.includeDeleted] - Whether to include deleted events
+ * @property {number | false} [opts.bufferMs] - Buffer time in ms, false to disable
+ * @property {string[]} [relays] - Optional relay URLs to connect to
+ */
+interface UseSubscribeParams {
+ filters: NDKFilter[] | null;
+ opts?: NDKSubscriptionOptions & {
+ klass?: NDKEventWithFrom;
+ includeMuted?: boolean;
+ includeDeleted?: boolean;
+ bufferMs?: number | false;
+ };
+ relays?: string[];
+}
+
+/**
+ * Store interface for managing subscription state
+ * @interface SubscribeStore
+ * @property {T[]} events - Array of received events
+ * @property {Map} eventMap - Map of events by ID
+ * @property {boolean} eose - End of stored events flag
+ * @property {boolean} isSubscribed - Subscription status
+ */
+interface SubscribeStore {
+ events: T[];
+ eventMap: Map;
+ eose: boolean;
+ isSubscribed: boolean;
+ addEvent: (event: T) => void;
+ removeEventId: (id: string) => void;
+ setEose: () => void;
+ clearEvents: () => void;
+ setSubscription: (sub: NDKSubscription | undefined) => void;
+ subscriptionRef: NDKSubscription | undefined;
+}
+
+/**
+ * Creates a store to manage subscription state with optional event buffering
+ * @param bufferMs - Buffer time in milliseconds, false to disable buffering
+ */
+const createSubscribeStore = (bufferMs: number | false = 16) =>
+ createStore>((set, get) => {
+ let buffer: T[] = [];
+ let timeout: NodeJS.Timeout | null = null;
+
+ // Function to flush the buffered events to the store
+ const flushBuffer = () => {
+ set((state) => {
+ const { eventMap } = state;
+ buffer.forEach((event) => {
+ const currentEvent = eventMap.get(event.tagId());
+ if (currentEvent && currentEvent.created_at! >= event.created_at!) return;
+ eventMap.set(event.tagId(), event);
+ });
+ const events = Array.from(eventMap.values());
+ buffer = [];
+ return { eventMap, events };
+ });
+ timeout = null;
+ };
+
+ return {
+ events: [],
+ eventMap: new Map(),
+ eose: false,
+ isSubscribed: false,
+ subscriptionRef: undefined,
+
+ addEvent: (event) => {
+ const { eose } = get();
+
+ if (!eose && bufferMs !== false) {
+ buffer.push(event);
+ if (!timeout) {
+ timeout = setTimeout(flushBuffer, bufferMs);
+ }
+ } else {
+ // Direct update logic when buffering is disabled or after EOSE
+ set((state) => {
+ const { eventMap } = state;
+ const currentEvent = eventMap.get(event.tagId());
+ if (currentEvent && currentEvent.created_at! >= event.created_at!) return state;
+
+ eventMap.set(event.tagId(), event);
+ const events = Array.from(eventMap.values());
+ return { eventMap, events };
+ });
+ }
+ },
+
+ removeEventId: (id) => {
+ set((state) => {
+ state.eventMap.delete(id);
+ const events = Array.from(state.eventMap.values());
+ return { eventMap: state.eventMap, events };
+ });
+ },
+
+ setEose: () => {
+ if (timeout) {
+ clearTimeout(timeout);
+ flushBuffer(); // Ensure any remaining buffered events are flushed immediately
+ }
+ set({ eose: true });
+ },
+
+ clearEvents: () => set({ eventMap: new Map(), eose: false }),
+ setSubscription: (sub) => set({ subscriptionRef: sub, isSubscribed: !!sub }),
+ };
+ });
+
+/**
+ * React hook for subscribing to Nostr events
+ * @param params - Subscription parameters
+ * @returns {Object} Subscription state
+ * @returns {T[]} events - Array of received events
+ * @returns {boolean} eose - End of stored events flag
+ * @returns {boolean} isSubscribed - Subscription status
+ */
+export const useSubscribe = ({ filters, opts = undefined, relays = undefined }: UseSubscribeParams) => {
+ const { ndk } = useNDK();
+ const muteList = useSessionStore((state) => state.muteList);
+ const store = useMemo(() => createSubscribeStore(opts?.bufferMs), [opts?.bufferMs]);
+ const storeInstance = useStore(store);
+
+ /**
+ * Map of eventIds that have been received by this subscription.
+ *
+ * Key: event identifier (event.dTag or event.id)
+ *
+ * Value: timestamp of the event, used to choose the
+ * most recent event on replaceable events
+ */
+ const eventIds = useRef>(new Map());
+
+ const relaySet = useMemo(() => {
+ if (ndk && relays && relays.length > 0) {
+ return NDKRelaySet.fromRelayUrls(relays, ndk);
+ }
+ return undefined;
+ }, [ndk, relays]);
+
+ const shouldAcceptEvent = (event: NDKEvent) => {
+ const id = event.tagId();
+ const currentVal = eventIds.current.get(id);
+
+ // if it's from a muted pubkey, we don't accept it
+ if (opts?.includeMuted !== true && muteList.has(event.pubkey)) {
+ console.log('rejecting from muted pubkey', event.pubkey);
+ return false;
+ }
+
+ // We have not seen this ID yet
+ if (!currentVal) return true;
+
+ // The ID we have seen is older
+ if (currentVal < event.created_at!) return true;
+
+ return false;
+ };
+
+ const handleEvent = useCallback(
+ (event: NDKEvent) => {
+ const id = event.tagId();
+
+ if (!shouldAcceptEvent(event)) return;
+
+ if (opts?.includeDeleted !== true && event.isParamReplaceable() && event.hasTag('deleted')) {
+ // We mark the event but we don't add the actual event, since
+ // it has been deleted
+ eventIds.current.set(id, event.created_at!);
+
+ return;
+ }
+
+ // If we need to convert the event, we do so
+ if (opts?.klass) event = opts.klass.from(event);
+
+ event.once("deleted", () => {
+ storeInstance.removeEventId(id);
+ });
+
+ // If conversion failed, we bail
+ if (!event) return;
+
+ storeInstance.addEvent(event as T);
+ eventIds.current.set(id, event.created_at!);
+ },
+ [opts?.klass]
+ );
+
+ const handleEose = () => {
+ storeInstance.setEose();
+ };
+
+ const handleClosed = () => {
+ storeInstance.setSubscription(undefined);
+ };
+
+ useEffect(() => {
+ if (!filters || filters.length === 0 || !ndk) return;
+
+ if (storeInstance.subscriptionRef) {
+ storeInstance.subscriptionRef.stop();
+ storeInstance.setSubscription(undefined);
+ }
+
+ const subscription = ndk.subscribe(filters, opts, relaySet, false);
+ subscription.on('event', handleEvent);
+ subscription.on('eose', handleEose);
+ subscription.on('closed', handleClosed);
+
+ storeInstance.setSubscription(subscription);
+ subscription.start();
+
+ return () => {
+ if (storeInstance.subscriptionRef) {
+ storeInstance.subscriptionRef.stop();
+ storeInstance.setSubscription(undefined);
+ }
+ eventIds.current.clear();
+ storeInstance.clearEvents();
+ };
+ }, [filters, opts, relaySet, ndk]);
+
+ return {
+ events: storeInstance.events,
+ eose: storeInstance.eose,
+ isSubscribed: storeInstance.isSubscribed,
+ subscription: storeInstance.subscriptionRef,
+ };
+};
diff --git a/ndk-mobile/src/hooks/user-profile.ts b/ndk-mobile/src/hooks/user-profile.ts
new file mode 100644
index 00000000..d61ad521
--- /dev/null
+++ b/ndk-mobile/src/hooks/user-profile.ts
@@ -0,0 +1,85 @@
+import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
+import { useNDK } from './ndk'
+
+export function useUserProfile(pubkey: string) {
+ const { ndk } = useNDK();
+
+ const user = useMemo(() => ndk?.getUser({ pubkey }), [ndk, pubkey]);
+
+ const fetchFromCache = useCallback(() => {
+ if (!ndk) return null;
+
+ const cachedProfile = ndk.cacheAdapter.fetchProfileSync?.(pubkey);
+ if (cachedProfile) cachedProfile.pubkey = pubkey;
+
+ return cachedProfile;
+ }, [ndk, pubkey]);
+
+ const [state, setState] = useState(() => {
+ if (!ndk || !pubkey) {
+ return { userProfile: null, user: null, loading: false, cache: false, pubkey };
+ }
+
+ const cachedProfile = fetchFromCache();
+ return {
+ userProfile: cachedProfile,
+ user,
+ loading: !cachedProfile,
+ cache: !!cachedProfile,
+ pubkey,
+ };
+ });
+
+ // Use a ref to track the currently active pubkey fetch
+ const activePubkeyRef = useRef(pubkey);
+
+ useEffect(() => {
+ activePubkeyRef.current = pubkey;
+
+ if (!ndk || !pubkey) return;
+
+ // If the cached profile is already loaded, don't fetch again
+ if (state.userProfile && state.userProfile.pubkey === pubkey) return;
+
+ const cachedProfile = fetchFromCache();
+ if (cachedProfile) {
+ setState({
+ userProfile: cachedProfile,
+ user,
+ loading: false,
+ cache: true,
+ pubkey,
+ });
+ return; // If we have a cached profile, no need to fetch remotely
+ }
+
+ setState((prev) => ({ ...prev, loading: true, userProfile: null, cache: false, pubkey }));
+
+ const fetchProfile = async () => {
+ try {
+ const profile = await user?.fetchProfile();
+ if (activePubkeyRef.current !== pubkey) {
+ return;
+ }
+ if (profile) profile.pubkey = pubkey;
+
+ setState({
+ userProfile: profile || null,
+ user,
+ loading: false,
+ cache: false,
+ pubkey,
+ });
+ } catch (error) {
+ console.error(`Error fetching user profile for ${pubkey}:`, error);
+ if (activePubkeyRef.current === pubkey) {
+ setState((prev) => ({ ...prev, loading: false }));
+ }
+ }
+ };
+
+ fetchProfile();
+ }, [ndk, pubkey, user, fetchFromCache]);
+
+ return state;
+}
\ No newline at end of file
diff --git a/ndk-mobile/src/index.ts b/ndk-mobile/src/index.ts
new file mode 100644
index 00000000..5ac2fc89
--- /dev/null
+++ b/ndk-mobile/src/index.ts
@@ -0,0 +1,13 @@
+import '@bacons/text-decoder/install';
+import 'react-native-get-random-values';
+
+export * from './hooks';
+export * from './context';
+export * from './providers';
+export * from './cache-adapter/sqlite';
+export * from './components';
+
+export * from '@nostr-dev-kit/ndk';
+import NDK from '@nostr-dev-kit/ndk';
+
+export default NDK;
\ No newline at end of file
diff --git a/ndk-mobile/src/providers/index.ts b/ndk-mobile/src/providers/index.ts
new file mode 100644
index 00000000..4a64a434
--- /dev/null
+++ b/ndk-mobile/src/providers/index.ts
@@ -0,0 +1,2 @@
+export * from './ndk';
+export * from './session';
\ No newline at end of file
diff --git a/ndk-mobile/src/providers/ndk/index.tsx b/ndk-mobile/src/providers/ndk/index.tsx
new file mode 100644
index 00000000..811f380a
--- /dev/null
+++ b/ndk-mobile/src/providers/ndk/index.tsx
@@ -0,0 +1,118 @@
+import React, { PropsWithChildren, useEffect, useRef, useState } from 'react';
+import NDK, { NDKConstructorParams, NDKEvent, NDKSigner, NDKUser } from '@nostr-dev-kit/ndk';
+import 'react-native-get-random-values';
+import '@bacons/text-decoder/install';
+import NDKContext from '../../context/ndk';
+import * as SecureStore from 'expo-secure-store';
+import { withPayload } from './signers';
+
+export interface UnpublishedEventEntry {
+ event: NDKEvent;
+ relays?: string[];
+ lastTryAt?: number;
+}
+
+const NDKProvider = ({
+ children,
+ connect = true,
+ ...opts
+}: PropsWithChildren<
+ NDKConstructorParams & {
+ connect?: boolean;
+ }
+>) => {
+ const ndk = useRef(new NDK({ ...opts }));
+ const [currentUser, setCurrentUser] = useState(null);
+ const [unpublishedEvents, setUnpublishedEvents] = useState>(new Map());
+ const [cacheInitialized, setCacheInitialized] = useState(opts?.cacheAdapter ? false : null);
+
+ if (!ndk.current.cacheAdapter?.ready) {
+ ndk.current.cacheAdapter?.onReady(() => {
+ setCacheInitialized(true);
+ });
+ }
+
+ useEffect(() => {
+ ndk.current.cacheAdapter?.getUnpublishedEvents?.().then((entries) => {
+ const e = new Map();
+ entries.forEach((entry) => {
+ e.set(entry.event.id, entry);
+ });
+ setUnpublishedEvents(e);
+ });
+ }, []);
+
+ if (connect) {
+ ndk.current.connect();
+ }
+
+ ndk.current.on('event:publish-failed', (event: NDKEvent) => {
+ if (unpublishedEvents.has(event.id)) return;
+ unpublishedEvents.set(event.id, { event });
+ setUnpublishedEvents(unpublishedEvents);
+ event.once('published', () => {
+ unpublishedEvents.delete(event.id);
+ setUnpublishedEvents(unpublishedEvents);
+ });
+ });
+
+ useEffect(() => {
+ const storePayload = SecureStore.getItem('key');
+
+ if (storePayload) {
+ loginWithPayload(storePayload, { save: false });
+ }
+ }, []);
+
+ async function loginWithPayload(payload: string, opts?: { save?: boolean }) {
+ const signer = withPayload(ndk.current, payload);
+ await login(signer);
+ if (!ndk.current.signer) return;
+
+ if (opts?.save) {
+ SecureStore.setItemAsync('key', payload);
+ }
+ }
+
+ async function login(promise: Promise) {
+ promise
+ .then((signer) => {
+ ndk.current.signer = signer ?? undefined;
+
+ if (signer) {
+ signer.user().then(setCurrentUser);
+ } else {
+ setCurrentUser(null);
+ }
+ })
+ .catch((e) => {
+ console.log('error in login, removing signer', ndk.current.signer, e);
+ ndk.current.signer = undefined;
+ });
+ }
+
+ async function logout() {
+ ndk.current.signer = undefined;
+
+ setCurrentUser(null);
+
+ SecureStore.deleteItemAsync('key');
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export { NDKProvider };
diff --git a/ndk-mobile/src/providers/ndk/signers/index.ts b/ndk-mobile/src/providers/ndk/signers/index.ts
new file mode 100644
index 00000000..dd719da0
--- /dev/null
+++ b/ndk-mobile/src/providers/ndk/signers/index.ts
@@ -0,0 +1,4 @@
+export * from './pk';
+export * from './nip07';
+export * from './nip46';
+export * from './nip55';
diff --git a/ndk-mobile/src/providers/ndk/signers/nip07.ts b/ndk-mobile/src/providers/ndk/signers/nip07.ts
new file mode 100644
index 00000000..11b58999
--- /dev/null
+++ b/ndk-mobile/src/providers/ndk/signers/nip07.ts
@@ -0,0 +1,14 @@
+import { NDKNip07Signer, NDKUser } from '@nostr-dev-kit/ndk';
+
+export async function loginWithNip07() {
+ try {
+ const signer = new NDKNip07Signer();
+ return signer.user().then(async (user: NDKUser) => {
+ if (user.npub) {
+ return { user: user, npub: user.npub, signer: signer };
+ }
+ });
+ } catch (e) {
+ throw e;
+ }
+}
diff --git a/ndk-mobile/src/providers/ndk/signers/nip46.ts b/ndk-mobile/src/providers/ndk/signers/nip46.ts
new file mode 100644
index 00000000..2d0e7a34
--- /dev/null
+++ b/ndk-mobile/src/providers/ndk/signers/nip46.ts
@@ -0,0 +1,16 @@
+import NDK, { NDKPrivateKeySigner, NDKNip46Signer, NDKSigner } from '@nostr-dev-kit/ndk';
+
+export async function withNip46(ndk: NDK, token: string, sk?: string): Promise {
+ let localSigner = NDKPrivateKeySigner.generate();
+ if (sk) {
+ localSigner = new NDKPrivateKeySigner(sk);
+ }
+
+ const signer = new NDKNip46Signer(ndk, token, localSigner);
+
+ return new Promise((resolve, reject) => {
+ signer.blockUntilReady().then(() => {
+ resolve(signer);
+ }).catch(reject);
+ });
+}
diff --git a/ndk-mobile/src/providers/ndk/signers/nip55.ts b/ndk-mobile/src/providers/ndk/signers/nip55.ts
new file mode 100644
index 00000000..dfea5bb9
--- /dev/null
+++ b/ndk-mobile/src/providers/ndk/signers/nip55.ts
@@ -0,0 +1,324 @@
+import debug from "debug";
+
+import {
+ type NostrEvent,
+ Hexpubkey,
+ NDKUser,
+ DEFAULT_ENCRYPTION_SCHEME,
+ ENCRYPTION_SCHEMES,
+ type NDKSigner,
+ NDKRelay,
+} from "@nostr-dev-kit/ndk";
+import * as IntentLauncher from "expo-intent-launcher";
+
+type Nip04QueueItem = {
+ type: "encrypt" | "decrypt";
+ counterpartyHexpubkey: string;
+ value: string;
+ resolve: (value: string) => void;
+ reject: (reason?: Error) => void;
+};
+
+type Nip55RelayMap = {
+ [key: string]: {
+ read: boolean;
+ write: boolean;
+ };
+};
+
+/**
+ * NDKNip55Signer implements the NDKSigner interface for signing Nostr events
+ * with a NIP-55 compatible android mobile client.
+ */
+export class NDKNip55Signer implements NDKSigner {
+ private _userPromise: Promise | undefined;
+ public nip04Queue: Nip04QueueItem[] = [];
+ private nip04Processing = false;
+ private debug: debug.Debugger;
+ private waitTimeout: number;
+
+ /**
+ * @param waitTimeout - The timeout in milliseconds to wait for the NIP-55 to become available
+ */
+ public constructor(waitTimeout: number = 1000) {
+ this.debug = debug("ndk:nip55");
+ this.waitTimeout = waitTimeout;
+ }
+
+ public async blockUntilReady(): Promise {
+ // TODO
+ // Set the type to be a Hexpubkey or npub
+ let npub = await this.getPublicKey();
+
+ // TODO
+ // Also add check to to see if an external signer is installed
+ if (!npub) {
+ // TODO
+ // Handle gracefully instead of crashing
+ // If unable to obtain pubkey, error out
+ throw new Error("Unable to obtain pubkey from external signer");
+ }
+
+ // TODO
+ // Convert npub to hexpubkey if necessary
+ // return new NDKUser({ pubkey: pubkey });
+ return new NDKUser({ npub: npub });
+ }
+
+ // TODO
+ // Test this method
+ /**
+ * Getter for the user property.
+ * @returns The NDKUser instance.
+ */
+ public async user(): Promise {
+ if (!this._userPromise) {
+ this._userPromise = this.blockUntilReady();
+ }
+
+ return this._userPromise;
+ }
+
+ // TODO
+ // Implement these methods
+ /**
+ * Signs the given Nostr event.
+ * @param event - The Nostr event to be signed.
+ * @returns The signature of the signed event.
+ * @throws Error if the NIP-07 is not available on the window object.
+ */
+ public async sign(event: NostrEvent): Promise {
+ await this.waitForExtension();
+
+ const signedEvent = await window.nostr!.signEvent(event);
+ return signedEvent.sig;
+ }
+
+ // TODO import NDK type, ndk?: NDK
+ public async relays(ndk?: any): Promise {
+ await this.waitForExtension();
+
+ const relays = (await window.nostr!.getRelays?.()) || {};
+
+ const activeRelays = [];
+ for (const url of Object.keys(relays)) {
+ // Currently only respects relays that are both readable and writable.
+ if (relays[url].read && relays[url].write) {
+ activeRelays.push(url);
+ }
+ }
+ return activeRelays.map((url) => new NDKRelay(url, ndk?.relayAuthDefaultPolicy, ndk));
+ }
+
+ public async encrypt(
+ recipient: NDKUser,
+ value: string,
+ type: ENCRYPTION_SCHEMES = DEFAULT_ENCRYPTION_SCHEME
+ ): Promise {
+ if (type === "nip44") {
+ return this.nip44Encrypt(recipient, value);
+ } else {
+ return this.nip04Encrypt(recipient, value);
+ }
+ }
+
+ public async decrypt(
+ sender: NDKUser,
+ value: string,
+ type: ENCRYPTION_SCHEMES = DEFAULT_ENCRYPTION_SCHEME
+ ): Promise {
+ if (type === "nip44") {
+ return this.nip44Decrypt(sender, value);
+ } else {
+ return this.nip04Decrypt(sender, value);
+ }
+ }
+
+ public async nip44Encrypt(recipient: NDKUser, value: string): Promise {
+ await this.waitForExtension();
+ return await this.nip44.encrypt(recipient.pubkey, value);
+ }
+
+ get nip44(): Nip44 {
+ if (!window.nostr?.nip44) {
+ throw new Error("NIP-44 not supported by your browser extension");
+ }
+
+ return window.nostr.nip44;
+ }
+
+ public async nip44Decrypt(sender: NDKUser, value: string): Promise {
+ await this.waitForExtension();
+ return await this.nip44.decrypt(sender.pubkey, value);
+ }
+
+ public async nip04Encrypt(recipient: NDKUser, value: string): Promise {
+ await this.waitForExtension();
+
+ const recipientHexPubKey = recipient.pubkey;
+ return this.queueNip04("encrypt", recipientHexPubKey, value);
+ }
+
+ public async nip04Decrypt(sender: NDKUser, value: string): Promise {
+ await this.waitForExtension();
+
+ const senderHexPubKey = sender.pubkey;
+ return this.queueNip04("decrypt", senderHexPubKey, value);
+ }
+
+ private async queueNip04(
+ type: "encrypt" | "decrypt",
+ counterpartyHexpubkey: string,
+ value: string
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ this.nip04Queue.push({
+ type,
+ counterpartyHexpubkey,
+ value,
+ resolve,
+ reject,
+ });
+
+ if (!this.nip04Processing) {
+ this.processNip04Queue();
+ }
+ });
+ }
+
+ private async processNip04Queue(item?: Nip04QueueItem, retries = 0): Promise {
+ if (!item && this.nip04Queue.length === 0) {
+ this.nip04Processing = false;
+ return;
+ }
+
+ this.nip04Processing = true;
+ const { type, counterpartyHexpubkey, value, resolve, reject } =
+ item || this.nip04Queue.shift()!;
+
+ try {
+ let result;
+
+ if (type === "encrypt") {
+ result = await window.nostr!.nip04!.encrypt(counterpartyHexpubkey, value);
+ } else {
+ result = await window.nostr!.nip04!.decrypt(counterpartyHexpubkey, value);
+ }
+
+ resolve(result);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (error: any) {
+ // retry a few times if the call is already executing
+ if (error.message && error.message.includes("call already executing")) {
+ if (retries < 5) {
+ this.debug("Retrying encryption queue item", {
+ type,
+ counterpartyHexpubkey,
+ value,
+ retries,
+ });
+ setTimeout(() => {
+ this.processNip04Queue(item, retries + 1);
+ }, 50 * retries);
+
+ return;
+ }
+ }
+ reject(error);
+ }
+
+ this.processNip04Queue();
+ }
+
+ private waitForExtension(): Promise {
+ return new Promise((resolve, reject) => {
+ if (window.nostr) {
+ resolve();
+ return;
+ }
+
+ let timerId: NodeJS.Timeout | number;
+
+ // Create an interval to repeatedly check for window.nostr
+ const intervalId = setInterval(() => {
+ if (window.nostr) {
+ clearTimeout(timerId as number);
+ clearInterval(intervalId);
+ resolve();
+ }
+ }, 100);
+
+ // Set a timer to reject the promise if window.nostr is not available within the timeout
+ timerId = setTimeout(() => {
+ clearInterval(intervalId);
+ reject(new Error("NIP-07 extension not available"));
+ }, this.waitTimeout);
+ });
+ }
+
+ // TODO
+ // Add timeout like in the waitForExtension
+ // Update string type to be hexpubkey
+ private async getPublicKey(): Promise {
+ try {
+ const permissions = [
+ { permission: "sign_event", id: 22242 },
+ { permission: "nip04_encrypt" },
+ { permission: "nip04_decrypt" },
+ { permission: "nip44_encrypt" },
+ { permission: "nip44_decrypt" },
+ { permission: "decrypt_zap_event" },
+ ];
+
+ const result = await IntentLauncher.startActivityAsync("android.intent.action.VIEW", {
+ category: "android.intent.category.BROWSABLE",
+ data: "nostrsigner:",
+ extra: {
+ package: "com.greenart7c3.nostrsigner", // TODO Detect and specify a general app package
+ permissions: JSON.stringify(permissions),
+ type: "get_public_key",
+ },
+ });
+
+ // TODO
+ // Handle Canceled, Error, and maybe FirstUser results from expo-intent-launcher
+ // If the result was successful, handle the response
+ if (result.resultCode === -1) {
+ const resultExtraObj = result.extra;
+ console.log("Signer result:", resultExtraObj);
+ // TODO
+ // Fix this by defining a type
+ // @ts-ignore
+ return resultExtraObj.result;
+ } else {
+ // If the result code indicates the user rejected the request
+ console.log("Sign request rejected");
+ return "";
+ }
+ } catch (error) {
+ // If there is an error launching the intent, handle the failure
+ console.error("Error getting the public key:", error);
+ return "";
+ }
+ }
+}
+
+type Nip44 = {
+ encrypt: (recipient: Hexpubkey, value: string) => Promise;
+ decrypt: (sender: Hexpubkey, value: string) => Promise;
+};
+
+declare global {
+ interface Window {
+ nostr?: {
+ getPublicKey(): Promise;
+ signEvent(event: NostrEvent): Promise<{ sig: string }>;
+ getRelays?: () => Promise;
+ nip04?: {
+ encrypt(recipientHexPubKey: string, value: string): Promise;
+ decrypt(senderHexPubKey: string, value: string): Promise;
+ };
+ nip44?: Nip44;
+ };
+ }
+}
diff --git a/ndk-mobile/src/providers/ndk/signers/pk.ts b/ndk-mobile/src/providers/ndk/signers/pk.ts
new file mode 100644
index 00000000..cab9bfa5
--- /dev/null
+++ b/ndk-mobile/src/providers/ndk/signers/pk.ts
@@ -0,0 +1,12 @@
+import NDK, { NDKNip46Signer, NDKPrivateKeySigner, NDKSigner, NDKUser } from '@nostr-dev-kit/ndk';
+import { withNip46 } from './nip46';
+
+export async function withPrivateKey(key: string): Promise {
+ return new NDKPrivateKeySigner(key);
+}
+
+export async function withPayload(ndk: NDK, payload: string): Promise {
+ if (payload.startsWith('nsec1')) return withPrivateKey(payload);
+
+ return withNip46(ndk, payload);
+}
diff --git a/ndk-mobile/src/providers/session/index.tsx b/ndk-mobile/src/providers/session/index.tsx
new file mode 100644
index 00000000..3ebec72a
--- /dev/null
+++ b/ndk-mobile/src/providers/session/index.tsx
@@ -0,0 +1,273 @@
+import React from 'react';
+import NDKSessionContext from '../../context/session';
+import { NDKEventWithFrom } from '../../hooks';
+import { useNDK } from '../../hooks/ndk';
+import NDK, { NDKEvent, NDKEventId, NDKFilter, NDKKind, NDKRelay, NDKSubscription, NDKSubscriptionCacheUsage, NostrEvent } from '@nostr-dev-kit/ndk';
+import { PropsWithChildren, useEffect } from 'react';
+import { useSessionStore } from '../../stores/session';
+import { NDKCashuWallet, NDKNutzapMonitor, NDKNWCWallet, NDKWallet, NDKWalletTypes } from '@nostr-dev-kit/ndk-wallet';
+import { useWalletStore } from '../../stores/wallet';
+
+type SettingsStore = {
+ get: (key: string) => Promise;
+ set: (key: string, value: string) => Promise;
+ delete: (key: string) => Promise;
+}
+
+/**
+ * Options for the NDKSessionProvider
+ *
+ * @param follows - Whether to subscribe to follow events
+ * @param muteList - Whether to subscribe to mute list events
+ * @param wallet - Whether to subscribe to wallet events
+ * @param settingsStore - A store for storing and retrieving configuration values
+ * @param kinds - A map of kinds to wrap with a custom wrapper
+ */
+interface NDKSessionProviderProps {
+ follows?: boolean;
+ muteList?: boolean;
+ wallet?: boolean
+ settingsStore?: SettingsStore;
+ kinds?: Map }>;
+}
+
+const NDKSessionProvider = ({ children, ...opts }: PropsWithChildren) => {
+ const { ndk, currentUser } = useNDK();
+ const { setFollows, setMuteList, addEvent } = useSessionStore();
+ const walletStore = useWalletStore();
+ let sub: NDKSubscription | undefined;
+ let knownEventIds = new Set();
+ let followEvent: NDKEvent | undefined;
+ const balances = useWalletStore((state) => state.balances);
+ const setBalances = useWalletStore((state) => state.setBalances);
+ const setNutzapMonitor = useWalletStore((state) => state.setNutzapMonitor);
+ const processFollowEvent = (event: NDKEvent, relay: NDKRelay) => {
+ if (followEvent && followEvent.created_at! > event.created_at!) return;
+
+ const pubkeys = new Set(event.tags.filter((tag) => tag[0] === 'p' && !!tag[1]).map((tag) => tag[1]));
+ setFollows(Array.from(pubkeys));
+ followEvent = event;
+ };
+
+ const processMuteListEvent = (event: NDKEvent, relay: NDKRelay) => {
+ setMuteList(event);
+ };
+
+ const processCashuWalletEvent = (event: NDKEvent) => {
+ addEvent(NDKKind.CashuWallet, event);
+ };
+
+ const handleEvent = (event: NDKEvent, relay: NDKRelay) => {
+ if (knownEventIds.has(event.id)) return;
+ knownEventIds.add(event.id);
+ const kind = event.kind!;
+
+ switch (kind) {
+ case 3:
+ return processFollowEvent(event, relay);
+ case NDKKind.MuteList:
+ return processMuteListEvent(event, relay);
+ case NDKKind.CashuWallet:
+ return processCashuWalletEvent(event);
+ default:
+ const entry = opts.kinds!.get(kind);
+ if (entry?.wrapper) {
+ event = entry.wrapper.from(event);
+ }
+ addEvent(kind, event);
+ }
+ };
+
+ /**
+ * Set the active wallet
+ * @param wallet - The wallet to set
+ * @param save - Whether to store this setting locally
+ */
+ const setActiveWallet = (
+ wallet: NDKWallet,
+ save = true
+ ) => {
+ ndk.wallet = wallet;
+
+ const updateBalance = () => {
+ if (!wallet) return;
+ console.log('Updating balance from balance_updated event')
+ setBalances(wallet.balance());
+ }
+
+ if (wallet) {
+ wallet.on("ready", () => {
+ console.log('Updating balance from ready event')
+ setBalances(wallet.balance());
+ });
+
+ wallet.on('balance_updated', () => {
+ updateBalance();
+ });
+
+ if (wallet instanceof NDKCashuWallet) {
+ wallet.start({
+ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
+ });
+ const monitor = new NDKNutzapMonitor(ndk, currentUser);
+ monitor.addWallet(wallet);
+ monitor.on('seen', (zap) => {
+ console.log('zap seen', zap.rawEvent());
+ });
+ monitor.on('redeem', (zap) => {
+ console.log('zap redeemed', zap.rawEvent());
+ });
+ setNutzapMonitor(monitor);
+
+ monitor.start();
+ }
+ }
+
+ walletStore.setActiveWallet(wallet);
+ if (wallet) updateBalance();
+ else {
+ setBalances([]);
+ }
+
+ if (save && opts.settingsStore) {
+ persistWalletConfiguration(wallet, opts.settingsStore);
+ }
+ }
+
+ useEffect(() => {
+ if (!ndk || !currentUser) return;
+ if (sub) {
+ sub.stop();
+ }
+
+ let filters: NDKFilter[] = [];
+
+ filters.push({ kinds: [], authors: [currentUser.pubkey] });
+
+ if (opts.follows) filters[0].kinds!.push(3);
+ if (opts.muteList) filters[0].kinds!.push(NDKKind.MuteList);
+ if (opts.wallet) filters[0].kinds!.push(NDKKind.CashuWallet);
+ if (opts.kinds) filters[0].kinds!.push(...opts.kinds.keys());
+
+ if (opts.settingsStore && !ndk.wallet) {
+ loadWallet(ndk, opts.settingsStore, (wallet) => setActiveWallet(wallet, false));
+ }
+
+ if (filters[0].kinds!.length > 0) {
+ sub = ndk.subscribe(filters, { closeOnEose: false }, undefined, false);
+ sub.on('event', handleEvent);
+ sub.start();
+ }
+ }, [ndk, opts.follows, opts.muteList, opts.settingsStore, currentUser]);
+
+ return (
+ state.follows),
+ events: useSessionStore((state) => state.events),
+ muteList: useSessionStore((state) => state.muteList),
+ mutePubkey: useSessionStore((state) => state.mutePubkey),
+ ...walletStore,
+ balances,
+ setActiveWallet
+ }}>
+ {children}
+
+ );
+};
+
+function walletPayload(wallet: NDKWallet) {
+ if (wallet instanceof NDKNWCWallet) {
+ return wallet.pairingCode;
+ } else if (wallet instanceof NDKCashuWallet) {
+ return wallet.event.rawEvent();
+ }
+}
+
+/**
+ * Persist the wallet configuration
+ * @param wallet - The wallet to persist
+ * @param settingsStore - The settings store to use
+ */
+function persistWalletConfiguration(wallet: NDKWallet, settingsStore: SettingsStore) {
+ if (!wallet) {
+ settingsStore.delete('wallet');
+ return;
+ }
+
+ const payload = walletPayload(wallet);
+ if (!payload) {
+ alert('Failed to persist wallet configuration!');
+ return;
+ }
+
+ const type = wallet.type;
+ settingsStore.set('wallet', JSON.stringify({ type, payload }));
+}
+
+async function loadWallet(ndk: NDK, settingsStore: SettingsStore, setActiveWallet: (wallet: NDKWallet | null) => void) {
+ const walletConfig = await settingsStore.get('wallet');
+ if (!walletConfig) return;
+
+ const loadNWCWallet = (pairingCode: string) => {
+ const wallet = new NDKNWCWallet(ndk);
+ wallet.initWithPairingCode(pairingCode).then(() => {
+ setActiveWallet(wallet);
+ });
+ }
+
+ const loadNIP60Wallet = async (payload: NostrEvent) => {
+ try {
+ // Load the cached event
+ const event = new NDKEvent(ndk, payload);
+ const wallet = await NDKCashuWallet.from(event);
+ setActiveWallet(wallet);
+
+ const relaySet = wallet.relaySet;
+
+ // Load remotely
+ const freshEvent = await ndk.fetchEvent(event.encode(), { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }, relaySet);
+ if (!freshEvent) {
+ console.log("Refreshing the event came back empty, has the wallet been deleted?")
+ setActiveWallet(null);
+ return null;
+ }
+
+ if (freshEvent.hasTag('deleted')) {
+ alert('This wallet has been deleted');
+ setActiveWallet(null);
+ return null;
+ } else if (freshEvent.created_at! > event.created_at!) {
+ const wallet = await NDKCashuWallet.from(freshEvent);
+ alert('This wallet has been updated');
+ setActiveWallet(wallet);
+
+ // update the cache
+ persistWalletConfiguration(wallet, settingsStore);
+
+ return wallet;
+ }
+
+ return wallet;
+ } catch (e) {
+ console.error('Error activating wallet', e);
+ console.log(payload)
+ }
+ }
+
+ try {
+ const { type, payload } = JSON.parse(walletConfig);
+ if (type === 'nwc') {
+ loadNWCWallet(payload);
+ } else if (type === 'nip-60') {
+ loadNIP60Wallet(payload);
+ } else {
+ alert('Unknown wallet type: ' + type);
+ }
+ } catch (e) {
+ alert('Failed to load wallet configuration');
+ settingsStore.delete('wallet');
+ }
+}
+
+export { NDKSessionProvider };
diff --git a/ndk-mobile/src/stores/session.ts b/ndk-mobile/src/stores/session.ts
new file mode 100644
index 00000000..897d03f3
--- /dev/null
+++ b/ndk-mobile/src/stores/session.ts
@@ -0,0 +1,75 @@
+import { create } from 'zustand'
+import { NDKEvent, NDKKind, Hexpubkey, NDKList } from '@nostr-dev-kit/ndk'
+
+interface SessionState {
+ follows: string[] | undefined
+ muteListEvent: NDKEvent | undefined
+ muteList: Set
+ events: Map
+
+ setFollows: (follows: string[]) => void
+ setMuteList: (muteList: NDKEvent) => void
+ setEvents: (kind: NDKKind, events: NDKEvent[]) => void
+ mutePubkey: (pubkey: Hexpubkey) => void
+
+ addEvent: (kind: NDKKind, event: NDKEvent) => void
+}
+
+export const useSessionStore = create((set) => ({
+ follows: undefined,
+
+ muteList: new Set(),
+ muteListEvent: undefined,
+ mutePubkey: (pubkey: Hexpubkey) => set((state) => {
+ state.muteList.add(pubkey);
+ console.log('muting user', pubkey);
+ if (state.muteListEvent) {
+ console.log('publishing mute list event');
+ state.muteListEvent.tags.push(['p', pubkey]);
+ state.muteListEvent.publishReplaceable();
+ } else {
+ console.log('no mute list event, creating one');
+ }
+ return state;
+ }),
+
+ events: new Map(),
+ setFollows: (follows) => set({ follows }),
+ setMuteList: (muteList: NDKEvent) => {
+ set((state) => {
+ console.log('setting mute list', muteList.created_at, state.muteListEvent?.created_at);
+ if (state.muteListEvent && state.muteListEvent.created_at >= muteList.created_at) {
+ return state
+ }
+
+ const pubkeys = new Set(muteList.tags.filter((tag) => tag[0] === 'p' && !!tag[1]).map((tag) => tag[1]));
+ console.log('have a mute list of ', pubkeys.size, 'users');
+ return { muteList: pubkeys, muteListEvent: NDKList.from(muteList) }
+ })
+ },
+ setEvents: (kind, events) => set((state) => {
+ const newEvents = new Map(state.events)
+ newEvents.set(kind, events)
+ return { events: newEvents }
+ }),
+ addEvent: (kind, event) => set((state) => {
+ const newEvents = new Map(state.events)
+ let existing = newEvents.get(kind) || []
+
+ // safety check for replaceable events
+ if (event.isReplaceable()) {
+ const existingEvent = existing.find((e) => e.dTag === event.dTag)
+ if (existingEvent) {
+ if (existingEvent.created_at >= event.created_at) {
+ return state
+ } else {
+ // remove the existing event
+ existing = existing.filter((e) => e.id !== existingEvent.id)
+ }
+ }
+ }
+
+ newEvents.set(kind, [...existing, event])
+ return { events: newEvents }
+ })
+}))
diff --git a/ndk-mobile/src/stores/wallet.ts b/ndk-mobile/src/stores/wallet.ts
new file mode 100644
index 00000000..cdc8cdb9
--- /dev/null
+++ b/ndk-mobile/src/stores/wallet.ts
@@ -0,0 +1,27 @@
+import { NDKNutzapMonitor, NDKWallet, NDKWalletBalance } from "@nostr-dev-kit/ndk-wallet"
+import { create } from "zustand"
+
+interface WalletState {
+ activeWallet: NDKWallet | undefined
+ setActiveWallet: (wallet: NDKWallet) => void
+
+ balances: NDKWalletBalance[],
+ setBalances: (balances: NDKWalletBalance[]) => void
+
+ nutzapMonitor: NDKNutzapMonitor | undefined
+ setNutzapMonitor: (monitor: NDKNutzapMonitor) => void
+}
+
+export const useWalletStore = create((set) => ({
+ activeWallet: undefined,
+ setActiveWallet: (wallet: NDKWallet) => set({ activeWallet: wallet }),
+
+ balances: [],
+ setBalances: (balances) => {
+ console.log('Setting balances to:', balances);
+ set({ balances });
+ },
+
+ nutzapMonitor: undefined,
+ setNutzapMonitor: (monitor: NDKNutzapMonitor) => set({ nutzapMonitor: monitor }),
+}))
\ No newline at end of file
diff --git a/ndk-mobile/tsconfig.json b/ndk-mobile/tsconfig.json
new file mode 100644
index 00000000..1e6152b0
--- /dev/null
+++ b/ndk-mobile/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "types": ["node", "react-native"],
+ "target": "esnext",
+ "skipLibCheck": true,
+ "module": "commonjs",
+ "strict": false,
+ "jsx": "react",
+ "declaration": true,
+ "esModuleInterop": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["node_modules"],
+ "paths": {
+ "@/*": ["src/*"]
+ }
+}
diff --git a/ndk-svelte-components/.eslintrc.cjs b/ndk-svelte-components/.eslintrc.cjs
new file mode 100644
index 00000000..64aacdc8
--- /dev/null
+++ b/ndk-svelte-components/.eslintrc.cjs
@@ -0,0 +1,32 @@
+module.exports = {
+ root: true,
+ extends: [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:svelte/recommended",
+ "prettier",
+ "plugin:storybook/recommended",
+ ],
+ parser: "@typescript-eslint/parser",
+ plugins: ["@typescript-eslint"],
+ parserOptions: {
+ sourceType: "module",
+ ecmaVersion: 2020,
+ extraFileExtensions: [".svelte"],
+ },
+ env: {
+ browser: true,
+ es2017: true,
+ node: true,
+ },
+ overrides: [
+ {
+ files: ["*.svelte"],
+ parser: "svelte-eslint-parser",
+ parserOptions: {
+ parser: "@typescript-eslint/parser",
+ },
+ },
+ ],
+ ignorePatterns: ["dist/", ".storybook/"],
+};
diff --git a/ndk-svelte-components/.gitignore b/ndk-svelte-components/.gitignore
new file mode 100644
index 00000000..5dbd1b22
--- /dev/null
+++ b/ndk-svelte-components/.gitignore
@@ -0,0 +1,14 @@
+.DS_Store
+node_modules
+**/build
+**/dist
+**/.svelte-kit
+**/package
+.env
+.env.*
+!.env.example
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+pnpm-lock.yaml
+justfile
+storybook-static
diff --git a/ndk-svelte-components/.npmrc b/ndk-svelte-components/.npmrc
new file mode 100644
index 00000000..4045e675
--- /dev/null
+++ b/ndk-svelte-components/.npmrc
@@ -0,0 +1,5 @@
+engine-strict=true
+resolution-mode=highest
+uto-install-peers=true
+legacy-peer-deps=true
+node-linker=hoisted
\ No newline at end of file
diff --git a/ndk-svelte-components/.prettierignore b/ndk-svelte-components/.prettierignore
new file mode 100644
index 00000000..4af38e24
--- /dev/null
+++ b/ndk-svelte-components/.prettierignore
@@ -0,0 +1,3 @@
+dist
+.svelte-kit
+.storybook
diff --git a/ndk-svelte-components/.storybook/main.ts b/ndk-svelte-components/.storybook/main.ts
new file mode 100644
index 00000000..61ac19bc
--- /dev/null
+++ b/ndk-svelte-components/.storybook/main.ts
@@ -0,0 +1,26 @@
+import type { StorybookConfig } from "@storybook/sveltekit";
+import { join, dirname } from "path";
+
+/**
+ * This function is used to resolve the absolute path of a package.
+ * It is needed in projects that use Yarn PnP or are set up within a monorepo.
+ */
+function getAbsolutePath(value: string): any {
+ return dirname(require.resolve(join(value, "package.json")));
+}
+const config: StorybookConfig = {
+ stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
+ addons: [
+ getAbsolutePath("@storybook/addon-links"),
+ getAbsolutePath("@storybook/addon-essentials"),
+ getAbsolutePath("@storybook/addon-interactions"),
+ getAbsolutePath("@storybook/addon-mdx-gfm"),
+ ],
+ framework: {
+ name: "@storybook/sveltekit",
+ },
+ docs: {
+ autodocs: "tag",
+ },
+};
+export default config;
diff --git a/ndk-svelte-components/.storybook/preview.ts b/ndk-svelte-components/.storybook/preview.ts
new file mode 100644
index 00000000..999117f3
--- /dev/null
+++ b/ndk-svelte-components/.storybook/preview.ts
@@ -0,0 +1,16 @@
+import type { Preview } from "@storybook/svelte";
+import "../src/styles/global.css";
+
+const preview: Preview = {
+ parameters: {
+ actions: { argTypesRegex: "^on[A-Z].*" },
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/,
+ },
+ },
+ },
+};
+
+export default preview;
diff --git a/ndk-svelte-components/CHANGELOG.md b/ndk-svelte-components/CHANGELOG.md
new file mode 100644
index 00000000..3fb07a64
--- /dev/null
+++ b/ndk-svelte-components/CHANGELOG.md
@@ -0,0 +1,380 @@
+# @nostr-dev-kit/ndk-svelte-components
+
+## 2.3.5
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.7
+
+## 2.3.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.6
+
+## 2.3.3
+
+### Patch Changes
+
+- Updated dependencies [5939a3e]
+- Updated dependencies
+- Updated dependencies [f2a0cce]
+ - @nostr-dev-kit/ndk@2.10.5
+
+## 2.3.2
+
+### Patch Changes
+
+- Updated dependencies [5bed70c]
+- Updated dependencies [873ad4a]
+ - @nostr-dev-kit/ndk@2.10.4
+
+## 2.3.1
+
+### Patch Changes
+
+- Updated dependencies [0fc66c5]
+ - @nostr-dev-kit/ndk@2.10.3
+
+## 2.3.0
+
+### Minor Changes
+
+- Refactor how markdown parsing is done to use a more modular approach with user-supplied svelte component rendering support
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.2
+
+## 2.2.20
+
+### Patch Changes
+
+- 5ac3ce8: use urlFactory to generate URLs on rendering engine
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [722345b]
+ - @nostr-dev-kit/ndk@2.10.1
+
+## 2.2.19
+
+### Patch Changes
+
+- Updated dependencies [ec83ddc]
+- Updated dependencies [18c55bb]
+- Updated dependencies
+- Updated dependencies [18c55bb]
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies [3029124]
+ - @nostr-dev-kit/ndk@2.10.0
+
+## 2.2.18
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.9.1
+
+## 2.2.17
+
+### Patch Changes
+
+- 548f4d8: add optimistic updates
+- Updated dependencies [94018b4]
+- Updated dependencies [548f4d8]
+ - @nostr-dev-kit/ndk@2.9.0
+
+## 2.2.16
+
+### Patch Changes
+
+- Updated dependencies [0af033f]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.8.2
+
+## 2.2.15
+
+### Patch Changes
+
+- Updated dependencies [e40312b]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.8.1
+
+## 2.2.14
+
+### Patch Changes
+
+- truncate npub by default
+- 1e854f7: better event content rendering and mentions
+- Updated dependencies [91d873c]
+- Updated dependencies [6fd9ddc]
+- Updated dependencies [0b8f331]
+- Updated dependencies
+- Updated dependencies [f2898ad]
+- Updated dependencies [9b92cd9]
+- Updated dependencies
+- Updated dependencies [6814f0c]
+- Updated dependencies [89b5b3f]
+- Updated dependencies [9b92cd9]
+- Updated dependencies [27b10cc]
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies [ed7cdc4]
+ - @nostr-dev-kit/ndk@2.8.0
+
+## 2.2.13
+
+### Patch Changes
+
+- fix display of markdown
+
+## 2.2.12
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.7.1
+
+## 2.2.11
+
+### Patch Changes
+
+- improve markdown rendering
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.7.0
+
+## 2.2.10
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.6.1
+
+## 2.2.9
+
+### Patch Changes
+
+- c2db3c1: Add Delete to EventCardDropdownMenu
+- Updated dependencies
+- Updated dependencies [c2db3c1]
+- Updated dependencies
+- Updated dependencies [c2db3c1]
+- Updated dependencies [c2db3c1]
+ - @nostr-dev-kit/ndk@2.6.0
+
+## 2.2.8
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.5.1
+
+## 2.2.7
+
+### Patch Changes
+
+- Updated dependencies [e08fc74]
+ - @nostr-dev-kit/ndk@2.5.0
+
+## 2.2.6
+
+### Patch Changes
+
+- 55011e3: fix rendering issues on RelayList
+- Updated dependencies [111c1ea]
+- Updated dependencies [5c0ae51]
+- Updated dependencies [6f5ea49]
+- Updated dependencies [3738d39]
+- Updated dependencies [d22239a]
+ - @nostr-dev-kit/ndk@2.4.1
+
+## 2.2.5
+
+### Patch Changes
+
+- Updated dependencies [b9bbf1d]
+ - @nostr-dev-kit/ndk@2.4.0
+
+## 2.2.4
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [885b6c2]
+- Updated dependencies [5666d56]
+ - @nostr-dev-kit/ndk@2.3.3
+
+## 2.2.3
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [4628481]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.3.2
+
+## 2.2.2
+
+### Patch Changes
+
+- Updated dependencies [ece965f]
+ - @nostr-dev-kit/ndk@2.3.1
+
+## 2.2.1
+
+### Patch Changes
+
+- Updated dependencies [54cec78]
+- Updated dependencies [ef61d83]
+- Updated dependencies [98b77dd]
+- Updated dependencies [46b0c77]
+- Updated dependencies [082e243]
+ - @nostr-dev-kit/ndk@2.3.0
+
+## 2.1.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.2.0
+
+## 2.1.1
+
+### Patch Changes
+
+- 3bcbb59: allow Avatar and Name to work without an ndk instance
+- Updated dependencies [180d774]
+- Updated dependencies [7f00c40]
+ - @nostr-dev-kit/ndk@2.1.3
+
+## 2.1.0
+
+### Minor Changes
+
+- Improve how multiple media images can be handled
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.2
+
+## 2.0.8
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.1
+
+## 2.0.7
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.0
+
+## 2.0.6
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.6
+
+## 2.0.5
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [d45d962]
+ - @nostr-dev-kit/ndk@2.0.5
+
+## 2.0.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.4
+
+## 2.0.3
+
+### Patch Changes
+
+- 375e62f: Display NIP-23 titles
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.3
+
+## 2.0.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.2
+
+## 1.4.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.0
+
+## 1.4.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.2
+
+## 1.4.0
+
+### Minor Changes
+
+- New EventThread component to finally and easily properly display threads and replies.
+
+## 1.3.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.1
+
+## 1.3.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.0
+
+## 1.3.2
+
+### Patch Changes
+
+- Updated dependencies [b3561af]
+ - @nostr-dev-kit/ndk@1.3.2
+
+## 1.3.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.3.1
+
+## 1.2.4
+
+### Patch Changes
+
+- Updated dependencies [88df10a]
+- Updated dependencies [c225094]
+- Updated dependencies [cf4a648]
+- Updated dependencies [3946078]
+- Updated dependencies [3440768]
+ - @nostr-dev-kit/ndk@1.3.0
diff --git a/ndk-svelte-components/LICENSE b/ndk-svelte-components/LICENSE
new file mode 100644
index 00000000..2a6ea523
--- /dev/null
+++ b/ndk-svelte-components/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Pablo Fernandez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ndk-svelte-components/README.md b/ndk-svelte-components/README.md
new file mode 100644
index 00000000..6ae8fcec
--- /dev/null
+++ b/ndk-svelte-components/README.md
@@ -0,0 +1,69 @@
+# ndk-svelte-components
+
+Reusable Svelte components.
+
+## Installation
+
+```
+# With npm
+npm add @nostr-dev-kit/ndk-svelte-components
+
+# With pnpm
+pnpm add @nostr-dev-kit/ndk-svelte-components
+
+# With yarn
+yarn install @nostr-dev-kit/ndk-svelte-components
+```
+
+## Storybook
+
+This project uses `pnpm` to manage dependencies.
+
+```
+git clone https://github.com/nostr-dev-kit/ndk-svelte-components
+cd ndk-svelte-components
+pnpm i
+pnpm run storybook
+```
+
+# Components
+
+## Event
+
+### ``
+
+Displays a card with formatted event content.
+
+### ``
+
+Formats the content of an event for an `EventCard`. Currently supports:
+
+- kind 1 events
+- Embedded kind 1 events in other kind 1 events
+
+## User
+
+### ``
+
+Displays a user's avatar
+
+### ``
+
+Displays a user's name
+
+## Relay
+
+### ` `
+
+Displays a list of relays the NDK instance is connected to, along with information about active subscriptions and connectivity stats.
+
+![](images/relay-list.png)
+
+# License
+
+MIT
+
+# Author
+
+- pablof7z ([npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft](https://primal.net/pablof7z))
+- jeffg ([npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc](https://primal.net/jeffg))
diff --git a/ndk-svelte-components/images/relay-list.png b/ndk-svelte-components/images/relay-list.png
new file mode 100644
index 00000000..4151551a
Binary files /dev/null and b/ndk-svelte-components/images/relay-list.png differ
diff --git a/ndk-svelte-components/package.json b/ndk-svelte-components/package.json
new file mode 100644
index 00000000..5f1e26f8
--- /dev/null
+++ b/ndk-svelte-components/package.json
@@ -0,0 +1,97 @@
+{
+ "name": "@nostr-dev-kit/ndk-svelte-components",
+ "version": "2.3.5",
+ "description": "",
+ "license": "MIT",
+ "type": "module",
+ "svelte": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "svelte": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "!dist/**/*.test.*",
+ "!dist/**/*.spec.*"
+ ],
+ "scripts": {
+ "dev": "vite build --watch",
+ "build": "vite build && pnpm run package",
+ "preview": "vite preview",
+ "package": "svelte-kit sync && svelte-package && publint",
+ "prepublishOnly": "pnpm run package",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write .",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
+ },
+ "devDependencies": {
+ "@nostr-dev-kit/eslint-config-custom": "workspace:*",
+ "@nostr-dev-kit/ndk-cache-dexie": "workspace:*",
+ "@nostr-dev-kit/ndk-svelte": "workspace:*",
+ "@nostr-dev-kit/tailwind-config": "workspace:*",
+ "@nostr-dev-kit/tsconfig": "workspace:*",
+ "@storybook/addon-essentials": "^7.6.20",
+ "@storybook/addon-interactions": "^7.6.20",
+ "@storybook/addon-links": "^7.6.20",
+ "@storybook/addon-mdx-gfm": "^7.6.20",
+ "@storybook/blocks": "^7.6.20",
+ "@storybook/cli": "^7.6.20",
+ "@storybook/manager-api": "^7.6.20",
+ "@storybook/svelte": "^7.6.20",
+ "@storybook/sveltekit": "^7.6.20",
+ "@storybook/testing-library": "^0.2.2",
+ "@storybook/theming": "^7.6.20",
+ "@sveltejs/adapter-auto": "^2.1.1",
+ "@sveltejs/kit": "^2.6.4",
+ "@sveltejs/package": "^2.3.5",
+ "@types/ramda": "^0.29.12",
+ "@types/sanitize-html": "^2.13.0",
+ "@typescript-eslint/eslint-plugin": "^6.21.0",
+ "@typescript-eslint/parser": "^6.21.0",
+ "autoprefixer": "^10.4.20",
+ "eslint-plugin-storybook": "0.6.14",
+ "mdsvex": "^0.12.3",
+ "postcss": "^8.4.47",
+ "prettier": "^3.3.3",
+ "prettier-plugin-svelte": "^3.2.6",
+ "publint": "^0.2.11",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "storybook": "^7.6.20",
+ "svelte": "^4.2.19",
+ "svelte-check": "^4.0.4",
+ "tailwindcss": "^3.4.12",
+ "tslib": "^2.7.0",
+ "vite": "^5.4.8"
+ },
+ "dependencies": {
+ "@nostr-dev-kit/ndk": "workspace:*",
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
+ "classnames": "^2.5.1",
+ "lucide-svelte": "^0.451.0",
+ "marked": "^14.1.2",
+ "marked-footnote": "^1.2.4",
+ "marked-gfm-heading-id": "^4.1.0",
+ "marked-mangle": "^1.1.9",
+ "nostr-tools": "^2.7.2",
+ "ramda": "^0.29.1",
+ "rehype-autolink-headings": "^7.1.0",
+ "rehype-slug": "^6.0.0",
+ "sanitize-html": "^2.13.0",
+ "svelte-markdown": "^0.4.1",
+ "svelte-preprocess": "^5.1.4",
+ "svelte-time": "^0.9.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.2.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/ndk-svelte-components/postcss.config.cjs b/ndk-svelte-components/postcss.config.cjs
new file mode 100644
index 00000000..307e497d
--- /dev/null
+++ b/ndk-svelte-components/postcss.config.cjs
@@ -0,0 +1,7 @@
+module.exports = {
+ plugins: {
+ "tailwindcss/nesting": {},
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/ndk-svelte-components/src/app.html b/ndk-svelte-components/src/app.html
new file mode 100644
index 00000000..b50d0a54
--- /dev/null
+++ b/ndk-svelte-components/src/app.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+
+
+ %sveltekit.body%
+
+
diff --git a/ndk-svelte-components/src/lib/event/ElementConnector.svelte b/ndk-svelte-components/src/lib/event/ElementConnector.svelte
new file mode 100644
index 00000000..fbf79733
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/ElementConnector.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/event/EventCard.svelte b/ndk-svelte-components/src/lib/event/EventCard.svelte
new file mode 100644
index 00000000..34cd04fa
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/EventCard.svelte
@@ -0,0 +1,109 @@
+
+
+{#await eventPromise then}
+
+
+ {#if !$$slots.default}
+
+ {:else}
+
+ {/if}
+
+{:catch error}
+
+{/await}
+
+
diff --git a/ndk-svelte-components/src/lib/event/EventCardDropdownMenu.svelte b/ndk-svelte-components/src/lib/event/EventCardDropdownMenu.svelte
new file mode 100644
index 00000000..9b4b773b
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/EventCardDropdownMenu.svelte
@@ -0,0 +1,87 @@
+
+
+
+ { open = !open}}>
+
+
+
+ {#if open}
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/event/EventThread.svelte b/ndk-svelte-components/src/lib/event/EventThread.svelte
new file mode 100644
index 00000000..fa9ec263
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/EventThread.svelte
@@ -0,0 +1,217 @@
+
+
+
+ {#if !skipEvent}
+
+ {#each Array.from(threadIds.values()).sort(sortThread) as event (event.id)}
+
+ {/each}
+
+ {/if}
+
+ {#if replyIds.size > 0 || $extraItems}
+
+ {#each $extraItems??[] as item (item.props.key)}
+
+
+
+ {/each}
+
+ {#each Array.from(replyIds.values()).sort(sortReplies) as reply (reply.id)}
+ {#if !whitelistPubkeys || !useWhitelist || whitelistPubkeys.has(reply.pubkey)}
+
+
+
+ {:else if whitelistPubkeys && useWhitelist && !whitelistPubkeys.has(reply.pubkey)}
+
+
+ This reply was hidden
+ useWhitelist = false}>Show anyway
+
+
+ {/if}
+ {/each}
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/event/content/EventContent.svelte b/ndk-svelte-components/src/lib/event/content/EventContent.svelte
new file mode 100644
index 00000000..e9411834
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/EventContent.svelte
@@ -0,0 +1,94 @@
+
+
+{#if event}
+ {#if event.kind === 1}
+
+ {:else if event.kind === 40}
+
+ {:else if event.kind === 1063}
+
+ {:else if event.kind === 1985}
+
+ {:else if event.kind === 9802}
+
+ {:else if event.kind === 30000}
+
+ {:else if event.kind === 30001}
+
+ {:else if markdownKinds.includes(event.kind)}
+
+ {:else}
+
+ {/if}
+{/if}
diff --git a/ndk-svelte-components/src/lib/event/content/Kind1.svelte b/ndk-svelte-components/src/lib/event/content/Kind1.svelte
new file mode 100644
index 00000000..e9a11e78
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/Kind1.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+ {#each groupedContent as { type, value }, i}
+ {#if type === NEWLINE}
+
+ {:else if type === TOPIC}
+
+ {:else if type === LINK}
+
+ {:else if type === LINKCOLLECTION}
+ {#if mediaCollectionComponent}
+ v.value.url)} />
+ {:else}
+
+ {#each value as {type: _type, value: _value}, j}
+
+ {/each}
+
+ {/if}
+ {:else if type.match(/^nostr:np(rofile|ub)$/)}
+
+ {:else if type.startsWith('nostr:') && showMedia && isStartOrEnd(i) && value.id !== anchorId}
+
+ {:else if type.startsWith('nostr:')}
+
+ {:else}
+ {value}
+ {/if}
+ {' '}
+ {/each}
+
+
+
diff --git a/ndk-svelte-components/src/lib/event/content/Kind1063.svelte b/ndk-svelte-components/src/lib/event/content/Kind1063.svelte
new file mode 100644
index 00000000..4dfab226
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/Kind1063.svelte
@@ -0,0 +1,81 @@
+
+
+
+
File metadata
+
Description: {event.content}
+
+
MIME type: {mimeType}
+
File size: {size}
+
Dimensions: {dim}
+ {#if showMedia && SUPPORTED_IMAGE_TYPES.includes(mimeType)}
+
File preview:
+
+
+
+ {/if}
+ {#if showMedia && SUPPORTED_VIDEO_TYPES.includes(mimeType)}
+
File preview:
+
+ {/if}
+
+
+
diff --git a/ndk-svelte-components/src/lib/event/content/Kind30000.svelte b/ndk-svelte-components/src/lib/event/content/Kind30000.svelte
new file mode 100644
index 00000000..8b11f1c5
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/Kind30000.svelte
@@ -0,0 +1,29 @@
+
+
+{#each list.items as tag (tag[1])}
+
+{/each}
+
+
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/event/content/Kind30001.svelte b/ndk-svelte-components/src/lib/event/content/Kind30001.svelte
new file mode 100644
index 00000000..997fd0a3
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/Kind30001.svelte
@@ -0,0 +1,27 @@
+
+
+{#each list.items as tag (tag[1])}
+
+
+
+{/each}
+
+
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/event/content/Kind30023.svelte b/ndk-svelte-components/src/lib/event/content/Kind30023.svelte
new file mode 100644
index 00000000..86c33fc5
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/Kind30023.svelte
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
diff --git a/ndk-svelte-components/src/lib/event/content/Kind9802.svelte b/ndk-svelte-components/src/lib/event/content/Kind9802.svelte
new file mode 100644
index 00000000..ae9960b6
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/Kind9802.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
+ {@html sanitizeHtml(context || event.content)}
+
+
+
+{#if ref}
+
+
+
+{/if}
diff --git a/ndk-svelte-components/src/lib/event/content/NoteContentLink.svelte b/ndk-svelte-components/src/lib/event/content/NoteContentLink.svelte
new file mode 100644
index 00000000..1160cc62
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/NoteContentLink.svelte
@@ -0,0 +1,30 @@
+
+
+{#if showMedia && value.isMedia}
+ {#if !!isImage(value.url)}
+
+ {:else if isVideo(value.url)}
+
+
+ {:else if isAudio(value.url)}
+
+ {value.url.replace(/https?:\/\/(www\.)?/, "")}
+
+ {:else}
+
+ {value.url.replace(/https?:\/\/(www\.)?/, "")}
+
+ {/if}
+{:else}
+
+ {value.url.replace(/https?:\/\/(www\.)?/, "")}
+
+{/if}
diff --git a/ndk-svelte-components/src/lib/event/content/NoteContentNewline.svelte b/ndk-svelte-components/src/lib/event/content/NoteContentNewline.svelte
new file mode 100644
index 00000000..f66bdb11
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/NoteContentNewline.svelte
@@ -0,0 +1,8 @@
+
+
+
+{#each value as _}
+
+{/each}
diff --git a/ndk-svelte-components/src/lib/event/content/NoteContentPerson.svelte b/ndk-svelte-components/src/lib/event/content/NoteContentPerson.svelte
new file mode 100644
index 00000000..18f5a24d
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/NoteContentPerson.svelte
@@ -0,0 +1,25 @@
+
+
+
+ @
+
diff --git a/ndk-svelte-components/src/lib/event/content/NoteContentTopic.svelte b/ndk-svelte-components/src/lib/event/content/NoteContentTopic.svelte
new file mode 100644
index 00000000..39cfd007
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/NoteContentTopic.svelte
@@ -0,0 +1,8 @@
+
+
+#{value}
diff --git a/ndk-svelte-components/src/lib/event/content/RenderHtml.svelte b/ndk-svelte-components/src/lib/event/content/RenderHtml.svelte
new file mode 100644
index 00000000..dfe53313
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/RenderHtml.svelte
@@ -0,0 +1,69 @@
+
+
+
+ {@html renderedContent}
+
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/event/content/renderer/hashtag.svelte b/ndk-svelte-components/src/lib/event/content/renderer/hashtag.svelte
new file mode 100644
index 00000000..9bd02eed
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/renderer/hashtag.svelte
@@ -0,0 +1,5 @@
+
+
+{hashtag}
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/event/content/renderer/index.ts b/ndk-svelte-components/src/lib/event/content/renderer/index.ts
new file mode 100644
index 00000000..1c8e4ab5
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/renderer/index.ts
@@ -0,0 +1,12 @@
+import Link from "./link.svelte";
+import Hashtag from "./hashtag.svelte";
+import Mention from "./mention.svelte";
+import NostrEvent from "./nostr-event.svelte";
+import markedFootnote from "marked-footnote";
+
+export default {
+ link: Link,
+ hashtag: Hashtag,
+ mention: Mention,
+ nostrEvent: NostrEvent,
+}
diff --git a/ndk-svelte-components/src/lib/event/content/renderer/link.svelte b/ndk-svelte-components/src/lib/event/content/renderer/link.svelte
new file mode 100644
index 00000000..34efd6f1
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/renderer/link.svelte
@@ -0,0 +1,29 @@
+
+
+{#if showMedia}
+ {#if !!isImage(href)}
+
+ {:else if isVideo(href)}
+
+
+ {:else if isAudio(href)}
+
+ {text}
+
+ {:else}
+
+ {text}
+
+ {/if}
+{:else}
+
+ {text}
+
+{/if}
diff --git a/ndk-svelte-components/src/lib/event/content/renderer/mention.svelte b/ndk-svelte-components/src/lib/event/content/renderer/mention.svelte
new file mode 100644
index 00000000..129447f1
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/renderer/mention.svelte
@@ -0,0 +1,15 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/event/content/renderer/nostr-event.svelte b/ndk-svelte-components/src/lib/event/content/renderer/nostr-event.svelte
new file mode 100644
index 00000000..c78237c1
--- /dev/null
+++ b/ndk-svelte-components/src/lib/event/content/renderer/nostr-event.svelte
@@ -0,0 +1,19 @@
+
+
+{#if event}
+
+{/if}
diff --git a/ndk-svelte-components/src/lib/index.ts b/ndk-svelte-components/src/lib/index.ts
new file mode 100644
index 00000000..0af3c569
--- /dev/null
+++ b/ndk-svelte-components/src/lib/index.ts
@@ -0,0 +1,32 @@
+import EventCard from "./event/EventCard.svelte";
+import EventCardDropdownMenu from "./event/EventCardDropdownMenu.svelte";
+import EventContent from "./event/content/EventContent.svelte";
+import RelayList from "./relay/RelayList.svelte";
+import Avatar from "./user/Avatar.svelte";
+import Name from "./user/Name.svelte";
+import Nip05 from "./user/Nip05.svelte";
+import UserCard from "./user/UserCard.svelte";
+import EventThread from "./event/EventThread.svelte";
+
+export * from "./utils";
+
+export type UrlType = "hashtag" | "mention";
+
+export type UrlFactory = (type: UrlType, value: string) => string;
+
+export {
+ // Event
+ EventContent,
+ EventCard,
+ EventCardDropdownMenu,
+ EventThread,
+
+ // User
+ Avatar,
+ Name,
+ Nip05,
+ UserCard,
+
+ // Relay
+ RelayList,
+};
diff --git a/ndk-svelte-components/src/lib/relay/RelayList.svelte b/ndk-svelte-components/src/lib/relay/RelayList.svelte
new file mode 100644
index 00000000..5f8ec133
--- /dev/null
+++ b/ndk-svelte-components/src/lib/relay/RelayList.svelte
@@ -0,0 +1,34 @@
+
+
+
+ {#each relays as relay}
+
+ {/each}
+
diff --git a/ndk-svelte-components/src/lib/relay/RelayListItem.svelte b/ndk-svelte-components/src/lib/relay/RelayListItem.svelte
new file mode 100644
index 00000000..deafc8fa
--- /dev/null
+++ b/ndk-svelte-components/src/lib/relay/RelayListItem.svelte
@@ -0,0 +1,208 @@
+
+
+
+ expanded = !expanded}
+ >
+ {#if relay.status === NDKRelayStatus.CONNECTING || relay.status === NDKRelayStatus.RECONNECTING}
+
+ {:else if relay.status === NDKRelayStatus.DISCONNECTED}
+
+ {:else if relay.status === NDKRelayStatus.CONNECTED}
+
+ {:else if relay.status === NDKRelayStatus.FLAPPING}
+
+ {:else if relay.status === NDKRelayStatus.AUTHENTICATING}
+
+ {/if}
+
+ {#if activeSubCount > 0}
+
+ {activeSubCount}
+ {activeSubCount === 1 ? 'subscription' : 'subscriptions'}
+
+ {/if}
+
+
+ {#if relay.connectionStats.attempts > 1 && relay.status !== NDKRelayStatus.CONNECTED}
+
+
+ Reconnection attempts: {relay.connectionStats.attempts}
+
+
+ {#if nextReconnectIn}
+
+ Next reconnect in
+ {nextReconnectIn} seconds
+
+ {/if}
+
+ {/if}
+
+ {#if notices.length > 0}
+
+ {#each notices as notice, i (i)}
+ {notice}
+ {/each}
+
+ {/if}
+
+ {#if expanded}
+
+ {/if}
+
+
+
+
diff --git a/ndk-svelte-components/src/lib/relay/RelayName.svelte b/ndk-svelte-components/src/lib/relay/RelayName.svelte
new file mode 100644
index 00000000..83ca82cc
--- /dev/null
+++ b/ndk-svelte-components/src/lib/relay/RelayName.svelte
@@ -0,0 +1,8 @@
+
+
+{formatRelayName(relay)}
diff --git a/ndk-svelte-components/src/lib/stores/ndk.ts b/ndk-svelte-components/src/lib/stores/ndk.ts
new file mode 100644
index 00000000..b2868fcd
--- /dev/null
+++ b/ndk-svelte-components/src/lib/stores/ndk.ts
@@ -0,0 +1,13 @@
+import NDK from "@nostr-dev-kit/ndk";
+import { writable } from "svelte/store";
+
+const _ndk = new NDK({
+ explicitRelayUrls: [
+ "wss://relay.f7z.io",
+ "wss://nos.lol",
+ "wss://relay.damus.io",
+ "wss://relay.snort.social",
+ ],
+});
+
+export default writable(_ndk);
diff --git a/ndk-svelte-components/src/lib/user/Avatar.svelte b/ndk-svelte-components/src/lib/user/Avatar.svelte
new file mode 100644
index 00000000..a4021c7e
--- /dev/null
+++ b/ndk-svelte-components/src/lib/user/Avatar.svelte
@@ -0,0 +1,97 @@
+
+
+{#await fetchProfilePromise}
+
+{:then userProfile}
+
+{:catch error}
+
+{/await}
+
+
diff --git a/ndk-svelte-components/src/lib/user/Name.svelte b/ndk-svelte-components/src/lib/user/Name.svelte
new file mode 100644
index 00000000..7d8b200f
--- /dev/null
+++ b/ndk-svelte-components/src/lib/user/Name.svelte
@@ -0,0 +1,82 @@
+
+
+
+ {#if userProfile}
+ {chooseNameFromDisplay(userProfile)}
+ {:else if user}
+ {#await user.fetchProfile({ closeOnEose: true, groupable: true, groupableDelay: 200 })}
+ {chooseNameFromDisplay()}
+ {:then}
+ {chooseNameFromDisplay(user.profile)}
+ {:catch error}
+
+ {truncatedNpub}
+
+ {/await}
+ {/if}
+
diff --git a/ndk-svelte-components/src/lib/user/Nip05.svelte b/ndk-svelte-components/src/lib/user/Nip05.svelte
new file mode 100644
index 00000000..e6b74ea3
--- /dev/null
+++ b/ndk-svelte-components/src/lib/user/Nip05.svelte
@@ -0,0 +1,99 @@
+
+
+
+ {#await fetchAndValidate()}
+
+
+
+ {:then validationResponse}
+
+
+
+ {validationResponse.userProfile?.nip05 ? prettifyNip05(validationResponse.userProfile.nip05, nip05MaxLength) : ""}
+
+
+ {:catch}
+
+
+ Error loading user profile
+
+ {/await}
+
diff --git a/ndk-svelte-components/src/lib/user/Npub.svelte b/ndk-svelte-components/src/lib/user/Npub.svelte
new file mode 100644
index 00000000..390b465d
--- /dev/null
+++ b/ndk-svelte-components/src/lib/user/Npub.svelte
@@ -0,0 +1,86 @@
+
+
+
+ {#if user && user.npub}
+
+ {npubMaxLength ? truncatedBech32(user.npub, npubMaxLength) : user.npub}
+
+
+
+ {#if checkVisible}
+
+
+
+ {/if}
+
+ {:else}
+ Error loading user
+ {/if}
+
+
+
diff --git a/ndk-svelte-components/src/lib/user/UserCard.svelte b/ndk-svelte-components/src/lib/user/UserCard.svelte
new file mode 100644
index 00000000..384303ed
--- /dev/null
+++ b/ndk-svelte-components/src/lib/user/UserCard.svelte
@@ -0,0 +1,110 @@
+
+
+{#await fetchProfilePromise}
+ Loading user...
+{:then userProfile}
+
+
+
+
+
+
+
{userProfile?.bio || userProfile?.about}
+
+
+{:catch}
+ Error fetching user
+{/await}
+
+
diff --git a/ndk-svelte-components/src/lib/utils/event/index.ts b/ndk-svelte-components/src/lib/utils/event/index.ts
new file mode 100644
index 00000000..bd2794d3
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/event/index.ts
@@ -0,0 +1,30 @@
+/**
+ * Format bytes as human-readable text.
+ *
+ * @param bytes Number of bytes.
+ * @param si True to use metric (SI) units, aka powers of 1000. False to use
+ * binary (IEC), aka powers of 1024.
+ * @param dp Number of decimal places to display.
+ *
+ * @return Formatted string.
+ */
+export function humanFileSize(bytes: number, si = false, dp = 1): string {
+ const thresh = si ? 1000 : 1024;
+
+ if (Math.abs(bytes) < thresh) {
+ return bytes + " B";
+ }
+
+ const units = si
+ ? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
+ : ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
+ let u = -1;
+ const r = 10 ** dp;
+
+ do {
+ bytes /= thresh;
+ ++u;
+ } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
+
+ return bytes.toFixed(dp) + " " + units[u];
+}
diff --git a/ndk-svelte-components/src/lib/utils/extensions/event.svelte b/ndk-svelte-components/src/lib/utils/extensions/event.svelte
new file mode 100644
index 00000000..c78237c1
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/extensions/event.svelte
@@ -0,0 +1,19 @@
+
+
+{#if event}
+
+{/if}
diff --git a/ndk-svelte-components/src/lib/utils/extensions/hashtag.svelte b/ndk-svelte-components/src/lib/utils/extensions/hashtag.svelte
new file mode 100644
index 00000000..9bd02eed
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/extensions/hashtag.svelte
@@ -0,0 +1,5 @@
+
+
+{hashtag}
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/utils/extensions/image.svelte b/ndk-svelte-components/src/lib/utils/extensions/image.svelte
new file mode 100644
index 00000000..17391abd
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/extensions/image.svelte
@@ -0,0 +1,10 @@
+
+
+{#if isImage($$props.href)}
+
+{:else}
+ {$$props.children??$$props.text}
+{/if}
diff --git a/ndk-svelte-components/src/lib/utils/extensions/mention.svelte b/ndk-svelte-components/src/lib/utils/extensions/mention.svelte
new file mode 100644
index 00000000..4da65f06
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/extensions/mention.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/ndk-svelte-components/src/lib/utils/index.ts b/ndk-svelte-components/src/lib/utils/index.ts
new file mode 100644
index 00000000..38920bc5
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/index.ts
@@ -0,0 +1,14 @@
+export * from "./relay";
+export * from "./user";
+
+export function truncatedBech32(bech32: string, length?: number): string {
+ return `${bech32.substring(0, length || 9)}...`;
+}
+
+export async function copyToClipboard(textToCopy: string | undefined) {
+ try {
+ await navigator.clipboard.writeText(textToCopy as string);
+ } catch (err) {
+ console.error("Failed to copy: ", err);
+ }
+}
diff --git a/ndk-svelte-components/src/lib/utils/markdown.ts b/ndk-svelte-components/src/lib/utils/markdown.ts
new file mode 100644
index 00000000..0b67d787
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/markdown.ts
@@ -0,0 +1,111 @@
+import { marked, type MarkedExtension, type TokenizerAndRendererExtension, type TokensList } from "marked";
+import { gfmHeadingId } from "marked-gfm-heading-id";
+import { mangle } from "marked-mangle";
+import markedFootnote from "marked-footnote";
+
+export const markdownToHtml = (
+ content: string,
+ extraMarkedExtensions?: MarkedExtension[]
+): TokensList => {
+ marked.use(mangle());
+ marked.use(gfmHeadingId());
+ marked.use(markedFootnote());
+
+ extraMarkedExtensions?.forEach((extension) => {
+ console.log('adding extension', extension)
+ marked.use(extension);
+ });
+
+ const mentionRegexp = /^(nostr:|@)npub1[a-zA-Z0-9]+/;
+ const eventRegexp = /^(nostr:|@)n(event|ote|addr)1[0-9a-zA-Z]+/;
+ const hashtagRegexp = /^#\w+/;
+
+ marked.use({
+ extensions: [{
+ name: "mention",
+ level: "inline",
+ start(src: string) {
+ return src.indexOf("nostr:npub1");
+ },
+ tokenizer(src: string, tokens) {
+ const match = mentionRegexp.exec(src);
+ if (!match) return;
+
+ const token = { type: 'mention', raw: match[0], npub: match[0].replace(/^(nostr:|@)/, '') };
+ return token;
+
+
+ // if (match.index > 0) {
+ // const text = src.slice(0, match.index);
+ // tokens.push({ type: 'text', raw: text, text });
+ // }
+
+ // const npub = match[0].replace(/^(nostr:|@)/, '');
+ // tokens.push({ type: 'nostrMention', raw: "nostr:" + npub, npub });
+
+ // // if there is more after the mention, add it as text
+ // if (match.index + match[0].length < src.length) {
+ // const text = src.slice(match.index + match[0].length);
+ // tokens.push({ type: 'text', raw: text, text });
+ // }
+
+ // return { type: 'block', raw: src, text: src, tokens };
+ }
+ }, {
+ name: "nostrEvent",
+ level: "inline",
+ start(src: string) {
+ return src.indexOf("nostr:note") ?? src.indexOf("nostr:nevent") ?? src.indexOf("nostr:naddr");
+ },
+ tokenizer(src: string, tokens) {
+ const match = eventRegexp.exec(src);
+ if (!match) return;
+
+ const token = { type: 'nostrEvent', raw: match[0], id: match[0].replace(/^(nostr:|@)/, '') };
+ return token;
+
+ // const match = eventRegexp.exec(src); // Use extracted event regex
+ // if (match) {
+ // const id = match[0].replace(/^(nostr:|@)/, '');
+
+ // // Add prefix as text token
+ // const prefix = src.slice(0, match.index);
+ // if (prefix) {
+ // tokens.push({ type: 'text', raw: prefix, text: prefix });
+ // }
+
+ // // Add the main event token
+ // tokens.push({ type: "nostrEvent", raw: match[0], id });
+
+ // // Add suffix as text token if there's more text after the match
+ // const suffix = src.slice(match.index + match[0].length);
+ // if (suffix) {
+ // tokens.push({ type: 'text', raw: suffix, text: suffix });
+ // }
+
+ // return { type: 'block', raw: src, text: src, tokens };
+ // }
+ }
+ }, {
+ name: 'hashtag',
+ level: 'inline',
+ start(src: string) {
+ return src.indexOf('#');
+ },
+ tokenizer(src: string, tokens) {
+ const match = hashtagRegexp.exec(src);
+ if (!match) return;
+
+ const token = { type: 'hashtag', raw: match[0], hashtag: match[0], tokens };
+ // this.lexer.inline(token.text, token.tokens);
+
+ return token;
+ }
+ }]
+ });
+
+ // marked.Lexer.lex(content)
+
+ const tokens = marked.lexer(content);
+ return tokens;
+};
diff --git a/ndk-svelte-components/src/lib/utils/notes.ts b/ndk-svelte-components/src/lib/utils/notes.ts
new file mode 100644
index 00000000..e6132251
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/notes.ts
@@ -0,0 +1,312 @@
+import { nip19 } from "nostr-tools";
+import { identity, last } from "ramda";
+
+export const NEWLINE = "newline";
+export const TEXT = "text";
+export const TOPIC = "topic";
+export const LINK = "link";
+export const LINKCOLLECTION = "link[]";
+export const HTML = "html";
+export const INVOICE = "invoice";
+export const NOSTR_NOTE = "nostr:note";
+export const NOSTR_NEVENT = "nostr:nevent";
+export const NOSTR_NPUB = "nostr:npub";
+export const NOSTR_NPROFILE = "nostr:nprofile";
+export const NOSTR_NADDR = "nostr:naddr";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const first = (list: any) => (list ? list[0] : undefined);
+
+export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, "");
+
+export const urlIsMedia = (url: string) =>
+ !url.match(/\.(apk|docx|xlsx|csv|dmg)/) && last(url.split("://"))?.includes("/");
+
+type ContentArgs = {
+ content: string;
+ tags?: Array<[string, string, string]>;
+ html?: boolean;
+};
+
+export type ParsedPart = {
+ type: string;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ value: any;
+};
+
+export const isEmbeddableMedia = (url: string) => isImage(url) || isVideo(url) || isAudio(url);
+
+export const isImage = (url: string) => url?.match(/^.*\.(jpg|jpeg|png|webp|gif|avif|svg)/gi);
+export const isVideo = (url: string) => url?.match(/^.*\.(mov|mkv|mp4|avi|m4v|webm)/gi);
+export const isAudio = (url: string) => url?.match(/^.*\.(ogg|mp3|wav)/gi);
+
+/**
+ * Groups content parts into link collections when they are consecutive media links
+ */
+export function groupContent(parts: ParsedPart[]): ParsedPart[] {
+ // if there are multiple consecutive links, group them together, but if
+ const result: ParsedPart[] = [];
+ let buffer: ParsedPart | undefined;
+
+ const popBuffer = () => {
+ if (buffer) {
+ if (buffer.value.length > 1) {
+ result.push(buffer);
+ } else {
+ // If there is only one link in the buffer, just push the link to the result
+ result.push({
+ type: LINK,
+ value: buffer.value[0].value,
+ });
+ }
+ buffer = undefined;
+ }
+ };
+
+ parts.forEach((part, index) => {
+ if (
+ part.type === LINK &&
+ (isImage(part.value.url) || isVideo(part.value.url) || isAudio(part.value.url))
+ ) {
+ if (!buffer) {
+ buffer = {
+ type: LINKCOLLECTION,
+ value: [],
+ };
+ }
+
+ buffer.value.push(part);
+ } else {
+ let nextPartsAreNoops: boolean | undefined = undefined;
+
+ for (const nextPart of parts.slice(index + 1)) {
+ const isNewline = nextPart.type === NEWLINE;
+ const isBlankText = nextPart.type === TEXT && nextPart.value.trim() === "";
+ const isLink = nextPart.type === LINK;
+
+ // This is a noop, keep checking the next part
+ if (isNewline || isBlankText) continue;
+
+ nextPartsAreNoops = isLink;
+
+ break;
+ }
+
+ if (nextPartsAreNoops === false) {
+ // we found a non-noop part after the current part
+ popBuffer();
+ result.push(part);
+ } else {
+ result.push(part);
+ }
+ }
+ });
+
+ popBuffer();
+
+ return result;
+}
+
+export const parseContent = ({ content, tags = [], html = false }: ContentArgs): ParsedPart[] => {
+ const result: ParsedPart[] = [];
+ let text = content.trim();
+ let buffer = "";
+
+ const parseNewline = () => {
+ if (html) return;
+ const newline = first(text.match(/^\n+/));
+
+ if (newline) {
+ return [NEWLINE, newline, newline];
+ }
+ };
+
+ const parseMention = () => {
+ // Convert legacy mentions to bech32 entities
+ const mentionMatch = text.match(/^#\[(\d+)\]/i);
+
+ if (mentionMatch) {
+ const i = parseInt(mentionMatch[1]);
+
+ if (tags[i]) {
+ const [tag, value, url] = tags[i];
+ const relays = [url].filter(identity);
+
+ let type, data, entity;
+ try {
+ if (tag === "p") {
+ type = "nprofile";
+ data = { pubkey: value, relays };
+ entity = nip19.nprofileEncode(data);
+ } else {
+ type = "nevent";
+ data = { id: value, relays, pubkey: null };
+ entity = nip19.neventEncode(data);
+ }
+ } catch {
+ /**/
+ }
+
+ return [`nostr:${type}`, mentionMatch[0], { ...data, entity }];
+ }
+ }
+ };
+
+ const parseTopic = () => {
+ const topic = first(text.match(/^#\w+/i));
+
+ // Skip numeric topics
+ if (topic && !topic.match(/^#\d+$/)) {
+ return [TOPIC, topic, topic.slice(1)];
+ }
+ };
+
+ const parseBech32 = () => {
+ const bech32 = first(
+ text.match(/^(web\+)?(nostr:)?\/?\/?n(event|ote|profile|pub|addr)1[\d\w]+/i)
+ );
+
+ if (bech32) {
+ try {
+ const entity = fromNostrURI(bech32);
+ const { type, data } = nip19.decode(entity) as { type: string; data: object };
+
+ let value = data;
+ if (type === "note") {
+ value = { id: data };
+ } else if (type === "npub") {
+ value = { pubkey: data };
+ }
+
+ return [`nostr:${type}`, bech32, { ...value, entity }];
+ } catch (e) {
+ console.log(e);
+ // pass
+ }
+ }
+ };
+
+ const parseInvoice = () => {
+ const invoice = first(text.match(/^ln(bc|url)[\d\w]{50,1000}/i));
+
+ if (invoice) {
+ return [INVOICE, invoice, invoice];
+ }
+ };
+
+ const parseUrl = () => {
+ const raw = first(text.match(/^([a-z+:]{2,30}:\/\/)?[^\s]+\.[a-z]{2,6}[^\s]*[^.!?,:\s]/gi));
+
+ // Skip url if it's just the end of a filepath
+ if (raw) {
+ const prev = last(result);
+
+ if (prev?.type === "text" && prev.value.endsWith("/")) {
+ return;
+ }
+
+ let url = raw;
+
+ // Skip ellipses and very short non-urls
+ if (url.match(/\.\./)) {
+ return;
+ }
+
+ if (!url.match("://")) {
+ url = "https://" + url;
+ }
+
+ return [LINK, raw, { url, isMedia: urlIsMedia(url) }];
+ }
+ };
+
+ const parseHtml = (): any[] | undefined => {
+ // Only parse out specific html tags
+ const raw = first(text.match(/^<(pre|code)>.*?<\/\1>/gis));
+
+ if (raw) {
+ return [HTML, raw, raw];
+ }
+ };
+
+ while (text) {
+ let part: any[] | undefined;
+
+ if (html) {
+ part = parseBech32() || parseMention() || parseTopic();
+ } else {
+ part =
+ parseHtml() ||
+ parseNewline() ||
+ parseMention() ||
+ parseTopic() ||
+ parseBech32() ||
+ parseUrl() ||
+ parseInvoice();
+ }
+
+ if (part) {
+ if (buffer) {
+ result.push({ type: "text", value: buffer });
+ buffer = "";
+ }
+
+ const [type, raw, value] = part;
+
+ result.push({ type, value });
+ text = text.slice(raw.length);
+ } else {
+ // Instead of going character by character and re-running all the above regular expressions
+ // a million times, try to match the next word and add it to the buffer
+ const match = first(text.match(/^[\w\d]+ ?/i)) || text[0];
+
+ buffer += match;
+ text = text.slice(match.length);
+ }
+ }
+
+ if (buffer) {
+ result.push({ type: TEXT, value: buffer });
+ }
+
+ return result;
+};
+
+export const truncateContent = (content, { showEntire, maxLength, showMedia = false }) => {
+ if (showEntire) {
+ return content;
+ }
+
+ let length = 0;
+ const result = [];
+ const truncateAt = maxLength * 0.6;
+
+ content.every((part, i) => {
+ const isText =
+ [TOPIC, TEXT].includes(part.type) || (part.type === LINK && !part.value.isMedia);
+ const isMedia =
+ part.type === INVOICE || part.type.startsWith("nostr:") || part.value.isMedia;
+
+ if (isText) {
+ length += part.value.length;
+ }
+
+ if (isMedia) {
+ length += showMedia ? maxLength / 3 : part.value.length;
+ }
+
+ result.push(part);
+
+ if (length > truncateAt && i < content.length - 1) {
+ if (isText || (isMedia && !showMedia)) {
+ result.push({ type: TEXT, value: "..." });
+ }
+
+ return false;
+ }
+
+ return true;
+ });
+
+ return result;
+};
diff --git a/ndk-svelte-components/src/lib/utils/relay/index.ts b/ndk-svelte-components/src/lib/utils/relay/index.ts
new file mode 100644
index 00000000..4a9d7b30
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/relay/index.ts
@@ -0,0 +1,20 @@
+import type { NDKRelay } from "@nostr-dev-kit/ndk";
+
+export function formatRelayName(relay: NDKRelay): string {
+ let name = relay.url;
+
+ // Some well known relays
+ switch (relay.url) {
+ case "wss://purplepag.es":
+ return "Purple Pages";
+ case "wss://relay.damus.io":
+ return "Damus relay";
+ case "wss://relay.snort.social":
+ return "Snort relay";
+ }
+
+ // strip protocol prefix
+ name = name.replace(/^(ws|wss):\/\//, "");
+
+ return name;
+}
diff --git a/ndk-svelte-components/src/lib/utils/user/index.ts b/ndk-svelte-components/src/lib/utils/user/index.ts
new file mode 100644
index 00000000..fd751888
--- /dev/null
+++ b/ndk-svelte-components/src/lib/utils/user/index.ts
@@ -0,0 +1,8 @@
+export function prettifyNip05(nip05: string, maxLength?: number | undefined): string {
+ const trimmedNip05: string = nip05.startsWith("_@") ? nip05.substring(2) : nip05;
+ if (maxLength) {
+ return trimmedNip05.slice(0, maxLength);
+ } else {
+ return trimmedNip05;
+ }
+}
diff --git a/ndk-svelte-components/src/routes/+page.svelte b/ndk-svelte-components/src/routes/+page.svelte
new file mode 100644
index 00000000..d7bb508b
--- /dev/null
+++ b/ndk-svelte-components/src/routes/+page.svelte
@@ -0,0 +1,6 @@
+What are you looking at?
+
+
+ If you got here by running npm run dev
you're doing it wrong. You should run
+ npm run storybook
instead.
+
diff --git a/ndk-svelte-components/src/stories/Introduction.mdx b/ndk-svelte-components/src/stories/Introduction.mdx
new file mode 100644
index 00000000..3086bbbe
--- /dev/null
+++ b/ndk-svelte-components/src/stories/Introduction.mdx
@@ -0,0 +1,21 @@
+import { Meta } from "@storybook/blocks";
+
+
+
+# NDK Svelte Component Library
+
+The goal of these components is to make it as easy as possible to develop Nostr apps using NDK and Svelte. Think of the components detailed in this Storybook as unstyled legos that you can use to build your Nostr apps without having to think about fetching data for common patterns.
+
+## Use the components in your app
+
+First, install the library using: `npm install @nostr-dev-kit/ndk-svelte-components`
+
+Then, simply import any of the components that you want to use: `import { Avatar, Name } from '@nostr-dev-kit/ndk-svelte-components'`
+
+### Styling components
+
+You can pass `style` or `class` attributes on any component to pass those through to the outermost `HTMLElement` rendered in a component. This allows you to style directly or to use a framework like [Tailwind](https://tailwindcss.com/).
+
+## Contribute
+
+Any and all contributions are welcome. You can find the code for [this repo on GitHub here](https://github.com/nostr-dev-kit/ndk-svelte-components).
diff --git a/ndk-svelte-components/src/stories/events/EventCard.stories.ts b/ndk-svelte-components/src/stories/events/EventCard.stories.ts
new file mode 100644
index 00000000..85a32ee0
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/EventCard.stories.ts
@@ -0,0 +1,63 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import EventCard from "../../lib/event/EventCard.svelte";
+
+/**
+ * Renders an event's card
+ */
+
+const meta = {
+ title: "Event/EventCard",
+ component: EventCard,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ id: {
+ control: { type: null },
+ type: { name: "other", value: "Event", required: true },
+ table: { type: { summary: "string" } },
+ description: "The event ID you want to render in hex or bech32 format",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] });
+await ndk.connect();
+
+const id = "note194n247lecqgcskk5rmmfgrapt4jx7ppq64xec0eca3s4ta3hwkrsex7pxa";
+
+const withEmbeddedNoteId =
+ "nevent1qqsrjpqwtmwy2aw0t745d6vdj6k267wjv5xjklek7ucr2pv65p2ydgspz9mhxue69uhkummnw3ezuamfdejj7qmsa3q";
+
+const withImage = "note1np37t0mgh0ucuujf7lm7wawz42d8krcwc95cng9090yglcltpk7sgat8rs";
+
+export const Kind1Event: Story = {
+ args: {
+ ndk,
+ id,
+ },
+};
+
+export const Kind1EventWithEmbeddedNote: Story = {
+ args: {
+ ndk,
+ id: withEmbeddedNoteId,
+ },
+};
+
+export const Kind1EventWithImage: Story = {
+ args: {
+ ndk,
+ id: withImage,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/EventCardDropdownMenu.stories.ts b/ndk-svelte-components/src/stories/events/EventCardDropdownMenu.stories.ts
new file mode 100644
index 00000000..5e3891d6
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/EventCardDropdownMenu.stories.ts
@@ -0,0 +1,46 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import EventCardDropdownMenu from "../../lib/event/EventCardDropdownMenu.svelte";
+
+/**
+ * Renders an event's card
+ */
+
+const meta = {
+ title: "Event/EventCardDropdownMenu",
+ component: EventCardDropdownMenu,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ event: {
+ control: { type: null },
+ type: { name: "other", value: "NDKEvent", required: true },
+ table: { type: { summary: "NDKEvent" } },
+ description: "The event you want to render",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] });
+await ndk.connect();
+
+const id = "note194n247lecqgcskk5rmmfgrapt4jx7ppq64xec0eca3s4ta3hwkrsex7pxa";
+const event = await ndk.fetchEvent(id);
+event.relay = undefined;
+
+export const Kind1Event: Story = {
+ args: {
+ ndk,
+ event,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/EventContent.stories.ts b/ndk-svelte-components/src/stories/events/EventContent.stories.ts
new file mode 100644
index 00000000..2cb34469
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/EventContent.stories.ts
@@ -0,0 +1,60 @@
+import NDK, { NDKArticle, type NDKEvent } from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import EventContent from "../../lib/event/content/EventContent.svelte";
+
+/**
+ * Renders an event's content
+ */
+
+const meta: Meta = {
+ title: "Event/EventContent",
+ component: EventContent,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ event: {
+ control: { type: null },
+ type: { name: "other", value: "Event", required: true },
+ table: { type: { summary: "NDKEvent" } },
+ description: "The event you want to render.",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://relay.nostr.band"] });
+ndk.connect();
+
+let event: NDKEvent;
+let article: NDKEvent;
+
+ndk.fetchEvent("note194n247lecqgcskk5rmmfgrapt4jx7ppq64xec0eca3s4ta3hwkrsex7pxa").then(
+ (e) => (event = e!)
+);
+
+article = await ndk.fetchEvent("naddr1qqxnzdejxyurxveexymnxwfeqgsg3nqnfvdxta2w7j9vc80nvegx85l2ghcya2u273jxu4sutt5eq7grqsqqqa286jzgn2")
+// article = await ndk.fetchEvent("naddr1qvzqqqr4gupzqkcpsw4kc03j906dg8rt8thes432z3yy0d6fj4phylz48xs3g437qy88wumn8ghj7mn0wvhxcmmv9uqq6vfhxg6rjd33x5cnvd33xcvvu6c4")
+article.relay = undefined;
+
+export const Kind1Event: Story = {
+ args: {
+ ndk,
+ event,
+ },
+};
+
+export const Kind30023Event: Story = {
+ args: {
+ ndk,
+ event: article,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/EventThread.stories.ts b/ndk-svelte-components/src/stories/events/EventThread.stories.ts
new file mode 100644
index 00000000..178c0e75
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/EventThread.stories.ts
@@ -0,0 +1,67 @@
+import NDK from "@nostr-dev-kit/ndk-svelte";
+import { NDKEvent } from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+import NDKCacheAdapterDexie from "@nostr-dev-kit/ndk-cache-dexie";
+
+import EventThread from "../../lib/event/EventThread.svelte";
+
+/**
+ * Render an event as a thread.
+ */
+
+const meta = {
+ title: "Event/EventThread",
+ component: EventThread,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ event: {
+ control: { type: null },
+ type: { name: "other", value: "Event", required: true },
+ table: { type: { summary: "NDKEvent" } },
+ description: "The root event of the thread.",
+ },
+ skipEvent: {
+ control: { type: "boolean" },
+ type: { name: "boolean", required: false },
+ table: { type: { summary: "boolean" } },
+ description: "Skip rendering the root event; just render the events tagging this event",
+ defaultValue: false,
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const cacheAdapter = new NDKCacheAdapterDexie({ dbName: "ndk-svelte-components-storybook" });
+
+const ndk = new NDK({
+ explicitRelayUrls: ["wss://nos.lol"],
+ enableOutboxModel: true,
+ cacheAdapter,
+});
+await ndk.connect();
+
+const event = new NDKEvent(ndk, {
+ id: "9dcccd571449f26bd8505eb88fdb9b11dbe123635db918e31304a1b5e462536f",
+ pubkey: "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
+ created_at: 1697028628,
+ kind: 1,
+ tags: [],
+ content: "The new note view in my new upcoming client handles threads quite nicely.",
+ sig: "afb176fb960c87ec9456e7275cadf3b444d2de3be447f1e62c5e802715120685ff464070b33dcdb264225c52cf617600280838950585db38203c59ac9bb6737c",
+});
+
+export const Thread: Story = {
+ args: {
+ ndk,
+ event,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/kinds/1.stories.ts b/ndk-svelte-components/src/stories/events/kinds/1.stories.ts
new file mode 100644
index 00000000..a882acf7
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/kinds/1.stories.ts
@@ -0,0 +1,49 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Kind1Component from "../../../lib/event/content/Kind1.svelte";
+
+/**
+ * Renders a Kind 1 event content
+ */
+
+const meta = {
+ title: "Event/Kinds/Kind 1 - Text note",
+ component: Kind1Component,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ event: {
+ control: { type: null },
+ type: { name: "other", value: "NDKEvent", required: true },
+ table: { type: { summary: "NDKEvent" } },
+ description: "The event you want to render",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] });
+await ndk.connect();
+
+const id =
+ "nevent1qqstuzqnw6czlugwszyj0z6gvffz8y27tsg4mfmnqnjqlj2vx5kac4cprpmhxue69uhhyetvv9ujumn0wd68yct5dyhxxmmdqgsx8zd7vjg70d5na8ek3m8g3lx3ghc8cp5d9sdm4epy0wd4aape6vsxrtmuk";
+const event = await ndk.fetchEvent(id);
+
+event.relay = undefined;
+event.onRelays = [];
+
+export const Kind1: Story = {
+ args: {
+ ndk,
+ event,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/kinds/1063.stories.ts b/ndk-svelte-components/src/stories/events/kinds/1063.stories.ts
new file mode 100644
index 00000000..119d735a
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/kinds/1063.stories.ts
@@ -0,0 +1,43 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Kind1063Component from "../../../lib/event/content/Kind1063.svelte";
+
+/**
+ * Renders a Kind 1063 metadata event
+ */
+
+const meta = {
+ title: "Event/Kinds/Kind 1063 - File metadata",
+ component: Kind1063Component,
+ tags: ["autodocs"],
+ argTypes: {
+ event: {
+ control: { type: null },
+ type: { name: "other", value: "NDKEvent", required: true },
+ description: "The event you want to render",
+ },
+ showMedia: {
+ control: { type: "boolean" },
+ type: { required: false },
+ description: "Whether or not to render media inline (images, video, audio)",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] });
+await ndk.connect();
+
+const id = "nevent1qqs2vrx4ffqyq42yge95v3rfyr5gqr9z3pqpe7j7dymlk4lv3pwse6qfcjqkn";
+const event = await ndk.fetchEvent(id);
+
+event.relay = undefined;
+
+export const Kind1063: Story = {
+ args: {
+ event,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/kinds/30023.stories.ts b/ndk-svelte-components/src/stories/events/kinds/30023.stories.ts
new file mode 100644
index 00000000..6100360f
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/kinds/30023.stories.ts
@@ -0,0 +1,53 @@
+import NDK from "@nostr-dev-kit/ndk";
+import { NDKArticle } from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Kind30023Component from "../../../lib/event/content/Kind30023.svelte";
+
+/**
+ * Renders a Kind 30023 long-form article
+ */
+
+const meta = {
+ title: "Event/Kinds/Kind 30023 - Long-form articles",
+ component: Kind30023Component,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ article: {
+ control: { type: null },
+ type: { name: "other", value: "NDKArticle", required: true },
+ table: { type: { summary: "NDKArticle" } },
+ description: "The article you want to render",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] });
+await ndk.connect();
+
+const id =
+ "naddr1qvzqqqr4gupzq6ksswfdrw4r7mlh49qfu2k9u4zrtpextk955kquvpna3r4rq9vyqyfhwumn8ghj7ur4wfcxcetsv9njuetn9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpzamhxue69uhhyetvv9ujumn0wd68ytnzv9hxgtcpz3mhxue69uhhyetvv9ukzcnvv5hx7un89uq3zamnwvaz7tmwdaehgu3wwa5kuef0qqv9yetkd9jhw6twvuk4yetkd9jhwuedd3cxjmncvv3euyg7";
+// Tony 'naddr1qqz82unvwvpzql6u9d8y3g8flm9x8frtz0xmsfyf7spq8xxkpgs8p2tge25p346aqvzqqqr4gupdy396';
+// Der Gigi 'naddr1qqxnzd3cxqmrzv3exgmr2wfeqgsxu35yyt0mwjjh8pcz4zprhxegz69t4wr9t74vk6zne58wzh0waycrqsqqqa28pjfdhz';
+const event = await ndk.fetchEvent(id);
+
+event.relay = undefined;
+
+const article = NDKArticle.from(event);
+
+export const Kind30023: Story = {
+ args: {
+ ndk,
+ article,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/kinds/9802.stories.ts b/ndk-svelte-components/src/stories/events/kinds/9802.stories.ts
new file mode 100644
index 00000000..00ad8570
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/kinds/9802.stories.ts
@@ -0,0 +1,45 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Kind9802Component from "../../../lib/event/content/Kind9802.svelte";
+
+/**
+ * Renders a Kind 9802 highlight event
+ */
+
+const meta = {
+ title: "Event/Kinds/Kind 9802 - Highlights",
+ component: Kind9802Component,
+ tags: ["autodocs"],
+ argTypes: {
+ event: {
+ control: { type: null },
+ type: { name: "other", value: "NDKEvent", required: true },
+ description: "The event you want to render",
+ },
+ showMedia: {
+ control: { type: "boolean" },
+ type: { required: false },
+ description: "Whether or not to render media inline (images, video, audio)",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] });
+await ndk.connect();
+
+const id =
+ "nevent1qqsg643qyh3anmfmuqckt7dm082uk5qtlqpuv6z8c2fa45352sh502qzyphydppzm7m554ecwq4gsgaek2qk32atse2l4t9ks57dpms4mmhfx0qhfet";
+const event = await ndk.fetchEvent(id);
+
+event.relay = undefined;
+
+export const Kind9802: Story = {
+ args: {
+ ndk,
+ event,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/kinds/lists/30000.stories.ts b/ndk-svelte-components/src/stories/events/kinds/lists/30000.stories.ts
new file mode 100644
index 00000000..094a9053
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/kinds/lists/30000.stories.ts
@@ -0,0 +1,46 @@
+import NDK, { NDKList } from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Kind30000 from "../../../../lib/event/content/Kind30000.svelte";
+
+/**
+ * Renders a Kind 30000 (Categorized People) list.
+ */
+
+const meta = {
+ title: "Event/Kinds/Lists/Kind 30000 - Categorized People",
+ component: Kind30000,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ list: {
+ control: { type: null },
+ type: { name: "other", value: "NDKList", required: true },
+ table: { type: { summary: "NDKList" } },
+ description: "The kind 30001 event ID want to render",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] });
+await ndk.connect();
+
+const id =
+ "naddr1qq9yummnw3ezq3r9weesygqh88vn0hyvp3ehp238tpvn3sgeufwyrakygxjaxnrd8pgruvfkaupsgqqqw5cq9prfqc";
+const list = NDKList.from(await ndk.fetchEvent(id));
+
+export const Default: Story = {
+ args: {
+ ndk,
+ list,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/events/kinds/lists/30001.stories.ts b/ndk-svelte-components/src/stories/events/kinds/lists/30001.stories.ts
new file mode 100644
index 00000000..83fcb87a
--- /dev/null
+++ b/ndk-svelte-components/src/stories/events/kinds/lists/30001.stories.ts
@@ -0,0 +1,46 @@
+import NDK, { NDKList } from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Kind30001 from "../../../../lib/event/content/Kind30001.svelte";
+
+/**
+ * Renders a Kind 30001 (Categorized Bookmarks) list.
+ */
+
+const meta = {
+ title: "Event/Kinds/Lists/Kind 30001 - Categorized Bookmarks",
+ component: Kind30001,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ list: {
+ control: { type: null },
+ type: { name: "other", value: "NDKList", required: true },
+ table: { type: { summary: "NDKList" } },
+ description: "The kind 30001 event ID want to render",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] });
+await ndk.connect();
+
+const id =
+ "naddr1qqv5ummnw3fks6tjwssyxatnw3hk6etjypfx2anfv4msygxv35rjalwvvahuhtq57mxksf0dcdtku40t0p4z4967uq62dgpxevpsgqqqw5cswq0n3l";
+const list = NDKList.from(await ndk.fetchEvent(id));
+
+export const Default: Story = {
+ args: {
+ ndk,
+ list,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/relay/RelayList.stories.ts b/ndk-svelte-components/src/stories/relay/RelayList.stories.ts
new file mode 100644
index 00000000..03c5e3a9
--- /dev/null
+++ b/ndk-svelte-components/src/stories/relay/RelayList.stories.ts
@@ -0,0 +1,57 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import RelayList from "../../lib/relay/RelayList.svelte";
+
+const meta = {
+ title: "Relay/RelayList",
+ component: RelayList,
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Displays a list of relays the NDK instance is connected to, along with information about active subscriptions and connectivity stats.",
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+const ndk = new NDK({
+ explicitRelayUrls: [
+ "wss://relay.f7z.io",
+ "wss://nos.lol",
+ "wss://relay.damus.io",
+ "wss://relay.snort.social",
+ "wss://filter.nostr.wine/npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc?broadcast=true",
+ ],
+});
+ndk.connect();
+const pablo = ndk.getUser({
+ npub: "npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft",
+});
+// pablo.fetchProfile()
+
+const sub1 = ndk.subscribe({ limit: 10 }, { closeOnEose: false });
+const sub2 = ndk.subscribe(
+ { authors: [pablo.pubkey], kinds: [1] },
+ { groupable: true, subId: "pablo-kind-1s" }
+);
+const sub3 = ndk.subscribe(
+ { authors: [pablo.pubkey], kinds: [7] },
+ { groupable: true, subId: "pablo-kind-7s" }
+);
+
+setTimeout(() => sub1.stop(), 1000);
+setTimeout(() => sub2.stop(), 2000);
+setTimeout(() => sub3.stop(), 5000);
+
+export const Default: Story = {
+ args: {
+ ndk: ndk,
+ },
+};
diff --git a/ndk-svelte-components/src/stories/user/Avatar.stories.ts b/ndk-svelte-components/src/stories/user/Avatar.stories.ts
new file mode 100644
index 00000000..083dd578
--- /dev/null
+++ b/ndk-svelte-components/src/stories/user/Avatar.stories.ts
@@ -0,0 +1,87 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Avatar from "../../lib/user/Avatar.svelte";
+
+/**
+ * Renders a user's avatar image using the `user.userProfile.image` value from their latest kind `0` event.
+ *
+ * You can pass `class` or `style` props to the component to style the resulting image.
+ */
+
+const meta = {
+ title: "User/Avatar",
+ component: Avatar,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ npub: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description:
+ "The user's npub. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ pubkey: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description:
+ "The user's hex pubkey. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ user: {
+ control: { type: null },
+ type: { name: "other", value: "NDKUser", required: false },
+ table: { type: { summary: "NDKUser" } },
+ description:
+ "An NDKUser object. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ userProfile: {
+ control: { type: null },
+ type: { name: "other", value: "NDKUserProfile", required: false },
+ table: { type: { summary: "NDKUserProfile" } },
+ description:
+ "An NDKUserProfile object. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ class: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "Any classes you want applied to the ` ` HTML element",
+ },
+ style: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "Any styles you want applied to the ` ` HTML element",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://purplepag.es"] });
+ndk.connect();
+
+export const Default: Story = {
+ args: {
+ ndk: ndk,
+ npub: "npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc",
+ style: "width: 64px; height: 64px;",
+ },
+};
+
+export const Nonexistent: Story = {
+ args: {
+ ndk: ndk,
+ npub: "npub1vqtlp64gdfdqr64xq9g7t8qc9kyyns7nd23nnsf3mv94aqht8ensn29e34",
+ style: "width: 64px; height: 64px;",
+ },
+};
diff --git a/ndk-svelte-components/src/stories/user/Name.stories.ts b/ndk-svelte-components/src/stories/user/Name.stories.ts
new file mode 100644
index 00000000..b04bdcfe
--- /dev/null
+++ b/ndk-svelte-components/src/stories/user/Name.stories.ts
@@ -0,0 +1,89 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Name from "../../lib/user/Name.svelte";
+
+/**
+ * Renders a user name, falling back depending on what values are available. The order used is:
+ * 1. `user.userProfile.displayName`
+ * 2. `user.userProfile.name`
+ * 3. `user.userProfile.nip05`
+ * 4. `npub` optionally truncated with the npubMaxLength param
+ *
+ * As with all components, you can pass `class` or `style` props to the component.
+ * If no `class` or `style` prop is passed, default styles will render the name as normal text.
+ */
+
+const meta = {
+ title: "User/Name",
+ component: Name,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: "object" },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ npub: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description:
+ "The user's npub. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ pubkey: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description:
+ "The user's hex pubkey. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ user: {
+ control: { type: null },
+ type: { name: "other", value: "NDKUser", required: false },
+ table: { type: { summary: "NDKUser" } },
+ description:
+ "An NDKUser object. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ userProfile: {
+ control: { type: null },
+ type: { name: "other", value: "NDKUserProfile", required: false },
+ table: { type: { summary: "NDKUserProfile" } },
+ description:
+ "An NDKUserProfile object. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ npubMaxLength: {
+ control: "number",
+ type: "number",
+ table: { type: { summary: "number" } },
+ description: "The max length of the npub",
+ },
+ class: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "Any classes you want applied to the ` ` HTML element",
+ },
+ style: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "Any styles you want applied to the ` ` HTML element",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://purplepag.es"] });
+ndk.connect();
+
+export const Default: Story = {
+ args: {
+ ndk: ndk,
+ npub: "npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc",
+ },
+};
diff --git a/ndk-svelte-components/src/stories/user/Nip05.stories.ts b/ndk-svelte-components/src/stories/user/Nip05.stories.ts
new file mode 100644
index 00000000..b8955a02
--- /dev/null
+++ b/ndk-svelte-components/src/stories/user/Nip05.stories.ts
@@ -0,0 +1,96 @@
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+import Nip05 from "../../lib/user/Nip05.svelte";
+
+/**
+ * Renders a user's NIP-05 string.
+ *
+ * If a user's NIP-05 starts with an `_` (underscore), only the domain will be rendered.
+ *
+ * This component has a named slot called "badge", that you can use to pass in an icon, image or anything else you want.
+ *
+ * As with all components, you can pass `class` or `style` props to the component.
+ * If no `class` or `style` prop is passed, default styles will render the name as normal text.
+ */
+
+const meta = {
+ title: "User/NIP-05",
+ component: Nip05,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: "object" },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ npub: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description:
+ "The user's npub. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ pubkey: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description:
+ "The user's hex pubkey. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ user: {
+ control: { type: null },
+ type: { name: "other", value: "NDKUser", required: false },
+ table: { type: { summary: "NDKUser" } },
+ description:
+ "An NDKUser object. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ userProfile: {
+ control: { type: null },
+ type: { name: "other", value: "NDKUserProfile", required: false },
+ table: { type: { summary: "NDKUserProfile" } },
+ description:
+ "An NDKUserProfile object. Only one of `npub`, `pubkey`, `user`, or `userProfile` is required.",
+ },
+ nip05MaxLength: {
+ control: "number",
+ type: "number",
+ table: { type: { summary: "number" } },
+ description: "The max length of the nip-05",
+ },
+ class: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "Any classes you want applied to the ` ` HTML element",
+ },
+ style: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "Any styles you want applied to the ` ` HTML element",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://purplepag.es"] });
+ndk.connect();
+
+export const Default: Story = {
+ args: {
+ ndk: ndk,
+ npub: "npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx",
+ },
+};
+
+export const WithUnderscore: Story = {
+ args: {
+ ndk: ndk,
+ npub: "npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc",
+ },
+};
diff --git a/ndk-svelte-components/src/stories/user/Npub.stories.ts b/ndk-svelte-components/src/stories/user/Npub.stories.ts
new file mode 100644
index 00000000..0b410cc3
--- /dev/null
+++ b/ndk-svelte-components/src/stories/user/Npub.stories.ts
@@ -0,0 +1,59 @@
+import Npub from "$lib/user/Npub.svelte";
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+/**
+ * Renders a user's npub string.
+ *
+ * Displays a truncated version of a user's npub and an icon that allows user's to copy the npub.
+ *
+ * As with all components, you can pass `class` or `style` props to the component.
+ * If no `class` or `style` prop is passed, default styles will render the name as normal text.
+ */
+
+const meta = {
+ title: "User/Npub",
+ component: Npub,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: "object" },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ npub: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "The user's npub. Only one of `npub`, `pubkey`, or `user` is required.",
+ },
+ pubkey: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description:
+ "The user's hex pubkey. Only one of `npub`, `pubkey`, or `user` is required.",
+ },
+ user: {
+ control: { type: null },
+ type: { name: "other", value: "NDKUser", required: false },
+ table: { type: { summary: "NDKUser" } },
+ description: "An NDKUser object. Only one of `npub`, `pubkey`, or `user` is required.",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://purplepag.es"] });
+ndk.connect();
+
+export const Default: Story = {
+ args: {
+ ndk: ndk,
+ npub: "npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx",
+ },
+};
diff --git a/ndk-svelte-components/src/stories/user/UserCard.stories.ts b/ndk-svelte-components/src/stories/user/UserCard.stories.ts
new file mode 100644
index 00000000..a979ad4a
--- /dev/null
+++ b/ndk-svelte-components/src/stories/user/UserCard.stories.ts
@@ -0,0 +1,75 @@
+import UserCard from "$lib/user/UserCard.svelte";
+import NDK from "@nostr-dev-kit/ndk";
+import type { Meta, StoryObj } from "@storybook/svelte";
+
+/**
+ * Renders a user card with basic metadata info on the user.
+ *
+ * You can pass `class` or `style` props to the component to style the card itself.
+ */
+
+const meta = {
+ title: "User/UserCard",
+ component: UserCard,
+ tags: ["autodocs"],
+ argTypes: {
+ ndk: {
+ control: { type: null },
+ type: { name: "other", value: "NDK", required: true },
+ table: { type: { summary: "NDK" } },
+ description:
+ "The NDK instance you want to use. This should be already connected to relays.",
+ },
+ npub: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "The user's npub. Only one of `npub`, `pubkey`, or `user` is required.",
+ },
+ pubkey: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description:
+ "The user's hex pubkey. Only one of `npub`, `pubkey`, or `user` is required.",
+ },
+ user: {
+ control: { type: null },
+ type: { name: "other", value: "NDKUser", required: false },
+ table: { type: { summary: "NDKUser" } },
+ description: "An NDKUser object. Only one of `npub`, `pubkey`, or `user` is required.",
+ },
+ class: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "Any classes you want applied to the ` ` HTML element",
+ },
+ style: {
+ control: "text",
+ type: "string",
+ table: { type: { summary: "string" } },
+ description: "Any styles you want applied to the ` ` HTML element",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const ndk = new NDK({ explicitRelayUrls: ["wss://purplepag.es"] });
+ndk.connect();
+
+export const Default: Story = {
+ args: {
+ ndk: ndk,
+ npub: "npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc",
+ },
+};
+
+export const Nonexistent: Story = {
+ args: {
+ ndk: ndk,
+ npub: "npub1vqtlp64gdfdqr64xq9g7t8qc9kyyns7nd23nnsf3mv94aqht8ensn29e34",
+ },
+};
diff --git a/ndk-svelte-components/src/styles/global.css b/ndk-svelte-components/src/styles/global.css
new file mode 100644
index 00000000..524bf4a9
--- /dev/null
+++ b/ndk-svelte-components/src/styles/global.css
@@ -0,0 +1,21 @@
+:root {
+ --color-shadow: rgba(0, 0, 0, 0.2);
+ --color-border: #eaeaea;
+ --color-bg: white;
+
+ --color-primary: #0070f3;
+
+ --connector-width: 4px;
+ --connector-style: solid;
+ --connector-color: var(--color-border);
+}
+
+a {
+ color: var(--color-primary);
+ text-decoration: none;
+}
+
+.event-card .event-content img {
+ /* fit content */
+ width: 100%;
+}
diff --git a/ndk-svelte-components/static/favicon.png b/ndk-svelte-components/static/favicon.png
new file mode 100644
index 00000000..825b9e65
Binary files /dev/null and b/ndk-svelte-components/static/favicon.png differ
diff --git a/ndk-svelte-components/svelte.config.js b/ndk-svelte-components/svelte.config.js
new file mode 100644
index 00000000..ae0a4ef3
--- /dev/null
+++ b/ndk-svelte-components/svelte.config.js
@@ -0,0 +1,15 @@
+import preprocess from "svelte-preprocess";
+import adapter from "@sveltejs/adapter-auto";
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: preprocess(),
+ compilerOptions: {
+ customElement: true,
+ },
+ kit: {
+ adapter: adapter(),
+ },
+};
+
+export default config;
diff --git a/ndk-svelte-components/tailwind.config.js b/ndk-svelte-components/tailwind.config.js
new file mode 100644
index 00000000..6c855fcf
--- /dev/null
+++ b/ndk-svelte-components/tailwind.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ prefix: "ndk-svelte-",
+ presets: [require("@nostr-dev-kit/tailwind-config/tailwind.config.js")],
+};
diff --git a/ndk-svelte-components/tsconfig.json b/ndk-svelte-components/tsconfig.json
new file mode 100644
index 00000000..cb286cec
--- /dev/null
+++ b/ndk-svelte-components/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true
+ }
+}
diff --git a/ndk-svelte-components/vite.config.ts b/ndk-svelte-components/vite.config.ts
new file mode 100644
index 00000000..4a79a4b1
--- /dev/null
+++ b/ndk-svelte-components/vite.config.ts
@@ -0,0 +1,6 @@
+import { sveltekit } from "@sveltejs/kit/vite";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [sveltekit()],
+});
diff --git a/ndk-svelte/.gitignore b/ndk-svelte/.gitignore
new file mode 100644
index 00000000..8d40ce19
--- /dev/null
+++ b/ndk-svelte/.gitignore
@@ -0,0 +1,13 @@
+**/node_modules
+**/build
+**/dist
+**/lib
+**/.vscode
+justfile
+package-lock.json
+**/*.js
+!jest.config.js
+**/*.d.ts
+**/*.d.ts.map
+*.tgz
+.DS_Store
diff --git a/ndk-svelte/.prettierignore b/ndk-svelte/.prettierignore
new file mode 100644
index 00000000..53c37a16
--- /dev/null
+++ b/ndk-svelte/.prettierignore
@@ -0,0 +1 @@
+dist
\ No newline at end of file
diff --git a/ndk-svelte/CHANGELOG.md b/ndk-svelte/CHANGELOG.md
new file mode 100644
index 00000000..86ce28e9
--- /dev/null
+++ b/ndk-svelte/CHANGELOG.md
@@ -0,0 +1,362 @@
+# @nostr-dev-kit/ndk-svelte
+
+## 2.3.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.7
+
+## 2.3.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.6
+
+## 2.3.0
+
+### Minor Changes
+
+- 7bddeff: skip by default, PRE that have been tagged as "deleted"
+
+### Patch Changes
+
+- Updated dependencies [5939a3e]
+- Updated dependencies
+- Updated dependencies [f2a0cce]
+ - @nostr-dev-kit/ndk@2.10.5
+
+## 2.2.22
+
+### Patch Changes
+
+- Updated dependencies [5bed70c]
+- Updated dependencies [873ad4a]
+ - @nostr-dev-kit/ndk@2.10.4
+
+## 2.2.21
+
+### Patch Changes
+
+- Updated dependencies [0fc66c5]
+ - @nostr-dev-kit/ndk@2.10.3
+
+## 2.2.20
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.10.2
+
+## 2.2.19
+
+### Patch Changes
+
+- d6cfa8a: Fix inconsistent store result
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [d6cfa8a]
+- Updated dependencies [722345b]
+ - @nostr-dev-kit/ndk@2.10.1
+
+## 2.2.18
+
+### Patch Changes
+
+- e8ad796: expose a way to peak into events as they come
+- Updated dependencies [ec83ddc]
+- Updated dependencies [18c55bb]
+- Updated dependencies
+- Updated dependencies [18c55bb]
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies [3029124]
+ - @nostr-dev-kit/ndk@2.10.0
+
+## 2.2.17
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.9.1
+
+## 2.2.16
+
+### Patch Changes
+
+- 548f4d8: add optimistic updates
+- Updated dependencies [94018b4]
+- Updated dependencies [548f4d8]
+ - @nostr-dev-kit/ndk@2.9.0
+
+## 2.2.15
+
+### Patch Changes
+
+- Updated dependencies [0af033f]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.8.2
+
+## 2.2.14
+
+### Patch Changes
+
+- Updated dependencies [e40312b]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.8.1
+
+## 2.2.13
+
+### Patch Changes
+
+- fix broken subscription unref
+- Updated dependencies [91d873c]
+- Updated dependencies [6fd9ddc]
+- Updated dependencies [0b8f331]
+- Updated dependencies
+- Updated dependencies [f2898ad]
+- Updated dependencies [9b92cd9]
+- Updated dependencies
+- Updated dependencies [6814f0c]
+- Updated dependencies [89b5b3f]
+- Updated dependencies [9b92cd9]
+- Updated dependencies [27b10cc]
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies [ed7cdc4]
+ - @nostr-dev-kit/ndk@2.8.0
+
+## 2.2.12
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.7.1
+
+## 2.2.11
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.7.0
+
+## 2.2.10
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.6.1
+
+## 2.2.9
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [c2db3c1]
+- Updated dependencies
+- Updated dependencies [c2db3c1]
+- Updated dependencies [c2db3c1]
+ - @nostr-dev-kit/ndk@2.6.0
+
+## 2.2.8
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.5.1
+
+## 2.2.7
+
+### Patch Changes
+
+- Updated dependencies [e08fc74]
+ - @nostr-dev-kit/ndk@2.5.0
+
+## 2.2.6
+
+### Patch Changes
+
+- Updated dependencies [111c1ea]
+- Updated dependencies [5c0ae51]
+- Updated dependencies [6f5ea49]
+- Updated dependencies [3738d39]
+- Updated dependencies [d22239a]
+ - @nostr-dev-kit/ndk@2.4.1
+
+## 2.2.5
+
+### Patch Changes
+
+- Updated dependencies [b9bbf1d]
+ - @nostr-dev-kit/ndk@2.4.0
+
+## 2.2.4
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [885b6c2]
+- Updated dependencies [5666d56]
+ - @nostr-dev-kit/ndk@2.3.3
+
+## 2.2.3
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies [4628481]
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.3.2
+
+## 2.2.2
+
+### Patch Changes
+
+- Updated dependencies [ece965f]
+ - @nostr-dev-kit/ndk@2.3.1
+
+## 2.2.1
+
+### Patch Changes
+
+- Updated dependencies [54cec78]
+- Updated dependencies [ef61d83]
+- Updated dependencies [98b77dd]
+- Updated dependencies [46b0c77]
+- Updated dependencies [082e243]
+ - @nostr-dev-kit/ndk@2.3.0
+
+## 2.1.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.2.0
+
+## 2.0.10
+
+### Patch Changes
+
+- Updated dependencies [180d774]
+- Updated dependencies [7f00c40]
+ - @nostr-dev-kit/ndk@2.1.3
+
+## 2.0.9
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.2
+
+## 2.0.8
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.1
+
+## 2.0.7
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.1.0
+
+## 2.0.6
+
+### Patch Changes
+
+- Updated dependencies
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.6
+
+## 2.0.5
+
+### Patch Changes
+
+- Updated dependencies [d45d962]
+ - @nostr-dev-kit/ndk@2.0.5
+
+## 2.0.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.4
+
+## 2.0.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.3
+
+## 2.0.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.2
+
+## 1.3.6
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@2.0.0
+
+## 1.3.5
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.2
+
+## 1.3.4
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.1
+
+## 1.3.3
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.4.0
+
+## 1.3.2
+
+### Patch Changes
+
+- Updated dependencies [b3561af]
+ - @nostr-dev-kit/ndk@1.3.2
+
+## 1.3.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @nostr-dev-kit/ndk@1.3.1
+
+## 1.3.0
+
+### Minor Changes
+
+- 38fa741: Fixes issue where NIP-33 events are not properly replaced
+
+### Patch Changes
+
+- Updated dependencies [88df10a]
+- Updated dependencies [c225094]
+- Updated dependencies [cf4a648]
+- Updated dependencies [3946078]
+- Updated dependencies [3440768]
+ - @nostr-dev-kit/ndk@1.3.0
diff --git a/ndk-svelte/LICENSE b/ndk-svelte/LICENSE
new file mode 100644
index 00000000..2a6ea523
--- /dev/null
+++ b/ndk-svelte/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Pablo Fernandez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ndk-svelte/README.md b/ndk-svelte/README.md
new file mode 100644
index 00000000..5e954ff2
--- /dev/null
+++ b/ndk-svelte/README.md
@@ -0,0 +1,102 @@
+# ndk-svelte
+
+This package provides convenience functionalities to make usage of NDK with Svelte nicer.
+
+## Install
+
+```
+pnpm add @nostr-dev-kit/ndk-svelte
+```
+
+## Store subscriptions
+
+NDK-svelte provides Svelte Store subscriptions so your components can have simple reactivity
+when events arrive.
+
+Events in the store will appear in a set ordered by `created_at`.
+
+```typescript
+import NDKSvelte from "@nostr-dev-kit/ndk-svelte";
+
+const ndk = new NDKSvelte({
+ explicitRelayUrls: ["wss://relay.f7z.io"],
+});
+```
+
+```typescript
+// in your components
+
+
+
+ {$highlights.length} highlights seen
+
+
+
+ {$nostrHighlightsAndReposts.length} nostr highlights (including reposts)
+
+```
+
+## Reference Counting with ref/unref
+
+NDK-svelte introduces a reference counting mechanism through the ref and unref methods on the stores. This system is particularly useful for optimizing the lifecycle of subscriptions in components that might be frequently mounted and unmounted.
+
+### Benefits:
+
+- **Optimized Lifecycle**: Instead of starting a new subscription every time a component mounts, and ending it when it unmounts, you can reuse an existing subscription if another component is already using it.
+
+- **Resource Efficiency**: By preventing redundant subscriptions, you save both network bandwidth and processing power.
+
+- **Synchronization**: Ensures that multiple components referencing the same data are synchronized with a single data source.
+
+### How to use:
+
+Whenever you subscribe to a store in a component, call ref to increment the reference count:
+
+```typescript
+// lib/stores/highlightsStore.ts
+const highlightsStore = $ndk.storeSubscribe(..., { autoStart: false } });
+
+// component 1
+
+
+{$highlightsStore.length} highlights seen
+```
+
+You can mount this component as many times as you want, and the subscription will only be started once. When the last component unmounts, the subscription will be terminated.
+
+# Notes
+
+If you are interested in NDK and Svelte you might want to checkout the
+[ndk-svelte-components](https://github.com/nostr-dev-kit/ndk-svelte-components) package
+which provides some components to make it easier to build nostr apps with Svelte.
+
+# Authors
+
+- [@pablof7z](https://njump.me/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
diff --git a/ndk-svelte/package.json b/ndk-svelte/package.json
new file mode 100644
index 00000000..f53210e3
--- /dev/null
+++ b/ndk-svelte/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@nostr-dev-kit/ndk-svelte",
+ "version": "2.3.2",
+ "description": "This package provides convenience functionalities to make usage of NDK with Svelte nicer.",
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "./svelte5": {
+ "import": {
+ "types": "./dist/index.svelte.d.mts",
+ "default": "./dist/index.svelte.js"
+ },
+ "require": {
+ "types": "./dist/index.svelte.d.ts",
+ "default": "./dist/index.svelte.js"
+ }
+ }
+ },
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "tsup src/index.ts src/index.svelte.ts --format cjs,esm --dts",
+ "dev": "tsup --watch src src/index.ts --format cjs,esm --dts",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write .",
+ "postbuild": "mv dist/index.svelte.mjs dist/index.svelte.js; mv dist/index.svelte.d.mts dist/index.svelte.d.ts"
+ },
+ "keywords": [
+ "nostr",
+ "nostr-dev-kit",
+ "ndk",
+ "svelte"
+ ],
+ "author": "pablof7z",
+ "license": "MIT",
+ "dependencies": {
+ "@nostr-dev-kit/ndk": "workspace:*"
+ },
+ "peerDependencies": {
+ "svelte": "*"
+ },
+ "devDependencies": {
+ "@nostr-dev-kit/eslint-config-custom": "workspace:*",
+ "@nostr-dev-kit/tsconfig": "workspace:*",
+ "svelte": "5.0.0-next.272",
+ "tsup": "^7.2.0"
+ }
+}
diff --git a/ndk-svelte/src/index.svelte.ts b/ndk-svelte/src/index.svelte.ts
new file mode 100644
index 00000000..96a45688
--- /dev/null
+++ b/ndk-svelte/src/index.svelte.ts
@@ -0,0 +1,119 @@
+import NDK, {
+ type NDKConstructorParams,
+ NDKEvent,
+ type NDKFilter,
+ type NDKRelay,
+ type NDKRelaySet,
+ type NDKSubscriptionOptions,
+} from "@nostr-dev-kit/ndk";
+import { onDestroy } from "svelte";
+
+type ClassWithConvertFunction = {
+ from: (event: NDKEvent) => T | undefined;
+};
+
+type NDKSubscribeOptions = NDKSubscriptionOptions & {
+ autoStart?: boolean;
+ repostsFilters?: NDKFilter[];
+ unrefUnsubscribeTimeout?: number;
+ relaySet?: NDKRelaySet;
+ skipDeleted?: boolean;
+ onEose?: () => void;
+ onEvent?: (event: NDKEvent, relay?: NDKRelay) => void;
+};
+
+type Actions = {
+ unsubscribe?: () => void;
+};
+
+class NDKSvelte extends NDK {
+ constructor(opts?: NDKConstructorParams) {
+ super(opts);
+ }
+
+ /**
+ * Subscribes to NDK events and returns a reactive list of events.
+ * Automatically cleans up the subscription when no longer needed.
+ */
+ public $subscribe = (
+ filters: NDKFilter[],
+ opts?: NDKSubscribeOptions,
+ klass?: ClassWithConvertFunction
+ ) => {
+ // A reactive list for the events
+ const eventList = $state([]);
+ const eventMap = new Map(); // Map for deduplication
+
+ // Process an incoming event
+ const processEvent = (event: NDKEvent) => {
+ let e = event;
+
+ // Convert the event to a specific class if provided
+ if (klass) {
+ const convertedEvent = klass.from(event);
+ if (!convertedEvent) return;
+ e = convertedEvent;
+ e.relay = event.relay;
+ }
+
+ const dedupKey = e.deduplicationKey();
+
+ // Avoid duplicate or older events
+ if (eventMap.has(dedupKey)) {
+ const existingEvent = eventMap.get(dedupKey)!;
+ if (existingEvent.created_at! >= e.created_at!) return;
+ }
+
+ // Check if the event is marked as deleted
+ const isDeleted = e.isParamReplaceable() && e.hasTag("deleted");
+
+ // Update the event map
+ eventMap.set(dedupKey, e as T);
+
+ // If the event is deleted and skipDeleted is true (default), remove it from the list
+ if (isDeleted && opts?.skipDeleted !== false) {
+ const index = eventList.findIndex(event => event.deduplicationKey() === dedupKey);
+ if (index !== -1) {
+ eventList.splice(index, 1);
+ }
+ return;
+ }
+
+ // Update the reactive event list inserting the event in the right position according to the created_at timestamp
+ const pos = eventList.findIndex(event => event.created_at! < e.created_at!);
+ eventList.splice(pos, 0, e as T);
+ };
+
+ // Create the subscription
+ const subscription = this.subscribe(
+ Array.isArray(filters) ? filters : [filters],
+ opts,
+ opts?.relaySet,
+ false
+ );
+
+ // Handle incoming events
+ subscription.on("event", (event, relay) => {
+ processEvent(event);
+ if (opts?.onEvent) opts.onEvent(event, relay);
+ });
+
+ // Handle EOSE
+ subscription.on("eose", () => {
+ if (opts?.onEose) opts.onEose();
+ });
+
+ subscription.start();
+
+ // Cleanup when the component or context is destroyed
+ onDestroy(() => {
+ subscription.stop();
+ });
+
+ eventList.unsubscribe = () => subscription.stop();
+
+ return eventList;
+ }
+}
+
+export default NDKSvelte;
diff --git a/ndk-svelte/src/index.ts b/ndk-svelte/src/index.ts
new file mode 100644
index 00000000..dc26f010
--- /dev/null
+++ b/ndk-svelte/src/index.ts
@@ -0,0 +1,359 @@
+import NDK, {
+ type NDKConstructorParams,
+ NDKEvent,
+ type NDKFilter,
+ NDKKind,
+ type NDKRelay,
+ type NDKRelaySet,
+ NDKRepost,
+ type NDKSubscription,
+ type NDKSubscriptionOptions,
+} from "@nostr-dev-kit/ndk";
+import { type Unsubscriber, type Writable, writable } from "svelte/store";
+
+/**
+ * Type for NDKEvent classes that have a static `from` method like NDKHighlight.
+ */
+type ClassWithConvertFunction