mirror of
https://github.com/hoornet/vega.git
synced 2026-06-15 01:03:34 -07:00
Bump to v0.8.3 — trending feed, NIP-46 remote signer, media feed, profile media gallery
This commit is contained in:
@@ -69,12 +69,21 @@ 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.8.2 — Writing & Reading Polish
|
||||
- **No more 280-char limit** — Nostr has no protocol limit; compose freely up to 4000 chars with soft warnings, never blocked
|
||||
- **Serif reading font** — article reader uses Georgia/serif at 17px for comfortable long-form reading; code blocks stay monospace
|
||||
- **Reading progress bar** — thin accent-colored bar at the top of articles tracks your scroll position
|
||||
- **Article table of contents** — floating TOC in the right margin on wide screens; parses h2/h3 headings, highlights active section, click to scroll; hidden on narrow screens
|
||||
- **Connection indicator fix** — data-aware connectivity checking with 25s grace period; no more false "offline" on startup
|
||||
### New in v0.8.3 — Trending, Remote Signer, Media
|
||||
- **Trending feed polish** — 24h time window with engagement decay scoring; articles (kind 30023) appear alongside notes in trending; better empty state
|
||||
- **NIP-46 remote signer** — connect via bunker:// URI (nsecBunker, Amber, etc.); session persistence across restarts; third login tab in onboarding and add-account modal
|
||||
- **Media feed** — new "Media" view in sidebar; All/Videos/Images/Audio tabs filter notes by embedded media type
|
||||
- **Profile media gallery** — new "Media" tab on profiles shows a grid of the user's images, videos, and audio; images open in lightbox, videos/audio navigate to thread
|
||||
- **Syntax highlighting** — code blocks in notes and articles render with syntax highlighting
|
||||
- **OS push notifications** — background poller (60s) for mentions, zaps, new followers; each type independently toggleable
|
||||
- **Zen writing mode** — distraction-free article editing with auto-save indicator
|
||||
- **NIP-05 verification badges** — cached verification with green checkmark on note cards
|
||||
- **Dedicated hashtag pages** — clicking #tag opens a live feed, not generic search
|
||||
- **Keyword muting** — word/phrase mute list, client-side filtering across all views
|
||||
- **Follow suggestion dismissal** — persistent "don't suggest again" per person
|
||||
|
||||
### Previous: v0.8.2 — Writing & Reading Polish
|
||||
- No more 280-char limit, serif reading font, reading progress bar, article TOC, connection indicator fix
|
||||
|
||||
### Previous: v0.8.0 — Polish, Portability & Discovery
|
||||
- Profile banner polish, data export, relay recommendations, reading list tracking, trending hashtags
|
||||
|
||||
@@ -57,6 +57,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
||||
- `src/lib/nostr/relayHealth.ts` — Relay health checker (NIP-11, latency probing, status classification)
|
||||
- `src/components/article/` — ArticleEditor, ArticleView, ArticleFeed, ArticleCard, MarkdownToolbar (NIP-23)
|
||||
- `src/components/bookmark/` — BookmarkView
|
||||
- `src/components/media/` — MediaFeed (media discovery with tab filtering)
|
||||
- `src/components/zap/` — ZapModal
|
||||
- `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login)
|
||||
- `src/components/shared/` — RelaysView (relay health dashboard + recommendations), SettingsView (NWC + identity + data export)
|
||||
@@ -125,9 +126,17 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
||||
- Media players (video/audio inline, YouTube/Vimeo/Spotify cards)
|
||||
- Multi-account switcher with keychain-backed session restore
|
||||
- System tray, keyboard shortcuts, auto-updater
|
||||
- **NIP-05 verification badges** — cached verification with green checkmark on note cards
|
||||
- **Dedicated hashtag pages** — clicking #tag opens a live feed, not generic search
|
||||
- **Keyword muting** — word/phrase mute list, client-side filtering across all views
|
||||
- **Follow suggestion dismissal** — persistent "don't suggest again" per person
|
||||
- **Background notification poller** — 60s polling for mentions, zaps, new followers; each type independently toggleable
|
||||
- **Trending feed polish** — 24h time window, time decay scoring, articles mixed with notes
|
||||
- **NIP-46 remote signer** — bunker:// URI login, session persistence via toPayload/fromPayload, account switching
|
||||
- **Media feed** — dedicated "Media" view with All/Videos/Images/Audio tabs; filters notes by embedded media type
|
||||
- **Profile media gallery** — "Media" tab on profiles with grid layout; images open lightbox, videos/audio navigate to thread
|
||||
|
||||
**Not yet implemented:**
|
||||
- Web of Trust scoring
|
||||
- NIP-46 remote signer
|
||||
- NIP-96 file storage
|
||||
- Custom feeds / lists
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
pkgname=wrystr
|
||||
pkgver=0.8.2
|
||||
pkgver=0.8.3
|
||||
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.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+69
-74
@@ -234,28 +234,6 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -436,8 +414,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -492,15 +468,6 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -1116,12 +1083,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -1996,16 +1957,6 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.91"
|
||||
@@ -2200,6 +2151,18 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
@@ -2252,16 +2215,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
@@ -2352,6 +2305,20 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -2822,7 +2789,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -2978,6 +2945,15 @@ dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -3013,7 +2989,6 @@ version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
@@ -3319,10 +3294,8 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
@@ -3428,7 +3401,6 @@ version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3492,7 +3464,6 @@ version = "0.103.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -4325,6 +4296,25 @@ dependencies = [
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
@@ -4490,6 +4480,18 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.26.0"
|
||||
@@ -4932,12 +4934,6 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -5936,11 +5932,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wrystr"
|
||||
version = "0.6.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"keyring",
|
||||
"mime_guess",
|
||||
"reqwest 0.13.2",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5949,6 +5943,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wrystr"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
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.8.2",
|
||||
"version": "0.8.3",
|
||||
"identifier": "com.hoornet.wrystr",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { MediaFeed } from "./components/media/MediaFeed";
|
||||
import { OnboardingFlow } from "./components/onboarding/OnboardingFlow";
|
||||
import { AboutView } from "./components/shared/AboutView";
|
||||
import { ZapHistoryView } from "./components/zap/ZapHistoryView";
|
||||
@@ -73,6 +74,7 @@ function App() {
|
||||
{currentView === "profile" && <ProfileView />}
|
||||
{currentView === "thread" && <ThreadView />}
|
||||
{currentView === "articles" && <ArticleFeed />}
|
||||
{currentView === "media" && <MediaFeed />}
|
||||
{currentView === "article-editor" && <ArticleEditor />}
|
||||
{currentView === "article" && <ArticleView />}
|
||||
{currentView === "about" && <AboutView />}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useUIStore } from "../../stores/ui";
|
||||
import { fetchFollowFeed, getNDK } from "../../lib/nostr";
|
||||
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
|
||||
import { NoteCard } from "./NoteCard";
|
||||
import { ArticleCard } from "../article/ArticleCard";
|
||||
import { ComposeBox } from "./ComposeBox";
|
||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
@@ -168,25 +169,33 @@ export function Feed() {
|
||||
{!isLoading && filteredNotes.length === 0 && (
|
||||
<div className="px-4 py-12 text-center space-y-2">
|
||||
<p className="text-text-dim text-[13px]">
|
||||
{isFollowing && follows.length === 0
|
||||
? "You're not following anyone yet."
|
||||
: feedLanguageFilter
|
||||
? `No ${feedLanguageFilter} notes found.`
|
||||
: "No notes to show."}
|
||||
{isTrending
|
||||
? "No trending notes right now."
|
||||
: isFollowing && follows.length === 0
|
||||
? "You're not following anyone yet."
|
||||
: feedLanguageFilter
|
||||
? `No ${feedLanguageFilter} notes found.`
|
||||
: "No notes to show."}
|
||||
</p>
|
||||
<p className="text-text-dim text-[11px] opacity-60">
|
||||
{isFollowing && follows.length === 0
|
||||
? "Use search to find people to follow."
|
||||
: feedLanguageFilter
|
||||
? "Try clearing the script filter or refreshing."
|
||||
: "Try refreshing or switching tabs."}
|
||||
{isTrending
|
||||
? "Check back in a bit."
|
||||
: isFollowing && follows.length === 0
|
||||
? "Use search to find people to follow."
|
||||
: feedLanguageFilter
|
||||
? "Try clearing the script filter or refreshing."
|
||||
: "Try refreshing or switching tabs."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredNotes.map((event, index) => (
|
||||
<NoteCard key={event.id} event={event} focused={focusedNoteIndex === index} />
|
||||
))}
|
||||
{filteredNotes.map((event, index) =>
|
||||
event.kind === 30023 ? (
|
||||
<ArticleCard key={event.id} event={event} />
|
||||
) : (
|
||||
<NoteCard key={event.id} event={event} focused={focusedNoteIndex === index} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { fetchGlobalFeed } from "../../lib/nostr";
|
||||
import { parseContent, ContentSegment } from "../../lib/parsing";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
|
||||
type MediaTab = "all" | "videos" | "images" | "audio";
|
||||
|
||||
const MEDIA_TYPES: Record<MediaTab, ContentSegment["type"][]> = {
|
||||
all: ["image", "video", "audio", "youtube", "vimeo", "spotify", "tidal"],
|
||||
videos: ["video", "youtube", "vimeo"],
|
||||
images: ["image"],
|
||||
audio: ["audio", "spotify", "tidal"],
|
||||
};
|
||||
|
||||
function hasMediaType(content: string, types: ContentSegment["type"][]): boolean {
|
||||
const segments = parseContent(content);
|
||||
return segments.some((s) => types.includes(s.type));
|
||||
}
|
||||
|
||||
export function MediaFeed() {
|
||||
const [allNotes, setAllNotes] = useState<NDKEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<MediaTab>("all");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchGlobalFeed(300)
|
||||
.then((notes) => {
|
||||
const mediaNotes = notes.filter((n) => hasMediaType(n.content, MEDIA_TYPES.all));
|
||||
setAllNotes(mediaNotes);
|
||||
})
|
||||
.catch(() => setAllNotes([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = tab === "all"
|
||||
? allNotes
|
||||
: allNotes.filter((n) => hasMediaType(n.content, MEDIA_TYPES[tab]));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<h1 className="text-text text-sm font-medium mr-3">Media</h1>
|
||||
{(["all", "videos", "images", "audio"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-1 text-[12px] transition-colors ${
|
||||
tab === t
|
||||
? "text-text border-b-2 border-accent"
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && <SkeletonNoteList count={4} />}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="px-4 py-12 text-center space-y-2">
|
||||
<p className="text-text-dim text-[13px]">
|
||||
No {tab === "all" ? "media" : tab} found.
|
||||
</p>
|
||||
<p className="text-text-dim text-[11px] opacity-60">
|
||||
Try switching tabs or check back later.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -187,8 +187,8 @@ function BackupStep({ signer, onComplete }: { signer: NDKPrivateKeySigner; onCom
|
||||
// ─── Step: Login with existing key ───────────────────────────────────────────
|
||||
|
||||
function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: () => void }) {
|
||||
const { loginWithNsec, loginWithPubkey, loginError, loggedIn } = useUserStore();
|
||||
const [mode, setMode] = useState<"nsec" | "npub">("nsec");
|
||||
const { loginWithNsec, loginWithPubkey, loginWithRemoteSigner, loginError, loggedIn } = useUserStore();
|
||||
const [mode, setMode] = useState<"nsec" | "npub" | "bunker">("nsec");
|
||||
const [value, setValue] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -201,6 +201,8 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
|
||||
setLoading(true);
|
||||
if (mode === "nsec") {
|
||||
await loginWithNsec(value.trim());
|
||||
} else if (mode === "bunker") {
|
||||
await loginWithRemoteSigner(value.trim());
|
||||
} else {
|
||||
await loginWithPubkey(value.trim());
|
||||
}
|
||||
@@ -211,12 +213,15 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
|
||||
if (e.key === "Enter") handleLogin();
|
||||
};
|
||||
|
||||
const tabLabel = (m: "nsec" | "npub" | "bunker") =>
|
||||
m === "nsec" ? "Secret key" : m === "npub" ? "Public key" : "Remote signer";
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Heading>Log in with your key.</Heading>
|
||||
|
||||
<div className="flex border border-border mb-4">
|
||||
{(["nsec", "npub"] as const).map((m) => (
|
||||
{(["nsec", "npub", "bunker"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setMode(m); setValue(""); }}
|
||||
@@ -224,7 +229,7 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
|
||||
mode === m ? "bg-accent/10 text-accent" : "text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{m === "nsec" ? "Secret key (nsec)" : "Public key only (read-only)"}
|
||||
{tabLabel(m)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -233,7 +238,7 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={mode === "nsec" ? "nsec1…" : "npub1…"}
|
||||
placeholder={mode === "nsec" ? "nsec1…" : mode === "npub" ? "npub1…" : "bunker://…"}
|
||||
autoFocus
|
||||
className="w-full bg-bg border border-border px-3 py-2 text-text text-[12px] font-mono focus:outline-none focus:border-accent/50 placeholder:text-text-dim mb-2"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
@@ -242,6 +247,9 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
|
||||
{mode === "npub" && (
|
||||
<p className="text-text-dim text-[11px] mb-4">Read-only mode — you can browse but not post, react, or zap.</p>
|
||||
)}
|
||||
{mode === "bunker" && (
|
||||
<p className="text-text-dim text-[11px] mb-4">Connect to nsecBunker, Amber, or similar. Paste your bunker:// URI.</p>
|
||||
)}
|
||||
|
||||
{loginError && <p className="text-danger text-[11px] mb-3">{loginError}</p>}
|
||||
|
||||
@@ -251,7 +259,7 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
|
||||
disabled={!value.trim() || loading}
|
||||
className="w-full py-2.5 text-[13px] font-medium bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Logging in…" : "Log in"}
|
||||
{loading ? (mode === "bunker" ? "Connecting…" : "Logging in…") : "Log in"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onBack}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useProfile, invalidateProfileCache } from "../../hooks/useProfile";
|
||||
import { fetchUserNotesNIP65, fetchAuthorArticles, publishProfile, getNDK } from "../../lib/nostr";
|
||||
import { parseContent } from "../../lib/parsing";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
import { uploadImage } from "../../lib/upload";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
@@ -231,7 +232,7 @@ export function ProfileView() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [followPending, setFollowPending] = useState(false);
|
||||
const [showZap, setShowZap] = useState(false);
|
||||
const [profileTab, setProfileTab] = useState<"notes" | "articles">("notes");
|
||||
const [profileTab, setProfileTab] = useState<"notes" | "articles" | "media">("notes");
|
||||
const [bannerLightbox, setBannerLightbox] = useState(false);
|
||||
const [bannerLoaded, setBannerLoaded] = useState(false);
|
||||
|
||||
@@ -422,9 +423,9 @@ export function ProfileView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notes / Articles tabs */}
|
||||
{/* Notes / Articles / Media tabs */}
|
||||
<div className="border-b border-border flex shrink-0">
|
||||
{(["notes", "articles"] as const).map((t) => (
|
||||
{(["notes", "articles", "media"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setProfileTab(t)}
|
||||
@@ -458,7 +459,138 @@ export function ProfileView() {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{profileTab === "media" && (
|
||||
<ProfileMediaGallery notes={notes} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Media gallery sub-component ──────────────────────────────────────────────
|
||||
|
||||
const MEDIA_SEGMENT_TYPES = new Set(["image", "video", "audio", "youtube", "vimeo"]);
|
||||
|
||||
interface MediaItem {
|
||||
type: "image" | "video" | "audio";
|
||||
url: string;
|
||||
thumbnailId?: string;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
function extractMediaItems(notes: NDKEvent[]): MediaItem[] {
|
||||
const items: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const note of notes) {
|
||||
const segments = parseContent(note.content);
|
||||
for (const seg of segments) {
|
||||
if (!MEDIA_SEGMENT_TYPES.has(seg.type)) continue;
|
||||
if (seen.has(seg.value)) continue;
|
||||
seen.add(seg.value);
|
||||
if (seg.type === "image") {
|
||||
items.push({ type: "image", url: seg.value, noteId: note.id! });
|
||||
} else if (seg.type === "video" || seg.type === "youtube" || seg.type === "vimeo") {
|
||||
items.push({ type: "video", url: seg.value, thumbnailId: seg.mediaId, noteId: note.id! });
|
||||
} else if (seg.type === "audio") {
|
||||
items.push({ type: "audio", url: seg.value, noteId: note.id! });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function ProfileMediaGallery({ notes, loading }: { notes: NDKEvent[]; loading: boolean }) {
|
||||
const { openThread } = useUIStore();
|
||||
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading media…</div>;
|
||||
}
|
||||
|
||||
const items = extractMediaItems(notes);
|
||||
const imageUrls = items.filter((i) => i.type === "image").map((i) => i.url);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">No media found.</div>;
|
||||
}
|
||||
|
||||
const openNote = (noteId: string) => {
|
||||
const note = notes.find((n) => n.id === noteId);
|
||||
if (note) openThread(note, "profile");
|
||||
};
|
||||
|
||||
let imageIndex = 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-1 p-2">
|
||||
{items.map((item, idx) => {
|
||||
if (item.type === "image") {
|
||||
const currentImageIdx = imageIndex++;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setLightboxIdx(currentImageIdx)}
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.type === "video") {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity relative flex items-center justify-center"
|
||||
onClick={() => openNote(item.noteId)}
|
||||
>
|
||||
{item.thumbnailId ? (
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${item.thumbnailId}/mqdefault.jpg`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-bg-raised" />
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="w-10 h-10 rounded-full bg-black/60 flex items-center justify-center text-white text-lg">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// audio
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity flex items-center justify-center"
|
||||
onClick={() => openNote(item.noteId)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<span className="text-3xl text-text-dim">♪</span>
|
||||
<p className="text-text-dim text-[10px] mt-1 px-2 truncate">{item.url.split("/").pop()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lightboxIdx !== null && (
|
||||
<ImageLightbox
|
||||
images={imageUrls}
|
||||
index={lightboxIdx}
|
||||
onClose={() => setLightboxIdx(null)}
|
||||
onNavigate={(i) => setLightboxIdx(i)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,19 +78,24 @@ function NewAccountTab({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
|
||||
export function LoginModal({ onClose }: LoginModalProps) {
|
||||
const [tab, setTab] = useState<"nsec" | "pubkey" | "new">("nsec");
|
||||
const [tab, setTab] = useState<"nsec" | "pubkey" | "bunker" | "new">("nsec");
|
||||
const [input, setInput] = useState("");
|
||||
const { loginWithNsec, loginWithPubkey, loginError } = useUserStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginWithNsec, loginWithPubkey, loginWithRemoteSigner, loginError } = useUserStore();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!input.trim()) return;
|
||||
if (!input.trim() || loading) return;
|
||||
setLoading(true);
|
||||
|
||||
if (tab === "nsec") {
|
||||
await loginWithNsec(input.trim());
|
||||
} else if (tab === "pubkey") {
|
||||
await loginWithPubkey(input.trim());
|
||||
} else if (tab === "bunker") {
|
||||
await loginWithRemoteSigner(input.trim());
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
// Close if no error
|
||||
if (!useUserStore.getState().loginError) {
|
||||
onClose();
|
||||
@@ -124,36 +129,19 @@ export function LoginModal({ onClose }: LoginModalProps) {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
<button
|
||||
onClick={() => setTab("nsec")}
|
||||
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
|
||||
tab === "nsec"
|
||||
? "text-accent border-b-2 border-accent"
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Private key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("pubkey")}
|
||||
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
|
||||
tab === "pubkey"
|
||||
? "text-accent border-b-2 border-accent"
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Read-only
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("new")}
|
||||
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
|
||||
tab === "new"
|
||||
? "text-accent border-b-2 border-accent"
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
New account
|
||||
</button>
|
||||
{(["nsec", "pubkey", "bunker", "new"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTab(t); setInput(""); }}
|
||||
className={`flex-1 px-3 py-2 text-[11px] transition-colors ${
|
||||
tab === t
|
||||
? "text-accent border-b-2 border-accent"
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{t === "nsec" ? "Private key" : t === "pubkey" ? "Read-only" : t === "bunker" ? "Remote signer" : "New account"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -165,14 +153,16 @@ export function LoginModal({ onClose }: LoginModalProps) {
|
||||
<label className="block text-text-muted text-[11px] mb-1.5">
|
||||
{tab === "nsec"
|
||||
? "Paste your nsec or hex private key"
|
||||
: "Paste your npub or hex public key"}
|
||||
: tab === "pubkey"
|
||||
? "Paste your npub or hex public key"
|
||||
: "Paste your bunker:// URI"}
|
||||
</label>
|
||||
<input
|
||||
type={tab === "nsec" ? "password" : "text"}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={tab === "nsec" ? "nsec1…" : "npub1…"}
|
||||
placeholder={tab === "nsec" ? "nsec1…" : tab === "pubkey" ? "npub1…" : "bunker://…"}
|
||||
autoFocus
|
||||
className="w-full bg-bg border border-border px-3 py-2 text-text text-[13px] font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
@@ -189,16 +179,24 @@ export function LoginModal({ onClose }: LoginModalProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tab === "bunker" && (
|
||||
<p className="text-text-dim text-[10px] mt-1.5">
|
||||
Connect to nsecBunker, Amber, or similar. Your keys never leave the signer.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loginError && (
|
||||
<p className="text-danger text-[11px] mt-2">{loginError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={!input.trim()}
|
||||
disabled={!input.trim() || loading}
|
||||
className="w-full mt-3 px-4 py-2 text-[12px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{tab === "nsec" ? "Login" : "View as read-only"}
|
||||
{loading
|
||||
? (tab === "bunker" ? "Connecting…" : "Logging in…")
|
||||
: tab === "nsec" ? "Login" : tab === "pubkey" ? "View as read-only" : "Connect"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -98,6 +98,9 @@ export function AccountSwitcher() {
|
||||
>
|
||||
<Avatar account={a} />
|
||||
<span className="text-text-muted text-[11px] truncate flex-1">{displayName(a)}</span>
|
||||
{a.loginType === "remote-signer" && (
|
||||
<span className="text-[10px] text-text-dim" title="Remote signer (NIP-46)">NIP-46</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleRemove(e, a.pubkey)}
|
||||
className="text-text-dim hover:text-danger text-[11px] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
||||
@@ -11,6 +11,7 @@ import pkg from "../../../package.json";
|
||||
const NAV_ITEMS = [
|
||||
{ id: "feed" as const, label: "feed", icon: "◈" },
|
||||
{ id: "articles" as const, label: "articles", icon: "☰" },
|
||||
{ id: "media" as const, label: "media", icon: "▶" },
|
||||
{ id: "search" as const, label: "search", icon: "⌕" },
|
||||
{ id: "bookmarks" as const, label: "bookmarks", icon: "▪" },
|
||||
{ id: "dm" as const, label: "messages", icon: "✉" },
|
||||
|
||||
@@ -836,6 +836,22 @@ export async function fetchRelayRecommendations(
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
// ── Trending Candidates ───────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchTrendingCandidates(limit = 200, sinceHours = 24): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const since = Math.floor(Date.now() / 1000) - sinceHours * 3600;
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text, 30023 as NDKKind],
|
||||
since,
|
||||
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));
|
||||
}
|
||||
|
||||
// ── Trending Hashtags ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchTrendingHashtags(limit = 15): Promise<{ tag: string; count: number }[]> {
|
||||
|
||||
@@ -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, fetchRelayRecommendations, fetchTrendingHashtags, fetchHashtagFeed, fetchNewFollowers } 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, fetchTrendingCandidates, fetchHashtagFeed, fetchNewFollowers } from "./client";
|
||||
export type { UserRelayList, AdvancedSearchResults } from "./client";
|
||||
|
||||
+15
-11
@@ -5,6 +5,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
vi.mock("../lib/nostr", () => ({
|
||||
connectToRelays: vi.fn(),
|
||||
fetchGlobalFeed: vi.fn(),
|
||||
fetchTrendingCandidates: vi.fn(),
|
||||
fetchBatchEngagement: vi.fn(),
|
||||
getNDK: vi.fn(() => ({ pool: { relays: new Map() } })),
|
||||
}));
|
||||
@@ -16,7 +17,7 @@ vi.mock("../lib/db", () => ({
|
||||
}));
|
||||
|
||||
import { useFeedStore } from "./feed";
|
||||
import { fetchGlobalFeed, fetchBatchEngagement } from "../lib/nostr";
|
||||
import { fetchTrendingCandidates, fetchBatchEngagement } from "../lib/nostr";
|
||||
|
||||
function makeMockNote(id: string, created_at: number): NDKEvent {
|
||||
const event = { id, created_at, content: "test", kind: 1, pubkey: "pk", tags: [], sig: "", rawEvent: () => ({ id, created_at, content: "test", kind: 1, pubkey: "pk", tags: [], sig: "" }) } as unknown as NDKEvent;
|
||||
@@ -39,10 +40,11 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
});
|
||||
|
||||
it("scores and sorts notes by engagement", async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const notes = [
|
||||
makeMockNote("a", 1000),
|
||||
makeMockNote("b", 1001),
|
||||
makeMockNote("c", 1002),
|
||||
makeMockNote("a", now - 100),
|
||||
makeMockNote("b", now - 100),
|
||||
makeMockNote("c", now - 100),
|
||||
];
|
||||
|
||||
const engagement = new Map([
|
||||
@@ -51,7 +53,7 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
["c", { reactions: 1, replies: 1, zapSats: 100 }], // score: 5
|
||||
]);
|
||||
|
||||
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
|
||||
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);
|
||||
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
|
||||
|
||||
await useFeedStore.getState().loadTrendingFeed(true);
|
||||
@@ -64,9 +66,10 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
});
|
||||
|
||||
it("filters out notes with zero engagement", async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const notes = [
|
||||
makeMockNote("a", 1000),
|
||||
makeMockNote("b", 1001),
|
||||
makeMockNote("a", now - 100),
|
||||
makeMockNote("b", now - 100),
|
||||
];
|
||||
|
||||
const engagement = new Map([
|
||||
@@ -74,7 +77,7 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
["b", { reactions: 0, replies: 0, zapSats: 0 }],
|
||||
]);
|
||||
|
||||
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
|
||||
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);
|
||||
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
|
||||
|
||||
await useFeedStore.getState().loadTrendingFeed(true);
|
||||
@@ -85,12 +88,13 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
});
|
||||
|
||||
it("limits results to 50", async () => {
|
||||
const notes = Array.from({ length: 60 }, (_, i) => makeMockNote(`n${i}`, i));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const notes = Array.from({ length: 60 }, (_, i) => makeMockNote(`n${i}`, now - i));
|
||||
const engagement = new Map(
|
||||
notes.map((n) => [n.id, { reactions: 10, replies: 1, zapSats: 0 }])
|
||||
);
|
||||
|
||||
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
|
||||
vi.mocked(fetchTrendingCandidates).mockResolvedValue(notes);
|
||||
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
|
||||
|
||||
await useFeedStore.getState().loadTrendingFeed(true);
|
||||
@@ -99,7 +103,7 @@ describe("useFeedStore - loadTrendingFeed", () => {
|
||||
});
|
||||
|
||||
it("handles empty feed gracefully", async () => {
|
||||
vi.mocked(fetchGlobalFeed).mockResolvedValue([]);
|
||||
vi.mocked(fetchTrendingCandidates).mockResolvedValue([]);
|
||||
|
||||
await useFeedStore.getState().loadTrendingFeed(true);
|
||||
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { connectToRelays, fetchGlobalFeed, fetchBatchEngagement, getNDK } from "../lib/nostr";
|
||||
import { connectToRelays, fetchGlobalFeed, fetchBatchEngagement, fetchTrendingCandidates, getNDK } from "../lib/nostr";
|
||||
import { dbLoadFeed, dbSaveNotes } from "../lib/db";
|
||||
|
||||
const TRENDING_CACHE_KEY = "wrystr_trending_cache";
|
||||
@@ -135,7 +135,7 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
||||
|
||||
set({ trendingLoading: true });
|
||||
try {
|
||||
const notes = await fetchGlobalFeed(200);
|
||||
const notes = await fetchTrendingCandidates(200, 24);
|
||||
|
||||
if (notes.length === 0) {
|
||||
set({ trendingNotes: [], trendingLoading: false });
|
||||
@@ -145,10 +145,13 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
||||
const eventIds = notes.map((n) => n.id).filter(Boolean) as string[];
|
||||
const engagement = await fetchBatchEngagement(eventIds);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const scored = notes
|
||||
.map((note) => {
|
||||
const eng = engagement.get(note.id) ?? { reactions: 0, replies: 0, zapSats: 0 };
|
||||
const score = eng.reactions * 1 + eng.replies * 3 + eng.zapSats * 0.01;
|
||||
const ageHours = (now - (note.created_at ?? now)) / 3600;
|
||||
const decay = 1 / (1 + ageHours * 0.15);
|
||||
const score = (eng.reactions * 1 + eng.replies * 3 + eng.zapSats * 0.01) * decay;
|
||||
return { note, score };
|
||||
})
|
||||
.filter((s) => s.score > 0)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type FeedTab = "global" | "following" | "trending";
|
||||
|
||||
interface UIState {
|
||||
|
||||
+106
-2
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { NDKPrivateKeySigner, NDKNip46Signer } from "@nostr-dev-kit/ndk";
|
||||
import { getNDK, publishContactList } from "../lib/nostr";
|
||||
import { nip19 } from "@nostr-dev-kit/ndk";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -15,7 +15,8 @@ export interface SavedAccount {
|
||||
npub: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
loginType?: "nsec" | "pubkey";
|
||||
loginType?: "nsec" | "pubkey" | "remote-signer";
|
||||
signerPayload?: string;
|
||||
}
|
||||
|
||||
// In-memory signer cache — survives account switches within a session.
|
||||
@@ -23,6 +24,7 @@ export interface SavedAccount {
|
||||
// This means the keychain is only ever consulted at startup (restoreSession),
|
||||
// not on every switch, eliminating the "read-only after switch" class of bugs.
|
||||
const _signerCache = new Map<string, NDKPrivateKeySigner>();
|
||||
const _nip46SignerCache = new Map<string, NDKNip46Signer>();
|
||||
|
||||
function loadSavedAccounts(): SavedAccount[] {
|
||||
try {
|
||||
@@ -53,6 +55,7 @@ interface UserState {
|
||||
|
||||
loginWithNsec: (nsec: string) => Promise<void>;
|
||||
loginWithPubkey: (pubkey: string) => Promise<void>;
|
||||
loginWithRemoteSigner: (bunkerUri: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
restoreSession: () => Promise<void>;
|
||||
switchAccount: (pubkey: string) => Promise<void>;
|
||||
@@ -176,6 +179,50 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginWithRemoteSigner: async (bunkerUri: string) => {
|
||||
try {
|
||||
set({ loginError: null });
|
||||
|
||||
const ndk = getNDK();
|
||||
const signer = NDKNip46Signer.bunker(ndk, bunkerUri);
|
||||
|
||||
// Wait for signer with 15s timeout
|
||||
const user = await Promise.race([
|
||||
signer.blockUntilReady(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Remote signer didn't respond within 15 seconds. Check your connection.")), 15000)
|
||||
),
|
||||
]);
|
||||
|
||||
ndk.signer = signer;
|
||||
const pubkey = user.pubkey;
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
_nip46SignerCache.set(pubkey, signer);
|
||||
|
||||
const signerPayload = signer.toPayload();
|
||||
const accounts = upsertAccount(get().accounts, { pubkey, npub, loginType: "remote-signer", signerPayload });
|
||||
persistAccounts(accounts);
|
||||
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null, accounts });
|
||||
|
||||
localStorage.setItem("wrystr_pubkey", pubkey);
|
||||
localStorage.setItem("wrystr_login_type", "remote-signer");
|
||||
|
||||
useLightningStore.getState().loadNwcForAccount(pubkey);
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(pubkey);
|
||||
useNotificationsStore.getState().fetchNotifications(pubkey);
|
||||
startNotificationPoller(pubkey);
|
||||
|
||||
useUIStore.getState().setView("feed");
|
||||
useFeedStore.getState().loadFeed();
|
||||
} catch (err) {
|
||||
set({ loginError: `Remote signer login failed: ${err}` });
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
stopNotificationPoller();
|
||||
const ndk = getNDK();
|
||||
@@ -209,6 +256,17 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
// Restore NIP-46 signers from saved payloads
|
||||
for (const acct of accounts) {
|
||||
if (acct.loginType !== "remote-signer" || !acct.signerPayload || _nip46SignerCache.has(acct.pubkey)) continue;
|
||||
try {
|
||||
const signer = await NDKNip46Signer.fromPayload(acct.signerPayload, getNDK());
|
||||
_nip46SignerCache.set(acct.pubkey, signer);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to restore NIP-46 session for ${acct.npub}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Now restore the active account
|
||||
const savedPubkey = localStorage.getItem("wrystr_pubkey");
|
||||
const savedLoginType = localStorage.getItem("wrystr_login_type");
|
||||
@@ -219,6 +277,29 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedLoginType === "remote-signer") {
|
||||
const cachedSigner = _nip46SignerCache.get(savedPubkey);
|
||||
if (cachedSigner) {
|
||||
try {
|
||||
await cachedSigner.blockUntilReady();
|
||||
getNDK().signer = cachedSigner;
|
||||
const npub = nip19.npubEncode(savedPubkey);
|
||||
set({ pubkey: savedPubkey, npub, loggedIn: true, loginError: null });
|
||||
localStorage.setItem("wrystr_pubkey", savedPubkey);
|
||||
localStorage.setItem("wrystr_login_type", "remote-signer");
|
||||
useLightningStore.getState().loadNwcForAccount(savedPubkey);
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(savedPubkey);
|
||||
useNotificationsStore.getState().fetchNotifications(savedPubkey);
|
||||
startNotificationPoller(savedPubkey);
|
||||
} catch (err) {
|
||||
console.warn("Failed to restore NIP-46 session:", err);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedLoginType === "nsec") {
|
||||
const cachedSigner = _signerCache.get(savedPubkey);
|
||||
if (cachedSigner) {
|
||||
@@ -242,6 +323,29 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
// Clear signer immediately — no window where old account could sign
|
||||
getNDK().signer = undefined;
|
||||
|
||||
// Fast path: NIP-46 cached signer
|
||||
const cachedNip46 = _nip46SignerCache.get(pubkey);
|
||||
if (cachedNip46) {
|
||||
try {
|
||||
await cachedNip46.blockUntilReady();
|
||||
getNDK().signer = cachedNip46;
|
||||
const account = get().accounts.find((a) => a.pubkey === pubkey);
|
||||
const npub = account?.npub ?? nip19.npubEncode(pubkey);
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null });
|
||||
localStorage.setItem("wrystr_pubkey", pubkey);
|
||||
localStorage.setItem("wrystr_login_type", "remote-signer");
|
||||
useLightningStore.getState().loadNwcForAccount(pubkey);
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(pubkey);
|
||||
startNotificationPoller(pubkey);
|
||||
useUIStore.getState().setView("feed");
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn("NIP-46 signer reconnect failed during switch:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path: reuse in-memory signer cached from the login that added this
|
||||
// account earlier in this session. Avoids a round-trip to the OS keychain
|
||||
// and eliminates the "becomes read-only after switch" failure class.
|
||||
|
||||
Reference in New Issue
Block a user