diff --git a/packages/app/package.json b/packages/app/package.json index 0a5dd2530..eef5446db 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -23,6 +23,8 @@ "emojilib": "^3.0.10", "highlight.js": "^11.8.0", "light-bolt11-decoder": "^2.1.0", + "marked": "^9.1.0", + "marked-footnote": "^1.0.0", "match-sorter": "^6.3.1", "qr-code-styling": "^1.6.0-rc.1", "react": "^18.2.0", diff --git a/packages/app/src/Element/Deck/Articles.tsx b/packages/app/src/Element/Deck/Articles.tsx index fa9b4a987..a81ebd3ce 100644 --- a/packages/app/src/Element/Deck/Articles.tsx +++ b/packages/app/src/Element/Deck/Articles.tsx @@ -11,7 +11,14 @@ export default function Articles() { return ( <> {orderDescending(data.data ?? []).map(a => ( - + ))} ); diff --git a/packages/app/src/Element/Event/LongFormText.css b/packages/app/src/Element/Event/LongFormText.css new file mode 100644 index 000000000..43a4221da --- /dev/null +++ b/packages/app/src/Element/Event/LongFormText.css @@ -0,0 +1,51 @@ +.long-form-note .header-image { + height: 360px; + background: var(--img); + background-position: center; + background-size: cover; +} + +.long-form-note h1 { + font-size: 32px; + font-weight: 700; + line-height: 40px; /* 125% */ + margin: 0; +} + +.long-form-note small { + font-weight: 400; + line-height: 24px; /* 150% */ +} + +.long-form-note img:not(.custom-emoji), +.long-form-note video, +.long-form-note iframe, +.long-form-note audio { + width: 100%; + display: block; +} + +.long-form-note iframe, +.long-form-note video { + width: -webkit-fill-available; + aspect-ratio: 16 / 9; +} + +.long-form-note .footer { + display: flex; +} + +.long-form-note .footer .footer-reactions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-left: auto; + gap: 48px; +} + +@media (min-width: 720px) { + .long-form-note .footer .footer-reactions { + margin-left: 0; + } +} diff --git a/packages/app/src/Element/Event/LongFormText.tsx b/packages/app/src/Element/Event/LongFormText.tsx new file mode 100644 index 000000000..6d48770e9 --- /dev/null +++ b/packages/app/src/Element/Event/LongFormText.tsx @@ -0,0 +1,75 @@ +import "./LongFormText.css"; +import { Link } from "react-router-dom"; +import { FormattedMessage } from "react-intl"; +import { NostrLink, TaggedNostrEvent } from "@snort/system"; + +import { findTag } from "SnortUtils"; +import Text from "Element/Text"; +import { Markdown } from "./Markdown"; +import useImgProxy from "Hooks/useImgProxy"; +import { CSSProperties } from "react"; +import ProfilePreview from "Element/User/ProfilePreview"; +import NoteFooter from "./NoteFooter"; +import { useEventReactions } from "Hooks/useEventReactions"; +import NoteTime from "./NoteTime"; + +interface LongFormTextProps { + ev: TaggedNostrEvent; + isPreview: boolean; + related: ReadonlyArray; +} + +export function LongFormText(props: LongFormTextProps) { + const title = findTag(props.ev, "title"); + const summary = findTag(props.ev, "summary"); + const image = findTag(props.ev, "image"); + const { proxy } = useImgProxy(); + const { reactions, reposts, zaps } = useEventReactions(props.ev, props.related); + + function previewText() { + return ( + <> + + + + + + ); + } + + function fullText() { + return ( + <> + + + + ); + } + + return ( +
+ + + + } + options={{ + about: false, + }} + /> +

{title}

+ {summary} + {image &&
} + {props.isPreview ? previewText() : fullText()} +
+ ); +} diff --git a/packages/app/src/Element/Event/Markdown.css b/packages/app/src/Element/Event/Markdown.css new file mode 100644 index 000000000..35b059d48 --- /dev/null +++ b/packages/app/src/Element/Event/Markdown.css @@ -0,0 +1,31 @@ +.markdown a { + color: var(--highlight); +} + +.markdown blockquote { + margin: 0; + color: var(--font-secondary-color); + border-left: 2px solid var(--font-secondary-color); + padding-left: 12px; +} + +.markdown hr { + border: 0; + height: 1px; + background-image: var(--gray-gradient); + margin: 20px; +} + +.markdown img:not(.custom-emoji), +.markdown video, +.markdown iframe, +.markdown audio { + width: 100%; + display: block; +} + +.markdown iframe, +.markdown video { + width: -webkit-fill-available; + aspect-ratio: 16 / 9; +} diff --git a/packages/app/src/Element/Event/Markdown.tsx b/packages/app/src/Element/Event/Markdown.tsx new file mode 100644 index 000000000..79be59745 --- /dev/null +++ b/packages/app/src/Element/Event/Markdown.tsx @@ -0,0 +1,113 @@ +import "./Markdown.css"; + +import { ReactNode, useMemo } from "react"; +import { marked, Token } from "marked"; +import { Link } from "react-router-dom"; +import markedFootnote, { Footnotes, Footnote, FootnoteRef } from "marked-footnote"; +import { ProxyImg } from "Element/ProxyImg"; + +interface MarkdownProps { + content: string; + tags?: Array>; +} + +function renderToken(t: Token | Footnotes | Footnote | FootnoteRef): ReactNode { + try { + switch (t.type) { + case "paragraph": { + return

{t.tokens ? t.tokens.map(renderToken) : t.raw}

; + } + case "image": { + return ; + } + case "heading": { + switch (t.depth) { + case 1: + return

{t.tokens ? t.tokens.map(renderToken) : t.raw}

; + case 2: + return

{t.tokens ? t.tokens.map(renderToken) : t.raw}

; + case 3: + return

{t.tokens ? t.tokens.map(renderToken) : t.raw}

; + case 4: + return

{t.tokens ? t.tokens.map(renderToken) : t.raw}

; + case 5: + return
{t.tokens ? t.tokens.map(renderToken) : t.raw}
; + case 6: + return
{t.tokens ? t.tokens.map(renderToken) : t.raw}
; + } + throw new Error("Invalid heading"); + } + case "codespan": { + return {t.raw}; + } + case "code": { + return
{t.raw}
; + } + case "br": { + return
; + } + case "hr": { + return
; + } + case "blockquote": { + return
{t.tokens ? t.tokens.map(renderToken) : t.raw}
; + } + case "link": { + return ( + + {t.tokens ? t.tokens.map(renderToken) : t.raw} + + ); + } + case "list": { + if (t.ordered) { + return
    {t.items.map(renderToken)}
; + } else { + return
    {t.items.map(renderToken)}
; + } + } + case "list_item": { + return
  • {t.tokens ? t.tokens.map(renderToken) : t.raw}
  • ; + } + case "em": { + return {t.tokens ? t.tokens.map(renderToken) : t.raw}; + } + case "del": { + return {t.tokens ? t.tokens.map(renderToken) : t.raw}; + } + case "footnoteRef": { + return ( + + + [{t.label}] + + + ); + } + case "footnotes": + case "footnote": { + return; + } + default: { + if ("tokens" in t) { + return (t.tokens as Array).map(renderToken); + } + return t.raw; + } + } + } catch (e) { + console.error(e); + } +} + +export function Markdown({ content, tags = [] }: MarkdownProps) { + const parsed = useMemo(() => { + return marked.use(markedFootnote()).lexer(content); + }, [content, tags]); + + return ( +
    + {parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))} +
    + ); +} diff --git a/packages/app/src/Element/Event/Note.tsx b/packages/app/src/Element/Event/Note.tsx index aba48e7cd..9b54a6e5b 100644 --- a/packages/app/src/Element/Event/Note.tsx +++ b/packages/app/src/Element/Event/Note.tsx @@ -9,6 +9,7 @@ import { ZapGoal } from "Element/Event/ZapGoal"; import NoteReaction from "Element/Event/NoteReaction"; import ProfilePreview from "Element/User/ProfilePreview"; import { NoteInner } from "./NoteInner"; +import { LongFormText } from "./LongFormText"; export interface NoteProps { data: TaggedNostrEvent; @@ -32,6 +33,7 @@ export interface NoteProps { canUnbookmark?: boolean; canClick?: boolean; showMediaSpotlight?: boolean; + longFormPreview?: boolean; }; } @@ -58,6 +60,9 @@ export default function Note(props: NoteProps) { if (ev.kind === (9041 as EventKind)) { return ; } + if (ev.kind === EventKind.LongFormTextNote) { + return ; + } return ; } diff --git a/packages/app/src/Element/Event/NoteCreator.tsx b/packages/app/src/Element/Event/NoteCreator.tsx index 37a207aef..4008c91a6 100644 --- a/packages/app/src/Element/Event/NoteCreator.tsx +++ b/packages/app/src/Element/Event/NoteCreator.tsx @@ -359,6 +359,7 @@ export function NoteCreator() { showTime: false, canClick: false, showMedia: false, + longFormPreview: true, }} /> )} diff --git a/packages/app/src/Element/Event/NoteInner.tsx b/packages/app/src/Element/Event/NoteInner.tsx index 4fc00af58..864a7cc0b 100644 --- a/packages/app/src/Element/Event/NoteInner.tsx +++ b/packages/app/src/Element/Event/NoteInner.tsx @@ -1,27 +1,18 @@ 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 React, { ReactNode, useState } from "react"; 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 { EventExt, EventKind, HexKey, Lists, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system"; +import { findTag, hexToBech32, profileLink } from "SnortUtils"; +import useModeration from "Hooks/useModeration"; +import useLogin from "Hooks/useLogin"; +import useEventPublisher from "Hooks/useEventPublisher"; +import { NoteContextMenu, NoteTranslation } from "./NoteContextMenu"; 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"; @@ -31,7 +22,7 @@ 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"; +import { useEventReactions } from "Hooks/useEventReactions"; export function NoteInner(props: NoteProps) { const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props; @@ -39,50 +30,17 @@ export function NoteInner(props: NoteProps) { 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 { reactions, reposts, deletions, zaps } = useEventReactions(ev, related); 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 totalReactions = reactions.positive.length + reactions.negative.length + reposts.length + zaps.length; const options = { showHeader: true, @@ -117,45 +75,19 @@ export function NoteInner(props: NoteProps) { } 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 body = ev?.content ?? ""; + return ( + + ); }; const transformBody = () => { @@ -278,7 +210,7 @@ export function NoteInner(props: NoteProps) { ); } - const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls, EventKind.LongFormTextNote]; + const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls]; if (!canRenderAsTextNote.includes(ev.kind)) { const alt = findTag(ev, "alt"); if (alt) { @@ -374,12 +306,12 @@ export function NoteInner(props: NoteProps) {
    )} - {options.showFooter && } + {options.showFooter && } diff --git a/packages/app/src/Element/Event/Poll.tsx b/packages/app/src/Element/Event/Poll.tsx index 950dbf467..3223914ac 100644 --- a/packages/app/src/Element/Event/Poll.tsx +++ b/packages/app/src/Element/Event/Poll.tsx @@ -1,4 +1,4 @@ -import { TaggedNostrEvent, ParsedZap } from "@snort/system"; +import { TaggedNostrEvent, ParsedZap, NostrLink } from "@snort/system"; import { LNURL } from "@snort/shared"; import { useState } from "react"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; @@ -58,7 +58,7 @@ export default function Poll(props: PollProps) { setVoting(opt); const r = Object.keys(relays.item); - const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, props.ev.id, undefined, eb => + const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, NostrLink.fromEvent(props.ev), undefined, eb => eb.tag(["poll_option", opt.toString()]), ); diff --git a/packages/app/src/Element/Event/Thread.tsx b/packages/app/src/Element/Event/Thread.tsx index 20ccb7212..780576402 100644 --- a/packages/app/src/Element/Event/Thread.tsx +++ b/packages/app/src/Element/Event/Thread.tsx @@ -2,9 +2,9 @@ import "./Thread.css"; import { useMemo, useState, ReactNode, useContext } from "react"; import { useIntl } from "react-intl"; import { useNavigate, useParams } from "react-router-dom"; -import { TaggedNostrEvent, u256, NostrPrefix, EventExt, parseNostrLink } from "@snort/system"; +import { TaggedNostrEvent, u256, NostrPrefix, EventExt, parseNostrLink, NostrLink } from "@snort/system"; -import { getReactions, getAllReactions } from "SnortUtils"; +import { getAllLinkReactions, getLinkReactions } from "SnortUtils"; import BackButton from "Element/BackButton"; import Note from "Element/Event/Note"; import NoteGhost from "Element/Event/NoteGhost"; @@ -248,7 +248,7 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean className={className} key={note.id} data={note} - related={getReactions(thread.reactions, note.id)} + related={getLinkReactions(thread.reactions, NostrLink.fromEvent(note))} options={{ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight }} onClick={navigateThread} /> @@ -268,9 +268,9 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean a.id), + replies.map(a => NostrLink.fromEvent(a)), )} chains={thread.chains} onNavigate={navigateThread} diff --git a/packages/app/src/Element/HyperText.tsx b/packages/app/src/Element/HyperText.tsx index 83c6e5a55..a09675368 100644 --- a/packages/app/src/Element/HyperText.tsx +++ b/packages/app/src/Element/HyperText.tsx @@ -23,14 +23,16 @@ import WavlakeEmbed from "Element/Embed/WavlakeEmbed"; import LinkPreview from "Element/Embed/LinkPreview"; import NostrLink from "Element/Embed/NostrLink"; import MagnetLink from "Element/Embed/MagnetLink"; +import { ReactNode } from "react"; interface HypeTextProps { link: string; + children?: ReactNode | Array | null; depth?: number; showLinkPreview?: boolean; } -export default function HyperText({ link, depth, showLinkPreview }: HypeTextProps) { +export default function HyperText({ link, depth, showLinkPreview, children }: HypeTextProps) { const a = link; try { const url = new URL(a); @@ -78,7 +80,7 @@ export default function HyperText({ link, depth, showLinkPreview }: HypeTextProp return ( <> e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> - {a} + {children ?? a} {/*,*/} @@ -100,7 +102,7 @@ export default function HyperText({ link, depth, showLinkPreview }: HypeTextProp } return ( e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> - {a} + {children ?? a} ); } diff --git a/packages/app/src/Element/Text.css b/packages/app/src/Element/Text.css index 238a26823..ac6ed74b8 100644 --- a/packages/app/src/Element/Text.css +++ b/packages/app/src/Element/Text.css @@ -23,53 +23,11 @@ text-decoration: underline; } -.text h1 { - margin: 0; -} -.text h2 { - margin: 0; -} -.text h3 { - margin: 0; -} -.text h4 { - margin: 0; -} -.text h5 { - margin: 0; -} -.text h6 { - margin: 0; -} - -.text p { - margin: 0; - margin-bottom: 4px; -} - -.text p:last-child { - margin-bottom: 0; -} - .text pre { margin: 0; overflow: scroll; } -.text li { - margin-top: -1em; -} -.text li:last-child { - margin-bottom: -2em; -} - -.text hr { - border: 0; - height: 1px; - background-image: var(--gray-gradient); - margin: 20px; -} - .text img:not(.custom-emoji), .text video, .text iframe, @@ -84,13 +42,6 @@ aspect-ratio: 16 / 9; } -.text blockquote { - margin: 0; - color: var(--font-secondary-color); - border-left: 2px solid var(--font-secondary-color); - padding-left: 12px; -} - .gallery { grid-template-columns: repeat(4, 1fr); gap: 2px; diff --git a/packages/app/src/Hooks/useEventReactions.tsx b/packages/app/src/Hooks/useEventReactions.tsx new file mode 100644 index 000000000..05a225260 --- /dev/null +++ b/packages/app/src/Hooks/useEventReactions.tsx @@ -0,0 +1,49 @@ +import { EventKind, NostrLink, parseZap, TaggedNostrEvent } from "@snort/system"; +import { UserCache } from "Cache"; +import { useMemo } from "react"; +import { dedupeByPubkey, getLinkReactions, normalizeReaction, Reaction } from "SnortUtils"; + +export function useEventReactions(ev: TaggedNostrEvent, related: ReadonlyArray) { + return useMemo(() => { + const link = NostrLink.fromEvent(ev); + const deletions = getLinkReactions(related, link, EventKind.Deletion); + const reactions = getLinkReactions(related, link, EventKind.Reaction); + const reposts = getLinkReactions(related, link, EventKind.Repost); + + const groupReactions = (() => { + 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]), + }; + })(); + const positive = groupReactions[Reaction.Positive]; + const negative = groupReactions[Reaction.Negative]; + + const zaps = getLinkReactions(related, link, EventKind.ZapReceipt) + .map(a => parseZap(a, UserCache, ev)) + .filter(a => a.valid) + .sort((a, b) => b.amount - a.amount); + + return { + deletions, + reactions: { + all: reactions, + positive, + negative, + }, + reposts, + zaps, + }; + }, [ev, related]); +} diff --git a/packages/app/src/Login/Preferences.ts b/packages/app/src/Login/Preferences.ts index f68a22ea9..15f09085a 100644 --- a/packages/app/src/Login/Preferences.ts +++ b/packages/app/src/Login/Preferences.ts @@ -95,7 +95,7 @@ export const DefaultPreferences = { autoLoadMedia: "all", theme: "system", confirmReposts: false, - showDebugMenus: false, + showDebugMenus: true, autoShowLatest: false, fileUploader: "void.cat", imgProxyConfig: DefaultImgProxy, diff --git a/packages/app/src/Pages/Profile/ProfilePage.tsx b/packages/app/src/Pages/Profile/ProfilePage.tsx index fb515ddca..01a6615e4 100644 --- a/packages/app/src/Pages/Profile/ProfilePage.tsx +++ b/packages/app/src/Pages/Profile/ProfilePage.tsx @@ -2,11 +2,19 @@ import "./ProfilePage.css"; import { useEffect, useState } from "react"; import FormattedMessage from "Element/FormattedMessage"; import { useNavigate, useParams } from "react-router-dom"; -import { encodeTLV, encodeTLVEntries, EventKind, NostrPrefix, TLVEntryType, tryParseNostrLink } from "@snort/system"; +import { + encodeTLV, + encodeTLVEntries, + EventKind, + NostrLink, + NostrPrefix, + TLVEntryType, + tryParseNostrLink, +} from "@snort/system"; import { LNURL } from "@snort/shared"; import { useUserProfile } from "@snort/system-react"; -import { findTag, getReactions, unwrap } from "SnortUtils"; +import { findTag, getLinkReactions, unwrap } from "SnortUtils"; import Note from "Element/Event/Note"; import { Tab, TabElement } from "Element/Tabs"; import Icon from "Icons/Icon"; @@ -232,7 +240,7 @@ export default function ProfilePage({ id: propId }: ProfilePageProps) { ); diff --git a/packages/app/src/SnortUtils/index.ts b/packages/app/src/SnortUtils/index.ts index 520187934..307cae166 100644 --- a/packages/app/src/SnortUtils/index.ts +++ b/packages/app/src/SnortUtils/index.ts @@ -13,6 +13,7 @@ import { NostrPrefix, NostrEvent, MetadataCache, + NostrLink, } from "@snort/system"; export const sha256 = (str: string | Uint8Array): u256 => { @@ -162,15 +163,20 @@ export function normalizeReaction(content: string) { } } -/** - * Get reactions to a specific event (#e + kind filter) - */ -export function getReactions(notes: readonly TaggedNostrEvent[] | undefined, id: u256, kind?: EventKind) { - return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && a[1] === id)) || []; +export function getLinkReactions( + notes: ReadonlyArray | undefined, + link: NostrLink, + kind?: EventKind, +) { + return notes?.filter(a => a.kind === (kind ?? a.kind) && link.isReplyToThis(a)) || []; } -export function getAllReactions(notes: readonly TaggedNostrEvent[] | undefined, ids: Array, kind?: EventKind) { - return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && ids.includes(a[1]))) || []; +export function getAllLinkReactions( + notes: readonly TaggedNostrEvent[] | undefined, + links: Array, + kind?: EventKind, +) { + return notes?.filter(a => a.kind === (kind ?? a.kind) && links.some(b => b.isReplyToThis(a))) || []; } export function deepClone(obj: T) { diff --git a/packages/app/src/Zapper.ts b/packages/app/src/Zapper.ts index eb211ef85..2950294cd 100644 --- a/packages/app/src/Zapper.ts +++ b/packages/app/src/Zapper.ts @@ -94,13 +94,7 @@ export class Zapper { const pub = t.zap?.anon ?? false ? EventPublisher.privateKey(generateRandomKey().privateKey) : this.publisher; const zap = t.zap && svc.canZap - ? await pub?.zap(toSend * 1000, t.zap.pubkey, relays, undefined, t.memo, eb => { - if (t.zap?.event) { - const tag = t.zap.event.toEventTag(); - if (tag) { - eb.tag(tag); - } - } + ? await pub?.zap(toSend * 1000, t.zap.pubkey, relays, t.zap?.event, t.memo, eb => { if (t.zap?.anon) { eb.tag(["anon", ""]); } diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 4b11a8a4d..91e1952be 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -709,10 +709,6 @@ div.form-col { line-height: 36px; } -.main-content .profile-preview { - margin: 8px 0; -} - button.tall { height: 40px; } diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index db3313171..ff2b39c42 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -1353,6 +1353,9 @@ "rx1i0i": { "defaultMessage": "Short link" }, + "s5yJ8G": { + "defaultMessage": "Read full story" + }, "sKDn4e": { "defaultMessage": "Show Badges" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index 701112640..1f53403f1 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -443,6 +443,7 @@ "rrfdTe": "This is the same technology which is used by Bitcoin and has been proven to be extremely secure.", "rudscU": "Failed to load follows, please try again later", "rx1i0i": "Short link", + "s5yJ8G": "Read full story", "sKDn4e": "Show Badges", "sUNhQE": "user", "sZQzjQ": "Failed to parse zap split: {input}", diff --git a/packages/system/src/event-ext.ts b/packages/system/src/event-ext.ts index 76feff643..739c69796 100644 --- a/packages/system/src/event-ext.ts +++ b/packages/system/src/event-ext.ts @@ -105,7 +105,6 @@ export abstract class EventExt { } static extractThread(ev: NostrEvent) { - const shouldWriteMarkers = ev.kind === EventKind.TextNote; const ret = { mentions: [], pubKeys: [], @@ -115,16 +114,14 @@ export abstract class EventExt { const marked = replyTags.some(a => a.marker); if (!marked) { ret.root = replyTags[0]; - ret.root.marker = shouldWriteMarkers ? "root" : undefined; + ret.root.marker = "root"; if (replyTags.length > 1) { ret.replyTo = replyTags[replyTags.length - 1]; - ret.replyTo.marker = shouldWriteMarkers ? "reply" : undefined; + ret.replyTo.marker = "reply"; } if (replyTags.length > 2) { ret.mentions = replyTags.slice(1, -1); - if (shouldWriteMarkers) { - ret.mentions.forEach(a => (a.marker = "mention")); - } + ret.mentions.forEach(a => (a.marker = "mention")); } } else { const root = replyTags.find(a => a.marker === "root"); diff --git a/packages/system/src/event-publisher.ts b/packages/system/src/event-publisher.ts index b2bd0ad45..df5c17a91 100644 --- a/packages/system/src/event-publisher.ts +++ b/packages/system/src/event-publisher.ts @@ -165,14 +165,14 @@ export class EventPublisher { amount: number, author: HexKey, relays: Array, - note?: HexKey, + note?: NostrLink, msg?: string, fnExtra?: EventBuilderHook, ) { const eb = this.#eb(EventKind.ZapRequest); eb.content(msg ?? ""); if (note) { - eb.tag(["e", note]); + eb.tag(unwrap(note.toEventTag())); } eb.tag(["p", author]); eb.tag(["relays", ...relays.map(a => a.trim())]); @@ -205,7 +205,7 @@ export class EventPublisher { eb.tag(["p", pk]); } } else { - eb.tag([...(NostrLink.fromEvent(replyTo).toEventTag() ?? []), "reply"]); + eb.tag([...(NostrLink.fromEvent(replyTo).toEventTag() ?? []), "root"]); // dont tag self in replies if (replyTo.pubkey !== this.#pubKey) { eb.tag(["p", replyTo.pubkey]); @@ -219,7 +219,7 @@ export class EventPublisher { async react(evRef: NostrEvent, content = "+") { const eb = this.#eb(EventKind.Reaction); eb.content(content); - eb.tag(["e", evRef.id]); + eb.tag(unwrap(NostrLink.fromEvent(evRef).toEventTag())); eb.tag(["p", evRef.pubkey]); return await this.#sign(eb); } @@ -269,7 +269,7 @@ export class EventPublisher { */ async repost(note: NostrEvent) { const eb = this.#eb(EventKind.Repost); - eb.tag(["e", note.id, ""]); + eb.tag(unwrap(NostrLink.fromEvent(note).toEventTag())); eb.tag(["p", note.pubkey]); return await this.#sign(eb); } diff --git a/packages/system/src/nostr-link.ts b/packages/system/src/nostr-link.ts index 42e568685..98a20b169 100644 --- a/packages/system/src/nostr-link.ts +++ b/packages/system/src/nostr-link.ts @@ -1,5 +1,5 @@ import { bech32ToHex, hexToBech32, unwrap } from "@snort/shared"; -import { NostrPrefix, decodeTLV, TLVEntryType, encodeTLV, NostrEvent, TaggedNostrEvent } from "."; +import { NostrPrefix, decodeTLV, TLVEntryType, encodeTLV, NostrEvent, TaggedNostrEvent, EventExt, Tag } from "."; import { findTag } from "./utils"; export class NostrLink { @@ -43,6 +43,79 @@ export class NostrLink { return false; } + /** + * Is the supplied event a reply to this link + */ + isReplyToThis(ev: NostrEvent) { + const thread = EventExt.extractThread(ev); + if (!thread) return false; // non-thread events are not replies + + if (!thread.root) return false; // must have root marker or positional e/a tag in position 0 + + if ( + thread.root.key === "e" && + thread.root.value === this.id && + (this.type === NostrPrefix.Event || this.type === NostrPrefix.Note) + ) { + return true; + } + if (thread.root.key === "a" && this.type === NostrPrefix.Address) { + const [kind, author, dTag] = unwrap(thread.root.value).split(":"); + if (Number(kind) === this.kind && author === this.author && dTag === this.id) { + return true; + } + } + return false; + } + + /** + * Does the supplied event contain a tag matching this link + */ + referencesThis(ev: NostrEvent) { + for (const t of ev.tags) { + if (t[0] === "e" && t[1] === this.id && (this.type === NostrPrefix.Event || this.type === NostrPrefix.Note)) { + return true; + } + if (t[0] === "a" && this.type === NostrPrefix.Address) { + const [kind, author, dTag] = t[1].split(":"); + if (Number(kind) === this.kind && author === this.author && dTag === this.id) { + return true; + } + } + if ( + t[0] === "p" && + (this.type === NostrPrefix.Profile || this.type === NostrPrefix.PublicKey) && + this.id === t[1] + ) { + return true; + } + } + return false; + } + + equals(other: NostrLink) { + if (other.type === this.type && this.type === NostrPrefix.Address) { + } + } + + static fromThreadTag(tag: Tag) { + const relay = tag.relay ? [tag.relay] : undefined; + + switch (tag.key) { + case "e": { + return new NostrLink(NostrPrefix.Event, unwrap(tag.value), undefined, undefined, relay); + } + case "p": { + return new NostrLink(NostrPrefix.Profile, unwrap(tag.value), undefined, undefined, relay); + } + case "a": { + const [kind, author, dTag] = unwrap(tag.value).split(":"); + return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relay); + } + } + throw new Error(`Unknown tag kind ${tag.key}`); + } + static fromTag(tag: Array) { const relays = tag.length > 2 ? [tag[2]] : undefined; switch (tag[0]) { diff --git a/yarn.lock b/yarn.lock index 5f2ef34ff..8ad91a97d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2734,6 +2734,8 @@ __metadata: jest: ^29.5.0 jest-environment-jsdom: ^29.5.0 light-bolt11-decoder: ^2.1.0 + marked: ^9.1.0 + marked-footnote: ^1.0.0 match-sorter: ^6.3.1 mini-css-extract-plugin: ^2.7.5 prettier: 2.8.3 @@ -9480,6 +9482,24 @@ __metadata: languageName: node linkType: hard +"marked-footnote@npm:^1.0.0": + version: 1.0.0 + resolution: "marked-footnote@npm:1.0.0" + peerDependencies: + marked: ">=7.0.0" + checksum: 14f11592bf936ca32d1a43d55ef0df92e5319e8d3e9df517b5f41ed50d76e2b38f782ad475bd16933f026629c357da4c95e46aa0edf4cb196a8c475086fc2909 + languageName: node + linkType: hard + +"marked@npm:^9.1.0": + version: 9.1.0 + resolution: "marked@npm:9.1.0" + bin: + marked: bin/marked.js + checksum: 452a5f564719c93a55136d77e6aa51852df9b24a4359c74d6b2c661bbb09fc8db1bb5ee0b9a8c0eb6d0ba22ec4a3af110bc97ba881e4ffae9f5e83c3ce2676d2 + languageName: node + linkType: hard + "match-sorter@npm:^6.3.1": version: 6.3.1 resolution: "match-sorter@npm:6.3.1"