diff --git a/src/App.tsx b/src/App.tsx index d35b9fc..e8ed9ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { Sidebar } from "./components/sidebar/Sidebar"; import { Feed } from "./components/feed/Feed"; +import { SearchView } from "./components/search/SearchView"; import { RelaysView } from "./components/shared/RelaysView"; import { SettingsView } from "./components/shared/SettingsView"; import { ProfileView } from "./components/profile/ProfileView"; @@ -15,6 +16,7 @@ function App() {
{currentView === "feed" && } + {currentView === "search" && } {currentView === "relays" && } {currentView === "settings" && } {currentView === "profile" && } diff --git a/src/components/search/SearchView.tsx b/src/components/search/SearchView.tsx new file mode 100644 index 0000000..3db2b4b --- /dev/null +++ b/src/components/search/SearchView.tsx @@ -0,0 +1,202 @@ +import { useState, useRef } from "react"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import { searchNotes, searchUsers } from "../../lib/nostr"; +import { useUserStore } from "../../stores/user"; +import { useUIStore } from "../../stores/ui"; +import { shortenPubkey } from "../../lib/utils"; +import { NoteCard } from "../feed/NoteCard"; + +interface ParsedUser { + pubkey: string; + name: string; + displayName: string; + picture: string; + nip05: string; + about: string; +} + +function parseUserEvent(event: NDKEvent): ParsedUser { + let meta: Record = {}; + try { meta = JSON.parse(event.content); } catch { /* ignore */ } + return { + pubkey: event.pubkey, + name: meta.name || "", + displayName: meta.display_name || meta.name || "", + picture: meta.picture || "", + nip05: meta.nip05 || "", + about: meta.about || "", + }; +} + +function UserRow({ user }: { user: ParsedUser }) { + const { loggedIn, pubkey: myPubkey, follows, follow, unfollow } = useUserStore(); + const { openProfile: navToProfile } = useUIStore(); + const isOwn = user.pubkey === myPubkey; + const isFollowing = follows.includes(user.pubkey); + const [pending, setPending] = useState(false); + const displayName = user.displayName || user.name || shortenPubkey(user.pubkey); + + const handleFollowToggle = async () => { + setPending(true); + try { + if (isFollowing) await unfollow(user.pubkey); + else await follow(user.pubkey); + } finally { + setPending(false); + } + }; + + return ( +
+
navToProfile(user.pubkey)} + > + {user.picture ? ( + { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} +
+
navToProfile(user.pubkey)}> +
{displayName}
+ {user.nip05 &&
{user.nip05}
} + {user.about &&
{user.about}
} +
+ {loggedIn && !isOwn && ( + + )} +
+ ); +} + +export function SearchView() { + const [query, setQuery] = useState(""); + const [noteResults, setNoteResults] = useState([]); + const [userResults, setUserResults] = useState([]); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + const [activeTab, setActiveTab] = useState<"notes" | "people">("notes"); + const inputRef = useRef(null); + + const isHashtag = query.trim().startsWith("#"); + + const handleSearch = async () => { + const q = query.trim(); + if (!q) return; + setLoading(true); + setSearched(false); + try { + const notesPromise = searchNotes(q); + const usersPromise = isHashtag ? Promise.resolve([]) : searchUsers(q); + const [notes, userEvents] = await Promise.all([notesPromise, usersPromise]); + setNoteResults(notes); + setUserResults(userEvents.map(parseUserEvent)); + setActiveTab(notes.length > 0 ? "notes" : "people"); + } finally { + setLoading(false); + setSearched(true); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSearch(); + }; + + const totalResults = noteResults.length + userResults.length; + + return ( +
+ {/* Header */} +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="search notes, #hashtags, or people…" + autoFocus + className="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-dim focus:outline-none" + /> + +
+
+ + {/* Tabs (only when we have both note and people results) */} + {searched && !isHashtag && noteResults.length > 0 && userResults.length > 0 && ( +
+ {(["notes", "people"] as const).map((tab) => ( + + ))} +
+ )} + + {/* Results */} +
+ {/* Empty / idle state */} + {!searched && !loading && ( +
+

Search notes with NIP-50 full-text, or use #hashtag to browse topics.

+

NIP-50 requires relay support — results vary by relay.

+
+ )} + + {searched && totalResults === 0 && ( +
+ No results for {query}. + {!isHashtag &&

Your relays may not support NIP-50 full-text search.

} +
+ )} + + {/* People tab */} + {activeTab === "people" && userResults.map((user) => ( + + ))} + + {/* Notes tab (or all notes for hashtag search) */} + {(activeTab === "notes" || isHashtag) && noteResults.map((event) => ( + + ))} + + {/* People inline when only people results */} + {searched && noteResults.length === 0 && userResults.length > 0 && activeTab === "notes" && ( + userResults.map((user) => ) + )} +
+
+ ); +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 7e82aa9..9ccee53 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -7,6 +7,7 @@ import { shortenPubkey } from "../../lib/utils"; const NAV_ITEMS = [ { id: "feed" as const, label: "feed", icon: "◈" }, + { id: "search" as const, label: "search", icon: "⌕" }, { id: "relays" as const, label: "relays", icon: "⟐" }, { id: "settings" as const, label: "settings", icon: "⚙" }, ] as const; diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts index 5071c61..f55a00d 100644 --- a/src/lib/nostr/client.ts +++ b/src/lib/nostr/client.ts @@ -226,6 +226,31 @@ export async function fetchUserNotes(pubkey: string, limit = 30): Promise (b.created_at ?? 0) - (a.created_at ?? 0)); } +export async function searchNotes(query: string, limit = 50): Promise { + const instance = getNDK(); + const isHashtag = query.startsWith("#"); + const filter: NDKFilter & { search?: string } = isHashtag + ? { kinds: [NDKKind.Text], "#t": [query.slice(1).toLowerCase()], limit } + : { kinds: [NDKKind.Text], search: query, limit }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); +} + +export async function searchUsers(query: string, limit = 20): Promise { + const instance = getNDK(); + const filter: NDKFilter & { search?: string } = { + kinds: [NDKKind.Metadata], + search: query, + limit, + }; + const events = await instance.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + }); + return Array.from(events); +} + export async function fetchReactionCount(eventId: string): Promise { const instance = getNDK(); const filter: NDKFilter = { diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index cf3c444..06e18b5 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -1 +1 @@ -export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, getStoredRelayUrls, addRelay, removeRelay } from "./client"; +export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client"; diff --git a/src/stores/ui.ts b/src/stores/ui.ts index f969d76..6c56d4e 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import { NDKEvent } from "@nostr-dev-kit/ndk"; -type View = "feed" | "relays" | "settings" | "profile" | "thread" | "article-editor"; +type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor"; interface UIState { currentView: View;