mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
Bump to v0.4.0 — Phase 3: image lightbox, bookmarks, discover, language filter, UI polish
This commit is contained in:
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -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`
|
||||||
|
|||||||
15
CLAUDE.md
15
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/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
|
|
||||||
|
|||||||
2
PKGBUILD
2
PKGBUILD
@@ -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')
|
||||||
|
|||||||
20
README.md
20
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` |
|
| 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
|
||||||
|
|
||||||
|
|||||||
56
ROADMAP.md
56
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
|
## 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
|
||||||
|
|||||||
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
72
src/components/bookmark/BookmarkView.tsx
Normal file
72
src/components/bookmark/BookmarkView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
{isFollowing && follows.length === 0
|
<p className="text-text-dim text-[13px]">
|
||||||
? "You're not following anyone yet."
|
{isFollowing && follows.length === 0
|
||||||
: "No notes yet."}
|
? "You're not following anyone yet."
|
||||||
|
: feedLanguageFilter
|
||||||
|
? `No ${feedLanguageFilter} notes found.`
|
||||||
|
: "No notes to show."}
|
||||||
|
</p>
|
||||||
|
<p className="text-text-dim text-[11px] opacity-60">
|
||||||
|
{isFollowing && follows.length === 0
|
||||||
|
? "Use search to find people to follow."
|
||||||
|
: feedLanguageFilter
|
||||||
|
? "Try clearing the script filter or refreshing."
|
||||||
|
: "Try refreshing or switching tabs."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
75
src/components/shared/ImageLightbox.tsx
Normal file
75
src/components/shared/ImageLightbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/shared/Skeleton.tsx
Normal file
46
src/components/shared/Skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: "⚡" },
|
||||||
|
|||||||
@@ -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
71
src/lib/language.ts
Normal 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;
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
61
src/stores/bookmark.ts
Normal 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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user