mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
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:
21
README.md
21
README.md
@@ -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
|
||||
|
||||
|
||||
37
ROADMAP.md
37
ROADMAP.md
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wrystr",
|
||||
"private": true,
|
||||
"version": "0.1.10",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
76
src/components/notifications/NotificationsView.tsx
Normal file
76
src/components/notifications/NotificationsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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}
|
||||
|
||||
42
src/components/shared/HelpModal.tsx
Normal file
42
src/components/shared/HelpModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
42
src/hooks/useKeyboardShortcuts.ts
Normal file
42
src/hooks/useKeyboardShortcuts.ts
Normal 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]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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())));
|
||||
|
||||
85
src/stores/notifications.ts
Normal file
85
src/stores/notifications.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
@@ -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 })),
|
||||
}));
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user