diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ba2f0f..00538c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,14 @@ jobs: > **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install. - ### New in v0.7.1 — Relay Health Checker & Advanced Search + ### New in v0.8.0 — Polish, Portability & Discovery + - **Profile banner polish** — hero-height banner, click to open in lightbox, avatar overlaps banner edge Telegram-style, loading shimmer + - **Export Data** — export your bookmarks, follows, and relay list as JSON via native save dialog; your keys, your data + - **Relay recommendations** — "Discover relays" analyzes your follows' NIP-65 relay lists and suggests popular relays you're missing, with one-click add + - **Reading list tracking** — bookmarked articles show read/unread status with dot indicators; auto-marks read when opened; unread count badge in sidebar; hover to toggle read/unread + - **Trending hashtags** — search idle screen shows popular hashtags from the last 24 hours as clickable pills; click to search + + ### Previous: v0.7.1 — Relay Health Checker & Advanced Search - **Relay health checker** — NIP-11 info fetch + WebSocket latency probing; relays classified as online/slow/offline; expandable cards show software, description, supported NIPs; "Remove dead" strips offline relays; "Republish list" publishes cleaned NIP-65 relay list - **Advanced search** — ants-inspired query parser with modifiers: `by:author`, `mentions:npub`, `kind:N`, `is:article`, `has:image`, `since:2026-01-01`, `until:2026-12-31`, `#hashtag`, `"exact phrase"`, boolean `OR`; NIP-05 resolution for author lookups; search help panel diff --git a/CLAUDE.md b/CLAUDE.md index bbdbf36..5e7ef03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR **Frontend** (`src/`): React 19 + TypeScript + Vite + Tailwind CSS 4 - `src/App.tsx` — root component; shows `OnboardingFlow` for new users, then view routing via UI store -- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`, `lightning.ts`, `drafts.ts`, `relayHealth.ts` +- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`, `lightning.ts`, `drafts.ts`, `relayHealth.ts`, `bookmark.ts` - `src/lib/nostr/` — NDK wrapper (`client.ts` + `index.ts`); all Nostr calls go through here - `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic - `src/hooks/` — `useProfile.ts`, `useReactionCount.ts` @@ -59,7 +59,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - `src/components/bookmark/` — BookmarkView - `src/components/zap/` — ZapModal - `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login) -- `src/components/shared/` — RelaysView (relay health dashboard + management), SettingsView (NWC + identity) +- `src/components/shared/` — RelaysView (relay health dashboard + recommendations), SettingsView (NWC + identity + data export) - `src/components/sidebar/` — Sidebar navigation **Backend** (`src-tauri/`): Rust + Tauri 2.0 @@ -106,12 +106,17 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - Search: NIP-50 full-text, hashtag (#t filter), people, articles - Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy - **Relay health checker** — NIP-11 info fetch, WebSocket latency probing, online/slow/offline status; expandable cards with supported NIPs, software info; "Remove dead" + "Republish list" workflow +- **Relay recommendations** — suggest relays based on follows' NIP-65 relay lists; "Discover relays" button with follow count, one-click "Add" +- **Data export** — export bookmarks, follows, and relay list as JSON via native save dialog (Tauri plugin-dialog + plugin-fs) +- **Profile banner polish** — hero-height banner (h-36), click-to-lightbox, avatar overlaps banner edge with ring, loading shimmer +- **Reading list tracking** — read/unread state on bookmarked articles (localStorage-backed), unread dot indicators, sidebar badge, auto-mark-read on open +- **Trending hashtags** — #t tag frequency analysis from recent events; clickable tag pills on search idle screen - OS keychain integration — nsec persists across restarts via `keyring` crate - SQLite note + profile cache - Direct messages (NIP-04 + NIP-17 gift wrap) - NIP-65 outbox model - Image lightbox (click to expand, arrow key navigation) -- Bookmark list (NIP-51 kind 10003) with sidebar nav, **Notes/Articles tabs**, article `a` tag support +- Bookmark list (NIP-51 kind 10003) with sidebar nav, **Notes/Articles tabs**, article `a` tag support, **read/unread tracking** - Follow suggestions / discovery (follows-of-follows algorithm) - Language/script feed filter (Unicode script detection + NIP-32 tags) - Skeleton loading states, view fade transitions diff --git a/PKGBUILD b/PKGBUILD index 3c526fe..ec83c41 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet pkgname=wrystr -pkgver=0.7.1 +pkgver=0.8.0 pkgrel=1 pkgdesc="Cross-platform Nostr desktop client with Lightning integration" arch=('x86_64') diff --git a/package.json b/package.json index 1da54b7..96dd106 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wrystr", "private": true, - "version": "0.7.1", + "version": "0.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3fe1b4..7ce056f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wrystr" -version = "0.7.1" +version = "0.8.0" description = "Cross-platform Nostr desktop client with Lightning integration" authors = ["hoornet"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fec12b4..2255c36 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Wrystr", - "version": "0.7.1", + "version": "0.8.0", "identifier": "com.hoornet.wrystr", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/article/ArticleView.tsx b/src/components/article/ArticleView.tsx index ba3ef74..59e01db 100644 --- a/src/components/article/ArticleView.tsx +++ b/src/components/article/ArticleView.tsx @@ -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") : ""; diff --git a/src/components/bookmark/BookmarkView.tsx b/src/components/bookmark/BookmarkView.tsx index f1e96de..875ed52 100644 --- a/src/components/bookmark/BookmarkView.tsx +++ b/src/components/bookmark/BookmarkView.tsx @@ -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 ( +
+ {!isRead && fullAddr && ( +
+ )} + + {fullAddr && ( + + )} +
+ ); +} + export function BookmarkView() { - const { bookmarkedIds, bookmarkedArticleAddrs, fetchBookmarks } = useBookmarkStore(); + const { bookmarkedIds, bookmarkedArticleAddrs, fetchBookmarks, unreadArticleCount } = useBookmarkStore(); const { pubkey } = useUserStore(); const [tab, setTab] = useState("notes"); const [notes, setNotes] = useState([]); @@ -89,9 +113,12 @@ export function BookmarkView() {
@@ -123,7 +150,7 @@ export function BookmarkView() { ))} {tab === "articles" && articles.map((event) => ( - + ))} diff --git a/src/components/profile/ProfileView.tsx b/src/components/profile/ProfileView.tsx index 67ae876..81ca346 100644 --- a/src/components/profile/ProfileView.tsx +++ b/src/components/profile/ProfileView.tsx @@ -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 && (
{/* Banner */} - {profile?.banner && ( -
- { (e.target as HTMLImageElement).style.display = "none"; }} /> + {profile?.banner ? ( +
+ {!bannerLoaded && ( +
+ )} + setBannerLightbox(true)} + onLoad={() => setBannerLoaded(true)} + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} + />
- )} + ) : null} -
+ {/* Avatar + info — avatar overlaps banner when present */} +
{avatar ? ( - { (e.target as HTMLImageElement).style.display = "none"; }} /> + { (e.target as HTMLImageElement).style.display = "none"; }} + /> ) : ( -
+
{name.charAt(0).toUpperCase()}
)} -
+
{name}
{nip05 &&
{nip05}
} {lud16 &&
⚡ {lud16}
} @@ -382,6 +405,15 @@ export function ProfileView() {
)} + {bannerLightbox && profile?.banner && ( + setBannerLightbox(false)} + onNavigate={() => {}} + /> + )} + {showZap && ( (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() {
)} + {/* Trending hashtags */} + {!searched && !loading && (trending.length > 0 || trendingLoading) && ( +
+

Trending now

+ {trendingLoading && ( +

Loading trends…

+ )} +
+ {trending.map((t) => ( + + ))} +
+
+ )} + {/* Discover — follow suggestions */} {!searched && !loading && loggedIn && (
diff --git a/src/components/shared/RelaysView.tsx b/src/components/shared/RelaysView.tsx index 0a256e9..6cc1357 100644 --- a/src/components/shared/RelaysView.tsx +++ b/src/components/shared/RelaysView.tsx @@ -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…
)} + + {/* Suggested Relays */} + {loggedIn && } +
+
+ ); +} + +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 ( +
+
+
+

Suggested Relays

+

Based on relays your follows use

+
+ +
+ + {loaded && suggestions.length === 0 && ( +

No new relay suggestions found.

+ )} + +
+ {suggestions.map((s) => ( +
+ {s.url} + + {s.count} follow{s.count !== 1 ? "s" : ""} + + +
+ ))}
); diff --git a/src/components/shared/SettingsView.tsx b/src/components/shared/SettingsView.tsx index a8f32c5..7422839 100644 --- a/src/components/shared/SettingsView.tsx +++ b/src/components/shared/SettingsView.tsx @@ -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(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 ( +
+

Export Data

+

+ Save your bookmarks, follows, and relay list to a JSON file. Your keys, your data. +

+
+ + + {bookmarkedIds.length} notes · {bookmarkedArticleAddrs.length} articles · {follows.length} follows · {getStoredRelayUrls().length} relays + +
+ {errorMsg &&

{errorMsg}

} +
+ ); +} + export function SettingsView() { return (
@@ -208,6 +273,7 @@ export function SettingsView() {
+
diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index e46c330..fc81758 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -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 (