snort/packages/app/src/Components/Event/Note/NoteFooter.tsx

332 lines
9.7 KiB
TypeScript
Raw Normal View History

2023-10-16 20:33:01 +00:00
import { normalizeReaction } from "@snort/shared";
2024-01-08 09:24:14 +00:00
import { countLeadingZeros, NostrLink, TaggedNostrEvent } from "@snort/system";
2024-01-08 09:01:26 +00:00
import { useEventReactions, useReactions, useUserProfile } from "@snort/system-react";
2023-10-13 11:40:39 +00:00
import { Menu, MenuItem } from "@szhsin/react-menu";
2023-10-16 15:54:55 +00:00
import classNames from "classnames";
2024-01-04 17:01:18 +00:00
import React, { forwardRef, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
2023-01-08 00:29:59 +00:00
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
2024-01-04 17:01:18 +00:00
import { ZapsSummary } from "@/Components/Event/Zap";
import Icon from "@/Components/Icons/Icon";
import SendSats from "@/Components/SendSats/SendSats";
import useEventPublisher from "@/Hooks/useEventPublisher";
2023-11-17 11:52:10 +00:00
import { useInteractionCache } from "@/Hooks/useInteractionCache";
2024-01-04 17:01:18 +00:00
import useLogin from "@/Hooks/useLogin";
2023-11-17 11:52:10 +00:00
import { useNoteCreator } from "@/State/NoteCreator";
2024-01-04 17:01:18 +00:00
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";
2023-01-31 11:52:55 +00:00
import messages from "../../messages";
2023-02-08 21:10:26 +00:00
2023-03-11 17:03:42 +00:00
let isZapperBusy = false;
const barrierZapper = async <T,>(then: () => Promise<T>): Promise<T> => {
while (isZapperBusy) {
await delay(100);
}
isZapperBusy = true;
try {
return await then();
} finally {
isZapperBusy = false;
}
};
2023-01-16 17:48:25 +00:00
export interface NoteFooterProps {
2023-10-13 10:22:58 +00:00
replies?: number;
2023-08-17 18:54:14 +00:00
ev: TaggedNostrEvent;
2023-01-16 17:48:25 +00:00
}
export default function NoteFooter(props: NoteFooterProps) {
2024-01-08 09:01:26 +00:00
const { ev } = props;
const link = NostrLink.fromEvent(ev);
2024-01-08 10:38:20 +00:00
const related = useReactions(link.id + "related", [link], undefined, false);
2024-01-08 09:01:26 +00:00
const { reactions, zaps, reposts } = useEventReactions(link, related.data ?? []);
const { positive } = reactions;
2023-02-08 21:10:26 +00:00
const { formatMessage } = useIntl();
2023-09-23 21:21:37 +00:00
const {
publicKey,
preferences: prefs,
readonly,
2023-11-13 16:51:29 +00:00
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, readonly: s.readonly }));
2023-08-24 14:25:54 +00:00
const author = useUserProfile(ev.pubkey);
2023-04-25 11:57:09 +00:00
const interactionCache = useInteractionCache(publicKey, ev.id);
2023-10-13 15:34:31 +00:00
const { publisher, system } = useEventPublisher();
2023-10-13 11:40:39 +00:00
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
2023-01-20 17:07:14 +00:00
const [tip, setTip] = useState(false);
2023-02-25 21:18:36 +00:00
const [zapping, setZapping] = useState(false);
2023-03-02 15:23:53 +00:00
const walletState = useWallet();
const wallet = walletState.wallet;
2023-09-23 21:21:37 +00:00
const canFastZap = wallet?.isReady() && !readonly;
2023-04-14 11:33:19 +00:00
const isMine = ev.pubkey === publicKey;
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
2023-04-25 11:57:09 +00:00
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
2023-02-25 21:18:36 +00:00
const longPress = useLongPress(
e => {
e.stopPropagation();
setTip(true);
},
{
captureEvent: true,
2023-09-12 21:58:37 +00:00
},
2023-02-25 21:18:36 +00:00
);
2023-01-08 00:29:59 +00:00
2023-01-20 17:07:14 +00:00
function hasReacted(emoji: string) {
2023-04-25 11:57:09 +00:00
return (
interactionCache.data.reacted ||
positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey)
);
2023-01-20 17:07:14 +00:00
}
2023-01-08 00:29:59 +00:00
2023-01-20 17:07:14 +00:00
function hasReposted() {
2023-04-25 11:57:09 +00:00
return interactionCache.data.reposted || reposts.some(a => a.pubkey === publicKey);
2023-01-20 17:07:14 +00:00
}
2023-01-08 00:29:59 +00:00
2023-01-20 17:07:14 +00:00
async function react(content: string) {
2023-04-14 15:02:15 +00:00
if (!hasReacted(content) && publisher) {
2023-02-07 19:47:57 +00:00
const evLike = await publisher.react(ev, content);
2023-10-13 15:34:31 +00:00
system.BroadcastEvent(evLike);
2023-12-18 11:17:06 +00:00
interactionCache.react();
2023-01-08 17:33:54 +00:00
}
2023-01-20 17:07:14 +00:00
}
2023-01-08 17:33:54 +00:00
2023-01-20 17:07:14 +00:00
async function repost() {
2023-04-14 15:02:15 +00:00
if (!hasReposted() && publisher) {
2023-03-28 14:34:01 +00:00
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
2023-02-07 19:47:57 +00:00
const evRepost = await publisher.repost(ev);
2023-10-13 15:34:31 +00:00
system.BroadcastEvent(evRepost);
2023-04-25 11:57:09 +00:00
await interactionCache.repost();
2023-01-20 17:07:14 +00:00
}
2023-01-08 00:29:59 +00:00
}
2023-01-20 17:07:14 +00:00
}
2023-01-08 00:29:59 +00:00
2023-09-14 11:31:17 +00:00
function getZapTarget(): Array<ZapTarget> | undefined {
if (ev.tags.some(v => v[0] === "zap")) {
return Zapper.fromEvent(ev);
}
2023-03-27 22:58:29 +00:00
2023-09-14 11:31:17 +00:00
const authorTarget = author?.lud16 || author?.lud06;
if (authorTarget) {
return [
{
type: "lnurl",
value: authorTarget,
weight: 1,
name: getDisplayName(author, ev.pubkey),
zap: {
pubkey: ev.pubkey,
2024-01-08 09:01:26 +00:00
event: link,
2023-09-14 11:31:17 +00:00
},
} as ZapTarget,
];
2023-04-05 10:58:26 +00:00
}
2023-03-27 22:58:29 +00:00
}
2023-03-12 19:25:22 +00:00
async function fastZap(e?: React.MouseEvent) {
if (zapping || e?.isPropagationStopped()) return;
2023-02-25 21:18:36 +00:00
2023-09-14 11:31:17 +00:00
const lnurl = getZapTarget();
2023-09-23 21:21:37 +00:00
if (canFastZap && lnurl) {
2023-02-25 21:18:36 +00:00
setZapping(true);
try {
2023-09-14 11:31:17 +00:00
await fastZapInner(lnurl, prefs.defaultZapAmount);
2023-02-25 21:18:36 +00:00
} catch (e) {
2023-02-27 21:19:26 +00:00
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
setTip(true);
}
2023-02-25 21:18:36 +00:00
} finally {
setZapping(false);
}
} else {
setTip(true);
}
}
2023-09-14 11:31:17 +00:00
async function fastZapInner(targets: Array<ZapTarget>, 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) {
2023-10-17 09:54:34 +00:00
if (CONFIG.features.zapPool) {
ZapPoolController?.allocate(totalSent);
}
2023-09-14 11:31:17 +00:00
await interactionCache.zap();
}
});
}
2023-03-12 19:25:22 +00:00
}
2023-03-10 23:52:39 +00:00
useEffect(() => {
2023-04-25 11:57:09 +00:00
if (prefs.autoZap && !didZap && !isMine && !zapping) {
2023-09-14 11:31:17 +00:00
const lnurl = getZapTarget();
2023-03-12 19:25:22 +00:00
if (wallet?.isReady() && lnurl) {
2023-03-11 17:03:42 +00:00
setZapping(true);
2023-03-10 23:52:39 +00:00
queueMicrotask(async () => {
try {
2023-09-14 11:31:17 +00:00
await fastZapInner(lnurl, prefs.defaultZapAmount);
2023-03-12 19:25:22 +00:00
} catch {
// ignored
2023-03-10 23:52:39 +00:00
} finally {
setZapping(false);
}
});
}
}
2023-03-11 17:03:42 +00:00
}, [prefs.autoZap, author, zapping]);
2023-03-10 23:52:39 +00:00
2023-08-18 18:01:34 +00:00
function powIcon() {
const pow = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
if (pow) {
return (
2023-11-20 19:16:47 +00:00
<AsyncFooterIcon
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
iconName="diamond"
value={pow}
/>
2023-08-18 18:01:34 +00:00
);
}
}
2023-01-20 17:07:14 +00:00
function tipButton() {
2023-09-14 11:31:17 +00:00
const targets = getZapTarget();
if (targets) {
return (
2023-08-18 18:01:34 +00:00
<AsyncFooterIcon
2023-10-18 08:19:50 +00:00
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
2023-08-18 18:01:34 +00:00
{...longPress()}
2023-11-20 19:16:47 +00:00
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
2023-09-23 21:21:37 +00:00
iconName={canFastZap ? "zapFast" : "zap"}
2023-08-18 18:01:34 +00:00
value={zapTotal}
onClick={e => fastZap(e)}
/>
);
}
2023-01-20 17:07:14 +00:00
return null;
}
2023-01-20 17:07:14 +00:00
function repostIcon() {
2023-09-23 21:21:37 +00:00
if (readonly) return;
2023-01-20 17:07:14 +00:00
return (
2023-10-13 11:40:39 +00:00
<Menu
menuButton={
<AsyncFooterIcon
2023-10-18 08:19:50 +00:00
className={hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue"}
2023-10-13 11:40:39 +00:00
iconName="repeat"
2023-11-20 19:16:47 +00:00
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
2023-10-13 11:40:39 +00:00
value={reposts.length}
/>
}
menuClassName="ctx-menu"
align="start">
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => repost()} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
2023-10-13 11:40:39 +00:00
</MenuItem>
<MenuItem
onClick={() =>
note.update(n => {
n.reset();
n.quote = ev;
n.show = true;
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
2023-10-13 11:40:39 +00:00
</MenuItem>
</Menu>
);
2023-01-20 17:07:14 +00:00
}
2023-08-18 18:11:05 +00:00
function reactionIcon() {
2023-04-25 09:15:15 +00:00
if (!prefs.enableReactions) {
2023-01-20 17:07:14 +00:00
return null;
}
2023-07-24 14:30:21 +00:00
const reacted = hasReacted("+");
2023-01-08 00:29:59 +00:00
return (
2023-08-18 18:01:34 +00:00
<AsyncFooterIcon
2023-10-18 08:19:50 +00:00
className={reacted ? "reacted text-nostr-red" : "hover:text-nostr-red"}
2023-08-18 18:01:34 +00:00
iconName={reacted ? "heart-solid" : "heart"}
2023-11-20 19:16:47 +00:00
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
2023-08-18 18:01:34 +00:00
value={positive.length}
2023-09-23 21:21:37 +00:00
onClick={async () => {
if (readonly) return;
await react(prefs.reactionEmoji);
}}
2023-08-18 18:01:34 +00:00
/>
);
2023-01-20 17:07:14 +00:00
}
2023-08-18 18:11:05 +00:00
function replyIcon() {
2023-09-23 21:21:37 +00:00
if (readonly) return;
2023-08-18 18:11:05 +00:00
return (
<AsyncFooterIcon
2023-10-18 08:19:50 +00:00
className={note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple"}
2023-08-18 18:11:05 +00:00
iconName="reply"
2023-11-20 19:16:47 +00:00
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
2023-10-13 10:22:58 +00:00
value={props.replies ?? 0}
2023-08-18 18:11:05 +00:00
onClick={async () => handleReplyButtonClick()}
/>
);
}
const handleReplyButtonClick = () => {
2023-09-21 20:02:59 +00:00
note.update(v => {
if (v.replyTo?.id !== ev.id) {
v.reset();
}
v.show = true;
v.replyTo = ev;
});
};
2023-01-20 17:07:14 +00:00
return (
2023-02-03 21:55:03 +00:00
<>
<div className="footer">
<div className="footer-reactions">
2023-08-18 18:11:05 +00:00
{replyIcon()}
Squashed commit of the following: commit 87cda09ac6442820a0b16933989525dcb53a4425 Merge: 02d9bbf7 2bec5d95 Author: Karnage <karnagebitcoin@gmail.com> Date: Tue Sep 19 23:13:32 2023 +0900 Merge branch 'css-fixes' commit 2bec5d95c00476537dc5460f01557af12c1baa7b Author: Karnage <karnagebitcoin@gmail.com> Date: Tue Sep 19 22:40:27 2023 +0900 minor tweak on tabs commit 776005e5eaafd5a8bc94cbf7821579a5bb831b1c Author: Karnage <karnagebitcoin@gmail.com> Date: Tue Sep 19 00:28:53 2023 +0900 added subscription tier borders in light mode commit 66a55feb9950f433787d2a6966b200339462c4c6 Author: Karnage <karnagebitcoin@gmail.com> Date: Tue Sep 19 00:28:43 2023 +0900 fixed tab colors in light mode commit 830cc3c973301d2ce63745e1e328c69a219db1ef Author: Karnage <karnagebitcoin@gmail.com> Date: Tue Sep 19 00:28:29 2023 +0900 secondary color adjustment commit 8c9939f3484c2dd5678ac72795dba7fa7f970402 Author: Karnage <karnagebitcoin@gmail.com> Date: Tue Sep 19 00:12:46 2023 +0900 szh menu shadow fix commit a59124f91055a4a9a3758823dee76e8bc44c5e83 Author: Karnage <karnagebitcoin@gmail.com> Date: Tue Sep 19 00:01:34 2023 +0900 misc fixes commit a4da5d86677eadace1b86df2d72e35255178a5f0 Author: Karnage <karnagebitcoin@gmail.com> Date: Mon Sep 18 17:50:15 2023 +0900 Re-ordered reactions for consistency commit 665162b6918b1666f1413884d4b4185d4df0f74a Author: Karnage <karnagebitcoin@gmail.com> Date: Mon Sep 18 13:25:55 2023 +0900 styled light load more button commit a3058168d6df685cdf9d2871f8e3b335318eedcb Author: Karnage <karnagebitcoin@gmail.com> Date: Mon Sep 18 00:38:16 2023 +0900 styled subscriptions a bit commit dcc940d96cefeca476e16891c148e2058fb48fd7 Author: Karnage <karnagebitcoin@gmail.com> Date: Mon Sep 18 00:22:39 2023 +0900 adjusted input border to 2px and font size 15 commit 690e1662eeded6eab9f3029ce168d44b35604dca Author: Karnage <karnagebitcoin@gmail.com> Date: Mon Sep 18 00:22:12 2023 +0900 fixed settings tabs paddings commit a5809c4c7d502da42788c6c758d6025e79699cf5 Author: Karnage <karnagebitcoin@gmail.com> Date: Mon Sep 18 00:05:45 2023 +0900 removed some double borders commit d1e0c331ed665c63160c2ae83f9e010b666c39df Author: Karnage <karnagebitcoin@gmail.com> Date: Mon Sep 18 00:05:28 2023 +0900 follow is primary now commit 338bc03aa3616906326fe0a777a63c04dc4a7f48 Author: Karnage <karnagebitcoin@gmail.com> Date: Mon Sep 18 00:05:10 2023 +0900 made follow button a primary commit 4b5eb0c4fc71e0a8cf822ba8cac8facc53301e1e Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 23:43:12 2023 +0900 made follows you into a label commit 0a9d616402528311f4feba53f0ba9171b0640274 Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 23:35:07 2023 +0900 created secondary button bg variable commit 2abcab804adf392e8f7ff2907762bc35c77f0297 Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 23:34:41 2023 +0900 added gap to profile buttons commit 04a54ddb0d7b19c5ea7f420645c6bd3d37747c17 Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 23:34:26 2023 +0900 change new note button color commit 2d2401586af70187785cff693061666d1de343f2 Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 22:57:01 2023 +0900 forget what I did here commit 14d8bd255cc41e5450b18908b807e3d745c10fbe Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 22:35:32 2023 +0900 adjusted new note button position commit e3a6143626424b898aad43fc2eb1625336342c16 Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 22:32:37 2023 +0900 added primary button colors commit 4d02bfef54668d1a09f0615634d6be540c9837ea Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 18:51:37 2023 +0900 cleaned up modal reactions commit 891f7985c6cf674d03188a95b3ca807ecb16c0bc Author: Karnage <karnagebitcoin@gmail.com> Date: Sun Sep 17 00:10:46 2023 +0900 minimized error text from scary to less scary commit dc563e7ded8794e43ed14409d5ffeb917172b800 Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 23:50:27 2023 +0900 adjusted search colors in light mode commit 328cc853794c9b32abf8eab067bfd3f9b1a76aee Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 22:59:33 2023 +0900 revert main font size, adjust thread root size commit 2e55cbcbc1bc6e9494410ec77052adb3fd8bbbcf Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 22:49:09 2023 +0900 re-styled quoted replies note creator commit a3257eecff0a028a22d975eec778daeb73d8ec2e Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 22:38:00 2023 +0900 got rid of weird filter menu shadow and radius commit 258d51c6fddb65d3afecd595a1fd9352c2cfa13e Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 22:12:03 2023 +0900 adjusted header bottom spacing per design to 8px commit 03669925086e8a4995124d6e9077d61373fa89d3 Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 22:00:34 2023 +0900 adjusted gap to 16 per design commit 9a1c77b0dd876632ecd313d25ce163204ff89897 Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 21:56:50 2023 +0900 set root font size to 16px per design commit 4d0a91317d8946465b0d591db3d13c2e3df63ff8 Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 21:50:57 2023 +0900 made search full width commit 3ea6b4ca005b27003f1554d53def14337620af2a Author: Karnage <karnagebitcoin@gmail.com> Date: Sat Sep 16 21:47:29 2023 +0900 fixed line height on thread root note
2023-09-22 08:33:21 +00:00
{repostIcon()}
{reactionIcon()}
{tipButton()}
2023-08-18 18:01:34 +00:00
{powIcon()}
2023-01-25 18:08:53 +00:00
</div>
2023-09-14 12:35:37 +00:00
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
</div>
2023-07-18 15:14:38 +00:00
<ZapsSummary zaps={zaps} />
2023-02-03 21:55:03 +00:00
</>
);
}
2023-08-18 18:01:34 +00:00
2023-11-17 11:52:10 +00:00
const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, ref) => {
2023-08-23 12:19:48 +00:00
const mergedProps = {
...props,
iconSize: 18,
2023-11-27 13:31:45 +00:00
className: classNames("transition duration-200 ease-in-out reaction-pill cursor-pointer", props.className),
2023-08-23 12:19:48 +00:00
};
2023-11-17 11:52:10 +00:00
2023-08-18 18:01:34 +00:00
return (
2023-11-17 11:52:10 +00:00
<AsyncIcon ref={ref} {...mergedProps}>
2023-08-18 18:11:05 +00:00
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
2023-08-23 12:19:48 +00:00
</AsyncIcon>
2023-08-18 18:01:34 +00:00
);
});
2024-01-04 11:13:28 +00:00
2024-01-04 12:05:13 +00:00
AsyncFooterIcon.displayName = "AsyncFooterIcon";