Files
vega/src/components/feed/TextSegments.tsx
Jure 80838fb204 Refactor: split overgrown files into focused modules
Split client.ts (1036 lines) into 11 domain modules under lib/nostr/ —
core, notes, social, articles, engagement, dms, bookmarks, muting,
search, relays, trending. Barrel index.ts re-exports all; zero consumer
import changes.

Extract ProfileView sub-components (ImageField, Nip05Field,
EditProfileForm, ProfileMediaGallery), NoteContent renderers
(TextSegments, MediaCards), and NoteCard actions (NoteActions,
InlineReplyBox). All component files now ≤270 lines, all lib files ≤300.
2026-03-20 16:32:50 +01:00

117 lines
3.6 KiB
TypeScript

import { ReactNode } from "react";
import { nip19 } from "@nostr-dev-kit/ndk";
import { useUIStore } from "../../stores/ui";
import { useProfile } from "../../hooks/useProfile";
import { ContentSegment } from "../../lib/parsing";
// Returns true if we handled the URL internally (njump.me interception).
export 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.
export function tryOpenNostrEntity(raw: string): boolean {
try {
const decoded = nip19.decode(raw);
const { openProfile, openArticle } = 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;
}
if (decoded.type === "naddr") {
const { kind } = decoded.data as { kind: number; pubkey: string; identifier: string };
if (kind === 30023) {
openArticle(raw);
return true;
}
}
// note / nevent / other naddr kinds — fall through to njump.me
} catch { /* invalid entity */ }
return false;
}
export function MentionName({ pubkey, fallback }: { pubkey?: string; fallback: string }) {
const profile = useProfile(pubkey ?? "");
if (!pubkey) return <>{fallback}</>;
const name = profile?.displayName || profile?.name;
return <>{name || fallback}</>;
}
interface RenderTextSegmentsOptions {
/** If true, use MentionName component for mentions (inline mode). If false, use seg.display directly. */
resolveMentions?: boolean;
}
export function renderTextSegments(
segments: ContentSegment[],
openHashtag: (tag: string) => void,
options: RenderTextSegmentsOptions = {}
): ReactNode[] {
const { resolveMentions = false } = options;
const elements: ReactNode[] = [];
segments.forEach((seg, i) => {
switch (seg.type) {
case "text":
elements.push(<span key={i}>{seg.value}</span>);
break;
case "link":
elements.push(
<a
key={i}
href={seg.value}
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>
);
break;
case "mention":
elements.push(
<span
key={i}
className="text-accent cursor-pointer hover:text-accent-hover"
onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(seg.value); }}
>
@{resolveMentions
? <MentionName pubkey={seg.mentionPubkey} fallback={seg.display ?? seg.value.slice(0, 12) + "…"} />
: seg.display}
</span>
);
break;
case "hashtag":
elements.push(
<span
key={i}
className="text-accent/80 cursor-pointer hover:text-accent"
onClick={(e) => { e.stopPropagation(); openHashtag(seg.value); }}
>
{seg.display}
</span>
);
break;
default:
break;
}
});
return elements;
}