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 <noreply@anthropic.com>
This commit is contained in:
Jure
2026-03-09 20:47:57 +01:00
parent 2cdf99b725
commit b7853a14e2
3 changed files with 65 additions and 6 deletions

View File

@@ -1,5 +1,6 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { nip19 } from "@nostr-dev-kit/ndk"; import { nip19 } from "@nostr-dev-kit/ndk";
import { useUIStore } from "../../stores/ui";
// Regex patterns // Regex patterns
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g; const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
@@ -116,7 +117,39 @@ function parseContent(content: string): ContentSegment[] {
return segments; 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 }) { export function NoteContent({ content }: { content: string }) {
const { openSearch } = useUIStore();
const segments = parseContent(content); const segments = parseContent(content);
const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value); 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); 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" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/40" className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/40"
onClick={(e) => {
if (tryHandleUrlInternally(seg.value)) e.preventDefault();
}}
> >
{seg.display} {seg.display}
</a> </a>
@@ -146,6 +182,10 @@ export function NoteContent({ content }: { content: string }) {
<span <span
key={i} key={i}
className="text-accent cursor-pointer hover:text-accent-hover" className="text-accent cursor-pointer hover:text-accent-hover"
onClick={(e) => {
e.stopPropagation();
tryOpenNostrEntity(seg.value);
}}
> >
@{seg.display} @{seg.display}
</span> </span>
@@ -156,6 +196,10 @@ export function NoteContent({ content }: { content: string }) {
<span <span
key={i} key={i}
className="text-accent/80 cursor-pointer hover:text-accent" className="text-accent/80 cursor-pointer hover:text-accent"
onClick={(e) => {
e.stopPropagation();
openSearch(`#${seg.value}`);
}}
> >
{seg.display} {seg.display}
</span> </span>

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react"; import { useState, useRef, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { searchNotes, searchUsers } from "../../lib/nostr"; import { searchNotes, searchUsers } from "../../lib/nostr";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
@@ -88,7 +88,8 @@ function UserRow({ user }: { user: ParsedUser }) {
} }
export function SearchView() { export function SearchView() {
const [query, setQuery] = useState(""); const { pendingSearch } = useUIStore();
const [query, setQuery] = useState(pendingSearch ?? "");
const [noteResults, setNoteResults] = useState<NDKEvent[]>([]); const [noteResults, setNoteResults] = useState<NDKEvent[]>([]);
const [userResults, setUserResults] = useState<ParsedUser[]>([]); const [userResults, setUserResults] = useState<ParsedUser[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -98,14 +99,24 @@ export function SearchView() {
const isHashtag = query.trim().startsWith("#"); const isHashtag = query.trim().startsWith("#");
const handleSearch = async () => { // If opened with a pending search query (e.g. from a hashtag click), run it immediately
const q = query.trim(); useEffect(() => {
if (pendingSearch) {
useUIStore.setState({ pendingSearch: null });
handleSearch(pendingSearch);
}
}, []);
const handleSearch = async (overrideQuery?: string) => {
const q = (overrideQuery ?? query).trim();
if (!q) return; if (!q) return;
if (overrideQuery) setQuery(overrideQuery);
setLoading(true); setLoading(true);
setSearched(false); setSearched(false);
try { try {
const isTag = q.startsWith("#");
const notesPromise = searchNotes(q); 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]); const [notes, userEvents] = await Promise.all([notesPromise, usersPromise]);
setNoteResults(notes); setNoteResults(notes);
setUserResults(userEvents.map(parseUserEvent)); 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" className="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-dim focus:outline-none"
/> />
<button <button
onClick={handleSearch} onClick={() => handleSearch()}
disabled={!query.trim() || loading} disabled={!query.trim() || loading}
className="text-[11px] px-3 py-1 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-30 disabled:cursor-not-allowed shrink-0" className="text-[11px] px-3 py-1 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-30 disabled:cursor-not-allowed shrink-0"
> >

View File

@@ -10,9 +10,11 @@ interface UIState {
selectedPubkey: string | null; selectedPubkey: string | null;
selectedNote: NDKEvent | null; selectedNote: NDKEvent | null;
previousView: View; previousView: View;
pendingSearch: string | null;
setView: (view: View) => void; setView: (view: View) => void;
openProfile: (pubkey: string) => void; openProfile: (pubkey: string) => void;
openThread: (note: NDKEvent, from: View) => void; openThread: (note: NDKEvent, from: View) => void;
openSearch: (query: string) => void;
goBack: () => void; goBack: () => void;
toggleSidebar: () => void; toggleSidebar: () => void;
} }
@@ -23,9 +25,11 @@ export const useUIStore = create<UIState>((set, _get) => ({
selectedPubkey: null, selectedPubkey: null,
selectedNote: null, selectedNote: null,
previousView: "feed", previousView: "feed",
pendingSearch: null,
setView: (currentView) => set({ currentView }), setView: (currentView) => set({ currentView }),
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })), openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }), openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
openSearch: (query) => set({ currentView: "search", pendingSearch: query }),
goBack: () => set((s) => ({ goBack: () => set((s) => ({
currentView: s.previousView !== s.currentView ? s.previousView : "feed", currentView: s.previousView !== s.currentView ? s.previousView : "feed",
selectedNote: null, selectedNote: null,