mirror of
https://github.com/hoornet/vega.git
synced 2026-06-16 09:49:43 -07:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
pkgname=wrystr
|
||||
pkgver=0.7.1
|
||||
pkgver=0.8.0
|
||||
pkgrel=1
|
||||
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
||||
arch=('x86_64')
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wrystr",
|
||||
"private": true,
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") : "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -799,6 +799,73 @@ export async function resolveNip05(identifier: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relay Recommendations ─────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchRelayRecommendations(
|
||||
follows: string[],
|
||||
ownRelays: string[],
|
||||
sampleSize = 30
|
||||
): Promise<{ url: string; count: number }[]> {
|
||||
if (follows.length === 0) return [];
|
||||
// Sample random follows to avoid hammering relays
|
||||
const shuffled = [...follows].sort(() => Math.random() - 0.5);
|
||||
const sample = shuffled.slice(0, sampleSize);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
sample.map((pk) => fetchUserRelayList(pk))
|
||||
);
|
||||
|
||||
const ownSet = new Set(ownRelays.map((u) => u.replace(/\/$/, "")));
|
||||
const tally = new Map<string, number>();
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
const allUrls = Array.from(new Set([...result.value.read, ...result.value.write]));
|
||||
for (const url of allUrls) {
|
||||
const normalized = url.replace(/\/$/, "");
|
||||
if (ownSet.has(normalized)) continue;
|
||||
tally.set(normalized, (tally.get(normalized) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tally.entries())
|
||||
.map(([url, count]) => ({ url, count }))
|
||||
.filter((r) => r.count >= 2)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
// ── Trending Hashtags ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchTrendingHashtags(limit = 15): Promise<{ tag: string; count: number }[]> {
|
||||
const instance = getNDK();
|
||||
const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60;
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text],
|
||||
since,
|
||||
limit: 500,
|
||||
};
|
||||
const events = await instance.fetchEvents(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const event of events) {
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== "t" || !tag[1]) continue;
|
||||
const normalized = tag[1].toLowerCase().trim();
|
||||
if (normalized.length === 0) continue;
|
||||
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.filter(([, count]) => count >= 2)
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// ── Advanced Search ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdvancedSearchResults {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions, resolveNip05, advancedSearch } from "./client";
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions, resolveNip05, advancedSearch, fetchRelayRecommendations, fetchTrendingHashtags } from "./client";
|
||||
export type { UserRelayList, AdvancedSearchResults } from "./client";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fetchBookmarkList, fetchBookmarkListFull, publishBookmarkListFull } fro
|
||||
|
||||
const STORAGE_KEY = "wrystr_bookmarks";
|
||||
const ARTICLE_STORAGE_KEY = "wrystr_bookmarks_articles";
|
||||
const READ_STORAGE_KEY = "wrystr_articles_read";
|
||||
|
||||
function loadLocal(): string[] {
|
||||
try {
|
||||
@@ -28,9 +29,22 @@ function saveArticleAddrs(addrs: string[]) {
|
||||
localStorage.setItem(ARTICLE_STORAGE_KEY, JSON.stringify(addrs));
|
||||
}
|
||||
|
||||
function loadReadAddrs(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(READ_STORAGE_KEY) ?? "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveReadAddrs(addrs: string[]) {
|
||||
localStorage.setItem(READ_STORAGE_KEY, JSON.stringify(addrs));
|
||||
}
|
||||
|
||||
interface BookmarkState {
|
||||
bookmarkedIds: string[];
|
||||
bookmarkedArticleAddrs: string[]; // "30023:<pubkey>:<d-tag>" format
|
||||
readArticleAddrs: string[];
|
||||
fetchBookmarks: (pubkey: string) => Promise<void>;
|
||||
addBookmark: (eventId: string) => Promise<void>;
|
||||
removeBookmark: (eventId: string) => Promise<void>;
|
||||
@@ -38,11 +52,16 @@ interface BookmarkState {
|
||||
addArticleBookmark: (addr: string) => Promise<void>;
|
||||
removeArticleBookmark: (addr: string) => Promise<void>;
|
||||
isArticleBookmarked: (addr: string) => boolean;
|
||||
markArticleRead: (addr: string) => void;
|
||||
markArticleUnread: (addr: string) => void;
|
||||
isArticleRead: (addr: string) => boolean;
|
||||
unreadArticleCount: () => number;
|
||||
}
|
||||
|
||||
export const useBookmarkStore = create<BookmarkState>((set, get) => ({
|
||||
bookmarkedIds: loadLocal(),
|
||||
bookmarkedArticleAddrs: loadArticleAddrs(),
|
||||
readArticleAddrs: loadReadAddrs(),
|
||||
|
||||
fetchBookmarks: async (pubkey: string) => {
|
||||
try {
|
||||
@@ -108,4 +127,27 @@ export const useBookmarkStore = create<BookmarkState>((set, get) => ({
|
||||
isArticleBookmarked: (addr: string) => {
|
||||
return get().bookmarkedArticleAddrs.includes(addr);
|
||||
},
|
||||
|
||||
markArticleRead: (addr: string) => {
|
||||
const { readArticleAddrs } = get();
|
||||
if (readArticleAddrs.includes(addr)) return;
|
||||
const updated = [...readArticleAddrs, addr];
|
||||
set({ readArticleAddrs: updated });
|
||||
saveReadAddrs(updated);
|
||||
},
|
||||
|
||||
markArticleUnread: (addr: string) => {
|
||||
const updated = get().readArticleAddrs.filter((a) => a !== addr);
|
||||
set({ readArticleAddrs: updated });
|
||||
saveReadAddrs(updated);
|
||||
},
|
||||
|
||||
isArticleRead: (addr: string) => {
|
||||
return get().readArticleAddrs.includes(addr);
|
||||
},
|
||||
|
||||
unreadArticleCount: () => {
|
||||
const { bookmarkedArticleAddrs, readArticleAddrs } = get();
|
||||
return bookmarkedArticleAddrs.filter((a) => !readArticleAddrs.includes(a)).length;
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user