feat: long form deck modal
All checks were successful
continuous-integration/drone/push Build is passing

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 { orderDescending } from "SnortUtils";
import Note from "../Event/Note"; import Note from "../Event/Note";
import { useReactions } from "Feed/Reactions"; import { useReactions } from "Feed/Reactions";
import { useContext } from "react";
import { DeckContext } from "Pages/DeckLayout";
export default function Articles() { export default function Articles() {
const data = useArticles(); const data = useArticles();
const deck = useContext(DeckContext);
const related = useReactions("articles:reactions", data.data?.map(v => NostrLink.fromEvent(v)) ?? []); const related = useReactions("articles:reactions", data.data?.map(v => NostrLink.fromEvent(v)) ?? []);
return ( return (
@ -18,6 +21,9 @@ export default function Articles() {
options={{ options={{
longFormPreview: true, 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 { .long-form-note .header-image {
height: 360px; height: 360px;
background: var(--img); background: var(--img);

View File

@ -1,13 +1,12 @@
import "./LongFormText.css"; import "./LongFormText.css";
import { Link } from "react-router-dom"; import { CSSProperties, useCallback, useRef, useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage, FormattedNumber } from "react-intl";
import { NostrLink, TaggedNostrEvent } from "@snort/system"; import { TaggedNostrEvent } from "@snort/system";
import { findTag } from "SnortUtils"; import { findTag } from "SnortUtils";
import Text from "Element/Text"; import Text from "Element/Text";
import { Markdown } from "./Markdown"; import { Markdown } from "./Markdown";
import useImgProxy from "Hooks/useImgProxy"; import useImgProxy from "Hooks/useImgProxy";
import { CSSProperties } from "react";
import ProfilePreview from "Element/User/ProfilePreview"; import ProfilePreview from "Element/User/ProfilePreview";
import NoteFooter from "./NoteFooter"; import NoteFooter from "./NoteFooter";
import { useEventReactions } from "Hooks/useEventReactions"; import { useEventReactions } from "Hooks/useEventReactions";
@ -17,6 +16,7 @@ interface LongFormTextProps {
ev: TaggedNostrEvent; ev: TaggedNostrEvent;
isPreview: boolean; isPreview: boolean;
related: ReadonlyArray<TaggedNostrEvent>; related: ReadonlyArray<TaggedNostrEvent>;
onClick?: () => void;
} }
export function LongFormText(props: LongFormTextProps) { export function LongFormText(props: LongFormTextProps) {
@ -24,11 +24,12 @@ export function LongFormText(props: LongFormTextProps) {
const summary = findTag(props.ev, "summary"); const summary = findTag(props.ev, "summary");
const image = findTag(props.ev, "image"); const image = findTag(props.ev, "image");
const { proxy } = useImgProxy(); const { proxy } = useImgProxy();
const [reading, setReading] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { reactions, reposts, zaps } = useEventReactions(props.ev, props.related); const { reactions, reposts, zaps } = useEventReactions(props.ev, props.related);
function previewText() { function previewText() {
return ( return (
<>
<Text <Text
id={props.ev.id} id={props.ev.id}
content={props.ev.content} content={props.ev.content}
@ -37,24 +38,94 @@ export function LongFormText(props: LongFormTextProps) {
truncate={props.isPreview ? 250 : undefined} truncate={props.isPreview ? 250 : undefined}
disableLinkPreview={props.isPreview} disableLinkPreview={props.isPreview}
/> />
<Link to={`/e/${NostrLink.fromEvent(props.ev).encode()}`}>
<FormattedMessage defaultMessage="Read full story" />
</Link>
</>
); );
} }
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() { function fullText() {
return ( return (
<> <>
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} /> <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 ( 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 <ProfilePreview
pubkey={props.ev.pubkey} pubkey={props.ev.pubkey}
actions={ actions={

View File

@ -1,10 +1,13 @@
import "./Markdown.css"; import "./Markdown.css";
import { ReactNode, useMemo } from "react"; import { ReactNode, forwardRef, useMemo } from "react";
import { marked, Token } from "marked"; import { marked, Token } from "marked";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import markedFootnote, { Footnotes, Footnote, FootnoteRef } from "marked-footnote"; import markedFootnote, { Footnotes, Footnote, FootnoteRef } from "marked-footnote";
import { ProxyImg } from "Element/ProxyImg"; import { ProxyImg } from "Element/ProxyImg";
import { transformText } from "@snort/system";
import Mention from "Element/Embed/Mention";
import NostrLink from "Element/Embed/NostrLink";
interface MarkdownProps { interface MarkdownProps {
content: string; content: string;
@ -92,7 +95,23 @@ function renderToken(t: Token | Footnotes | Footnote | FootnoteRef): ReactNode {
if ("tokens" in t) { if ("tokens" in t) {
return (t.tokens as Array<Token>).map(renderToken); 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) { } 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(() => { const parsed = useMemo(() => {
return marked.use(markedFootnote()).lexer(content); return marked.use(markedFootnote()).lexer(props.content);
}, [content, tags]); }, [props.content, props.tags]);
return ( return (
<div className="markdown"> <div className="markdown" ref={ref}>
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))} {parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))}
</div> </div>
); );
} });

View File

@ -62,7 +62,14 @@ export default function Note(props: NoteProps) {
return <ZapGoal ev={ev} />; return <ZapGoal ev={ev} />;
} }
if (ev.kind === EventKind.LongFormTextNote) { 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} />; return <NoteInner {...props} />;

View File

@ -68,7 +68,7 @@
gap: 16px; 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; width: 550px;
min-width: 550px; min-width: 550px;
height: 100vh; height: 100vh;
@ -76,6 +76,14 @@
background-color: var(--gray-superdark); 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 { .thread-overlay .spotlight {
flex-grow: 1; flex-grow: 1;
margin: auto; margin: auto;

View File

@ -2,7 +2,7 @@ import "./Deck.css";
import { CSSProperties, createContext, useContext, useEffect, useState } from "react"; import { CSSProperties, createContext, useContext, useEffect, useState } from "react";
import { Outlet, useNavigate } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import FormattedMessage from "Element/FormattedMessage"; import FormattedMessage from "Element/FormattedMessage";
import { NostrLink } from "@snort/system"; import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { DeckNav } from "Element/Deck/Nav"; import { DeckNav } from "Element/Deck/Nav";
import useLoginFeed from "Feed/LoginFeed"; import useLoginFeed from "Feed/LoginFeed";
@ -21,12 +21,19 @@ import { SpotlightMedia } from "Element/Deck/SpotlightMedia";
import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext"; import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext";
import Toaster from "Toaster"; import Toaster from "Toaster";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { LongFormText } from "Element/Event/LongFormText";
type Cols = "notes" | "articles" | "media" | "streams" | "notifications"; type Cols = "notes" | "articles" | "media" | "streams" | "notifications";
interface DeckScope { interface DeckState {
thread?: NostrLink; thread?: NostrLink;
article?: TaggedNostrEvent;
}
interface DeckScope {
setThread: (e?: NostrLink) => void; setThread: (e?: NostrLink) => void;
setArticle: (e?: TaggedNostrEvent) => void;
reset: () => void;
} }
export const DeckContext = createContext<DeckScope | undefined>(undefined); export const DeckContext = createContext<DeckScope | undefined>(undefined);
@ -34,8 +41,9 @@ export const DeckContext = createContext<DeckScope | undefined>(undefined);
export function SnortDeckLayout() { export function SnortDeckLayout() {
const login = useLogin(); const login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const [deckScope, setDeckScope] = useState<DeckScope>({ const [deckState, setDeckState] = useState<DeckState>({
setThread: (e?: NostrLink) => setDeckScope(s => ({ ...s, thread: e })), thread: undefined,
article: undefined,
}); });
useLoginFeed(); useLoginFeed();
@ -52,7 +60,13 @@ export function SnortDeckLayout() {
const cols = ["notes", "media", "notifications", "articles"] as Array<Cols>; const cols = ["notes", "media", "notifications", "articles"] as Array<Cols>;
return ( return (
<div className="deck-layout"> <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 /> <DeckNav />
<div className="deck-cols"> <div className="deck-cols">
{cols.map(c => { {cols.map(c => {
@ -60,26 +74,39 @@ export function SnortDeckLayout() {
case "notes": case "notes":
return <NotesCol />; return <NotesCol />;
case "media": case "media":
return <MediaCol setThread={deckScope.setThread} />; return <MediaCol setThread={t => setDeckState({ thread: t })} />;
case "articles": case "articles":
return <ArticlesCol />; return <ArticlesCol />;
case "notifications": case "notifications":
return <NotificationsCol setThread={deckScope.setThread} />; return <NotificationsCol setThread={t => setDeckState({ thread: t })} />;
} }
})} })}
</div> </div>
{deckScope.thread && ( {deckState.thread && (
<> <>
<Modal id="thread-overlay" onClose={() => deckScope.setThread(undefined)} className="thread-overlay"> <Modal id="thread-overlay" onClose={() => setDeckState({})} className="thread-overlay thread">
<ThreadContextWrapper link={deckScope.thread}> <ThreadContextWrapper link={deckState.thread}>
<SpotlightFromThread onClose={() => deckScope.setThread(undefined)} /> <SpotlightFromThread onClose={() => setDeckState({})} />
<div> <div>
<Thread onBack={() => deckScope.setThread(undefined)} disableSpotlight={true} /> <Thread onBack={() => setDeckState({})} disableSpotlight={true} />
</div> </div>
</ThreadContextWrapper> </ThreadContextWrapper>
</Modal> </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 /> <Toaster />
</DeckContext.Provider> </DeckContext.Provider>
</div> </div>

View File

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

View File

@ -829,6 +829,9 @@
"Tpy00S": { "Tpy00S": {
"defaultMessage": "People" "defaultMessage": "People"
}, },
"U1aPPi": {
"defaultMessage": "Stop listening"
},
"UDYlxu": { "UDYlxu": {
"defaultMessage": "Pending Subscriptions" "defaultMessage": "Pending Subscriptions"
}, },
@ -1267,6 +1270,9 @@
"ncbgUU": { "ncbgUU": {
"defaultMessage": "{site} is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\"." "defaultMessage": "{site} is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\"."
}, },
"nihgfo": {
"defaultMessage": "Listen to this article"
},
"nn1qb3": { "nn1qb3": {
"defaultMessage": "Your donations are greatly appreciated" "defaultMessage": "Your donations are greatly appreciated"
}, },
@ -1374,9 +1380,6 @@
"rx1i0i": { "rx1i0i": {
"defaultMessage": "Short link" "defaultMessage": "Short link"
}, },
"s5yJ8G": {
"defaultMessage": "Read full story"
},
"sKDn4e": { "sKDn4e": {
"defaultMessage": "Show Badges" "defaultMessage": "Show Badges"
}, },
@ -1534,6 +1537,9 @@
"zjJZBd": { "zjJZBd": {
"defaultMessage": "You're ready!" "defaultMessage": "You're ready!"
}, },
"zm6qS1": {
"defaultMessage": "{n} mins to read"
},
"zonsdq": { "zonsdq": {
"defaultMessage": "Failed to load LNURL service" "defaultMessage": "Failed to load LNURL service"
}, },

View File

@ -271,6 +271,7 @@
"TP/cMX": "Ended", "TP/cMX": "Ended",
"TpgeGw": "Hex Salt..", "TpgeGw": "Hex Salt..",
"Tpy00S": "People", "Tpy00S": "People",
"U1aPPi": "Stop listening",
"UDYlxu": "Pending Subscriptions", "UDYlxu": "Pending Subscriptions",
"UJTWqI": "Remove from my relays", "UJTWqI": "Remove from my relays",
"UNjfWJ": "Check all event signatures received from relays", "UNjfWJ": "Check all event signatures received from relays",
@ -415,6 +416,7 @@
"nOaArs": "Setup Profile", "nOaArs": "Setup Profile",
"nWQFic": "Renew", "nWQFic": "Renew",
"ncbgUU": "{site} is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\".", "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", "nn1qb3": "Your donations are greatly appreciated",
"nwZXeh": "{n} blocked", "nwZXeh": "{n} blocked",
"o6Uy3d": "Only the secret key can be used to publish (sign events), everything else logs you in read-only mode.", "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.", "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", "rudscU": "Failed to load follows, please try again later",
"rx1i0i": "Short link", "rx1i0i": "Short link",
"s5yJ8G": "Read full story",
"sKDn4e": "Show Badges", "sKDn4e": "Show Badges",
"sUNhQE": "user", "sUNhQE": "user",
"sZQzjQ": "Failed to parse zap split: {input}", "sZQzjQ": "Failed to parse zap split: {input}",
@ -502,6 +503,7 @@
"zQvVDJ": "All", "zQvVDJ": "All",
"zcaOTs": "Zap amount in sats", "zcaOTs": "Zap amount in sats",
"zjJZBd": "You're ready!", "zjJZBd": "You're ready!",
"zm6qS1": "{n} mins to read",
"zonsdq": "Failed to load LNURL service", "zonsdq": "Failed to load LNURL service",
"zvCDao": "Automatically show latest notes", "zvCDao": "Automatically show latest notes",
"zwb6LR": "<b>Mint:</b> {url}", "zwb6LR": "<b>Mint:</b> {url}",