feat: long form rendering
This commit is contained in:
parent
3b505f6c3e
commit
6eca5a632d
@ -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",
|
||||
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
51
packages/app/src/Element/Event/LongFormText.css
Normal file
51
packages/app/src/Element/Event/LongFormText.css
Normal 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;
|
||||
}
|
||||
}
|
75
packages/app/src/Element/Event/LongFormText.tsx
Normal file
75
packages/app/src/Element/Event/LongFormText.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
packages/app/src/Element/Event/Markdown.css
Normal file
31
packages/app/src/Element/Event/Markdown.css
Normal 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;
|
||||
}
|
113
packages/app/src/Element/Event/Markdown.tsx
Normal file
113
packages/app/src/Element/Event/Markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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} />;
|
||||
}
|
||||
|
@ -359,6 +359,7 @@ export function NoteCreator() {
|
||||
showTime: false,
|
||||
canClick: false,
|
||||
showMedia: false,
|
||||
longFormPreview: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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()]),
|
||||
);
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
49
packages/app/src/Hooks/useEventReactions.tsx
Normal file
49
packages/app/src/Hooks/useEventReactions.tsx
Normal 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]);
|
||||
}
|
@ -95,7 +95,7 @@ export const DefaultPreferences = {
|
||||
autoLoadMedia: "all",
|
||||
theme: "system",
|
||||
confirmReposts: false,
|
||||
showDebugMenus: false,
|
||||
showDebugMenus: true,
|
||||
autoShowLatest: false,
|
||||
fileUploader: "void.cat",
|
||||
imgProxyConfig: DefaultImgProxy,
|
||||
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
|
@ -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) {
|
||||
|
@ -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", ""]);
|
||||
}
|
||||
|
@ -709,10 +709,6 @@ div.form-col {
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.main-content .profile-preview {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
button.tall {
|
||||
height: 40px;
|
||||
}
|
||||
|
@ -1353,6 +1353,9 @@
|
||||
"rx1i0i": {
|
||||
"defaultMessage": "Short link"
|
||||
},
|
||||
"s5yJ8G": {
|
||||
"defaultMessage": "Read full story"
|
||||
},
|
||||
"sKDn4e": {
|
||||
"defaultMessage": "Show Badges"
|
||||
},
|
||||
|
@ -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}",
|
||||
|
@ -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");
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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]) {
|
||||
|
20
yarn.lock
20
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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user