From c180e0eb51656d45de2486f4ab9b7c48e7371d4f Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:37:13 +0200 Subject: [PATCH 1/2] fix: exclude replies from the Feed and Profile timelines (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --------- Co-authored-by: highperfocused Co-authored-by: Claude Sonnet 5 --- src/apps/feed/index.tsx | 11 +++++- src/apps/profile/index.tsx | 9 ++++- src/lib/nostrUtils.test.ts | 78 ++++++++++++++++++++++++++++++++++++++ src/lib/nostrUtils.ts | 16 ++++++-- 4 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/lib/nostrUtils.test.ts diff --git a/src/apps/feed/index.tsx b/src/apps/feed/index.tsx index dc25322..bfca4ed 100644 --- a/src/apps/feed/index.tsx +++ b/src/apps/feed/index.tsx @@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMyFollows } from '@/hooks/useFollows'; import { cn } from '@/lib/utils'; +import { isReply } from '@/lib/nostrUtils'; import type { AppProps } from '@/os/types'; type Scope = 'following' | 'global'; @@ -20,9 +21,17 @@ const PAGE_SIZE = 50; /** * A kind 1 event is only worth rendering if it has something to render. Relays * happily return blanks and oddities, so the feed validates before it draws. + * Replies (NIP-10 `e` tags) are excluded too: without their parent for + * context they read as indistinguishable, orphaned root posts — open the + * thread from the Note app instead. */ function isRenderableNote(event: NostrEvent): boolean { - return event.kind === 1 && typeof event.content === 'string' && event.content.trim().length > 0; + return ( + event.kind === 1 && + typeof event.content === 'string' && + event.content.trim().length > 0 && + !isReply(event) + ); } function useFeed(scope: Scope, authors: string[] | undefined) { diff --git a/src/apps/profile/index.tsx b/src/apps/profile/index.tsx index ba9f76f..651aa7a 100644 --- a/src/apps/profile/index.tsx +++ b/src/apps/profile/index.tsx @@ -14,9 +14,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMyFollows } from '@/hooks/useFollows'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; -import { decodeRelayHints, displayName, npubOf, sanitizeUrl } from '@/lib/nostrUtils'; +import { decodeRelayHints, displayName, isReply, npubOf, sanitizeUrl } from '@/lib/nostrUtils'; import type { AppProps } from '@/os/types'; +/** + * Replies are excluded here for the same reason as the Feed: without their + * parent for context, a reply on a profile's timeline reads as an orphaned + * root post rather than what it is. + */ function useAuthorNotes(pubkey: string | undefined, relays: string[] | undefined) { const { nostr } = useNostr(); @@ -29,7 +34,7 @@ function useAuthorNotes(pubkey: string | undefined, relays: string[] | undefined { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]), relays }, ); return events - .filter((event) => event.content.trim().length > 0) + .filter((event) => event.content.trim().length > 0 && !isReply(event)) .sort((a, b) => b.created_at - a.created_at); }, staleTime: 60_000, diff --git a/src/lib/nostrUtils.test.ts b/src/lib/nostrUtils.test.ts new file mode 100644 index 0000000..e90a105 --- /dev/null +++ b/src/lib/nostrUtils.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { isReply, rootReference } from './nostrUtils'; + +function note(tags: string[][]): NostrEvent { + return { + id: 'x', + pubkey: 'y', + created_at: 0, + kind: 1, + tags, + content: 'hello', + sig: '', + }; +} + +describe('isReply', () => { + it('is false for a root note with no e tag', () => { + expect(isReply(note([]))).toBe(false); + }); + + it('is true for a note with a marked root e tag', () => { + expect(isReply(note([['e', 'root-id', '', 'root']]))).toBe(true); + }); + + it('is true for a note using the deprecated positional e tag', () => { + expect(isReply(note([['e', 'parent-id']]))).toBe(true); + }); + + it('is false for a note that only mentions another event', () => { + expect(isReply(note([['e', 'mentioned-id', '', 'mention']]))).toBe(false); + }); + + it('is true for a note with a mention alongside a marked reply', () => { + expect( + isReply( + note([ + ['e', 'root-id', '', 'root'], + ['e', 'mentioned-id', '', 'mention'], + ]), + ), + ).toBe(true); + }); +}); + +describe('rootReference', () => { + it('prefers the marked root tag over positional ones', () => { + const event = note([ + ['e', 'mention-id', '', 'mention'], + ['e', 'root-id', '', 'root'], + ]); + expect(rootReference(event)).toBe('root-id'); + }); + + it('falls back to the first positional e tag per the deprecated scheme', () => { + const event = note([ + ['e', 'root-id'], + ['e', 'reply-id'], + ]); + expect(rootReference(event)).toBe('root-id'); + }); + + it('is undefined for a root note', () => { + expect(rootReference(note([]))).toBeUndefined(); + }); + + it('is undefined for a note that only mentions another event', () => { + expect(rootReference(note([['e', 'mentioned-id', '', 'mention']]))).toBeUndefined(); + }); + + it('ignores a mention when falling back to the positional scheme', () => { + const event = note([ + ['e', 'mentioned-id', '', 'mention'], + ['e', 'root-id'], + ]); + expect(rootReference(event)).toBe('root-id'); + }); +}); diff --git a/src/lib/nostrUtils.ts b/src/lib/nostrUtils.ts index 7b1247d..76de0a9 100644 --- a/src/lib/nostrUtils.ts +++ b/src/lib/nostrUtils.ts @@ -93,15 +93,25 @@ export function tagValues(event: NostrEvent, name: string): string[] { /** * The event a reply points at, following NIP-10: prefer an explicit `root` - * marker, fall back to the last positional `e` tag. + * marker, fall back to the first *unmarked* `e` tag (the deprecated scheme + * puts the root id first: `["e", ], ["e", ]`). A `mention` + * or `reply`-only marker is never treated as the root — a mention isn't + * part of the thread, and a lone `reply` marker without `root` is malformed + * per NIP-10 rather than an implicit root. */ export function rootReference(event: NostrEvent): string | undefined { const marked = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'root'); if (marked) return marked[1]; - const positional = event.tags.filter(([name]) => name === 'e'); + const positional = event.tags.filter(([name, , , marker]) => name === 'e' && !marker); return positional[0]?.[1]; } +/** + * True for a reply per NIP-10: a marked `root`/`reply` `e` tag, or an + * unmarked one (the deprecated positional scheme). An `e` tag marked + * `mention` alone does not make an event a reply — it cites another event + * without being part of its thread. + */ export function isReply(event: NostrEvent): boolean { - return event.tags.some(([name]) => name === 'e'); + return event.tags.some(([name, , , marker]) => name === 'e' && marker !== 'mention'); } From e7cf9c4677b61eafb061aa1ea36f3e13fb921e9f Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:40:40 +0200 Subject: [PATCH 2/2] feat: local draft notes with a blank "new note" entry point (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 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 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --------- Co-authored-by: highperfocused Co-authored-by: Claude Sonnet 5 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- docs/apps.md | 2 +- src/apps/feed/index.tsx | 13 ++++- src/apps/notes/Draft.tsx | 107 +++++++++++++++++++++++++++++++++++ src/apps/notes/index.tsx | 9 +-- src/hooks/useLocalStorage.ts | 67 +++++++++++++++++----- 5 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 src/apps/notes/Draft.tsx diff --git a/docs/apps.md b/docs/apps.md index 6cc09bf..4997cd5 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -95,7 +95,7 @@ export default function ExampleApp({ setTitle }: AppProps) { |---|---|---|---| | Feed | `feed` | — | kind 1 timeline, Following/Global, composer (⌘↵ publishes) | | Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow | -| Note | `notes` | `id`, `relays?` | One note and its replies. **Not** a singleton | +| Note | `notes` | `id?`, `relays?` | One note and its replies, or a blank local draft when `id` is absent. **Not** a singleton | | Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` | | Relays | `relays` | — | Connection state, subscription count, measured latency | | Settings | `settings` | — | Theme, relay list, Blossom servers, account, session | diff --git a/src/apps/feed/index.tsx b/src/apps/feed/index.tsx index bfca4ed..63e676e 100644 --- a/src/apps/feed/index.tsx +++ b/src/apps/feed/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; -import { Globe, Loader2, Users } from 'lucide-react'; +import { FileText, Globe, Loader2, Users } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome'; import { NoteCard } from '@/components/nostr/NoteCard'; @@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMyFollows } from '@/hooks/useFollows'; import { cn } from '@/lib/utils'; +import { useWindowManager } from '@/os/useWindowManager'; import { isReply } from '@/lib/nostrUtils'; import type { AppProps } from '@/os/types'; @@ -60,6 +61,7 @@ function useFeed(scope: Scope, authors: string[] | undefined) { export default function FeedApp({ setTitle }: AppProps) { const { user } = useCurrentUser(); + const { openApp } = useWindowManager(); const { data: follows } = useMyFollows(); const [requestedScope, setScope] = useState('following'); @@ -94,6 +96,15 @@ export default function FeedApp({ setTitle }: AppProps) { />
{query.isFetching && } + + )} + {user && ( + + )} +
+ + + +