Bump to v0.6.0 — article discovery, search, profile tab, reader polish

Article discovery feed with Latest/Following tabs, article search
(NIP-50 + hashtag for kind 30023), Notes/Articles tab on profiles,
reading time + bookmark + like buttons on article reader. Event
passed directly from card to reader to avoid relay re-fetch failures.
This commit is contained in:
Jure
2026-03-17 21:47:24 +01:00
parent 8ce1d43d2d
commit ef189932e6
20 changed files with 1647 additions and 76 deletions

View File

@@ -0,0 +1,120 @@
import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { useUIStore } from "../../stores/ui";
import { shortenPubkey } from "../../lib/utils";
function getTag(event: NDKEvent, name: string): string {
return event.tags.find((t) => t[0] === name)?.[1] ?? "";
}
function getTags(event: NDKEvent, name: string): string[] {
return event.tags.filter((t) => t[0] === name).map((t) => t[1]).filter(Boolean);
}
function buildNaddr(event: NDKEvent): string {
const d = getTag(event, "d");
if (!d) return "";
return nip19.naddrEncode({
identifier: d,
pubkey: event.pubkey,
kind: event.kind!,
});
}
export function ArticleCard({ event }: { event: NDKEvent }) {
const { openArticle, openProfile } = useUIStore();
const profile = useProfile(event.pubkey);
const title = getTag(event, "title");
const summary = getTag(event, "summary");
const image = getTag(event, "image");
const tags = getTags(event, "t");
const publishedAt = parseInt(getTag(event, "published_at")) || event.created_at || null;
const naddr = buildNaddr(event);
const authorName = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
const date = publishedAt
? new Date(publishedAt * 1000).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
: null;
const wordCount = event.content?.trim().split(/\s+/).length ?? 0;
const readingTime = Math.max(1, Math.ceil(wordCount / 230));
if (!naddr) return null;
return (
<div
className="border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors cursor-pointer"
onClick={() => openArticle(naddr, event)}
>
<div className="flex gap-3">
{/* Text content */}
<div className="flex-1 min-w-0">
{/* Title */}
<h3 className="text-text text-[14px] font-medium leading-snug mb-1 line-clamp-2">
{title || "Untitled"}
</h3>
{/* Summary */}
{summary && (
<p className="text-text-muted text-[12px] leading-relaxed mb-2 line-clamp-2">
{summary}
</p>
)}
{/* Author row */}
<div className="flex items-center gap-2 mb-1.5">
<button
className="shrink-0"
onClick={(e) => { e.stopPropagation(); openProfile(event.pubkey); }}
>
{profile?.picture ? (
<img
src={profile.picture}
alt=""
className="w-5 h-5 rounded-sm object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="w-5 h-5 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-[9px]">
{authorName.charAt(0).toUpperCase()}
</div>
)}
</button>
<button
className="text-text-dim text-[11px] hover:text-accent transition-colors"
onClick={(e) => { e.stopPropagation(); openProfile(event.pubkey); }}
>
{authorName}
</button>
{date && <span className="text-text-dim text-[10px]">{date}</span>}
<span className="text-text-dim text-[10px]">{readingTime} min read</span>
</div>
{/* Tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 5).map((tag) => (
<span key={tag} className="px-1.5 py-0 text-[9px] border border-border text-text-dim">
#{tag}
</span>
))}
</div>
)}
</div>
{/* Cover image thumbnail */}
{image && (
<div className="shrink-0 w-24 h-20 rounded-sm overflow-hidden bg-bg-raised">
<img
src={image}
alt=""
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useState, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchArticleFeed } from "../../lib/nostr";
import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { ArticleCard } from "./ArticleCard";
type ArticleTab = "latest" | "following";
export function ArticleFeed() {
const { loggedIn, follows } = useUserStore();
const { setView } = useUIStore();
const [tab, setTab] = useState<ArticleTab>("latest");
const [articles, setArticles] = useState<NDKEvent[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const authors = tab === "following" ? follows : undefined;
fetchArticleFeed(40, authors)
.then(setArticles)
.catch(() => setArticles([]))
.finally(() => setLoading(false));
}, [tab, follows]);
return (
<div className="h-full flex flex-col">
{/* Header */}
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
<h1 className="text-text text-sm font-medium">Articles</h1>
<button
onClick={() => setView("article-editor")}
className="text-[11px] px-3 py-1 border border-accent/60 text-accent hover:bg-accent hover:text-white transition-colors"
>
write article
</button>
</header>
{/* Tabs */}
<div className="border-b border-border flex shrink-0">
{(["latest", "following"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
disabled={t === "following" && !loggedIn}
className={`px-4 py-2 text-[11px] border-b-2 transition-colors ${
tab === t
? "border-accent text-accent"
: "border-transparent text-text-dim hover:text-text"
} disabled:opacity-30 disabled:cursor-not-allowed`}
>
{t}
</button>
))}
</div>
{/* Articles list */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading articles...</div>
)}
{!loading && articles.length === 0 && (
<div className="px-4 py-8 text-center space-y-2">
<p className="text-text-dim text-[12px]">
{tab === "following"
? "No articles from people you follow yet."
: "No articles found on your relays."}
</p>
{tab === "following" && (
<p className="text-text-dim text-[10px]">
Try the "latest" tab to discover writers, then follow them.
</p>
)}
</div>
)}
{articles.map((event) => (
<ArticleCard key={event.id} event={event} />
))}
</div>
</div>
);
}

View File

@@ -4,7 +4,8 @@ import { marked } from "marked";
import DOMPurify from "dompurify";
import { useUIStore } from "../../stores/ui";
import { useUserStore } from "../../stores/user";
import { fetchArticle } from "../../lib/nostr";
import { useBookmarkStore } from "../../stores/bookmark";
import { fetchArticle, publishReaction } from "../../lib/nostr";
import { useProfile } from "../../hooks/useProfile";
import { ZapModal } from "../zap/ZapModal";
@@ -25,7 +26,7 @@ function renderMarkdown(md: string): string {
// ── Author row ────────────────────────────────────────────────────────────────
function AuthorRow({ pubkey, publishedAt }: { pubkey: string; publishedAt: number | null }) {
function AuthorRow({ pubkey, publishedAt, readingTime }: { pubkey: string; publishedAt: number | null; readingTime?: number }) {
const { openProfile } = useUIStore();
const profile = useProfile(pubkey);
const name = profile?.displayName || profile?.name || pubkey.slice(0, 12) + "…";
@@ -53,6 +54,7 @@ function AuthorRow({ pubkey, publishedAt }: { pubkey: string; publishedAt: numbe
{name}
</button>
{date && <span className="text-text-dim text-[11px]">{date}</span>}
{readingTime && <span className="text-text-dim text-[11px]"> · {readingTime} min read</span>}
</div>
</div>
);
@@ -61,18 +63,27 @@ function AuthorRow({ pubkey, publishedAt }: { pubkey: string; publishedAt: numbe
// ── Main view ─────────────────────────────────────────────────────────────────
export function ArticleView() {
const { pendingArticleNaddr, goBack } = useUIStore();
const { pendingArticleNaddr, pendingArticleEvent, goBack } = useUIStore();
const { loggedIn } = useUserStore();
const [event, setEvent] = useState<NDKEvent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showZap, setShowZap] = useState(false);
const [reacted, setReacted] = useState(false);
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
const naddr = pendingArticleNaddr ?? "";
useEffect(() => {
if (!naddr) { setLoading(false); return; }
// Use cached event if available (from ArticleCard click), skip relay fetch
if (pendingArticleEvent) {
setEvent(pendingArticleEvent);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
setEvent(null);
@@ -95,6 +106,25 @@ export function ArticleView() {
const authorName = authorProfile?.displayName || authorProfile?.name || authorPubkey.slice(0, 12) + "…";
const bodyHtml = event?.content ? renderMarkdown(event.content) : "";
const wordCount = event?.content?.trim().split(/\s+/).length ?? 0;
const readingTime = Math.max(1, Math.ceil(wordCount / 230));
const bookmarked = event?.id ? isBookmarked(event.id) : false;
const handleReaction = async () => {
if (!event?.id || reacted) return;
setReacted(true);
try {
await publishReaction(event.id, event.pubkey);
} catch {
setReacted(false);
}
};
const handleBookmark = () => {
if (!event?.id) return;
if (bookmarked) removeBookmark(event.id);
else addBookmark(event.id);
};
return (
<div className="h-full flex flex-col">
@@ -104,6 +134,19 @@ export function ArticleView() {
back
</button>
<div className="flex items-center gap-2">
{event && loggedIn && (
<button
onClick={handleBookmark}
className={`text-[11px] px-3 py-1 border transition-colors ${
bookmarked
? "border-accent/40 text-accent"
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
}`}
title={bookmarked ? "Remove bookmark" : "Bookmark article"}
>
{bookmarked ? "▪ saved" : "▫ save"}
</button>
)}
{event && loggedIn && (
<button
onClick={() => setShowZap(true)}
@@ -170,8 +213,8 @@ export function ArticleView() {
</p>
)}
{/* Author + date */}
<AuthorRow pubkey={authorPubkey} publishedAt={publishedAt} />
{/* Author + date + reading time */}
<AuthorRow pubkey={authorPubkey} publishedAt={publishedAt} readingTime={readingTime} />
{/* Tags */}
{articleTags.length > 0 && (
@@ -195,14 +238,41 @@ export function ArticleView() {
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
back
</button>
{loggedIn && (
<button
onClick={() => setShowZap(true)}
className="text-[11px] px-4 py-2 bg-zap hover:bg-zap/90 text-white transition-colors"
>
Zap {authorName}
</button>
)}
<div className="flex items-center gap-2">
{loggedIn && (
<button
onClick={handleReaction}
disabled={reacted}
className={`text-[11px] px-3 py-1.5 border transition-colors disabled:cursor-not-allowed ${
reacted
? "border-accent/40 text-accent"
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
}`}
>
{reacted ? "♥ liked" : "♡ like"}
</button>
)}
{loggedIn && (
<button
onClick={handleBookmark}
className={`text-[11px] px-3 py-1.5 border transition-colors ${
bookmarked
? "border-accent/40 text-accent"
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
}`}
>
{bookmarked ? "▪ saved" : "▫ save"}
</button>
)}
{loggedIn && (
<button
onClick={() => setShowZap(true)}
className="text-[11px] px-4 py-1.5 bg-zap hover:bg-zap/90 text-white transition-colors"
>
Zap {authorName}
</button>
)}
</div>
</div>
</article>
)}

View File

@@ -4,10 +4,11 @@ import { useUIStore } from "../../stores/ui";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useProfile, invalidateProfileCache } from "../../hooks/useProfile";
import { fetchUserNotesNIP65, publishProfile, getNDK } from "../../lib/nostr";
import { fetchUserNotesNIP65, fetchAuthorArticles, publishProfile, getNDK } from "../../lib/nostr";
import { shortenPubkey } from "../../lib/utils";
import { uploadImage } from "../../lib/upload";
import { NoteCard } from "../feed/NoteCard";
import { ArticleCard } from "../article/ArticleCard";
import { ZapModal } from "../zap/ZapModal";
// ── Profile helper sub-components ────────────────────────────────────────────
@@ -223,10 +224,13 @@ export function ProfileView() {
const fetchedProfile = useProfile(pubkey);
const profile = isOwn ? ownProfile : fetchedProfile;
const [notes, setNotes] = useState<NDKEvent[]>([]);
const [articles, setArticles] = useState<NDKEvent[]>([]);
const [loading, setLoading] = useState(true);
const [articlesLoading, setArticlesLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [followPending, setFollowPending] = useState(false);
const [showZap, setShowZap] = useState(false);
const [profileTab, setProfileTab] = useState<"notes" | "articles">("notes");
const isFollowing = follows.includes(pubkey);
const { mutedPubkeys, mute, unmute } = useMuteStore();
@@ -254,12 +258,19 @@ export function ProfileView() {
useEffect(() => {
setLoading(true);
setProfileTab("notes");
fetchUserNotesNIP65(pubkey).then((events) => {
setNotes(events);
setLoading(false);
}).catch(() => setLoading(false));
}, [pubkey]);
useEffect(() => {
if (profileTab !== "articles" || articles.length > 0) return;
setArticlesLoading(true);
fetchAuthorArticles(pubkey).then(setArticles).catch(() => setArticles([])).finally(() => setArticlesLoading(false));
}, [profileTab, pubkey]);
return (
<div className="h-full flex flex-col">
{/* Header */}
@@ -379,12 +390,42 @@ export function ProfileView() {
/>
)}
{/* Notes */}
{loading && <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading notes</div>}
{!loading && notes.length === 0 && <div className="px-4 py-8 text-text-dim text-[12px] text-center">No notes found.</div>}
{notes.map((event) => (
<NoteCard key={event.id} event={event} />
))}
{/* Notes / Articles tabs */}
<div className="border-b border-border flex shrink-0">
{(["notes", "articles"] as const).map((t) => (
<button
key={t}
onClick={() => setProfileTab(t)}
className={`px-4 py-2 text-[11px] border-b-2 transition-colors ${
profileTab === t
? "border-accent text-accent"
: "border-transparent text-text-dim hover:text-text"
}`}
>
{t}
</button>
))}
</div>
{profileTab === "notes" && (
<>
{loading && <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading notes</div>}
{!loading && notes.length === 0 && <div className="px-4 py-8 text-text-dim text-[12px] text-center">No notes found.</div>}
{notes.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</>
)}
{profileTab === "articles" && (
<>
{articlesLoading && <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading articles</div>}
{!articlesLoading && articles.length === 0 && <div className="px-4 py-8 text-text-dim text-[12px] text-center">No articles found.</div>}
{articles.map((event) => (
<ArticleCard key={event.id} event={event} />
))}
</>
)}
</div>
</div>
);

View File

@@ -1,11 +1,12 @@
import { useState, useRef, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { searchNotes, searchUsers, getStoredRelayUrls, fetchFollowSuggestions, fetchProfile } from "../../lib/nostr";
import { searchNotes, searchUsers, searchArticles, getStoredRelayUrls, fetchFollowSuggestions, fetchProfile } from "../../lib/nostr";
import { getNip50Relays } from "../../lib/nostr/relayInfo";
import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { shortenPubkey } from "../../lib/utils";
import { NoteCard } from "../feed/NoteCard";
import { ArticleCard } from "../article/ArticleCard";
interface ParsedUser {
pubkey: string;
@@ -124,9 +125,10 @@ export function SearchView() {
const [query, setQuery] = useState(pendingSearch ?? "");
const [noteResults, setNoteResults] = useState<NDKEvent[]>([]);
const [userResults, setUserResults] = useState<ParsedUser[]>([]);
const [articleResults, setArticleResults] = useState<NDKEvent[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [activeTab, setActiveTab] = useState<"notes" | "people">("notes");
const [activeTab, setActiveTab] = useState<"notes" | "people" | "articles">("notes");
const [nip50Relays, setNip50Relays] = useState<string[] | null>(null); // null = not checked yet
const inputRef = useRef<HTMLInputElement>(null);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
@@ -191,13 +193,15 @@ export function SearchView() {
setSearched(false);
try {
const isTag = q.startsWith("#");
const [notes, userEvents] = await Promise.all([
const [notes, userEvents, articleEvents] = await Promise.all([
searchNotes(q),
isTag ? Promise.resolve([]) : searchUsers(q),
searchArticles(q),
]);
setNoteResults(notes);
setUserResults(userEvents.map(parseUserEvent));
setActiveTab(notes.length > 0 ? "notes" : "people");
setArticleResults(articleEvents);
setActiveTab(notes.length > 0 ? "notes" : articleEvents.length > 0 ? "articles" : "people");
} finally {
setLoading(false);
setSearched(true);
@@ -216,7 +220,7 @@ export function SearchView() {
handleSearch(hashQuery);
};
const totalResults = noteResults.length + userResults.length;
const totalResults = noteResults.length + userResults.length + articleResults.length;
const allRelays = getStoredRelayUrls();
const nip50Count = nip50Relays?.length ?? null;
const noNip50 = nip50Relays !== null && nip50Relays.length === 0;
@@ -249,8 +253,8 @@ export function SearchView() {
{/* Tabs — shown once a search has been run (except for hashtag, which is notes-only) */}
{searched && !isHashtag && (
<div className="border-b border-border flex shrink-0">
{(["notes", "people"] as const).map((tab) => {
const count = tab === "notes" ? noteResults.length : userResults.length;
{(["notes", "articles", "people"] as const).map((tab) => {
const count = tab === "notes" ? noteResults.length : tab === "articles" ? articleResults.length : userResults.length;
return (
<button
key={tab}
@@ -386,6 +390,11 @@ export function SearchView() {
<UserRow key={user.pubkey} user={user} />
))}
{/* Articles results */}
{activeTab === "articles" && articleResults.map((event) => (
<ArticleCard key={event.id} event={event} />
))}
{/* Notes results */}
{(activeTab === "notes" || isHashtag) && noteResults.map((event) => (
<NoteCard key={event.id} event={event} />

View File

@@ -8,6 +8,7 @@ import pkg from "../../../package.json";
const NAV_ITEMS = [
{ id: "feed" as const, label: "feed", icon: "◈" },
{ id: "articles" as const, label: "articles", icon: "☰" },
{ id: "search" as const, label: "search", icon: "⌕" },
{ id: "bookmarks" as const, label: "bookmarks", icon: "▪" },
{ id: "dm" as const, label: "messages", icon: "✉" },