From e378a53b21dd5d2657319e574e7b6c0a140bc0fc Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 16 Oct 2023 13:45:51 +0100 Subject: [PATCH] feat: long form deck modal --- packages/app/src/Element/Deck/Articles.tsx | 6 + .../app/src/Element/Event/LongFormText.css | 16 +++ .../app/src/Element/Event/LongFormText.tsx | 109 +++++++++++++++--- packages/app/src/Element/Event/Markdown.tsx | 33 ++++-- packages/app/src/Element/Event/Note.tsx | 9 +- packages/app/src/Pages/Deck.css | 10 +- packages/app/src/Pages/DeckLayout.tsx | 51 ++++++-- packages/app/src/index.css | 3 +- packages/app/src/lang.json | 12 +- packages/app/src/translations/en.json | 4 +- 10 files changed, 207 insertions(+), 46 deletions(-) diff --git a/packages/app/src/Element/Deck/Articles.tsx b/packages/app/src/Element/Deck/Articles.tsx index a81ebd3c..8c7eff25 100644 --- a/packages/app/src/Element/Deck/Articles.tsx +++ b/packages/app/src/Element/Deck/Articles.tsx @@ -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); + }} /> ))} diff --git a/packages/app/src/Element/Event/LongFormText.css b/packages/app/src/Element/Event/LongFormText.css index 43a4221d..84801193 100644 --- a/packages/app/src/Element/Event/LongFormText.css +++ b/packages/app/src/Element/Event/LongFormText.css @@ -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); diff --git a/packages/app/src/Element/Event/LongFormText.tsx b/packages/app/src/Element/Event/LongFormText.tsx index 6d48770e..15c2560f 100644 --- a/packages/app/src/Element/Event/LongFormText.tsx +++ b/packages/app/src/Element/Event/LongFormText.tsx @@ -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; + 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(null); const { reactions, reposts, zaps } = useEventReactions(props.ev, props.related); function previewText() { return ( - <> - - - - - + ); } + 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(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 ( <> - +
+
+
+ , + }} + /> +
+
+ {!reading && ( +
readArticle()}> + +
+ )} + {reading && ( +
stopReading()}> + +
+ )} +
+
+ +
+ ); } return ( -
+
{ + e.stopPropagation(); + props.onClick?.(); + }}> ).map(renderToken); } - return t.raw; + return transformText(t.raw, []).map(v => { + switch (v.type) { + case "link": { + if (v.content.startsWith("nostr:")) { + return ; + } else { + return v.content; + } + } + case "mention": { + return ; + } + 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((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 ( -
+
{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 8814fba1..7857edbe 100644 --- a/packages/app/src/Element/Event/Note.tsx +++ b/packages/app/src/Element/Event/Note.tsx @@ -62,7 +62,14 @@ export default function Note(props: NoteProps) { return ; } if (ev.kind === EventKind.LongFormTextNote) { - return ; + return ( + props.onClick?.(ev)} + /> + ); } return ; diff --git a/packages/app/src/Pages/Deck.css b/packages/app/src/Pages/Deck.css index 81147ddf..2c0487cc 100644 --- a/packages/app/src/Pages/Deck.css +++ b/packages/app/src/Pages/Deck.css @@ -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; diff --git a/packages/app/src/Pages/DeckLayout.tsx b/packages/app/src/Pages/DeckLayout.tsx index 8d8f35d9..be79ccfc 100644 --- a/packages/app/src/Pages/DeckLayout.tsx +++ b/packages/app/src/Pages/DeckLayout.tsx @@ -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(undefined); @@ -34,8 +41,9 @@ export const DeckContext = createContext(undefined); export function SnortDeckLayout() { const login = useLogin(); const navigate = useNavigate(); - const [deckScope, setDeckScope] = useState({ - setThread: (e?: NostrLink) => setDeckScope(s => ({ ...s, thread: e })), + const [deckState, setDeckState] = useState({ + thread: undefined, + article: undefined, }); useLoginFeed(); @@ -52,7 +60,13 @@ export function SnortDeckLayout() { const cols = ["notes", "media", "notifications", "articles"] as Array; return (
- + setDeckState({ thread: e }), + setArticle: (e?: TaggedNostrEvent) => setDeckState({ article: e }), + reset: () => setDeckState({}), + }}>
{cols.map(c => { @@ -60,26 +74,39 @@ export function SnortDeckLayout() { case "notes": return ; case "media": - return ; + return setDeckState({ thread: t })} />; case "articles": return ; case "notifications": - return ; + return setDeckState({ thread: t })} />; } })}
- {deckScope.thread && ( + {deckState.thread && ( <> - deckScope.setThread(undefined)} className="thread-overlay"> - - deckScope.setThread(undefined)} /> + setDeckState({})} className="thread-overlay thread"> + + setDeckState({})} />
- deckScope.setThread(undefined)} disableSpotlight={true} /> + setDeckState({})} disableSpotlight={true} />
)} + {deckState.article && ( + <> + setDeckState({})} + className="thread-overlay long-form" + onClick={() => setDeckState({})}> +
e.stopPropagation()}> + +
+
+ + )}
diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 11b3063f..58ebb981 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -125,8 +125,7 @@ a:hover { } a.ext { - word-break: break-all; - white-space: initial; + overflow-wrap: break-word; } #root { diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index 700ad1b4..6d6f417e 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -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" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index daa444a9..906020e7 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -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": "Mint: {url}",