setup simple person search using nostr.band

add heavy qr scanner component
This commit is contained in:
hzrd149
2023-03-14 12:24:53 -05:00
parent f3d36a4208
commit 13dc63ab99
10 changed files with 287 additions and 8 deletions

View File

@@ -24,6 +24,7 @@
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-hook-form": "^7.43.1",
"react-qr-barcode-scanner": "^1.0.6",
"react-router-dom": "^6.5.0",
"react-singleton-hook": "^4.0.1",
"react-use": "^17.4.0",

View File

@@ -1,4 +1,4 @@
import { Suspense } from "react";
import React, { Suspense } from "react";
import { createBrowserRouter, Navigate, Outlet, RouterProvider, useLocation } from "react-router-dom";
import { Button, Flex, Spinner, Text } from "@chakra-ui/react";
import { ErrorBoundary } from "./components/error-boundary";
@@ -32,6 +32,8 @@ import DirectMessagesView from "./views/dm";
import DirectMessageChatView from "./views/dm/chat";
import NostrLinkView from "./views/link";
import UserReportsTab from "./views/user/reports";
// code split search view because QrScanner library is 400kB
const SearchView = React.lazy(() => import("./views/search"));
const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => {
let location = useLocation();
@@ -113,6 +115,7 @@ const router = createBrowserRouter([
{ path: "settings", element: <SettingsView /> },
{ path: "relays", element: <RelaysView /> },
{ path: "notifications", element: <NotificationsView /> },
{ path: "search", element: <SearchView /> },
{ path: "dm", element: <DirectMessagesView /> },
{ path: "dm/:key", element: <DirectMessageChatView /> },
{ path: "profile", element: <ProfileView /> },

View File

@@ -4,7 +4,7 @@ import { Link, useNavigate } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import accountService from "../../services/account";
import { ConnectedRelays } from "../connected-relays";
import { ChatIcon, FeedIcon, LogoutIcon, NotificationIcon, ProfileIcon, RelayIcon } from "../icons";
import { ChatIcon, FeedIcon, LogoutIcon, NotificationIcon, ProfileIcon, RelayIcon, SearchIcon } from "../icons";
import { ProfileButton } from "../profile-button";
import AccountSwitcher from "./account-switcher";
@@ -30,6 +30,9 @@ export default function DesktopSideNav() {
<Button onClick={() => navigate("/dm")} leftIcon={<ChatIcon />}>
Messages
</Button>
<Button onClick={() => navigate("/search")} leftIcon={<SearchIcon />}>
Search
</Button>
<Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}>
Profile
</Button>

View File

@@ -15,7 +15,6 @@ export default function MobileBottomNav() {
onClick={() => navigate(`/search`)}
flexGrow="1"
size="md"
disabled
/>
<IconButton icon={<ChatIcon />} aria-label="Messages" onClick={() => navigate(`/dm`)} flexGrow="1" size="md" />
<IconButton

View File

@@ -0,0 +1,40 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay, ModalProps } from "@chakra-ui/react";
import { useState } from "react";
import BarcodeScannerComponent from "react-qr-barcode-scanner";
export default function QrScannerModal({
isOpen,
onClose,
onData,
}: { onData: (text: string) => void } & Pick<ModalProps, "isOpen" | "onClose">) {
const [stopStream, setStopStream] = useState(false);
const handleClose = () => {
// Stop the QR Reader stream (fixes issue where the browser freezes when closing the modal) and then dismiss the modal one tick later
setStopStream(true);
setTimeout(() => onClose(), 0);
};
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<ModalOverlay />
<ModalContent>
<ModalBody>
<BarcodeScannerComponent
stopStream={stopStream}
onUpdate={(err, result) => {
if (result && result.getText()) {
handleClose();
// wait for steam to be stopped before returning data
setTimeout(() => onData(result.getText()), 0);
}
}}
/>
</ModalBody>
<ModalFooter>
<Button onClick={handleClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -39,13 +39,23 @@ type FormValues = {
comment: string;
};
export type ZapModalProps = Omit<ModalProps, "children"> & {
event?: NostrEvent;
pubkey: string;
onPaid?: () => void;
initialComment?: string;
initialAmount?: number;
};
export default function ZapModal({
event,
pubkey,
onClose,
onPaid,
initialComment,
initialAmount,
...props
}: { event?: NostrEvent; pubkey: string; onPaid?: () => void } & Omit<ModalProps, "children">) {
}: ZapModalProps) {
const metadata = useUserMetadata(pubkey);
const { requestSignature } = useSigningContext();
const toast = useToast();
@@ -62,8 +72,8 @@ export default function ZapModal({
} = useForm<FormValues>({
mode: "onBlur",
defaultValues: {
amount: 10,
comment: "",
amount: initialAmount ?? 10,
comment: initialComment ?? "",
},
});

View File

@@ -52,7 +52,7 @@ export default function NostrLinkView() {
const cleanLink = rawLink.replace(/(web\+)?nostr:/, "");
const decoded = nip19.decode(cleanLink);
if ((decoded.type = "npub")) return <NpubLinkHandler pubkey={decoded.data as string} />;
if (decoded.type === "npub") return <NpubLinkHandler pubkey={decoded.data as string} />;
if (decoded.type === "nprofile") {
const data = decoded.data as ProfilePointer;
return <NpubLinkHandler pubkey={data.pubkey} relays={data.relays} />;

181
src/views/search/index.tsx Normal file
View File

@@ -0,0 +1,181 @@
import {
Button,
Card,
CardBody,
CardFooter,
CardHeader,
Flex,
Heading,
IconButton,
Input,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useSearchParams, Link as RouterLink, useNavigate } from "react-router-dom";
import { useAsync } from "react-use";
import { LightningIcon, QrCodeIcon } from "../../components/icons";
import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity";
import ZapModal from "../../components/zap-modal";
import { convertTimestampToDate } from "../../helpers/date";
import { truncatedId } from "../../helpers/nostr-event";
import QrScannerModal from "../../components/qr-scanner-modal";
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>;
};
export default function SearchView() {
const navigate = useNavigate();
const { isOpen: donateOpen, onOpen: openDonate, onClose: closeDonate } = useDisclosure();
const { isOpen: qrScannerOpen, onOpen: openScanner, onClose: closeScanner } = useDisclosure();
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(searchParams.get("q") ?? "");
// update the input value when search changes
useEffect(() => {
setSearch(searchParams.get("q") ?? "");
}, [searchParams]);
// set the search when the form is submitted
const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
setSearchParams({ q: search }, { replace: true });
};
// 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 = (text: string) => {
// if its a nostr: link pass it on the the link handler
if (text.startsWith("nostr:")) {
navigate({ pathname: "/nostr-link", search: `q=${text}` }, { replace: true });
} else {
setSearchParams({ q: text }, { replace: true });
}
};
return (
<Flex direction="column" overflowX="hidden" overflowY="auto" height="100%" p="2" gap="2">
<QrScannerModal isOpen={qrScannerOpen} onClose={closeScanner} onData={handleQrCodeData} />
<form onSubmit={handleSubmit}>
<Flex gap="2">
<IconButton onClick={openScanner} icon={<QrCodeIcon />} aria-label="Qr Scanner" />
<Input type="search" value={search} onChange={(e) => setSearch(e.target.value)} />
<Button type="submit" isLoading={loading}>
Search
</Button>
</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}
/>
)}
</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: {convertTimestampToDate(person.first_tm).toLocaleDateString()}</Text>
</CardFooter>
</Card>
))}
</Flex>
</Flex>
);
}

View File

@@ -1,4 +1,4 @@
import { Flex, Heading, SkeletonText, Text, Link, IconButton } from "@chakra-ui/react";
import { Flex, Heading, SkeletonText, Text, Link, IconButton, Image } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { useMemo } from "react";
import { useNavigate, Link as RouterLink } from "react-router-dom";

View File

@@ -2331,6 +2331,15 @@
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/react@^16.9.35":
version "16.14.35"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.35.tgz#9d3cf047d85aca8006c4776693124a5be90ee429"
integrity sha512-NUEiwmSS1XXtmBcsm1NyRRPYjoZF2YTE89/5QiLt5mlGffYK9FQqOKuOLuXNrjPQV04oQgaZG+Yq02ZfHoFyyg==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@@ -2374,6 +2383,20 @@
resolved "https://registry.yarnpkg.com/@zag-js/focus-visible/-/focus-visible-0.1.0.tgz#9777bbaff8316d0b3a14a9095631e1494f69dbc7"
integrity sha512-PeaBcTmdZWcFf7n1aM+oiOdZc+sy14qi0emPIeUuGMTjbP0xLGrZu43kdpHnWSXy7/r4Ubp/vlg50MCV8+9Isg==
"@zxing/library@^0.17.0":
version "0.17.1"
resolved "https://registry.yarnpkg.com/@zxing/library/-/library-0.17.1.tgz#4c82bf401391c2b79bfbab0a6b1143da6d8feb1a"
integrity sha512-RuiBZuteGaFXCle/b0X+g3peN8UpDc3pGe/J7hZBzKWaMZLbjensR7ja3vy47xWhXU4e8MICGqegPMxc2V2sow==
dependencies:
ts-custom-error "^3.0.0"
optionalDependencies:
"@zxing/text-encoding" "~0.9.0"
"@zxing/text-encoding@~0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"
integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==
acorn@^8.5.0:
version "8.8.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73"
@@ -3673,6 +3696,15 @@ react-is@^16.13.1, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-qr-barcode-scanner@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/react-qr-barcode-scanner/-/react-qr-barcode-scanner-1.0.6.tgz#1df7ac3f3cb839ad673e8b619e0e93b4bdddc4e3"
integrity sha512-DdalO4oqHyxWPa4cIjiHeMS19HbIvKq+oo/PAglAsxmfhAUGC8sM1mJnzo0zPQM1yw9ZNpjrtqHz+rs86Mu7Ww==
dependencies:
"@types/react" "^16.9.35"
"@zxing/library" "^0.17.0"
react-webcam "^5.0.1"
react-refresh@^0.14.0:
version "0.14.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"
@@ -3751,6 +3783,11 @@ react-use@^17.4.0:
ts-easing "^0.2.0"
tslib "^2.1.0"
react-webcam@^5.0.1:
version "5.2.4"
resolved "https://registry.yarnpkg.com/react-webcam/-/react-webcam-5.2.4.tgz#714b4460ea43ac7ed081824299cd2a580f764478"
integrity sha512-Qqj14t68Ke1eoEYjFde+N48HtuIJg0ePIQRpFww9eZt5oBcDpe/l60h+m3VRFJAR5/E3dOhSU5R8EJEcdCq/Eg==
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
@@ -4132,6 +4169,11 @@ tr46@^1.0.1:
dependencies:
punycode "^2.1.0"
ts-custom-error@^3.0.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/ts-custom-error/-/ts-custom-error-3.3.1.tgz#8bd3c8fc6b8dc8e1cb329267c45200f1e17a65d1"
integrity sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==
ts-easing@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec"