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)
This commit is contained in:
Jure
2026-03-08 16:53:14 +01:00
parent b75ccb7f46
commit 0b70d25712
6 changed files with 555 additions and 48 deletions

View File

@@ -1,6 +1,7 @@
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile"; import { useProfile } from "../../hooks/useProfile";
import { timeAgo, shortenPubkey } from "../../lib/utils"; import { timeAgo, shortenPubkey } from "../../lib/utils";
import { NoteContent } from "./NoteContent";
interface NoteCardProps { interface NoteCardProps {
event: NDKEvent; event: NDKEvent;
@@ -10,6 +11,7 @@ export function NoteCard({ event }: NoteCardProps) {
const profile = useProfile(event.pubkey); const profile = useProfile(event.pubkey);
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
const avatar = profile?.picture; const avatar = profile?.picture;
const nip05 = profile?.nip05;
const time = event.created_at ? timeAgo(event.created_at) : ""; const time = event.created_at ? timeAgo(event.created_at) : "";
return ( return (
@@ -40,11 +42,14 @@ export function NoteCard({ event }: NoteCardProps) {
<span className="text-text font-medium truncate text-[13px]"> <span className="text-text font-medium truncate text-[13px]">
{name} {name}
</span> </span>
{nip05 && (
<span className="text-text-dim text-[10px] truncate max-w-40">
{nip05}
</span>
)}
<span className="text-text-dim text-[11px] shrink-0">{time}</span> <span className="text-text-dim text-[11px] shrink-0">{time}</span>
</div> </div>
<div className="note-content text-text text-[13px] break-words whitespace-pre-wrap"> <NoteContent content={event.content} />
{event.content}
</div>
</div> </div>
</div> </div>
</article> </article>

View File

@@ -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(<span key={i}>{seg.value}</span>);
break;
case "link":
inlineElements.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"
>
{seg.display}
</a>
);
break;
case "mention":
inlineElements.push(
<span
key={i}
className="text-accent cursor-pointer hover:text-accent-hover"
>
@{seg.display}
</span>
);
break;
case "hashtag":
inlineElements.push(
<span
key={i}
className="text-accent/80 cursor-pointer hover:text-accent"
>
{seg.display}
</span>
);
break;
case "image":
case "video":
// Rendered separately below the text
break;
}
});
return (
<div>
<div className="note-content text-text text-[13px] break-words whitespace-pre-wrap leading-relaxed">
{inlineElements}
</div>
{/* Images */}
{images.length > 0 && (
<div className={`mt-2 ${images.length > 1 ? "grid grid-cols-2 gap-1" : ""}`}>
{images.map((src, i) => (
<img
key={i}
src={src}
alt=""
loading="lazy"
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
))}
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div className="mt-2">
{videos.map((src, i) => (
<video
key={i}
src={src}
controls
preload="metadata"
className="max-w-full max-h-80 rounded-sm bg-bg-raised border border-border"
/>
))}
</div>
)}
</div>
);
}

View File

@@ -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 (
<div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-bg-raised border border-border w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="text-text text-sm font-medium">Login</h2>
<button
onClick={onClose}
className="text-text-dim hover:text-text text-lg leading-none"
>
×
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-border">
<button
onClick={() => setTab("nsec")}
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
tab === "nsec"
? "text-accent border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
Private key (nsec)
</button>
<button
onClick={() => setTab("pubkey")}
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
tab === "pubkey"
? "text-accent border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
Public key (read-only)
</button>
</div>
{/* Input */}
<div className="p-4">
<label className="block text-text-muted text-[11px] mb-1.5">
{tab === "nsec"
? "Paste your nsec or hex private key"
: "Paste your npub or hex public key"}
</label>
<input
type={tab === "nsec" ? "password" : "text"}
value={input}
onChange={(e) => 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" && (
<p className="text-text-dim text-[10px] mt-1.5">
Your key stays local. Never sent to any server.
</p>
)}
{tab === "pubkey" && (
<p className="text-text-dim text-[10px] mt-1.5">
Read-only mode you can browse but not post or zap.
</p>
)}
{loginError && (
<p className="text-danger text-[11px] mt-2">{loginError}</p>
)}
<button
onClick={handleLogin}
disabled={!input.trim()}
className="w-full mt-3 px-4 py-2 text-[12px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{tab === "nsec" ? "Login" : "View as read-only"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { useState } from "react";
import { useUIStore } from "../../stores/ui"; import { useUIStore } from "../../stores/ui";
import { useFeedStore } from "../../stores/feed"; import { useFeedStore } from "../../stores/feed";
import { useUserStore } from "../../stores/user";
import { LoginModal } from "../shared/LoginModal";
import { shortenPubkey } from "../../lib/utils";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: "feed" as const, label: "feed", icon: "◈" }, { id: "feed" as const, label: "feed", icon: "◈" },
@@ -10,55 +14,108 @@ const NAV_ITEMS = [
export function Sidebar() { export function Sidebar() {
const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore(); const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore();
const { connected, notes } = useFeedStore(); 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 ( return (
<aside <>
className={`h-full border-r border-border bg-bg flex flex-col transition-all duration-150 ${ <aside
sidebarCollapsed ? "w-12" : "w-48" className={`h-full border-r border-border bg-bg flex flex-col transition-all duration-150 ${
}`} sidebarCollapsed ? "w-12" : "w-48"
> }`}
{/* Logo */} >
<div className="border-b border-border px-3 py-2.5 flex items-center justify-between shrink-0"> {/* Logo */}
<button <div className="border-b border-border px-3 py-2.5 flex items-center justify-between shrink-0">
onClick={toggleSidebar}
className="text-text hover:text-accent transition-colors"
>
{sidebarCollapsed ? (
<span className="text-sm font-bold">W</span>
) : (
<span className="text-sm font-bold tracking-widest">WRYSTR</span>
)}
</button>
</div>
{/* Nav */}
<nav className="flex-1 py-2">
{NAV_ITEMS.map((item) => (
<button <button
key={item.id} onClick={toggleSidebar}
onClick={() => setView(item.id)} className="text-text hover:text-accent transition-colors"
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 text-[12px] transition-colors ${
currentView === item.id
? "text-accent bg-accent/8"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
> >
<span className="w-4 text-center text-[14px]">{item.icon}</span> {sidebarCollapsed ? (
{!sidebarCollapsed && <span>{item.label}</span>} <span className="text-sm font-bold">W</span>
) : (
<span className="text-sm font-bold tracking-widest">WRYSTR</span>
)}
</button> </button>
))}
</nav>
{/* Status footer */}
{!sidebarCollapsed && (
<div className="border-t border-border px-3 py-2 text-[10px] text-text-dim">
<div className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-success" : "bg-danger"}`} />
<span>{connected ? "online" : "offline"}</span>
</div>
<div className="mt-0.5">{notes.length} notes</div>
</div> </div>
)}
</aside> {/* Nav */}
<nav className="flex-1 py-2">
{NAV_ITEMS.map((item) => (
<button
key={item.id}
onClick={() => setView(item.id)}
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 text-[12px] transition-colors ${
currentView === item.id
? "text-accent bg-accent/8"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
<span className="w-4 text-center text-[14px]">{item.icon}</span>
{!sidebarCollapsed && <span>{item.label}</span>}
</button>
))}
</nav>
{/* User / Login */}
{!sidebarCollapsed && (
<div className="border-t border-border">
{loggedIn ? (
<div className="px-3 py-2">
<div className="flex items-center gap-2 mb-1.5">
{userAvatar ? (
<img
src={userAvatar}
alt=""
className="w-6 h-6 rounded-sm object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-6 h-6 rounded-sm bg-accent/20 flex items-center justify-center text-accent text-[10px]">
{(userName || "?").charAt(0).toUpperCase()}
</div>
)}
<span className="text-text text-[11px] truncate flex-1">
{userName}
</span>
</div>
<button
onClick={logout}
className="text-text-dim hover:text-danger text-[10px] transition-colors"
>
logout
</button>
</div>
) : (
<div className="px-3 py-2">
<button
onClick={() => setShowLogin(true)}
className="w-full px-2 py-1.5 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
>
login
</button>
</div>
)}
</div>
)}
{/* Status footer */}
{!sidebarCollapsed && (
<div className="border-t border-border px-3 py-2 text-[10px] text-text-dim">
<div className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-success" : "bg-danger"}`} />
<span>{connected ? "online" : "offline"}</span>
</div>
<div className="mt-0.5">{notes.length} notes</div>
</div>
)}
</aside>
{showLogin && <LoginModal onClose={() => setShowLogin(false)} />}
</>
); );
} }

View File

@@ -19,7 +19,7 @@ export function getNDK(): NDK {
} }
function waitForConnectedRelay(instance: NDK, timeoutMs = 10000): Promise<void> { function waitForConnectedRelay(instance: NDK, timeoutMs = 10000): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, _reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
// Even on timeout, continue — some relays may connect later // Even on timeout, continue — some relays may connect later
console.warn("Relay connection timeout, continuing anyway"); console.warn("Relay connection timeout, continuing anyway");

113
src/stores/user.ts Normal file
View File

@@ -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<void>;
loginWithPubkey: (pubkey: string) => Promise<void>;
logout: () => void;
fetchOwnProfile: () => Promise<void>;
}
export const useUserStore = create<UserState>((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
}
},
}));