import "./Note.css"; import React, { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useSelector, useDispatch } from "react-redux"; import { useInView } from "react-intersection-observer"; import { useIntl, FormattedMessage } from "react-intl"; import useEventPublisher from "Feed/EventPublisher"; import Icon from "Icons/Icon"; import { parseZap } from "Element/Zap"; import ProfileImage from "Element/ProfileImage"; import Text from "Element/Text"; import { eventLink, getReactions, dedupeByPubkey, tagFilterOfTextRepost, hexToBech32, normalizeReaction, Reaction, profileLink, } from "Util"; import NoteFooter, { Translation } from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; import { TaggedRawEvent, u256, HexKey, Event as NEvent, EventKind } from "@snort/nostr"; import useModeration from "Hooks/useModeration"; import { setPinned, setBookmarked } from "State/Login"; import type { RootState } from "State/Store"; import { UserCache } from "State/Users/UserCache"; import messages from "./messages"; export interface NoteProps { data?: TaggedRawEvent; className?: string; related: TaggedRawEvent[]; highlight?: boolean; ignoreModeration?: boolean; options?: { showHeader?: boolean; showTime?: boolean; showPinned?: boolean; showBookmarked?: boolean; showFooter?: boolean; showReactionsLink?: boolean; canUnpin?: boolean; canUnbookmark?: boolean; }; ["data-ev"]?: NEvent; } const HiddenNote = ({ children }: { children: React.ReactNode }) => { const [show, setShow] = useState(false); return show ? ( <>{children} ) : (

); }; export default function Note(props: NoteProps) { const navigate = useNavigate(); const dispatch = useDispatch(); const { data, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props; const [showReactions, setShowReactions] = useState(false); const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); const { isMuted } = useModeration(); const isOpMuted = isMuted(ev.PubKey); const { ref, inView, entry } = useInView({ triggerOnce: true }); const [extendable, setExtendable] = useState(false); const [showMore, setShowMore] = useState(false); const baseClassName = `note card ${props.className ? props.className : ""}`; const { pinned, bookmarked } = useSelector((s: RootState) => s.login); const publisher = useEventPublisher(); const [translated, setTranslated] = useState(); const { formatMessage } = useIntl(); const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]); const groupReactions = useMemo(() => { const result = reactions?.reduce( (acc, reaction) => { const kind = normalizeReaction(reaction.content); const rs = acc[kind] || []; return { ...acc, [kind]: [...rs, reaction] }; }, { [Reaction.Positive]: [] as TaggedRawEvent[], [Reaction.Negative]: [] as TaggedRawEvent[], } ); return { [Reaction.Positive]: dedupeByPubkey(result[Reaction.Positive]), [Reaction.Negative]: dedupeByPubkey(result[Reaction.Negative]), }; }, [reactions]); const positive = groupReactions[Reaction.Positive]; const negative = groupReactions[Reaction.Negative]; const reposts = useMemo( () => dedupeByPubkey([ ...getReactions(related, ev.Id, EventKind.TextNote).filter(e => e.tags.some(tagFilterOfTextRepost(e, ev.Id))), ...getReactions(related, ev.Id, EventKind.Repost), ]), [related, ev] ); const zaps = useMemo(() => { const sortedZaps = getReactions(related, ev.Id, EventKind.ZapReceipt) .map(parseZap) .filter(z => z.valid && z.sender !== ev.PubKey); sortedZaps.sort((a, b) => b.amount - a.amount); return sortedZaps; }, [related]); const totalReactions = positive.length + negative.length + reposts.length + zaps.length; const options = { showHeader: true, showTime: true, showFooter: true, canUnpin: false, canUnbookmark: false, ...opt, }; async function unpin(id: HexKey) { if (options.canUnpin) { if (window.confirm(formatMessage(messages.ConfirmUnpin))) { const es = pinned.filter(e => e !== id); const ev = await publisher.pinned(es); publisher.broadcast(ev); dispatch(setPinned({ keys: es, createdAt: new Date().getTime() })); } } } async function unbookmark(id: HexKey) { if (options.canUnbookmark) { if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) { const es = bookmarked.filter(e => e !== id); const ev = await publisher.bookmarked(es); publisher.broadcast(ev); dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() })); } } } const transformBody = useCallback(() => { const body = ev?.Content ?? ""; if (deletions?.length > 0) { return ( ); } return ; }, [ev]); useLayoutEffect(() => { if (entry && inView && extendable === false) { const h = (entry?.target as HTMLDivElement)?.offsetHeight ?? 0; if (h > 650) { setExtendable(true); } } }, [inView, entry, extendable]); function goToEvent(e: React.MouseEvent, id: u256, isTargetAllowed: boolean = e.target === e.currentTarget) { if (!isTargetAllowed) { return; } e.stopPropagation(); // detect cmd key and open in new tab if (e.metaKey) { window.open(eventLink(id), "_blank"); } else { navigate(eventLink(id)); } } function replyTag() { if (ev.Thread === null) { return null; } const maxMentions = 2; const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; const mentions: { pk: string; name: string; link: ReactNode }[] = []; for (const pk of ev.Thread?.PubKeys ?? []) { const u = UserCache.get(pk); const npub = hexToBech32("npub", pk); const shortNpub = npub.substring(0, 12); mentions.push({ pk, name: u?.name ?? shortNpub, link: {u?.name ? `@${u.name}` : shortNpub}, }); } mentions.sort(a => (a.name.startsWith("npub") ? 1 : -1)); const othersLength = mentions.length - maxMentions; const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => { return ( {idx > 0 && ", "} {m.link} ); }; const pubMentions = mentions.length > maxMentions ? mentions?.slice(0, maxMentions).map(renderMention) : mentions?.map(renderMention); const others = mentions.length > maxMentions ? formatMessage(messages.Others, { n: othersLength }) : ""; return (
re:  {(mentions?.length ?? 0) > 0 ? ( <> {pubMentions} {others} ) : ( replyId && {hexToBech32("note", replyId)?.substring(0, 12)} )}
); } if (ev.Kind !== EventKind.TextNote) { return ( <>

{JSON.stringify(ev.ToObject(), undefined, "  ")}
); } function translation() { if (translated && translated.confidence > 0.5) { return ( <>

{translated.text} ); } else if (translated) { return (

); } } function content() { if (!inView) return null; return ( <> {options.showHeader && (
{(options.showTime || options.showBookmarked) && (
{options.showBookmarked && (
unbookmark(ev.Id)}>
)} {!options.showBookmarked && }
)} {options.showPinned && (
unpin(ev.Id)}>
)}
)}
goToEvent(e, ev.Id, true)}> {transformBody()} {translation()} {options.showReactionsLink && (
setShowReactions(true)}>
)}
{extendable && !showMore && ( setShowMore(true)}> )} {options.showFooter && ( setTranslated(t)} showReactions={showReactions} setShowReactions={setShowReactions} /> )} ); } const note = (
goToEvent(e, ev.Id)} ref={ref}> {content()}
); return !ignoreModeration && isOpMuted ? {note} : note; }