import "./Note.css"; import React, { useMemo, useState, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useInView } from "react-intersection-observer"; import { useIntl, FormattedMessage } from "react-intl"; import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap, NostrLink } from "@snort/system"; import { System } from "index"; import useEventPublisher from "Hooks/useEventPublisher"; import Icon from "Icons/Icon"; import ProfileImage from "Element/User/ProfileImage"; import Text from "Element/Text"; import { getReactions, dedupeByPubkey, tagFilterOfTextRepost, hexToBech32, normalizeReaction, Reaction, profileLink, findTag, } from "SnortUtils"; import NoteFooter from "Element/Event/NoteFooter"; import NoteTime from "Element/Event/NoteTime"; import Reveal from "Element/Event/Reveal"; import useModeration from "Hooks/useModeration"; import { UserCache } from "Cache"; import Poll from "Element/Event/Poll"; import useLogin from "Hooks/useLogin"; import { setBookmarked, setPinned } from "Login"; import { NostrFileElement } from "Element/Event/NostrFileHeader"; import ZapstrEmbed from "Element/Embed/ZapstrEmbed"; import PubkeyList from "Element/Embed/PubkeyList"; import { LiveEvent } from "Element/LiveEvent"; import { NoteContextMenu, NoteTranslation } from "Element/Event/NoteContextMenu"; import Reactions from "Element/Event/Reactions"; import { ZapGoal } from "Element/Event/ZapGoal"; import NoteReaction from "Element/Event/NoteReaction"; import ProfilePreview from "Element/User/ProfilePreview"; import { ProxyImg } from "Element/ProxyImg"; import messages from "../messages"; export interface NoteProps { data: TaggedNostrEvent; className?: string; related: readonly TaggedNostrEvent[]; highlight?: boolean; ignoreModeration?: boolean; onClick?: (e: TaggedNostrEvent) => void; depth?: number; options?: { showHeader?: boolean; showContextMenu?: boolean; showTime?: boolean; showPinned?: boolean; showBookmarked?: boolean; showFooter?: boolean; showReactionsLink?: boolean; showMedia?: boolean; canUnpin?: boolean; canUnbookmark?: boolean; canClick?: boolean; showMediaSpotlight?: boolean; }; } const HiddenNote = ({ children }: { children: React.ReactNode }) => { const [show, setShow] = useState(false); return show ? ( children ) : (

); }; export default function Note(props: NoteProps) { const { data: ev, className } = props; if (ev.kind === EventKind.Repost) { return ; } if (ev.kind === EventKind.FileHeader) { return ; } if (ev.kind === EventKind.ZapstrTrack) { return ; } if (ev.kind === EventKind.PubkeyLists) { return ; } if (ev.kind === EventKind.LiveEvent) { return ; } if (ev.kind === EventKind.SetMetadata) { return } pubkey={ev.pubkey} className="card" />; } if (ev.kind === (9041 as EventKind)) { return ; } return ; } export function NoteInner(props: NoteProps) { const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props; const baseClassName = `note card${className ? ` ${className}` : ""}`; const navigate = useNavigate(); const [showReactions, setShowReactions] = useState(false); const deletions = useMemo(() => getReactions(related, ev.id, EventKind.Deletion), [related]); const { isEventMuted } = useModeration(); const { ref, inView } = useInView({ triggerOnce: true }); const login = useLogin(); const { pinned, bookmarked } = 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 TaggedNostrEvent[], [Reaction.Negative]: [] as TaggedNostrEvent[], }, ); 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(a => parseZap(a, UserCache, ev)) .filter(z => z.valid); 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, showContextMenu: true, ...opt, }; async function unpin(id: HexKey) { if (options.canUnpin && publisher) { if (window.confirm(formatMessage(messages.ConfirmUnpin))) { const es = pinned.item.filter(e => e !== id); const ev = await publisher.noteList(es, Lists.Pinned); System.BroadcastEvent(ev); setPinned(login, es, ev.created_at * 1000); } } } async function unbookmark(id: HexKey) { if (options.canUnbookmark && publisher) { if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) { const es = bookmarked.item.filter(e => e !== id); const ev = await publisher.noteList(es, Lists.Bookmarked); System.BroadcastEvent(ev); setBookmarked(login, es, ev.created_at * 1000); } } } const innerContent = () => { if (ev.kind === EventKind.LongFormTextNote) { const title = findTag(ev, "title"); const summary = findTag(ev, "simmary"); const image = findTag(ev, "image"); return (

{title}

{summary}

{image && }
); } else { const body = ev?.content ?? ""; return ( ); } }; const transformBody = () => { if (deletions?.length > 0) { return ( ); } const contentWarning = ev.tags.find(a => a[0] === "content-warning"); if (contentWarning) { return ( {c}, }} /> {contentWarning[1] && ( <>   {c}, reason: contentWarning[1], }} /> )}   }> {innerContent()} ); } return innerContent(); }; function goToEvent( e: React.MouseEvent, eTarget: TaggedNostrEvent, isTargetAllowed: boolean = e.target === e.currentTarget, ) { if (!isTargetAllowed || opt?.canClick === false) { return; } e.stopPropagation(); if (props.onClick) { props.onClick(eTarget); return; } const link = NostrLink.fromEvent(eTarget); // detect cmd key and open in new tab if (e.metaKey) { window.open(`/e/${link.encode()}`, "_blank"); } else { navigate(`/e/${link.encode()}`, { state: eTarget, }); } } function replyTag() { const thread = EventExt.extractThread(ev); if (thread === undefined) { return undefined; } const maxMentions = 2; const replyTo = thread?.replyTo ?? thread?.root; const replyLink = replyTo ? NostrLink.fromTag( [replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0), ) : undefined; const mentions: { pk: string; name: string; link: ReactNode }[] = []; for (const pk of thread?.pubKeys ?? []) { const u = UserCache.getFromCache(pk); const npub = hexToBech32(NostrPrefix.PublicKey, 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(NostrPrefix.PublicKey) ? 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} ) : ( replyLink && {replyLink.encode().substring(0, 12)} )}
); } const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls, EventKind.LongFormTextNote]; if (!canRenderAsTextNote.includes(ev.kind)) { const alt = findTag(ev, "alt"); if (alt) { return (
); } else { return ( <>

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

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

); } } function pollOptions() { if (ev.kind !== EventKind.Polls) return; return ; } function content() { if (!inView) return undefined; return ( <> {options.showHeader && (
{(options.showTime || options.showBookmarked) && ( <> {options.showBookmarked && (
unbookmark(ev.id)}>
)} {!options.showBookmarked && } )} {options.showPinned && (
unpin(ev.id)}>
)} {options.showContextMenu && ( {}} onTranslated={t => setTranslated(t)} setShowReactions={setShowReactions} /> )}
)}
goToEvent(e, ev, true)}> {transformBody()} {translation()} {pollOptions()} {options.showReactionsLink && (
setShowReactions(true)}>
)}
{options.showFooter && } ); } const note = (
goToEvent(e, ev)} ref={ref}> {content()}
); return !ignoreModeration && isEventMuted(ev) ? {note} : note; }