feat: long form deck modal
This commit is contained in:
parent
d9fc4f37b0
commit
e378a53b21
@ -3,9 +3,12 @@ import { useArticles } from "Feed/ArticlesFeed";
|
||||
import { orderDescending } from "SnortUtils";
|
||||
import Note from "../Event/Note";
|
||||
import { useReactions } from "Feed/Reactions";
|
||||
import { useContext } from "react";
|
||||
import { DeckContext } from "Pages/DeckLayout";
|
||||
|
||||
export default function Articles() {
|
||||
const data = useArticles();
|
||||
const deck = useContext(DeckContext);
|
||||
const related = useReactions("articles:reactions", data.data?.map(v => NostrLink.fromEvent(v)) ?? []);
|
||||
|
||||
return (
|
||||
@ -18,6 +21,9 @@ export default function Articles() {
|
||||
options={{
|
||||
longFormPreview: true,
|
||||
}}
|
||||
onClick={ev => {
|
||||
deck?.setArticle(ev);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
@ -1,3 +1,19 @@
|
||||
.long-form-note p {
|
||||
font-family: Georgia;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.long-form-note hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-color: var(--gray);
|
||||
margin: 5px 0px;
|
||||
}
|
||||
|
||||
.long-form-note .reading {
|
||||
border: 1px dashed var(--highlight);
|
||||
}
|
||||
|
||||
.long-form-note .header-image {
|
||||
height: 360px;
|
||||
background: var(--img);
|
||||
|
@ -1,13 +1,12 @@
|
||||
import "./LongFormText.css";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { CSSProperties, useCallback, useRef, useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { 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";
|
||||
@ -17,6 +16,7 @@ interface LongFormTextProps {
|
||||
ev: TaggedNostrEvent;
|
||||
isPreview: boolean;
|
||||
related: ReadonlyArray<TaggedNostrEvent>;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function LongFormText(props: LongFormTextProps) {
|
||||
@ -24,37 +24,108 @@ export function LongFormText(props: LongFormTextProps) {
|
||||
const summary = findTag(props.ev, "summary");
|
||||
const image = findTag(props.ev, "image");
|
||||
const { proxy } = useImgProxy();
|
||||
const [reading, setReading] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
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>
|
||||
</>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function readTime() {
|
||||
const wpm = 225;
|
||||
const words = props.ev.content.trim().split(/\s+/).length;
|
||||
return {
|
||||
words,
|
||||
wpm,
|
||||
mins: Math.ceil(words / wpm),
|
||||
};
|
||||
}
|
||||
|
||||
const readAsync = async (text: string) => {
|
||||
return await new Promise<void>(resolve => {
|
||||
const ut = new SpeechSynthesisUtterance(text);
|
||||
ut.onend = () => {
|
||||
resolve();
|
||||
};
|
||||
window.speechSynthesis.speak(ut);
|
||||
});
|
||||
};
|
||||
|
||||
const readArticle = useCallback(async () => {
|
||||
if (ref.current && !reading) {
|
||||
setReading(true);
|
||||
const paragraphs = ref.current.querySelectorAll("p,h1,h2,h3,h4,h5,h6");
|
||||
for (const p of paragraphs) {
|
||||
if (p.textContent) {
|
||||
p.classList.add("reading");
|
||||
await readAsync(p.textContent);
|
||||
p.classList.remove("reading");
|
||||
}
|
||||
}
|
||||
setReading(false);
|
||||
}
|
||||
}, [ref, reading]);
|
||||
|
||||
const stopReading = () => {
|
||||
setReading(false);
|
||||
if (ref.current) {
|
||||
const paragraphs = ref.current.querySelectorAll("p,h1,h2,h3,h4,h5,h6");
|
||||
paragraphs.forEach(a => a.classList.remove("reading"));
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
function fullText() {
|
||||
return (
|
||||
<>
|
||||
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
|
||||
<Markdown content={props.ev.content} tags={props.ev.tags} />
|
||||
<hr />
|
||||
<div className="flex g8">
|
||||
<div>
|
||||
<FormattedMessage
|
||||
defaultMessage="{n} mins to read"
|
||||
values={{
|
||||
n: <FormattedNumber value={readTime().mins} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>‧</div>
|
||||
{!reading && (
|
||||
<div className="pointer" onClick={() => readArticle()}>
|
||||
<FormattedMessage defaultMessage="Listen to this article" />
|
||||
</div>
|
||||
)}
|
||||
{reading && (
|
||||
<div className="pointer" onClick={() => stopReading()}>
|
||||
<FormattedMessage defaultMessage="Stop listening" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
<Markdown content={props.ev.content} tags={props.ev.tags} ref={ref} />
|
||||
<hr />
|
||||
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="long-form-note flex-column g16 p">
|
||||
<div
|
||||
className="long-form-note flex-column g16 p pointer"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.();
|
||||
}}>
|
||||
<ProfilePreview
|
||||
pubkey={props.ev.pubkey}
|
||||
actions={
|
||||
|
@ -1,10 +1,13 @@
|
||||
import "./Markdown.css";
|
||||
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { ReactNode, forwardRef, 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";
|
||||
import { transformText } from "@snort/system";
|
||||
import Mention from "Element/Embed/Mention";
|
||||
import NostrLink from "Element/Embed/NostrLink";
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
@ -92,7 +95,23 @@ function renderToken(t: Token | Footnotes | Footnote | FootnoteRef): ReactNode {
|
||||
if ("tokens" in t) {
|
||||
return (t.tokens as Array<Token>).map(renderToken);
|
||||
}
|
||||
return t.raw;
|
||||
return transformText(t.raw, []).map(v => {
|
||||
switch (v.type) {
|
||||
case "link": {
|
||||
if (v.content.startsWith("nostr:")) {
|
||||
return <NostrLink link={v.content} />;
|
||||
} else {
|
||||
return v.content;
|
||||
}
|
||||
}
|
||||
case "mention": {
|
||||
return <Mention pubkey={v.content} />;
|
||||
}
|
||||
default: {
|
||||
return v.content;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@ -100,14 +119,14 @@ function renderToken(t: Token | Footnotes | Footnote | FootnoteRef): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
export function Markdown({ content, tags = [] }: MarkdownProps) {
|
||||
export const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps, ref) => {
|
||||
const parsed = useMemo(() => {
|
||||
return marked.use(markedFootnote()).lexer(content);
|
||||
}, [content, tags]);
|
||||
return marked.use(markedFootnote()).lexer(props.content);
|
||||
}, [props.content, props.tags]);
|
||||
|
||||
return (
|
||||
<div className="markdown">
|
||||
<div className="markdown" ref={ref}>
|
||||
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -62,7 +62,14 @@ export default function Note(props: NoteProps) {
|
||||
return <ZapGoal ev={ev} />;
|
||||
}
|
||||
if (ev.kind === EventKind.LongFormTextNote) {
|
||||
return <LongFormText ev={ev} related={props.related} isPreview={props.options?.longFormPreview ?? false} />;
|
||||
return (
|
||||
<LongFormText
|
||||
ev={ev}
|
||||
related={props.related}
|
||||
isPreview={props.options?.longFormPreview ?? false}
|
||||
onClick={() => props.onClick?.(ev)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoteInner {...props} />;
|
||||
|
@ -68,7 +68,7 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal.thread-overlay > .modal-body > div > div:last-of-type {
|
||||
.modal.thread-overlay.thread > .modal-body > div > div:last-of-type {
|
||||
width: 550px;
|
||||
min-width: 550px;
|
||||
height: 100vh;
|
||||
@ -76,6 +76,14 @@
|
||||
background-color: var(--gray-superdark);
|
||||
}
|
||||
|
||||
.modal.thread-overlay.long-form > .modal-body > div > div:last-of-type {
|
||||
width: 660px;
|
||||
height: calc(100vh - 48px);
|
||||
padding: 48px 56px 0 56px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--gray-superdark);
|
||||
}
|
||||
|
||||
.thread-overlay .spotlight {
|
||||
flex-grow: 1;
|
||||
margin: auto;
|
||||
|
@ -2,7 +2,7 @@ import "./Deck.css";
|
||||
import { CSSProperties, createContext, useContext, useEffect, useState } from "react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import FormattedMessage from "Element/FormattedMessage";
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import { DeckNav } from "Element/Deck/Nav";
|
||||
import useLoginFeed from "Feed/LoginFeed";
|
||||
@ -21,12 +21,19 @@ import { SpotlightMedia } from "Element/Deck/SpotlightMedia";
|
||||
import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext";
|
||||
import Toaster from "Toaster";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { LongFormText } from "Element/Event/LongFormText";
|
||||
|
||||
type Cols = "notes" | "articles" | "media" | "streams" | "notifications";
|
||||
|
||||
interface DeckScope {
|
||||
interface DeckState {
|
||||
thread?: NostrLink;
|
||||
article?: TaggedNostrEvent;
|
||||
}
|
||||
|
||||
interface DeckScope {
|
||||
setThread: (e?: NostrLink) => void;
|
||||
setArticle: (e?: TaggedNostrEvent) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const DeckContext = createContext<DeckScope | undefined>(undefined);
|
||||
@ -34,8 +41,9 @@ export const DeckContext = createContext<DeckScope | undefined>(undefined);
|
||||
export function SnortDeckLayout() {
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const [deckScope, setDeckScope] = useState<DeckScope>({
|
||||
setThread: (e?: NostrLink) => setDeckScope(s => ({ ...s, thread: e })),
|
||||
const [deckState, setDeckState] = useState<DeckState>({
|
||||
thread: undefined,
|
||||
article: undefined,
|
||||
});
|
||||
|
||||
useLoginFeed();
|
||||
@ -52,7 +60,13 @@ export function SnortDeckLayout() {
|
||||
const cols = ["notes", "media", "notifications", "articles"] as Array<Cols>;
|
||||
return (
|
||||
<div className="deck-layout">
|
||||
<DeckContext.Provider value={deckScope}>
|
||||
<DeckContext.Provider
|
||||
value={{
|
||||
...deckState,
|
||||
setThread: (e?: NostrLink) => setDeckState({ thread: e }),
|
||||
setArticle: (e?: TaggedNostrEvent) => setDeckState({ article: e }),
|
||||
reset: () => setDeckState({}),
|
||||
}}>
|
||||
<DeckNav />
|
||||
<div className="deck-cols">
|
||||
{cols.map(c => {
|
||||
@ -60,26 +74,39 @@ export function SnortDeckLayout() {
|
||||
case "notes":
|
||||
return <NotesCol />;
|
||||
case "media":
|
||||
return <MediaCol setThread={deckScope.setThread} />;
|
||||
return <MediaCol setThread={t => setDeckState({ thread: t })} />;
|
||||
case "articles":
|
||||
return <ArticlesCol />;
|
||||
case "notifications":
|
||||
return <NotificationsCol setThread={deckScope.setThread} />;
|
||||
return <NotificationsCol setThread={t => setDeckState({ thread: t })} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{deckScope.thread && (
|
||||
{deckState.thread && (
|
||||
<>
|
||||
<Modal id="thread-overlay" onClose={() => deckScope.setThread(undefined)} className="thread-overlay">
|
||||
<ThreadContextWrapper link={deckScope.thread}>
|
||||
<SpotlightFromThread onClose={() => deckScope.setThread(undefined)} />
|
||||
<Modal id="thread-overlay" onClose={() => setDeckState({})} className="thread-overlay thread">
|
||||
<ThreadContextWrapper link={deckState.thread}>
|
||||
<SpotlightFromThread onClose={() => setDeckState({})} />
|
||||
<div>
|
||||
<Thread onBack={() => deckScope.setThread(undefined)} disableSpotlight={true} />
|
||||
<Thread onBack={() => setDeckState({})} disableSpotlight={true} />
|
||||
</div>
|
||||
</ThreadContextWrapper>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
{deckState.article && (
|
||||
<>
|
||||
<Modal
|
||||
id="thread-overlay-article"
|
||||
onClose={() => setDeckState({})}
|
||||
className="thread-overlay long-form"
|
||||
onClick={() => setDeckState({})}>
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<LongFormText ev={deckState.article} isPreview={false} related={[]} />
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
<Toaster />
|
||||
</DeckContext.Provider>
|
||||
</div>
|
||||
|
@ -125,8 +125,7 @@ a:hover {
|
||||
}
|
||||
|
||||
a.ext {
|
||||
word-break: break-all;
|
||||
white-space: initial;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
@ -829,6 +829,9 @@
|
||||
"Tpy00S": {
|
||||
"defaultMessage": "People"
|
||||
},
|
||||
"U1aPPi": {
|
||||
"defaultMessage": "Stop listening"
|
||||
},
|
||||
"UDYlxu": {
|
||||
"defaultMessage": "Pending Subscriptions"
|
||||
},
|
||||
@ -1267,6 +1270,9 @@
|
||||
"ncbgUU": {
|
||||
"defaultMessage": "{site} is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\"."
|
||||
},
|
||||
"nihgfo": {
|
||||
"defaultMessage": "Listen to this article"
|
||||
},
|
||||
"nn1qb3": {
|
||||
"defaultMessage": "Your donations are greatly appreciated"
|
||||
},
|
||||
@ -1374,9 +1380,6 @@
|
||||
"rx1i0i": {
|
||||
"defaultMessage": "Short link"
|
||||
},
|
||||
"s5yJ8G": {
|
||||
"defaultMessage": "Read full story"
|
||||
},
|
||||
"sKDn4e": {
|
||||
"defaultMessage": "Show Badges"
|
||||
},
|
||||
@ -1534,6 +1537,9 @@
|
||||
"zjJZBd": {
|
||||
"defaultMessage": "You're ready!"
|
||||
},
|
||||
"zm6qS1": {
|
||||
"defaultMessage": "{n} mins to read"
|
||||
},
|
||||
"zonsdq": {
|
||||
"defaultMessage": "Failed to load LNURL service"
|
||||
},
|
||||
|
@ -271,6 +271,7 @@
|
||||
"TP/cMX": "Ended",
|
||||
"TpgeGw": "Hex Salt..",
|
||||
"Tpy00S": "People",
|
||||
"U1aPPi": "Stop listening",
|
||||
"UDYlxu": "Pending Subscriptions",
|
||||
"UJTWqI": "Remove from my relays",
|
||||
"UNjfWJ": "Check all event signatures received from relays",
|
||||
@ -415,6 +416,7 @@
|
||||
"nOaArs": "Setup Profile",
|
||||
"nWQFic": "Renew",
|
||||
"ncbgUU": "{site} is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\".",
|
||||
"nihgfo": "Listen to this article",
|
||||
"nn1qb3": "Your donations are greatly appreciated",
|
||||
"nwZXeh": "{n} blocked",
|
||||
"o6Uy3d": "Only the secret key can be used to publish (sign events), everything else logs you in read-only mode.",
|
||||
@ -450,7 +452,6 @@
|
||||
"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}",
|
||||
@ -502,6 +503,7 @@
|
||||
"zQvVDJ": "All",
|
||||
"zcaOTs": "Zap amount in sats",
|
||||
"zjJZBd": "You're ready!",
|
||||
"zm6qS1": "{n} mins to read",
|
||||
"zonsdq": "Failed to load LNURL service",
|
||||
"zvCDao": "Automatically show latest notes",
|
||||
"zwb6LR": "<b>Mint:</b> {url}",
|
||||
|
Loading…
x
Reference in New Issue
Block a user