Merge branch 'cleanup' into next

This commit is contained in:
hzrd149 2023-06-08 18:03:30 -04:00
commit b4878c5fce
32 changed files with 622 additions and 181 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Create about tab in profile view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Virtulize following and followers tabs in profile view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add profile stats from nostr.band

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix redirect not working on login view

View File

@ -1,5 +1,5 @@
describe("Profile view", () => {
it("should load user on single relay", () => {
it("should load a rss feed profile", () => {
cy.visit(
"#/u/nprofile1qqsp6hxqjatvxtesgszs8aee0fcjccxa3ef3mzjva4uv2yr5lucp6jcpzemhxue69uhhyumnd3shjtnwdaehgu3wd4hk2s8c5un"
);
@ -7,4 +7,10 @@ describe("Profile view", () => {
cy.contains("fjsmu");
cy.contains("https://rsshub.app/pixiv/user/7569500@rsslay.nostr.moe");
});
it("should load a rss feed fiatjef", () => {
cy.visit("#/u/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft");
cy.contains("npub1l2vyh...3afqutajft");
});
});

View File

@ -30,16 +30,19 @@
"react-router-dom": "^6.11.2",
"react-singleton-hook": "^4.0.1",
"react-use": "^17.4.0",
"react-virtualized-auto-sizer": "^1.0.20",
"react-window": "^1.8.9",
"webln": "^0.3.2"
},
"devDependencies": {
"@testing-library/cypress": "^9.0.0",
"cypress": "^12.13.0",
"@changesets/cli": "^2.26.1",
"@testing-library/cypress": "^9.0.0",
"@types/identicon.js": "^2.3.1",
"@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4",
"@types/react-window": "^1.8.5",
"@vitejs/plugin-react": "^4.0.0",
"cypress": "^12.13.0",
"prettier": "^2.8.8",
"typescript": "^5.0.4",
"vite": "^4.3.8",

View File

@ -34,6 +34,7 @@ import appSettings from "./services/app-settings";
import UserMediaTab from "./views/user/media";
import ToolsHomeView from "./views/tools";
import Nip19ToolsView from "./views/tools/nip19";
import UserAboutTab from "./views/user/about";
// code split search view because QrScanner library is 400kB
const SearchView = React.lazy(() => import("./views/search"));
@ -64,7 +65,8 @@ const router = createHashRouter([
path: "/u/:pubkey",
element: <UserView />,
children: [
{ path: "", element: <UserNotesTab /> },
{ path: "", element: <UserAboutTab /> },
{ path: "about", element: <UserAboutTab /> },
{ path: "notes", element: <UserNotesTab /> },
{ path: "media", element: <UserMediaTab /> },
{ path: "zaps", element: <UserZapsTab /> },

View File

@ -43,7 +43,7 @@ export function renderVideoUrl(match: URL) {
return <video src={match.toString()} controls style={{ maxWidth: "30rem", maxHeight: "20rem" }} />;
}
export function renderDefaultUrl(match: URL) {
export function renderGenericUrl(match: URL) {
return (
<Link color="blue.500" href={match.toString()} target="_blank" isExternal>
{match.toString()}

View File

@ -9,7 +9,7 @@ import { Link as RouterLink } from "react-router-dom";
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
export function embedNostrLinks(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
export function embedNostrLinks(content: EmbedableContent) {
return embedJSX(content, {
name: "nostr-link",
regexp: /(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/i,

View File

@ -1,6 +1,6 @@
import { replaceDomain } from "../../helpers/url";
import appSettings from "../../services/app-settings";
import { renderDefaultUrl } from "./common";
import { renderGenericUrl } from "./common";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/reddit.js
const REDDIT_DOMAINS = [
@ -21,5 +21,5 @@ export function renderRedditUrl(match: URL) {
const { redditRedirect } = appSettings.value;
const fixed = redditRedirect ? replaceDomain(match, redditRedirect) : match;
return renderDefaultUrl(fixed);
return renderGenericUrl(fixed);
}

View File

@ -1,21 +1,15 @@
import { replaceDomain } from "../../helpers/url";
import appSettings from "../../services/app-settings";
import { TweetEmbed } from "../tweet-embed";
import { renderDefaultUrl } from "./common";
import { renderGenericUrl } from "./common";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/twitter.js
export const TWITTER_DOMAINS = [
"twitter.com",
"www.twitter.com",
"mobile.twitter.com",
"pbs.twimg.com",
"video.twimg.com",
];
export const TWITTER_DOMAINS = ["twitter.com", "www.twitter.com", "mobile.twitter.com", "pbs.twimg.com"];
export function renderTwitterUrl(match: URL) {
if (!TWITTER_DOMAINS.includes(match.hostname)) return null;
const { twitterRedirect } = appSettings.value;
if (twitterRedirect) return renderDefaultUrl(replaceDomain(match, twitterRedirect));
if (twitterRedirect) return renderGenericUrl(replaceDomain(match, twitterRedirect));
else return <TweetEmbed href={match.toString()} conversation={false} />;
}

View File

@ -247,3 +247,15 @@ export const ToolsIcon = createIcon({
d: "M5.32894 3.27158C6.56203 2.8332 7.99181 3.10749 8.97878 4.09446C10.0997 5.21537 10.3014 6.90741 9.58382 8.23385L20.2925 18.9437L18.8783 20.3579L8.16933 9.64875C6.84277 10.3669 5.1502 10.1654 4.02903 9.04421C3.04178 8.05696 2.76761 6.62665 3.20652 5.39332L5.44325 7.63C6.02903 8.21578 6.97878 8.21578 7.56457 7.63C8.15035 7.04421 8.15035 6.09446 7.56457 5.50868L5.32894 3.27158ZM15.6963 5.15512L18.8783 3.38736L20.2925 4.80157L18.5247 7.98355L16.757 8.3371L14.6356 10.4584L13.2214 9.04421L15.3427 6.92289L15.6963 5.15512ZM8.97878 13.2868L10.393 14.7011L5.08969 20.0044C4.69917 20.3949 4.066 20.3949 3.67548 20.0044C3.31285 19.6417 3.28695 19.0699 3.59777 18.6774L3.67548 18.5902L8.97878 13.2868Z",
defaultProps,
});
export const EditIcon = createIcon({
displayName: "EditIcon",
d: "M6.41421 15.89L16.5563 5.74786L15.1421 4.33365L5 14.4758V15.89H6.41421ZM7.24264 17.89H3V13.6474L14.435 2.21233C14.8256 1.8218 15.4587 1.8218 15.8492 2.21233L18.6777 5.04075C19.0682 5.43128 19.0682 6.06444 18.6777 6.45497L7.24264 17.89ZM3 19.89H21V21.89H3V19.89Z",
defaultProps,
});
export const AtIcon = createIcon({
displayName: "AtIcon",
d: "M20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C13.6418 20 15.1681 19.5054 16.4381 18.6571L17.5476 20.3214C15.9602 21.3818 14.0523 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12V13.5C22 15.433 20.433 17 18.5 17C17.2958 17 16.2336 16.3918 15.6038 15.4659C14.6942 16.4115 13.4158 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7C13.1258 7 14.1647 7.37209 15.0005 8H17V13.5C17 14.3284 17.6716 15 18.5 15C19.3284 15 20 14.3284 20 13.5V12ZM12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9Z",
defaultProps,
});

View File

@ -11,7 +11,7 @@ import {
embedNostrHashtags,
renderWavlakeUrl,
renderYoutubeUrl,
renderDefaultUrl,
renderGenericUrl,
renderImageUrl,
renderTwitterUrl,
renderAppleMusicUrl,
@ -37,14 +37,14 @@ function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) {
renderTidalUrl,
renderImageUrl,
renderVideoUrl,
renderDefaultUrl,
renderGenericUrl,
]);
// bitcoin
content = embedLightningInvoice(content);
// nostr
content = embedNostrLinks(content, event);
content = embedNostrLinks(content);
content = embedNostrMentions(content, event);
content = embedNostrHashtags(content, event);

View File

@ -18,7 +18,7 @@ import { useUserMetadata } from "../../hooks/use-user-metadata";
import accountService from "../../services/account";
import { AddIcon } from "../icons";
import { UserAvatar } from "../user-avatar";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
function AccountItem({ pubkey }: { pubkey: string }) {
const metadata = useUserMetadata(pubkey, []);
@ -53,6 +53,7 @@ export function AccountSwitcherList() {
const navigate = useNavigate();
const accounts = useSubject(accountService.accounts);
const current = useSubject(accountService.current);
const location = useLocation();
const otherAccounts = accounts.filter((acc) => acc.pubkey !== current?.pubkey);

View File

@ -1,5 +1,5 @@
import { Box, Button, LinkBox, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { UserAvatar } from "../user-avatar";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
@ -29,6 +29,7 @@ function ProfileButton() {
export default function ProfileLink() {
const account = useCurrentAccount();
const location = useLocation();
if (account) return <ProfileButton />;
else

View File

@ -1,4 +1,4 @@
import { Spinner, Tooltip } from "@chakra-ui/react";
import { Spinner, Text, Tooltip } from "@chakra-ui/react";
import { useDnsIdentity } from "../hooks/use-dns-identity";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { VerificationFailed, VerificationMissing, VerifiedIcon } from "./icons";
@ -29,8 +29,8 @@ export const UserDnsIdentityIcon = ({ pubkey, onlyIcon }: { pubkey: string; only
return <Tooltip label={metadata.nip05}>{renderIcon()}</Tooltip>;
}
return (
<span>
<Text as="span" whiteSpace="nowrap">
{metadata.nip05} {renderIcon()}
</span>
</Text>
);
};

View File

@ -2,8 +2,9 @@ import { Button, ButtonProps } from "@chakra-ui/react";
import { useCurrentAccount } from "../hooks/use-current-account";
import useSubject from "../hooks/use-subject";
import clientFollowingService from "../services/client-following";
import clientRelaysService from "../services/client-relays";
import { useUserContacts } from "../hooks/use-user-contacts";
import { useReadRelayUrls } from "../hooks/use-client-relays";
import { useAdditionalRelayContext } from "../providers/additional-relay-context";
export const UserFollowButton = ({
pubkey,
@ -13,9 +14,11 @@ export const UserFollowButton = ({
const following = useSubject(clientFollowingService.following) ?? [];
const savingDraft = useSubject(clientFollowingService.savingDraft);
const isFollowing = following.some((t) => t[1] === pubkey);
const readRelays = useReadRelayUrls(useAdditionalRelayContext());
const userContacts = useUserContacts(pubkey, readRelays);
const userContacts = useUserContacts(pubkey, clientRelaysService.getReadUrls());
const isFollowing = following.some((t) => t[1] === pubkey);
const isFollowingMe = account && userContacts?.contacts.includes(account.pubkey);
const toggleFollow = async () => {
if (isFollowing) {
@ -29,13 +32,13 @@ export const UserFollowButton = ({
return (
<Button
colorScheme="brand"
colorScheme={isFollowing ? "orange" : "brand"}
{...props}
isLoading={savingDraft}
onClick={toggleFollow}
isDisabled={account?.readonly ?? true}
>
{isFollowing ? "Unfollow" : account && userContacts?.contacts.includes(account.pubkey) ? "Follow Back" : "Follow"}
{isFollowing ? "Unfollow" : isFollowingMe ? "Follow Back" : "Follow"}
</Button>
);
};

View File

@ -22,6 +22,9 @@ export function parseKind0Event(event: NostrEvent): Kind0ParsedContent {
// ensure nip05 is a string
if (metadata.nip05 && typeof metadata.nip05 !== "string") metadata.nip05 = String(metadata.nip05);
// fix user website
if (metadata.website) metadata.website = fixWebsiteUrl(metadata.website);
return metadata;
} catch (e) {}
return {};

View File

@ -0,0 +1,90 @@
export type NostrBandProfileStats = {
pubkey: string;
pub_note_count: number;
pub_post_count: number;
pub_reply_count: number;
pub_reaction_count: number;
pub_repost_count: number;
pub_report_count: number;
pub_badge_definition_count: number;
pub_long_note_count: number;
pub_note_ref_event_count: number;
pub_note_ref_pubkey_count: number;
pub_reaction_ref_event_count: number;
pub_reaction_ref_pubkey_count: number;
pub_repost_ref_event_count: number;
pub_repost_ref_pubkey_count: number;
pub_report_ref_event_count: number;
pub_report_ref_pubkey_count: number;
pub_mute_ref_pubkey_count: number;
pub_bookmark_ref_event_count: number;
pub_badge_award_ref_pubkey_count: number;
pub_profile_badge_ref_event_count: number;
pub_following_pubkey_count: number;
reaction_count: number;
reaction_pubkey_count: number;
repost_count: number;
repost_pubkey_count: number;
reply_count: number;
reply_pubkey_count: number;
report_count: number;
report_pubkey_count: number;
mute_pubkey_count: number;
followers_pubkey_count: number;
zaps_sent?: {
count: number;
zapper_count: number;
target_event_count: number;
target_pubkey_count: number;
provider_count: number;
msats: number;
min_msats: number;
max_msats: number;
avg_msats: number;
median_msats: number;
};
zaps_received?: {
count: number;
zapper_count: number;
target_event_count: number;
target_pubkey_count: number;
provider_count: number;
msats: number;
min_msats: number;
max_msats: number;
avg_msats: number;
median_msats: number;
};
};
class UserTrustedStatsService {
private userStats = new Map<string, NostrBandProfileStats>();
async fetchUserStats(pubkey: string) {
try {
const stats = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`).then(
(res) => res.json() as Promise<{ stats: Record<string, NostrBandProfileStats> }>
);
if (stats?.stats[pubkey]) {
this.userStats.set(pubkey, stats?.stats[pubkey]);
return stats?.stats[pubkey];
}
} catch (e) {}
}
private dedupe = new Map<string, Promise<NostrBandProfileStats | undefined>>();
async getUserStats(pubkey: string, alwaysFetch = false) {
if (this.userStats.has(pubkey) && !alwaysFetch) return this.userStats.get(pubkey)!;
if (this.dedupe.has(pubkey)) this.dedupe.get(pubkey)!;
const p = this.fetchUserStats(pubkey);
this.dedupe.set(pubkey, p);
p.then(() => this.dedupe.delete(pubkey));
return p;
}
}
const userTrustedStatsService = new UserTrustedStatsService();
export default userTrustedStatsService;

View File

@ -18,7 +18,7 @@ import directMessagesService, { getMessageRecipient } from "../../services/direc
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import DecryptPlaceholder from "./decrypt-placeholder";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { embedNostrLinks, renderDefaultUrl, renderImageUrl, renderVideoUrl } from "../../components/embed-types";
import { embedNostrLinks, renderGenericUrl, renderImageUrl, renderVideoUrl } from "../../components/embed-types";
import RequireCurrentAccount from "../../providers/require-current-account";
function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
@ -26,7 +26,7 @@ function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
content = embedNostrLinks(content, event);
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderDefaultUrl]);
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
return <Box whiteSpace="pre-wrap">{content}</Box>;
}

248
src/views/user/about.tsx Normal file
View File

@ -0,0 +1,248 @@
import React from "react";
import { useNavigate, useOutletContext } from "react-router-dom";
import moment from "moment";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
IconButton,
Image,
Link,
Stat,
StatGroup,
StatHelpText,
StatLabel,
StatNumber,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { useAsync } from "react-use";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { ArrowDownSIcon, ArrowUpSIcon, AtIcon, ExternalLinkIcon, KeyIcon } from "../../components/icons";
import { normalizeToBech32 } from "../../helpers/nip19";
import { Bech32Prefix } from "../../helpers/nip19";
import { truncatedId } from "../../helpers/nostr-event";
import { CopyIconButton } from "../../components/copy-icon-button";
import { QrIconButton } from "./components/share-qr-button";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { convertTimestampToDate } from "../../helpers/date";
import userTrustedStatsService from "../../services/user-trusted-stats";
import { readablizeSats } from "../../helpers/bolt11";
function buildDescriptionContent(description: string) {
let content: EmbedableContent = [description.trim()];
content = embedNostrLinks(content);
content = embedUrls(content, [renderGenericUrl]);
return content;
}
export default function UserAboutTab() {
const navigate = useNavigate();
const expanded = useDisclosure();
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
const metadata = useUserMetadata(pubkey, contextRelays);
const contacts = useUserContacts(pubkey, contextRelays);
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
const { value: stats } = useAsync(() => userTrustedStatsService.getUserStats(pubkey), [pubkey]);
const account = useCurrentAccount();
const isSelf = pubkey === account?.pubkey;
const aboutContent = metadata?.about && buildDescriptionContent(metadata?.about);
return (
<Flex
overflowY="auto"
overflowX="hidden"
direction="column"
gap="2"
pt={metadata?.banner ? 0 : "2"}
pb="8"
h="full"
>
{metadata?.banner && (
<Box
pt={!expanded.isOpen ? "20vh" : 0}
px={!expanded.isOpen ? "2" : 0}
pb={!expanded.isOpen ? "4" : 0}
w="full"
position="relative"
backgroundImage={!expanded.isOpen ? metadata.banner : ""}
backgroundPosition="center"
backgroundSize="cover"
backgroundRepeat="no-repeat"
>
{expanded.isOpen && <Image src={metadata?.banner} w="full" />}
<IconButton
icon={expanded.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}
aria-label="expand"
onClick={expanded.onToggle}
top="2"
right="2"
variant="solid"
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>
)}
<Flex gap="2" px="2" direction="column">
{metadata?.nip05 && (
<Flex gap="2">
<AtIcon />
<UserDnsIdentityIcon pubkey={pubkey} />
</Flex>
)}
{metadata?.website && (
<Flex gap="2">
<ExternalLinkIcon />
<Link href={metadata.website} target="_blank" color="blue.500" isExternal>
{metadata.website}
</Link>
</Flex>
)}
{npub && (
<Flex gap="2">
<KeyIcon />
<Text>{truncatedId(npub, 10)}</Text>
<CopyIconButton text={npub} title="Copy npub" aria-label="Copy npub" size="xs" />
<QrIconButton pubkey={pubkey} title="Show QrCode" aria-label="Show QrCode" size="xs" />
</Flex>
)}
</Flex>
<Accordion allowToggle allowMultiple>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Network Stats
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb="2">
<StatGroup gap="4" whiteSpace="pre">
<Stat>
<StatLabel>Following</StatLabel>
<StatNumber>{contacts ? readablizeSats(contacts.contacts.length) : "Unknown"}</StatNumber>
{contacts && (
<StatHelpText>Updated {moment(convertTimestampToDate(contacts.created_at)).fromNow()}</StatHelpText>
)}
</Stat>
{stats && (
<>
<Stat>
<StatLabel>Followers</StatLabel>
<StatNumber>{readablizeSats(stats.followers_pubkey_count) || 0}</StatNumber>
</Stat>
<Stat>
<StatLabel>Published Notes</StatLabel>
<StatNumber>{readablizeSats(stats.pub_post_count) || 0}</StatNumber>
</Stat>
<Stat>
<StatLabel>Reactions</StatLabel>
<StatNumber>{readablizeSats(stats.pub_reaction_count) || 0}</StatNumber>
</Stat>
</>
)}
</StatGroup>
</AccordionPanel>
</AccordionItem>
{(stats?.zaps_sent || stats?.zaps_received) && (
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Zap Stats
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb="2">
<StatGroup gap="4" whiteSpace="pre">
{stats.zaps_sent && (
<>
<Stat>
<StatLabel>Zap Sent</StatLabel>
<StatNumber>{stats.zaps_sent.count}</StatNumber>
</Stat>
<Stat>
<StatLabel>Total Sats Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Avg Zap Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.avg_msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Biggest Zap Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.max_msats / 1000)}</StatNumber>
</Stat>
</>
)}
{stats.zaps_received && (
<>
<Stat>
<StatLabel>Zap Received</StatLabel>
<StatNumber>{stats.zaps_received.count}</StatNumber>
</Stat>
<Stat>
<StatLabel>Total Sats Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Avg Zap Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.avg_msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Biggest Zap Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.max_msats / 1000)}</StatNumber>
</Stat>
</>
)}
</StatGroup>
<Text color="slategrey">
Stats from{" "}
<Link href="https://nostr.band" isExternal color="blue.500">
nostr.band
</Link>
</Text>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
</Flex>
);
}

View File

@ -1,7 +1,7 @@
import { Flex, Heading, SkeletonText, Text, Link, IconButton } from "@chakra-ui/react";
import { Flex, Heading, SkeletonText, Text, Link, IconButton, Spacer } from "@chakra-ui/react";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { CopyIconButton } from "../../../components/copy-icon-button";
import { ChatIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
import { ChatIcon, EditIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
import { QrIconButton } from "./share-qr-button";
import { UserAvatar } from "../../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
@ -15,7 +15,7 @@ import { useIsMobile } from "../../../hooks/use-is-mobile";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { UserProfileMenu } from "./user-profile-menu";
import { embedUrls } from "../../../helpers/embeds";
import { renderDefaultUrl } from "../../../components/embed-types";
import { renderGenericUrl } from "../../../components/embed-types";
export default function Header({
pubkey,
@ -34,56 +34,23 @@ export default function Header({
return (
<Flex direction="column" gap="2" px="2" pt="2">
<Flex gap="4">
<UserAvatar pubkey={pubkey} size={isMobile ? "md" : "xl"} noProxy />
<Flex direction="column" gap={isMobile ? 0 : 2} grow="1" overflow="hidden">
<Flex gap="2" justifyContent="space-between" width="100%">
<Flex gap="2" alignItems="center" wrap="wrap">
<Heading size={isMobile ? "md" : "lg"}>{getUserDisplayName(metadata, pubkey)}</Heading>
<UserDnsIdentityIcon pubkey={pubkey} />
</Flex>
<Flex gap="2">
<UserTipButton pubkey={pubkey} size="sm" variant="link" />
<UserProfileMenu
pubkey={pubkey}
aria-label="More Options"
size="sm"
variant="link"
showRelaySelectionModal={showRelaySelectionModal}
/>
</Flex>
</Flex>
{metadata?.about && <Text>{embedUrls([metadata.about], [renderDefaultUrl])}</Text>}
</Flex>
</Flex>
<Flex wrap="wrap" gap="2">
{metadata?.website && (
<Text>
<ExternalLinkIcon />{" "}
<Link href={fixWebsiteUrl(metadata.website)} target="_blank" color="blue.500">
{metadata.website}
</Link>
</Text>
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" noProxy mr="2" />
<Heading size="md">{getUserDisplayName(metadata, pubkey)}</Heading>
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon={isMobile} />
<Spacer />
{isSelf && (
<IconButton
icon={<EditIcon />}
aria-label="Edit profile"
title="Edit profile"
size="sm"
onClick={() => navigate("/profile")}
/>
)}
{npub && (
<Flex gap="2">
<KeyIcon />
<Text>{truncatedId(npub, 10)}</Text>
<CopyIconButton text={npub} title="Copy npub" aria-label="Copy npub" size="xs" />
<QrIconButton pubkey={pubkey} title="Show QrCode" aria-label="Show QrCode" size="xs" />
</Flex>
)}
<Flex gap="2" ml="auto">
{isMobile && isSelf && (
<IconButton
icon={<SettingsIcon />}
aria-label="Settings"
title="Settings"
size="sm"
onClick={() => navigate("/settings")}
/>
)}
{!isSelf && (
{!isSelf && (
<>
<UserTipButton pubkey={pubkey} size="sm" variant="link" />
<IconButton
as={RouterLink}
size="sm"
@ -91,9 +58,15 @@ export default function Header({
aria-label="Message"
to={`/dm/${npub ?? pubkey}`}
/>
)}
{!isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
</Flex>
<UserFollowButton pubkey={pubkey} size="sm" />
</>
)}
<UserProfileMenu
pubkey={pubkey}
aria-label="More Options"
size="sm"
showRelaySelectionModal={showRelaySelectionModal}
/>
</Flex>
</Flex>
);

View File

@ -1,4 +1,4 @@
import { Box, Flex, Heading, Link, Text } from "@chakra-ui/react";
import { Box, Code, Flex, Heading, Input, Link, Spacer, Text } from "@chakra-ui/react";
import { Link as ReactRouterLink } from "react-router-dom";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
@ -6,22 +6,37 @@ import { getUserDisplayName } from "../../../helpers/user-metadata";
import { UserAvatar } from "../../../components/user-avatar";
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import { UserFollowButton } from "../../../components/user-follow-button";
import { useIsMobile } from "../../../hooks/use-is-mobile";
export const UserCard = ({ pubkey, relay }: { pubkey: string; relay?: string }) => {
const isMobile = useIsMobile();
const metadata = useUserMetadata(pubkey, relay ? [relay] : []);
return (
<Box borderWidth="1px" borderRadius="lg" pl="3" pr="3" pt="2" pb="2" overflow="hidden">
<Flex gap="4" alignItems="center">
<UserAvatar pubkey={pubkey} />
<Box>
<Link as={ReactRouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<Heading size="sm">{getUserDisplayName(metadata, pubkey)}</Heading>
</Link>
<UserDnsIdentityIcon pubkey={pubkey} />
{relay && <Text>{relay}</Text>}
</Box>
<Box
borderWidth="1px"
borderRadius="lg"
pl="3"
pr="3"
pt="2"
pb="2"
overflow="hidden"
gap="4"
display="flex"
alignItems="center"
>
<UserAvatar pubkey={pubkey} />
<Flex direction="column" flex={1} overflowY="hidden" overflowX="auto">
<Link as={ReactRouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<Heading size="sm" whiteSpace="nowrap">
{getUserDisplayName(metadata, pubkey)}
</Heading>
</Link>
<UserDnsIdentityIcon pubkey={pubkey} />
</Flex>
{relay && !isMobile && <Input readOnly value={relay} w="xs" />}
<UserFollowButton pubkey={pubkey} size="sm" variant="outline" flexShrink={0} />
</Box>
);
};

View File

@ -10,6 +10,7 @@ import { RelayMode } from "../../../classes/relay";
import UserDebugModal from "../../../components/debug-modals/user-debug-modal";
import { useCopyToClipboard } from "react-use";
import { useSharableProfileId } from "../../../hooks/use-shareable-profile-id";
import { truncatedId } from "../../../helpers/nostr-event";
export const UserProfileMenu = ({
pubkey,
@ -39,7 +40,7 @@ export const UserProfileMenu = ({
<>
<MenuIconButton {...props}>
<MenuItem icon={<SpyIcon fontSize="1.5em" />} onClick={() => loginAsUser()}>
Login as {getUserDisplayName(metadata, pubkey)}
Login as {truncatedId(getUserDisplayName(metadata, pubkey))}
</MenuItem>
<MenuItem
onClick={() => window.open(`https://nostrapp.link/#${sharableId}?select=true`, "_blank")}

View File

@ -1,41 +1,51 @@
import { Flex, FormControl, FormLabel, Grid, SkeletonText, Switch, useDisclosure } from "@chakra-ui/react";
import AutoSizer from "react-virtualized-auto-sizer";
import { Box, Flex, Spinner } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { FixedSizeList, ListChildComponentProps } from "react-window";
import { UserCard } from "./components/user-card";
import { useUserFollowers } from "../../hooks/use-user-followers";
import { useOutletContext } from "react-router-dom";
import { usePaginatedList } from "../../hooks/use-paginated-list";
import { PaginationControls } from "../../components/pagination-controls";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
const UserFollowersTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const { isOpen, onToggle } = useDisclosure();
const contextRelays = useAdditionalRelayContext();
const relays = useReadRelayUrls(contextRelays);
const followers = useUserFollowers(pubkey, relays, isOpen);
const pagination = usePaginatedList(followers ?? [], { pageSize: 3 * 10 });
function FollowerItem({ index, style, data: followers }: ListChildComponentProps<string[]>) {
const pubkey = followers[index];
return (
<Flex gap="2" direction="column">
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="fetch-followers" mb="0">
Fetch Followers
</FormLabel>
<Switch id="fetch-followers" isChecked={isOpen} onChange={onToggle} />
</FormControl>
<div style={style}>
<UserCard key={pubkey + index} pubkey={pubkey} />
</div>
);
}
const UserFollowersTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const relays = useReadRelayUrls(useAdditionalRelayContext());
const followers = useUserFollowers(pubkey, relays, true);
return (
<Flex gap="2" direction="column" overflowY="auto" p="2" h="full">
{followers ? (
<>
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)", "2xl": "repeat(3, 1fr)" }} gap="2">
{pagination.pageItems.map((pubkey) => (
<UserCard key={pubkey} pubkey={pubkey} />
))}
</Grid>
<PaginationControls {...pagination} buttonSize="sm" />
</>
<Box flex={1}>
<AutoSizer disableWidth>
{({ height }: { height: number }) => (
<FixedSizeList
itemCount={followers.length}
itemData={followers}
itemSize={70}
itemKey={(i, d) => d[i]}
width="100%"
height={height}
overscanCount={10}
>
{FollowerItem}
</FixedSizeList>
)}
</AutoSizer>
</Box>
) : (
<SkeletonText />
<Spinner />
)}
</Flex>
);

View File

@ -1,36 +1,51 @@
import moment from "moment";
import { Flex, Grid, SkeletonText } from "@chakra-ui/react";
import { Box, Flex, Spinner } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList, ListChildComponentProps } from "react-window";
import { UserCard } from "./components/user-card";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { useOutletContext } from "react-router-dom";
import { usePaginatedList } from "../../hooks/use-paginated-list";
import { PaginationControls } from "../../components/pagination-controls";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { UserContacts } from "../../services/user-contacts";
const UserFollowingTab = () => {
function ContactItem({ index, style, data: contacts }: ListChildComponentProps<UserContacts>) {
const pubkey = contacts.contacts[index];
return (
<div style={style}>
<UserCard key={pubkey + index} pubkey={pubkey} relay={contacts.contactRelay[pubkey]} />
</div>
);
}
export default function UserFollowingTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
const contacts = useUserContacts(pubkey, contextRelays, true);
const pagination = usePaginatedList(contacts?.contacts ?? [], { pageSize: 3 * 10 });
return (
<Flex gap="2" direction="column">
<Flex gap="2" direction="column" overflowY="auto" p="2" h="full">
{contacts ? (
<>
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)", "2xl": "repeat(3, 1fr)" }} gap="2">
{pagination.pageItems.map((pubkey, i) => (
<UserCard key={pubkey + i} pubkey={pubkey} relay={contacts.contactRelay[pubkey]} />
))}
</Grid>
<PaginationControls {...pagination} buttonSize="sm" />
</>
<Box flex={1}>
<AutoSizer disableWidth>
{({ height }: { height: number }) => (
<FixedSizeList
itemCount={contacts.contacts.length}
itemData={contacts}
itemSize={70}
itemKey={(i, d) => d.contacts[i]}
width="100%"
height={height}
overscanCount={10}
>
{ContactItem}
</FixedSizeList>
)}
</AutoSizer>
</Box>
) : (
<SkeletonText />
<Spinner />
)}
</Flex>
);
};
export default UserFollowingTab;
}

View File

@ -27,10 +27,8 @@ import {
import { Outlet, useMatches, useNavigate, useParams } from "react-router-dom";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../helpers/user-metadata";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { Bech32Prefix, isHex, normalizeToBech32 } from "../../helpers/nip19";
import { useAppTitle } from "../../hooks/use-app-title";
import Header from "./components/header";
import { Suspense, useState } from "react";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import relayScoreboardService from "../../services/relay-scoreboard";
@ -40,15 +38,17 @@ import { nip19 } from "nostr-tools";
import { unique } from "../../helpers/array";
import { RelayFavicon } from "../../components/relay-favicon";
import { useUserRelays } from "../../hooks/use-user-relays";
import Header from "./components/header";
const tabs = [
{ label: "About", path: "about" },
{ label: "Notes", path: "notes" },
{ label: "Media", path: "media" },
{ label: "Zaps", path: "zaps" },
{ label: "Followers", path: "followers" },
{ label: "Following", path: "following" },
{ label: "Relays", path: "relays" },
{ label: "Reports", path: "reports" },
{ label: "Followers", path: "followers" },
];
function useUserPointer() {
@ -82,7 +82,6 @@ function useUserTopRelays(pubkey: string, count: number = 4) {
const UserView = () => {
const { pubkey, relays: pointerRelays } = useUserPointer();
const isMobile = useIsMobile();
const navigate = useNavigate();
const [relayCount, setRelayCount] = useState(4);
const userTopRelays = useUserTopRelays(pubkey, relayCount);
@ -91,7 +90,7 @@ const UserView = () => {
const matches = useMatches();
const lastMatch = matches[matches.length - 1];
const activeTab = tabs.indexOf(tabs.find((t) => lastMatch.pathname.includes(t.path)) ?? tabs[0]);
const activeTab = tabs.indexOf(tabs.find((t) => lastMatch.pathname.endsWith(t.path)) ?? tabs[0]);
const metadata = useUserMetadata(pubkey, userTopRelays, true);
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
@ -101,14 +100,13 @@ const UserView = () => {
return (
<>
<AdditionalRelayProvider relays={unique([...userTopRelays, ...pointerRelays])}>
<Flex direction="column" alignItems="stretch" gap="2" overflow={isMobile ? "auto" : "hidden"} height="100%">
{/* {metadata?.banner && <Image src={metadata.banner} mb={-120} />} */}
<Flex direction="column" alignItems="stretch" gap="2" overflow="hidden" h="full">
<Header pubkey={pubkey} showRelaySelectionModal={relayModal.onOpen} />
<Tabs
display="flex"
flexDirection="column"
flexGrow="1"
overflow={isMobile ? undefined : "hidden"}
overflow="hidden"
isLazy
index={activeTab}
onChange={(v) => navigate(tabs[v].path)}
@ -120,9 +118,9 @@ const UserView = () => {
))}
</TabList>
<TabPanels overflow={isMobile ? undefined : "auto"} height="100%">
<TabPanels overflow="hidden" h="full">
{tabs.map(({ label }) => (
<TabPanel key={label} pr={0} pl={0}>
<TabPanel key={label} p={0} h="full" overflow="hidden">
<Suspense fallback={<Spinner />}>
<Outlet context={{ pubkey, setRelayCount }} />
</Suspense>

View File

@ -1,19 +1,20 @@
import { Box, Button, Flex, Grid, IconButton, Spinner } from "@chakra-ui/react";
import { Link as RouterLink, useOutletContext } from "react-router-dom";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useEffect, useMemo } from "react";
import { Box, Button, Flex, Grid, IconButton, Spinner } from "@chakra-ui/react";
import { useNavigate, useOutletContext } from "react-router-dom";
import { useMount, useUnmount } from "react-use";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { matchImageUrls } from "../../helpers/regexp";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { ImageGalleryLink, ImageGalleryProvider } from "../../components/image-gallery";
import { ExternalLinkIcon } from "../../components/icons";
import { getSharableNoteId } from "../../helpers/nip19";
import { useMount, useUnmount } from "react-use";
import useSubject from "../../hooks/use-subject";
import userTimelineService from "../../services/user-timeline";
const matchAllImages = new RegExp(matchImageUrls, "ig");
const UserMediaTab = () => {
const navigate = useNavigate();
const isMobile = useIsMobile();
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
@ -48,7 +49,7 @@ const UserMediaTab = () => {
}, [filteredEvents]);
return (
<Flex direction="column" gap="2" pr="2" pl="2">
<Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto">
<ImageGalleryProvider>
<Grid templateColumns={`repeat(${isMobile ? 2 : 5}, 1fr)`} gap="4">
{images.map((image) => (
@ -60,23 +61,28 @@ const UserMediaTab = () => {
backgroundPosition="center"
/>
<IconButton
as={RouterLink}
icon={<ExternalLinkIcon />}
aria-label="Open note"
to={`/n/${getSharableNoteId(image.eventId)}`}
position="absolute"
right="2"
top="2"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/n/${getSharableNoteId(image.eventId)}`);
}}
/>
</ImageGalleryLink>
))}
</Grid>
</ImageGalleryProvider>
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => timeline.loadMore()}>Load More</Button>
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
);

View File

@ -35,8 +35,8 @@ const UserNotesTab = () => {
});
return (
<Flex direction="column" gap="2" pr="2" pl="2">
<FormControl display="flex" alignItems="center">
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden">
<FormControl display="flex" alignItems="center" mx="2">
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
<FormLabel htmlFor="replies" mb="0">
Replies
@ -55,9 +55,11 @@ const UserNotesTab = () => {
)
)}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => timeline.loadMore()}>Load More</Button>
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
);

View File

@ -54,7 +54,13 @@ export default function UserReportsTab() {
{reports.map((report) => (
<ReportEvent key={report.id} report={report} />
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
);
}

View File

@ -1,5 +1,5 @@
import { Box, Button, Flex, Select, Spinner, Text, useDisclosure } from "@chakra-ui/react";
import moment from "moment";
import moment, { isMoment } from "moment";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary";
@ -30,6 +30,7 @@ const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
display="flex"
gap="2"
flexDirection="column"
flexShrink={0}
>
<Flex gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={request.pubkey} size="xs" />
@ -37,10 +38,10 @@ const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
<Text>Zapped</Text>
{eventId && <NoteLink noteId={eventId} />}
{payment.amount && (
<>
<Flex gap="2">
<LightningIcon color="yellow.400" />
<Text>{readablizeSats(payment.amount / 1000)} sats</Text>
</>
</Flex>
)}
{request.content && (
<Button variant="link" onClick={onToggle}>
@ -79,21 +80,21 @@ const UserZapsTab = () => {
filter === "note" ? events.filter(isNoteZap) : filter === "profile" ? events.filter(isProfileZap) : events;
return (
<Flex direction="column" gap="2" pr="2" pl="2">
<Flex gap="2" alignItems="center">
<Select value={filter} onChange={(e) => setFilter(e.target.value)} maxW="lg">
<Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto">
<Flex gap="2" alignItems="center" wrap="wrap">
<Select value={filter} onChange={(e) => setFilter(e.target.value)} maxW="md">
<option value="both">Note & Profile Zaps</option>
<option value="note">Note Zaps</option>
<option value="profile">Profile Zaps</option>
</Select>
{timeline.length && (
<>
<Flex gap="2">
<LightningIcon color="yellow.400" />
<Text>
{readablizeSats(totalZaps(timeline) / 1000)} sats in the last{" "}
{moment(convertTimestampToDate(timeline[timeline.length - 1].created_at)).fromNow(true)}
</Text>
</>
</Flex>
)}
</Flex>
{timeline.map((event) => (
@ -101,7 +102,13 @@ const UserZapsTab = () => {
<Zap zapEvent={event} />
</ErrorBoundary>
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
);
};

View File

@ -2683,6 +2683,13 @@
dependencies:
"@types/react" "*"
"@types/react-window@^1.8.5":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
dependencies:
"@types/react" "*"
"@types/react@*":
version "18.0.34"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.34.tgz#e553444a578f023e6e1ac499514688fb80b0a984"
@ -4827,6 +4834,11 @@ mdn-data@2.0.14:
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
"memoize-one@>=3.1.1 <6":
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
meow@^6.0.0:
version "6.1.1"
resolved "https://registry.yarnpkg.com/meow/-/meow-6.1.1.tgz#1ad64c4b76b2a24dfb2f635fddcadf320d251467"
@ -5432,11 +5444,24 @@ react-use@^17.4.0:
ts-easing "^0.2.0"
tslib "^2.1.0"
react-virtualized-auto-sizer@^1.0.20:
version "1.0.20"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.20.tgz#d9a907253a7c221c52fa57dc775a6ef40c182645"
integrity sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA==
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-window@^1.8.9:
version "1.8.9"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8"
integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"