* 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>
- Updated @radix-ui/react-dialog from ^1.1.2 to ^1.1.15
- Updated @radix-ui/react-separator from ^1.1.0 to ^1.1.8
- Updated @radix-ui/react-slot from ^1.1.0 to ^1.2.4
- Updated @radix-ui/react-tooltip from ^1.1.4 to ^1.2.8
- Added a new hook `useIsMobile` to determine if the viewport is mobile-sized
- Refactored tailwind.config.ts for better readability and maintainability