diff --git a/src/App.tsx b/src/App.tsx index f17cd10..dc4db69 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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" && } {currentView === "settings" && } {currentView === "profile" && } + {currentView === "thread" && } ); diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index 9872eb1..553bf34 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -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(JSON.parse(localStorage.getItem(likedKey) || "[]")); } @@ -109,7 +109,12 @@ export function NoteCard({ event }: NoteCardProps) { {time} - + openThread(event, currentView as "feed" | "profile")} + > + + {/* Actions */} {loggedIn && ( diff --git a/src/components/profile/ProfileView.tsx b/src/components/profile/ProfileView.tsx index e89d81d..a510a31 100644 --- a/src/components/profile/ProfileView.tsx +++ b/src/components/profile/ProfileView.tsx @@ -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 */} setView("feed")} + onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors" > ← back diff --git a/src/components/thread/ThreadView.tsx b/src/components/thread/ThreadView.tsx new file mode 100644 index 0000000..ec36b92 --- /dev/null +++ b/src/components/thread/ThreadView.tsx @@ -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 ( + + + openProfile(event.pubkey)}> + {avatar ? ( + + ) : ( + + {name.charAt(0).toUpperCase()} + + )} + + + openProfile(event.pubkey)} + >{name} + {nip05 && {nip05}} + + + + {time} + + ); +} + +export function ThreadView() { + const { selectedNote, goBack } = useUIStore(); + const { loggedIn } = useUserStore(); + const event = selectedNote!; + + const [replies, setReplies] = useState([]); + const [loading, setLoading] = useState(true); + const [replyText, setReplyText] = useState(""); + const [replying, setReplying] = useState(false); + const [replySent, setReplySent] = useState(false); + const replyRef = useRef(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 ( + + {/* Header */} + + + ← back + + Thread + + + + {/* Root note */} + + + {/* Reply composer */} + {loggedIn && ( + + 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" + /> + + Ctrl+Enter + + {replySent ? "replied ✓" : replying ? "posting…" : "reply"} + + + + )} + + {/* Replies */} + {loading && ( + + Loading replies… + + )} + + {!loading && replies.length === 0 && ( + + No replies yet. + + )} + + {replies.map((reply) => ( + + ))} + + + ); +} diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts index e78cd7b..96ae64d 100644 --- a/src/lib/nostr/client.ts +++ b/src/lib/nostr/client.ts @@ -99,6 +99,18 @@ export async function publishNote(content: string): Promise { await event.publish(); } +export async function fetchReplies(eventId: string): Promise { + 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 { if (pubkeys.length === 0) return []; const instance = getNDK(); diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index 0991dc9..f5e43cd 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -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"; diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 28a9dfe..d0a1f56 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -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((set) => ({ +export const useUIStore = create((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 })), }));