mirror of
https://github.com/hoornet/vega.git
synced 2026-05-13 23:28:36 -07:00
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:
@@ -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>
|
||||||
|
|||||||
211
src/components/feed/NoteContent.tsx
Normal file
211
src/components/feed/NoteContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/components/shared/LoginModal.tsx
Normal file
121
src/components/shared/LoginModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
113
src/stores/user.ts
Normal 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user