mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
Bump to v0.4.0 — Phase 3: image lightbox, bookmarks, discover, language filter, UI polish
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { searchNotes, searchUsers, getStoredRelayUrls } from "../../lib/nostr";
|
||||
import { searchNotes, searchUsers, getStoredRelayUrls, fetchFollowSuggestions, fetchProfile } from "../../lib/nostr";
|
||||
import { getNip50Relays } from "../../lib/nostr/relayInfo";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
@@ -81,6 +81,44 @@ function UserRow({ user }: { user: ParsedUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
pubkey: string;
|
||||
mutualCount: number;
|
||||
profile: ParsedUser | null;
|
||||
}
|
||||
|
||||
function SuggestionFollowButton({ pubkey }: { pubkey: string }) {
|
||||
const { loggedIn, follows, follow, unfollow } = useUserStore();
|
||||
const isFollowing = follows.includes(pubkey);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
if (!loggedIn) return null;
|
||||
|
||||
const handleClick = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
if (isFollowing) await unfollow(pubkey);
|
||||
else await follow(pubkey);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={pending}
|
||||
className={`text-[11px] px-3 py-1 border transition-colors shrink-0 disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
isFollowing
|
||||
? "border-border text-text-muted hover:text-danger hover:border-danger/40"
|
||||
: "border-accent/60 text-accent hover:bg-accent hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{pending ? "..." : isFollowing ? "unfollow" : "follow"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchView() {
|
||||
const { pendingSearch } = useUIStore();
|
||||
const [query, setQuery] = useState(pendingSearch ?? "");
|
||||
@@ -91,6 +129,9 @@ export function SearchView() {
|
||||
const [activeTab, setActiveTab] = useState<"notes" | "people">("notes");
|
||||
const [nip50Relays, setNip50Relays] = useState<string[] | null>(null); // null = not checked yet
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
||||
const [suggestionsLoaded, setSuggestionsLoaded] = useState(false);
|
||||
|
||||
const isHashtag = query.trim().startsWith("#");
|
||||
|
||||
@@ -100,6 +141,40 @@ export function SearchView() {
|
||||
getNip50Relays(urls).then(setNip50Relays);
|
||||
}, []);
|
||||
|
||||
const { loggedIn, follows } = useUserStore();
|
||||
|
||||
// Load follow suggestions on mount (only for logged-in users with follows)
|
||||
useEffect(() => {
|
||||
if (!loggedIn || follows.length === 0 || suggestionsLoaded) return;
|
||||
setSuggestionsLoading(true);
|
||||
fetchFollowSuggestions(follows).then(async (results) => {
|
||||
// Load profiles for top suggestions
|
||||
const withProfiles: Suggestion[] = await Promise.all(
|
||||
results.slice(0, 20).map(async (s) => {
|
||||
try {
|
||||
const p = await fetchProfile(s.pubkey);
|
||||
return {
|
||||
...s,
|
||||
profile: p ? {
|
||||
pubkey: s.pubkey,
|
||||
name: (p as Record<string, string>).name || "",
|
||||
displayName: (p as Record<string, string>).display_name || (p as Record<string, string>).name || "",
|
||||
picture: (p as Record<string, string>).picture || "",
|
||||
nip05: (p as Record<string, string>).nip05 || "",
|
||||
about: (p as Record<string, string>).about || "",
|
||||
} : null,
|
||||
};
|
||||
} catch {
|
||||
return { ...s, profile: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
setSuggestions(withProfiles.filter((s) => s.profile !== null));
|
||||
setSuggestionsLoading(false);
|
||||
setSuggestionsLoaded(true);
|
||||
}).catch(() => setSuggestionsLoading(false));
|
||||
}, [loggedIn, follows.length]);
|
||||
|
||||
// Run pending search from hashtag/mention click
|
||||
useEffect(() => {
|
||||
if (pendingSearch) {
|
||||
@@ -212,6 +287,51 @@ export function SearchView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discover — follow suggestions */}
|
||||
{!searched && !loading && loggedIn && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2.5 border-b border-border">
|
||||
<h3 className="text-text text-[12px] font-medium">Discover people</h3>
|
||||
<p className="text-text-dim text-[10px] mt-0.5">Based on who your follows follow</p>
|
||||
</div>
|
||||
{suggestionsLoading && (
|
||||
<div className="px-4 py-6 text-text-dim text-[11px] text-center">Finding suggestions...</div>
|
||||
)}
|
||||
{suggestions.map((s) => s.profile && (
|
||||
<div key={s.pubkey} className="flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-bg-hover transition-colors">
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
{s.profile.picture ? (
|
||||
<img src={s.profile.picture} alt="" className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
|
||||
{(s.profile.displayName || s.profile.name || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
<div className="text-text text-[13px] font-medium truncate">
|
||||
{s.profile.displayName || s.profile.name || shortenPubkey(s.pubkey)}
|
||||
</div>
|
||||
<div className="text-text-dim text-[10px]">
|
||||
{s.mutualCount} mutual follow{s.mutualCount !== 1 ? "s" : ""}
|
||||
{s.profile.nip05 && <span className="ml-2">{s.profile.nip05}</span>}
|
||||
</div>
|
||||
{s.profile.about && (
|
||||
<div className="text-text-dim text-[11px] truncate mt-0.5">{s.profile.about}</div>
|
||||
)}
|
||||
</div>
|
||||
<SuggestionFollowButton pubkey={s.pubkey} />
|
||||
</div>
|
||||
))}
|
||||
{suggestionsLoaded && suggestions.length === 0 && (
|
||||
<div className="px-4 py-6 text-text-dim text-[11px] text-center">
|
||||
Follow more people to see suggestions here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zero results for full-text search */}
|
||||
{searched && totalResults === 0 && !isHashtag && (
|
||||
<div className="px-4 py-8 text-center space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user