import { normalizeReaction } from "@snort/shared"; import { countLeadingZeros, NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; import { Menu, MenuItem } from "@szhsin/react-menu"; import classNames from "classnames"; import React, { forwardRef, useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useLongPress } from "use-long-press"; import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon"; import { ZapsSummary } from "@/Components/Event/Zap"; import Icon from "@/Components/Icons/Icon"; import SendSats from "@/Components/SendSats/SendSats"; import useEventPublisher from "@/Hooks/useEventPublisher"; import { useInteractionCache } from "@/Hooks/useInteractionCache"; import useLogin from "@/Hooks/useLogin"; import { useNoteCreator } from "@/State/NoteCreator"; import { delay, findTag, getDisplayName } from "@/Utils"; import { formatShort } from "@/Utils/Number"; import { Zapper, ZapTarget } from "@/Utils/Zapper"; import { ZapPoolController } from "@/Utils/ZapPoolController"; import { useWallet } from "@/Wallet"; import messages from "../messages"; let isZapperBusy = false; const barrierZapper = async (then: () => Promise): Promise => { while (isZapperBusy) { await delay(100); } isZapperBusy = true; try { return await then(); } finally { isZapperBusy = false; } }; export interface NoteFooterProps { reposts: TaggedNostrEvent[]; zaps: ParsedZap[]; positive: TaggedNostrEvent[]; replies?: number; ev: TaggedNostrEvent; } export default function NoteFooter(props: NoteFooterProps) { const { ev, positive, reposts, zaps } = props; const { formatMessage } = useIntl(); const { publicKey, preferences: prefs, readonly, } = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, readonly: s.readonly })); const author = useUserProfile(ev.pubkey); const interactionCache = useInteractionCache(publicKey, ev.id); const { publisher, system } = useEventPublisher(); const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote })); const [tip, setTip] = useState(false); const [zapping, setZapping] = useState(false); const walletState = useWallet(); const wallet = walletState.wallet; const canFastZap = wallet?.isReady() && !readonly; const isMine = ev.pubkey === publicKey; const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0); const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey); const longPress = useLongPress( e => { e.stopPropagation(); setTip(true); }, { captureEvent: true, }, ); function hasReacted(emoji: string) { return ( interactionCache.data.reacted || positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey) ); } function hasReposted() { return interactionCache.data.reposted || reposts.some(a => a.pubkey === publicKey); } async function react(content: string) { if (!hasReacted(content) && publisher) { const evLike = await publisher.react(ev, content); system.BroadcastEvent(evLike); interactionCache.react(); } } async function repost() { if (!hasReposted() && publisher) { if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) { const evRepost = await publisher.repost(ev); system.BroadcastEvent(evRepost); await interactionCache.repost(); } } } function getZapTarget(): Array | undefined { if (ev.tags.some(v => v[0] === "zap")) { return Zapper.fromEvent(ev); } const authorTarget = author?.lud16 || author?.lud06; if (authorTarget) { return [ { type: "lnurl", value: authorTarget, weight: 1, name: getDisplayName(author, ev.pubkey), zap: { pubkey: ev.pubkey, event: NostrLink.fromEvent(ev), }, } as ZapTarget, ]; } } async function fastZap(e?: React.MouseEvent) { if (zapping || e?.isPropagationStopped()) return; const lnurl = getZapTarget(); if (canFastZap && lnurl) { setZapping(true); try { await fastZapInner(lnurl, prefs.defaultZapAmount); } catch (e) { console.warn("Fast zap failed", e); if (!(e instanceof Error) || e.message !== "User rejected") { setTip(true); } } finally { setZapping(false); } } else { setTip(true); } } async function fastZapInner(targets: Array, amount: number) { if (wallet) { // only allow 1 invoice req/payment at a time to avoid hitting rate limits await barrierZapper(async () => { const zapper = new Zapper(system, publisher); const result = await zapper.send(wallet, targets, amount); const totalSent = result.reduce((acc, v) => (acc += v.sent), 0); if (totalSent > 0) { if (CONFIG.features.zapPool) { ZapPoolController?.allocate(totalSent); } await interactionCache.zap(); } }); } } useEffect(() => { if (prefs.autoZap && !didZap && !isMine && !zapping) { const lnurl = getZapTarget(); if (wallet?.isReady() && lnurl) { setZapping(true); queueMicrotask(async () => { try { await fastZapInner(lnurl, prefs.defaultZapAmount); } catch { // ignored } finally { setZapping(false); } }); } } }, [prefs.autoZap, author, zapping]); function powIcon() { const pow = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined; if (pow) { return ( ); } } function tipButton() { const targets = getZapTarget(); if (targets) { return ( fastZap(e)} /> ); } return null; } function repostIcon() { if (readonly) return; return ( } menuClassName="ctx-menu" align="start">
{/* This menu item serves as a "close menu" button; it allows the user to click anywhere nearby the menu to close it. */}
repost()} disabled={hasReposted()}> note.update(n => { n.reset(); n.quote = ev; n.show = true; }) }>
); } function reactionIcon() { if (!prefs.enableReactions) { return null; } const reacted = hasReacted("+"); return ( { if (readonly) return; await react(prefs.reactionEmoji); }} /> ); } function replyIcon() { if (readonly) return; return ( handleReplyButtonClick()} /> ); } const handleReplyButtonClick = () => { note.update(v => { if (v.replyTo?.id !== ev.id) { v.reset(); } v.show = true; v.replyTo = ev; }); }; return ( <>
{replyIcon()} {repostIcon()} {reactionIcon()} {tipButton()} {powIcon()}
setTip(false)} show={tip} note={ev.id} allocatePool={true} />
); } const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, ref) => { const mergedProps = { ...props, iconSize: 18, className: classNames("transition duration-200 ease-in-out reaction-pill cursor-pointer", props.className), }; return ( {props.value > 0 &&
{formatShort(props.value)}
}
); }); AsyncFooterIcon.displayName = "AsyncFooterIcon";