mirror of
https://github.com/hoornet/vega.git
synced 2026-05-12 16:38:36 -07:00
Resolve naddr references to clickable named links
Parse naddr bech32 references as a dedicated segment type with kind, pubkey, and identifier. NaddrName component fetches the referenced event and displays its title/name/d-tag. Clicking navigates to article view (kind 30023) or author profile (other kinds). Supports emoji sets, app listings, and other addressable content.
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode, useState, useEffect } from "react";
|
||||||
import { nip19 } from "@nostr-dev-kit/ndk";
|
import { nip19, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
import { useUIStore } from "../../stores/ui";
|
import { useUIStore } from "../../stores/ui";
|
||||||
import { useProfile } from "../../hooks/useProfile";
|
import { useProfile } from "../../hooks/useProfile";
|
||||||
import { ContentSegment } from "../../lib/parsing";
|
import { ContentSegment } from "../../lib/parsing";
|
||||||
|
import { getNDK, fetchWithTimeout } from "../../lib/nostr";
|
||||||
|
|
||||||
// Returns true if we handled the URL internally (njump.me interception).
|
// Returns true if we handled the URL internally (njump.me interception).
|
||||||
export function tryHandleUrlInternally(url: string): boolean {
|
export function tryHandleUrlInternally(url: string): boolean {
|
||||||
@@ -31,17 +32,66 @@ export function tryOpenNostrEntity(raw: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (decoded.type === "naddr") {
|
if (decoded.type === "naddr") {
|
||||||
const { kind } = decoded.data as { kind: number; pubkey: string; identifier: string };
|
const { kind, pubkey } = decoded.data as { kind: number; pubkey: string; identifier: string };
|
||||||
if (kind === 30023) {
|
if (kind === 30023) {
|
||||||
openArticle(raw);
|
openArticle(raw);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// For other addressable kinds (app listings, emoji sets, etc.)
|
||||||
|
// open the author's profile as the best available in-app destination
|
||||||
|
if (pubkey) {
|
||||||
|
openProfile(pubkey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// note / nevent / other naddr kinds — fall through to njump.me
|
// note / nevent — fall through to njump.me
|
||||||
} catch { /* invalid entity */ }
|
} catch { /* invalid entity */ }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves an naddr reference to a human-readable name by fetching the event. */
|
||||||
|
function NaddrName({ kind, pubkey, identifier, fallback }: { kind: number; pubkey: string; identifier: string; fallback: string }) {
|
||||||
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const filter: NDKFilter = { kinds: [kind as NDKKind], authors: [pubkey], "#d": [identifier], limit: 1 };
|
||||||
|
const events = await fetchWithTimeout(getNDK(), filter, 5000);
|
||||||
|
if (cancelled) return;
|
||||||
|
const event = Array.from(events)[0];
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
// Helper: return string if non-empty, else undefined
|
||||||
|
const tagVal = (key: string) => {
|
||||||
|
const v = event.tags.find((t) => t[0] === key)?.[1];
|
||||||
|
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try tags first: title, name, d
|
||||||
|
let resolved = tagVal("title") || tagVal("name") || tagVal("d");
|
||||||
|
|
||||||
|
// If no tag found, try parsing content as JSON (some kinds store metadata in content)
|
||||||
|
if (!resolved && event.content) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(event.content);
|
||||||
|
const candidate = json.display_name || json.name || json.title;
|
||||||
|
if (typeof candidate === "string" && candidate.trim()) {
|
||||||
|
resolved = candidate.trim();
|
||||||
|
}
|
||||||
|
} catch { /* not JSON, ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved) setName(resolved);
|
||||||
|
} catch { /* keep fallback */ }
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [kind, pubkey, identifier]);
|
||||||
|
|
||||||
|
return <>{name || fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export function MentionName({ pubkey, fallback }: { pubkey?: string; fallback: string }) {
|
export function MentionName({ pubkey, fallback }: { pubkey?: string; fallback: string }) {
|
||||||
const profile = useProfile(pubkey ?? "");
|
const profile = useProfile(pubkey ?? "");
|
||||||
if (!pubkey) return <>{fallback}</>;
|
if (!pubkey) return <>{fallback}</>;
|
||||||
@@ -97,6 +147,24 @@ export function renderTextSegments(
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "naddr":
|
||||||
|
elements.push(
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="text-accent cursor-pointer hover:text-accent-hover inline-flex items-center gap-0.5"
|
||||||
|
onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(seg.value); }}
|
||||||
|
title={`Kind ${seg.naddrKind} — click to open`}
|
||||||
|
>
|
||||||
|
<span className="opacity-60 text-xs">🔗</span>
|
||||||
|
<NaddrName
|
||||||
|
kind={seg.naddrKind!}
|
||||||
|
pubkey={seg.naddrPubkey!}
|
||||||
|
identifier={seg.naddrIdentifier!}
|
||||||
|
fallback={seg.value.slice(0, 16) + "…"}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
break;
|
||||||
case "hashtag":
|
case "hashtag":
|
||||||
elements.push(
|
elements.push(
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9
|
|||||||
const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
|
const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
|
||||||
|
|
||||||
export interface ContentSegment {
|
export interface ContentSegment {
|
||||||
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "fountain" | "mention" | "hashtag" | "quote";
|
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "fountain" | "mention" | "hashtag" | "quote" | "naddr";
|
||||||
value: string; // for "quote": the hex event ID
|
value: string; // for "quote": the hex event ID; for "naddr": raw bech32
|
||||||
display?: string;
|
display?: string;
|
||||||
mediaId?: string; // video/embed ID for youtube/vimeo
|
mediaId?: string; // video/embed ID for youtube/vimeo
|
||||||
mediaType?: string; // e.g. "track", "album" for spotify/tidal
|
mediaType?: string; // e.g. "track", "album" for spotify/tidal
|
||||||
mentionPubkey?: string; // hex pubkey for npub/nprofile mentions
|
mentionPubkey?: string; // hex pubkey for npub/nprofile mentions
|
||||||
|
naddrKind?: number; // event kind for naddr references
|
||||||
|
naddrPubkey?: string; // author pubkey for naddr references
|
||||||
|
naddrIdentifier?: string; // "d" tag for naddr references
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseContent(content: string): ContentSegment[] {
|
export function parseContent(content: string): ContentSegment[] {
|
||||||
@@ -118,6 +121,8 @@ export function parseContent(content: string): ContentSegment[] {
|
|||||||
let mentionPubkey: string | undefined;
|
let mentionPubkey: string | undefined;
|
||||||
|
|
||||||
let isQuote = false;
|
let isQuote = false;
|
||||||
|
let isNaddr = false;
|
||||||
|
let naddrData: { kind: number; pubkey: string; identifier: string } | null = null;
|
||||||
let eventId = "";
|
let eventId = "";
|
||||||
try {
|
try {
|
||||||
const decoded = nip19.decode(raw);
|
const decoded = nip19.decode(raw);
|
||||||
@@ -138,6 +143,10 @@ export function parseContent(content: string): ContentSegment[] {
|
|||||||
} else {
|
} else {
|
||||||
display = "event:" + raw.slice(7, 15) + "…";
|
display = "event:" + raw.slice(7, 15) + "…";
|
||||||
}
|
}
|
||||||
|
} else if (decoded.type === "naddr") {
|
||||||
|
const d = decoded.data as { kind: number; pubkey: string; identifier: string };
|
||||||
|
isNaddr = true;
|
||||||
|
naddrData = d;
|
||||||
}
|
}
|
||||||
} catch { /* keep default */ }
|
} catch { /* keep default */ }
|
||||||
|
|
||||||
@@ -146,7 +155,9 @@ export function parseContent(content: string): ContentSegment[] {
|
|||||||
length: match[0].length,
|
length: match[0].length,
|
||||||
segment: isQuote
|
segment: isQuote
|
||||||
? { type: "quote", value: eventId }
|
? { type: "quote", value: eventId }
|
||||||
: { type: "mention", value: raw, display, mentionPubkey },
|
: isNaddr && naddrData
|
||||||
|
? { type: "naddr", value: raw, naddrKind: naddrData.kind, naddrPubkey: naddrData.pubkey, naddrIdentifier: naddrData.identifier }
|
||||||
|
: { type: "mention", value: raw, display, mentionPubkey },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user