Bump to v0.8.0 — polish, portability, discovery

Profile banner polish (hero height, click-to-lightbox, avatar overlap),
data export (bookmarks/follows/relays as JSON), relay recommendations
(discover from follows' NIP-65 lists), reading list tracking (read/unread
on bookmarked articles with sidebar badge), trending hashtags (clickable
pills on search idle screen). Updated CLAUDE.md and release notes.
This commit is contained in:
Jure
2026-03-19 19:54:14 +01:00
parent d257075023
commit d993ae1131
16 changed files with 390 additions and 23 deletions

View File

@@ -75,6 +75,8 @@ export function ArticleView() {
const naddr = pendingArticleNaddr ?? "";
const { markArticleRead } = useBookmarkStore();
useEffect(() => {
if (!naddr) { setLoading(false); return; }
// Use cached event if available (from ArticleCard click), skip relay fetch
@@ -96,6 +98,15 @@ export function ArticleView() {
.finally(() => setLoading(false));
}, [naddr]);
// Auto-mark article as read when opened
useEffect(() => {
if (!event) return;
const dTag = event.tags.find((t) => t[0] === "d")?.[1];
if (dTag) {
markArticleRead(`30023:${event.pubkey}:${dTag}`);
}
}, [event]);
const title = event ? getTag(event, "title") : "";
const summary = event ? getTag(event, "summary") : "";
const image = event ? getTag(event, "image") : "";

View File

@@ -9,8 +9,32 @@ import { SkeletonNoteList } from "../shared/Skeleton";
type BookmarkTab = "notes" | "articles";
function ArticleCardWithReadStatus({ event }: { event: NDKEvent }) {
const { isArticleRead, markArticleRead, markArticleUnread } = useBookmarkStore();
const addr = event.tags.find((t) => t[0] === "d")?.[1];
const fullAddr = addr ? `30023:${event.pubkey}:${addr}` : null;
const isRead = fullAddr ? isArticleRead(fullAddr) : false;
return (
<div className="relative group">
{!isRead && fullAddr && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-accent z-10" title="Unread" />
)}
<ArticleCard key={event.id} event={event} />
{fullAddr && (
<button
onClick={() => isRead ? markArticleUnread(fullAddr) : markArticleRead(fullAddr)}
className="absolute right-3 top-3 text-[10px] text-text-dim hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
{isRead ? "mark unread" : "mark read"}
</button>
)}
</div>
);
}
export function BookmarkView() {
const { bookmarkedIds, bookmarkedArticleAddrs, fetchBookmarks } = useBookmarkStore();
const { bookmarkedIds, bookmarkedArticleAddrs, fetchBookmarks, unreadArticleCount } = useBookmarkStore();
const { pubkey } = useUserStore();
const [tab, setTab] = useState<BookmarkTab>("notes");
const [notes, setNotes] = useState<NDKEvent[]>([]);
@@ -89,9 +113,12 @@ export function BookmarkView() {
</button>
<button
onClick={() => setTab("articles")}
className={`px-3 py-0.5 transition-colors ${tab === "articles" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
className={`px-3 py-0.5 transition-colors relative ${tab === "articles" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
>
Articles
{unreadArticleCount() > 0 && (
<span className="ml-1 text-[9px] bg-accent/20 text-accent px-1 rounded-sm">{unreadArticleCount()}</span>
)}
</button>
</div>
</div>
@@ -123,7 +150,7 @@ export function BookmarkView() {
))}
{tab === "articles" && articles.map((event) => (
<ArticleCard key={event.id} event={event} />
<ArticleCardWithReadStatus key={event.id} event={event} />
))}
</div>
</div>

View File

@@ -10,6 +10,7 @@ import { uploadImage } from "../../lib/upload";
import { NoteCard } from "../feed/NoteCard";
import { ArticleCard } from "../article/ArticleCard";
import { ZapModal } from "../zap/ZapModal";
import { ImageLightbox } from "../shared/ImageLightbox";
// ── Profile helper sub-components ────────────────────────────────────────────
@@ -231,6 +232,8 @@ export function ProfileView() {
const [followPending, setFollowPending] = useState(false);
const [showZap, setShowZap] = useState(false);
const [profileTab, setProfileTab] = useState<"notes" | "articles">("notes");
const [bannerLightbox, setBannerLightbox] = useState(false);
const [bannerLoaded, setBannerLoaded] = useState(false);
const isFollowing = follows.includes(pubkey);
const { mutedPubkeys, mute, unmute } = useMuteStore();
@@ -348,22 +351,42 @@ export function ProfileView() {
{!editing && (
<div className="border-b border-border">
{/* Banner */}
{profile?.banner && (
<div className="h-24 bg-bg-raised overflow-hidden">
<img src={profile.banner} alt="" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
{profile?.banner ? (
<div className="relative h-36 bg-bg-raised overflow-hidden">
{!bannerLoaded && (
<div className="absolute inset-0 bg-bg-raised animate-pulse" />
)}
<img
src={profile.banner}
alt=""
className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setBannerLightbox(true)}
onLoad={() => setBannerLoaded(true)}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
) : null}
<div className="px-4 py-4 flex gap-4 items-start">
{/* Avatar + info — avatar overlaps banner when present */}
<div className={`px-4 flex gap-4 items-start ${profile?.banner ? "-mt-7 pb-4" : "py-4"}`}>
{avatar ? (
<img src={avatar} alt="" className="w-14 h-14 rounded-sm object-cover bg-bg-raised shrink-0" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<img
src={avatar}
alt=""
className={`w-16 h-16 rounded-sm object-cover bg-bg-raised shrink-0 ${
profile?.banner ? "ring-2 ring-bg shadow-md" : ""
}`}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="w-14 h-14 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-lg shrink-0">
<div className={`w-16 h-16 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-lg shrink-0 ${
profile?.banner ? "ring-2 ring-bg shadow-md" : ""
}`}>
{name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className={`min-w-0 flex-1 ${profile?.banner ? "pt-8" : ""}`}>
<div className="text-text font-medium text-[15px]">{name}</div>
{nip05 && <div className="text-text-dim text-[11px] mt-0.5">{nip05}</div>}
{lud16 && <div className="text-zap text-[11px] mt-0.5"> {lud16}</div>}
@@ -382,6 +405,15 @@ export function ProfileView() {
</div>
)}
{bannerLightbox && profile?.banner && (
<ImageLightbox
images={[profile.banner]}
index={0}
onClose={() => setBannerLightbox(false)}
onNavigate={() => {}}
/>
)}
{showZap && (
<ZapModal
target={{ type: "profile", pubkey }}

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { getStoredRelayUrls, fetchFollowSuggestions, fetchProfile, advancedSearch } from "../../lib/nostr";
import { getStoredRelayUrls, fetchFollowSuggestions, fetchProfile, advancedSearch, fetchTrendingHashtags } from "../../lib/nostr";
import { parseSearchQuery, describeSearch } from "../../lib/search";
import { getNip50Relays } from "../../lib/nostr/relayInfo";
import { useUserStore } from "../../stores/user";
@@ -137,6 +137,9 @@ export function SearchView() {
const [suggestionsLoaded, setSuggestionsLoaded] = useState(false);
const [searchHint, setSearchHint] = useState<string | null>(null);
const [trending, setTrending] = useState<{ tag: string; count: number }[]>([]);
const [trendingLoading, setTrendingLoading] = useState(false);
const [trendingLoaded, setTrendingLoaded] = useState(false);
const isHashtag = query.trim().startsWith("#") && !query.includes(":");
// Check relay NIP-50 support once on mount (background, non-blocking)
@@ -145,6 +148,16 @@ export function SearchView() {
getNip50Relays(urls).then(setNip50Relays);
}, []);
// Load trending hashtags on mount
useEffect(() => {
if (trendingLoaded) return;
setTrendingLoading(true);
fetchTrendingHashtags().then((results) => {
setTrending(results);
setTrendingLoaded(true);
}).catch(() => {}).finally(() => setTrendingLoading(false));
}, []);
const { loggedIn, follows } = useUserStore();
// Load follow suggestions on mount (only for logged-in users with follows)
@@ -336,6 +349,31 @@ export function SearchView() {
</div>
)}
{/* Trending hashtags */}
{!searched && !loading && (trending.length > 0 || trendingLoading) && (
<div className="border-t border-border px-4 py-4">
<h3 className="text-text-dim text-[10px] uppercase tracking-widest mb-2">Trending now</h3>
{trendingLoading && (
<p className="text-text-dim text-[11px]">Loading trends</p>
)}
<div className="flex flex-wrap gap-2">
{trending.map((t) => (
<button
key={t.tag}
onClick={() => {
const hashQuery = `#${t.tag}`;
setQuery(hashQuery);
handleSearch(hashQuery);
}}
className="px-2.5 py-1 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
>
#{t.tag} <span className="text-text-dim text-[10px]">({t.count})</span>
</button>
))}
</div>
</div>
)}
{/* Discover — follow suggestions */}
{!searched && !loading && loggedIn && (
<div className="border-t border-border">

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { getNDK, getStoredRelayUrls, removeRelay, publishRelayList } from "../../lib/nostr";
import { getNDK, getStoredRelayUrls, addRelay, removeRelay, publishRelayList, fetchRelayRecommendations } from "../../lib/nostr";
import { useRelayHealthStore } from "../../stores/relayHealth";
import { useUserStore } from "../../stores/user";
import type { RelayHealthResult } from "../../lib/nostr/relayHealth";
@@ -273,6 +273,76 @@ export function RelaysView() {
Checking relay health
</div>
)}
{/* Suggested Relays */}
{loggedIn && <SuggestedRelays />}
</div>
</div>
);
}
function SuggestedRelays() {
const { follows } = useUserStore();
const [suggestions, setSuggestions] = useState<{ url: string; count: number }[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const handleDiscover = async () => {
setLoading(true);
try {
const results = await fetchRelayRecommendations(follows, getStoredRelayUrls());
setSuggestions(results);
setLoaded(true);
} finally {
setLoading(false);
}
};
const handleAdd = (url: string) => {
addRelay(url);
setSuggestions((prev) => prev.filter((s) => s.url !== url));
};
return (
<div className="mt-6 pt-4 border-t border-border">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-text text-[11px] font-medium uppercase tracking-widest text-text-dim">Suggested Relays</h3>
<p className="text-text-dim text-[10px] mt-0.5">Based on relays your follows use</p>
</div>
<button
onClick={handleDiscover}
disabled={loading || follows.length === 0}
className="px-3 py-1 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? (
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
discovering
</span>
) : "discover relays"}
</button>
</div>
{loaded && suggestions.length === 0 && (
<p className="text-text-dim text-[11px]">No new relay suggestions found.</p>
)}
<div className="space-y-1">
{suggestions.map((s) => (
<div key={s.url} className="flex items-center gap-3 px-3 py-2 border border-border text-[12px] group">
<span className="text-text truncate flex-1 font-mono">{s.url}</span>
<span className="text-text-dim text-[10px] shrink-0">
{s.count} follow{s.count !== 1 ? "s" : ""}
</span>
<button
onClick={() => handleAdd(s.url)}
className="text-accent hover:text-accent-hover text-[10px] opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
add
</button>
</div>
))}
</div>
</div>
);

View File

@@ -1,6 +1,9 @@
import { useState } from "react";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useBookmarkStore } from "../../stores/bookmark";
import { getNDK, getStoredRelayUrls, addRelay, removeRelay, publishRelayList } from "../../lib/nostr";
import { useProfile } from "../../hooks/useProfile";
import { NWCWizard } from "./NWCWizard";
@@ -198,6 +201,68 @@ function WalletSection() {
);
}
function ExportSection() {
const { follows } = useUserStore();
const { bookmarkedIds, bookmarkedArticleAddrs } = useBookmarkStore();
const [status, setStatus] = useState<"idle" | "saving" | "done" | "error">("idle");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const handleExport = async () => {
setStatus("saving");
setErrorMsg(null);
try {
const filePath = await save({
defaultPath: `wrystr-export-${new Date().toISOString().slice(0, 10)}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!filePath) {
setStatus("idle");
return;
}
const exportData = {
version: 1,
exportedAt: new Date().toISOString(),
bookmarks: {
noteIds: bookmarkedIds,
articleAddrs: bookmarkedArticleAddrs,
},
follows,
relays: getStoredRelayUrls(),
};
await writeTextFile(filePath, JSON.stringify(exportData, null, 2));
setStatus("done");
setTimeout(() => setStatus("idle"), 3000);
} catch (err) {
setErrorMsg(String(err));
setStatus("error");
}
};
return (
<section>
<h2 className="text-text text-[11px] font-medium uppercase tracking-widest mb-2 text-text-dim">Export Data</h2>
<p className="text-text-dim text-[11px] mb-3">
Save your bookmarks, follows, and relay list to a JSON file. Your keys, your data.
</p>
<div className="flex items-center gap-3">
<button
onClick={handleExport}
disabled={status === "saving"}
className="px-3 py-1.5 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{status === "saving" ? "exporting…" : status === "done" ? "exported ✓" : "export data"}
</button>
<span className="text-text-dim text-[10px]">
{bookmarkedIds.length} notes · {bookmarkedArticleAddrs.length} articles · {follows.length} follows · {getStoredRelayUrls().length} relays
</span>
</div>
{errorMsg && <p className="text-danger text-[10px] mt-1">{errorMsg}</p>}
</section>
);
}
export function SettingsView() {
return (
<div className="h-full flex flex-col">
@@ -208,6 +273,7 @@ export function SettingsView() {
<div className="flex-1 overflow-y-auto p-4 space-y-8">
<WalletSection />
<RelaySection />
<ExportSection />
<IdentitySection />
<MuteSection />
</div>

View File

@@ -3,6 +3,7 @@ import { useFeedStore } from "../../stores/feed";
import { useUserStore } from "../../stores/user";
import { useNotificationsStore } from "../../stores/notifications";
import { useDraftStore } from "../../stores/drafts";
import { useBookmarkStore } from "../../stores/bookmark";
import { getNDK } from "../../lib/nostr";
import { AccountSwitcher } from "./AccountSwitcher";
import pkg from "../../../package.json";
@@ -26,6 +27,7 @@ export function Sidebar() {
const { loggedIn } = useUserStore();
const { unreadCount: notifUnread, dmUnreadCount } = useNotificationsStore();
const draftCount = useDraftStore((s) => s.drafts.length);
const bookmarkUnread = useBookmarkStore((s) => s.unreadArticleCount());
const c = sidebarCollapsed;
@@ -91,7 +93,7 @@ export function Sidebar() {
)}
{NAV_ITEMS.map((item) => {
const badge = item.id === "dm" ? dmUnreadCount : item.id === "notifications" ? notifUnread : 0;
const badge = item.id === "dm" ? dmUnreadCount : item.id === "notifications" ? notifUnread : item.id === "bookmarks" ? bookmarkUnread : 0;
return (
<button
key={item.id}