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:
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user