feat: improve search

This commit is contained in:
reya 2024-05-12 08:18:25 +07:00
parent 73f80f27fb
commit 571d4b4004
6 changed files with 1226 additions and 1241 deletions

View File

@ -3,151 +3,141 @@ import { type Event, Kind } from "@lume/types";
import { Note, Spinner, User } from "@lume/ui"; import { Note, Spinner, User } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
export const Route = createFileRoute("/search")({ export const Route = createFileRoute("/search")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext(); const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<Event[]>([]);
const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500);
const [loading, setLoading] = useState(false); const searchEvents = async () => {
const [events, setEvents] = useState<Event[]>([]); try {
const [search, setSearch] = useState(""); setLoading(true);
const [value] = useDebounce(search, 500);
const searchEvents = async () => { const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
if (!value.length) return; const res = await fetch(query);
const content = await res.json();
const events = content.data as Event[];
const sorted = events.sort((a, b) => b.created_at - a.created_at);
// start loading setLoading(false);
setLoading(true); setEvents(sorted);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
const data = await ark.search(value, 100); useEffect(() => {
if (searchValue.length >= 3 && searchValue.length < 500) {
searchEvents();
}
}, [searchValue]);
// update state return (
setLoading(false); <div data-tauri-drag-region className="flex flex-col w-full h-full">
setEvents(data); <div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5">
}; <div data-tauri-drag-region className="w-full h-4 shrink-0" />
<input
useEffect(() => { value={search}
searchEvents(); onChange={(e) => setSearch(e.target.value)}
}, [value]); onKeyDown={(e) => {
if (e.key === "Enter") searchEvents();
return ( }}
<div placeholder="Search anything..."
data-tauri-drag-region className="w-full h-20 pt-10 px-3 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
className="flex flex-col w-full h-full bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900" />
> </div>
<div <div className="flex-1 p-3 overflow-y-auto scrollbar-none">
data-tauri-drag-region {loading ? (
className="relative h-24 shrink-0 flex items-end border-neutral-300 border-b dark:border-neutral-700" <div className="w-full h-full flex items-center justify-center">
> <Spinner />
<div </div>
data-tauri-drag-region ) : events.length ? (
className="absolute top-0 left-0 w-full h-4" <div className="flex flex-col gap-5">
/> <div className="flex flex-col gap-1.5">
<input <div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
value={search} Users
onChange={(e) => setSearch(e.target.value)} </div>
onKeyDown={(e) => { <div className="flex-1 flex flex-col gap-1">
if (e.key === "Enter") searchEvents(); {events
}} .filter((ev) => ev.kind === Kind.Metadata)
placeholder="Search anything..." .map((event, index) => (
className="z-10 w-full h-20 pt-10 px-6 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600" <SearchUser key={event.pubkey + index} event={event} />
/> ))}
</div> </div>
<div className="flex-1 p-3 overflow-y-auto scrollbar-none"> </div>
{loading ? ( <div className="flex flex-col gap-1.5">
<div className="w-full h-full flex items-center justify-center"> <div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
<Spinner /> Notes
</div> </div>
) : !events.length ? ( <div className="flex-1 flex flex-col gap-3">
<div className="flex items-center justify-center h-full text-sm"> {events
Empty .filter((ev) => ev.kind === Kind.Text)
</div> .map((event) => (
) : ( <SearchNote key={event.id} event={event} />
<div className="flex flex-col gap-5"> ))}
<div className="flex flex-col gap-1.5"> </div>
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0"> </div>
Users </div>
</div> ) : null}
<div className="flex-1 flex flex-col gap-3"> {!loading && !events.length ? (
{events <div className="h-full flex items-center justify-center flex-col gap-3">
.filter((ev) => ev.kind === Kind.Metadata) <div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center">
.map((event) => ( <SearchIcon className="size-6" />
<SearchUser key={event.id} event={event} /> </div>
))} Try searching for people, notes, or keywords
</div> </div>
</div> ) : null}
<div className="flex flex-col gap-1.5"> </div>
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0"> </div>
Notes );
</div>
<div className="flex-1 flex flex-col gap-3">
{events
.filter((ev) => ev.kind === Kind.Text)
.map((event) => (
<SearchNote key={event.id} event={event} />
))}
</div>
</div>
</div>
)}
{!loading && !events.length ? (
<div className="h-full flex items-center justify-center flex-col gap-3">
<div className="size-16 bg-blue-100 dark:bg-blue-900 rounded-full inline-flex items-center justify-center text-blue-500">
<SearchIcon className="size-6" />
</div>
Try searching for people, notes, or keywords
</div>
) : null}
</div>
</div>
);
} }
function SearchUser({ event }: { event: Event }) { function SearchUser({ event }: { event: Event }) {
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
return ( return (
<button <button
key={event.id} key={event.id}
type="button" type="button"
onClick={() => ark.open_profile(event.pubkey)} onClick={() => ark.open_profile(event.pubkey)}
className="p-3 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg" className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
> >
<User.Provider pubkey={event.pubkey} embedProfile={event.content}> <User.Provider pubkey={event.pubkey} embedProfile={event.content}>
<User.Root className="flex items-center gap-2"> <User.Root className="flex items-center gap-2">
<User.Avatar className="size-11 rounded-full shrink-0" /> <User.Avatar className="size-9 rounded-full shrink-0" />
<div> <div className="inline-flex items-center gap-1.5">
<User.Name className="font-semibold" /> <User.Name className="font-semibold" />
<User.NIP05 /> <User.NIP05 />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</button> </button>
); );
} }
function SearchNote({ event }: { event: Event }) { function SearchNote({ event }: { event: Event }) {
const { ark } = Route.useRouteContext(); return (
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
return ( <Note.Provider event={event}>
<div <Note.Root>
key={event.id} <div className="px-3 h-14 flex items-center justify-between">
onClick={() => ark.open_event(event)} <Note.User />
onKeyDown={() => ark.open_event(event)} <Note.Menu />
className="p-3 bg-white rounded-lg dark:bg-black" </div>
> <Note.Content className="px-3" quote={false} mention={false} />
<Note.Provider event={event}> <div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Root> <Note.Open />
<Note.User /> </div>
<div className="select-text mt-2.5 leading-normal line-clamp-5 text-balance"> </Note.Root>
{event.content} </Note.Provider>
</div> </div>
</Note.Root> );
</Note.Provider>
</div>
);
} }

View File

@ -9,178 +9,172 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/settings/user")({ export const Route = createFileRoute("/settings/user")({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const profile = await ark.get_current_user_profile(); const profile = await ark.get_current_user_profile();
return { profile }; return { profile };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, profile } = Route.useRouteContext(); const { ark, profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm(); const { register, handleSubmit } = useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState<string>(""); const [picture, setPicture] = useState<string>("");
const onSubmit = async (data: Metadata) => { const onSubmit = async (data: Metadata) => {
try { try {
setLoading(true); setLoading(true);
const profile = { ...data, picture }; const profile = { ...data, picture };
await ark.create_profile(profile); await ark.create_profile(profile);
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); toast.error(String(e));
} }
}; };
return ( return (
<div className="flex w-full h-full"> <div className="flex w-full h-full">
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3"> <div className="flex-1 h-full flex items-center flex-col justify-center gap-3">
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200"> <div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? ( {profile.picture ? (
<img <img
src={picture || profile.picture} src={picture || profile.picture}
alt="avatar" alt="avatar"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover" className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/> />
) : null} ) : null}
<AvatarUploader <AvatarUploader
setPicture={setPicture} setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
<PlusIcon className="size-8" /> <PlusIcon className="size-8" />
</AvatarUploader> </AvatarUploader>
</div> </div>
<div className="text-center flex flex-col items-center"> <div className="text-center flex flex-col items-center">
<div className="text-lg font-semibold">{profile.display_name}</div> <div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-800 dark:text-neutral-200"> <div className="text-neutral-800 dark:text-neutral-200">
{profile.nip05} {profile.nip05}
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Link <Link
to="/settings/backup" to="/settings/backup"
className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center" className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center"
> >
Backup Account Backup Account
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="flex-1 h-full"> <div className="flex-1 h-full">
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0" className="flex flex-col gap-3 mb-0"
> >
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="display_name" htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Display Name Display Name
</label> </label>
<input <input
name="display_name" name="display_name"
{...register("display_name", { required: true, minLength: 1 })} {...register("display_name", { required: true, minLength: 1 })}
value={profile.display_name} spellCheck={false}
spellCheck={false} className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" />
/> </div>
</div> <div className="flex w-full flex-col gap-1">
<div className="flex w-full flex-col gap-1"> <label
<label htmlFor="name"
htmlFor="name" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" >
> Name
Name </label>
</label> <input
<input name="name"
name="name" {...register("name")}
{...register("name")} spellCheck={false}
spellCheck={false} className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
value={profile.name} />
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" </div>
/> <div className="flex w-full flex-col gap-1">
</div> <label
<div className="flex w-full flex-col gap-1"> htmlFor="website"
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
htmlFor="website" >
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" Website
> </label>
Website <input
</label> name="website"
<input type="url"
name="website" {...register("website")}
type="url" spellCheck={false}
{...register("website")} className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
spellCheck={false} />
value={profile.website} </div>
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" <div className="flex w-full flex-col gap-1">
/> <label
</div> htmlFor="banner"
<div className="flex w-full flex-col gap-1"> className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
<label >
htmlFor="banner" Cover
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" </label>
> <input
Cover name="banner"
</label> type="url"
<input {...register("banner")}
name="banner" spellCheck={false}
type="url" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
{...register("banner")} />
spellCheck={false} </div>
value={profile.banner} <div className="flex w-full flex-col gap-1">
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" <label
/> htmlFor="nip05"
</div> className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
<div className="flex w-full flex-col gap-1"> >
<label NIP-05
htmlFor="nip05" </label>
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" <input
> name="nip05"
NIP-05 type="email"
</label> {...register("nip05")}
<input spellCheck={false}
name="nip05" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
type="email" />
{...register("nip05")} </div>
spellCheck={false} <div className="flex w-full flex-col gap-1">
value={profile.nip05} <label
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" htmlFor="lnaddress"
/> className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
</div> >
<div className="flex w-full flex-col gap-1"> Lightning Address
<label </label>
htmlFor="lnaddress" <input
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" name="lnaddress"
> type="email"
Lightning Address {...register("lud16")}
</label> className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
<input />
name="lnaddress" </div>
type="email" <div className="flex items-center justify-end">
{...register("lud16")} <button
value={profile.lud16} type="submit"
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50"
/> >
</div> {loading ? <Spinner className="size-4" /> : "Update Profile"}
<div className="flex items-center justify-end"> </button>
<button </div>
type="submit" </form>
className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50" </div>
> </div>
{loading ? <Spinner className="size-4" /> : "Update Profile"} );
</button>
</div>
</form>
</div>
</div>
);
} }

File diff suppressed because it is too large Load Diff

View File

@ -6,32 +6,32 @@ import { useMemo } from "react";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) { export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext(); const user = useUserContext();
const fallbackAvatar = useMemo( const fallbackAvatar = useMemo(
() => () =>
`data:image/svg+xml;utf8,${encodeURIComponent( `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50), minidenticon(user.pubkey || nanoid(), 90, 50),
)}`, )}`,
[user], [user],
); );
return ( return (
<Avatar.Root className="shrink-0"> <Avatar.Root className="shrink-0">
<Avatar.Image <Avatar.Image
src={user.profile?.picture} src={user.profile?.picture}
alt={user.pubkey} alt={user.pubkey}
loading="eager" loading="eager"
decoding="async" decoding="async"
className={cn("ring-1 ring-black/5 dark:ring-white/5", className)} className={cn("outline-[.5px] outline-black/5", className)}
/> />
<Avatar.Fallback delayMs={120}> <Avatar.Fallback delayMs={120}>
<img <img
src={fallbackAvatar} src={fallbackAvatar}
alt={user.pubkey} alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)} className={cn("bg-black dark:bg-white", className)}
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
); );
} }

View File

@ -50,20 +50,22 @@ fn main() {
Err(_) => ClientBuilder::default().build(), Err(_) => ClientBuilder::default().build(),
}; };
// Add some bootstrap relays // Add bootstrap relays
// #TODO: Pull bootstrap relays from user's settings
client client
.add_relay("wss://relay.nostr.net") .add_relay("wss://relay.nostr.net")
.await .await
.expect("Cannot connect to relay.nostr.net, please try again later."); .expect("Cannot connect to relay.nostr.net, please try again later.");
client client
.add_relay("wss://bostr.nokotaro.work/") .add_relay("wss://relay.damus.io")
.await .await
.expect("Cannot connect to bostr.nokotaro.work, please try again later."); .expect("Cannot connect to relay.damus.io, please try again later.");
client client
.add_relay("wss://purplepag.es/") .add_relay_with_opts(
"wss://directory.yabu.me/",
RelayOptions::new().read(true).write(false),
)
.await .await
.expect("Cannot connect to purplepag.es, please try again later."); .expect("Cannot connect to directory.yabu.me, please try again later.");
// Connect // Connect
client.connect().await; client.connect().await;

View File

@ -97,10 +97,9 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
} else { } else {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let _ = WebviewWindowBuilder::new(app, "search", WebviewUrl::App(PathBuf::from("search"))) let _ = WebviewWindowBuilder::new(app, "search", WebviewUrl::App(PathBuf::from("search")))
.title("Editor") .title("Search")
.inner_size(750., 470.) .inner_size(400., 600.)
.minimizable(false) .minimizable(false)
.resizable(false)
.title_bar_style(TitleBarStyle::Overlay) .title_bar_style(TitleBarStyle::Overlay)
.transparent(true) .transparent(true)
.effects(WindowEffectsConfig { .effects(WindowEffectsConfig {