search dropdown

This commit is contained in:
Martti Malmi 2023-10-18 12:50:03 +03:00
parent 5535614455
commit a331e43b4e
5 changed files with 132 additions and 19 deletions

View File

@ -1,7 +1,6 @@
.search { .search {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
padding: 9px 16px;
background: var(--gray-superdark); background: var(--gray-superdark);
border-radius: 1000px; border-radius: 1000px;
} }
@ -16,7 +15,11 @@
border-radius: 0 !important; border-radius: 0 !important;
font-size: 15px; font-size: 15px;
line-height: 21px; line-height: 21px;
padding: 0 !important; padding: 9px 16px;
}
.search > svg {
margin: 9px 16px;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@ -28,4 +31,4 @@
.search input { .search input {
display: none; display: none;
} }
} }

View File

@ -1,19 +1,70 @@
import "./SearchBox.css"; import "./SearchBox.css";
import Spinner from "../Icons/Spinner"; import Spinner from "../Icons/Spinner";
import Icon from "../Icons/Icon"; import Icon from "../Icons/Icon";
import { useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { fetchNip05Pubkey } from "../Nip05/Verifier"; import { fetchNip05Pubkey } from "../Nip05/Verifier";
import { useState } from "react"; import { ChangeEvent, useEffect, useRef, useState } from "react";
import { NostrLink, NostrPrefix, tryParseNostrLink } from "@snort/system"; import { NostrLink, tryParseNostrLink } from "@snort/system";
import { useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { unixNow } from "@snort/shared";
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../Feed/TimelineFeed";
import Note from "./Event/Note";
const MAX_RESULTS = 3;
export default function SearchBox() { export default function SearchBox() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
async function searchThing() { const [activeIndex, setActiveIndex] = useState<number>(-1);
const resultListRef = useRef<HTMLDivElement | null>(null);
const options: TimelineFeedOptions = {
method: "LIMIT_UNTIL",
window: undefined,
now: unixNow(),
};
const subject: TimelineSubject = {
type: "profile_keyword",
discriminator: search,
items: [search],
relay: undefined,
streams: false,
};
const { main } = useTimelineFeed(subject, options);
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setSearch("");
}
};
const inputElement = resultListRef.current?.previousSibling as HTMLElement;
inputElement?.addEventListener("focus", () => {
document.addEventListener("keydown", handleGlobalKeyDown);
});
inputElement?.addEventListener("blur", () => {
document.removeEventListener("keydown", handleGlobalKeyDown);
});
return () => {
document.removeEventListener("keydown", handleGlobalKeyDown);
};
}, []);
useEffect(() => {
// Close the search on navigation
setSearch("");
setActiveIndex(-1);
}, [location]);
const executeSearch = async () => {
try { try {
setSearching(true); setSearching(true);
const link = tryParseNostrLink(search); const link = tryParseNostrLink(search);
@ -25,36 +76,90 @@ export default function SearchBox() {
const [handle, domain] = search.split("@"); const [handle, domain] = search.split("@");
const pk = await fetchNip05Pubkey(handle, domain); const pk = await fetchNip05Pubkey(handle, domain);
if (pk) { if (pk) {
navigate(`/${new NostrLink(NostrPrefix.PublicKey, pk).encode()}`); navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, pk).encode()}`);
return; return;
} }
} }
navigate(`/search/${encodeURIComponent(search)}`); navigate(`/search/${encodeURIComponent(search)}`);
} finally { } finally {
setSearch("");
setSearching(false); setSearching(false);
} }
} };
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.value.match(/nsec1[a-zA-Z0-9]{20,65}/gi)) {
setSearch(e.target.value);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "Enter":
if (activeIndex === 0) {
navigate(`/search/${encodeURIComponent(search)}`);
} else if (activeIndex > 0 && main) {
const selectedResult = main[activeIndex - 1];
navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, selectedResult.pubkey).encode()}`);
} else {
executeSearch();
}
break;
case "ArrowDown":
e.preventDefault();
setActiveIndex(prev => Math.min(prev + 1, Math.min(MAX_RESULTS, main ? main.length : 0)));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex(prev => Math.max(prev - 1, 0));
break;
default:
break;
}
};
return ( return (
<div className="search"> <div className="search relative">
<input <input
type="text" type="text"
placeholder={formatMessage({ defaultMessage: "Search" })} placeholder={formatMessage({ defaultMessage: "Search" })}
className="w-max" className="w-max"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={handleChange}
onKeyDown={async e => { onKeyDown={handleKeyDown}
if (e.key === "Enter") {
await searchThing();
}
}}
/> />
{searching ? ( {searching ? (
<Spinner width={24} height={24} /> <Spinner width={24} height={24} />
) : ( ) : (
<Icon name="search" size={24} onClick={() => navigate("/search")} /> <Icon name="search" size={24} onClick={() => navigate("/search")} />
)} )}
{search && !searching && (
<div
className="absolute top-full mt-2 w-full border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-black shadow-lg rounded-lg z-10 overflow-hidden"
ref={resultListRef}>
<div
className={`p-2 cursor-pointer ${
activeIndex === 0
? "bg-neutral-300 dark:bg-neutral-800 hover:bg-neutral-400 dark:hover:bg-neutral-600"
: "hover:bg-neutral-200 dark:hover:bg-neutral-800"
}`}
onMouseEnter={() => setActiveIndex(0)}
onClick={() => navigate(`/search/${encodeURIComponent(search)}`, { state: { forceRefresh: true } })}>
<FormattedMessage defaultMessage="Search notes" />: <b>{search}</b>
</div>
{main?.slice(0, MAX_RESULTS).map((result, idx) => (
<div
key={idx}
className={`p-2 cursor-pointer ${
activeIndex === idx + 1
? "bg-neutral-300 dark:bg-neutral-800 hover:bg-neutral-400 dark:hover:bg-neutral-600"
: "hover:bg-neutral-200 dark:hover:bg-neutral-800"
}`}
onMouseEnter={() => setActiveIndex(idx + 1)}>
<Note data={result} depth={0} related={[]} />
</div>
))}
</div>
)}
</div> </div>
); );
} }

View File

@ -22,7 +22,8 @@ export default function NostrLinkHandler() {
if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) { if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) {
setRenderComponent(<ThreadRoute id={nav.encode()} />); // Directly render ThreadRoute setRenderComponent(<ThreadRoute id={nav.encode()} />); // Directly render ThreadRoute
} else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) { } else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) {
setRenderComponent(<ProfilePage id={nav.encode()} state={state} />); // Directly render ProfilePage const id = nav.encode();
setRenderComponent(<ProfilePage key={id} id={id} state={state} />); // Directly render ProfilePage
} }
} else { } else {
if (state) { if (state) {

View File

@ -401,6 +401,9 @@
"E8a4yq": { "E8a4yq": {
"defaultMessage": "Follow some popular accounts" "defaultMessage": "Follow some popular accounts"
}, },
"EJbFi7": {
"defaultMessage": "Search notes"
},
"ELbg9p": { "ELbg9p": {
"defaultMessage": "Data Providers" "defaultMessage": "Data Providers"
}, },

View File

@ -131,6 +131,7 @@
"Dn82AL": "Live", "Dn82AL": "Live",
"DtYelJ": "Transfer", "DtYelJ": "Transfer",
"E8a4yq": "Follow some popular accounts", "E8a4yq": "Follow some popular accounts",
"EJbFi7": "Search notes",
"ELbg9p": "Data Providers", "ELbg9p": "Data Providers",
"EPYwm7": "Your private key is your password. If you lose this key, you will lose access to your account! Copy it and keep it in a safe place. There is no way to reset your private key.", "EPYwm7": "Your private key is your password. If you lose this key, you will lose access to your account! Copy it and keep it in a safe place. There is no way to reset your private key.",
"EQKRE4": "Show badges on profile pages", "EQKRE4": "Show badges on profile pages",