Bump to v0.2.0 — Phase 2: Engagement & Reach

Four features shipped in this release:

- Feed reply context: replies show "↩ replying to @name" above the
  note content; clicking fetches and opens the parent thread

- NIP-65 outbox model: fetchUserRelayList + publishRelayList +
  fetchUserNotesNIP65 in client.ts; profile notes fetched via the
  author's write relays; "Publish relay list to Nostr" button in
  Settings (kind 10002)

- Notifications: new store (notifications.ts) + NotificationsView;
  🔔 sidebar nav item with unread badge; DM nav item also shows
  unread conversation count; badges clear on open/select

- Keyboard shortcuts: useKeyboardShortcuts hook + HelpModal;
  n=compose, /=search, j/k=feed nav with ring highlight,
  Esc=back, ?=help overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jure
2026-03-11 20:39:30 +01:00
parent 181233796b
commit 3a196cb9a0
22 changed files with 479 additions and 63 deletions

View File

@@ -31,6 +31,7 @@ Grab the latest release from the [Releases page](https://github.com/hoornet/wrys
- Global and following feeds with live relay connection
- Compose notes, inline replies, full thread view
- **Image paste in compose** — paste an image from clipboard → auto-uploads and inserts the URL
- **Feed reply context** — replies show "↩ replying to @name"; click to jump to the parent thread
- Reactions (NIP-25) with live network counts
- Follow / unfollow (NIP-02) with contact list publishing
- **Quote & Repost** (NIP-18) — one-click repost or quote with compose modal
@@ -38,7 +39,12 @@ Grab the latest release from the [Releases page](https://github.com/hoornet/wrys
- 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
- Note rendering: images, video, mentions, hashtags, njump.me link interception
- **Direct Messages** (NIP-04) — conversation list, thread view, per-message decryption
- **Direct Messages** (NIP-04) — conversation list, thread view, per-message decryption; unread badge in sidebar
- **Notifications** — mentions view (🔔 in sidebar) with unread badge; clears on open
**Relay & network**
- Relay management: add/remove relays with live connection status
- **NIP-65 outbox model** — reads user relay lists (kind 10002) so you see notes from people who publish to their own relays; publish your own relay list to Nostr from Settings
**Lightning & zaps**
- **Per-account NWC wallet** — each account remembers its own Lightning wallet; switching accounts loads the correct one automatically
@@ -53,8 +59,8 @@ Grab the latest release from the [Releases page](https://github.com/hoornet/wrys
- **SQLite note cache** — feed loads instantly from local cache on startup; profiles cached for immediate avatar display
- **System tray** — close button hides to tray; "Quit" in tray menu to fully exit
- Collapsible sidebar (icon-only mode)
- **Keyboard shortcuts** — `n` compose, `/` search, `j`/`k` navigate feed, `Esc` back, `?` help overlay
- Search: NIP-50 full-text, `#hashtag`, people search with inline follow
- Relay management: add/remove relays with live connection status
## Stack
@@ -82,11 +88,12 @@ npm run tauri build # production binary
See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps.
Up next (Phase 2):
- Notifications — mentions, replies, DM badge, OS native alerts
- NIP-65 outbox model — fetch notes from the right relay set per author
- Feed reply context — show "↩ replying to @name" for replies in the feed
- Keyboard shortcuts — N, R, /, J/K, Escape, ? help overlay
Up next (Phase 3):
- NIP-17 DMs (gift wrap) — proper sender/recipient privacy, replacing NIP-04
- Image lightbox — click to expand images full-size
- Bookmark list (NIP-51 kind 10003)
- Follow suggestions / discovery
- UI polish pass
## Support

View File

@@ -31,31 +31,15 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
---
## Phase 2 — Engagement & reach
*Test v0.1.7 on Windows first. Fix any issues before starting.*
## Phase 2 — Engagement & reach ✓ COMPLETE
### 5. Notifications
- No way to see mentions, replies to own notes, or incoming DMs without manually checking
- Badge on the messages nav item for unread DMs
- Notifications view: mentions of your pubkey, replies to your notes, new DMs
- System notification (OS native) for DMs and mentions — Tauri has a notification plugin
*Shipped in v0.1.11.*
### 6. NIP-65 outbox model (relay lists, kind 10002)
- Without NIP-65, we miss notes from people who publish to their own relay set
- On profile open: fetch their kind 10002 relay list, query those relays for their notes
- On publish: write to own relay list (configurable in settings)
- Dramatically improves note discovery and reach
### 7. Feed reply context
- In the feed, replies look identical to top-level posts — no visual distinction
- Show "↩ replying to @name" above the note content for kind-1 events with `e` tags
- Clicking the context navigates to the parent note thread
### 8. Keyboard shortcuts
- A writing-focused desktop app should be keyboard-navigable
- N — compose new note, R — reply to focused note, / — focus search
- J/K — navigate feed up/down, Escape — close modal/back
- Show shortcuts in a `?` help overlay
-**Feed reply context** — "↩ replying to @name" shown above reply notes; click to open parent thread
- **NIP-65 outbox model** — fetch user relay lists (kind 10002) for better note discovery; "Publish relay list" button in Settings; profile notes fetched via write relays
- **Notifications** — mentions view with unread badge; 🔔 nav item in sidebar; badge clears on view
- **DM unread badge** — messages nav item shows badge count; clears when conversation opened
- **Keyboard shortcuts** — n (compose), / (search), j/k (feed nav), Esc (back), ? (help modal)
---
@@ -116,6 +100,13 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
## What's already shipped
### v0.2.0 — Phase 2: Engagement & Reach
- **Feed reply context** — replies show "↩ replying to @name" above the note; click to open the parent thread
- **NIP-65 outbox model** — reads kind 10002 relay lists so you see notes from people who publish to their own relays; profile notes fetched via their write relays; "Publish relay list to Nostr" button in Settings
- **Notifications view** — 🔔 sidebar nav item; lists recent mentions with unread badge; badge clears on open
- **DM unread badge** — messages nav item shows count of conversations with new messages; clears when conversation is opened
- **Keyboard shortcuts** — `n` focus compose, `/` focus search, `j`/`k` navigate feed with ring highlight, `Esc` go back, `?` help overlay
### v0.1.10
- **Fix: Bitcoin QR to right edge** — Support page QR section uses `justify-between` so Lightning sits left, Bitcoin sits right

View File

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

View File

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

View File

@@ -12,8 +12,11 @@ import { OnboardingFlow } from "./components/onboarding/OnboardingFlow";
import { AboutView } from "./components/shared/AboutView";
import { ZapHistoryView } from "./components/zap/ZapHistoryView";
import { DMView } from "./components/dm/DMView";
import { NotificationsView } from "./components/notifications/NotificationsView";
import { HelpModal } from "./components/shared/HelpModal";
import { useUIStore } from "./stores/ui";
import { useUpdater } from "./hooks/useUpdater";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
function UpdateBanner() {
const { available, version, installing, error, install, dismiss } = useUpdater();
@@ -40,10 +43,14 @@ function UpdateBanner() {
function App() {
const currentView = useUIStore((s) => s.currentView);
const showHelp = useUIStore((s) => s.showHelp);
const toggleHelp = useUIStore((s) => s.toggleHelp);
const [onboardingDone, setOnboardingDone] = useState(
() => !!localStorage.getItem("wrystr_pubkey")
);
useKeyboardShortcuts();
if (!onboardingDone) {
return <OnboardingFlow onComplete={() => setOnboardingDone(true)} />;
}
@@ -65,8 +72,10 @@ function App() {
{currentView === "about" && <AboutView />}
{currentView === "zaps" && <ZapHistoryView />}
{currentView === "dm" && <DMView />}
{currentView === "notifications" && <NotificationsView />}
</main>
</div>
{showHelp && <HelpModal onClose={toggleHelp} />}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk";
import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { useNotificationsStore } from "../../stores/notifications";
import { fetchDMConversations, fetchDMThread, sendDM, decryptDM, getNDK } from "../../lib/nostr";
import { useProfile } from "../../hooks/useProfile";
import { timeAgo, shortenPubkey } from "../../lib/utils";
@@ -306,6 +307,12 @@ export function DMView() {
if (!selectedPubkey && !pendingDMPubkey && grouped.size > 0) {
setSelectedPubkey(Array.from(grouped.keys())[0]);
}
// Compute DM unread counts
const convList = Array.from(grouped.entries()).map(([partnerPubkey, msgs]) => ({
partnerPubkey,
lastAt: msgs[0]?.created_at ?? 0,
}));
useNotificationsStore.getState().computeDMUnread(convList);
})
.finally(() => setLoading(false));
}, [pubkey, hasSigner]);
@@ -354,7 +361,10 @@ export function DMView() {
partnerPubkey={partner}
lastEvent={events[0]}
selected={selectedPubkey === partner}
onSelect={() => setSelectedPubkey(partner)}
onSelect={() => {
setSelectedPubkey(partner);
useNotificationsStore.getState().markDMRead(partner);
}}
/>
))}
</div>

View File

@@ -93,6 +93,7 @@ export function ComposeBox({ onPublished }: { onPublished?: () => void }) {
<div className="flex-1 min-w-0">
<textarea
ref={textareaRef}
data-compose
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -10,7 +10,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
type FeedTab = "global" | "following";
export function Feed() {
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed } = useFeedStore();
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, focusedNoteIndex } = useFeedStore();
const { loggedIn, follows } = useUserStore();
const { mutedPubkeys } = useMuteStore();
@@ -124,8 +124,8 @@ export function Feed() {
</div>
)}
{filteredNotes.map((event) => (
<NoteCard key={event.id} event={event} />
{filteredNotes.map((event, index) => (
<NoteCard key={event.id} event={event} focused={focusedNoteIndex === index} />
))}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { useReactionCount } from "../../hooks/useReactionCount";
@@ -7,16 +7,31 @@ import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useUIStore } from "../../stores/ui";
import { timeAgo, shortenPubkey } from "../../lib/utils";
import { publishReaction, publishReply, publishRepost, getNDK } from "../../lib/nostr";
import { publishReaction, publishReply, publishRepost, getNDK, fetchNoteById } from "../../lib/nostr";
import { NoteContent } from "./NoteContent";
import { ZapModal } from "../zap/ZapModal";
import { QuoteModal } from "./QuoteModal";
interface NoteCardProps {
event: NDKEvent;
focused?: boolean;
}
export function NoteCard({ event }: NoteCardProps) {
function getParentEventId(event: NDKEvent): string | null {
const eTags = event.tags.filter((t) => t[0] === "e");
if (eTags.length === 0) return null;
return eTags.find((t) => t[3] === "reply")?.[1]
?? eTags.find((t) => t[3] === "root")?.[1]
?? eTags[eTags.length - 1][1];
}
function ParentAuthorName({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
const name = profile?.displayName || profile?.name || pubkey.slice(0, 8) + "…";
return <span className="text-accent">@{name}</span>;
}
export function NoteCard({ event, focused }: NoteCardProps) {
const profile = useProfile(event.pubkey);
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
const avatar = profile?.picture;
@@ -27,6 +42,14 @@ export function NoteCard({ event }: NoteCardProps) {
const { mutedPubkeys, mute, unmute } = useMuteStore();
const isMuted = mutedPubkeys.includes(event.pubkey);
const { openProfile, openThread, currentView } = useUIStore();
const parentEventId = getParentEventId(event);
const parentAuthorPubkey = event.tags.find((t) => t[0] === "p")?.[1] ?? null;
const cardRef = useRef<HTMLElement>(null);
useEffect(() => {
if (focused) cardRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [focused]);
const likedKey = "wrystr_liked";
const getLiked = () => {
try { return new Set<string>(JSON.parse(localStorage.getItem(likedKey) || "[]")); }
@@ -101,7 +124,10 @@ export function NoteCard({ event }: NoteCardProps) {
};
return (
<article className="border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors duration-100 group/card">
<article
ref={cardRef}
className={`border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors duration-100 group/card${focused ? " ring-1 ring-inset ring-accent/30" : ""}`}
>
<div className="flex gap-3">
{/* Avatar */}
<div className="shrink-0 cursor-pointer" onClick={() => openProfile(event.pubkey)}>
@@ -159,6 +185,20 @@ export function NoteCard({ event }: NoteCardProps) {
)}
</div>
{parentEventId && parentAuthorPubkey && (
<button
onClick={async (e) => {
e.stopPropagation();
const parent = await fetchNoteById(parentEventId);
if (parent) openThread(parent, "feed");
}}
className="text-text-dim text-[11px] mb-1.5 flex items-center gap-1 hover:text-accent transition-colors"
>
<span> replying to </span>
<ParentAuthorName pubkey={parentAuthorPubkey} />
</button>
)}
<div
className="cursor-pointer"
onClick={() => openThread(event, currentView as "feed" | "profile")}

View File

@@ -0,0 +1,76 @@
import { useEffect, useRef } from "react";
import { useUserStore } from "../../stores/user";
import { useNotificationsStore } from "../../stores/notifications";
import { NoteCard } from "../feed/NoteCard";
export function NotificationsView() {
const { pubkey, loggedIn } = useUserStore();
const {
notifications,
unreadCount,
lastSeenAt,
loading,
fetchNotifications,
markAllRead,
} = useNotificationsStore();
// Capture lastSeenAt at mount time so unread highlights persist during this view session
const prevLastSeenAtRef = useRef(lastSeenAt);
useEffect(() => {
if (!pubkey) return;
fetchNotifications(pubkey).then(() => {
setTimeout(() => markAllRead(), 500);
});
}, [pubkey]);
if (!loggedIn || !pubkey) {
return (
<div className="h-full flex items-center justify-center text-text-dim text-[12px]">
Log in to see notifications.
</div>
);
}
return (
<div className="h-full flex flex-col">
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
<h1 className="text-text text-sm font-medium tracking-wide">Notifications</h1>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-[11px] text-text-dim hover:text-accent transition-colors"
>
mark all read
</button>
)}
</header>
<div className="flex-1 overflow-y-auto">
{loading && notifications.length === 0 && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
Loading notifications
</div>
)}
{!loading && notifications.length === 0 && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
No mentions yet.
</div>
)}
{notifications.map((event) => {
const isUnread = (event.created_at ?? 0) > prevLastSeenAtRef.current;
return (
<div
key={event.id}
className={isUnread ? "border-l-2 border-accent/40" : ""}
>
<NoteCard event={event} />
</div>
);
})}
</div>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { useUIStore } from "../../stores/ui";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useProfile, invalidateProfileCache } from "../../hooks/useProfile";
import { fetchUserNotes, publishProfile, getNDK } from "../../lib/nostr";
import { fetchUserNotesNIP65, publishProfile, getNDK } from "../../lib/nostr";
import { shortenPubkey } from "../../lib/utils";
import { uploadImage } from "../../lib/upload";
import { NoteCard } from "../feed/NoteCard";
@@ -254,7 +254,7 @@ export function ProfileView() {
useEffect(() => {
setLoading(true);
fetchUserNotes(pubkey).then((events) => {
fetchUserNotesNIP65(pubkey).then((events) => {
setNotes(events);
setLoading(false);
}).catch(() => setLoading(false));

View File

@@ -153,6 +153,7 @@ export function SearchView() {
<div className="flex gap-2">
<input
ref={inputRef}
data-search-input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -0,0 +1,42 @@
const SHORTCUTS = [
{ key: "n", desc: "New note / focus compose" },
{ key: "/", desc: "Search" },
{ key: "j", desc: "Next note in feed" },
{ key: "k", desc: "Previous note in feed" },
{ key: "Esc", desc: "Go back" },
{ key: "?", desc: "This help" },
];
export function HelpModal({ onClose }: { onClose: () => void }) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="bg-bg border border-border shadow-xl p-6 w-72"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-text text-sm font-medium tracking-wide">Keyboard Shortcuts</h2>
<button
onClick={onClose}
className="text-text-dim hover:text-text transition-colors text-[14px]"
>
×
</button>
</div>
<div className="space-y-2">
{SHORTCUTS.map(({ key, desc }) => (
<div key={key} className="flex items-center gap-3">
<kbd className="bg-bg-raised border border-border text-text text-[11px] font-mono px-2 py-0.5 min-w-[2.5rem] text-center shrink-0">
{key}
</kbd>
<span className="text-text-dim text-[12px]">{desc}</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { getNDK, getStoredRelayUrls, addRelay, removeRelay } from "../../lib/nostr";
import { getNDK, getStoredRelayUrls, addRelay, removeRelay, publishRelayList } from "../../lib/nostr";
import { useProfile } from "../../hooks/useProfile";
import { NWCWizard } from "./NWCWizard";
@@ -61,9 +61,12 @@ function RelayRow({ url, onRemove }: { url: string; onRemove: () => void }) {
}
function RelaySection() {
const { loggedIn } = useUserStore();
const [relays, setRelays] = useState<string[]>(() => getStoredRelayUrls());
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [publishing, setPublishing] = useState(false);
const [publishedAt, setPublishedAt] = useState<number | null>(null);
const handleAdd = () => {
const url = input.trim();
@@ -92,6 +95,18 @@ function RelaySection() {
if (e.key === "Escape") setInput("");
};
const handlePublishRelayList = async () => {
setPublishing(true);
try {
await publishRelayList(getStoredRelayUrls());
setPublishedAt(Date.now());
} catch {
// ignore — publishing failure is non-critical
} finally {
setPublishing(false);
}
};
return (
<section>
<h2 className="text-text text-[11px] font-medium uppercase tracking-widest mb-2 text-text-dim">Relays</h2>
@@ -119,6 +134,20 @@ function RelaySection() {
</button>
</div>
{error && <p className="text-danger text-[11px] mt-1">{error}</p>}
{loggedIn && !!getNDK().signer && (
<div className="mt-3">
<button
onClick={handlePublishRelayList}
disabled={publishing}
className="text-[11px] px-3 py-1.5 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{publishing ? "publishing…" : publishedAt ? "published ✓" : "publish relay list to Nostr"}
</button>
<p className="text-text-dim text-[10px] mt-1">
Saves your relay list as a kind 10002 event (NIP-65) so other clients can find your notes.
</p>
</div>
)}
</section>
);
}

View File

@@ -1,6 +1,7 @@
import { useUIStore } from "../../stores/ui";
import { useFeedStore } from "../../stores/feed";
import { useUserStore } from "../../stores/user";
import { useNotificationsStore } from "../../stores/notifications";
import { getNDK } from "../../lib/nostr";
import { AccountSwitcher } from "./AccountSwitcher";
import pkg from "../../../package.json";
@@ -9,6 +10,7 @@ const NAV_ITEMS = [
{ id: "feed" as const, label: "feed", icon: "◈" },
{ id: "search" as const, label: "search", icon: "⌕" },
{ id: "dm" as const, label: "messages", icon: "✉" },
{ id: "notifications" as const, label: "notifications", icon: "🔔" },
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
{ id: "relays" as const, label: "relays", icon: "⟐" },
{ id: "settings" as const, label: "settings", icon: "⚙" },
@@ -19,6 +21,7 @@ export function Sidebar() {
const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore();
const { connected } = useFeedStore();
const { loggedIn } = useUserStore();
const { unreadCount: notifUnread, dmUnreadCount } = useNotificationsStore();
const c = sidebarCollapsed;
@@ -75,21 +78,32 @@ export function Sidebar() {
</button>
)}
{NAV_ITEMS.map((item) => (
<button
key={item.id}
onClick={() => setView(item.id)}
title={c ? item.label : undefined}
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 text-[12px] transition-colors ${
currentView === item.id
? "text-accent bg-accent/8"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
<span className="w-4 text-center text-[14px]">{item.icon}</span>
{!c && <span>{item.label}</span>}
</button>
))}
{NAV_ITEMS.map((item) => {
const badge = item.id === "dm" ? dmUnreadCount : item.id === "notifications" ? notifUnread : 0;
return (
<button
key={item.id}
onClick={() => setView(item.id)}
title={c ? item.label : undefined}
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 text-[12px] transition-colors ${
currentView === item.id
? "text-accent bg-accent/8"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
<span className="relative w-4 text-center text-[14px]">
{item.icon}
{badge > 0 && c && (
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
)}
</span>
{!c && <span>{item.label}</span>}
{!c && badge > 0 && (
<span className="ml-auto text-[10px] bg-accent/20 text-accent px-1 rounded-sm">{badge}</span>
)}
</button>
);
})}
</nav>
{/* Account switcher (full) — expanded only */}

View File

@@ -0,0 +1,42 @@
import { useEffect } from "react";
import { useUIStore } from "../stores/ui";
import { useFeedStore } from "../stores/feed";
export function useKeyboardShortcuts() {
const { currentView, setView, goBack, toggleHelp } = useUIStore();
const { focusedNoteIndex, setFocusedNoteIndex, notes } = useFeedStore();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
switch (e.key) {
case "n":
setView("feed");
setTimeout(() => (document.querySelector("[data-compose]") as HTMLTextAreaElement)?.focus(), 50);
break;
case "/":
e.preventDefault();
setView("search");
setTimeout(() => (document.querySelector("[data-search-input]") as HTMLInputElement)?.focus(), 50);
break;
case "Escape":
goBack();
break;
case "?":
toggleHelp();
break;
case "j":
if (currentView === "feed")
setFocusedNoteIndex(Math.min(focusedNoteIndex + 1, notes.length - 1));
break;
case "k":
if (currentView === "feed")
setFocusedNoteIndex(Math.max(focusedNoteIndex - 1, 0));
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [currentView, focusedNoteIndex, notes.length]);
}

View File

@@ -1,4 +1,4 @@
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKSubscriptionCacheUsage, nip19 } from "@nostr-dev-kit/ndk";
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKRelaySet, NDKSubscriptionCacheUsage, nip19 } from "@nostr-dev-kit/ndk";
const RELAY_STORAGE_KEY = "wrystr_relays";
@@ -469,3 +469,59 @@ export async function fetchProfile(pubkey: string) {
await user.fetchProfile();
return user.profile;
}
// ── NIP-65 Relay Lists ────────────────────────────────────────────────────────
export interface UserRelayList { read: string[]; write: string[]; }
export async function fetchUserRelayList(pubkey: string): Promise<UserRelayList> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [10002 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY });
if (events.size === 0) return { read: [], write: [] };
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
const read: string[] = [], write: string[] = [];
for (const tag of event.tags) {
if (tag[0] !== "r" || !tag[1]) continue;
const marker = tag[2];
if (!marker || marker === "read") read.push(tag[1]);
if (!marker || marker === "write") write.push(tag[1]);
}
return { read, write };
}
export async function publishRelayList(relayUrls: string[]): Promise<void> {
const instance = getNDK();
if (!instance.signer) throw new Error("Not logged in");
const event = new NDKEvent(instance);
event.kind = 10002 as NDKKind;
event.content = "";
event.tags = relayUrls.map((url) => ["r", url]);
await event.publish();
}
export async function fetchUserNotesNIP65(pubkey: string, limit = 30): Promise<NDKEvent[]> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Text], authors: [pubkey], limit };
try {
const relayList = await fetchUserRelayList(pubkey);
if (relayList.write.length > 0) {
const merged = Array.from(new Set([...relayList.write, ...getStoredRelayUrls()]));
const relaySet = NDKRelaySet.fromRelayUrls(merged, instance);
const events = await instance.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }, relaySet);
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
}
} catch { /* fallthrough */ }
return fetchUserNotes(pubkey, limit);
}
// ── Notifications (mentions) ──────────────────────────────────────────────────
export async function fetchMentions(pubkey: string, since: number, limit = 50): Promise<NDKEvent[]> {
const instance = getNDK();
const events = await instance.fetchEvents(
{ kinds: [NDKKind.Text], "#p": [pubkey], since, limit },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
);
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
}

View File

@@ -1 +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 } 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, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchMentions } from "./client";
export type { UserRelayList } from "./client";

View File

@@ -8,9 +8,11 @@ interface FeedState {
loading: boolean;
connected: boolean;
error: string | null;
focusedNoteIndex: number;
connect: () => Promise<void>;
loadCachedFeed: () => Promise<void>;
loadFeed: () => Promise<void>;
setFocusedNoteIndex: (n: number) => void;
}
export const useFeedStore = create<FeedState>((set, get) => ({
@@ -18,6 +20,8 @@ export const useFeedStore = create<FeedState>((set, get) => ({
loading: false,
connected: false,
error: null,
focusedNoteIndex: -1,
setFocusedNoteIndex: (n: number) => set({ focusedNoteIndex: n }),
connect: async () => {
try {
@@ -55,7 +59,7 @@ export const useFeedStore = create<FeedState>((set, get) => ({
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
.slice(0, 200);
set({ notes: merged, loading: false });
set({ notes: merged, loading: false, focusedNoteIndex: -1 });
// Persist fresh notes to SQLite (fire-and-forget)
dbSaveNotes(fresh.map((e) => JSON.stringify(e.rawEvent())));

View File

@@ -0,0 +1,85 @@
import { create } from "zustand";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchMentions } from "../lib/nostr";
const NOTIF_SEEN_KEY = "wrystr_notif_last_seen";
const DM_SEEN_KEY = "wrystr_dm_last_seen";
interface NotificationsState {
notifications: NDKEvent[];
unreadCount: number;
lastSeenAt: number;
loading: boolean;
currentPubkey: string | null;
dmLastSeen: Record<string, number>;
dmUnreadCount: number;
fetchNotifications: (pubkey: string) => Promise<void>;
markAllRead: () => void;
markDMRead: (partnerPubkey: string) => void;
computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => void;
}
function loadLastSeen(): number {
const stored = parseInt(localStorage.getItem(NOTIF_SEEN_KEY) ?? "0");
return stored || Math.floor(Date.now() / 1000) - 86400;
}
function loadDMLastSeen(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(DM_SEEN_KEY) ?? "{}");
} catch {
return {};
}
}
export const useNotificationsStore = create<NotificationsState>((set, get) => ({
notifications: [],
unreadCount: 0,
lastSeenAt: loadLastSeen(),
loading: false,
currentPubkey: null,
dmLastSeen: loadDMLastSeen(),
dmUnreadCount: 0,
fetchNotifications: async (pubkey: string) => {
const state = get();
const isNewAccount = pubkey !== state.currentPubkey;
if (isNewAccount) {
set({ notifications: [], currentPubkey: pubkey });
}
set({ loading: true });
try {
const lastSeenAt = isNewAccount ? loadLastSeen() : get().lastSeenAt;
const events = await fetchMentions(pubkey, lastSeenAt);
const unreadCount = events.filter((e) => (e.created_at ?? 0) > lastSeenAt).length;
set({ notifications: events, unreadCount, lastSeenAt });
} catch {
// Non-critical
} finally {
set({ loading: false });
}
},
markAllRead: () => {
const now = Math.floor(Date.now() / 1000);
localStorage.setItem(NOTIF_SEEN_KEY, String(now));
set({ lastSeenAt: now, unreadCount: 0 });
},
markDMRead: (partnerPubkey: string) => {
const now = Math.floor(Date.now() / 1000);
const dmLastSeen = { ...get().dmLastSeen, [partnerPubkey]: now };
localStorage.setItem(DM_SEEN_KEY, JSON.stringify(dmLastSeen));
set({ dmLastSeen });
// dmUnreadCount will be recomputed by computeDMUnread on next DM view render
},
computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => {
const { dmLastSeen } = get();
const dmUnreadCount = conversations.filter(
(c) => c.lastAt > (dmLastSeen[c.partnerPubkey] ?? 0)
).length;
set({ dmUnreadCount });
},
}));

View File

@@ -2,7 +2,7 @@ import { create } from "zustand";
import { NDKEvent } from "@nostr-dev-kit/ndk";
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "about" | "zaps" | "dm";
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "about" | "zaps" | "dm" | "notifications";
interface UIState {
currentView: View;
@@ -13,6 +13,7 @@ interface UIState {
pendingSearch: string | null;
pendingDMPubkey: string | null;
pendingArticleNaddr: string | null;
showHelp: boolean;
setView: (view: View) => void;
openProfile: (pubkey: string) => void;
openThread: (note: NDKEvent, from: View) => void;
@@ -21,6 +22,7 @@ interface UIState {
openArticle: (naddr: string) => void;
goBack: () => void;
toggleSidebar: () => void;
toggleHelp: () => void;
}
const SIDEBAR_KEY = "wrystr_sidebar_collapsed";
@@ -34,6 +36,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
pendingSearch: null,
pendingDMPubkey: null,
pendingArticleNaddr: null,
showHelp: false,
setView: (currentView) => set({ currentView }),
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
@@ -41,6 +44,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
openDM: (pubkey) => set({ currentView: "dm", pendingDMPubkey: pubkey }),
openArticle: (naddr) => set((s) => ({ currentView: "article", pendingArticleNaddr: naddr, previousView: s.currentView as View })),
goBack: () => set((s) => ({
showHelp: false,
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
selectedNote: null,
})),
@@ -49,4 +53,5 @@ export const useUIStore = create<UIState>((set, _get) => ({
localStorage.setItem(SIDEBAR_KEY, String(next));
return { sidebarCollapsed: next };
}),
toggleHelp: () => set((s) => ({ showHelp: !s.showHelp })),
}));

View File

@@ -6,6 +6,7 @@ import { invoke } from "@tauri-apps/api/core";
import { useMuteStore } from "./mute";
import { useLightningStore } from "./lightning";
import { useUIStore } from "./ui";
import { useNotificationsStore } from "./notifications";
export interface SavedAccount {
pubkey: string;
@@ -112,10 +113,11 @@ export const useUserStore = create<UserState>((set, get) => ({
// Load per-account NWC wallet
useLightningStore.getState().loadNwcForAccount(pubkey);
// Fetch profile, follows, and mute list
// Fetch profile, follows, mute list, and notifications
get().fetchOwnProfile();
get().fetchFollows();
useMuteStore.getState().fetchMuteList(pubkey);
useNotificationsStore.getState().fetchNotifications(pubkey);
} catch (err) {
set({ loginError: `Login failed: ${err}` });
}
@@ -154,6 +156,7 @@ export const useUserStore = create<UserState>((set, get) => ({
get().fetchOwnProfile();
get().fetchFollows();
useMuteStore.getState().fetchMuteList(pubkey);
useNotificationsStore.getState().fetchNotifications(pubkey);
} catch (err) {
set({ loginError: `Login failed: ${err}` });
}