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 (
-