mirror of
https://github.com/hoornet/vega.git
synced 2026-05-17 05:14: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 { 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}
|
||||
</a>
|
||||
@@ -146,6 +182,10 @@ export function NoteContent({ content }: { content: string }) {
|
||||
<span
|
||||
key={i}
|
||||
className="text-accent cursor-pointer hover:text-accent-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
tryOpenNostrEntity(seg.value);
|
||||
}}
|
||||
>
|
||||
@{seg.display}
|
||||
</span>
|
||||
@@ -156,6 +196,10 @@ export function NoteContent({ content }: { content: string }) {
|
||||
<span
|
||||
key={i}
|
||||
className="text-accent/80 cursor-pointer hover:text-accent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openSearch(`#${seg.value}`);
|
||||
}}
|
||||
>
|
||||
{seg.display}
|
||||
</span>
|
||||
|
||||
@@ -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<NDKEvent[]>([]);
|
||||
const [userResults, setUserResults] = useState<ParsedUser[]>([]);
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
onClick={() => handleSearch()}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -10,9 +10,11 @@ interface UIState {
|
||||
selectedPubkey: string | null;
|
||||
selectedNote: NDKEvent | null;
|
||||
previousView: View;
|
||||
pendingSearch: string | null;
|
||||
setView: (view: View) => void;
|
||||
openProfile: (pubkey: string) => void;
|
||||
openThread: (note: NDKEvent, from: View) => void;
|
||||
openSearch: (query: string) => void;
|
||||
goBack: () => void;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
@@ -23,9 +25,11 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
selectedPubkey: null,
|
||||
selectedNote: null,
|
||||
previousView: "feed",
|
||||
pendingSearch: null,
|
||||
setView: (currentView) => set({ currentView }),
|
||||
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
|
||||
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
|
||||
openSearch: (query) => set({ currentView: "search", pendingSearch: query }),
|
||||
goBack: () => set((s) => ({
|
||||
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
|
||||
selectedNote: null,
|
||||
|
||||
Reference in New Issue
Block a user