snort/packages/app/src/Element/NoteFooter.tsx

411 lines
13 KiB
TypeScript
Raw Normal View History

2023-03-10 23:52:39 +00:00
import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
2023-02-08 21:10:26 +00:00
import { useIntl, FormattedMessage } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
2023-02-25 21:18:36 +00:00
import { useLongPress } from "use-long-press";
2023-03-03 22:40:49 +00:00
import { Event as NEvent, TaggedRawEvent, HexKey, u256 } from "@snort/nostr";
2023-01-08 00:29:59 +00:00
2023-03-02 17:47:02 +00:00
import Icon from "Icons/Icon";
2023-03-02 15:23:53 +00:00
import Spinner from "Icons/Spinner";
2023-03-02 17:47:02 +00:00
2023-01-20 11:11:50 +00:00
import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
2023-03-03 22:40:49 +00:00
import { bech32ToHex, hexToBech32, normalizeReaction, unwrap } from "Util";
2023-01-20 11:11:50 +00:00
import { NoteCreator } from "Element/NoteCreator";
2023-02-08 21:10:26 +00:00
import Reactions from "Element/Reactions";
2023-02-07 13:32:32 +00:00
import SendSats from "Element/SendSats";
2023-02-12 12:31:48 +00:00
import { ParsedZap, ZapsSummary } from "Element/Zap";
2023-03-03 14:30:31 +00:00
import { useUserProfile } from "Hooks/useUserProfile";
2023-01-20 11:11:50 +00:00
import { RootState } from "State/Store";
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
2023-01-26 11:34:18 +00:00
import useModeration from "Hooks/useModeration";
2023-03-03 22:40:49 +00:00
import { SnortPubKey, TranslateHost } from "Const";
2023-02-25 21:18:36 +00:00
import { LNURL } from "LNURL";
2023-03-03 22:01:15 +00:00
import { DonateLNURL } from "Pages/DonatePage";
2023-03-02 15:23:53 +00:00
import { useWallet } from "Wallet";
2023-01-31 11:52:55 +00:00
2023-02-08 21:10:26 +00:00
import messages from "./messages";
2023-03-10 23:52:39 +00:00
// a dumb cache to remember which notes we zapped
class DumbZapCache {
#set: Set<u256> = new Set();
constructor() {
this.#load();
}
add(id: u256) {
this.#set.add(this.#truncId(id));
this.#save();
}
has(id: u256) {
return this.#set.has(this.#truncId(id));
}
#truncId(id: u256) {
return id.slice(0, 12);
}
#save() {
window.localStorage.setItem("zap-cache", JSON.stringify([...this.#set]));
}
#load() {
const data = window.localStorage.getItem("zap-cache");
if (data) {
this.#set = new Set<u256>(JSON.parse(data) as Array<u256>);
}
}
}
const ZapCache = new DumbZapCache();
2023-01-31 11:52:55 +00:00
export interface Translation {
text: string;
fromLanguage: string;
confidence: number;
2023-01-31 11:52:55 +00:00
}
2023-01-08 00:29:59 +00:00
2023-01-16 17:48:25 +00:00
export interface NoteFooterProps {
2023-02-12 12:31:48 +00:00
reposts: TaggedRawEvent[];
zaps: ParsedZap[];
positive: TaggedRawEvent[];
negative: TaggedRawEvent[];
showReactions: boolean;
setShowReactions(b: boolean): void;
ev: NEvent;
onTranslated?: (content: Translation) => void;
2023-01-16 17:48:25 +00:00
}
export default function NoteFooter(props: NoteFooterProps) {
2023-02-12 12:31:48 +00:00
const { ev, showReactions, setShowReactions, positive, negative, reposts, zaps } = props;
const dispatch = useDispatch();
2023-02-08 21:10:26 +00:00
const { formatMessage } = useIntl();
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
2023-02-09 12:26:54 +00:00
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const { mute, block } = useModeration();
2023-02-09 12:26:54 +00:00
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useUserProfile(ev.RootPubKey);
2023-01-20 17:07:14 +00:00
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
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-01-20 17:07:14 +00:00
const isMine = ev.RootPubKey === login;
2023-01-31 11:52:55 +00:00
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
2023-03-10 23:52:39 +00:00
const didZap = ZapCache.has(ev.Id) || zaps.some(a => a.sender === login);
2023-02-25 21:18:36 +00:00
const longPress = useLongPress(
e => {
e.stopPropagation();
setTip(true);
},
{
captureEvent: true,
}
);
2023-01-08 00:29:59 +00:00
2023-01-20 17:07:14 +00:00
function hasReacted(emoji: string) {
2023-02-12 12:31:48 +00:00
return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login);
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-02-09 12:26:54 +00:00
return reposts.some(a => a.pubkey === login);
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) {
if (!hasReacted(content)) {
2023-02-07 19:47:57 +00:00
const evLike = await publisher.react(ev, content);
2023-01-20 17:07:14 +00:00
publisher.broadcast(evLike);
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 deleteEvent() {
2023-02-09 12:26:54 +00:00
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.Id.substring(0, 8) }))) {
2023-02-07 19:47:57 +00:00
const evDelete = await publisher.delete(ev.Id);
2023-01-20 17:07:14 +00:00
publisher.broadcast(evDelete);
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-01-20 17:07:14 +00:00
async function repost() {
if (!hasReposted()) {
2023-02-09 12:26:54 +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-01-20 17:07:14 +00:00
publisher.broadcast(evRepost);
}
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-02-27 19:15:39 +00:00
async function fastZap(e: React.MouseEvent) {
2023-02-25 21:18:36 +00:00
if (zapping || e.isPropagationStopped()) return;
const lnurl = author?.lud16 || author?.lud06;
2023-03-02 15:23:53 +00:00
if (wallet?.isReady() && lnurl) {
2023-02-25 21:18:36 +00:00
setZapping(true);
try {
2023-03-03 22:40:49 +00:00
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.PubKey, ev.Id);
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-03-03 22:40:49 +00:00
async function fastZapInner(lnurl: string, amount: number, key: HexKey, id?: u256) {
2023-03-03 22:01:15 +00:00
if (wallet?.isReady() && lnurl) {
const handler = new LNURL(lnurl);
await handler.load();
2023-03-03 22:40:49 +00:00
const zap = handler.canZap ? await publisher.zap(amount * 1000, key, id) : undefined;
2023-03-03 22:01:15 +00:00
const invoice = await handler.getInvoice(amount, undefined, zap);
await wallet.payInvoice(unwrap(invoice.pr));
2023-03-10 23:52:39 +00:00
if (prefs.fastZapDonate > 0) {
// spin off donate
const donateAmount = Math.floor(prefs.defaultZapAmount * prefs.fastZapDonate);
if (donateAmount > 0) {
console.debug(`Donating ${donateAmount} sats to ${DonateLNURL}`);
fastZapInner(DonateLNURL, donateAmount, bech32ToHex(SnortPubKey))
.then(() => console.debug("Donation sent! Thank You!"))
.catch(() => console.debug("Failed to donate"));
}
}
2023-03-03 22:01:15 +00:00
}
}
2023-03-10 23:52:39 +00:00
useEffect(() => {
if (prefs.autoZap) {
const lnurl = author?.lud16 || author?.lud06;
if (wallet?.isReady() && lnurl && !ZapCache.has(ev.Id)) {
queueMicrotask(async () => {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.PubKey, ev.Id);
ZapCache.add(ev.Id);
} finally {
setZapping(false);
}
});
}
}
}, [prefs.autoZap]);
2023-01-20 17:07:14 +00:00
function tipButton() {
2023-02-07 19:47:57 +00:00
const service = author?.lud16 || author?.lud06;
2023-01-20 17:07:14 +00:00
if (service) {
return (
2023-01-20 17:07:14 +00:00
<>
2023-02-27 19:15:39 +00:00
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} {...longPress()} onClick={e => fastZap(e)}>
2023-03-03 23:04:26 +00:00
{zapping ? <Spinner /> : wallet?.isReady() ? <Icon name="zapFast" /> : <Icon name="zap" />}
2023-02-09 12:26:54 +00:00
{zapTotal > 0 && <div className="reaction-pill-number">{formatShort(zapTotal)}</div>}
</div>
2023-01-20 17:07:14 +00:00
</>
);
}
2023-01-20 17:07:14 +00:00
return null;
}
2023-01-20 17:07:14 +00:00
function repostIcon() {
return (
2023-02-09 12:26:54 +00:00
<div className={`reaction-pill ${hasReposted() ? "reacted" : ""}`} onClick={() => repost()}>
2023-03-02 18:39:29 +00:00
<Icon name="repost" size={17} />
2023-02-09 12:26:54 +00:00
{reposts.length > 0 && <div className="reaction-pill-number">{formatShort(reposts.length)}</div>}
2023-01-20 17:07:14 +00:00
</div>
);
2023-01-20 17:07:14 +00:00
}
2023-01-20 17:07:14 +00:00
function reactionIcons() {
if (!prefs.enableReactions) {
return null;
}
2023-01-08 00:29:59 +00:00
return (
2023-01-20 17:07:14 +00:00
<>
2023-02-10 19:23:52 +00:00
<div
className={`reaction-pill ${hasReacted("+") ? "reacted" : ""} `}
onClick={() => react(prefs.reactionEmoji)}>
2023-03-02 18:39:29 +00:00
<Icon name="heart" />
2023-02-09 12:26:54 +00:00
<div className="reaction-pill-number">{formatShort(positive.length)}</div>
2023-01-20 17:07:14 +00:00
</div>
{repostIcon()}
</>
);
2023-01-20 17:07:14 +00:00
}
2023-01-20 22:59:26 +00:00
async function share() {
2023-02-09 12:26:54 +00:00
const url = `${window.location.protocol}//${window.location.host}/e/${hexToBech32("note", ev.Id)}`;
2023-01-20 22:59:26 +00:00
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
2023-01-20 22:59:26 +00:00
});
} else {
await navigator.clipboard.writeText(url);
}
}
2023-01-31 11:52:55 +00:00
async function translate() {
const res = await fetch(`${TranslateHost}/translate`, {
method: "POST",
body: JSON.stringify({
q: ev.Content,
source: "auto",
2023-02-06 22:06:47 +00:00
target: lang.split("-")[0],
2023-01-31 11:52:55 +00:00
}),
headers: { "Content-Type": "application/json" },
2023-01-31 11:52:55 +00:00
});
if (res.ok) {
2023-02-07 19:47:57 +00:00
const result = await res.json();
2023-01-31 11:52:55 +00:00
if (typeof props.onTranslated === "function" && result) {
props.onTranslated({
text: result.translatedText,
fromLanguage: langNames.of(result.detectedLanguage.language),
confidence: result.detectedLanguage.confidence,
2023-01-31 11:52:55 +00:00
} as Translation);
}
}
}
2023-01-21 11:59:19 +00:00
async function copyId() {
await navigator.clipboard.writeText(hexToBech32("note", ev.Id));
}
async function pin(id: HexKey) {
const es = [...pinned, id];
const ev = await publisher.pinned(es);
publisher.broadcast(ev);
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
}
async function bookmark(id: HexKey) {
const es = [...bookmarked, id];
const ev = await publisher.bookmarked(es);
publisher.broadcast(ev);
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
}
2023-01-21 17:00:09 +00:00
async function copyEvent() {
2023-02-09 12:26:54 +00:00
await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, " "));
2023-01-21 17:00:09 +00:00
}
2023-01-20 22:59:26 +00:00
function menuItems() {
return (
<>
2023-02-14 22:47:28 +00:00
<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>
2023-02-28 19:50:00 +00:00
<MenuItem onClick={() => setShowReactions(true)}>
2023-03-02 17:47:02 +00:00
<Icon name="heart" />
2023-02-28 19:50:00 +00:00
<FormattedMessage {...messages.Reactions} />
</MenuItem>
2023-01-20 22:59:26 +00:00
<MenuItem onClick={() => share()}>
2023-03-02 17:47:02 +00:00
<Icon name="share" />
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.Share} />
2023-01-20 22:59:26 +00:00
</MenuItem>
{!pinned.includes(ev.Id) && (
<MenuItem onClick={() => pin(ev.Id)}>
2023-03-02 17:47:02 +00:00
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.includes(ev.Id) && (
<MenuItem onClick={() => bookmark(ev.Id)}>
2023-03-02 18:39:29 +00:00
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
2023-01-21 11:59:19 +00:00
<MenuItem onClick={() => copyId()}>
2023-03-04 16:54:56 +00:00
<Icon name="copy" />
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.CopyID} />
2023-01-21 11:59:19 +00:00
</MenuItem>
2023-01-26 11:34:18 +00:00
<MenuItem onClick={() => mute(ev.PubKey)}>
2023-03-02 17:47:02 +00:00
<Icon name="mute" />
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.Mute} />
</MenuItem>
2023-02-08 21:10:26 +00:00
{prefs.enableReactions && (
<MenuItem onClick={() => react("-")}>
2023-03-04 16:54:56 +00:00
<Icon name="dislike" />
2023-02-09 11:24:15 +00:00
<FormattedMessage {...messages.DislikeAction} />
2023-02-08 21:10:26 +00:00
</MenuItem>
)}
<MenuItem onClick={() => block(ev.PubKey)}>
2023-03-02 17:47:02 +00:00
<Icon name="block" />
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.Block} />
2023-01-26 11:34:18 +00:00
</MenuItem>
2023-01-31 11:52:55 +00:00
<MenuItem onClick={() => translate()}>
2023-03-02 17:47:02 +00:00
<Icon name="translate" />
2023-02-09 12:26:54 +00:00
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
2023-01-31 11:52:55 +00:00
</MenuItem>
2023-01-21 17:00:09 +00:00
{prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}>
2023-03-02 17:47:02 +00:00
<Icon name="json" />
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.CopyJSON} />
2023-01-21 17:00:09 +00:00
</MenuItem>
)}
2023-01-20 22:59:26 +00:00
{isMine && (
<MenuItem onClick={() => deleteEvent()}>
2023-03-02 17:47:02 +00:00
<Icon name="trash" className="red" />
2023-02-08 21:10:26 +00:00
<FormattedMessage {...messages.Delete} />
2023-01-20 22:59:26 +00:00
</MenuItem>
)}
</>
);
2023-01-20 22:59:26 +00:00
}
2023-01-21 11:59:19 +00:00
2023-01-20 17:07:14 +00:00
return (
2023-02-03 21:55:03 +00:00
<>
<div className="footer">
<div className="footer-reactions">
{tipButton()}
{reactionIcons()}
2023-02-09 12:26:54 +00:00
<div className={`reaction-pill ${reply ? "reacted" : ""}`} onClick={() => setReply(s => !s)}>
2023-03-02 18:39:29 +00:00
<Icon name="reply" size={17} />
2023-01-25 18:08:53 +00:00
</div>
<Menu
menuButton={
<div className="reaction-pill">
2023-03-02 18:39:29 +00:00
<Icon name="dots" size={15} />
</div>
}
2023-02-09 12:26:54 +00:00
menuClassName="ctx-menu">
{menuItems()}
</Menu>
2023-01-25 18:08:53 +00:00
</div>
2023-02-09 12:26:54 +00:00
<NoteCreator autoFocus={true} replyTo={ev} onSend={() => setReply(false)} show={reply} setShow={setReply} />
2023-02-08 21:10:26 +00:00
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
/>
<SendSats
2023-02-25 21:18:36 +00:00
lnurl={author?.lud16 || author?.lud06}
onClose={() => setTip(false)}
show={tip}
author={author?.pubkey}
target={author?.display_name || author?.name}
note={ev.Id}
/>
</div>
<div className="zaps-container">
<ZapsSummary zaps={zaps} />
2023-01-20 17:07:14 +00:00
</div>
2023-02-03 21:55:03 +00:00
</>
);
}