Phosphor is a Nostr web client built on the KISS (Keep It Simple, Stupid) principle, wrapped in a thick cyberpunk-terminal aesthetic. It is specifically designed for a lightweight, instant reading and interaction experience on the Nostr network without the bloat of modern interfaces.
This client UI is built entirely on top of the Terminal Framework created by @jeannesbryan.
- Minimalist Timeline: Fetch Text Note feeds (NIP-01) in real-time without lag.
- Timeline Tabs & Filtering: Toggle seamlessly between a curated Following timeline (instantly filtered via lightweight CSS selectors based on your Kind 3 follow list) and a live Global stream.
- Aggressive Local Caching: Utilizes IndexedDB to cache user profiles locally, drastically reducing redundant relay queries and providing instant timeline rendering.
- Proof of Work (NIP-13): Native support for adjusting computational difficulty (up to Level 16) directly from the composer to prevent network spam, utilizing an asynchronous event loop that doesn't freeze your browser.
- Notifications & Mentions: Dedicated hub to monitor network signals, replies, mentions, reposts, and telemetry reactions targeting your node.
- Secure Bookmarks (NIP-51): Save and organize your favorite posts, articles, and threads locally via decentralized lists synced securely across your relays.
- Versatile Authentication: Supports NIP-07 browser extensions (nos2x, Alby), Secure Encrypted Keys (NIP-49 / ncryptsec), NIP-46 Remote Signers (Bunker URI), and Raw Private Keys (nsec/hex) using an internal client-side signer powered by
nostr-tools. - Quoted Notes (NIP-18): A note that quotes another one shows the quoted note as a compact card. The quoted note is fetched from the relay named in its
qtag, which is often not one of your own relays. - GitHub Link Cards: A link to a repository or file on github.com renders a small card with the repo name, description, stars, language and file path. Cached locally for 6 hours; if the API is unreachable or rate-limited, the link simply stays a link.
- Clean Timeline (NIP-10): Replies stay out of the timeline and live in threads, where a note shows its replies. Quote posts are not affected — only notes that point at a parent with an
etag are held back. Reply counters still count every reply that arrives. - Long Form Article (NIP-23): Read and author Markdown articles directly from the Nostr network with a built-in clean editor.
- Dynamic Configuration: Multi-relay lists and Blossom servers are easily managed externally via a
config.jsonfile. - Event Deletion & Editing: Full support for replacing Long-form content (NIP-33) and editing regular Notes via Event Deletion signals (NIP-09).
- Rich Media Support & Multi-Upload: Automatically detects and renders inline image URLs and video formats (mp4, webm, ogg), and turns a standalone YouTube link into a full-width click-to-load player — thumbnail first, no iframe, no third-party script, and no YouTube cookie until you press play. Links sitting mid-sentence are left as plain links. Supported on the timeline, profile, bookmarks, relay explorer, threads, notifications, and long-form articles. Supports uploading up to 4 media files simultaneously via Blossom servers, neatly rendered in a responsive grid. The rule set lives once in
assets/nostr-embed.jsand is shared by all pages; uploaded media keeps rendering exactly as it always did. - Content Warning (NIP-36): Toggle NSFW tags easily on the composer and auto-blurs sensitive incoming feeds with a click-to-reveal interface.
- NIP-19 Entity Support: Automatically parses Nostr links (naddr, npub, note, nevent) into interactive embeds.
- Ephemeral Events (NIP-40): Supports 24-hour expiring Pulse stories with live expiration timers.
- Client Tags parsing: Identifies the client source used to broadcast an event (e.g.,
via Jumble,via YakiHonne,via Phosphor). - Machine Telemetry Ready: Seamlessly formats and highlights raw JSON payloads generated by bots or node telemetry tools on the network.
- Universal Repost (NIP-18): Supports reposting standard notes (Kind 6) and Generic Repost (Kind 16) for articles.
- Quote & Threading: Quote messages and open discussion threads easily using a clean modal interface. Includes a one-click auto-copy button to generate interactive Thread links.
- Zap Payments (NIP-57): ⚡ Support for sending sats to authors via Nostr-native zaps. Preset amounts (100/1K/10K sats) appear on every post that carries a zap-enabled Lightning address (lud16/lud06) in the author's profile — and on the profile header itself for the viewed user. Signs kind-9734 requests via your NIP-07 signer and pays through WebLN, with a copy-invoice fallback when no wallet extension is present. Zero new dependencies — built on the
nostr-toolsalready bundled via CDN. - Live Zap Receipt Counters: Every post card tallies incoming kind-9735 zap receipts through the shared metrics engine and renders a running
[⚡ sats]total right next to the like/repost buttons — deduplicated across relays and isolated per post. - Multi-Relay Connection: Pull and broadcast data across multiple relays independently and simultaneously.
- Targeted Relay Explorer: Click on any verified NIP-05 domain on a user's profile to instantly open a dedicated timeline isolated to that specific node/instance.
- Mute / Block List (NIP-51): Seamlessly block noisy nodes or spam with synchronized Muted Lists (Kind 10000) that automatically filter out unwanted content across devices.
- Nomadic Network Preferences (NIP-65): Automatically syncs and applies your custom relay network topology (Kind 10002) upon login, overriding static configurations for true portability.
- Activity & Sport Telemetry Tracking: Native rendering for outdoor fitness events (Kind 30311 & 33400). Automatically calculates Avg Pace or Avg Speed based on the activity type, acting as a decentralized, text-based alternative to Strava on the Nostr network.
Since Phosphor runs 100% purely client-side (Vanilla HTML/CSS/JS), there is no dependency hell, compilation, or complex backend setup required.
- Clone or download this repository.
- Ensure the
config.jsonfiles are in the same directory. (Editconfig.jsonto customize your default relays). - You can run a local web server within the folder (e.g.,
python -m http.server 5500) or simply host it on static hosting providers like Cloudflare Pages or GitHub Pages. - Open your browser and navigate to the address.
No build step, no test framework — plain Node, no network:
node tests/nostr-embed.test.mjs # 64 assertions
node tests/nostr-note.test.mjs # 157 assertions
node tests/blossom-auth.test.mjs # 100 assertions
node tests/nostr-like.test.mjs # 133 assertions
node tests/reply-thread.test.mjs # 20 assertions
node tests/nostr-nip05.test.mjs # 79 assertions
node tests/nostr-relay-info.test.mjs # 66 assertions
tests/blossom-auth.test.mjs guards a real incident: blossom-server 6.4.1
started requiring the BUD-11 x tag (the sha256 of the blob) for uploads.
Phosphor was sending a tag named payload instead, so every media upload was
rejected with 403 Auth token does not authorize operation on blob ….
Verified against a real 6.4.1 server: payload → 403, x → 201. Keep the
x tag.
The rules for that upload — hashing the file, building the kind 24242
authorisation event, signing it, and sending it as Authorization: Nostr … —
live in one file, assets/blossom-auth.js, used by all seven pages. They
used to be copied into each page, which is why the 6.4.1 change had to be
fixed in seven places at once (and why one page, bookmark.html, never
showed the server's rejection message at all). The test therefore checks two
things: that the module really produces a matching x tag for the file being
uploaded (it feeds a fake fetch and inspects the header actually sent), and
that no page keeps a copy of the rules, so a copy cannot drift again.
tests/nostr-note.test.mjs covers reply detection (NIP-10), quote targets
(NIP-18) and GitHub link parsing — including the cases that must not match,
such as github.com.evil.example, gist.github.com and javascript: URLs.
Its fixtures are three real notes taken from the relay, kept verbatim.
tests/nostr-embed.test.mjs covers YouTube URL detection (including the URLs
that must not match, like Vimeo and look-alike domains) and the media
assembler: which blocks get packed into a grid, which stay full width, and why
the original order inside a note is preserved.
tests/reply-thread.test.mjs guards a navigation rule, not a rendering one:
replies are shown one level deep only, so the [r] button on a reply card
must open that reply's own thread page (thread.html?id=…) instead of pulling
focus back into the composer of the current page — otherwise the replies to a
reply are unreachable. The test slices the actions bar of the main post
(mt-4) and of a reply card (mt-3) and asserts that only the main post still
calls focus(). Mutation-checked: putting the old handler back on a reply card
fails four assertions.
tests/nostr-nip05.test.mjs guards an honesty rule, not a feature: every page
used to print "handle ✓ domain" the moment a profile merely CONTAINED a nip05
field, without ever fetching anything — so anyone could claim someone else's
handle and Phosphor showed it as verified. Now the check mark appears only after
https://<domain>/.well-known/nostr.json?name=<local> has confirmed the pubkey.
The test covers the parser (hosts, IP literals, injection attempts), the fetch
result ('valid' / 'invalid' / 'error' / 'none'), caching and de-duplication, and
the "no check mark without proof" promise across several failure modes.
Which reactions count as "liked" is one rule too: Phosphor has a single
like/love button (no custom emoji picker), so +, an empty content, and a heart
(❤ / ♥, with or without a variation selector) all mean "we liked this",
while - (the NIP-25 dislike) and plain emoji such as 👍 do not. Counting every
kind 7 as a like was a real bug — a dislike used to light the button up.
tests/nostr-like.test.mjs covers like/unlike as NIP-09 sets: state is
recomputed from every known reaction minus the deleted ones, so the result no
longer depends on the arrival order of events (a stale reaction plus its
deletion used to hide a like that was still live on another relay).
Media assembly used to be inlined in each page — the same URL classifier was
pasted into seven pages (ten places), and it had already drifted: three
different "video not supported" texts, an alt of "Pulse Media" on two branches,
and one page missing rel="noopener noreferrer". It now lives once, in
NostrEmbed.assembleMedia(content, opts), which returns { html, text }:
html is the media laid out (YouTube players full width, files packed into a
grid, original order preserved by a single pass) and text is the content with
the media URLs removed. Options are per-page layout only (wrap class, grid
class, extra attributes for the NSFW spoiler). Tests part 9 covers the assembler
and part 10 asserts that no page carries the old code any more — the count of
assembleMedia() call sites must stay at ten.
The rest of the note text pipeline (escape, then linkify bare URLs, then NIP-19
ids) was inlined too — and had drifted the same way (notifications.html linkified
URLs without rel="noopener noreferrer"). It is now NostrNote.renderContentText(text, ev)
with escapeHtml alongside it. Two branches are deliberately left out: the sport
activity branches in index.html and profile.html do escape + linkify but do NOT
resolve NIP-19 ids, so wiring them in would change behaviour. The test asserts
exactly ten renderContentText call sites and exactly two remaining copies.
The media rules are no longer duplicated: each page calls the shared assembler,
which is the single place that classifies a URL. grep -c 'assembleMedia(' *.html
must still add up to ten call sites across the seven pages; if a new page needs
media, call assembleMedia there and bump that number deliberately.
The surrounding page HTML (card layout, action buttons) is still written per page — that part is not shared and is not covered by the count above.
Developed for decentralization. Free to modify, fork, and use as needed. Hack the planet! 🌐