4.7 KiB
Nostr data layer
The template's infrastructure is unchanged: NostrProvider owns a single NPool, and
data is read through useNostr() + TanStack Query. This document covers what was added on
top and the rules that keep relay content safe to render.
Reading
Every query follows the same shape: a queryKey that includes everything the result
depends on, an abort signal combined with a timeout, and validation before the data
reaches a component.
useQuery<NostrEvent[]>({
queryKey: ['nostr', 'feed', scope, authors.length],
queryFn: async ({ signal }) => {
const events = await nostr.query([filter], {
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
relays, // optional hints, see below
});
return events.filter(isRenderable).sort((a, b) => b.created_at - a.created_at);
},
staleTime: 30_000,
});
Validate before rendering. Relays return blanks, oddities and events that do not match
what their kind promises. Each app defines a small predicate — isRenderableNote requires
non-empty content on a kind 1; isRenderableArticle additionally requires a d tag,
without which an addressable event cannot be addressed at all.
Custom hooks:
| Hook | Purpose |
|---|---|
useFollows(pubkey) / useMyFollows() |
The pubkeys in a kind 3 contact list |
useRelayStatus() |
Live socket state of every configured relay, sampled each second |
useRelayHints() |
The first two read relays, for embedding in identifiers we hand out |
Relay hints
This is the single change that made deep links actually work.
A nprofile, nevent or naddr can carry relay hints, and the event it points at very
often lives on a relay the reader does not subscribe to. Discarding the hints means a
shared link resolves only for people who happen to read the same relays as the sender —
which, in testing, was most of the time a failure.
The flow is symmetric:
- Incoming —
NIP19Pagedecodes the identifier and passes the hints on as arelaysparam (comma-separated). Apps read them withdecodeRelayHints(params.relays)and hand them tonostr.query(..., { relays }). - Outgoing — "Copy link" in
NoteCardand the Reader embedsuseRelayHints()into the identifier it encodes, so links leaving this client carry the same courtesy.
encodeRelayHints / decodeRelayHints in src/lib/nostrUtils.ts accept only ws:// and
wss:// URLs.
Publishing
Through useNostrPublish() (kind, content, tags). Two places write events:
Composer— kind 1. Replies carry NIP-10 tags:['e', id, '', 'root']plus['p', authorPubkey].- Follow button — kind 3, the complete list, read back before writing.
Failures surface as a toast with the underlying message; they are never swallowed.
Rendering untrusted content
Everything below arrived from a stranger's relay. The rules are not optional.
URLs. sanitizeUrl() parses the URL and returns it only if the protocol is https:,
http:, mailto: or nostr:. javascript: and data: never survive it. It guards every
href and src in the app — avatars, banners, article images, links inside note text.
Note text. NoteContent tokenises the raw string into text, URLs and NIP-19
references and renders each as an element. Nothing is ever passed to
dangerouslySetInnerHTML.
Markdown. react-markdown builds a React tree and never touches innerHTML, so raw
HTML inside an article body is inert by construction — that is why it was chosen over
marked or markdown-it, which return HTML strings you must remember to sanitize.
Additionally:
rehype-sanitizeruns as a second line of defence. Strictly redundant whilerehype-rawis absent, but it stops a future change from quietly opening a hole.- A custom
urlTransformroutes every link and image throughsanitizeUrl. rehype-rawis deliberately not installed. Adding it means overturning this decision on purpose.
External links get target="_blank" with rel="noopener noreferrer nofollow".
Nostr references open windows
components.a in the Markdown renderer and RefToken in NoteContent intercept
nostr: URIs and bare npub/nprofile/note/nevent/naddr strings, and turn them
into window openers rather than navigations:
npub / nprofile → openApp('profile', { pubkey })
note / nevent → openApp('notes', { id })
naddr → openApp('articles', { pubkey, kind, identifier })
Clicking a mention raises a Profile window next to the note you were reading instead of taking the page away from you. This is the point at which the app stops feeling like a website — and it is only cleanly possible because the Markdown renderer hands us components instead of an HTML string.