rebuild search view

add support for NIP-51 search relays
This commit is contained in:
hzrd149 2024-08-10 10:50:03 -05:00
parent 7ebf09c24f
commit c137b3d765
24 changed files with 648 additions and 319 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for NIP-51 search relay list

View File

@ -93,6 +93,7 @@ const VideosView = lazy(() => import("./views/videos"));
const VideoDetailsView = lazy(() => import("./views/videos/video"));
import BookmarksView from "./views/bookmarks";
import TaskManagerProvider from "./views/task-manager/provider";
import SearchRelaysView from "./views/relays/search";
const TracksView = lazy(() => import("./views/tracks"));
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const UserVideosTab = lazy(() => import("./views/user/videos"));
@ -265,6 +266,7 @@ const router = createHashRouter([
],
},
{ path: "mailboxes", element: <MailboxesView /> },
{ path: "search", element: <SearchRelaysView /> },
{ path: "media-servers", element: <MediaServersView /> },
{ path: "nip05", element: <NIP05RelaysView /> },
{ path: "contacts", element: <ContactListRelaysView /> },

View File

@ -42,6 +42,14 @@ export default class Subject<T> {
map<R>(callback: (value: T) => R, defaultValue?: R): Subject<R> {
const child = new Subject(defaultValue);
if (this.value !== undefined) {
try {
child.next(callback(this.value));
} catch (e) {
child.error(e);
}
}
this.subscribe((value) => {
try {
child.next(callback(value));

View File

@ -6,10 +6,21 @@ import { unique } from "../helpers/array";
export type RelayUrlInputProps = Omit<InputProps, "type">;
export const RelayUrlInput = forwardRef(({ ...props }: Omit<InputProps, "type">, ref) => {
const { value: relaysJson } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>),
);
export const RelayUrlInput = forwardRef(({ nips, ...props }: { nips?: number[] } & Omit<InputProps, "type">, ref) => {
const { value: relaysJson } = useAsync(async () => {
let online = await fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>);
if (!nips) return online;
for (const nip of nips) {
if (online.length === 0) break;
const supported = await fetch("https://api.nostr.watch/v1/nip/" + nip).then(
(res) => res.json() as Promise<string[]>,
);
online = online.filter((url) => supported.includes(url));
}
return online;
}, [nips?.join("|")]);
const relaySuggestions = unique(relaysJson ?? []);
return (

View File

@ -0,0 +1,24 @@
import { useMemo } from "react";
import { Box, BoxProps } from "@chakra-ui/react";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import useUserMetadata from "../../hooks/use-user-metadata";
import { embedNostrLinks, renderGenericUrl } from "../external-embeds";
export default function UserAbout({ pubkey, ...props }: { pubkey: string } & Omit<BoxProps, "children">) {
const metadata = useUserMetadata(pubkey);
const aboutContent = useMemo(() => {
if (!metadata?.about) return null;
let content: EmbedableContent = [metadata.about.trim()];
content = embedNostrLinks(content);
content = embedUrls(content, [renderGenericUrl]);
return content;
}, [metadata?.about]);
return (
<Box whiteSpace="pre-line" {...props}>
{aboutContent}
</Box>
);
}

View File

@ -1,9 +1,13 @@
import { Text, Tooltip } from "@chakra-ui/react";
import { Text, TextProps, Tooltip } from "@chakra-ui/react";
import useUserMetadata from "../../hooks/use-user-metadata";
import UserDnsIdentityIcon from "./user-dns-identity-icon";
export default function UserDnsIdentity({ pubkey, onlyIcon }: { pubkey: string; onlyIcon?: boolean }) {
export default function UserDnsIdentity({
pubkey,
onlyIcon,
...props
}: { pubkey: string; onlyIcon?: boolean } & Omit<TextProps, "children">) {
const metadata = useUserMetadata(pubkey);
if (!metadata?.nip05) return null;
@ -15,7 +19,7 @@ export default function UserDnsIdentity({ pubkey, onlyIcon }: { pubkey: string;
);
}
return (
<Text as="span" whiteSpace="nowrap">
<Text as="span" whiteSpace="nowrap" {...props}>
{metadata.nip05.startsWith("_@") ? metadata.nip05.substr(2) : metadata.nip05}{" "}
<UserDnsIdentityIcon pubkey={pubkey} />
</Text>

View File

@ -1,6 +1,6 @@
import { safeRelayUrl, safeRelayUrls } from "./helpers/relay";
export const SEARCH_RELAYS = safeRelayUrls([
export const DEFAULT_SEARCH_RELAYS = safeRelayUrls([
"wss://relay.nostr.band",
"wss://search.nos.today",
"wss://relay.noswhere.com",

View File

@ -72,7 +72,6 @@ export function getEventPointersFromList(event: NostrEvent | DraftNostrEvent): n
export function getReferencesFromList(event: NostrEvent | DraftNostrEvent) {
return event.tags.filter(isRTag).map((t) => ({ url: t[1], petname: t[2] }));
}
/** @deprecated */
export function getRelaysFromList(event: NostrEvent | DraftNostrEvent) {
if (event.kind === kinds.RelayList) return safeRelayUrls(event.tags.filter(isRTag).map((t) => t[1]));
else return safeRelayUrls(event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]) as string[]);
@ -119,7 +118,7 @@ export function listAddPerson(
relay?: string,
petname?: string,
): DraftNostrEvent {
if (list.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("person already in list");
if (list.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("Person already in list");
const pTag: PTag = ["p", pubkey, relay ?? "", petname ?? ""];
while (pTag[pTag.length - 1] === "") pTag.pop();
@ -141,7 +140,7 @@ export function listRemovePerson(list: NostrEvent | DraftNostrEvent, pubkey: str
}
export function listAddEvent(list: NostrEvent | DraftNostrEvent, event: string, relay?: string): DraftNostrEvent {
if (list.tags.some((t) => t[0] === "e" && t[1] === event)) throw new Error("event already in list");
if (list.tags.some((t) => t[0] === "e" && t[1] === event)) throw new Error("Event already in list");
return {
created_at: dayjs().unix(),
kind: list.kind,
@ -159,6 +158,25 @@ export function listRemoveEvent(list: NostrEvent | DraftNostrEvent, event: strin
};
}
export function listAddRelay(list: NostrEvent | DraftNostrEvent, relay: string): DraftNostrEvent {
if (list.tags.some((t) => t[0] === "e" && t[1] === relay)) throw new Error("Relay already in list");
return {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: [...list.tags, ["relay", relay]],
};
}
export function listRemoveRelay(list: NostrEvent | DraftNostrEvent, relay: string): DraftNostrEvent {
return {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: list.tags.filter((t) => !(t[0] === "relay" && t[1] === relay)),
};
}
export function listAddCoordinate(
list: NostrEvent | DraftNostrEvent,
coordinate: string,

View File

@ -0,0 +1,14 @@
import { DEFAULT_SEARCH_RELAYS } from "../const";
import { getRelaysFromList } from "../helpers/nostr/lists";
import useCurrentAccount from "./use-current-account";
import useUserSearchRelayList from "./use-user-search-relay-list";
export default function useSearchRelays() {
const account = useCurrentAccount();
const searchRelayList = useUserSearchRelayList(account?.pubkey);
const searchRelays = searchRelayList ? getRelaysFromList(searchRelayList) : DEFAULT_SEARCH_RELAYS;
// TODO: maybe add localRelay into the list if it supports NIP-50
return searchRelays;
}

View File

@ -0,0 +1,12 @@
import { kinds } from "nostr-tools";
import useReplaceableEvent from "./use-replaceable-event";
import { RequestOptions } from "../services/replaceable-events";
export default function useUserSearchRelayList(
pubkey?: string,
additionalRelays?: Iterable<string>,
opts: RequestOptions = {},
) {
return useReplaceableEvent(pubkey && { kind: kinds.SearchRelaysList, pubkey }, additionalRelays, opts);
}

View File

@ -1,4 +1,4 @@
import { Button, Flex, FlexProps } from "@chakra-ui/react";
import { Button, Flex, FlexProps, useToast } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { safeRelayUrl } from "../../../helpers/relay";
@ -6,24 +6,34 @@ import { RelayUrlInput } from "../../../components/relay-url-input";
export default function AddRelayForm({
onSubmit,
supportedNips,
...props
}: { onSubmit: (relay: string) => void } & Omit<FlexProps, "children" | "onSubmit">) {
}: { onSubmit: (relay: string) => void | Promise<void>; supportedNips?: number[] } & Omit<
FlexProps,
"children" | "onSubmit"
>) {
const toast = useToast();
const { register, handleSubmit, reset } = useForm({
defaultValues: {
url: "",
},
});
const submit = handleSubmit((values) => {
const url = safeRelayUrl(values.url);
if (!url) return;
onSubmit(url);
reset();
const submit = handleSubmit(async (values) => {
try {
const url = safeRelayUrl(values.url);
if (!url) return;
await onSubmit(url);
reset();
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
throw error;
}
});
return (
<Flex as="form" display="flex" gap="2" onSubmit={submit} {...props}>
<RelayUrlInput {...register("url")} placeholder="wss://relay.example.com" />
<RelayUrlInput {...register("url")} placeholder="wss://relay.example.com" nips={supportedNips} />
<Button type="submit" colorScheme="primary">
Add
</Button>

View File

@ -6,7 +6,7 @@ import useCurrentAccount from "../../hooks/use-current-account";
import useUserRelaySets from "../../hooks/use-user-relay-sets";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import Database01 from "../../components/icons/database-01";
import { AtIcon, RelayIcon } from "../../components/icons";
import { AtIcon, RelayIcon, SearchIcon } from "../../components/icons";
import Mail02 from "../../components/icons/mail-02";
import { useUserDNSIdentity } from "../../hooks/use-user-dns-identity";
import useUserContactRelays from "../../hooks/use-user-contact-relays";
@ -69,6 +69,15 @@ export default function RelaysView() {
>
Media Servers
</Button>
<Button
variant="outline"
as={RouterLink}
to="/relays/search"
leftIcon={<SearchIcon boxSize={6} />}
colorScheme={location.pathname.startsWith("/relays/search") ? "primary" : undefined}
>
Search Relays
</Button>
</>
)}
<Button

View File

@ -0,0 +1,166 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Button,
ButtonGroup,
Flex,
Heading,
IconButton,
Link,
Text,
useToast,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { EventTemplate, kinds } from "nostr-tools";
import dayjs from "dayjs";
import { CloseIcon } from "@chakra-ui/icons";
import BackButton from "../../../components/router/back-button";
import useUserSearchRelayList from "../../../hooks/use-user-search-relay-list";
import useCurrentAccount from "../../../hooks/use-current-account";
import { getRelaysFromList, listAddRelay, listRemoveRelay } from "../../../helpers/nostr/lists";
import { usePublishEvent } from "../../../providers/global/publish-provider";
import { RelayFavicon } from "../../../components/relay-favicon";
import AddRelayForm from "../app/add-relay-form";
import { useRelayInfo } from "../../../hooks/use-relay-info";
function RelayEntry({
url,
onRemove,
onMakeDefault,
isDefault,
}: {
url: string;
onRemove: () => void;
onMakeDefault: () => void;
isDefault: boolean;
}) {
const { info } = useRelayInfo(url);
return (
<Flex key={url} gap="2" alignItems="center" pl="2">
<RelayFavicon relay={url} size="xs" outline="2px solid" />
<Box overflow="hidden">
<Link as={RouterLink} to={`/r/${encodeURIComponent(url)}`} isTruncated>
{url}
</Link>
{info?.supported_nips && !info?.supported_nips.includes(50) && <Text color="red">Search not supported</Text>}
</Box>
<ButtonGroup size="sm" ml="auto">
<Button
onClick={() => onMakeDefault()}
variant={isDefault ? "solid" : "ghost"}
colorScheme={isDefault ? "primary" : undefined}
isDisabled={isDefault}
>
Default
</Button>
<IconButton
aria-label="Remove Relay"
icon={<CloseIcon />}
colorScheme="red"
onClick={() => onRemove()}
variant="ghost"
/>
</ButtonGroup>
</Flex>
);
}
function emptySearchRelayList(): EventTemplate {
return {
kind: kinds.SearchRelaysList,
tags: [],
content: "",
created_at: dayjs().unix(),
};
}
export default function SearchRelaysView() {
const toast = useToast();
const publish = usePublishEvent();
const account = useCurrentAccount();
const searchRelayList = useUserSearchRelayList(account?.pubkey);
const searchRelays = searchRelayList ? getRelaysFromList(searchRelayList) : [];
const addRelay = async (url: string) => {
try {
const draft = listAddRelay(searchRelayList || emptySearchRelayList(), url);
await publish("Add search relay", draft);
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
}
};
const makeDefault = async (url: string) => {
try {
const draft = searchRelayList || emptySearchRelayList();
draft.tags = Array.from(draft.tags).sort((a, b) => (a[1] === url ? -1 : 1));
await publish("Set default search relay", draft);
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
}
};
const removeRelay = async (url: string) => {
try {
const draft = listRemoveRelay(searchRelayList || emptySearchRelayList(), url);
await publish("Remove search relay", draft);
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
}
};
return (
<Flex gap="2" direction="column" overflow="auto hidden" flex={1}>
<Flex gap="2" alignItems="center">
<BackButton hideFrom="lg" size="sm" />
<Heading size="lg" px={{ base: 0, lg: "2" }}>
Search Relays
</Heading>
</Flex>
<Text fontStyle="italic" px="2" mt="-2">
These relays are used to search for users and content
</Text>
{searchRelays.length === 0 && (
<Alert
status="warning"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
height="200px"
>
<AlertIcon boxSize="40px" mr={0} />
<AlertTitle mt={4} mb={1} fontSize="lg">
No search relays set
</AlertTitle>
<AlertDescription maxWidth="sm">
You need to set at least one search relay to be able to use search
</AlertDescription>
<Button mt="2" onClick={() => addRelay("wss://relay.nostr.band/")}>
Use nostr.band relay
</Button>
</Alert>
)}
{searchRelays.map((url) => (
<RelayEntry
key={url}
url={url}
onMakeDefault={() => makeDefault(url)}
onRemove={() => removeRelay(url)}
isDefault={searchRelays[0] === url}
/>
))}
<AddRelayForm onSubmit={(relay) => addRelay(relay)} supportedNips={[50]} />
</Flex>
);
}

View File

@ -1,27 +0,0 @@
import { kinds } from "nostr-tools";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import GenericNoteTimeline from "../../components/timeline-page/generic-note-timeline";
import { usePeopleListContext } from "../../providers/local/people-list-provider";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
export default function ArticleSearchResults({ search }: { search: string }) {
const searchRelays = useAdditionalRelayContext();
const { listId, filter } = usePeopleListContext();
const timeline = useTimelineLoader(
`${listId ?? "global"}-${search}-article-search`,
searchRelays,
search ? { search: search, kinds: [kinds.LongFormArticle], ...filter } : undefined,
);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<GenericNoteTimeline timeline={timeline} />
</IntersectionObserverProvider>
);
}

View File

@ -1,45 +0,0 @@
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
import { getEventUID } from "../../helpers/nostr/event";
import { NostrEvent } from "../../types/nostr-event";
import CommunityCard from "../communities/components/community-card";
import useSubject from "../../hooks/use-subject";
import { usePeopleListContext } from "../../providers/local/people-list-provider";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
function CommunityResult({ community }: { community: NostrEvent }) {
const ref = useEventIntersectionRef(community);
return (
<div ref={ref}>
<CommunityCard community={community} maxW="xl" />
</div>
);
}
export default function CommunitySearchResults({ search }: { search: string }) {
const searchRelays = useAdditionalRelayContext();
const { listId, filter } = usePeopleListContext();
const timeline = useTimelineLoader(
`${listId ?? "global"}-${search}-community-search`,
searchRelays,
search ? { search: search, kinds: [COMMUNITY_DEFINITION_KIND], ...filter } : undefined,
);
const communities = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<IntersectionObserverProvider callback={callback}>
{communities.map((community) => (
<CommunityResult key={getEventUID(community)} community={community} />
))}
</IntersectionObserverProvider>
</IntersectionObserverProvider>
);
}

View File

@ -0,0 +1,37 @@
import { useMemo } from "react";
import { NostrEvent } from "nostr-tools";
import { Button, Flex, Heading, useDisclosure } from "@chakra-ui/react";
import EmbeddedArticle from "../../../components/embed-event/event-types/embedded-article";
const MAX_ARTICLES = 4;
export default function ArticleSearchResults({ articles }: { articles: NostrEvent[] }) {
const more = useDisclosure();
const filtered = useMemo(
() => (more.isOpen ? articles : Array.from(articles).slice(0, MAX_ARTICLES)),
[articles, more.isOpen],
);
return (
<>
<Flex justifyContent="space-between" gap="2" alignItems="flex-end">
<Heading size="md">Articles ({articles.length})</Heading>
{articles.length > MAX_ARTICLES && (
<Button size="sm" variant="ghost" onClick={more.onToggle}>
Show {more.isOpen ? "less" : "all"}
</Button>
)}
</Flex>
{filtered.map((article) => (
<EmbeddedArticle key={article.id} article={article} />
))}
{!more.isOpen && articles.length > MAX_ARTICLES && (
<Button mx="auto" size="lg" variant="ghost" onClick={more.onOpen} px="10">
Show all
</Button>
)}
</>
);
}

View File

@ -0,0 +1,34 @@
import { useMemo } from "react";
import { NostrEvent } from "nostr-tools";
import { Button, Flex, Heading, useDisclosure } from "@chakra-ui/react";
import { TimelineNote } from "../../../components/note/timeline-note";
const MAX_NOTES = 4;
export default function NoteSearchResults({ notes }: { notes: NostrEvent[] }) {
const more = useDisclosure();
const filtered = useMemo(() => (more.isOpen ? notes : Array.from(notes).slice(0, MAX_NOTES)), [notes, more.isOpen]);
return (
<>
<Flex justifyContent="space-between" gap="2" alignItems="flex-end">
<Heading size="md">Notes ({notes.length})</Heading>
{notes.length > MAX_NOTES && (
<Button size="sm" variant="ghost" onClick={more.onToggle}>
Show {more.isOpen ? "less" : "all"}
</Button>
)}
</Flex>
{filtered.map((note) => (
<TimelineNote key={note.id} event={note} />
))}
{!more.isOpen && notes.length > MAX_NOTES && (
<Button mx="auto" size="lg" variant="ghost" onClick={more.onOpen} px="10">
Show all
</Button>
)}
</>
);
}

View File

@ -0,0 +1,87 @@
import { useEffect, useMemo, useState } from "react";
import { useAsync, useStartTyping } from "react-use";
import { nip19, NostrEvent } from "nostr-tools";
import { Button, ButtonGroup, Flex, LinkBox, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import UserAvatar from "../../../components/user/user-avatar";
import UserDnsIdentity from "../../../components/user/user-dns-identity";
import trustedUserStatsService from "../../../services/trusted-user-stats";
import { readablizeSats } from "../../../helpers/bolt11";
import replaceableEventsService from "../../../services/replaceable-events";
import UserAbout from "../../../components/user/user-about";
import UserName from "../../../components/user/user-name";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import { useWebOfTrust } from "../../../providers/global/web-of-trust-provider";
function ProfileResult({ profile }: { profile: NostrEvent }) {
useEffect(() => replaceableEventsService.handleEvent(profile), [profile.id]);
const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(profile.pubkey), [profile.pubkey]);
return (
<Flex
as={LinkBox}
overflow="hidden"
direction="column"
borderWidth={1}
rounded="md"
p="2"
flexShrink={0}
maxW="xs"
minW="48"
>
<Flex gap="2" overflow="hidden">
<UserAvatar pubkey={profile.pubkey} float="left" />
<Flex direction="column" overflow="hidden">
<HoverLinkOverlay as={RouterLink} to={"/u/" + nip19.npubEncode(profile.pubkey)}>
<UserName pubkey={profile.pubkey} fontSize="xl" isTruncated />
</HoverLinkOverlay>
<UserDnsIdentity pubkey={profile.pubkey} isTruncated />
</Flex>
</Flex>
<UserAbout pubkey={profile.pubkey} noOfLines={3} isTruncated />
{stats && (
<>{stats.followers_pubkey_count && <Text>Followers: {readablizeSats(stats.followers_pubkey_count)}</Text>}</>
)}
</Flex>
);
}
export default function ProfileSearchResults({ profiles }: { profiles: NostrEvent[] }) {
const [order, setOrder] = useState("relay");
const graph = useWebOfTrust();
const sorted = useMemo(() => {
switch (order) {
case "trust":
return graph?.sortByDistanceAndConnections(profiles, (p) => p.pubkey) || profiles;
default:
case "relay":
return profiles;
}
}, [order, profiles, graph]);
return (
<>
<Flex gap="2">
<Text>Order By:</Text>
<ButtonGroup size="xs">
<Button variant={order === "relay" ? "solid" : "outline"} onClick={() => setOrder("relay")}>
Relay order
</Button>
{graph && (
<Button variant={order === "trust" ? "solid" : "outline"} onClick={() => setOrder("trust")}>
Trust
</Button>
)}
</ButtonGroup>
</Flex>
<Flex gap="2" overflowY="hidden" overflowX="auto" w="full" px="2" pb="2">
{sorted.map((profile) => (
<ProfileResult key={profile.pubkey} profile={profile} />
))}
</Flex>
</>
);
}

View File

@ -0,0 +1,117 @@
import { useEffect, useMemo, useState } from "react";
import { Filter, kinds, NostrEvent } from "nostr-tools";
import { AbstractRelay, Subscription, SubscriptionParams } from "nostr-tools/abstract-relay";
import { Alert, AlertDescription, AlertIcon, AlertTitle, Heading, Spinner, Text } from "@chakra-ui/react";
import relayPoolService from "../../../services/relay-pool";
import ProfileSearchResults from "./profile-results";
import NoteSearchResults from "./note-results";
import ArticleSearchResults from "./article-results";
function createSearchAction(url: string | AbstractRelay) {
let sub: Subscription | undefined = undefined;
let running = true;
const search = async (filters: Filter[], params: Partial<SubscriptionParams>) => {
running = true;
const relay = typeof url === "string" ? await relayPoolService.requestRelay(url, false) : url;
await relayPoolService.requestConnect(relay);
sub = relay.subscribe(filters, {
onevent: (event) => running && params.onevent?.(event),
oneose: () => {
sub?.close();
params.oneose?.();
},
onclose: params.onclose,
});
};
const cancel = () => {
running = false;
if (sub) sub.close();
};
return { search, cancel };
}
export default function SearchResults({ query, relay }: { query: string; relay: string | AbstractRelay }) {
const [results, setResults] = useState<NostrEvent[]>([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState<Error>();
const search = useMemo(() => createSearchAction(relay), [relay]);
useEffect(() => {
if (query.length < 3) return;
setResults([]);
setError(undefined);
setSearching(true);
search
.search(
[
{ search: query, kinds: [kinds.Metadata], limit: 50 },
{ search: query, kinds: [kinds.ShortTextNote], limit: 100 },
{ search: query, kinds: [kinds.LongFormArticle], limit: 20 },
],
{
onevent: (event) => setResults((arr) => [...arr, event]),
oneose: () => setSearching(false),
},
)
.catch((err) => setError(err));
return () => search.cancel();
}, [query, search]);
const profiles = results.filter((e) => e.kind === kinds.Metadata);
const notes = results.filter((e) => e.kind === kinds.ShortTextNote);
const articles = results.filter((e) => e.kind === kinds.LongFormArticle);
if (searching && results.length === 0) {
return (
<Heading size="md" mx="auto" my="10">
<Spinner /> Searching relay...
</Heading>
);
}
if (error) {
return (
<Alert
status="error"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
height="200px"
>
<AlertIcon boxSize="40px" mr={0} />
<AlertTitle mt={4} mb={1} fontSize="lg">
{error.name}
</AlertTitle>
<AlertDescription maxWidth="sm" whiteSpace="pre">
{error.message}
</AlertDescription>
</Alert>
);
}
if (results.length === 0) {
return (
<Heading size="md" mx="auto" my="10">
Found nothing... :(
</Heading>
);
}
return (
<>
{results.length > 0 && <Text>Found {results.length} results</Text>}
{profiles.length > 0 && <ProfileSearchResults profiles={profiles} />}
{notes.length > 0 && <NoteSearchResults notes={notes} />}
{articles.length > 0 && <ArticleSearchResults articles={articles} />}
</>
);
}

View File

@ -1,55 +1,48 @@
import { useCallback, useEffect, useState } from "react";
import { Button, ButtonGroup, Flex, IconButton, Input, Link } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { useCallback } from "react";
import { ButtonGroup, Flex, IconButton, Input, Select } from "@chakra-ui/react";
import { useNavigate, useSearchParams, Link as RouterLink } from "react-router-dom";
import { SEARCH_RELAYS } from "../../const";
import { safeDecode } from "../../helpers/nip19";
import { getMatchHashtag } from "../../helpers/regexp";
import { CommunityIcon, CopyToClipboardIcon, NotesIcon } from "../../components/icons";
import { CopyToClipboardIcon, SearchIcon, SettingsIcon } from "../../components/icons";
import VerticalPageLayout from "../../components/vertical-page-layout";
import User01 from "../../components/icons/user-01";
import Feather from "../../components/icons/feather";
import ProfileSearchResults from "./profile-results";
import NoteSearchResults from "./note-results";
import ArticleSearchResults from "./article-results";
import CommunitySearchResults from "./community-results";
import PeopleListProvider from "../../providers/local/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import useRouteSearchValue from "../../hooks/use-route-search-value";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import QRCodeScannerButton from "../../components/qr-code/qr-code-scanner-button";
import { AdditionalRelayProvider } from "../../providers/local/additional-relay-context";
import { useForm } from "react-hook-form";
import SearchResults from "./components/search-results";
import useSearchRelays from "../../hooks/use-search-relays";
export function SearchPage() {
const navigate = useNavigate();
const searchRelays = useSearchRelays();
const autoFocusSearch = useBreakpointValue({ base: false, lg: true });
const typeParam = useRouteSearchValue("type", "users");
const queryParam = useRouteSearchValue("q", "");
const [params, setParams] = useSearchParams();
const searchQuery = params.get("q") || "";
const searchRelay = params.get("relay") || searchRelays[0];
const [searchInput, setSearchInput] = useState(queryParam.value);
// update the input value when search changes
useEffect(() => {
setSearchInput(queryParam.value);
}, [queryParam.value]);
const { register, handleSubmit } = useForm({
defaultValues: { query: searchQuery, relay: searchRelay },
mode: "all",
});
const handleSearchText = (text: string) => {
const cleanText = text.trim();
if (cleanText.startsWith("nostr:") || cleanText.startsWith("web+nostr:") || safeDecode(text)) {
navigate({ pathname: "/l/" + encodeURIComponent(text) }, { replace: true });
return;
return true;
}
const hashTagMatch = getMatchHashtag().exec(cleanText);
if (hashTagMatch) {
navigate({ pathname: "/t/" + hashTagMatch[2].toLocaleLowerCase() }, { replace: true });
return;
return true;
}
queryParam.setValue(cleanText);
return false;
};
const readClipboard = useCallback(async () => {
@ -57,89 +50,60 @@ export function SearchPage() {
}, []);
// set the search when the form is submitted
const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
handleSearchText(searchInput);
};
const submit = handleSubmit((values) => {
if (!handleSearchText(values.query)) {
const newParams = new URLSearchParams(params);
newParams.set("q", values.query);
newParams.set("relay", values.relay);
setParams(newParams);
}
});
let SearchResults = ProfileSearchResults;
switch (typeParam.value) {
case "users":
SearchResults = ProfileSearchResults;
break;
case "notes":
SearchResults = NoteSearchResults;
break;
case "articles":
SearchResults = ArticleSearchResults;
break;
case "communities":
SearchResults = CommunitySearchResults;
break;
}
const shouldSearch = searchQuery && searchRelay;
return (
<VerticalPageLayout>
<form onSubmit={handleSubmit}>
<Flex gap="2" wrap="wrap">
<Flex gap="2" grow={1}>
<QRCodeScannerButton onData={handleSearchText} />
{!!navigator.clipboard?.readText && (
<IconButton onClick={readClipboard} icon={<CopyToClipboardIcon />} aria-label="Read clipboard" />
)}
<Input
type="search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
autoFocus={autoFocusSearch}
<Flex as="form" gap="2" wrap="wrap" onSubmit={submit}>
<ButtonGroup>
<QRCodeScannerButton onData={handleSearchText} />
{!!navigator.clipboard?.readText && (
<IconButton
onClick={readClipboard}
icon={<CopyToClipboardIcon boxSize={5} />}
aria-label="Read clipboard"
/>
<Button type="submit">Search</Button>
</Flex>
</Flex>
</form>
<Flex gap="2">
<PeopleListSelection size="sm" />
<ButtonGroup size="sm" isAttached variant="outline" flexWrap="wrap">
<Button
leftIcon={<User01 />}
colorScheme={typeParam.value === "users" ? "primary" : undefined}
onClick={() => typeParam.setValue("users")}
>
Users
</Button>
<Button
leftIcon={<NotesIcon />}
colorScheme={typeParam.value === "notes" ? "primary" : undefined}
onClick={() => typeParam.setValue("notes")}
>
Notes
</Button>
<Button
leftIcon={<Feather />}
colorScheme={typeParam.value === "articles" ? "primary" : undefined}
onClick={() => typeParam.setValue("articles")}
>
Articles
</Button>
<Button
leftIcon={<CommunityIcon />}
colorScheme={typeParam.value === "communities" ? "primary" : undefined}
onClick={() => typeParam.setValue("communities")}
>
Communities
</Button>
)}
</ButtonGroup>
<Input
type="search"
isRequired
autoFocus={autoFocusSearch}
w="auto"
flexGrow={1}
{...register("query", { required: true, minLength: 3 })}
autoComplete="off"
/>
<Select w="auto" {...register("relay")}>
{searchRelays.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</Select>
<ButtonGroup>
<IconButton type="submit" aria-label="Search" icon={<SearchIcon boxSize={5} />} colorScheme="primary" />
<IconButton
as={RouterLink}
type="button"
aria-label="Advanced"
icon={<SettingsIcon boxSize={5} />}
to="/relays/search"
/>
</ButtonGroup>
</Flex>
<Flex direction="column" gap="4">
{queryParam.value ? (
<SearchResults search={queryParam.value} />
) : (
<Link isExternal href="https://nostr.band" color="blue.500" mx="auto">
Advanced Search
</Link>
)}
<Flex direction="column" gap="2">
{shouldSearch ? <SearchResults relay={searchRelay} query={searchQuery} /> : null}
</Flex>
</VerticalPageLayout>
);
@ -147,10 +111,8 @@ export function SearchPage() {
export default function SearchView() {
return (
<AdditionalRelayProvider relays={SEARCH_RELAYS}>
<PeopleListProvider initList="global">
<SearchPage />
</PeopleListProvider>
</AdditionalRelayProvider>
<PeopleListProvider initList="global">
<SearchPage />
</PeopleListProvider>
);
}

View File

@ -1,27 +0,0 @@
import { kinds } from "nostr-tools";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import GenericNoteTimeline from "../../components/timeline-page/generic-note-timeline";
import { usePeopleListContext } from "../../providers/local/people-list-provider";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
export default function NoteSearchResults({ search }: { search: string }) {
const searchRelays = useAdditionalRelayContext();
const { listId, filter } = usePeopleListContext();
const timeline = useTimelineLoader(
`${listId ?? "global"}-${search}-note-search`,
searchRelays,
search ? { search: search, kinds: [kinds.ShortTextNote], ...filter } : undefined,
);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<GenericNoteTimeline timeline={timeline} />
</IntersectionObserverProvider>
);
}

View File

@ -1,92 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Box, Text } from "@chakra-ui/react";
import { useAsync } from "react-use";
import { kinds } from "nostr-tools";
import { NostrEvent } from "../../types/nostr-event";
import { parseMetadataContent } from "../../helpers/nostr/user-metadata";
import { readablizeSats } from "../../helpers/bolt11";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import UserAvatar from "../../components/user/user-avatar";
import UserDnsIdentity from "../../components/user/user-dns-identity";
import { embedNostrLinks, renderGenericUrl } from "../../components/external-embeds";
import UserLink from "../../components/user/user-link";
import trustedUserStatsService, { NostrBandUserStats } from "../../services/trusted-user-stats";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import { usePeopleListContext } from "../../providers/local/people-list-provider";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
function ProfileResult({ profile }: { profile: NostrEvent }) {
const metadata = parseMetadataContent(profile);
const aboutContent = useMemo(() => {
if (!metadata.about) return null;
let content: EmbedableContent = [metadata.about.trim()];
content = embedNostrLinks(content);
content = embedUrls(content, [renderGenericUrl]);
return content;
}, [metadata.about]);
const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(profile.pubkey), [profile.pubkey]);
return (
<Box>
<UserAvatar pubkey={profile.pubkey} noProxy mr="2" float="left" />
<UserLink pubkey={profile.pubkey} fontWeight="bold" fontSize="xl" isTruncated />
<br />
<UserDnsIdentity pubkey={profile.pubkey} />
<br />
<Box whiteSpace="pre" overflow="hidden" maxH="xs" isTruncated>
{aboutContent}
</Box>
{stats && (
<>{stats.followers_pubkey_count && <Text>Followers: {readablizeSats(stats.followers_pubkey_count)}</Text>}</>
)}
</Box>
);
}
export default function ProfileSearchResults({ search }: { search: string }) {
const searchRelays = useAdditionalRelayContext();
const { listId, filter } = usePeopleListContext();
const timeline = useTimelineLoader(
`${listId ?? "global"}-${search}-profile-search`,
searchRelays,
search ? { search: search, kinds: [kinds.Metadata], ...filter } : undefined,
);
const profiles = useSubject(timeline?.timeline) ?? [];
const [profileStats, setProfileStats] = useState<Record<string, NostrBandUserStats>>({});
useEffect(() => {
for (const profile of profiles) {
trustedUserStatsService.getUserStats(profile.pubkey).then((stats) => {
if (!stats) return;
setProfileStats((dir) => ({ ...dir, [stats.pubkey]: stats }));
});
}
}, [profiles]);
const sortedProfiles = useMemo(() => {
return profiles.sort(
(a, b) =>
(profileStats[b.pubkey]?.followers_pubkey_count ?? 0) - (profileStats[a.pubkey]?.followers_pubkey_count ?? 0),
);
}, [profileStats, profiles]);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
{sortedProfiles.map((event) => (
<ProfileResult key={event.id} profile={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</IntersectionObserverProvider>
);
}

View File

@ -85,7 +85,7 @@ function DesktopStreamPage({ stream }: { stream: ParsedStream }) {
return (
<VerticalPageLayout>
<Flex gap="2" alignItems="center">
<Button onClick={() => navigate(-1)} leftIcon={<ChevronLeftIcon />}>
<Button onClick={() => navigate(-1)} leftIcon={<ChevronLeftIcon boxSize={6} />}>
Back
</Button>
<UserAvatarLink pubkey={stream.host} size="sm" display={{ base: "none", md: "block" }} />

View File

@ -9,7 +9,7 @@ import { Subscription, getEventUID } from "nostr-idb";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useRouteSearchValue from "../../hooks/use-route-search-value";
import { subscribeMany } from "../../helpers/relay";
import { SEARCH_RELAYS, WIKI_RELAYS } from "../../const";
import { DEFAULT_SEARCH_RELAYS, WIKI_RELAYS } from "../../const";
import { WIKI_PAGE_KIND } from "../../helpers/nostr/wiki";
import { localRelay } from "../../services/local-relay";
import WikiPageResult from "./components/wiki-page-result";
@ -41,7 +41,7 @@ export default function WikiSearchView() {
seen.add(getEventUID(event));
};
const remoteSearchSub = subscribeMany([...SEARCH_RELAYS, ...WIKI_RELAYS], [filter], {
const remoteSearchSub = subscribeMany([...DEFAULT_SEARCH_RELAYS, ...WIKI_RELAYS], [filter], {
onevent: handleEvent,
oneose: () => remoteSearchSub.close(),
});