From b7853a14e2771bc1de8ac50781527e43e7fad600 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:47:57 +0100 Subject: [PATCH] Wire up NIP-19 / NIP-21 navigation + hashtag search from notes - Mention clicks (nostr:npub1, nostr:nprofile) open internal ProfileView - njump.me links intercepted: npub/nprofile decoded and opened internally, note/nevent/naddr fall through to browser (no reader yet) - Hashtag clicks navigate to SearchView and auto-run the search - openSearch(query) action added to UIStore; pendingSearch consumed on mount Co-Authored-By: Claude Sonnet 4.6 --- src/components/feed/NoteContent.tsx | 44 ++++++++++++++++++++++++++++ src/components/search/SearchView.tsx | 23 +++++++++++---- src/stores/ui.ts | 4 +++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx index 205c986..a33acc9 100644 --- a/src/components/feed/NoteContent.tsx +++ b/src/components/feed/NoteContent.tsx @@ -1,5 +1,6 @@ import { ReactNode } from "react"; import { nip19 } from "@nostr-dev-kit/ndk"; +import { useUIStore } from "../../stores/ui"; // Regex patterns const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g; @@ -116,7 +117,39 @@ function parseContent(content: string): ContentSegment[] { return segments; } +// Returns true if we handled the URL internally (njump.me interception). +function tryHandleUrlInternally(url: string): boolean { + try { + const u = new URL(url); + if (u.hostname === "njump.me") { + const entity = u.pathname.replace(/^\//, ""); + if (entity) return tryOpenNostrEntity(entity); + } + } catch { /* not a valid URL */ } + return false; +} + +// Decodes a NIP-19 bech32 string and navigates internally where possible. +// Returns true if handled, false if the caller should fall back to a browser open. +function tryOpenNostrEntity(raw: string): boolean { + try { + const decoded = nip19.decode(raw); + const { openProfile } = useUIStore.getState(); + if (decoded.type === "npub") { + openProfile(decoded.data as string); + return true; + } + if (decoded.type === "nprofile") { + openProfile((decoded.data as { pubkey: string }).pubkey); + return true; + } + // note / nevent / naddr — no internal reader yet, fall through to njump.me + } catch { /* invalid entity */ } + return false; +} + export function NoteContent({ content }: { content: string }) { + const { openSearch } = useUIStore(); const segments = parseContent(content); const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value); const videos: string[] = segments.filter((s) => s.type === "video").map((s) => s.value); @@ -136,6 +169,9 @@ export function NoteContent({ content }: { content: string }) { target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/40" + onClick={(e) => { + if (tryHandleUrlInternally(seg.value)) e.preventDefault(); + }} > {seg.display} @@ -146,6 +182,10 @@ export function NoteContent({ content }: { content: string }) { { + e.stopPropagation(); + tryOpenNostrEntity(seg.value); + }} > @{seg.display} @@ -156,6 +196,10 @@ export function NoteContent({ content }: { content: string }) { { + e.stopPropagation(); + openSearch(`#${seg.value}`); + }} > {seg.display} diff --git a/src/components/search/SearchView.tsx b/src/components/search/SearchView.tsx index 3db2b4b..0113280 100644 --- a/src/components/search/SearchView.tsx +++ b/src/components/search/SearchView.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { searchNotes, searchUsers } from "../../lib/nostr"; import { useUserStore } from "../../stores/user"; @@ -88,7 +88,8 @@ function UserRow({ user }: { user: ParsedUser }) { } export function SearchView() { - const [query, setQuery] = useState(""); + const { pendingSearch } = useUIStore(); + const [query, setQuery] = useState(pendingSearch ?? ""); const [noteResults, setNoteResults] = useState([]); const [userResults, setUserResults] = useState([]); const [loading, setLoading] = useState(false); @@ -98,14 +99,24 @@ export function SearchView() { const isHashtag = query.trim().startsWith("#"); - const handleSearch = async () => { - const q = query.trim(); + // If opened with a pending search query (e.g. from a hashtag click), run it immediately + useEffect(() => { + if (pendingSearch) { + useUIStore.setState({ pendingSearch: null }); + handleSearch(pendingSearch); + } + }, []); + + const handleSearch = async (overrideQuery?: string) => { + const q = (overrideQuery ?? query).trim(); if (!q) return; + if (overrideQuery) setQuery(overrideQuery); setLoading(true); setSearched(false); try { + const isTag = q.startsWith("#"); const notesPromise = searchNotes(q); - const usersPromise = isHashtag ? Promise.resolve([]) : searchUsers(q); + const usersPromise = isTag ? Promise.resolve([]) : searchUsers(q); const [notes, userEvents] = await Promise.all([notesPromise, usersPromise]); setNoteResults(notes); setUserResults(userEvents.map(parseUserEvent)); @@ -137,7 +148,7 @@ export function SearchView() { className="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-dim focus:outline-none" />