Redo long-form note view

This commit is contained in:
Bojan Mojsilovic 2024-06-03 15:29:57 +02:00
parent 622dad8ecc
commit 0081870198
4 changed files with 390 additions and 173 deletions

View File

@ -4,13 +4,14 @@ import { useThreadContext } from "../../contexts/ThreadContext";
import Avatar from "../Avatar/Avatar"; import Avatar from "../Avatar/Avatar";
import { TransitionGroup } from 'solid-transition-group'; import { TransitionGroup } from 'solid-transition-group';
import styles from "./Note.module.scss"; import styles from "./Note.module.scss";
import { TopZap } from "../../types/primal"; import { PrimalUser, TopZap } from "../../types/primal";
const NoteTopZaps: Component<{ const NoteTopZaps: Component<{
topZaps: TopZap[], topZaps: TopZap[],
zapCount: number, zapCount: number,
action: () => void, action: () => void,
id?: string, id?: string,
users?: PrimalUser[]
}> = (props) => { }> = (props) => {
const threadContext = useThreadContext(); const threadContext = useThreadContext();
@ -42,7 +43,7 @@ const NoteTopZaps: Component<{
} }
const zapSender = (zap: TopZap) => { const zapSender = (zap: TopZap) => {
return threadContext?.users.find(u => u.pubkey === zap.pubkey); return (props.users || threadContext?.users || []).find(u => u.pubkey === zap.pubkey);
}; };
return ( return (

View File

@ -145,7 +145,6 @@ body::after{
url(./assets/icons/nav/settings_selected.svg) url(./assets/icons/nav/settings_selected.svg)
url(./assets/icons/nav/long.svg) url(./assets/icons/nav/long.svg)
url(./assets/icons/nav/long_selected.svg) url(./assets/icons/nav/long_selected.svg)
url(./assets/images/reads_image.svg)
url(./assets/images/reads_image_dark.png) url(./assets/images/reads_image_dark.png)
url(./assets/images/reads_image_light.png); url(./assets/images/reads_image_light.png);
} }

View File

@ -11,17 +11,61 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
.userName { .userInfo {
color: var(--text-primary); display: flex;
flex-direction: column;
.userName {
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
}
.nip05 {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
}
}
}
.topBar {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 18px;
margin-top: 18px;
padding-inline: 20px;
.left {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
.time {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 700;
}
.client {
&::before {
content: '';
}
color: var(--text-tertiary);
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
} }
} }
.time { .right {
color: var(--text-tertiary); display: flex;
font-size: 14px; justify-content: flex-end;
font-weight: 700; align-items: center;
} }
} }

View File

@ -1,20 +1,20 @@
import { useIntl } from "@cookbook/solid-intl"; import { useIntl } from "@cookbook/solid-intl";
import { useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { Component, createEffect, createSignal, For, onMount, Show } from "solid-js"; import { Component, createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { APP_ID } from "../App"; import { APP_ID } from "../App";
import { Kind } from "../constants"; import { Kind } from "../constants";
import { useAccountContext } from "../contexts/AccountContext"; import { useAccountContext } from "../contexts/AccountContext";
import { decodeIdentifier } from "../lib/keys"; import { decodeIdentifier } from "../lib/keys";
import { getParametrizedEvent } from "../lib/notes"; import { getParametrizedEvent, setLinkPreviews } from "../lib/notes";
import { subscribeTo } from "../sockets"; import { subscribeTo } from "../sockets";
import { SolidMarkdown } from "solid-markdown"; import { SolidMarkdown } from "solid-markdown";
import styles from './Longform.module.scss'; import styles from './Longform.module.scss';
import Loader from "../components/Loader/Loader"; import Loader from "../components/Loader/Loader";
import { NostrUserContent, PrimalUser, TopZap } from "../types/primal"; import { FeedPage, NostrEventContent, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalNote, PrimalUser, TopZap } from "../types/primal";
import { getUserProfileInfo } from "../lib/profile"; import { getUserProfileInfo } from "../lib/profile";
import { convertToUser, userName } from "../stores/profile"; import { convertToUser, nip05Verification, userName } from "../stores/profile";
import Avatar from "../components/Avatar/Avatar"; import Avatar from "../components/Avatar/Avatar";
import { shortDate } from "../lib/dates"; import { shortDate } from "../lib/dates";
@ -26,11 +26,19 @@ import { full as mdEmoji } from 'markdown-it-emoji';
import PrimalMarkdown from "../components/PrimalMarkdown/PrimalMarkdown"; import PrimalMarkdown from "../components/PrimalMarkdown/PrimalMarkdown";
import NoteTopZaps from "../components/Note/NoteTopZaps"; import NoteTopZaps from "../components/Note/NoteTopZaps";
import { parseBolt11 } from "../utils"; import { parseBolt11 } from "../utils";
import { NoteReactionsState } from "../components/Note/Note"; import Note, { NoteReactionsState } from "../components/Note/Note";
import NoteFooter from "../components/Note/NoteFooter/NoteFooter"; import NoteFooter from "../components/Note/NoteFooter/NoteFooter";
import { getArticleThread, getThread } from "../lib/feed"; import { getArticleThread, getThread } from "../lib/feed";
import PhotoSwipeLightbox from "photoswipe/lightbox"; import PhotoSwipeLightbox from "photoswipe/lightbox";
import NoteImage from "../components/NoteImage/NoteImage"; import NoteImage from "../components/NoteImage/NoteImage";
import { nip19 } from "nostr-tools";
import { saveNotes } from "../services/StoreService";
import { sortByRecency, convertToNotes } from "../stores/note";
import { tableNodeTypes } from "@milkdown/prose/tables";
import VerificationCheck from "../components/VerificationCheck/VerificationCheck";
import BookmarkArticle from "../components/BookmarkNote/BookmarkArticle";
import NoteContextTrigger from "../components/Note/NoteContextTrigger";
import { useAppContext } from "../contexts/AppContext";
export type LongFormData = { export type LongFormData = {
title: string, title: string,
@ -41,9 +49,19 @@ export type LongFormData = {
content: string, content: string,
author: string, author: string,
topZaps: TopZap[], topZaps: TopZap[],
id: string,
client: string,
}; };
const emptyLongNote = { export type LongformThreadStore = {
page: FeedPage,
replies: PrimalNote[],
users: PrimalUser[],
isFetching: boolean,
lastReply: PrimalNote | undefined,
}
const emptyArticle = {
title: '', title: '',
summary: '', summary: '',
image: '', image: '',
@ -52,6 +70,24 @@ const emptyLongNote = {
content: '', content: '',
author: '', author: '',
topZaps: [], topZaps: [],
id: '',
client: '',
};
const emptyStore: LongformThreadStore = {
replies: [],
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
wordCount: {},
},
users: [],
isFetching: false,
lastReply: undefined,
} }
const test = ` const test = `
@ -249,10 +285,12 @@ Term 2 with *inline markup*
const Longform: Component< { naddr: string } > = (props) => { const Longform: Component< { naddr: string } > = (props) => {
const account = useAccountContext(); const account = useAccountContext();
const app = useAppContext();
const params = useParams(); const params = useParams();
const intl = useIntl(); const intl = useIntl();
const [note, setNote] = createStore<LongFormData>({...emptyLongNote}); const [article, setArticle] = createStore<LongFormData>({...emptyArticle});
const [store, updateStore] = createStore<LongformThreadStore>({ ...emptyStore })
const [pubkey, setPubkey] = createSignal<string>(''); const [pubkey, setPubkey] = createSignal<string>('');
@ -261,6 +299,8 @@ const Longform: Component< { naddr: string } > = (props) => {
const naddr = () => props.naddr; const naddr = () => props.naddr;
let articleContextMenu: HTMLDivElement | undefined;
const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({ const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({
likes: 0, likes: 0,
liked: false, liked: false,
@ -296,178 +336,278 @@ const Longform: Component< { naddr: string } > = (props) => {
onMount(() => { onMount(() => {
lightbox.init(); lightbox.init();
clearArticle();
fetchArticle();
}); });
createEffect(() => { const clearArticle = () => {
if (!pubkey()) { setArticle(() => ({ ...emptyArticle }));
return; updateStore(() => ({ ...emptyStore }));
} };
const subId = `author_${naddr()}_${APP_ID}`; const fetchArticle = () => {
const decoded = decodeIdentifier(naddr());
const { pubkey, identifier, kind } = decoded.data;
if (kind !== Kind.LongForm) return;
const subId = `naddr_${naddr()}_${APP_ID}`;
const unsub = subscribeTo(subId, (type, subId, content) =>{ const unsub = subscribeTo(subId, (type, subId, content) =>{
if (type === 'EOSE') { if (type === 'EOSE') {
unsub(); unsub();
savePage(store.page);
return; return;
} }
if (type === 'EVENT') { if (type === 'EVENT') {
if (!content) { content && updatePage(content);
return;
}
if(content.kind === Kind.Metadata) {
const userContent = content as NostrUserContent;
const user = convertToUser(userContent);
setAuthor(() => ({ ...user }));
}
} }
}) });
getUserProfileInfo(pubkey(), account?.publicKey, subId); updateStore('isFetching', () => true);
});
onMount(() => { updateStore('page', () => ({
if (naddr() === 'naddr1_test') { messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
wordCount: {},
}));
setNote(() => ({ getArticleThread(account?.publicKey, pubkey, identifier, kind, subId);
title: 'Test Long-Form Note', }
summary: 'This is a markdown test to show all elements of the markdown',
image: '',
tags: ['test', 'markdown', 'demo'],
published: (new Date()).getTime() / 1_000,
content: test,
author: account?.publicKey,
topZaps: [],
}));
setPubkey(() => note.author);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
updateStore('page', 'users',
(usrs) => ({ ...usrs, [user.pubkey]: { ...user } })
);
return; return;
} }
if (typeof naddr() === 'string' && naddr().startsWith('naddr')) { if (content.kind === Kind.LongForm) {
const decoded = decodeIdentifier(naddr());
const { pubkey, identifier, kind } = decoded.data; let n: LongFormData = {
title: '',
summary: '',
image: '',
tags: [],
published: content.created_at || 0,
content: content.content,
author: content.pubkey,
topZaps: [],
id: content.id,
client: '',
}
const subId = `naddr_${naddr()}_${APP_ID}`; content.tags.forEach(tag => {
switch (tag[0]) {
const unsub = subscribeTo(subId, (type, subId, content) =>{ case 't':
if (type === 'EOSE') { n.tags.push(tag[1]);
unsub(); break;
return; case 'title':
} n.title = tag[1];
break;
if (type === 'EVENT') { case 'summary':
if (!content) { n.summary = tag[1];
return; break;
} case 'image':
n.image = tag[1];
if(content.kind === Kind.LongForm) { break;
case 'published':
setPubkey(() => content.pubkey); n.published = parseInt(tag[1]);
break;
let n: LongFormData = { case 'content':
title: '', n.content = tag[1];
summary: '', break;
image: '', case 'author':
tags: [], n.author = tag[1];
published: content.created_at || 0, break;
content: content.content, case 'client':
author: content.pubkey, n.client = tag[1];
topZaps: note.topZaps || [], break;
} default:
break;
content.tags.forEach(tag => {
switch (tag[0]) {
case 't':
n.tags.push(tag[1]);
break;
case 'title':
n.title = tag[1];
break;
case 'summary':
n.summary = tag[1];
break;
case 'image':
n.image = tag[1];
break;
case 'published':
n.published = parseInt(tag[1]);
break;
case 'content':
n.content = tag[1];
break;
case 'author':
n.author = tag[1];
break;
default:
break;
}
});
setNote(() => ({...n}));
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
const oldZaps = note.topZaps;
if (!oldZaps || oldZaps.length === 0) {
setNote((n) => ({ ...n, topZaps: [{ ...zap }]}));
return;
}
if (oldZaps.find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
setNote((n) => ({ ...n, topZaps: [...newZaps]}));
return;
}
} }
}); });
// getThread(account?.publicKey, naddr, subId) setArticle(n);
getArticleThread(account?.publicKey, pubkey, identifier, kind, subId); return;
} }
})
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (store.lastReply?.noteId !== nip19.noteEncode(message.id)) {
updateStore('page', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
}
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
updateStore('page', 'postStats',
(stats) => ({ ...stats, [stat.event_id]: { ...stat } })
);
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
updateStore('page', 'mentions',
(mentions) => ({ ...mentions, [mention.id]: { ...mention } })
);
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
updateStore('page', 'noteActions',
(actions) => ({ ...actions, [noteActions.event_id]: { ...noteActions } })
);
return;
}
if (content.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
updateStore('page', 'relayHints', (rh) => ({ ...rh, ...hints }));
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
if (article.id === zap.eventId && !article.topZaps.find(i => i.id === zap.id)) {
const newZaps = [ ...article.topZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
setArticle('topZaps', (zaps) => [ ...newZaps ]);
}
const oldZaps = store.page.topZaps[eventId];
if (oldZaps === undefined) {
updateStore('page', 'topZaps', () => ({ [eventId]: [{ ...zap }]}));
return;
}
if (oldZaps.find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
updateStore('page', 'topZaps', eventId, () => [ ...newZaps ]);
return;
}
};
const savePage = (page: FeedPage) => {
const newPosts = sortByRecency(convertToNotes(page, page.topZaps));
const users = Object.values(page.users).map(convertToUser);
updateStore('users', () => [ ...users ]);
saveNotes(newPosts);
const a = users.find(u => u.pubkey === article.author);
if (a) {
setAuthor(() => ({ ...a }));
}
};
const saveNotes = (newNotes: PrimalNote[], scope?: 'future') => {
updateStore('replies', (notes) => [ ...notes, ...newNotes ]);
updateStore('isFetching', () => false);
};
const openReactionModal = (openOn = 'likes') => {
app?.actions.openReactionModal(article.id, {
likes: reactionsState.likes,
zaps: reactionsState.zapCount,
reposts: reactionsState.reposts,
quotes: reactionsState.quoteCount,
openOn,
});
};
const onContextMenuTrigger = () => {
// app?.actions.openContextMenu(
// article,
// articleContextMenu?.getBoundingClientRect(),
// () => {
// app?.actions.openCustomZapModal(customZapInfo());
// },
// openReactionModal,
// );
}
return ( return (
<> <>
@ -475,50 +615,78 @@ const Longform: Component< { naddr: string } > = (props) => {
<div class={styles.author}> <div class={styles.author}>
<Show when={author}> <Show when={author}>
<Avatar user={author} size="xs" /> <Avatar user={author} size="xs" />
<div class={styles.userName}> <div class={styles.userInfo}>
{userName(author)} <div class={styles.userName}>
{userName(author)}
<VerificationCheck user={author} />
</div>
<Show when={author.nip05}>
<div class={styles.nip05}>
{nip05Verification(author)}
</div>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
<div class={styles.time}> </div>
{shortDate(note.published)}
<div class={styles.topBar}>
<div class={styles.left}>
<div class={styles.time}>
{shortDate(article.published)}
</div>
<Show when={article.client.length > 0}>
<div class={styles.client}>
via {article.client}
</div>
</Show>
</div>
<div class={styles.right}>
<BookmarkArticle article={article} />
<NoteContextTrigger
ref={articleContextMenu}
onClick={onContextMenuTrigger}
/>
</div> </div>
</div> </div>
<div id={`read_${naddr()}`} class={styles.longform}> <div id={`read_${naddr()}`} class={styles.longform}>
<Show <Show
when={note.content.length > 0} when={article.content.length > 0}
fallback={<Loader />} fallback={<Loader />}
> >
<div class={styles.title}> <div class={styles.title}>
{note.title} {article.title}
</div> </div>
<NoteImage <NoteImage
class={`${styles.image} hero_image_${naddr()}`} class={`${styles.image} hero_image_${naddr()}`}
src={note.image} src={article.image}
width={640} width={640}
/> />
<div class={styles.summary}> <div class={styles.summary}>
<div class={styles.border}></div> <div class={styles.border}></div>
<div class={styles.text}> <div class={styles.text}>
{note.summary} {article.summary}
</div> </div>
</div> </div>
<NoteTopZaps <NoteTopZaps
topZaps={note.topZaps} topZaps={article.topZaps}
zapCount={reactionsState.zapCount} zapCount={reactionsState.zapCount}
users={store.users}
action={() => {}} action={() => {}}
/> />
<PrimalMarkdown <PrimalMarkdown
noteId={props.naddr} noteId={props.naddr}
content={note.content || ''} content={article.content || ''}
readonly={true} /> readonly={true} />
<div class={styles.tags}> <div class={styles.tags}>
<For each={note.tags}> <For each={article.tags}>
{tag => ( {tag => (
<div class={styles.tag}> <div class={styles.tag}>
{tag} {tag}
@ -533,6 +701,11 @@ const Longform: Component< { naddr: string } > = (props) => {
</div> */} </div> */}
</Show> </Show>
</div> </div>
<div>
<For each={store.replies}>
{reply => <Note note={reply} />}
</For>
</div>
</>); </>);
} }