mirror of
https://github.com/PrimalHQ/primal-web-app.git
synced 2024-09-30 00:41:09 +00:00
First markdown render try
This commit is contained in:
parent
52b4aba426
commit
786fda989a
7273
package-lock.json
generated
7273
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
33
src/components/PrimalMarkdown/PrimalMarkdown.module.scss
Normal file
33
src/components/PrimalMarkdown/PrimalMarkdown.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
227
src/components/PrimalMarkdown/PrimalMarkdown.tsx
Normal file
227
src/components/PrimalMarkdown/PrimalMarkdown.tsx
Normal 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;
|
@ -13,6 +13,7 @@ export const emptyPage: FeedPage = {
|
||||
messages: [],
|
||||
postStats: {},
|
||||
noteActions: {},
|
||||
topZaps: {},
|
||||
}
|
||||
|
||||
export const nostrHighlights ='9a500dccc084a138330a1d1b2be0d5e86394624325d25084d3eca164e7ea698a';
|
||||
|
185
src/handleNotes.ts
Normal file
185
src/handleNotes.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
@ -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>
|
||||
</>);
|
||||
|
Loading…
Reference in New Issue
Block a user