diff --git a/packages/app/package.json b/packages/app/package.json index 25c7b497..4f9cc05f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,6 +11,7 @@ "@noble/secp256k1": "^1.7.0", "@protobufjs/base64": "^1.1.2", "@reduxjs/toolkit": "^1.9.1", + "@snort/nostr": "^1.0.0", "@szhsin/react-menu": "^3.3.1", "@types/jest": "^29.2.5", "@types/node": "^18.11.18", @@ -37,6 +38,7 @@ "react-twitter-embed": "^4.0.4", "typescript": "^4.9.4", "unist-util-visit": "^4.1.2", + "use-long-press": "^2.0.3", "uuid": "^9.0.0", "workbox-background-sync": "^6.4.2", "workbox-broadcast-update": "^6.4.2", @@ -49,8 +51,7 @@ "workbox-range-requests": "^6.4.2", "workbox-routing": "^6.4.2", "workbox-strategies": "^6.4.2", - "workbox-streams": "^6.4.2", - "@snort/nostr": "^1.0.0" + "workbox-streams": "^6.4.2" }, "scripts": { "start": "react-app-rewired start", diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index f35e3781..d8e69449 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -1,7 +1,8 @@ -import { useState } from "react"; +import React, { useState } from "react"; import { useSelector, useDispatch } from "react-redux"; import { useIntl, FormattedMessage } from "react-intl"; import { Menu, MenuItem } from "@szhsin/react-menu"; +import { useLongPress } from "use-long-press"; import Bookmark from "Icons/Bookmark"; import Pin from "Icons/Pin"; @@ -31,6 +32,10 @@ import { RootState } from "State/Store"; import { UserPreferences, setPinned, setBookmarked } from "State/Login"; import useModeration from "Hooks/useModeration"; import { TranslateHost } from "Const"; +import useWebln from "Hooks/useWebln"; +import { LNURL } from "LNURL"; +import Spinner from "Icons/Spinner"; +import ZapFast from "Icons/ZapFast"; import messages from "./messages"; @@ -63,6 +68,8 @@ export default function NoteFooter(props: NoteFooterProps) { const publisher = useEventPublisher(); const [reply, setReply] = useState(false); const [tip, setTip] = useState(false); + const [zapping, setZapping] = useState(false); + const webln = useWebln(); const isMine = ev.RootPubKey === login; const lang = window.navigator.language; const langNames = new Intl.DisplayNames([...window.navigator.languages], { @@ -70,6 +77,15 @@ export default function NoteFooter(props: NoteFooterProps) { }); const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0); const didZap = zaps.some(a => a.zapper === login); + const longPress = useLongPress( + e => { + e.stopPropagation(); + setTip(true); + }, + { + captureEvent: true, + } + ); function hasReacted(emoji: string) { return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login); @@ -102,15 +118,39 @@ export default function NoteFooter(props: NoteFooterProps) { } } + async function zapClick(e: React.MouseEvent) { + if (zapping || e.isPropagationStopped()) return; + + const lnurl = author?.lud16 || author?.lud06; + if (webln?.enabled && lnurl) { + setZapping(true); + try { + const handler = new LNURL(lnurl); + await handler.load(); + + const zap = await publisher.zap(prefs.defaultZapAmount * 1000, ev.PubKey, ev.Id); + const invoice = await handler.getInvoice(prefs.defaultZapAmount, undefined, zap); + if (invoice.pr) { + await webln.sendPayment(invoice.pr); + } + } catch (e) { + console.warn("Instant zap failed", e); + setTip(true); + } finally { + setZapping(false); + } + } else { + setTip(true); + } + } + function tipButton() { const service = author?.lud16 || author?.lud06; if (service) { return ( <> -
setTip(true)}> -
- -
+
zapClick(e)}> +
{zapping ? : webln?.enabled ? : }
{zapTotal > 0 &&
{formatShort(zapTotal)}
}
@@ -309,7 +349,7 @@ export default function NoteFooter(props: NoteFooterProps) { zaps={zaps} /> setTip(false)} show={tip} author={author?.pubkey} diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index bc107474..593289d4 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -1,11 +1,10 @@ import "./SendSats.css"; -import { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useIntl, FormattedMessage } from "react-intl"; import { useSelector } from "react-redux"; import { formatShort } from "Number"; -import { bech32ToText } from "Util"; -import { HexKey, Tag } from "@snort/nostr"; +import { Event, HexKey, Tag } from "@snort/nostr"; import { RootState } from "State/Store"; import Check from "Icons/Check"; import Zap from "Icons/Zap"; @@ -18,25 +17,7 @@ import Copy from "Element/Copy"; import useWebln from "Hooks/useWebln"; import messages from "./messages"; - -interface LNURLService { - nostrPubkey?: HexKey; - minSendable?: number; - maxSendable?: number; - metadata: string; - callback: string; - commentAllowed?: number; -} - -interface LNURLInvoice { - pr: string; - successAction?: LNURLSuccessAction; -} - -interface LNURLSuccessAction { - description?: string; - url?: string; -} +import { LNURL, LNURLInvoice, LNURLSuccessAction } from "LNURL"; enum ZapType { PublicZap = 1, @@ -45,9 +26,9 @@ enum ZapType { NonZap = 4, } -export interface LNURLTipProps { +export interface SendSatsProps { onClose?: () => void; - svc?: string; + lnurl?: string; show?: boolean; invoice?: string; // shortcut to invoice qr tab title?: string; @@ -69,10 +50,8 @@ function chunks(arr: T[], length: number) { return result; } -export default function LNURLTip(props: LNURLTipProps) { +export default function SendSats(props: SendSatsProps) { const onClose = props.onClose || (() => undefined); - const service = props.svc; - const show = props.show || false; const { note, author, target } = props; const defaultZapAmount = useSelector((s: RootState) => s.login.preferences.defaultZapAmount); const amounts = [defaultZapAmount, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000]; @@ -85,97 +64,68 @@ export default function LNURLTip(props: LNURLTipProps) { 100_000: "🚀", 1_000_000: "🤯", }; - const [payService, setPayService] = useState(); + + const [handler, setHandler] = useState(); + const [invoice, setInvoice] = useState(props.invoice); const [amount, setAmount] = useState(defaultZapAmount); const [customAmount, setCustomAmount] = useState(); - const [invoice, setInvoice] = useState(); const [comment, setComment] = useState(); - const [error, setError] = useState(); const [success, setSuccess] = useState(); + const [error, setError] = useState(); const [zapType, setZapType] = useState(ZapType.PublicZap); const [paying, setPaying] = useState(false); - const webln = useWebln(show); + + const webln = useWebln(props.show); const { formatMessage } = useIntl(); const publisher = useEventPublisher(); - const canComment = (payService?.commentAllowed ?? 0) > 0 || payService?.nostrPubkey; + const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false; useEffect(() => { - if (show && !props.invoice) { - loadService() - .then(a => setPayService(a ?? undefined)) - .catch(() => setError(formatMessage(messages.LNURLFail))); - } else { - setPayService(undefined); + if (props.show) { setError(undefined); - setInvoice(props.invoice ? { pr: props.invoice } : undefined); setAmount(defaultZapAmount); setComment(undefined); - setSuccess(undefined); setZapType(ZapType.PublicZap); + setInvoice(undefined); + setSuccess(undefined); } - }, [show, service]); + }, [props.show]); + + useEffect(() => { + if (props.lnurl && props.show) { + try { + const h = new LNURL(props.lnurl); + setHandler(h); + h.load().catch(e => setError((e as Error).message)); + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } + } + } + }, [props.lnurl, props.show]); const serviceAmounts = useMemo(() => { - if (payService) { - const min = (payService.minSendable ?? 0) / 1000; - const max = (payService.maxSendable ?? 0) / 1000; + if (handler) { + const min = handler.min / 1000; + const max = handler.max / 1000; return amounts.filter(a => a >= min && a <= max); } return []; - }, [payService]); + }, [handler]); const amountRows = useMemo(() => chunks(serviceAmounts, 3), [serviceAmounts]); const selectAmount = (a: number) => { setError(undefined); - setInvoice(undefined); setAmount(a); }; - async function fetchJson(url: string) { - const rsp = await fetch(url); - if (rsp.ok) { - const data: T = await rsp.json(); - console.log(data); - setError(undefined); - return data; - } - return null; - } - - async function loadService(): Promise { - if (service) { - const isServiceUrl = service.toLowerCase().startsWith("lnurl"); - if (isServiceUrl) { - const serviceUrl = bech32ToText(service); - return await fetchJson(serviceUrl); - } else { - const ns = service.split("@"); - return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`); - } - } - return null; - } - async function loadInvoice() { - if (!amount || !payService) return null; + if (!amount || !handler) return null; - const callback = new URL(payService.callback); - const query = new Map(); - if (callback.search.length > 0) { - callback.search - .slice(1) - .split("&") - .forEach(a => { - const pSplit = a.split("="); - query.set(pSplit[0], pSplit[1]); - }); - } - query.set("amount", Math.floor(amount * 1000).toString()); - if (comment && payService?.commentAllowed) { - query.set("comment", comment); - } - if (payService.nostrPubkey && author && zapType !== ZapType.NonZap) { - const ev = await publisher.zap(author, note, comment); + let zap: Event | undefined; + if (author && zapType !== ZapType.NonZap) { + const ev = await publisher.zap(amount * 1000, author, note, comment); if (ev) { // replace sig for anon-zap if (zapType === ZapType.AnonZap) { @@ -186,26 +136,15 @@ export default function LNURLTip(props: LNURLTipProps) { ev.Tags.push(new Tag(["anon"], ev.Tags.length)); await ev.Sign(randomKey.privateKey); } - query.set("nostr", JSON.stringify(ev.ToObject())); + zap = ev; } } - const baseUrl = `${callback.protocol}//${callback.host}${callback.pathname}`; - const queryJoined = [...query.entries()].map(v => `${v[0]}=${encodeURIComponent(v[1])}`).join("&"); try { - const rsp = await fetch(`${baseUrl}?${queryJoined}`); - if (rsp.ok) { - const data = await rsp.json(); - console.log(data); - if (data.status === "ERROR") { - setError(data.reason); - } else { - setInvoice(data); - setError(""); - payWebLNIfEnabled(data); - } - } else { - setError(formatMessage(messages.InvoiceFail)); + const rsp = await handler.getInvoice(amount, comment, zap); + if (rsp.pr) { + setInvoice(rsp.pr); + await payWebLNIfEnabled(rsp); } } catch (e) { setError(formatMessage(messages.InvoiceFail)); @@ -213,8 +152,10 @@ export default function LNURLTip(props: LNURLTipProps) { } function custom() { - const min = (payService?.minSendable ?? 1000) / 1000; - const max = (payService?.maxSendable ?? 21_000_000_000) / 1000; + if (!handler) return null; + const min = handler.min / 1000; + const max = handler.max / 1000; + return (

{amountRows.map(amounts => renderAmounts(amount, amounts))} - {payService && custom()} + {custom()}
{canComment && ( setComment(e.target.value)} /> )} @@ -306,9 +247,9 @@ export default function LNURLTip(props: LNURLTipProps) { } function zapTypeSelector() { - if (!payService || !payService.nostrPubkey) return; + if (!handler || !handler.canZap) return; - const makeTab = (t: ZapType, n: string) => ( + const makeTab = (t: ZapType, n: React.ReactNode) => (
setZapType(t)}> {n}
@@ -319,18 +260,20 @@ export default function LNURLTip(props: LNURLTipProps) {
- {makeTab(ZapType.PublicZap, "Public")} + {makeTab(ZapType.PublicZap, )} {/*makeTab(ZapType.PrivateZap, "Private")*/} - {makeTab(ZapType.AnonZap, "Anon")} - {makeTab(ZapType.NonZap, "Non-Zap")} + {makeTab(ZapType.AnonZap, )} + {makeTab( + ZapType.NonZap, + + )}
); } function payInvoice() { - if (success) return null; - const pr = invoice?.pr; + if (success || !invoice) return null; return ( <>
@@ -341,15 +284,15 @@ export default function LNURLTip(props: LNURLTipProps) { ... ) : ( - + )}
- {pr && ( + {invoice && ( <>
- +
- @@ -379,14 +322,14 @@ export default function LNURLTip(props: LNURLTipProps) { ); } - const defaultTitle = payService?.nostrPubkey ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats); + const defaultTitle = handler?.canZap ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats); const title = target ? formatMessage(messages.ToTarget, { action: defaultTitle, target, }) : defaultTitle; - if (!show) return null; + if (!(props.show ?? false)) return null; return (
e.stopPropagation()}> diff --git a/packages/app/src/Element/ZapButton.tsx b/packages/app/src/Element/ZapButton.tsx index 5341647e..cf27d298 100644 --- a/packages/app/src/Element/ZapButton.tsx +++ b/packages/app/src/Element/ZapButton.tsx @@ -2,25 +2,30 @@ import "./ZapButton.css"; import { faBolt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useState } from "react"; +import { useLongPress } from "use-long-press"; + import { useUserProfile } from "Feed/ProfileFeed"; import { HexKey } from "@snort/nostr"; import SendSats from "Element/SendSats"; -const ZapButton = ({ pubkey, svc }: { pubkey: HexKey; svc?: string }) => { +const ZapButton = ({ pubkey, lnurl }: { pubkey: HexKey; lnurl?: string }) => { const profile = useUserProfile(pubkey); const [zap, setZap] = useState(false); - const service = svc ?? (profile?.lud16 || profile?.lud06); + const service = lnurl ?? (profile?.lud16 || profile?.lud06); + const longPress = useLongPress(() => { + console.debug("long press"); + }); if (!service) return null; return ( <> -
setZap(true)}> +
setZap(false)} author={pubkey} diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts index b18953be..5c93d47d 100644 --- a/packages/app/src/Feed/EventPublisher.ts +++ b/packages/app/src/Feed/EventPublisher.ts @@ -191,7 +191,7 @@ export default function useEventPublisher() { return await signEvent(ev); } }, - zap: async (author: HexKey, note?: HexKey, msg?: string) => { + zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string) => { if (pubKey) { const ev = NEvent.ForPubKey(pubKey); ev.Kind = EventKind.ZapRequest; @@ -201,6 +201,7 @@ export default function useEventPublisher() { ev.Tags.push(new Tag(["p", author], ev.Tags.length)); const relayTag = ["relays", ...Object.keys(relays)]; ev.Tags.push(new Tag(relayTag, ev.Tags.length)); + ev.Tags.push(new Tag(["amount", amount.toString()], ev.Tags.length)); processContent(ev, msg || ""); return await signEvent(ev); } diff --git a/packages/app/src/Icons/Spinner.css b/packages/app/src/Icons/Spinner.css new file mode 100644 index 00000000..4318a133 --- /dev/null +++ b/packages/app/src/Icons/Spinner.css @@ -0,0 +1,33 @@ +.spinner_V8m1 { + transform-origin: center; + animation: spinner_zKoa 2s linear infinite; +} + +.spinner_V8m1 circle { + stroke-linecap: round; + animation: spinner_YpZS 1.5s ease-in-out infinite; +} + +@keyframes spinner_zKoa { + 100% { + transform: rotate(360deg); + } +} + +@keyframes spinner_YpZS { + 0% { + stroke-dasharray: 0 150; + stroke-dashoffset: 0; + } + + 47.5% { + stroke-dasharray: 42 150; + stroke-dashoffset: -16; + } + + 95%, + 100% { + stroke-dasharray: 42 150; + stroke-dashoffset: -59; + } +} diff --git a/packages/app/src/Icons/Spinner.tsx b/packages/app/src/Icons/Spinner.tsx new file mode 100644 index 00000000..622c9239 --- /dev/null +++ b/packages/app/src/Icons/Spinner.tsx @@ -0,0 +1,12 @@ +import IconProps from "./IconProps"; +import "./Spinner.css"; + +const Spinner = (props: IconProps) => ( + + + + + +); + +export default Spinner; diff --git a/packages/app/src/Icons/ZapFast.tsx b/packages/app/src/Icons/ZapFast.tsx new file mode 100644 index 00000000..d507ce46 --- /dev/null +++ b/packages/app/src/Icons/ZapFast.tsx @@ -0,0 +1,17 @@ +import IconProps from "./IconProps"; + +const ZapFast = (props: IconProps) => { + return ( + + + + ); +}; + +export default ZapFast; diff --git a/packages/app/src/LNURL.ts b/packages/app/src/LNURL.ts new file mode 100644 index 00000000..020cf6a2 --- /dev/null +++ b/packages/app/src/LNURL.ts @@ -0,0 +1,145 @@ +import { Event, HexKey } from "@snort/nostr"; +import { EmailRegex } from "Const"; +import { bech32ToText, unwrap } from "Util"; + +const PayServiceTag = "payRequest"; + +export class LNURL { + #url: URL; + #service?: LNURLService; + + constructor(lnurl: string) { + if (lnurl.toLowerCase().startsWith("lnurl")) { + const decoded = bech32ToText(lnurl); + if (!decoded.startsWith("http")) { + throw new Error("Invalid LNURL: not a url"); + } + this.#url = new URL(decoded); + } else if (lnurl.match(EmailRegex)) { + const [handle, domain] = lnurl.toLowerCase().split("@"); + this.#url = new URL(`https://${domain}/.well-known/lnurlp/${handle}`); + } else if (lnurl.toLowerCase().startsWith("http")) { + this.#url = new URL(lnurl); + } else { + throw new Error("Invalid LNURL: could not determine service url"); + } + } + + async load() { + const rsp = await fetch(this.#url); + if (rsp.ok) { + this.#service = await rsp.json(); + this.#validateService(); + } + } + + /** + * Fetch an invoice from the LNURL service + * @param amount Amount in sats + * @param comment + * @param zap + * @returns + */ + async getInvoice(amount: number, comment?: string, zap?: Event) { + const callback = new URL(unwrap(this.#service?.callback)); + const query = new Map(); + + if (callback.search.length > 0) { + callback.search + .slice(1) + .split("&") + .forEach(a => { + const pSplit = a.split("="); + query.set(pSplit[0], pSplit[1]); + }); + } + query.set("amount", Math.floor(amount * 1000).toString()); + if (comment && this.#service?.commentAllowed) { + query.set("comment", comment); + } + if (this.#service?.nostrPubkey && zap) { + query.set("nostr", JSON.stringify(zap.ToObject())); + } + + const baseUrl = `${callback.protocol}//${callback.host}${callback.pathname}`; + const queryJoined = [...query.entries()].map(v => `${v[0]}=${encodeURIComponent(v[1])}`).join("&"); + try { + const rsp = await fetch(`${baseUrl}?${queryJoined}`); + if (rsp.ok) { + const data: LNURLInvoice = await rsp.json(); + console.debug("[LNURL]: ", data); + if (data.status === "ERROR") { + throw new Error(data.reason); + } else { + return data; + } + } else { + throw new Error(`Failed to fetch invoice (${rsp.statusText})`); + } + } catch (e) { + throw new Error("Failed to load callback"); + } + } + + /** + * Are zaps (NIP-57) supported + */ + get canZap() { + return this.#service?.nostrPubkey ? true : false; + } + + /** + * Get the max allowed comment length + */ + get maxCommentLength() { + return this.#service?.commentAllowed ?? 0; + } + + /** + * Min sendable in milli-sats + */ + get min() { + return this.#service?.minSendable ?? 1_000; // 1 sat + } + + /** + * Max sendable in milli-sats + */ + get max() { + return this.#service?.maxSendable ?? 100e9; // 1 BTC in milli-sats + } + + #validateService() { + if (this.#service?.tag !== PayServiceTag) { + throw new Error("Invalid service: only lnurlp is supported"); + } + if (!this.#service?.callback) { + throw new Error("Invalid service: no callback url"); + } + } +} + +export interface LNURLService { + tag: string; + nostrPubkey?: HexKey; + minSendable?: number; + maxSendable?: number; + metadata: string; + callback: string; + commentAllowed?: number; +} + +export interface LNURLStatus { + status: "SUCCESS" | "ERROR"; + reason?: string; +} + +export interface LNURLInvoice extends LNURLStatus { + pr?: string; + successAction?: LNURLSuccessAction; +} + +export interface LNURLSuccessAction { + description?: string; + url?: string; +} diff --git a/packages/app/src/Pages/DonatePage.tsx b/packages/app/src/Pages/DonatePage.tsx index 65eee534..3fe7e45b 100644 --- a/packages/app/src/Pages/DonatePage.tsx +++ b/packages/app/src/Pages/DonatePage.tsx @@ -100,7 +100,7 @@ const DonatePage = () => {
- +
{today && ( diff --git a/packages/app/src/Pages/ProfilePage.tsx b/packages/app/src/Pages/ProfilePage.tsx index 4eb54d8d..6989e910 100644 --- a/packages/app/src/Pages/ProfilePage.tsx +++ b/packages/app/src/Pages/ProfilePage.tsx @@ -160,7 +160,7 @@ export default function ProfilePage() { )} setShowLnQr(false)} author={id} diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index 6e2e9a02..a4b877c0 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -14,6 +14,10 @@ "/JE/X+": { "defaultMessage": "Account Support" }, + "/PCavi": { + "defaultMessage": "Public", + "description": "Public Zap" + }, "/RD0e2": { "defaultMessage": "Nostr uses digital signature technology to provide tamper proof notes which can safely be replicated to many relays to provide redundant storage of your content." }, @@ -162,6 +166,10 @@ "Adk34V": { "defaultMessage": "Setup your Profile" }, + "AnLrRC": { + "defaultMessage": "Non-Zap", + "description": "Non-Zap, Regular LN payment" + }, "AyGauy": { "defaultMessage": "Login" }, @@ -755,6 +763,10 @@ "defaultMessage": "Your key", "description": "Label for key input" }, + "wWLwvh": { + "defaultMessage": "Anon", + "description": "Anonymous Zap" + }, "wih7iJ": { "defaultMessage": "name is blocked" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index e6740e09..9c8ef3d6 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -4,6 +4,7 @@ "+vIQlC": "Please make sure to save the following password in order to manage your handle in the future", "/4tOwT": "Skip", "/JE/X+": "Account Support", + "/PCavi": "Public", "/RD0e2": "Nostr uses digital signature technology to provide tamper proof notes which can safely be replicated to many relays to provide redundant storage of your content.", "/d6vEc": "Make your profile easier to find and share", "/n5KSF": "{n} ms", @@ -52,6 +53,7 @@ "ADmfQT": "Parent", "ASRK0S": "This author has been muted", "Adk34V": "Setup your Profile", + "AnLrRC": "Non-Zap", "AyGauy": "Login", "B4C47Y": "name too short", "B6+XJy": "zapped", @@ -246,6 +248,7 @@ "vZ4quW": "NIP-05 is a DNS based verification spec which helps to validate you as a real user.", "wEQDC6": "Edit", "wLtRCF": "Your key", + "wWLwvh": "Anon", "wih7iJ": "name is blocked", "wqyN/i": "Find out more info about {service} at {link}", "wtLjP6": "Copy ID", diff --git a/yarn.lock b/yarn.lock index 54ac31b2..8d61c3d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9859,6 +9859,11 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-long-press@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/use-long-press/-/use-long-press-2.0.3.tgz#9e26da35f05819fe8056787c78a101e91e3e464b" + integrity sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw== + use-sync-external-store@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"