mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-06 02:48:33 +02:00
rebuild search view to use NIP-50
This commit is contained in:
parent
393c9d2214
commit
c7d9a04767
.changeset
src
components
types
views
search
streams
user
5
.changeset/eighty-phones-switch.md
Normal file
5
.changeset/eighty-phones-switch.md
Normal file
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user