mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 20:29:12 -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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user