mirror of
https://github.com/hoornet/vega.git
synced 2026-05-16 12:54:51 -07:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user