* Initial plan
* Add NIP-86 protocol client library with tests
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
* Add NIP-86 session hook and relay-admin app UI
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
* Add integration tests, NIP.md, and apps.md docs for Relay Admin
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
* Address review feedback on Relay Admin app
- sanitizeIconUrl() now only allows http:// for local relay hostnames
(localhost/127.0.0.1/::1/*.local), matching its docstring and error
message instead of accepting arbitrary http:// URLs.
- Scope the discovery-time cache clear to the previous session's own
relay URL instead of removing every ['nip86'] query, so connecting in
one Relay Admin window no longer disrupts other open windows.
- Use the AllowedPubkey type (not BannedPubkey) when mapping allowed-
pubkey entries, since the structural overlap today made a real type
mismatch invisible.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Initial plan
* Add nested reply threading to note detail view
Derive parent/child relationships from NIP-10 references and render replies
beneath their logical parent instead of as a flat list. Replies can be
answered inline at any point in the thread, with proper root/reply markers
and p tags. Deep nesting is capped so threads stay readable on mobile, the
conversation exposes tree semantics to assistive technology, and malformed,
cyclic, or incomplete reference chains render safely in a separate section
instead of breaking the thread.
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
* Render orphaned reply sub-trees and open the composer for orphaned notes
A reply whose parent was never fetched is promoted out of the thread, but
its own children resolved against it fine — keep them nested beneath it
instead of dropping them, and mount the inline composer when an orphaned
note is the reply target so the Reply button never claims a composer that
isn't there (aria-expanded/aria-controls now always point at a real node).
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
* Restore package-lock.json platform flags dropped by a stray npm install
No dependency changed; keep the lockfile byte-identical to the base branch.
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
* Apply remaining changes
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
* Address review feedback on nested reply threading
- Remove invalid aria-setsize={-1}; the real set size isn't tracked here.
- Make aria-expanded an explicit boolean and keep aria-controls consistent
across active/inactive Reply buttons.
- Fix cycle field docstring to match its boolean type.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Add mute, block, and report controls
Adds a ModerationMenu (mute/block/report) to notes and profiles, backed
by a NIP-51 mute list (kind 10000, private by default via NIP-44) and
NIP-56 reports (kind 1984). Blocking mutes and also removes the account
from the follow list. Muted accounts are filtered out of the feed and
notifications, and a new Settings section lists them with an Unmute
control to reverse either choice.
Closes#51.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* Fix mute-list review findings
- decryptPrivateTags now marks non-array decrypted JSON as unreadable
(ok: false) instead of silently treating it as an empty tag list,
so a rewrite can't clobber private entries it failed to parse.
- useSetPubkeyMuted's onSuccess now writes the cache using the pubkey
captured by mutationFn at call time instead of re-reading `user`,
which could point at a different (or logged-out) account by the
time the mutation resolves.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Add Lightning zaps with optional Nostr Wallet Connect
Signed-in users can zap a note or reply, and see its zap total, from
the feed, a thread view, and replies. Zapping opens a dialog to pick
an amount (presets or custom) and an optional comment, then:
- Builds and signs a NIP-57 zap request and fetches an invoice from
the recipient's LNURL/lud16 callback (rejecting non-https endpoints).
- If a wallet is connected via Nostr Wallet Connect (NIP-47, Settings
> Lightning wallet), pays the invoice automatically and only reports
success once the wallet returns a payment preimage.
- Otherwise shows the invoice as a QR code plus a copy/`lightning:`
link for the user's own wallet, and polls for a matching zap receipt
to confirm payment without ever asserting success it can't verify.
Zap totals sum kind-9735 receipts defensively: a receipt only counts
if it carries a bolt11 amount and a description whose embedded zap
request is a well-formed, signature-valid event, so a malformed or
forged receipt can't inflate the total.
The NWC connection secret is stored only in this browser, scoped to
the signed-in pubkey, and is used solely to sign/send payment requests
to the wallet's own relay — never published, logged, or shown besides
a truncated pubkey once connected.
Closes#54
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* Address PR #62 review feedback: NWC relay security, N+1 zap queries, manual-payment confirmation
Addresses Copilot review comments on PR #62:
- nwc.ts: reject plaintext ws:// Wallet Connect relays, requiring wss://.
Every request is signed by the connection secret (a private key); even
with an encrypted payload, an unencrypted transport still leaks metadata
about the connection and admits tampering.
- ZapButton/useZaps: fixed the N+1 query pattern — a feed page mounted one
unconditional zap-receipts query (limit 500) per rendered note. Added a
`revealed` gate so the query only fires once a note is actually hovered
or focused (the same interaction that already reveals the action row via
CSS), confirmed live: 0 queries fired across 49 mounted notes before any
interaction, exactly 1 after hovering one.
- ZapDialog: manual-payment confirmation compared the note's total receipt
*count* against a baseline, so anyone else zapping the same note while
the dialog waited would falsely confirm the viewer's own unpaid invoice.
Added `hasValidReceiptForInvoice` to match against the specific invoice
instead, with unit tests covering the exact race the review described.
- useZaps.ts: corrected a docstring overclaiming that receipt validation
prevents "forged" receipts from inflating totals — it only rules out
structurally invalid data; NIP-57 receipts are vouched for by the
recipient's own LNURL server, so trusting one is inherent to the
protocol, not something client-side validation can prove.
Investigated but did not change: the review's claim that
`nip04.decrypt(...)` needs an `await` because it returns a Promise. Not
correct for this project's actual `nostr-tools` dependency — confirmed by
running the real encrypt/decrypt round trip, `decrypt` is synchronous and
returns the plaintext string directly, so the existing `JSON.parse` call
already worked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* Merge main and resolve action conflicts
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* Add NIP-25 reactions to notes and replies
Signed-in users can like a note or reply from the feed, a thread view,
or its replies. The like count and the viewer's own reaction state
come from kind-7 events tagged to the target; liking publishes a kind
7 with content "+", and un-liking publishes a NIP-09 deletion of the
viewer's own reaction rather than a competing "-" event, since
deletions are what most relays and clients actually honor. Updates are
optimistic (instant toggle, rollback on publish failure) and reconcile
against relays afterward. Signed-out users get the sign-in dialog
instead of a silent no-op.
Closes#53
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* Fix reaction un-like to delete all of the user's reaction events
Addresses Copilot review feedback on PR #61: deleting only the latest
of a user's kind-7 reactions let an older `+` resurface as "the"
reaction after refetch, silently re-inflating the like count. Now a
single NIP-09 deletion covers every one of the viewer's own reaction
events on the target, tagged with `k` (7) to match this repo's other
deletion events (useWebBookmarks).
While validating this live against real relays, found and fixed two
more bugs in the same toggle mutation, both stemming from the
eventual-consistency trap already fixed for mute lists on another
branch:
- `onSettled` force-invalidated the reactions query right after a
successful publish; the refetch could hit a relay that hadn't
indexed the new event yet and silently revert a like back to
"unliked" about a second later.
- Fixing that naively (dropping the invalidate) left the optimistic
placeholder's fake `optimistic:...` id in the cache forever, so a
like immediately followed by an unlike built a deletion event that
targeted an id no relay had ever seen.
Both are fixed by writing the mutation's own known-correct result
(the real signed event, or its removal) straight into the query
cache on success, instead of trusting an immediate relay re-read.
Confirmed live with a throwaway account against production relays:
liking persists after the optimistic phase, unliking's deletion event
targets the real reaction id, and two full like/unlike cycles remain
stable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: Calendar app for NIP-52 date-based and time-based events
Adds a Calendar app with a polished month grid as the default view:
today/selected-day highlighting, adjacent-month days, per-day event
pills capped with a "+N more" indicator, and a day agenda panel with
an ordered event list. Supports combinable filters (event type,
author, free-text search) with a visible active-filter count and
per-filter clearing, keyboard navigation (arrows/Home/End/PageUp/
PageDown) with roving tabindex, loading skeletons, empty/error states,
and a NIP-19 share link for each event. naddr links for kind
31922/31923 now open the Calendar app instead of the Reader.
Time-based events (kind 31923) are queried with a relay-side `#D`
day-granularity filter; date-based events (kind 31922) have no
indexable date field in NIP-52, so they're bounded by `limit` and
filtered client-side against the viewed range — documented in
useCalendarEvents.ts.
Spike decision: built a custom month grid instead of using the
existing react-day-picker wrapper or a new calendar library.
react-day-picker is built for single/range date selection, not dense
per-cell event content or multi-day spanning; a full calendar library
would add real weight and its own theming to reconcile with shadcn.
A CSS-grid month view is the smallest solution that meets the
month-grid, filtering, and accessibility requirements with zero new
dependencies.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: address Copilot review — keyboard trap, midnight formatting, nprofile hint
- MonthGrid: exactly one gridcell must stay tab-focusable. When no day is
selected and the displayed month doesn't contain today (e.g. after a
PageUp/PageDown jump), every cell previously got tabIndex=-1, trapping
keyboard users out of the grid. Falls back to the 1st of the month.
- calendarEvents: formatEventTimeRange treated `end` as inclusive when
checking same-day, so a time-based event ending exactly at local
midnight formatted as a cross-day range even though eventDateKeys()
attributes it to the start day only. Now compares against end-1ms,
consistent with eventDateKeys().
- CalendarFilters: the author placeholder/error text only mentioned
npub/hex even though nprofile is accepted (resolveAuthorInput handles
it) — updated both to mention all three.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: address second Copilot review round on Calendar app
- MonthGrid: focusKey preferred selectedDate even when it fell outside
the currently rendered grid (selectedDate survives month navigation
so the agenda keeps showing it, so after Prev/Next/PageUp/PageDown
it commonly points at a day no longer on screen) — every cell got
tabIndex=-1 again, reopening the keyboard trap fixed last round.
Now only trusted when it's actually one of the rendered cells.
- MonthGrid: the weekday header row sat as a sibling before the
role="grid" element instead of inside it, so assistive tech
couldn't associate the columnheaders with the grid. Grid role now
wraps both the header row and the week rows.
- MonthGrid: the loading skeleton always rendered 35 cells (5 weeks)
even for 6-week months, causing a layout jump once the real 42-cell
grid replaced it. Now sized from the same buildWeeks() count used
for the real grid.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: guard arrow-key navigation against out-of-grid dates in MonthGrid
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* Add notifications center
* Fix notification event selection and account read-state sync
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
* Fix notification bell triggers
* Keep notification badge within menu bar
* Place notification count beside bell
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* feat: Spells app for saved, shareable Nostr queries (grimoire kind 777)
The original feedback mentioned "grimoire spells" — traced to
github.com/purrgrammer/grimoire, a third-party Nostr client with its
own draft NIP for kind 777 "Spell" events: a REQ filter (kinds,
authors, one tag filter, limit, time window) encoded as portable,
shareable tags, with $me/$contacts runtime variables and relative
timestamps ("7d", "now").
- src/hooks/useSpells.ts implements that draft NIP as-is (same tags,
same variables, same relative-time grammar) rather than a
reinterpretation, so a spell saved here round-trips with Grimoire.
- src/apps/spells: browse "My Spells" / "Discover", build one with
NewSpellForm, and Run it on demand against the resolved filter,
rendering kind-1 results with NoteCard.
- Only the "Spell" half is implemented; "Spellbook" (kind 30777,
saved window layouts) is left as a documented follow-up.
Closes#24
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: address review feedback on the Spells app
Per review:
- Scope is now derived (forced to "discover" when signed out) rather
than stored as the requested value directly, matching the Feed
app's pattern — signing out mid-session can no longer leave "My
Spells" selected.
- resolveSpellFilter() now validates a spell's tag-filter letter
(single a-zA-Z char) before using it as a "#<letter>" filter key,
and clamps limit to [1, 500] instead of trusting a relay-sourced
spell's number outright — a malformed or hostile spell can no
longer produce a "#undefined" filter key or an enormous/NaN/zero
limit. Added regression tests for all of these.
- NewSpellForm's Field now renders a real <label htmlFor> connected
to each input's id (via useId()), and the Authors button group
moved to a <fieldset>/<legend> instead of a label sitting over
unrelated buttons — screen readers can now name every control.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: validate spell tag filter/limit at write time, and hide malformed badges
Per review:
- encodeSpellTags() now validates the tag filter's letter and the
limit before writing them, instead of only resolveSpellFilter()
catching bad values on Run — a spell authored through this app can
no longer save a filter it will silently fail to apply later.
Exported isValidTagLetter() so both sides share one definition of
"valid."
- The spell detail view's tag-filter badge now hides itself for a
malformed tag filter (e.g. from a relay-sourced spell this app
didn't author) instead of rendering "#undefined:" or similar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: reject non-integer/negative spell kinds, de-flake a timing test
Per remaining "previously missed" findings:
- parseSpell() now requires k tags to be non-negative integers
(Number.isInteger && >= 0), not just finite — a relay-sourced spell
claiming kind "1.5" or "-1" no longer passes through into a
malformed filter.
- The resolveTimestamp wall-clock tests asserted toBeCloseTo a single
captured `now`, which a slow runner or timing skew between the two
Date.now() calls could flake. Replaced with a before/after range
assertion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Adds a Live app (kind 30311 live events + kind 1311 live chat),
scoped to broadcast streams only per the issue discussion — Spaces/
interactive rooms (kind 30312/30313) are a separate, larger effort
tracked in a follow-up issue.
- src/apps/live/index.tsx: sidebar list (live streams first, then
planned, then ended, newest within each bucket) and a detail pane
with title/summary/host/status/topics and a link out to the
`streaming` (or `recording`, once ended) URL.
- src/apps/live/LiveChat.tsx: kind 1311 messages tagged to the stream
via `a`, with a composer.
- No embedded video player: NIP-53 streams are typically HLS, which
needs a library (hls.js) to play in-browser — deferred rather than
pulled in for a first cut. docs/apps.md explains the tradeoff.
Closes#22
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: web bookmarks for arbitrary URLs (NIP-B0)
Adds a Web Bookmarks app backed by NIP-B0 (kind 39701): one
addressable event per saved URL, distinct from the NIP-51 bookmark
list (#21) since it carries its own title/description/tags per page
rather than being an entry in a list.
- src/hooks/useWebBookmarks.ts: create/list/delete, plus
bookmarkDTag/bookmarkUrl implementing the spec's "strip https://"
d-tag rule (round-tripped by a unit test).
- Delete publishes a NIP-09 kind 5 request and also drops the item
from the local query cache directly, since relays aren't obligated
to honor the deletion.
- New src/apps/web-bookmarks/index.tsx: inline add form, list with
title/description/tags, opens the saved URL in a new tab.
Closes#25
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: address review feedback on web bookmarks
Per review:
- bookmarkDTag() now matches the https scheme case-insensitively, so
"HTTPS://…" and "https://…" collapse to the same d tag instead of
creating duplicate bookmarks.
- The form now accepts every scheme sanitizeUrl() allows (https,
http, mailto, nostr) via a dedicated isBookmarkableUrl() check —
not sanitizeUrl() itself, which resolves relative URLs against this
app's own origin and would have "validated" a bare hostname like
"example.com" as a link back into the app.
- WebBookmarkRow no longer falls back to the raw unsanitized URL when
sanitizeUrl() rejects it (e.g. a malicious "d" tag) — it renders
plain text with no link instead of defeating the sanitization.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: round-trip mailto:/nostr: bookmarks and preserve published_at
Per review:
- bookmarkUrl() required "scheme://" to recognize an already-schemed
d tag, so opaque URIs with no "//" — mailto: and nostr: — were
incorrectly prefixed with "https://". Fixed by also checking a
closed list of the opaque schemes this app supports, alongside the
existing "://" check (kept as-is so a hierarchical scheme like
gemini:// still round-trips, and so a stripped https URL containing
a port, e.g. alice.blog:8080/post, still isn't misread as scheme
"alice.blog"). Added regression tests for all three cases.
- useCreateWebBookmark now looks up the existing bookmark for the
same d tag before publishing and carries its published_at forward,
instead of resetting it to now on every edit — per NIP-B0,
published_at is "the first time the bookmark was published."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: dedupe multiple revisions of the same web bookmark
Per an earlier "previously missed" finding: useMyWebBookmarks()
returned every kind-39701 event a relay handed back, but for an
addressable event the pool can return more than one revision of the
same d tag (an edit history, or relays disagreeing on what's
current), which showed up as duplicate rows for the same URL.
Extracted dedupeLatestByDTag() (keeps the newest per d, newest-first)
and covered it with regression tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: highlight text in the Reader (NIP-84)
Adds NIP-84 highlights (kind 9802) to the article reader:
- Selecting text in an article shows a floating "Highlight" button
(src/apps/articles/HighlightLayer.tsx), publishing the selected
plain text tagged to the article (`a`) and its author (`p`, role
"author").
- Existing highlights for the article are listed underneath it, with
the highlighter's identity and timestamp.
Closes#23
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: harden selection handling and clamp the highlight button
Per review:
- Guard sel.rangeCount === 0 before calling getRangeAt(0), which
throws otherwise.
- Scope containment by the range's commonAncestorContainer instead of
just anchorNode, so a selection that starts inside the article but
is dragged out past its boundary is correctly rejected.
- Clamp the floating button's top so a selection near the top of the
viewport doesn't push it off-screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: don't highlight against a malformed address, gate the listener
Per a "needs a closer look" review pass:
- articles/index.tsx now passes an empty string, not a malformed
"kind:pubkey:" address, when an article has no d tag. HighlightLayer
treats a falsy address as "highlighting isn't available here."
- The selectionchange listener is only registered when both user and
address are present (in the effect's deps), instead of always
running selection tracking regardless of whether a highlight could
ever be published.
- handleHighlight and the floating button both guard on address too,
not just selection, so stale selection state from before a prop
change went missing can't still trigger a publish.
- docs/apps.md corrected: the saved text comes from Selection.toString()
(window.getSelection()), not Range.toString().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: bookmarks for notes and articles (NIP-51)
Adds a Bookmarks app backed by a kind 10003 NIP-51 bookmark list:
- BookmarkButton toggles a note (`e` tag) or article (`a` tag) in and
out of the signed-in user's list, reading it back before publishing
so an update never clobbers other entries — the same whole-list
replacement trap follow lists have.
- Wired into NoteCard's action row and the Reader's article toolbar.
- The new Bookmarks app lists saved notes and articles, opening
articles back in the Reader.
Closes#21
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: address review feedback and add a Bookmarked tab to the Reader
Per review:
- useToggleBookmark now fetches the bookmark list fresh from relays
right before writing instead of trusting the query cache (60s
staleTime), which could otherwise clobber concurrent edits from
another tab or device.
- The article BookmarkButton only renders when the article actually
has a `d` tag, instead of falling back to an unresolvable
"kind:pubkey:" address.
- Bookmarked note/article ids are filtered for a non-empty tag value
before use, and article addresses are parsed properly (kind,
author, `d`) instead of a naive split(':')[2] — the relay query is
now also constrained by kind and author, not just `d`, and
identifiers containing ':' round-trip correctly.
- BookmarkButton sets type="button" so it can't misbehave as a form
submit button.
Per a reviewer comment: added a "Recent" / "Bookmarked" tab to the
Reader's sidebar (src/apps/articles/index.tsx) so bookmarked articles
are reachable without leaving the app — the dedicated Bookmarks app
stays as-is. Both now share useMyBookmarkedArticles from
src/hooks/useBookmarks.ts rather than duplicating the address-parsing
logic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: reject empty-identifier addresses and surface bookmark load errors
Per review:
- parseAddress() now rejects an empty d-identifier as malformed
(e.g. "30023:<pubkey>:") instead of producing a "#d: ['']" relay
query and an unopenable bookmark.
- useMyBookmarkedArticles() filters out matched events with empty
content, the same non-renderable criteria the Reader's own list
uses, so a broken/blank article can't land in the Bookmarked view.
- BookmarksApp now distinguishes "the query failed" from "there are
no bookmarks" — React Query leaves data undefined in both cases, so
a relay/network failure no longer reads as an empty list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: local draft notes with a blank "new note" entry point
Writing was tied to publishing: the Feed composer either sits empty
or fires a note straight to relays, with nowhere to keep something
you're not ready to publish yet.
The Note app now supports a draft mode when opened without an id: a
blank note kept in localStorage until you publish it or discard it,
reachable via a new "New note" button in the Feed toolbar, the Go
menu, or the command palette (all already open the Note app with no
params).
Closes#19
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: guard against double-publish and dropped relay params
Per review:
- handlePublish now also checks publish.isPending itself, not just
the button's disabled state — a second click landing before React
re-renders could otherwise fire mutateAsync twice.
- Publishing a draft now merges into the existing params instead of
replacing them outright, so relay hints (or anything else already
in params) survive the id being added.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* docs: mark notes app's id param as optional
Per review — the draft mode added by this PR means id is no longer
required to open the Note app.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: sync useLocalStorage across same-tab consumers of one key
Per review: the Notes app is explicitly non-singleton, so opening two
"New Note" windows meant two DraftNote instances writing the same
localStorage key independently — the native `storage` event only
fires in *other* tabs/documents, never the one that wrote, so the two
windows would silently diverge (discard/publish in one wouldn't
update the other).
useLocalStorage now also dispatches a same-document custom event on
every write, and every instance sharing that key listens for it —
verified live with two open draft windows staying in sync as one is
typed into.
Also dropped a redundant `{}` params argument on an openApp() call
that every other call site omits when opening with no parameters.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* fix: exclude replies from the Feed and Profile timelines
Per NIP-10 a kind-1 event with an `e` tag is a reply, but the Feed and
Profile timelines rendered every kind-1 event with no such check, so
replies showed up indistinguishable from root posts. `isReply()` was
already written for this in nostrUtils but never used anywhere.
Fixes#20
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: don't treat mention-only e tags as replies
Per review: isReply() flagged any e tag as a reply, including one
marked "mention" — a citation, not a thread reply per NIP-10. That
would have hidden quote-notes from the Feed/Profile timelines they
belong in. Also fixed rootReference()'s docstring, which claimed to
fall back to the *last* positional e tag when the code (correctly)
uses the first.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: rootReference() no longer treats a mention as the root
Follow-up to the isReply() fix: rootReference()'s positional fallback
still matched any e tag regardless of marker, so an event with only a
mention-marked e tag would incorrectly return the mentioned id as the
thread root. The fallback now only considers unmarked e tags, per the
deprecated positional NIP-10 scheme.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
The switcher only focused the window it was given. `FOCUS_WINDOW` raises
and focuses but never un-minimizes, while the mobile shell renders a
window only when it is focused *and* not minimized — so picking a
minimized app updated the URL and the document title (both follow
`focusedId`) while the home screen stayed on screen.
Restore a minimized window instead of merely focusing it, the same way the
menu bar's Window menu and the command palette already do.
Fixes#15
Claude-Session: https://claude.ai/code/session_01Trku191Ww2a2YmDWQF3fTS
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`mapWindow` flagged the state as changed whenever the target window id
matched, even when the updater handed the very same window object back.
`SET_TITLE` is guarded against a no-op title, but that guard was defeated:
the reducer still returned a fresh state object, so the context value
changed, every consumer re-rendered, and each app's `setTitle` effect —
keyed on a `setTitle` callback that the mobile shell re-created on every
render — dispatched `SET_TITLE` again. That loop ran continuously while an
app was open on mobile; opening the app-switcher Sheet on top of it made
Radix re-attach its composed refs on every one of those renders until
React bailed out with "Maximum update depth exceeded" and the
ErrorBoundary took over.
Only count a window as changed when the updater actually returned a
different object, and memoise the mobile shell's `setTitle`/`setParams`
per window, matching what `WindowFrame` already does.
Fixes#14
Claude-Session: https://claude.ai/code/session_01Trku191Ww2a2YmDWQF3fTS
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Raising a window made it visibly jump to the top-left corner and animate
back. Two things combined: the enter keyframes animated `transform`, which
overrode the inline `translate3d` a window's position is applied with, and
the window layer rendered the z-sorted list, so focusing a window moved its
DOM node and made the browser replay that animation.
Animate the standalone `scale` property instead — it composes with the
inline transform rather than replacing it — scope the animation to
`.os-window` (it was hitting every child of the layer, including the snap
preview, which is positioned the same way), and render windows in their
stable creation order, since the inline `z-index` already handles stacking.
Fixes#13
Claude-Session: https://claude.ai/code/session_01Trku191Ww2a2YmDWQF3fTS
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* new web os frontend
* add docs
* Add CNAME and restore NIP-05 nostr.json for GitHub Pages
The Pages custom domain (layer.systems) is only stored in repo settings;
a CNAME file in the build output makes it survive Pages reconfiguration.
Restore public/.well-known/nostr.json, which this branch had dropped —
removing it would break the existing NIP-05 identifiers on layer.systems.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi
* Ignore eslint and tsc build caches
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi
* Rebrand page title and metadata to LAYER.systems
The site ships on layer.systems, so the document title, meta and OG
description, and the web manifest now carry that name instead of
"Nostr OS". OsShell sets the title at runtime, so it is updated too —
otherwise the tab would fall back to the old branding after hydration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi
* Rename remaining visible "Nostr OS" strings to LAYER.systems
Covers the About window heading, the mobile shell header and the app
icon's aria-label, so the visible branding matches the page title.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>