diff --git a/packages/app/src/Element/Event/HiddenNote.tsx b/packages/app/src/Element/Event/HiddenNote.tsx new file mode 100644 index 00000000..f55f885c --- /dev/null +++ b/packages/app/src/Element/Event/HiddenNote.tsx @@ -0,0 +1,23 @@ +import messages from "../messages"; +import { useState } from "react"; +import { FormattedMessage } from "react-intl"; + +const HiddenNote = ({ children }: { children: React.ReactNode }) => { + const [show, setShow] = useState(false); + return show ? ( + children + ) : ( +
+
+

+ +

+ +
+
+ ); +}; + +export default HiddenNote; diff --git a/packages/app/src/Element/Event/Note.tsx b/packages/app/src/Element/Event/Note.tsx index 8cf17aa0..aba48e7c 100644 --- a/packages/app/src/Element/Event/Note.tsx +++ b/packages/app/src/Element/Event/Note.tsx @@ -1,45 +1,14 @@ 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 React from "react"; +import { EventKind, TaggedNostrEvent } from "@snort/system"; 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"; +import { NoteInner } from "./NoteInner"; export interface NoteProps { data: TaggedNostrEvent; @@ -66,24 +35,6 @@ export interface NoteProps { }; } -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) { @@ -110,366 +61,3 @@ export default function Note(props: NoteProps) { 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; -} diff --git a/packages/app/src/Element/Event/NoteInner.tsx b/packages/app/src/Element/Event/NoteInner.tsx new file mode 100644 index 00000000..4fc00af5 --- /dev/null +++ b/packages/app/src/Element/Event/NoteInner.tsx @@ -0,0 +1,397 @@ +import { Link, useNavigate } from "react-router-dom"; +import React, { ReactNode, useMemo, useState } from "react"; +import { + dedupeByPubkey, + findTag, + getReactions, + hexToBech32, + normalizeReaction, + profileLink, + Reaction, + tagFilterOfTextRepost, +} from "../../SnortUtils"; +import useModeration from "../../Hooks/useModeration"; +import { useInView } from "react-intersection-observer"; +import useLogin from "../../Hooks/useLogin"; +import useEventPublisher from "../../Hooks/useEventPublisher"; +import { NoteContextMenu, NoteTranslation } from "./NoteContextMenu"; +import { FormattedMessage, useIntl } from "react-intl"; +import { UserCache } from "../../Cache"; +import messages from "../messages"; +import { System } from "../../index"; +import { setBookmarked, setPinned } from "../../Login"; +import Text from "../Text"; +import { ProxyImg } from "../ProxyImg"; +import Reveal from "./Reveal"; +import Poll from "./Poll"; +import ProfileImage from "../User/ProfileImage"; +import Icon from "../../Icons/Icon"; +import NoteTime from "./NoteTime"; +import NoteFooter from "./NoteFooter"; +import Reactions from "./Reactions"; +import HiddenNote from "./HiddenNote"; +import { NoteProps } from "./Note"; +import { EventExt, EventKind, HexKey, Lists, NostrLink, NostrPrefix, parseZap, TaggedNostrEvent } from "@snort/system"; + +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; +}