feat: long form rendering
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Kieran 2023-10-11 11:44:53 +01:00
parent 3b505f6c3e
commit 6eca5a632d
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
25 changed files with 508 additions and 191 deletions

View File

@ -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",

View File

@ -11,7 +11,14 @@ export default function Articles() {
return (
<>
{orderDescending(data.data ?? []).map(a => (
<Note data={a} key={a.id} related={related.data ?? []} />
<Note
data={a}
key={a.id}
related={related.data ?? []}
options={{
longFormPreview: true,
}}
/>
))}
</>
);

View File

@ -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;
}
}

View File

@ -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<TaggedNostrEvent>;
}
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 (
<>
<Text
id={props.ev.id}
content={props.ev.content}
tags={props.ev.tags}
creator={props.ev.pubkey}
truncate={props.isPreview ? 250 : undefined}
disableLinkPreview={props.isPreview}
/>
<Link to={`/e/${NostrLink.fromEvent(props.ev).encode()}`}>
<FormattedMessage defaultMessage="Read full story" />
</Link>
</>
);
}
function fullText() {
return (
<>
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
<Markdown content={props.ev.content} tags={props.ev.tags} />
</>
);
}
return (
<div className="long-form-note flex-column g16 p">
<ProfilePreview
pubkey={props.ev.pubkey}
actions={
<>
<NoteTime from={props.ev.created_at * 1000} />
</>
}
options={{
about: false,
}}
/>
<h1>{title}</h1>
<small>{summary}</small>
{image && <div className="header-image" style={{ "--img": `url(${proxy(image)})` } as CSSProperties} />}
{props.isPreview ? previewText() : fullText()}
</div>
);
}

View File

@ -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;
}

View File

@ -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<Array<string>>;
}
function renderToken(t: Token | Footnotes | Footnote | FootnoteRef): ReactNode {
try {
switch (t.type) {
case "paragraph": {
return <p>{t.tokens ? t.tokens.map(renderToken) : t.raw}</p>;
}
case "image": {
return <ProxyImg src={t.href} />;
}
case "heading": {
switch (t.depth) {
case 1:
return <h1>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h1>;
case 2:
return <h2>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h2>;
case 3:
return <h3>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h3>;
case 4:
return <h4>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h4>;
case 5:
return <h5>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h5>;
case 6:
return <h6>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h6>;
}
throw new Error("Invalid heading");
}
case "codespan": {
return <code>{t.raw}</code>;
}
case "code": {
return <pre>{t.raw}</pre>;
}
case "br": {
return <br />;
}
case "hr": {
return <hr />;
}
case "blockquote": {
return <blockquote>{t.tokens ? t.tokens.map(renderToken) : t.raw}</blockquote>;
}
case "link": {
return (
<Link to={t.href as string} className="ext" target="_blank">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</Link>
);
}
case "list": {
if (t.ordered) {
return <ol>{t.items.map(renderToken)}</ol>;
} else {
return <ul>{t.items.map(renderToken)}</ul>;
}
}
case "list_item": {
return <li>{t.tokens ? t.tokens.map(renderToken) : t.raw}</li>;
}
case "em": {
return <em>{t.tokens ? t.tokens.map(renderToken) : t.raw}</em>;
}
case "del": {
return <s>{t.tokens ? t.tokens.map(renderToken) : t.raw}</s>;
}
case "footnoteRef": {
return (
<sup>
<Link to={`#fn-${t.label}`} className="super">
[{t.label}]
</Link>
</sup>
);
}
case "footnotes":
case "footnote": {
return;
}
default: {
if ("tokens" in t) {
return (t.tokens as Array<Token>).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 (
<div className="markdown">
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))}
</div>
);
}

View File

@ -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 <ZapGoal ev={ev} />;
}
if (ev.kind === EventKind.LongFormTextNote) {
return <LongFormText ev={ev} related={props.related} isPreview={props.options?.longFormPreview ?? false} />;
}
return <NoteInner {...props} />;
}

View File

@ -359,6 +359,7 @@ export function NoteCreator() {
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
}}
/>
)}

View File

@ -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<NoteTranslation>();
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 (
<div className="long-form-note">
<h3>{title}</h3>
<div className="text">
<p>{summary}</p>
<Text
id={ev.id}
content={ev.content}
highlighText={props.searchedValue}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
truncate={255}
disableLinkPreview={true}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
/>
{image && <ProxyImg src={image} />}
</div>
</div>
);
} else {
const body = ev?.content ?? "";
return (
<Text
id={ev.id}
highlighText={props.searchedValue}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
/>
);
}
const body = ev?.content ?? "";
return (
<Text
id={ev.id}
highlighText={props.searchedValue}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
/>
);
};
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) {
</div>
)}
</div>
{options.showFooter && <NoteFooter ev={ev} positive={positive} reposts={reposts} zaps={zaps} />}
{options.showFooter && <NoteFooter ev={ev} positive={reactions.positive} reposts={reposts} zaps={zaps} />}
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={positive}
negative={negative}
positive={reactions.positive}
negative={reactions.negative}
reposts={reposts}
zaps={zaps}
/>

View File

@ -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()]),
);

View File

@ -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
<Subthread
active={thread.current}
notes={replies}
related={getAllReactions(
related={getAllLinkReactions(
thread.reactions,
replies.map(a => a.id),
replies.map(a => NostrLink.fromEvent(a)),
)}
chains={thread.chains}
onNavigate={navigateThread}

View File

@ -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<ReactNode> | 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 (
<>
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
{children ?? a}
</a>
{/*<NostrNestsEmbed link={a} />,*/}
</>
@ -100,7 +102,7 @@ export default function HyperText({ link, depth, showLinkPreview }: HypeTextProp
}
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
{children ?? a}
</a>
);
}

View File

@ -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;

View File

@ -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<TaggedNostrEvent>) {
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]);
}

View File

@ -95,7 +95,7 @@ export const DefaultPreferences = {
autoLoadMedia: "all",
theme: "system",
confirmReposts: false,
showDebugMenus: false,
showDebugMenus: true,
autoShowLatest: false,
fileUploader: "void.cat",
imgProxyConfig: DefaultImgProxy,

View File

@ -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) {
<Note
key={`pinned-${n.id}`}
data={n}
related={getReactions(pinned, n.id)}
related={getLinkReactions(pinned, NostrLink.fromEvent(n))}
options={{ showTime: false, showPinned: true, canUnpin: id === loginPubKey }}
/>
);

View File

@ -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<TaggedNostrEvent> | 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<u256>, 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<NostrLink>,
kind?: EventKind,
) {
return notes?.filter(a => a.kind === (kind ?? a.kind) && links.some(b => b.isReplyToThis(a))) || [];
}
export function deepClone<T>(obj: T) {

View File

@ -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", ""]);
}

View File

@ -709,10 +709,6 @@ div.form-col {
line-height: 36px;
}
.main-content .profile-preview {
margin: 8px 0;
}
button.tall {
height: 40px;
}

View File

@ -1353,6 +1353,9 @@
"rx1i0i": {
"defaultMessage": "Short link"
},
"s5yJ8G": {
"defaultMessage": "Read full story"
},
"sKDn4e": {
"defaultMessage": "Show Badges"
},

View File

@ -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}",

View File

@ -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");

View File

@ -165,14 +165,14 @@ export class EventPublisher {
amount: number,
author: HexKey,
relays: Array<string>,
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);
}

View File

@ -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<string>) {
const relays = tag.length > 2 ? [tag[2]] : undefined;
switch (tag[0]) {

View File

@ -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"