mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 21:31:43 +01:00
rebuild search view
add support for NIP-51 search relays
This commit is contained in:
parent
7ebf09c24f
commit
c137b3d765
5
.changeset/nasty-chairs-kick.md
Normal file
5
.changeset/nasty-chairs-kick.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for NIP-51 search relay list
|
@ -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 /> },
|
||||
|
@ -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));
|
||||
|
@ -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 (
|
||||
|
24
src/components/user/user-about.tsx
Normal file
24
src/components/user/user-about.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
14
src/hooks/use-search-relays.ts
Normal file
14
src/hooks/use-search-relays.ts
Normal 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;
|
||||
}
|
12
src/hooks/use-user-search-relay-list.ts
Normal file
12
src/hooks/use-user-search-relay-list.ts
Normal 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);
|
||||
}
|
@ -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>
|
||||
|
@ -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
|
||||
|
166
src/views/relays/search/index.tsx
Normal file
166
src/views/relays/search/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
37
src/views/search/components/article-results.tsx
Normal file
37
src/views/search/components/article-results.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
34
src/views/search/components/note-results.tsx
Normal file
34
src/views/search/components/note-results.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
87
src/views/search/components/profile-results.tsx
Normal file
87
src/views/search/components/profile-results.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
117
src/views/search/components/search-results.tsx
Normal file
117
src/views/search/components/search-results.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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" }} />
|
||||
|
@ -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(),
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user