From c8d2b05440ce008fb85d4079fc09e2fdae8f96a4 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:56:50 +0100 Subject: [PATCH] =?UTF-8?q?Bump=20to=20v0.4.0=20=E2=80=94=20Phase=203:=20i?= =?UTF-8?q?mage=20lightbox,=20bookmarks,=20discover,=20language=20filter,?= =?UTF-8?q?=20UI=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 7 + CLAUDE.md | 15 ++- PKGBUILD | 2 +- README.md | 20 ++- ROADMAP.md | 56 ++++---- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 2 + src/components/bookmark/BookmarkView.tsx | 72 +++++++++++ src/components/feed/Feed.tsx | 60 +++++++-- src/components/feed/NoteCard.tsx | 11 ++ src/components/feed/NoteContent.tsx | 14 +- .../notifications/NotificationsView.tsx | 10 +- src/components/search/SearchView.tsx | 122 +++++++++++++++++- src/components/shared/ImageLightbox.tsx | 75 +++++++++++ src/components/shared/Skeleton.tsx | 46 +++++++ src/components/sidebar/Sidebar.tsx | 1 + src/index.css | 9 ++ src/lib/language.ts | 71 ++++++++++ src/lib/nostr/client.ts | 61 +++++++++ src/lib/nostr/index.ts | 2 +- src/stores/bookmark.ts | 61 +++++++++ src/stores/ui.ts | 6 +- 25 files changed, 670 insertions(+), 61 deletions(-) create mode 100644 src/components/bookmark/BookmarkView.tsx create mode 100644 src/components/shared/ImageLightbox.tsx create mode 100644 src/components/shared/Skeleton.tsx create mode 100644 src/lib/language.ts create mode 100644 src/stores/bookmark.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f3c5be..c9a394d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,13 @@ 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.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)** — save/unsave notes with one click; dedicated Bookmarks view in sidebar; synced to relays (kind 10003) + - **Discover people** — "follows of follows" suggestions on the Search page with mutual follow counts and one-click follow + - **Language/script feed filter** — dropdown in feed header filters by writing system (Latin, CJK, Cyrillic, Arabic, Korean, etc.); uses Unicode detection + NIP-32 language tags + - **UI polish** — skeleton loading placeholders, improved empty states with helpful prompts, subtle view fade transitions + ### New in v0.3.1 - **Feed tab persists across navigation** — back button now returns to the correct tab (Global/Following) instead of always resetting to Global - **Available on AUR** — Arch/Manjaro users can install with `yay -S wrystr-git` diff --git a/CLAUDE.md b/CLAUDE.md index b804849..2e8f453 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - `src/components/thread/` — ThreadView - `src/components/search/` — SearchView (NIP-50, hashtag, people) - `src/components/article/` — ArticleEditor (NIP-23) +- `src/components/bookmark/` — BookmarkView - `src/components/zap/` — ZapModal - `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login) - `src/components/shared/` — RelaysView, SettingsView (relay mgmt + NWC + identity) @@ -95,6 +96,16 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy - Relay connection status view +- 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 + **Not yet implemented:** - OS keychain integration (Rust) — nsec lives in NDK memory only - SQLite local note cache @@ -103,7 +114,3 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - Zap counts on notes - NIP-65 outbox model - NIP-17 DMs (gift wrap) -- Image lightbox -- Bookmark list (NIP-51 kind 10003) -- Follow suggestions / discovery -- Language/script feed filter diff --git a/PKGBUILD b/PKGBUILD index 719c1de..1f15144 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet pkgname=wrystr -pkgver=0.3.1 +pkgver=0.4.0 pkgrel=1 pkgdesc="Cross-platform Nostr desktop client with Lightning integration" arch=('x86_64') diff --git a/README.md b/README.md index b998d39..716cefe 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Grab the latest release from the [Releases page](https://github.com/hoornet/wrys | Ubuntu / Debian / Mint | `.deb` | `sudo dpkg -i wrystr_*.deb` | | Fedora | `.rpm` | `sudo rpm -i wrystr-*.rpm` | | openSUSE | `.rpm` | `sudo zypper install wrystr-*.rpm` | -| Arch / Manjaro | build from source | see [`PKGBUILD`](./PKGBUILD) | +| Arch / Manjaro | AUR | `yay -S wrystr-git` | | Windows | `.exe` installer | run the installer | | macOS (Apple Silicon) | `aarch64.dmg` | open and drag to Applications | @@ -30,12 +30,15 @@ Grab the latest release from the [Releases page](https://github.com/hoornet/wrys **Feed & content** - Global and following feeds with live relay connection +- **Language/script feed filter** — filter by writing system (Latin, CJK, Cyrillic, Arabic, Korean, etc.) via dropdown in feed header; uses Unicode detection + NIP-32 language tags - Compose notes, inline replies, full thread view - **Image paste in compose** — paste an image from clipboard → auto-uploads and inserts the URL +- **Image lightbox** — click any image to view full-screen; Escape to close, arrow keys to navigate multi-image posts - **Feed reply context** — replies show "↩ replying to @name"; click to jump to the parent thread - Reactions (NIP-25) with live network counts - Follow / unfollow (NIP-02) with contact list publishing - **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 - **Quoted note inline preview** — `nostr:note1…` / `nostr:nevent1…` renders as an inline card @@ -55,13 +58,17 @@ Grab the latest release from the [Releases page](https://github.com/hoornet/wrys - **Zap history** — Received and Sent tabs with amounts, counterparts, comments - **Support / About page** — zap the developer, Lightning + Bitcoin QR codes, Ko-fi and GitHub links +**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 + **Performance & UX** - **Auto-updater** — "Update & restart" banner when a new version is available - **SQLite note cache** — feed loads instantly from local cache on startup; profiles cached for immediate avatar display - **System tray** — close button hides to tray; "Quit" in tray menu to fully exit - Collapsible sidebar (icon-only mode) - **Keyboard shortcuts** — `n` compose, `/` search, `j`/`k` navigate feed, `Esc` back, `?` help overlay -- Search: NIP-50 full-text, `#hashtag`, people search with inline follow +- Skeleton loading placeholders, view fade transitions ## Stack @@ -89,12 +96,11 @@ npm run tauri build # production binary See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps. -Up next (Phase 3): +Up next: - NIP-17 DMs (gift wrap) — proper sender/recipient privacy, replacing NIP-04 -- Image lightbox — click to expand images full-size -- Bookmark list (NIP-51 kind 10003) -- Follow suggestions / discovery -- UI polish pass +- Web of Trust scoring +- Long-form content discovery (trending articles, reading history) +- NIP-46 remote signer support ## Support diff --git a/ROADMAP.md b/ROADMAP.md index b1b31df..c7ea6ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,40 +43,22 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be --- -## Phase 3 — Polish & completeness -*Test Phase 2 thoroughly first. Fix all reported issues before starting Phase 3.* +## Phase 3 — Polish & completeness ✓ MOSTLY COMPLETE -### 9. NIP-17 DMs (gift wrap) +*Shipped in v0.4.0. NIP-17 DMs deferred to Phase 4.* + +- ✓ **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 +- ✓ **Follow suggestions / discovery** — "follows of follows" algorithm on Search page; shows mutual follow counts with one-click follow +- ✓ **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 -### 10. Image lightbox -- Clicking an image in a note should open it full-size -- Click outside or Escape to close - -### 11. Bookmark list (NIP-51, kind 10003) -- Standard feature expected by users — save notes for later -- Bookmark icon on NoteCard, synced to relays via NIP-51 - -### 12. Follow suggestions / discovery -- New users start with an empty Following feed and no guidance -- Suggest popular accounts and curated starter packs -- "People followed by people you follow" as a discovery surface - -### 14. Language / alphabet feed filter -- Feature request from Windows playtest (2026-03-11): filter feed to specific languages or scripts -- Could be client-side: detect script via Unicode block ranges, offer toggle in settings -- Or server-side: NIP-50 `language` tag support if relays implement it -- Low effort client-side version: filter by Unicode script (Latin, Cyrillic, CJK, Arabic, etc.) - -### 13. UI polish pass -- Full design review: note cards, thread view, profile header, modals -- Target bar: Telegram Desktop — fast, keyboard-navigable, feels native not webby -- Typography, spacing, colour contrast audit -- Needs a dedicated design session before implementation - --- ## Brainstorm backlog (not yet scheduled) @@ -105,6 +87,24 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be ## What's already shipped +### 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 +- **Discover people** — "follows of follows" suggestions on Search page with mutual follow counts and one-click follow +- **Language/script feed filter** — dropdown in feed header filters by writing system (Latin, CJK, Cyrillic, Arabic, Korean, Hebrew, Greek, Thai, Devanagari); Unicode script detection + NIP-32 language tag support +- **UI polish** — skeleton loading placeholders instead of "Loading..." text; improved empty states with helpful prompts; subtle view fade transitions on navigation + +### v0.3.1 +- **Feed tab persists across navigation** — back button now returns to the correct tab (Global/Following) instead of always resetting to Global +- **Available on AUR** — Arch/Manjaro users can install with `yay -S wrystr-git` + +### v0.3.0 +- **Instant feedback** — posted notes appear in feed immediately; thread replies show up without waiting for relay +- **Image paste fix** — uploads now use Tauri HTTP plugin, fixing "Failed to fetch" on Windows +- **Sent zaps visible** — zap history now correctly shows sent zaps +- **Reply-to @name clickable** — clicking the @name in "↩ replying to @name" now opens that person's profile +- **Feed refresh on login** — switching or adding an account immediately loads the new account's feed + ### v0.2.1 — Batch 3 playtest fixes - **Fix: repost + quote in thread view** — root note in thread view now shows repost and quote buttons (parity with feed cards) - **Fix: login persistence after Windows update** — nsec accounts with a lost keychain entry now stay logged out (login button visible) instead of silently going read-only diff --git a/package.json b/package.json index 4d4df98..71252eb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wrystr", "private": true, - "version": "0.3.1", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0c2f99e..9fa6f4a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "wrystr" -version = "0.2.8" +version = "0.4.0" dependencies = [ "keyring", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f84a20f..49b5f0b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wrystr" -version = "0.3.1" +version = "0.4.0" description = "Cross-platform Nostr desktop client with Lightning integration" authors = ["hoornet"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fce5f3c..c1148f6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Wrystr", - "version": "0.3.1", + "version": "0.4.0", "identifier": "com.hoornet.wrystr", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index f010f5d..e78c71c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { AboutView } from "./components/shared/AboutView"; import { ZapHistoryView } from "./components/zap/ZapHistoryView"; import { DMView } from "./components/dm/DMView"; import { NotificationsView } from "./components/notifications/NotificationsView"; +import { BookmarkView } from "./components/bookmark/BookmarkView"; import { HelpModal } from "./components/shared/HelpModal"; import { useUIStore } from "./stores/ui"; import { useUpdater } from "./hooks/useUpdater"; @@ -73,6 +74,7 @@ function App() { {currentView === "zaps" && } {currentView === "dm" && } {currentView === "notifications" && } + {currentView === "bookmarks" && } {showHelp && } diff --git a/src/components/bookmark/BookmarkView.tsx b/src/components/bookmark/BookmarkView.tsx new file mode 100644 index 0000000..018d28a --- /dev/null +++ b/src/components/bookmark/BookmarkView.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import { useBookmarkStore } from "../../stores/bookmark"; +import { useUserStore } from "../../stores/user"; +import { fetchNoteById } from "../../lib/nostr"; +import { NoteCard } from "../feed/NoteCard"; +import { SkeletonNoteList } from "../shared/Skeleton"; + +export function BookmarkView() { + const { bookmarkedIds, fetchBookmarks } = useBookmarkStore(); + const { pubkey } = useUserStore(); + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (pubkey) fetchBookmarks(pubkey); + }, [pubkey]); + + useEffect(() => { + if (bookmarkedIds.length === 0) { + setNotes([]); + return; + } + loadNotes(); + }, [bookmarkedIds]); + + const loadNotes = async () => { + setLoading(true); + try { + const results = await Promise.all( + bookmarkedIds.map((id) => fetchNoteById(id)) + ); + setNotes( + results + .filter((e): e is NDKEvent => e !== null) + .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) + ); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Bookmarks

+ {bookmarkedIds.length} saved +
+
+ +
+ {loading && notes.length === 0 && ( + + )} + + {!loading && notes.length === 0 && ( +
+

No bookmarks yet.

+

+ Use the save button on any note to bookmark it here. +

+
+ )} + + {notes.map((event) => ( + + ))} +
+
+ ); +} diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx index 03d3222..00bf10b 100644 --- a/src/components/feed/Feed.tsx +++ b/src/components/feed/Feed.tsx @@ -4,15 +4,17 @@ import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; import { useUIStore } from "../../stores/ui"; import { fetchFollowFeed, getNDK } from "../../lib/nostr"; +import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language"; import { NoteCard } from "./NoteCard"; import { ComposeBox } from "./ComposeBox"; +import { SkeletonNoteList } from "../shared/Skeleton"; import { NDKEvent } from "@nostr-dev-kit/ndk"; export function Feed() { const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, focusedNoteIndex } = useFeedStore(); const { loggedIn, follows } = useUserStore(); const { mutedPubkeys } = useMuteStore(); - const { feedTab: tab, setFeedTab: setTab } = useUIStore(); + const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore(); const [followNotes, setFollowNotes] = useState([]); const [followLoading, setFollowLoading] = useState(false); @@ -49,6 +51,29 @@ export function Feed() { // Filter out notes that look like base64 blobs or relay protocol messages if (c.length > 500 && /^[A-Za-z0-9+/=]{50,}$/.test(c.slice(0, 100))) return false; if (c.startsWith("nlogpost:") || c.startsWith("T1772")) return false; + // Language/script filter + if (feedLanguageFilter) { + const langTag = getEventLanguageTag(event.tags); + if (langTag) { + // Map ISO-639-1 codes to script names for comparison + const langToScript: Record = { + en: "Latin", es: "Latin", fr: "Latin", de: "Latin", pt: "Latin", it: "Latin", nl: "Latin", pl: "Latin", sv: "Latin", da: "Latin", no: "Latin", fi: "Latin", ro: "Latin", tr: "Latin", cs: "Latin", hr: "Latin", hu: "Latin", + zh: "CJK", ja: "CJK", + ko: "Korean", + ru: "Cyrillic", uk: "Cyrillic", bg: "Cyrillic", sr: "Cyrillic", + ar: "Arabic", fa: "Arabic", ur: "Arabic", + hi: "Devanagari", mr: "Devanagari", ne: "Devanagari", + th: "Thai", + he: "Hebrew", + el: "Greek", + }; + const script = langToScript[langTag]; + if (script && script !== feedLanguageFilter) return false; + } else { + const script = detectScript(c); + if (script !== feedLanguageFilter) return false; + } + } return true; }); @@ -81,6 +106,16 @@ export function Feed() { )}
+ {connected && ( @@ -109,16 +144,25 @@ export function Feed() { )} {isLoading && filteredNotes.length === 0 && ( -
- {isFollowing ? "Loading notes from people you follow…" : "Connecting to relays…"} -
+ )} {!isLoading && filteredNotes.length === 0 && ( -
- {isFollowing && follows.length === 0 - ? "You're not following anyone yet." - : "No notes yet."} +
+

+ {isFollowing && follows.length === 0 + ? "You're not following anyone yet." + : feedLanguageFilter + ? `No ${feedLanguageFilter} notes found.` + : "No notes to show."} +

+

+ {isFollowing && follows.length === 0 + ? "Use search to find people to follow." + : feedLanguageFilter + ? "Try clearing the script filter or refreshing." + : "Try refreshing or switching tabs."} +

)} diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index 3680729..d0cc3cc 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -5,6 +5,7 @@ import { useReactionCount } from "../../hooks/useReactionCount"; import { useZapCount } from "../../hooks/useZapCount"; import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; +import { useBookmarkStore } from "../../stores/bookmark"; import { useUIStore } from "../../stores/ui"; import { timeAgo, shortenPubkey } from "../../lib/utils"; import { publishReaction, publishReply, publishRepost, getNDK, fetchNoteById } from "../../lib/nostr"; @@ -41,6 +42,8 @@ export function NoteCard({ event, focused }: NoteCardProps) { const { loggedIn, pubkey: ownPubkey } = useUserStore(); const { mutedPubkeys, mute, unmute } = useMuteStore(); const isMuted = mutedPubkeys.includes(event.pubkey); + const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore(); + const isBookmarked = bookmarkedIds.includes(event.id!); const { openProfile, openThread, currentView } = useUIStore(); const parentEventId = getParentEventId(event); @@ -261,6 +264,14 @@ export function NoteCard({ event, focused }: NoteCardProps) { : "⚡ zap"} )} +
)} diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx index 1f8dca1..b7753ff 100644 --- a/src/components/feed/NoteContent.tsx +++ b/src/components/feed/NoteContent.tsx @@ -4,6 +4,7 @@ import { useUIStore } from "../../stores/ui"; import { fetchNoteById } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { shortenPubkey } from "../../lib/utils"; +import { ImageLightbox } from "../shared/ImageLightbox"; // Regex patterns const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g; @@ -209,6 +210,7 @@ export function NoteContent({ content }: { content: string }) { const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value); const videos: string[] = segments.filter((s) => s.type === "video").map((s) => s.value); const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value); + const [lightboxIndex, setLightboxIndex] = useState(null); const inlineElements: ReactNode[] = []; @@ -284,7 +286,8 @@ export function NoteContent({ content }: { content: string }) { src={src} alt="" loading="lazy" - className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border" + className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in" + onClick={(e) => { e.stopPropagation(); setLightboxIndex(i); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} @@ -293,6 +296,15 @@ export function NoteContent({ content }: { content: string }) {
)} + {lightboxIndex !== null && ( + setLightboxIndex(null)} + onNavigate={setLightboxIndex} + /> + )} + {/* Quoted notes */} {quoteIds.map((id) => ( diff --git a/src/components/notifications/NotificationsView.tsx b/src/components/notifications/NotificationsView.tsx index 0bc4d65..5527c46 100644 --- a/src/components/notifications/NotificationsView.tsx +++ b/src/components/notifications/NotificationsView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { useUserStore } from "../../stores/user"; import { useNotificationsStore } from "../../stores/notifications"; import { NoteCard } from "../feed/NoteCard"; +import { SkeletonNoteList } from "../shared/Skeleton"; export function NotificationsView() { const { pubkey, loggedIn } = useUserStore(); @@ -48,14 +49,13 @@ export function NotificationsView() {
{loading && notifications.length === 0 && ( -
- Loading notifications… -
+ )} {!loading && notifications.length === 0 && ( -
- No mentions yet. +
+

No mentions yet.

+

When someone mentions you, it will appear here.

)} diff --git a/src/components/search/SearchView.tsx b/src/components/search/SearchView.tsx index 6b3e3a3..6d340c8 100644 --- a/src/components/search/SearchView.tsx +++ b/src/components/search/SearchView.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; -import { searchNotes, searchUsers, getStoredRelayUrls } from "../../lib/nostr"; +import { searchNotes, searchUsers, getStoredRelayUrls, fetchFollowSuggestions, fetchProfile } from "../../lib/nostr"; import { getNip50Relays } from "../../lib/nostr/relayInfo"; import { useUserStore } from "../../stores/user"; import { useUIStore } from "../../stores/ui"; @@ -81,6 +81,44 @@ function UserRow({ user }: { user: ParsedUser }) { ); } +interface Suggestion { + pubkey: string; + mutualCount: number; + profile: ParsedUser | null; +} + +function SuggestionFollowButton({ pubkey }: { pubkey: string }) { + const { loggedIn, follows, follow, unfollow } = useUserStore(); + const isFollowing = follows.includes(pubkey); + const [pending, setPending] = useState(false); + + if (!loggedIn) return null; + + const handleClick = async () => { + setPending(true); + try { + if (isFollowing) await unfollow(pubkey); + else await follow(pubkey); + } finally { + setPending(false); + } + }; + + return ( + + ); +} + export function SearchView() { const { pendingSearch } = useUIStore(); const [query, setQuery] = useState(pendingSearch ?? ""); @@ -91,6 +129,9 @@ export function SearchView() { const [activeTab, setActiveTab] = useState<"notes" | "people">("notes"); const [nip50Relays, setNip50Relays] = useState(null); // null = not checked yet const inputRef = useRef(null); + const [suggestions, setSuggestions] = useState([]); + const [suggestionsLoading, setSuggestionsLoading] = useState(false); + const [suggestionsLoaded, setSuggestionsLoaded] = useState(false); const isHashtag = query.trim().startsWith("#"); @@ -100,6 +141,40 @@ export function SearchView() { getNip50Relays(urls).then(setNip50Relays); }, []); + const { loggedIn, follows } = useUserStore(); + + // Load follow suggestions on mount (only for logged-in users with follows) + useEffect(() => { + if (!loggedIn || follows.length === 0 || suggestionsLoaded) return; + setSuggestionsLoading(true); + fetchFollowSuggestions(follows).then(async (results) => { + // Load profiles for top suggestions + const withProfiles: Suggestion[] = await Promise.all( + results.slice(0, 20).map(async (s) => { + try { + const p = await fetchProfile(s.pubkey); + return { + ...s, + profile: p ? { + pubkey: s.pubkey, + name: (p as Record).name || "", + displayName: (p as Record).display_name || (p as Record).name || "", + picture: (p as Record).picture || "", + nip05: (p as Record).nip05 || "", + about: (p as Record).about || "", + } : null, + }; + } catch { + return { ...s, profile: null }; + } + }) + ); + setSuggestions(withProfiles.filter((s) => s.profile !== null)); + setSuggestionsLoading(false); + setSuggestionsLoaded(true); + }).catch(() => setSuggestionsLoading(false)); + }, [loggedIn, follows.length]); + // Run pending search from hashtag/mention click useEffect(() => { if (pendingSearch) { @@ -212,6 +287,51 @@ export function SearchView() {
)} + {/* Discover — follow suggestions */} + {!searched && !loading && loggedIn && ( +
+
+

Discover people

+

Based on who your follows follow

+
+ {suggestionsLoading && ( +
Finding suggestions...
+ )} + {suggestions.map((s) => s.profile && ( +
+
useUIStore.getState().openProfile(s.pubkey)}> + {s.profile.picture ? ( + { (e.target as HTMLImageElement).style.display = "none"; }} /> + ) : ( +
+ {(s.profile.displayName || s.profile.name || "?").charAt(0).toUpperCase()} +
+ )} +
+
useUIStore.getState().openProfile(s.pubkey)}> +
+ {s.profile.displayName || s.profile.name || shortenPubkey(s.pubkey)} +
+
+ {s.mutualCount} mutual follow{s.mutualCount !== 1 ? "s" : ""} + {s.profile.nip05 && {s.profile.nip05}} +
+ {s.profile.about && ( +
{s.profile.about}
+ )} +
+ +
+ ))} + {suggestionsLoaded && suggestions.length === 0 && ( +
+ Follow more people to see suggestions here. +
+ )} +
+ )} + {/* Zero results for full-text search */} {searched && totalResults === 0 && !isHashtag && (
diff --git a/src/components/shared/ImageLightbox.tsx b/src/components/shared/ImageLightbox.tsx new file mode 100644 index 0000000..9c7589d --- /dev/null +++ b/src/components/shared/ImageLightbox.tsx @@ -0,0 +1,75 @@ +import { useEffect, useCallback } from "react"; + +interface ImageLightboxProps { + images: string[]; + index: number; + onClose: () => void; + onNavigate: (index: number) => void; +} + +export function ImageLightbox({ images, index, onClose, onNavigate }: ImageLightboxProps) { + const hasPrev = index > 0; + const hasNext = index < images.length - 1; + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "ArrowLeft" && hasPrev) onNavigate(index - 1); + if (e.key === "ArrowRight" && hasNext) onNavigate(index + 1); + }, [onClose, onNavigate, index, hasPrev, hasNext]); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + return ( +
+ {/* Close button */} + + + {/* Counter */} + {images.length > 1 && ( +
+ {index + 1} / {images.length} +
+ )} + + {/* Prev arrow */} + {hasPrev && ( + + )} + + {/* Image */} + e.stopPropagation()} + draggable={false} + /> + + {/* Next arrow */} + {hasNext && ( + + )} +
+ ); +} diff --git a/src/components/shared/Skeleton.tsx b/src/components/shared/Skeleton.tsx new file mode 100644 index 0000000..f630628 --- /dev/null +++ b/src/components/shared/Skeleton.tsx @@ -0,0 +1,46 @@ +export function SkeletonNote() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export function SkeletonNoteList({ count = 5 }: { count?: number }) { + return ( + <> + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +export function SkeletonProfile() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 2e9d81c..aa7711a 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -9,6 +9,7 @@ import pkg from "../../../package.json"; const NAV_ITEMS = [ { id: "feed" as const, label: "feed", icon: "◈" }, { id: "search" as const, label: "search", icon: "⌕" }, + { id: "bookmarks" as const, label: "bookmarks", icon: "▪" }, { id: "dm" as const, label: "messages", icon: "✉" }, { id: "notifications" as const, label: "notifications", icon: "🔔" }, { id: "zaps" as const, label: "zaps", icon: "⚡" }, diff --git a/src/index.css b/src/index.css index e5539f7..b02bfb4 100644 --- a/src/index.css +++ b/src/index.css @@ -84,6 +84,15 @@ body { .prose-article strong { color: var(--color-text); font-weight: 600; } .prose-article img { max-width: 100%; border-radius: 2px; margin: 1em 0; } +/* View transition fade-in */ +@keyframes fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.view-fade-in { + animation: fade-in 150ms ease-out; +} + /* Scrollbar — thin, minimal */ ::-webkit-scrollbar { width: 6px; diff --git a/src/lib/language.ts b/src/lib/language.ts new file mode 100644 index 0000000..52e0b1f --- /dev/null +++ b/src/lib/language.ts @@ -0,0 +1,71 @@ +// Unicode script detection for feed filtering + +const SCRIPT_RANGES: [string, RegExp][] = [ + ["Latin", /[\u0041-\u024F\u1E00-\u1EFF]/], + ["CJK", /[\u2E80-\u2FFF\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFFEF]|[\uD840-\uD87F][\uDC00-\uDFFF]/], + ["Cyrillic", /[\u0400-\u04FF\u0500-\u052F]/], + ["Arabic", /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/], + ["Devanagari", /[\u0900-\u097F]/], + ["Thai", /[\u0E00-\u0E7F]/], + ["Korean", /[\uAC00-\uD7AF\u1100-\u11FF]/], + ["Hebrew", /[\u0590-\u05FF]/], + ["Greek", /[\u0370-\u03FF]/], + ["Georgian", /[\u10A0-\u10FF]/], + ["Armenian", /[\u0530-\u058F]/], +]; + +export function detectScript(text: string): string { + // Strip URLs, mentions, hashtags to avoid noise + const cleaned = text + .replace(/https?:\/\/\S+/g, "") + .replace(/nostr:\S+/g, "") + .replace(/#\w+/g, "") + .trim(); + + if (!cleaned) return "Unknown"; + + // Count characters per script + const counts = new Map(); + for (const char of cleaned) { + for (const [name, regex] of SCRIPT_RANGES) { + if (regex.test(char)) { + counts.set(name, (counts.get(name) ?? 0) + 1); + break; + } + } + } + + if (counts.size === 0) return "Unknown"; + + // Return dominant script + let maxScript = "Unknown"; + let maxCount = 0; + for (const [script, count] of counts) { + if (count > maxCount) { + maxScript = script; + maxCount = count; + } + } + + return maxScript; +} + +// Check NIP-32 language tags on an event +export function getEventLanguageTag(tags: string[][]): string | null { + const langTag = tags.find( + (t) => t[0] === "l" && t[2] === "ISO-639-1" + ); + return langTag?.[1] ?? null; +} + +export const FILTER_SCRIPTS = [ + "Latin", + "CJK", + "Cyrillic", + "Arabic", + "Devanagari", + "Thai", + "Korean", + "Hebrew", + "Greek", +] as const; diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts index 345656a..8c94d80 100644 --- a/src/lib/nostr/client.ts +++ b/src/lib/nostr/client.ts @@ -445,6 +445,29 @@ export async function fetchZapsSent(pubkey: string, limit = 50): Promise (b.created_at ?? 0) - (a.created_at ?? 0)); } +// ── Bookmarks (NIP-51 kind 10003) ──────────────────────────────────────────── + +export async function fetchBookmarkList(pubkey: string): Promise { + const instance = getNDK(); + const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + if (events.size === 0) return []; + const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]; + return event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]); +} + +export async function publishBookmarkList(eventIds: string[]): Promise { + const instance = getNDK(); + if (!instance.signer) return; + const event = new NDKEvent(instance); + event.kind = 10003 as NDKKind; + event.content = ""; + event.tags = eventIds.map((id) => ["e", id]); + await event.publish(); +} + export async function fetchMuteList(pubkey: string): Promise { const instance = getNDK(); const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 }; @@ -520,6 +543,44 @@ export async function fetchUserNotesNIP65(pubkey: string, limit = 30): Promise { + if (myFollows.length === 0) return []; + const instance = getNDK(); + // Fetch contact lists (kind 3) from our follows + const batchSize = 20; + const allContactEvents: NDKEvent[] = []; + for (let i = 0; i < myFollows.length; i += batchSize) { + const batch = myFollows.slice(i, i + batchSize); + const filter: NDKFilter = { kinds: [3 as NDKKind], authors: batch, limit: batch.length }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + allContactEvents.push(...Array.from(events)); + } + + // Count how many of our follows follow each pubkey + const myFollowSet = new Set(myFollows); + const counts = new Map(); + for (const event of allContactEvents) { + const pubkeys = event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]); + for (const pk of pubkeys) { + if (myFollowSet.has(pk)) continue; // already following + counts.set(pk, (counts.get(pk) ?? 0) + 1); + } + } + + // Remove self + const myPubkey = (await instance.signer?.user())?.pubkey; + if (myPubkey) counts.delete(myPubkey); + + return Array.from(counts.entries()) + .map(([pubkey, mutualCount]) => ({ pubkey, mutualCount })) + .sort((a, b) => b.mutualCount - a.mutualCount) + .slice(0, 30); +} + export async function fetchMentions(pubkey: string, since: number, limit = 50): Promise { const instance = getNDK(); const events = await instance.fetchEvents( diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index 2eebc11..96f6909 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -1,2 +1,2 @@ -export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchMentions } from "./client"; +export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, 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 type { UserRelayList } from "./client"; diff --git a/src/stores/bookmark.ts b/src/stores/bookmark.ts new file mode 100644 index 0000000..67aa203 --- /dev/null +++ b/src/stores/bookmark.ts @@ -0,0 +1,61 @@ +import { create } from "zustand"; +import { fetchBookmarkList, publishBookmarkList } from "../lib/nostr"; + +const STORAGE_KEY = "wrystr_bookmarks"; + +function loadLocal(): string[] { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + } catch { + return []; + } +} + +function saveLocal(ids: string[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); +} + +interface BookmarkState { + bookmarkedIds: string[]; + fetchBookmarks: (pubkey: string) => Promise; + addBookmark: (eventId: string) => Promise; + removeBookmark: (eventId: string) => Promise; + isBookmarked: (eventId: string) => boolean; +} + +export const useBookmarkStore = create((set, get) => ({ + bookmarkedIds: loadLocal(), + + fetchBookmarks: async (pubkey: string) => { + try { + const ids = await fetchBookmarkList(pubkey); + if (ids.length === 0) return; + const local = get().bookmarkedIds; + const merged = Array.from(new Set([...ids, ...local])); + set({ bookmarkedIds: merged }); + saveLocal(merged); + } catch { + // Non-critical — local bookmarks still work + } + }, + + addBookmark: async (eventId: string) => { + const { bookmarkedIds } = get(); + if (bookmarkedIds.includes(eventId)) return; + const updated = [...bookmarkedIds, eventId]; + set({ bookmarkedIds: updated }); + saveLocal(updated); + publishBookmarkList(updated).catch(() => {}); + }, + + removeBookmark: async (eventId: string) => { + const updated = get().bookmarkedIds.filter((id) => id !== eventId); + set({ bookmarkedIds: updated }); + saveLocal(updated); + publishBookmarkList(updated).catch(() => {}); + }, + + isBookmarked: (eventId: string) => { + return get().bookmarkedIds.includes(eventId); + }, +})); diff --git a/src/stores/ui.ts b/src/stores/ui.ts index f4a68fc..4e5862a 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -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" | "about" | "zaps" | "dm" | "notifications"; +type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "about" | "zaps" | "dm" | "notifications" | "bookmarks"; type FeedTab = "global" | "following"; interface UIState { @@ -16,6 +16,7 @@ interface UIState { pendingDMPubkey: string | null; pendingArticleNaddr: string | null; showHelp: boolean; + feedLanguageFilter: string | null; setView: (view: View) => void; setFeedTab: (tab: FeedTab) => void; openProfile: (pubkey: string) => void; @@ -24,6 +25,7 @@ interface UIState { openDM: (pubkey: string) => void; openArticle: (naddr: string) => void; goBack: () => void; + setFeedLanguageFilter: (filter: string | null) => void; toggleSidebar: () => void; toggleHelp: () => void; } @@ -41,6 +43,7 @@ export const useUIStore = create((set, _get) => ({ pendingDMPubkey: null, pendingArticleNaddr: null, showHelp: false, + feedLanguageFilter: null, setView: (currentView) => set({ currentView }), setFeedTab: (feedTab) => set({ feedTab }), openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })), @@ -53,6 +56,7 @@ export const useUIStore = create((set, _get) => ({ currentView: s.previousView !== s.currentView ? s.previousView : "feed", selectedNote: null, })), + setFeedLanguageFilter: (feedLanguageFilter) => set({ feedLanguageFilter }), toggleSidebar: () => set((s) => { const next = !s.sidebarCollapsed; localStorage.setItem(SIDEBAR_KEY, String(next));