From 0b70d257127394b812b887cd188b81eef50a4832 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:53:14 +0100 Subject: [PATCH] Note rendering + login system - Rich note content parser: clickable links, inline images, videos - URL shortening for display, trailing punctuation cleanup - nostr: mention parsing (npub, note, nevent, nprofile) - Hashtag highlighting - NIP-05 display on note cards - Login modal with nsec (full access) and npub (read-only) modes - User store with Zustand, NDK signer integration - Sidebar shows logged-in user avatar/name + logout - Login state persisted via localStorage (pubkey only, never nsec) --- src/components/feed/NoteCard.tsx | 11 +- src/components/feed/NoteContent.tsx | 211 +++++++++++++++++++++++++++ src/components/shared/LoginModal.tsx | 121 +++++++++++++++ src/components/sidebar/Sidebar.tsx | 145 ++++++++++++------ src/lib/nostr/client.ts | 2 +- src/stores/user.ts | 113 ++++++++++++++ 6 files changed, 555 insertions(+), 48 deletions(-) create mode 100644 src/components/feed/NoteContent.tsx create mode 100644 src/components/shared/LoginModal.tsx create mode 100644 src/stores/user.ts diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index 4c92b2b..15f9f57 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -1,6 +1,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useProfile } from "../../hooks/useProfile"; import { timeAgo, shortenPubkey } from "../../lib/utils"; +import { NoteContent } from "./NoteContent"; interface NoteCardProps { event: NDKEvent; @@ -10,6 +11,7 @@ export function NoteCard({ event }: NoteCardProps) { const profile = useProfile(event.pubkey); const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); const avatar = profile?.picture; + const nip05 = profile?.nip05; const time = event.created_at ? timeAgo(event.created_at) : ""; return ( @@ -40,11 +42,14 @@ export function NoteCard({ event }: NoteCardProps) { {name} + {nip05 && ( + + {nip05} + + )} {time} -
- {event.content} -
+ diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx new file mode 100644 index 0000000..205c986 --- /dev/null +++ b/src/components/feed/NoteContent.tsx @@ -0,0 +1,211 @@ +import { ReactNode } from "react"; +import { nip19 } from "@nostr-dev-kit/ndk"; + +// Regex patterns +const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g; +const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?$/i; +const VIDEO_EXTENSIONS = /\.(mp4|webm|mov)(\?[^\s]*)?$/i; +const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g; +const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g; + +interface ContentSegment { + type: "text" | "link" | "image" | "video" | "mention" | "hashtag"; + value: string; + display?: string; +} + +function parseContent(content: string): ContentSegment[] { + const segments: ContentSegment[] = []; + const allMatches: { index: number; length: number; segment: ContentSegment }[] = []; + + // Find URLs + let match: RegExpExecArray | null; + const urlRegex = new RegExp(URL_REGEX.source, "g"); + while ((match = urlRegex.exec(content)) !== null) { + const url = match[0]; + // Clean trailing punctuation that's likely not part of the URL + const cleaned = url.replace(/[.,;:!?)]+$/, ""); + + if (IMAGE_EXTENSIONS.test(cleaned)) { + allMatches.push({ + index: match.index, + length: cleaned.length, + segment: { type: "image", value: cleaned }, + }); + } else if (VIDEO_EXTENSIONS.test(cleaned)) { + allMatches.push({ + index: match.index, + length: cleaned.length, + segment: { type: "video", value: cleaned }, + }); + } else { + // Shorten display URL + let display = cleaned; + try { + const u = new URL(cleaned); + display = u.hostname + (u.pathname !== "/" ? u.pathname : ""); + if (display.length > 50) display = display.slice(0, 47) + "…"; + } catch { /* keep as-is */ } + + allMatches.push({ + index: match.index, + length: cleaned.length, + segment: { type: "link", value: cleaned, display }, + }); + } + } + + // Find nostr: mentions + const mentionRegex = new RegExp(NOSTR_MENTION_REGEX.source, "g"); + while ((match = mentionRegex.exec(content)) !== null) { + const raw = match[1]; + let display = raw.slice(0, 12) + "…"; + + try { + const decoded = nip19.decode(raw); + if (decoded.type === "npub") { + display = raw.slice(0, 12) + "…"; + } else if (decoded.type === "note") { + display = "note:" + raw.slice(5, 13) + "…"; + } else if (decoded.type === "nevent") { + display = "event:" + raw.slice(7, 15) + "…"; + } + } catch { /* keep default */ } + + allMatches.push({ + index: match.index, + length: match[0].length, + segment: { type: "mention", value: raw, display }, + }); + } + + // Find hashtags + const hashtagRegex = new RegExp(HASHTAG_REGEX.source, "g"); + while ((match = hashtagRegex.exec(content)) !== null) { + allMatches.push({ + index: match.index, + length: match[0].length, + segment: { type: "hashtag", value: match[1], display: `#${match[1]}` }, + }); + } + + // Sort matches by index, remove overlaps + allMatches.sort((a, b) => a.index - b.index); + const filtered: typeof allMatches = []; + let lastEnd = 0; + for (const m of allMatches) { + if (m.index >= lastEnd) { + filtered.push(m); + lastEnd = m.index + m.length; + } + } + + // Build segments + let cursor = 0; + for (const m of filtered) { + if (m.index > cursor) { + segments.push({ type: "text", value: content.slice(cursor, m.index) }); + } + segments.push(m.segment); + cursor = m.index + m.length; + } + if (cursor < content.length) { + segments.push({ type: "text", value: content.slice(cursor) }); + } + + return segments; +} + +export function NoteContent({ content }: { content: string }) { + 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); + + const inlineElements: ReactNode[] = []; + + segments.forEach((seg, i) => { + switch (seg.type) { + case "text": + inlineElements.push({seg.value}); + break; + case "link": + inlineElements.push( + + {seg.display} + + ); + break; + case "mention": + inlineElements.push( + + @{seg.display} + + ); + break; + case "hashtag": + inlineElements.push( + + {seg.display} + + ); + break; + case "image": + case "video": + // Rendered separately below the text + break; + } + }); + + return ( +
+
+ {inlineElements} +
+ + {/* Images */} + {images.length > 0 && ( +
1 ? "grid grid-cols-2 gap-1" : ""}`}> + {images.map((src, i) => ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ))} +
+ )} + + {/* Videos */} + {videos.length > 0 && ( +
+ {videos.map((src, i) => ( +
+ )} +
+ ); +} diff --git a/src/components/shared/LoginModal.tsx b/src/components/shared/LoginModal.tsx new file mode 100644 index 0000000..2761519 --- /dev/null +++ b/src/components/shared/LoginModal.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { useUserStore } from "../../stores/user"; + +interface LoginModalProps { + onClose: () => void; +} + +export function LoginModal({ onClose }: LoginModalProps) { + const [tab, setTab] = useState<"nsec" | "pubkey">("nsec"); + const [input, setInput] = useState(""); + const { loginWithNsec, loginWithPubkey, loginError } = useUserStore(); + + const handleLogin = async () => { + if (!input.trim()) return; + + if (tab === "nsec") { + await loginWithNsec(input.trim()); + } else { + await loginWithPubkey(input.trim()); + } + + // Close if no error + if (!useUserStore.getState().loginError) { + onClose(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleLogin(); + if (e.key === "Escape") onClose(); + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Login

+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Input */} +
+ + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={tab === "nsec" ? "nsec1…" : "npub1…"} + autoFocus + className="w-full bg-bg border border-border px-3 py-2 text-text text-[13px] font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50" + /> + + {tab === "nsec" && ( +

+ Your key stays local. Never sent to any server. +

+ )} + + {tab === "pubkey" && ( +

+ Read-only mode — you can browse but not post or zap. +

+ )} + + {loginError && ( +

{loginError}

+ )} + + +
+
+
+ ); +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 3cbcc1b..caf232b 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -1,5 +1,9 @@ +import { useState } from "react"; import { useUIStore } from "../../stores/ui"; import { useFeedStore } from "../../stores/feed"; +import { useUserStore } from "../../stores/user"; +import { LoginModal } from "../shared/LoginModal"; +import { shortenPubkey } from "../../lib/utils"; const NAV_ITEMS = [ { id: "feed" as const, label: "feed", icon: "◈" }, @@ -10,55 +14,108 @@ const NAV_ITEMS = [ export function Sidebar() { const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore(); const { connected, notes } = useFeedStore(); + const { loggedIn, profile, npub, logout } = useUserStore(); + const [showLogin, setShowLogin] = useState(false); + + const userName = profile?.displayName || profile?.name || (npub ? shortenPubkey(npub) : null); + const userAvatar = profile?.picture; return ( - + + {/* Nav */} + + + {/* User / Login */} + {!sidebarCollapsed && ( +
+ {loggedIn ? ( +
+
+ {userAvatar ? ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( +
+ {(userName || "?").charAt(0).toUpperCase()} +
+ )} + + {userName} + +
+ +
+ ) : ( +
+ +
+ )} +
+ )} + + {/* Status footer */} + {!sidebarCollapsed && ( +
+
+ + {connected ? "online" : "offline"} +
+
{notes.length} notes
+
+ )} + + + {showLogin && setShowLogin(false)} />} + ); } diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts index a377886..a101019 100644 --- a/src/lib/nostr/client.ts +++ b/src/lib/nostr/client.ts @@ -19,7 +19,7 @@ export function getNDK(): NDK { } function waitForConnectedRelay(instance: NDK, timeoutMs = 10000): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve, _reject) => { const timer = setTimeout(() => { // Even on timeout, continue — some relays may connect later console.warn("Relay connection timeout, continuing anyway"); diff --git a/src/stores/user.ts b/src/stores/user.ts new file mode 100644 index 0000000..13259b2 --- /dev/null +++ b/src/stores/user.ts @@ -0,0 +1,113 @@ +import { create } from "zustand"; +import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; +import { getNDK } from "../lib/nostr"; +import { nip19 } from "@nostr-dev-kit/ndk"; + +interface UserState { + pubkey: string | null; + npub: string | null; + profile: any | null; + loggedIn: boolean; + loginError: string | null; + + loginWithNsec: (nsec: string) => Promise; + loginWithPubkey: (pubkey: string) => Promise; + logout: () => void; + fetchOwnProfile: () => Promise; +} + +export const useUserStore = create((set, get) => ({ + pubkey: null, + npub: null, + profile: null, + loggedIn: false, + loginError: null, + + loginWithNsec: async (nsecInput: string) => { + try { + set({ loginError: null }); + + let privkey: string; + + // Handle both nsec and raw hex + if (nsecInput.startsWith("nsec1")) { + const decoded = nip19.decode(nsecInput); + if (decoded.type !== "nsec") { + throw new Error("Invalid nsec key"); + } + privkey = decoded.data as string; + } else { + privkey = nsecInput; + } + + const signer = new NDKPrivateKeySigner(privkey); + const ndk = getNDK(); + ndk.signer = signer; + + const user = await signer.user(); + const pubkey = user.pubkey; + const npub = nip19.npubEncode(pubkey); + + set({ pubkey, npub, loggedIn: true, loginError: null }); + + // Store login (pubkey only, never the nsec) + localStorage.setItem("wrystr_pubkey", pubkey); + localStorage.setItem("wrystr_login_type", "nsec"); + + // Fetch profile + get().fetchOwnProfile(); + } catch (err) { + set({ loginError: `Login failed: ${err}` }); + } + }, + + loginWithPubkey: async (pubkeyInput: string) => { + try { + set({ loginError: null }); + + let pubkey: string; + + if (pubkeyInput.startsWith("npub1")) { + const decoded = nip19.decode(pubkeyInput); + if (decoded.type !== "npub") { + throw new Error("Invalid npub"); + } + pubkey = decoded.data as string; + } else { + pubkey = pubkeyInput; + } + + const npub = nip19.npubEncode(pubkey); + set({ pubkey, npub, loggedIn: true, loginError: null }); + + localStorage.setItem("wrystr_pubkey", pubkey); + localStorage.setItem("wrystr_login_type", "pubkey"); + + get().fetchOwnProfile(); + } catch (err) { + set({ loginError: `Login failed: ${err}` }); + } + }, + + logout: () => { + const ndk = getNDK(); + ndk.signer = undefined; + localStorage.removeItem("wrystr_pubkey"); + localStorage.removeItem("wrystr_login_type"); + set({ pubkey: null, npub: null, profile: null, loggedIn: false, loginError: null }); + }, + + fetchOwnProfile: async () => { + const { pubkey } = get(); + if (!pubkey) return; + + try { + const ndk = getNDK(); + const user = ndk.getUser({ pubkey }); + await user.fetchProfile(); + set({ profile: user.profile }); + } catch { + // Profile fetch is non-critical + } + }, +}));