diff --git a/src/components/Note/NoteTopZaps.tsx b/src/components/Note/NoteTopZaps.tsx index 348ca33..9455750 100644 --- a/src/components/Note/NoteTopZaps.tsx +++ b/src/components/Note/NoteTopZaps.tsx @@ -4,13 +4,14 @@ import { useThreadContext } from "../../contexts/ThreadContext"; import Avatar from "../Avatar/Avatar"; import { TransitionGroup } from 'solid-transition-group'; import styles from "./Note.module.scss"; -import { TopZap } from "../../types/primal"; +import { PrimalUser, TopZap } from "../../types/primal"; const NoteTopZaps: Component<{ topZaps: TopZap[], zapCount: number, action: () => void, id?: string, + users?: PrimalUser[] }> = (props) => { const threadContext = useThreadContext(); @@ -42,7 +43,7 @@ const NoteTopZaps: Component<{ } 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 ( diff --git a/src/index.scss b/src/index.scss index 56cec39..706d8e7 100644 --- a/src/index.scss +++ b/src/index.scss @@ -145,7 +145,6 @@ body::after{ url(./assets/icons/nav/settings_selected.svg) url(./assets/icons/nav/long.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_light.png); } diff --git a/src/pages/Longform.module.scss b/src/pages/Longform.module.scss index 1924402..aa014cc 100644 --- a/src/pages/Longform.module.scss +++ b/src/pages/Longform.module.scss @@ -11,17 +11,61 @@ align-items: center; gap: 6px; - .userName { - color: var(--text-primary); + .userInfo { + 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-weight: 700; } } - .time { - color: var(--text-tertiary); - font-size: 14px; - font-weight: 700; + .right { + display: flex; + justify-content: flex-end; + align-items: center; } } diff --git a/src/pages/Longform.tsx b/src/pages/Longform.tsx index f0b7099..2e24655 100644 --- a/src/pages/Longform.tsx +++ b/src/pages/Longform.tsx @@ -1,20 +1,20 @@ import { useIntl } from "@cookbook/solid-intl"; 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 { APP_ID } from "../App"; import { Kind } from "../constants"; import { useAccountContext } from "../contexts/AccountContext"; import { decodeIdentifier } from "../lib/keys"; -import { getParametrizedEvent } from "../lib/notes"; +import { getParametrizedEvent, setLinkPreviews } from "../lib/notes"; import { subscribeTo } from "../sockets"; import { SolidMarkdown } from "solid-markdown"; import styles from './Longform.module.scss'; 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 { convertToUser, userName } from "../stores/profile"; +import { convertToUser, nip05Verification, userName } from "../stores/profile"; import Avatar from "../components/Avatar/Avatar"; import { shortDate } from "../lib/dates"; @@ -26,11 +26,19 @@ import { full as mdEmoji } from 'markdown-it-emoji'; import PrimalMarkdown from "../components/PrimalMarkdown/PrimalMarkdown"; import NoteTopZaps from "../components/Note/NoteTopZaps"; 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 { getArticleThread, getThread } from "../lib/feed"; import PhotoSwipeLightbox from "photoswipe/lightbox"; 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 = { title: string, @@ -41,9 +49,19 @@ export type LongFormData = { content: string, author: string, 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: '', summary: '', image: '', @@ -52,6 +70,24 @@ const emptyLongNote = { content: '', author: '', topZaps: [], + id: '', + client: '', +}; + +const emptyStore: LongformThreadStore = { + replies: [], + page: { + messages: [], + users: {}, + postStats: {}, + mentions: {}, + noteActions: {}, + topZaps: {}, + wordCount: {}, + }, + users: [], + isFetching: false, + lastReply: undefined, } const test = ` @@ -249,10 +285,12 @@ Term 2 with *inline markup* const Longform: Component< { naddr: string } > = (props) => { const account = useAccountContext(); + const app = useAppContext(); const params = useParams(); const intl = useIntl(); - const [note, setNote] = createStore({...emptyLongNote}); + const [article, setArticle] = createStore({...emptyArticle}); + const [store, updateStore] = createStore({ ...emptyStore }) const [pubkey, setPubkey] = createSignal(''); @@ -261,6 +299,8 @@ const Longform: Component< { naddr: string } > = (props) => { const naddr = () => props.naddr; + let articleContextMenu: HTMLDivElement | undefined; + const [reactionsState, updateReactionsState] = createStore({ likes: 0, liked: false, @@ -296,178 +336,278 @@ const Longform: Component< { naddr: string } > = (props) => { onMount(() => { lightbox.init(); + clearArticle(); + fetchArticle(); }); - createEffect(() => { - if (!pubkey()) { - return; - } + const clearArticle = () => { + setArticle(() => ({ ...emptyArticle })); + 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) =>{ if (type === 'EOSE') { unsub(); + savePage(store.page); return; } if (type === 'EVENT') { - if (!content) { - return; - } - - if(content.kind === Kind.Metadata) { - const userContent = content as NostrUserContent; - - const user = convertToUser(userContent); - - setAuthor(() => ({ ...user })); - } + content && updatePage(content); } - }) + }); - getUserProfileInfo(pubkey(), account?.publicKey, subId); - }); + updateStore('isFetching', () => true); - onMount(() => { - if (naddr() === 'naddr1_test') { + updateStore('page', () => ({ + messages: [], + users: {}, + postStats: {}, + mentions: {}, + noteActions: {}, + topZaps: {}, + wordCount: {}, + })); - setNote(() => ({ - 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: [], - })); + getArticleThread(account?.publicKey, pubkey, identifier, kind, subId); + } - 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; } - if (typeof naddr() === 'string' && naddr().startsWith('naddr')) { - const decoded = decodeIdentifier(naddr()); + if (content.kind === Kind.LongForm) { - 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}`; - - const unsub = subscribeTo(subId, (type, subId, content) =>{ - if (type === 'EOSE') { - unsub(); - return; - } - - if (type === 'EVENT') { - if (!content) { - return; - } - - if(content.kind === Kind.LongForm) { - - setPubkey(() => content.pubkey); - - let n: LongFormData = { - title: '', - summary: '', - image: '', - tags: [], - published: content.created_at || 0, - content: content.content, - author: content.pubkey, - topZaps: note.topZaps || [], - } - - 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; - } + 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; + case 'client': + n.client = tag[1]; + break; + default: + break; } }); - // getThread(account?.publicKey, naddr, subId) - getArticleThread(account?.publicKey, pubkey, identifier, kind, subId); + setArticle(n); + 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 ( <> @@ -475,50 +615,78 @@ const Longform: Component< { naddr: string } > = (props) => {
-
- {userName(author)} +
+
+ {userName(author)} + +
+ +
+ {nip05Verification(author)} +
+
-
- {shortDate(note.published)} +
+ +
+
+
+ {shortDate(article.published)} +
+ 0}> +
+ via {article.client} +
+
+
+ +
+ +
+
0} + when={article.content.length > 0} fallback={} >
- {note.title} + {article.title}
- {note.summary} + {article.summary}
{}} />
- + {tag => (
{tag} @@ -533,6 +701,11 @@ const Longform: Component< { naddr: string } > = (props) => {
*/}
+
+ + {reply => } + +
); }