rebuild search view to use NIP-50

This commit is contained in:
hzrd149 2023-08-01 20:52:36 -05:00
parent 393c9d2214
commit c7d9a04767
11 changed files with 150 additions and 176 deletions
.changeset
src
components
note
timeline-page/generic-note-timeline
types
views

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild search view to use NIP-50

@ -17,7 +17,7 @@ import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
import { NoteMenu } from "./note-menu";
import { NoteRelays } from "./note-relays";
import { EventRelays } from "./note-relays";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { UserLink } from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
@ -90,7 +90,7 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
target="_blank"
/>
)}
<NoteRelays event={event} />
<EventRelays event={event} />
<NoteMenu event={event} size="sm" variant="link" aria-label="More Options" />
</CardFooter>
</Card>

@ -10,7 +10,7 @@ export type NoteRelaysProps = {
event: NostrEvent;
};
export const NoteRelays = memo(({ event }: NoteRelaysProps) => {
export const EventRelays = memo(({ event }: NoteRelaysProps) => {
const isMobile = useIsMobile();
const eventRelays = useSubject(getEventRelays(getEventUID(event)));

@ -23,7 +23,7 @@ import { useRegisterIntersectionEntity } from "../../../providers/intersection-o
import { UserAvatar } from "../../user-avatar";
import { UserLink } from "../../user-link";
import StreamStatusBadge from "../../../views/streams/components/status-badge";
import { NoteRelays } from "../../note/note-relays";
import { EventRelays } from "../../note/note-relays";
import { useAsync } from "react-use";
export default function StreamNote({ event, ...props }: CardProps & { event: NostrEvent }) {
@ -69,7 +69,7 @@ export default function StreamNote({ event, ...props }: CardProps & { event: Nos
<CardFooter p="2" display="flex" gap="2" alignItems="center">
<StreamStatusBadge stream={stream} />
<Spacer />
<NoteRelays event={stream.event} />
<EventRelays event={stream.event} />
</CardFooter>
</Card>
);

@ -18,6 +18,7 @@ export type NostrQuery = {
since?: number;
until?: number;
limit?: number;
search?: string;
};
export type NostrRequestFilter = NostrQuery | NostrQuery[];

@ -1,100 +1,129 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Card,
CardBody,
CardFooter,
CardHeader,
Flex,
Heading,
IconButton,
Input,
Text,
Link,
SimpleGrid,
useDisclosure,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams, Link as RouterLink, useNavigate } from "react-router-dom";
import { useAsync } from "react-use";
import { ClipboardIcon, LightningIcon, QrCodeIcon } from "../../components/icons";
import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import ZapModal from "../../components/zap-modal";
import { truncatedId } from "../../helpers/nostr/event";
import { ClipboardIcon, QrCodeIcon } from "../../components/icons";
import QrScannerModal from "../../components/qr-scanner-modal";
import { safeDecode } from "../../helpers/nip19";
import { useInvoiceModalContext } from "../../providers/invoice-modal";
import { matchHashtag } from "../../helpers/regexp";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { Kind, nip19 } from "nostr-tools";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { NostrEvent } from "../../types/nostr-event";
import { getUserDisplayName, parseKind0Event } from "../../helpers/user-metadata";
import { UserAvatar } from "../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import { EventRelays } from "../../components/note/note-relays";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
import { getEventRelays } from "../../services/event-relays";
import relayScoreboardService from "../../services/relay-scoreboard";
type relay = string;
type NostrBandSearchResults = {
query: string;
page: number;
page_size: number;
nip05_count: number;
timeline: any[];
page_count: number;
result_count: number;
serp: any[];
people_count: number;
people: [
{
i: number;
pubkey: string;
name: string;
about: string;
picture: string;
nip05: string;
nip05_verified: boolean;
website: string;
display_name: string;
lud06: string;
lud16: string;
lud06_url: string;
first_tm: number;
last_tm: number;
last_tm_str: string;
followed_count: number;
following_count: number;
zappers: number;
zap_amount: number;
zapped_pubkeys: number;
zap_amount_sent: number;
zap_amount_processed: number;
zapped_pubkeys_processed: number;
zappers_processed: number;
twitter?: {
verified: boolean;
verify_event: string;
handle: string;
name: string;
bio: string;
picture: string;
followers: number;
tweet: string;
};
relays: number[];
}
];
relays: Record<number | string, relay>;
};
function buildDescriptionContent(description: string) {
let content: EmbedableContent = [description.trim()];
export default function SearchView() {
content = embedNostrLinks(content);
content = embedUrls(content, [renderGenericUrl]);
return content;
}
function ProfileResult({ event }: { event: NostrEvent }) {
const metadata = parseKind0Event(event);
const aboutContent = metadata.about && buildDescriptionContent(metadata.about);
const nprofile = useMemo(() => {
const relays = getEventRelays(event.id).value;
const ranked = relayScoreboardService.getRankedRelays(relays).slice(2);
return nip19.nprofileEncode({ pubkey: event.pubkey, relays: ranked });
}, [event.id]);
return (
<Card overflow="hidden" variant="outline" size="sm">
<CardHeader display="flex" gap="4" alignItems="flex-start">
<UserAvatar pubkey={event.pubkey} noProxy />
<Flex alignItems="center" gap="2" overflow="hidden">
<Link as={RouterLink} to={`/u/${nprofile}`} whiteSpace="nowrap" fontWeight="bold" fontSize="xl" isTruncated>
{getUserDisplayName(metadata, event.pubkey)}
</Link>
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
</Flex>
</CardHeader>
<CardBody py={0} overflow="hidden" maxH="20rem">
{aboutContent && (
<Box whiteSpace="pre" isTruncated>
{aboutContent}
</Box>
)}
</CardBody>
<CardFooter>
<EventRelays event={event} />
</CardFooter>
</Card>
);
}
function SearchResults({ search }: { search: string }) {
const searchRelays = useRelaySelectionRelays();
const timeline = useTimelineLoader(
`search`,
searchRelays,
{ search: search || "", kinds: [Kind.Metadata] },
{ enabled: !!search }
);
const events = useSubject(timeline?.timeline) ?? [];
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<SimpleGrid minChildWidth="30rem" spacing="2">
{events.map((event) => (
<ProfileResult key={event.id} event={event} />
))}
</SimpleGrid>
<TimelineActionAndStatus timeline={timeline} />
</IntersectionObserverProvider>
);
}
export function SearchPage() {
const navigate = useNavigate();
const { isOpen: donateOpen, onOpen: openDonate, onClose: closeDonate } = useDisclosure();
const { isOpen: qrScannerOpen, onOpen: openScanner, onClose: closeScanner } = useDisclosure();
const qrScannerModal = useDisclosure();
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(searchParams.get("q") ?? "");
const { requestPay } = useInvoiceModalContext();
const [searchInput, setSearchInput] = useState(searchParams.get("q") ?? "");
const search = searchParams.get("q");
// update the input value when search changes
useEffect(() => {
setSearch(searchParams.get("q") ?? "");
setSearchInput(searchParams.get("q") ?? "");
}, [searchParams]);
const handleSearchText = (text: string) => {
const cleanText = text.trim();
if (cleanText.startsWith("nostr:") || cleanText.startsWith("web+nostr:") || safeDecode(search)) {
if (cleanText.startsWith("nostr:") || cleanText.startsWith("web+nostr:") || safeDecode(text)) {
navigate({ pathname: "/l/" + encodeURIComponent(text) }, { replace: true });
return;
}
@ -115,92 +144,41 @@ export default function SearchView() {
// set the search when the form is submitted
const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
handleSearchText(search);
handleSearchText(searchInput);
};
// fetch search data from nostr.band
const { value: searchResults, loading } = useAsync(async () => {
if (!searchParams.has("q")) return;
return await fetch(`https://nostr.realsearch.cc/nostr?method=search&count=10&q=${searchParams.get("q")}`).then(
(res) => res.json() as Promise<NostrBandSearchResults>
);
}, [searchParams.get("q")]);
// handle data from qr code scanner
const handleQrCodeData = handleSearchText;
return (
<Flex direction="column" overflowX="hidden" overflowY="auto" height="100%" p="2" gap="2">
<QrScannerModal isOpen={qrScannerOpen} onClose={closeScanner} onData={handleQrCodeData} />
<Flex direction="column" py="2" gap="2">
<QrScannerModal isOpen={qrScannerModal.isOpen} onClose={qrScannerModal.onClose} onData={handleSearchText} />
<form onSubmit={handleSubmit}>
<Flex gap="2">
<IconButton onClick={openScanner} icon={<QrCodeIcon />} aria-label="Qr Scanner" />
<IconButton onClick={qrScannerModal.onOpen} icon={<QrCodeIcon />} aria-label="Qr Scanner" />
{!!navigator.clipboard.readText && (
<IconButton onClick={readClipboard} icon={<ClipboardIcon />} aria-label="Read clipboard" />
)}
<Input type="search" value={search} onChange={(e) => setSearch(e.target.value)} />
<Button type="submit" isLoading={loading}>
Search
</Button>
<Input type="search" value={searchInput} onChange={(e) => setSearchInput(e.target.value)} />
<Button type="submit">Search</Button>
<RelaySelectionButton />
</Flex>
</form>
{searchResults && (
<Flex gap="2" alignItems="center" justifyContent="center">
<Text>Find what you where looking for?</Text>
<Button leftIcon={<LightningIcon color="yellow.400" />} size="sm" onClick={openDonate} flexShrink={0}>
Support Creator
</Button>
{donateOpen && (
<ZapModal
isOpen={donateOpen}
pubkey="3356de61b39647931ce8b2140b2bab837e0810c0ef515bbe92de0248040b8bdd"
initialAmount={500}
initialComment="Thanks for creating nostr.band"
onClose={closeDonate}
onInvoice={async (invoice) => {
closeDonate();
await requestPay(invoice);
}}
/>
)}
</Flex>
)}
<Flex direction="column" gap="2">
{searchResults?.people.map((person) => (
<Card key={person.pubkey} overflow="hidden" variant="outline" size="sm">
<CardHeader display="flex" gap="4" alignItems="flex-start">
<UserAvatarLink pubkey={person.pubkey} />
<Flex alignItems="center" gap="2">
<Heading size="md" overflow="hidden">
{person.name || truncatedId(person.pubkey)}
</Heading>
<UserDnsIdentityIcon pubkey={person.pubkey} onlyIcon />
</Flex>
<Button
as={RouterLink}
variant="solid"
colorScheme="blue"
to={`/u/${person.pubkey}`}
size="sm"
ml="auto"
flexShrink={0}
>
View Profile
</Button>
</CardHeader>
<CardBody py={0}>
<Text>{person.about}</Text>
</CardBody>
<CardFooter display="flex" gap="2">
<Text>{person.followed_count} Followers</Text>
<Text>Created: {dayjs.unix(person.first_tm).toString()}</Text>
</CardFooter>
</Card>
))}
</Flex>
{search && <SearchResults search={search} />}
</Flex>
);
}
// TODO: remove this when there is a good way to allow the user to select from a list of filtered relays that support NIP-50
const searchRelays = ["wss://relay.nostr.band", "wss://search.nos.today"];
export default function SearchView() {
// const { value: searchRelays = ["wss://relay.nostr.band"] } = useAsync(async () => {
// const relays: string[] = await fetch("https://api.nostr.watch/v1/nip/50").then((res) => res.json());
// return relays;
// });
return (
<RelaySelectionProvider overrideDefault={searchRelays}>
<SearchPage />
</RelaySelectionProvider>
);
}

@ -20,7 +20,7 @@ import { UserAvatar } from "../../../components/user-avatar";
import { UserLink } from "../../../components/user-link";
import dayjs from "dayjs";
import StreamStatusBadge from "./status-badge";
import { NoteRelays } from "../../../components/note/note-relays";
import { EventRelays } from "../../../components/note/note-relays";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import useEventNaddr from "../../../hooks/use-event-naddr";
import StreamDebugButton from "./stream-debug-button";
@ -62,7 +62,7 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P
<CardFooter p="2" display="flex" gap="2" alignItems="center">
<StreamStatusBadge stream={stream} />
<Spacer />
<NoteRelays event={stream.event} />
<EventRelays event={stream.event} />
<StreamDebugButton stream={stream} variant="ghost" size="sm" />
</CardFooter>
</Card>

@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Flex, Select } from "@chakra-ui/react";
import { useCallback, useMemo, useState } from "react";
import { Flex, Select, SimpleGrid } from "@chakra-ui/react";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
@ -66,12 +66,12 @@ function StreamsPage() {
<RelaySelectionButton ml="auto" />
</Flex>
<IntersectionObserverProvider callback={callback}>
<Flex gap="2" wrap="wrap">
<SimpleGrid minChildWidth="25rem" spacing="2">
{streams.map((stream) => (
<StreamCard key={stream.event.id} stream={stream} w="sm" />
<StreamCard key={stream.event.id} stream={stream} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</SimpleGrid>
<TimelineActionAndStatus timeline={timeline} />
</IntersectionObserverProvider>
</Flex>
);

@ -100,8 +100,8 @@ export default function UserAboutTab() {
alignItems={isMobile ? "flex-start" : "flex-end"}
>
<UserAvatar pubkey={pubkey} size={isMobile ? "lg" : "xl"} noProxy />
<Box>
<Heading>{getUserDisplayName(metadata, pubkey)}</Heading>
<Box overflow="hidden">
<Heading isTruncated>{getUserDisplayName(metadata, pubkey)}</Heading>
<UserDnsIdentityIcon pubkey={pubkey} />
</Box>
</Flex>
@ -115,19 +115,7 @@ export default function UserAboutTab() {
position="absolute"
/>
</Box>
{aboutContent && (
<Text whiteSpace="pre-wrap" px="2">
{aboutContent.map((part, i) =>
typeof part === "string" ? (
<Text as="span" key={"part-" + i}>
{part}
</Text>
) : (
React.cloneElement(part, { key: "part-" + i })
)
)}
</Text>
)}
{aboutContent && <Box whiteSpace="pre">{aboutContent}</Box>}
<Flex gap="2" px="2" direction="column">
{metadata?.lud16 && (

@ -31,7 +31,9 @@ export default function Header({
<Flex direction="column" gap="2" px="2" pt="2">
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" noProxy mr="2" />
<Heading size="md">{getUserDisplayName(metadata, pubkey)}</Heading>
<Heading size="md" isTruncated>
{getUserDisplayName(metadata, pubkey)}
</Heading>
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon={isMobile} />
<Spacer />
{isSelf && (

@ -1,5 +1,5 @@
import { Box, Flex, FlexProps, Heading, Input, Link } from "@chakra-ui/react";
import { Link as ReactRouterLink } from "react-router-dom";
import { Flex, FlexProps, Heading, Link } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../../helpers/user-metadata";
@ -28,7 +28,7 @@ export const UserCard = ({ pubkey, relay, ...props }: UserCardProps) => {
>
<UserAvatar pubkey={pubkey} />
<Flex direction="column" flex={1} overflow="hidden">
<Link as={ReactRouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<Link as={RouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<Heading size="sm" whiteSpace="nowrap" isTruncated>
{getUserDisplayName(metadata, pubkey)}
</Heading>