mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
Add thread view with replies
- Click note content to open thread view - ThreadView shows root note, reply composer, and replies - fetchReplies added to nostr lib (kind 1 #e filter) - UI store gains openThread, goBack, previousView for navigation history - Profile back button now returns to previous view correctly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Feed } from "./components/feed/Feed";
|
||||
import { RelaysView } from "./components/shared/RelaysView";
|
||||
import { SettingsView } from "./components/shared/SettingsView";
|
||||
import { ProfileView } from "./components/profile/ProfileView";
|
||||
import { ThreadView } from "./components/thread/ThreadView";
|
||||
import { useUIStore } from "./stores/ui";
|
||||
|
||||
function App() {
|
||||
@@ -16,6 +17,7 @@ function App() {
|
||||
{currentView === "relays" && <RelaysView />}
|
||||
{currentView === "settings" && <SettingsView />}
|
||||
{currentView === "profile" && <ProfileView />}
|
||||
{currentView === "thread" && <ThreadView />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function NoteCard({ event }: NoteCardProps) {
|
||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
||||
|
||||
const { loggedIn } = useUserStore();
|
||||
const { openProfile } = useUIStore();
|
||||
const { openProfile, openThread, currentView } = useUIStore();
|
||||
const likedKey = "wrystr_liked";
|
||||
const getLiked = () => {
|
||||
try { return new Set<string>(JSON.parse(localStorage.getItem(likedKey) || "[]")); }
|
||||
@@ -109,7 +109,12 @@ export function NoteCard({ event }: NoteCardProps) {
|
||||
<span className="text-text-dim text-[11px] shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<NoteContent content={event.content} />
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => openThread(event, currentView as "feed" | "profile")}
|
||||
>
|
||||
<NoteContent content={event.content} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{loggedIn && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { shortenPubkey } from "../../lib/utils";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
|
||||
export function ProfileView() {
|
||||
const { selectedPubkey, setView } = useUIStore();
|
||||
const { selectedPubkey, goBack } = useUIStore();
|
||||
const pubkey = selectedPubkey!;
|
||||
const profile = useProfile(pubkey);
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ProfileView() {
|
||||
{/* Header */}
|
||||
<header className="border-b border-border px-4 py-2.5 flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => setView("feed")}
|
||||
onClick={goBack}
|
||||
className="text-text-dim hover:text-text text-[11px] transition-colors"
|
||||
>
|
||||
← back
|
||||
|
||||
146
src/components/thread/ThreadView.tsx
Normal file
146
src/components/thread/ThreadView.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { fetchReplies, publishReply } from "../../lib/nostr";
|
||||
import { shortenPubkey, timeAgo } from "../../lib/utils";
|
||||
import { NoteContent } from "../feed/NoteContent";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
|
||||
function RootNote({ event }: { event: NDKEvent }) {
|
||||
const { openProfile } = useUIStore();
|
||||
const profile = useProfile(event.pubkey);
|
||||
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
|
||||
const avatar = profile?.picture;
|
||||
const nip05 = profile?.nip05;
|
||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 border-b border-border">
|
||||
<div className="flex gap-3 mb-3">
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => openProfile(event.pubkey)}>
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="" className="w-10 h-10 rounded-sm object-cover bg-bg-raised hover:opacity-80 transition-opacity" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-sm">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="text-text font-medium text-[13px] cursor-pointer hover:text-accent transition-colors"
|
||||
onClick={() => openProfile(event.pubkey)}
|
||||
>{name}</span>
|
||||
{nip05 && <div className="text-text-dim text-[10px]">{nip05}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<NoteContent content={event.content} />
|
||||
<div className="text-text-dim text-[10px] mt-3">{time}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadView() {
|
||||
const { selectedNote, goBack } = useUIStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
const event = selectedNote!;
|
||||
|
||||
const [replies, setReplies] = useState<NDKEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [replying, setReplying] = useState(false);
|
||||
const [replySent, setReplySent] = useState(false);
|
||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplies(event.id).then((r) => {
|
||||
setReplies(r);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [event.id]);
|
||||
|
||||
const handleReply = async () => {
|
||||
if (!replyText.trim() || replying) return;
|
||||
setReplying(true);
|
||||
try {
|
||||
await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey });
|
||||
setReplyText("");
|
||||
setReplySent(true);
|
||||
// Re-fetch replies to show the new one
|
||||
const updated = await fetchReplies(event.id);
|
||||
setReplies(updated);
|
||||
setTimeout(() => setReplySent(false), 2000);
|
||||
} finally {
|
||||
setReplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply();
|
||||
if (e.key === "Escape") replyRef.current?.blur();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border px-4 py-2.5 flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="text-text-dim hover:text-text text-[11px] transition-colors"
|
||||
>
|
||||
← back
|
||||
</button>
|
||||
<h1 className="text-text text-sm font-medium">Thread</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Root note */}
|
||||
<RootNote event={event} />
|
||||
|
||||
{/* Reply composer */}
|
||||
{loggedIn && (
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<textarea
|
||||
ref={replyRef}
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Write a reply…"
|
||||
rows={2}
|
||||
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 mt-1">
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
|
||||
<button
|
||||
onClick={handleReply}
|
||||
disabled={!replyText.trim() || replying}
|
||||
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{replySent ? "replied ✓" : replying ? "posting…" : "reply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replies */}
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-text-dim text-[12px] text-center">
|
||||
Loading replies…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && replies.length === 0 && (
|
||||
<div className="px-4 py-6 text-text-dim text-[12px] text-center">
|
||||
No replies yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replies.map((reply) => (
|
||||
<NoteCard key={reply.id} event={reply} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,18 @@ export async function publishNote(content: string): Promise<void> {
|
||||
await event.publish();
|
||||
}
|
||||
|
||||
export async function fetchReplies(eventId: string): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text],
|
||||
"#e": [eventId],
|
||||
};
|
||||
const events = await instance.fetchEvents(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
return Array.from(events).sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
||||
}
|
||||
|
||||
export async function fetchFollowFeed(pubkeys: string[], limit = 80): Promise<NDKEvent[]> {
|
||||
if (pubkeys.length === 0) return [];
|
||||
const instance = getNDK();
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, publishNote, publishReaction, publishReply, fetchUserNotes, fetchProfile } from "./client";
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishReaction, publishReply, fetchUserNotes, fetchProfile } from "./client";
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type View = "feed" | "relays" | "settings" | "profile";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
type View = "feed" | "relays" | "settings" | "profile" | "thread";
|
||||
|
||||
interface UIState {
|
||||
currentView: View;
|
||||
sidebarCollapsed: boolean;
|
||||
selectedPubkey: string | null;
|
||||
selectedNote: NDKEvent | null;
|
||||
previousView: View;
|
||||
setView: (view: View) => void;
|
||||
openProfile: (pubkey: string) => void;
|
||||
openThread: (note: NDKEvent, from: View) => void;
|
||||
goBack: () => void;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
export const useUIStore = create<UIState>((set, get) => ({
|
||||
currentView: "feed",
|
||||
sidebarCollapsed: false,
|
||||
selectedPubkey: null,
|
||||
selectedNote: null,
|
||||
previousView: "feed",
|
||||
setView: (currentView) => set({ currentView }),
|
||||
openProfile: (pubkey) => set({ currentView: "profile", selectedPubkey: pubkey }),
|
||||
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
|
||||
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
|
||||
goBack: () => set((s) => ({ currentView: s.previousView, selectedNote: null })),
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user