First markdown render try

This commit is contained in:
Bojan Mojsilovic 2024-05-22 13:31:13 +02:00
parent 52b4aba426
commit 786fda989a
7 changed files with 7740 additions and 12 deletions

7273
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,18 @@
"@cookbook/solid-intl": "0.1.2",
"@jukben/emoji-search": "3.0.0",
"@kobalte/core": "0.11.0",
"@milkdown/core": "^7.3.6",
"@milkdown/ctx": "^7.3.6",
"@milkdown/plugin-emoji": "^7.3.6",
"@milkdown/plugin-history": "^7.3.6",
"@milkdown/plugin-listener": "^7.3.6",
"@milkdown/plugin-slash": "^7.3.6",
"@milkdown/preset-commonmark": "^7.3.6",
"@milkdown/preset-gfm": "^7.3.6",
"@milkdown/prose": "^7.3.6",
"@milkdown/theme-nord": "^7.3.6",
"@milkdown/transformer": "^7.3.6",
"@milkdown/utils": "^7.3.6",
"@picocss/pico": "1.5.10",
"@scure/base": "1.1.3",
"@solidjs/router": "0.8.3",
@ -51,9 +63,11 @@
"nostr-tools": "1.15.0",
"photoswipe": "5.4.3",
"qr-code-styling": "^1.6.0-rc.1",
"remark-directive": "^3.0.0",
"sass": "1.67.0",
"solid-js": "1.7.11",
"solid-markdown": "^2.0.1",
"solid-transition-group": "0.2.3"
"solid-transition-group": "0.2.3",
"unist-util-visit": "^5.0.0"
}
}

View File

@ -0,0 +1,33 @@
.primalMarkdown {
width: 100%;
.toolbar {
display: flex;
gap: 8px;
}
.editor {
margin-block: 8px;
* {
color: var(--text-primary);
}
p, li {
font-size: 18px;
font-weight: 500;
}
a {
color: var(--accent-links);
}
pre, code, mark {
background-color: var(--background-input);
}
ins {
color: var(--warning-bright);
}
}
}

View File

@ -0,0 +1,227 @@
import { Component, createEffect, createSignal, For, lazy, Match, onCleanup, onMount, Show, Suspense, Switch } from 'solid-js';
import { editorViewOptionsCtx, Editor, rootCtx } from '@milkdown/core';
import {
commonmark,
toggleStrongCommand,
toggleEmphasisCommand,
} from '@milkdown/preset-commonmark';
import {
gfm,
insertTableCommand,
} from '@milkdown/preset-gfm';
import { callCommand, getMarkdown, replaceAll, insert, getHTML, outline } from '@milkdown/utils';
import { history, undoCommand, redoCommand } from '@milkdown/plugin-history';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import styles from './PrimalMarkdown.module.scss';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonGhost from '../Buttons/ButtonGhost';
import { Ctx } from '@milkdown/ctx';
import { npubToHex } from '../../lib/keys';
import { subscribeTo } from '../../sockets';
import { APP_ID } from '../../App';
import { getUserProfileInfo } from '../../lib/profile';
import { useAccountContext } from '../../contexts/AccountContext';
import { Kind } from '../../constants';
import { PrimalNote, PrimalUser } from '../../types/primal';
import { convertToUser, userName } from '../../stores/profile';
import { A } from '@solidjs/router';
import { createStore } from 'solid-js/store';
import { nip19 } from 'nostr-tools';
import { fetchNotes } from '../../handleNotes';
import { logError } from '../../lib/logger';
import EmbeddedNote from '../EmbeddedNote/EmbeddedNote';
const PrimalMarkdown: Component<{
id?: string,
content?: string,
readonly?: boolean,
}> = (props) => {
const account = useAccountContext();
let ref: HTMLDivElement | undefined;
let editor: Editor;
const [userMentions, setUserMentions] = createStore<Record<string, PrimalUser>>({});
const [noteMentions, setNoteMentions] = createStore<Record<string, PrimalNote>>({});
const fetchUserInfo = (npub: string) => {
const pubkey = npubToHex(npub);
const subId = `lf_fui_${APP_ID}`;
let user: PrimalUser;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
setUserMentions(() => ({ [user.npub]: { ...user }}))
return;
}
if (type === 'EVENT') {
if (content?.kind === Kind.Metadata) {
user = convertToUser(content);
}
}
});
getUserProfileInfo(pubkey, account?.publicKey, subId);
}
const fetchNoteInfo = async (npub: string) => {
const noteId = nip19.decode(npub).data;
const subId = `lf_fni_${APP_ID}`;
try {
const notes = await fetchNotes(account?.publicKey, [noteId], subId);
if (notes.length > 0) {
const note = notes[0];
setNoteMentions(() => ({ [note.post.noteId]: { ...note } }))
}
} catch (e) {
logError('Failed to fetch notes: ', e);
}
}
const isMention = (el: Element) => {
const regex = /nostr:([A-z0-9]+)/;
const content = el.innerHTML;
return regex.test(content)
}
const renderMention = (el: Element) => {
const regex = /nostr:([A-z0-9]+)/;
const content = el.innerHTML;
const match = content.match(regex);
if (match === null || match.length < 2) return el;
const [nostr, id] = match;
if (id.startsWith('npub1')) {
fetchUserInfo(id);
renderMention
return (
<Show
when={userMentions[id] !== undefined}
fallback={<A href={`/p/${id}`}>{nostr}</A>}
>
<A href={`/p/${id}`}>@{userName(userMentions[id])}</A>
</Show>
);
}
if (id.startsWith('note1')) {
fetchNoteInfo(id);
return (
<Show
when={noteMentions[id] !== undefined}
fallback={<A href={`/e/${id}`}>{nostr}</A>}
>
<EmbeddedNote
note={noteMentions[id]}
mentionedUsers={noteMentions[id].mentionedUsers || {}}
/>
</Show>
);
}
return el;
};
const [html, setHTML] = createSignal<string>();
onMount(async () => {
editor = await Editor.make()
.config((ctx) => {
ctx.set(rootCtx, ref);
ctx.update(editorViewOptionsCtx, prev => ({
...prev,
editable: () => !Boolean(props.readonly),
}))
})
.use(commonmark)
.use(gfm)
// .use(emoji)
.use(history)
// .use(userMention)
// .use(copilotPlugin)
// .use(noteMention)
// .use(slash)
// .use(mention)
.create();
insert(props.content || '')(editor.ctx);
setHTML(getHTML()(editor.ctx));
});
onCleanup(() => {
editor.destroy();
});
const htmlArray = () => {
const el = document.createElement('div');
el.innerHTML = html() || '';
return [ ...el.children ];
}
const undo = () => editor?.action(callCommand(redoCommand.key));
const redo = () => editor?.action(callCommand(redoCommand.key));
const bold = () => editor?.action(callCommand(toggleStrongCommand.key));
const italic = () => editor?.action(callCommand(toggleEmphasisCommand.key));
const table = () => editor?.action(callCommand(insertTableCommand.key));
return (
<div class={styles.primalMarkdown}>
<Show when={!(Boolean(props.readonly))}>
<div class={styles.toolbar}>
<ButtonGhost onClick={undo}>Undo</ButtonGhost>
<ButtonGhost onClick={redo}>Redo</ButtonGhost>
<ButtonGhost onClick={bold}>Bold</ButtonGhost>
<ButtonGhost onClick={italic}>Italic</ButtonGhost>
<ButtonGhost onClick={table}>Table</ButtonGhost>
</div>
</Show>
<div ref={ref} class={styles.editor} style="display: none;" />
<div class={styles.editor}>
<For each={htmlArray()}>
{el => (
<Switch fallback={<>{el}</>}>
<Match when={isMention(el)}>
{renderMention(el)}
</Match>
</Switch>
)}
</For>
</div>
{/* <ButtonPrimary
onClick={() => {
const tele = getMarkdown();
console.log('TELE: ', tele(editor.ctx));
}}
>
Export
</ButtonPrimary> */}
</div>
);
};
export default PrimalMarkdown;

View File

@ -13,6 +13,7 @@ export const emptyPage: FeedPage = {
messages: [],
postStats: {},
noteActions: {},
topZaps: {},
}
export const nostrHighlights ='9a500dccc084a138330a1d1b2be0d5e86394624325d25084d3eca164e7ea698a';

185
src/handleNotes.ts Normal file
View File

@ -0,0 +1,185 @@
import { nip19 } from "nostr-tools";
import { Kind } from "./constants";
import { getEvents } from "./lib/feed";
import { setLinkPreviews } from "./lib/notes";
import { updateStore, store } from "./services/StoreService";
import { subscribeTo } from "./sockets";
import { convertToNotes } from "./stores/note";
import { account } from "./translations";
import { FeedPage, NostrEventContent, NostrEventType, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalNote, TopZap } from "./types/primal";
import { parseBolt11 } from "./utils";
export const fetchNotes = (pubkey: string | undefined, noteIds: string[], subId: string) => {
return new Promise<PrimalNote[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let note: PrimalNote;
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
}
let lastNote: PrimalNote | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToNotes(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getEvents(pubkey, [...noteIds], subId, true);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
console.log('USER: ', user);
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.post?.noteId !== nip19.noteEncode(message.id)) {
page.messages.push({...message});
}
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
page.postStats[stat.event_id] = { ...stat };
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (!page.mentions) {
page.mentions = {};
}
page.mentions[mention.id] = { ...mention };
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
page.noteActions[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);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
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 (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};

View File

@ -32,6 +32,7 @@ import mdCont from 'markdown-it-container';
import mdAbbr from 'markdown-it-abbr';
import { rehype } from 'rehype';
import PrimalMarkdown from "../components/PrimalMarkdown/PrimalMarkdown";
export type LongFormData = {
title: string,
@ -61,6 +62,12 @@ const test = `
##### h5 Heading
###### h6 Heading
## Mentions
nostr:npub19f2765hdx8u9lz777w7azed2wsn9mqkf2gvn67mkldx8dnxvggcsmhe9da
nostr:note1tv033d7y088x8e90n5ut8htlsyy4yuwsw2fpgywq62w8xf0qcv8q8xvvhg
## Horizontal Rules
@ -489,11 +496,13 @@ const Longform: Component = () => {
<img class={styles.image} src={note.image} />
<div class={styles.content} innerHTML={inner()}>
{/* <SolidMarkdown
<PrimalMarkdown content={test} readonly={true} />
{/* <div class={styles.content} innerHTML={inner()}>
<SolidMarkdown
children={note.content || ''}
/> */}
</div>
/>
</div> */}
</Show>
</div>
</>);