mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 14:14:07 +02:00
111 lines
4.7 KiB
Markdown
111 lines
4.7 KiB
Markdown
# 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.
|
|
|
|
```ts
|
|
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** — `NIP19Page` decodes the identifier and passes the hints on as a `relays`
|
|
param (comma-separated). Apps read them with `decodeRelayHints(params.relays)` and hand
|
|
them to `nostr.query(..., { relays })`.
|
|
- **Outgoing** — "Copy link" in `NoteCard` and the Reader embeds `useRelayHints()` 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-sanitize` runs as a second line of defence. Strictly redundant while
|
|
`rehype-raw` is absent, but it stops a future change from quietly opening a hole.
|
|
- A custom `urlTransform` routes every link and image through `sanitizeUrl`.
|
|
- **`rehype-raw` is 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.
|