mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
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:
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -66,7 +66,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.5.0 — Sharing & Thread Indicators
|
||||
### New in v0.6.0 — Long-form Article Experience
|
||||
- **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors
|
||||
- **Article cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips
|
||||
- **Article search** — search notes, articles, and people in parallel; articles tab in search results; supports full-text (NIP-50) and hashtag search for articles
|
||||
- **Profile Articles tab** — Notes/Articles tab toggle on every profile; browse any author's long-form posts
|
||||
- **Article reader polish** — estimated reading time, bookmark/save, like (reaction), zap — all in header and footer
|
||||
|
||||
### Previous: v0.5.0 — Sharing & Thread Indicators
|
||||
- **Note sharing** — share button on every note copies a `nostr:nevent1…` URI to clipboard; works when logged out too
|
||||
- **Reply count** — notes now show reply count next to the reply button; updates optimistically when you reply
|
||||
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -52,8 +52,8 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
||||
- `src/components/feed/` — Feed, NoteCard, NoteContent, ComposeBox
|
||||
- `src/components/profile/` — ProfileView (own + others, edit form)
|
||||
- `src/components/thread/` — ThreadView
|
||||
- `src/components/search/` — SearchView (NIP-50, hashtag, people)
|
||||
- `src/components/article/` — ArticleEditor (NIP-23)
|
||||
- `src/components/search/` — SearchView (NIP-50, hashtag, people, articles)
|
||||
- `src/components/article/` — ArticleEditor, ArticleView, ArticleFeed, ArticleCard (NIP-23)
|
||||
- `src/components/bookmark/` — BookmarkView
|
||||
- `src/components/zap/` — ZapModal
|
||||
- `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login)
|
||||
@@ -64,14 +64,16 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
||||
|
||||
- `src-tauri/src/lib.rs` — Tauri app init and command registration
|
||||
- Rust commands must return `Result<T, String>`
|
||||
- Future: OS keychain for key storage, SQLite, lightning node integration
|
||||
- OS keychain via `keyring` crate — `store_nsec`, `load_nsec`, `delete_nsec` commands
|
||||
- SQLite note/profile cache via `rusqlite`
|
||||
- Future: lightning node integration
|
||||
|
||||
## Key Conventions (from AGENTS.md)
|
||||
|
||||
- Functional React components only — no class components
|
||||
- Never use `any` — define types in `src/types/`
|
||||
- Tailwind classes only — no inline styles, except unavoidable WebkitUserSelect
|
||||
- Private keys must never be exposed to JS; use OS keychain via Rust (not yet implemented — nsec currently lives in NDK signer memory only)
|
||||
- Private keys stored in OS keychain via Rust `keyring` crate; nsec persists across restarts
|
||||
- New Zustand stores per domain when adding features
|
||||
- NDK interactions only through `src/lib/nostr/` wrapper
|
||||
- Lightning/NWC only through `src/lib/lightning/` wrapper
|
||||
@@ -89,28 +91,33 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
||||
- Global + following feed, compose, reply, thread view
|
||||
- Reactions (NIP-25) with live network counts
|
||||
- Follow/unfollow (NIP-02), contact list publishing
|
||||
- Profile view + edit (kind 0)
|
||||
- Profile view + edit (kind 0) with Notes/Articles tab toggle
|
||||
- Long-form article editor (NIP-23) with draft auto-save
|
||||
- **Article discovery feed** — dedicated "Articles" view in sidebar; Latest/Following tabs
|
||||
- **Article reader** — markdown rendering, reading time, bookmark, like, zap
|
||||
- **Article search** — NIP-50 + hashtag search for kind 30023 articles
|
||||
- **Article cards** — reusable component with title, summary, author, cover thumbnail, reading time, tags
|
||||
- Zaps: NWC wallet connect (NIP-47) + NIP-57 via NDKZapper
|
||||
- Search: NIP-50 full-text, hashtag (#t filter), people
|
||||
- Search: NIP-50 full-text, hashtag (#t filter), people, articles
|
||||
- Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy
|
||||
- Relay connection status view
|
||||
|
||||
- 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
|
||||
- Follow suggestions / discovery (follows-of-follows algorithm)
|
||||
- Language/script feed filter (Unicode script detection + NIP-32 tags)
|
||||
- Skeleton loading states, view fade transitions
|
||||
- `src/components/shared/ImageLightbox.tsx` — full-screen image viewer
|
||||
- `src/stores/bookmark.ts` — bookmark store (mirrors mute store pattern)
|
||||
- `src/components/bookmark/BookmarkView.tsx` — saved notes view
|
||||
- `src/lib/language.ts` — Unicode script detection for feed filtering
|
||||
- Note sharing (nevent URI to clipboard)
|
||||
- Reply counts on notes
|
||||
- Media players (video/audio inline, YouTube/Vimeo/Spotify cards)
|
||||
- Multi-account switcher with keychain-backed session restore
|
||||
- System tray, keyboard shortcuts, auto-updater
|
||||
|
||||
**Not yet implemented:**
|
||||
- OS keychain integration (Rust) — nsec lives in NDK memory only
|
||||
- SQLite local note cache
|
||||
- Direct messages (NIP-44/17)
|
||||
- Reading long-form articles (NIP-23 reader view)
|
||||
- Zap counts on notes
|
||||
- NIP-65 outbox model
|
||||
- NIP-17 DMs (gift wrap)
|
||||
- Web of Trust scoring
|
||||
- NIP-46 remote signer
|
||||
- NIP-96 file storage
|
||||
- Custom feeds / lists
|
||||
|
||||
2
PKGBUILD
2
PKGBUILD
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
pkgname=wrystr
|
||||
pkgver=0.5.0
|
||||
pkgver=0.6.0
|
||||
pkgrel=1
|
||||
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -52,7 +52,7 @@ sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-liba
|
||||
- **Quote & Repost** (NIP-18) — one-click repost or quote with compose modal
|
||||
- **Bookmarks** (NIP-51 kind 10003) — save/unsave notes with one click; dedicated Bookmarks view in sidebar; synced to relays
|
||||
- **Mute users** (NIP-51) — muted list synced to relays, filtered from feed
|
||||
- Long-form article editor + reader (NIP-23) — write with title, tags, cover image, auto-save; click any `nostr:naddr1…` link to open in the in-app reader
|
||||
- **Long-form article experience** (NIP-23) — write articles with title, tags, cover image, auto-save; **dedicated article feed** with Latest/Following tabs; **article search** by keyword or hashtag; **article reader** with reading time, bookmark, like, and zap; **profile Articles tab** to browse any author's long-form posts
|
||||
- **Quoted note inline preview** — `nostr:note1…` / `nostr:nevent1…` renders as an inline card
|
||||
- Note rendering: images, video, mentions, hashtags, njump.me link interception
|
||||
- **Direct Messages** (NIP-04) — conversation list, thread view, per-message decryption; unread badge in sidebar
|
||||
@@ -72,7 +72,7 @@ sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-liba
|
||||
|
||||
**Discovery**
|
||||
- **Discover people** — "follows of follows" suggestions on the Search page with mutual follow counts and one-click follow
|
||||
- Search: NIP-50 full-text, `#hashtag`, people search with inline follow
|
||||
- Search: NIP-50 full-text, `#hashtag`, people search with inline follow, **article search** (kind 30023)
|
||||
|
||||
**Performance & UX**
|
||||
- **Auto-updater** — "Update & restart" banner when a new version is available
|
||||
@@ -109,10 +109,10 @@ npm run tauri build # production binary
|
||||
See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps.
|
||||
|
||||
Up next:
|
||||
- NIP-17 DMs (gift wrap) — proper sender/recipient privacy, replacing NIP-04
|
||||
- Web of Trust scoring
|
||||
- Long-form content discovery (trending articles, reading history)
|
||||
- NIP-46 remote signer support
|
||||
- Reading history / reading list
|
||||
- Custom feeds / lists
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
38
ROADMAP.md
38
ROADMAP.md
@@ -43,9 +43,9 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Polish & completeness ✓ MOSTLY COMPLETE
|
||||
## Phase 3 — Polish & completeness ✓ COMPLETE
|
||||
|
||||
*Shipped in v0.4.0. NIP-17 DMs deferred to Phase 4.*
|
||||
*Shipped in v0.4.0. NIP-17 DMs shipped in v0.5.0.*
|
||||
|
||||
- ✓ **Image lightbox** — click any image to view full-screen; Escape to close, left/right arrows for multi-image navigation
|
||||
- ✓ **Bookmarks (NIP-51 kind 10003)** — save/unsave notes with one click; dedicated Bookmarks view in sidebar; synced to relays
|
||||
@@ -53,11 +53,9 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
||||
- ✓ **Language/script feed filter** — dropdown in feed header; Unicode script detection (Latin, CJK, Cyrillic, Arabic, Korean, Hebrew, etc.) + NIP-32 language tag support
|
||||
- ✓ **UI polish** — skeleton loading placeholders, improved empty states with helpful prompts, subtle view fade transitions
|
||||
|
||||
### Remaining: NIP-17 DMs (gift wrap)
|
||||
- Current DMs use NIP-04 (kind 4) — works but deprecated and leaks metadata
|
||||
- NIP-17 wraps messages in gift wrap (kind 1059) for proper sender/recipient privacy
|
||||
- Needs inbox relay support (kind 10050) and ephemeral key signing
|
||||
- Not interoperable with NIP-04 — both should be supported during migration
|
||||
### NIP-17 DMs (gift wrap) ✓ SHIPPED
|
||||
- ✓ NIP-17 gift-wrapped DMs (kind 1059) with NIP-04 fallback
|
||||
- ✓ Both protocols supported — reads legacy NIP-04 + modern NIP-17
|
||||
|
||||
---
|
||||
|
||||
@@ -68,10 +66,13 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
||||
- Could power: feed ranking, spam filtering, people search, follow suggestions
|
||||
- Needs dedicated design session
|
||||
|
||||
### Long-form features (NIP-23 depth)
|
||||
- Discovery: browse articles from followed authors, trending articles
|
||||
- Reading history, estimated read time, table of contents
|
||||
- Editor improvements: image upload, word count, tag suggestions
|
||||
### Long-form features (NIP-23 depth) — partially shipped in v0.6.0
|
||||
- ✓ Discovery: dedicated article feed with Latest/Following tabs
|
||||
- ✓ Article search (NIP-50 + hashtag for kind 30023)
|
||||
- ✓ Profile Articles tab — browse any author's long-form posts
|
||||
- ✓ Reading time estimate, bookmark/like/zap on article reader
|
||||
- Remaining: reading history, table of contents, trending articles
|
||||
- Editor improvements: markdown toolbar, image upload, tag suggestions
|
||||
- Cross-posting to other platforms
|
||||
|
||||
### NIP-46 remote signer
|
||||
@@ -87,6 +88,21 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
||||
|
||||
## What's already shipped
|
||||
|
||||
### v0.6.0 — Long-form article experience
|
||||
- **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors
|
||||
- **Article cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips
|
||||
- **Article search** — search notes, articles, and people in parallel; articles tab in search results; supports full-text (NIP-50) and hashtag search
|
||||
- **Profile Articles tab** — Notes/Articles tab toggle on every profile; lazy-loads author's long-form posts
|
||||
- **Article reader polish** — estimated reading time (words/230), bookmark (save/unsave), like (reaction), zap — all in header and footer
|
||||
- **74 tests passing**, TypeScript strict, no regressions
|
||||
|
||||
### v0.5.0 — Sharing & Thread Indicators
|
||||
- **Note sharing** — share button copies `nostr:nevent1…` URI to clipboard; works logged out
|
||||
- **Reply count** — reply count next to reply button; optimistic update on send
|
||||
|
||||
### v0.4.1 — Media Players
|
||||
- Video/audio inline players, YouTube/Vimeo/Spotify rich cards
|
||||
|
||||
### v0.4.0 — Phase 3: Discovery & Polish
|
||||
- **Image lightbox** — click any image to view full-screen; Escape to close, arrow keys to navigate multi-image posts
|
||||
- **Bookmarks (NIP-51 kind 10003)** — save/unsave notes with one click; dedicated Bookmarks view in sidebar; synced to relays
|
||||
|
||||
1135
package-lock.json
generated
1135
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "wrystr",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "^3.0.3",
|
||||
@@ -28,11 +30,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
"vite": "^7.0.4",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -5824,7 +5824,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wrystr"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"keyring",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wrystr"
|
||||
version = "0.5.0"
|
||||
version = "0.6.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.5.0",
|
||||
"version": "0.6.0",
|
||||
"identifier": "com.hoornet.wrystr",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ProfileView } from "./components/profile/ProfileView";
|
||||
import { ThreadView } from "./components/thread/ThreadView";
|
||||
import { ArticleEditor } from "./components/article/ArticleEditor";
|
||||
import { ArticleView } from "./components/article/ArticleView";
|
||||
import { ArticleFeed } from "./components/article/ArticleFeed";
|
||||
import { OnboardingFlow } from "./components/onboarding/OnboardingFlow";
|
||||
import { AboutView } from "./components/shared/AboutView";
|
||||
import { ZapHistoryView } from "./components/zap/ZapHistoryView";
|
||||
@@ -70,6 +71,7 @@ function App() {
|
||||
{currentView === "settings" && <SettingsView />}
|
||||
{currentView === "profile" && <ProfileView />}
|
||||
{currentView === "thread" && <ThreadView />}
|
||||
{currentView === "articles" && <ArticleFeed />}
|
||||
{currentView === "article-editor" && <ArticleEditor />}
|
||||
{currentView === "article" && <ArticleView />}
|
||||
{currentView === "about" && <AboutView />}
|
||||
|
||||
120
src/components/article/ArticleCard.tsx
Normal file
120
src/components/article/ArticleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/article/ArticleFeed.tsx
Normal file
84
src/components/article/ArticleFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,15 +238,42 @@ export function ArticleView() {
|
||||
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
||||
← back
|
||||
</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-2 bg-zap hover:bg-zap/90 text-white transition-colors"
|
||||
className="text-[11px] px-4 py-1.5 bg-zap hover:bg-zap/90 text-white transition-colors"
|
||||
>
|
||||
⚡ Zap {authorName}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 */}
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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: "✉" },
|
||||
|
||||
@@ -325,6 +325,61 @@ export async function fetchReplyCount(eventId: string): Promise<number> {
|
||||
return events.size;
|
||||
}
|
||||
|
||||
export async function fetchBatchEngagement(eventIds: string[]): Promise<Map<string, { reactions: number; replies: number; zapSats: number }>> {
|
||||
const instance = getNDK();
|
||||
const result = new Map<string, { reactions: number; replies: number; zapSats: number }>();
|
||||
for (const id of eventIds) {
|
||||
result.set(id, { reactions: 0, replies: 0, zapSats: 0 });
|
||||
}
|
||||
|
||||
// Batch in chunks to avoid oversized filters
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < eventIds.length; i += chunkSize) {
|
||||
const chunk = eventIds.slice(i, i + chunkSize);
|
||||
|
||||
const [reactions, replies, zaps] = await Promise.all([
|
||||
instance.fetchEvents(
|
||||
{ kinds: [NDKKind.Reaction], "#e": chunk },
|
||||
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
|
||||
),
|
||||
instance.fetchEvents(
|
||||
{ kinds: [NDKKind.Text], "#e": chunk },
|
||||
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
|
||||
),
|
||||
instance.fetchEvents(
|
||||
{ kinds: [NDKKind.Zap], "#e": chunk },
|
||||
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
|
||||
),
|
||||
]);
|
||||
|
||||
for (const event of reactions) {
|
||||
const eTag = event.tags.find((t) => t[0] === "e")?.[1];
|
||||
if (eTag && result.has(eTag)) result.get(eTag)!.reactions++;
|
||||
}
|
||||
|
||||
for (const event of replies) {
|
||||
const eTag = event.tags.find((t) => t[0] === "e")?.[1];
|
||||
if (eTag && result.has(eTag)) result.get(eTag)!.replies++;
|
||||
}
|
||||
|
||||
for (const event of zaps) {
|
||||
const eTag = event.tags.find((t) => t[0] === "e")?.[1];
|
||||
if (eTag && result.has(eTag)) {
|
||||
const desc = event.tags.find((t) => t[0] === "description")?.[1];
|
||||
if (desc) {
|
||||
try {
|
||||
const zapReq = JSON.parse(desc) as { tags?: string[][] };
|
||||
const amountTag = zapReq.tags?.find((t) => t[0] === "amount");
|
||||
if (amountTag?.[1]) result.get(eTag)!.zapSats += Math.round(parseInt(amountTag[1]) / 1000);
|
||||
} catch { /* malformed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function fetchReactionCount(eventId: string): Promise<number> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = {
|
||||
@@ -497,6 +552,28 @@ export async function fetchAuthorArticles(pubkey: string, limit = 20): Promise<N
|
||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||
}
|
||||
|
||||
export async function fetchArticleFeed(limit = 40, authors?: string[]): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = { kinds: [NDKKind.Article], limit };
|
||||
if (authors && authors.length > 0) filter.authors = authors;
|
||||
const events = await instance.fetchEvents(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||
}
|
||||
|
||||
export async function searchArticles(query: string, limit = 30): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const isHashtag = query.startsWith("#");
|
||||
const filter: NDKFilter & { search?: string } = isHashtag
|
||||
? { kinds: [NDKKind.Article], "#t": [query.slice(1).toLowerCase()], limit }
|
||||
: { kinds: [NDKKind.Article], search: query, limit };
|
||||
const events = await instance.fetchEvents(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||
}
|
||||
|
||||
export async function fetchZapsReceived(pubkey: string, limit = 50): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = { kinds: [NDKKind.Zap], "#p": [pubkey], limit };
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } 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, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
|
||||
export type { UserRelayList } from "./client";
|
||||
|
||||
@@ -2,8 +2,8 @@ import { create } from "zustand";
|
||||
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "about" | "zaps" | "dm" | "notifications" | "bookmarks";
|
||||
type FeedTab = "global" | "following";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "about" | "zaps" | "dm" | "notifications" | "bookmarks";
|
||||
type FeedTab = "global" | "following" | "trending";
|
||||
|
||||
interface UIState {
|
||||
currentView: View;
|
||||
@@ -15,6 +15,7 @@ interface UIState {
|
||||
pendingSearch: string | null;
|
||||
pendingDMPubkey: string | null;
|
||||
pendingArticleNaddr: string | null;
|
||||
pendingArticleEvent: NDKEvent | null;
|
||||
showHelp: boolean;
|
||||
feedLanguageFilter: string | null;
|
||||
setView: (view: View) => void;
|
||||
@@ -23,7 +24,7 @@ interface UIState {
|
||||
openThread: (note: NDKEvent, from: View) => void;
|
||||
openSearch: (query: string) => void;
|
||||
openDM: (pubkey: string) => void;
|
||||
openArticle: (naddr: string) => void;
|
||||
openArticle: (naddr: string, event?: NDKEvent) => void;
|
||||
goBack: () => void;
|
||||
setFeedLanguageFilter: (filter: string | null) => void;
|
||||
toggleSidebar: () => void;
|
||||
@@ -42,6 +43,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
pendingSearch: null,
|
||||
pendingDMPubkey: null,
|
||||
pendingArticleNaddr: null,
|
||||
pendingArticleEvent: null,
|
||||
showHelp: false,
|
||||
feedLanguageFilter: null,
|
||||
setView: (currentView) => set({ currentView }),
|
||||
@@ -50,7 +52,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
|
||||
openSearch: (query) => set({ currentView: "search", pendingSearch: query }),
|
||||
openDM: (pubkey) => set({ currentView: "dm", pendingDMPubkey: pubkey }),
|
||||
openArticle: (naddr) => set((s) => ({ currentView: "article", pendingArticleNaddr: naddr, previousView: s.currentView as View })),
|
||||
openArticle: (naddr, event) => set((s) => ({ currentView: "article", pendingArticleNaddr: naddr, pendingArticleEvent: event ?? null, previousView: s.currentView as View })),
|
||||
goBack: () => set((s) => ({
|
||||
showHelp: false,
|
||||
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
|
||||
|
||||
Reference in New Issue
Block a user