feat: long form deck modal

This commit is contained in:
Kieran 2023-10-16 13:45:51 +01:00
parent d9fc4f37b0
commit e378a53b21
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
10 changed files with 207 additions and 46 deletions

View File

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

View File

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

View File

@ -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={

View File

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

View File

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

View File

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

View File

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

View File

@ -125,8 +125,7 @@ a:hover {
}
a.ext {
word-break: break-all;
white-space: initial;
overflow-wrap: break-word;
}
#root {

View File

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

View File

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