Bump to v0.4.0 — Phase 3: image lightbox, bookmarks, discover, language filter, UI polish

This commit is contained in:
Jure
2026-03-14 17:56:50 +01:00
parent af8a364e28
commit c8d2b05440
25 changed files with 670 additions and 61 deletions

View File

@@ -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. > **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 ### 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 - **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` - **Available on AUR** — Arch/Manjaro users can install with `yay -S wrystr-git`

View File

@@ -54,6 +54,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
- `src/components/thread/` — ThreadView - `src/components/thread/` — ThreadView
- `src/components/search/` — SearchView (NIP-50, hashtag, people) - `src/components/search/` — SearchView (NIP-50, hashtag, people)
- `src/components/article/` — ArticleEditor (NIP-23) - `src/components/article/` — ArticleEditor (NIP-23)
- `src/components/bookmark/` — BookmarkView
- `src/components/zap/` — ZapModal - `src/components/zap/` — ZapModal
- `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login) - `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login)
- `src/components/shared/` — RelaysView, SettingsView (relay mgmt + NWC + identity) - `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 - Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy
- Relay connection status view - 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:** **Not yet implemented:**
- OS keychain integration (Rust) — nsec lives in NDK memory only - OS keychain integration (Rust) — nsec lives in NDK memory only
- SQLite local note cache - 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 - Zap counts on notes
- NIP-65 outbox model - NIP-65 outbox model
- NIP-17 DMs (gift wrap) - NIP-17 DMs (gift wrap)
- Image lightbox
- Bookmark list (NIP-51 kind 10003)
- Follow suggestions / discovery
- Language/script feed filter

View File

@@ -1,6 +1,6 @@
# Maintainer: hoornet <hoornet@users.noreply.github.com> # Maintainer: hoornet <hoornet@users.noreply.github.com>
pkgname=wrystr pkgname=wrystr
pkgver=0.3.1 pkgver=0.4.0
pkgrel=1 pkgrel=1
pkgdesc="Cross-platform Nostr desktop client with Lightning integration" pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
arch=('x86_64') arch=('x86_64')

View File

@@ -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` | | Ubuntu / Debian / Mint | `.deb` | `sudo dpkg -i wrystr_*.deb` |
| Fedora | `.rpm` | `sudo rpm -i wrystr-*.rpm` | | Fedora | `.rpm` | `sudo rpm -i wrystr-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install 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 | | Windows | `.exe` installer | run the installer |
| macOS (Apple Silicon) | `aarch64.dmg` | open and drag to Applications | | 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** **Feed & content**
- Global and following feeds with live relay connection - 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 - Compose notes, inline replies, full thread view
- **Image paste in compose** — paste an image from clipboard → auto-uploads and inserts the URL - **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 - **Feed reply context** — replies show "↩ replying to @name"; click to jump to the parent thread
- Reactions (NIP-25) with live network counts - Reactions (NIP-25) with live network counts
- Follow / unfollow (NIP-02) with contact list publishing - Follow / unfollow (NIP-02) with contact list publishing
- **Quote & Repost** (NIP-18) — one-click repost or quote with compose modal - **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 - **Mute users** (NIP-51) — muted list synced to relays, filtered from feed
- Long-form article editor + reader (NIP-23) — write with title, tags, cover image, auto-save; click any `nostr:naddr1…` link to open in the in-app reader - Long-form article 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 - **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 - **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 - **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** **Performance & UX**
- **Auto-updater** — "Update & restart" banner when a new version is available - **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 - **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 - **System tray** — close button hides to tray; "Quit" in tray menu to fully exit
- Collapsible sidebar (icon-only mode) - Collapsible sidebar (icon-only mode)
- **Keyboard shortcuts** — `n` compose, `/` search, `j`/`k` navigate feed, `Esc` back, `?` help overlay - **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 ## Stack
@@ -89,12 +96,11 @@ npm run tauri build # production binary
See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps. 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 - NIP-17 DMs (gift wrap) — proper sender/recipient privacy, replacing NIP-04
- Image lightbox — click to expand images full-size - Web of Trust scoring
- Bookmark list (NIP-51 kind 10003) - Long-form content discovery (trending articles, reading history)
- Follow suggestions / discovery - NIP-46 remote signer support
- UI polish pass
## Support ## Support

View File

@@ -43,40 +43,22 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
--- ---
## Phase 3 — Polish & completeness ## Phase 3 — Polish & completeness ✓ MOSTLY COMPLETE
*Test Phase 2 thoroughly first. Fix all reported issues before starting Phase 3.*
### 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 - 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 - NIP-17 wraps messages in gift wrap (kind 1059) for proper sender/recipient privacy
- Needs inbox relay support (kind 10050) and ephemeral key signing - Needs inbox relay support (kind 10050) and ephemeral key signing
- Not interoperable with NIP-04 — both should be supported during migration - 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) ## 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 ## 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 ### 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: 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 - **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

View File

@@ -1,7 +1,7 @@
{ {
"name": "wrystr", "name": "wrystr",
"private": true, "private": true,
"version": "0.3.1", "version": "0.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@@ -5824,7 +5824,7 @@ dependencies = [
[[package]] [[package]]
name = "wrystr" name = "wrystr"
version = "0.2.8" version = "0.4.0"
dependencies = [ dependencies = [
"keyring", "keyring",
"rusqlite", "rusqlite",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "wrystr" name = "wrystr"
version = "0.3.1" version = "0.4.0"
description = "Cross-platform Nostr desktop client with Lightning integration" description = "Cross-platform Nostr desktop client with Lightning integration"
authors = ["hoornet"] authors = ["hoornet"]
edition = "2021" edition = "2021"

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Wrystr", "productName": "Wrystr",
"version": "0.3.1", "version": "0.4.0",
"identifier": "com.hoornet.wrystr", "identifier": "com.hoornet.wrystr",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -13,6 +13,7 @@ import { AboutView } from "./components/shared/AboutView";
import { ZapHistoryView } from "./components/zap/ZapHistoryView"; import { ZapHistoryView } from "./components/zap/ZapHistoryView";
import { DMView } from "./components/dm/DMView"; import { DMView } from "./components/dm/DMView";
import { NotificationsView } from "./components/notifications/NotificationsView"; import { NotificationsView } from "./components/notifications/NotificationsView";
import { BookmarkView } from "./components/bookmark/BookmarkView";
import { HelpModal } from "./components/shared/HelpModal"; import { HelpModal } from "./components/shared/HelpModal";
import { useUIStore } from "./stores/ui"; import { useUIStore } from "./stores/ui";
import { useUpdater } from "./hooks/useUpdater"; import { useUpdater } from "./hooks/useUpdater";
@@ -73,6 +74,7 @@ function App() {
{currentView === "zaps" && <ZapHistoryView />} {currentView === "zaps" && <ZapHistoryView />}
{currentView === "dm" && <DMView />} {currentView === "dm" && <DMView />}
{currentView === "notifications" && <NotificationsView />} {currentView === "notifications" && <NotificationsView />}
{currentView === "bookmarks" && <BookmarkView />}
</main> </main>
</div> </div>
{showHelp && <HelpModal onClose={toggleHelp} />} {showHelp && <HelpModal onClose={toggleHelp} />}

View File

@@ -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<NDKEvent[]>([]);
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 (
<div className="h-full flex flex-col">
<header className="border-b border-border px-4 py-2.5 shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-text text-[13px] font-medium">Bookmarks</h2>
<span className="text-text-dim text-[11px]">{bookmarkedIds.length} saved</span>
</div>
</header>
<div className="flex-1 overflow-y-auto">
{loading && notes.length === 0 && (
<SkeletonNoteList count={3} />
)}
{!loading && notes.length === 0 && (
<div className="px-4 py-12 text-center space-y-2">
<p className="text-text-dim text-[13px]">No bookmarks yet.</p>
<p className="text-text-dim text-[11px] opacity-60">
Use the <span className="text-accent">save</span> button on any note to bookmark it here.
</p>
</div>
)}
{notes.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
</div>
);
}

View File

@@ -4,15 +4,17 @@ import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute"; import { useMuteStore } from "../../stores/mute";
import { useUIStore } from "../../stores/ui"; import { useUIStore } from "../../stores/ui";
import { fetchFollowFeed, getNDK } from "../../lib/nostr"; import { fetchFollowFeed, getNDK } from "../../lib/nostr";
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
import { NoteCard } from "./NoteCard"; import { NoteCard } from "./NoteCard";
import { ComposeBox } from "./ComposeBox"; import { ComposeBox } from "./ComposeBox";
import { SkeletonNoteList } from "../shared/Skeleton";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
export function Feed() { export function Feed() {
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, focusedNoteIndex } = useFeedStore(); const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, focusedNoteIndex } = useFeedStore();
const { loggedIn, follows } = useUserStore(); const { loggedIn, follows } = useUserStore();
const { mutedPubkeys } = useMuteStore(); const { mutedPubkeys } = useMuteStore();
const { feedTab: tab, setFeedTab: setTab } = useUIStore(); const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore();
const [followNotes, setFollowNotes] = useState<NDKEvent[]>([]); const [followNotes, setFollowNotes] = useState<NDKEvent[]>([]);
const [followLoading, setFollowLoading] = useState(false); const [followLoading, setFollowLoading] = useState(false);
@@ -49,6 +51,29 @@ export function Feed() {
// Filter out notes that look like base64 blobs or relay protocol messages // 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.length > 500 && /^[A-Za-z0-9+/=]{50,}$/.test(c.slice(0, 100))) return false;
if (c.startsWith("nlogpost:") || c.startsWith("T1772")) 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<string, string> = {
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; return true;
}); });
@@ -81,6 +106,16 @@ export function Feed() {
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<select
value={feedLanguageFilter ?? ""}
onChange={(e) => setFeedLanguageFilter(e.target.value || null)}
className="bg-transparent text-text-dim text-[11px] border border-border px-1.5 py-0.5 focus:outline-none hover:border-text-dim transition-colors cursor-pointer"
>
<option value="">all scripts</option>
{FILTER_SCRIPTS.map((s) => (
<option key={s} value={s}>{s.toLowerCase()}</option>
))}
</select>
{connected && ( {connected && (
<span className="text-success text-[11px] flex items-center gap-1"> <span className="text-success text-[11px] flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-success inline-block" /> <span className="w-1.5 h-1.5 rounded-full bg-success inline-block" />
@@ -109,16 +144,25 @@ export function Feed() {
)} )}
{isLoading && filteredNotes.length === 0 && ( {isLoading && filteredNotes.length === 0 && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center"> <SkeletonNoteList count={6} />
{isFollowing ? "Loading notes from people you follow…" : "Connecting to relays…"}
</div>
)} )}
{!isLoading && filteredNotes.length === 0 && ( {!isLoading && filteredNotes.length === 0 && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center"> <div className="px-4 py-12 text-center space-y-2">
<p className="text-text-dim text-[13px]">
{isFollowing && follows.length === 0 {isFollowing && follows.length === 0
? "You're not following anyone yet." ? "You're not following anyone yet."
: "No notes 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."}
</p>
</div> </div>
)} )}

View File

@@ -5,6 +5,7 @@ import { useReactionCount } from "../../hooks/useReactionCount";
import { useZapCount } from "../../hooks/useZapCount"; import { useZapCount } from "../../hooks/useZapCount";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute"; import { useMuteStore } from "../../stores/mute";
import { useBookmarkStore } from "../../stores/bookmark";
import { useUIStore } from "../../stores/ui"; import { useUIStore } from "../../stores/ui";
import { timeAgo, shortenPubkey } from "../../lib/utils"; import { timeAgo, shortenPubkey } from "../../lib/utils";
import { publishReaction, publishReply, publishRepost, getNDK, fetchNoteById } from "../../lib/nostr"; 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 { loggedIn, pubkey: ownPubkey } = useUserStore();
const { mutedPubkeys, mute, unmute } = useMuteStore(); const { mutedPubkeys, mute, unmute } = useMuteStore();
const isMuted = mutedPubkeys.includes(event.pubkey); const isMuted = mutedPubkeys.includes(event.pubkey);
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
const isBookmarked = bookmarkedIds.includes(event.id!);
const { openProfile, openThread, currentView } = useUIStore(); const { openProfile, openThread, currentView } = useUIStore();
const parentEventId = getParentEventId(event); const parentEventId = getParentEventId(event);
@@ -261,6 +264,14 @@ export function NoteCard({ event, focused }: NoteCardProps) {
: "⚡ zap"} : "⚡ zap"}
</button> </button>
)} )}
<button
onClick={() => isBookmarked ? removeBookmark(event.id!) : addBookmark(event.id!)}
className={`text-[11px] transition-colors ${
isBookmarked ? "text-accent" : "text-text-dim hover:text-accent"
}`}
>
{isBookmarked ? "▪ saved" : "▫ save"}
</button>
</div> </div>
)} )}

View File

@@ -4,6 +4,7 @@ import { useUIStore } from "../../stores/ui";
import { fetchNoteById } from "../../lib/nostr"; import { fetchNoteById } from "../../lib/nostr";
import { useProfile } from "../../hooks/useProfile"; import { useProfile } from "../../hooks/useProfile";
import { shortenPubkey } from "../../lib/utils"; import { shortenPubkey } from "../../lib/utils";
import { ImageLightbox } from "../shared/ImageLightbox";
// Regex patterns // Regex patterns
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g; 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 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 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 quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const inlineElements: ReactNode[] = []; const inlineElements: ReactNode[] = [];
@@ -284,7 +286,8 @@ export function NoteContent({ content }: { content: string }) {
src={src} src={src}
alt="" alt=""
loading="lazy" 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) => { onError={(e) => {
(e.target as HTMLImageElement).style.display = "none"; (e.target as HTMLImageElement).style.display = "none";
}} }}
@@ -293,6 +296,15 @@ export function NoteContent({ content }: { content: string }) {
</div> </div>
)} )}
{lightboxIndex !== null && (
<ImageLightbox
images={images}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
/>
)}
{/* Quoted notes */} {/* Quoted notes */}
{quoteIds.map((id) => ( {quoteIds.map((id) => (
<QuotePreview key={id} eventId={id} /> <QuotePreview key={id} eventId={id} />

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { useNotificationsStore } from "../../stores/notifications"; import { useNotificationsStore } from "../../stores/notifications";
import { NoteCard } from "../feed/NoteCard"; import { NoteCard } from "../feed/NoteCard";
import { SkeletonNoteList } from "../shared/Skeleton";
export function NotificationsView() { export function NotificationsView() {
const { pubkey, loggedIn } = useUserStore(); const { pubkey, loggedIn } = useUserStore();
@@ -48,14 +49,13 @@ export function NotificationsView() {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{loading && notifications.length === 0 && ( {loading && notifications.length === 0 && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center"> <SkeletonNoteList count={4} />
Loading notifications
</div>
)} )}
{!loading && notifications.length === 0 && ( {!loading && notifications.length === 0 && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center"> <div className="px-4 py-12 text-center space-y-2">
No mentions yet. <p className="text-text-dim text-[13px]">No mentions yet.</p>
<p className="text-text-dim text-[11px] opacity-60">When someone mentions you, it will appear here.</p>
</div> </div>
)} )}

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk"; 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 { getNip50Relays } from "../../lib/nostr/relayInfo";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui"; 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 (
<button
onClick={handleClick}
disabled={pending}
className={`text-[11px] px-3 py-1 border transition-colors shrink-0 disabled:opacity-40 disabled:cursor-not-allowed ${
isFollowing
? "border-border text-text-muted hover:text-danger hover:border-danger/40"
: "border-accent/60 text-accent hover:bg-accent hover:text-white"
}`}
>
{pending ? "..." : isFollowing ? "unfollow" : "follow"}
</button>
);
}
export function SearchView() { export function SearchView() {
const { pendingSearch } = useUIStore(); const { pendingSearch } = useUIStore();
const [query, setQuery] = useState(pendingSearch ?? ""); const [query, setQuery] = useState(pendingSearch ?? "");
@@ -91,6 +129,9 @@ export function SearchView() {
const [activeTab, setActiveTab] = useState<"notes" | "people">("notes"); const [activeTab, setActiveTab] = useState<"notes" | "people">("notes");
const [nip50Relays, setNip50Relays] = useState<string[] | null>(null); // null = not checked yet const [nip50Relays, setNip50Relays] = useState<string[] | null>(null); // null = not checked yet
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
const [suggestionsLoaded, setSuggestionsLoaded] = useState(false);
const isHashtag = query.trim().startsWith("#"); const isHashtag = query.trim().startsWith("#");
@@ -100,6 +141,40 @@ export function SearchView() {
getNip50Relays(urls).then(setNip50Relays); 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<string, string>).name || "",
displayName: (p as Record<string, string>).display_name || (p as Record<string, string>).name || "",
picture: (p as Record<string, string>).picture || "",
nip05: (p as Record<string, string>).nip05 || "",
about: (p as Record<string, string>).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 // Run pending search from hashtag/mention click
useEffect(() => { useEffect(() => {
if (pendingSearch) { if (pendingSearch) {
@@ -212,6 +287,51 @@ export function SearchView() {
</div> </div>
)} )}
{/* Discover — follow suggestions */}
{!searched && !loading && loggedIn && (
<div className="border-t border-border">
<div className="px-4 py-2.5 border-b border-border">
<h3 className="text-text text-[12px] font-medium">Discover people</h3>
<p className="text-text-dim text-[10px] mt-0.5">Based on who your follows follow</p>
</div>
{suggestionsLoading && (
<div className="px-4 py-6 text-text-dim text-[11px] text-center">Finding suggestions...</div>
)}
{suggestions.map((s) => s.profile && (
<div key={s.pubkey} className="flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-bg-hover transition-colors">
<div className="shrink-0 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
{s.profile.picture ? (
<img src={s.profile.picture} alt="" className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
) : (
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
{(s.profile.displayName || s.profile.name || "?").charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="min-w-0 flex-1 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
<div className="text-text text-[13px] font-medium truncate">
{s.profile.displayName || s.profile.name || shortenPubkey(s.pubkey)}
</div>
<div className="text-text-dim text-[10px]">
{s.mutualCount} mutual follow{s.mutualCount !== 1 ? "s" : ""}
{s.profile.nip05 && <span className="ml-2">{s.profile.nip05}</span>}
</div>
{s.profile.about && (
<div className="text-text-dim text-[11px] truncate mt-0.5">{s.profile.about}</div>
)}
</div>
<SuggestionFollowButton pubkey={s.pubkey} />
</div>
))}
{suggestionsLoaded && suggestions.length === 0 && (
<div className="px-4 py-6 text-text-dim text-[11px] text-center">
Follow more people to see suggestions here.
</div>
)}
</div>
)}
{/* Zero results for full-text search */} {/* Zero results for full-text search */}
{searched && totalResults === 0 && !isHashtag && ( {searched && totalResults === 0 && !isHashtag && (
<div className="px-4 py-8 text-center space-y-3"> <div className="px-4 py-8 text-center space-y-3">

View File

@@ -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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/70 hover:text-white text-[20px] w-8 h-8 flex items-center justify-center transition-colors z-10"
>
</button>
{/* Counter */}
{images.length > 1 && (
<div className="absolute top-4 left-4 text-white/50 text-[12px] z-10">
{index + 1} / {images.length}
</div>
)}
{/* Prev arrow */}
{hasPrev && (
<button
onClick={(e) => { e.stopPropagation(); onNavigate(index - 1); }}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white text-[28px] w-10 h-10 flex items-center justify-center transition-colors z-10"
>
</button>
)}
{/* Image */}
<img
src={images[index]}
alt=""
className="max-w-[90vw] max-h-[90vh] object-contain select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
{/* Next arrow */}
{hasNext && (
<button
onClick={(e) => { e.stopPropagation(); onNavigate(index + 1); }}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white text-[28px] w-10 h-10 flex items-center justify-center transition-colors z-10"
>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
export function SkeletonNote() {
return (
<div className="px-4 py-3 border-b border-border animate-pulse">
<div className="flex gap-3">
<div className="w-9 h-9 rounded-sm bg-bg-raised shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="h-3 w-24 bg-bg-raised rounded-sm" />
<div className="h-3 w-12 bg-bg-raised rounded-sm" />
</div>
<div className="h-3 w-full bg-bg-raised rounded-sm" />
<div className="h-3 w-3/4 bg-bg-raised rounded-sm" />
</div>
</div>
</div>
);
}
export function SkeletonNoteList({ count = 5 }: { count?: number }) {
return (
<>
{Array.from({ length: count }, (_, i) => (
<SkeletonNote key={i} />
))}
</>
);
}
export function SkeletonProfile() {
return (
<div className="animate-pulse">
<div className="h-32 bg-bg-raised" />
<div className="px-4 py-3 space-y-3">
<div className="flex items-center gap-3">
<div className="w-16 h-16 rounded-sm bg-bg-raised border-2 border-bg -mt-10" />
<div className="space-y-2 flex-1">
<div className="h-4 w-32 bg-bg-raised rounded-sm" />
<div className="h-3 w-20 bg-bg-raised rounded-sm" />
</div>
</div>
<div className="h-3 w-full bg-bg-raised rounded-sm" />
<div className="h-3 w-2/3 bg-bg-raised rounded-sm" />
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import pkg from "../../../package.json";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: "feed" as const, label: "feed", icon: "◈" }, { id: "feed" as const, label: "feed", icon: "◈" },
{ id: "search" as const, label: "search", icon: "⌕" }, { id: "search" as const, label: "search", icon: "⌕" },
{ id: "bookmarks" as const, label: "bookmarks", icon: "▪" },
{ id: "dm" as const, label: "messages", icon: "✉" }, { id: "dm" as const, label: "messages", icon: "✉" },
{ id: "notifications" as const, label: "notifications", icon: "🔔" }, { id: "notifications" as const, label: "notifications", icon: "🔔" },
{ id: "zaps" as const, label: "zaps", icon: "⚡" }, { id: "zaps" as const, label: "zaps", icon: "⚡" },

View File

@@ -84,6 +84,15 @@ body {
.prose-article strong { color: var(--color-text); font-weight: 600; } .prose-article strong { color: var(--color-text); font-weight: 600; }
.prose-article img { max-width: 100%; border-radius: 2px; margin: 1em 0; } .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 */ /* Scrollbar — thin, minimal */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;

71
src/lib/language.ts Normal file
View File

@@ -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<string, number>();
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;

View File

@@ -445,6 +445,29 @@ export async function fetchZapsSent(pubkey: string, limit = 50): Promise<NDKEven
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
// ── Bookmarks (NIP-51 kind 10003) ────────────────────────────────────────────
export async function fetchBookmarkList(pubkey: string): Promise<string[]> {
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<void> {
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<string[]> { export async function fetchMuteList(pubkey: string): Promise<string[]> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 }; const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 };
@@ -520,6 +543,44 @@ export async function fetchUserNotesNIP65(pubkey: string, limit = 30): Promise<N
// ── Notifications (mentions) ────────────────────────────────────────────────── // ── Notifications (mentions) ──────────────────────────────────────────────────
// ── Follow Suggestions (follows-of-follows) ─────────────────────────────────
export async function fetchFollowSuggestions(myFollows: string[]): Promise<{ pubkey: string; mutualCount: number }[]> {
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<string, number>();
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<NDKEvent[]> { export async function fetchMentions(pubkey: string, since: number, limit = 50): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
const events = await instance.fetchEvents( const events = await instance.fetchEvents(

View File

@@ -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"; export type { UserRelayList } from "./client";

61
src/stores/bookmark.ts Normal file
View File

@@ -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<void>;
addBookmark: (eventId: string) => Promise<void>;
removeBookmark: (eventId: string) => Promise<void>;
isBookmarked: (eventId: string) => boolean;
}
export const useBookmarkStore = create<BookmarkState>((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);
},
}));

View File

@@ -2,7 +2,7 @@ import { create } from "zustand";
import { NDKEvent } from "@nostr-dev-kit/ndk"; 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"; type FeedTab = "global" | "following";
interface UIState { interface UIState {
@@ -16,6 +16,7 @@ interface UIState {
pendingDMPubkey: string | null; pendingDMPubkey: string | null;
pendingArticleNaddr: string | null; pendingArticleNaddr: string | null;
showHelp: boolean; showHelp: boolean;
feedLanguageFilter: string | null;
setView: (view: View) => void; setView: (view: View) => void;
setFeedTab: (tab: FeedTab) => void; setFeedTab: (tab: FeedTab) => void;
openProfile: (pubkey: string) => void; openProfile: (pubkey: string) => void;
@@ -24,6 +25,7 @@ interface UIState {
openDM: (pubkey: string) => void; openDM: (pubkey: string) => void;
openArticle: (naddr: string) => void; openArticle: (naddr: string) => void;
goBack: () => void; goBack: () => void;
setFeedLanguageFilter: (filter: string | null) => void;
toggleSidebar: () => void; toggleSidebar: () => void;
toggleHelp: () => void; toggleHelp: () => void;
} }
@@ -41,6 +43,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
pendingDMPubkey: null, pendingDMPubkey: null,
pendingArticleNaddr: null, pendingArticleNaddr: null,
showHelp: false, showHelp: false,
feedLanguageFilter: null,
setView: (currentView) => set({ currentView }), setView: (currentView) => set({ currentView }),
setFeedTab: (feedTab) => set({ feedTab }), setFeedTab: (feedTab) => set({ feedTab }),
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })), openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
@@ -53,6 +56,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
currentView: s.previousView !== s.currentView ? s.previousView : "feed", currentView: s.previousView !== s.currentView ? s.previousView : "feed",
selectedNote: null, selectedNote: null,
})), })),
setFeedLanguageFilter: (feedLanguageFilter) => set({ feedLanguageFilter }),
toggleSidebar: () => set((s) => { toggleSidebar: () => set((s) => {
const next = !s.sidebarCollapsed; const next = !s.sidebarCollapsed;
localStorage.setItem(SIDEBAR_KEY, String(next)); localStorage.setItem(SIDEBAR_KEY, String(next));