Skip to content
jeannesbryanPublic

About

Phosphor is a lightweight, cyberpunk-terminal-styled Nostr web client built on the KISS principle. Features offline-first local caching, multi-media uploads, Proof-of-Work mining, long-form articles, sport activity tracking, ephemeral stories, and multi-relay support.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Phosphor 📟

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.

🚀 Key Features

  • 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 q tag, 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 e tag 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.json file.
  • 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.js and 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-tools already 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.

🛠️ Installation & Usage

Since Phosphor runs 100% purely client-side (Vanilla HTML/CSS/JS), there is no dependency hell, compilation, or complex backend setup required.

  1. Clone or download this repository.
  2. Ensure the config.json files are in the same directory. (Edit config.json to customize your default relays).
  3. 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.
  4. Open your browser and navigate to the address.

🧪 Tests

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.

📜 License

Developed for decentralization. Free to modify, fork, and use as needed. Hack the planet! 🌐

About

Phosphor is a lightweight, cyberpunk-terminal-styled Nostr web client built on the KISS principle. Features offline-first local caching, multi-media uploads, Proof-of-Work mining, long-form articles, sport activity tracking, ephemeral stories, and multi-relay support.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages