refactor text parser

This commit is contained in:
reya 2023-10-28 14:36:12 +07:00
parent 6685d9af38
commit ace58ecdd5
6 changed files with 200 additions and 211 deletions

View File

@ -51,43 +51,43 @@ dependencies:
specifier: ^5.0.5
version: 5.0.5(react-dom@18.2.0)(react@18.2.0)
'@tauri-apps/api':
specifier: 2.0.0-alpha.9
specifier: ^2.0.0-alpha.9
version: 2.0.0-alpha.9
'@tauri-apps/cli':
specifier: 2.0.0-alpha.16
specifier: ^2.0.0-alpha.16
version: 2.0.0-alpha.16
'@tauri-apps/plugin-clipboard-manager':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-dialog':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-fs':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-http':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-notification':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-os':
specifier: 2.0.0-alpha.3
specifier: ^2.0.0-alpha.3
version: 2.0.0-alpha.3
'@tauri-apps/plugin-process':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-shell':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-sql':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-updater':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tauri-apps/plugin-upload':
specifier: 2.0.0-alpha.2
specifier: ^2.0.0-alpha.2
version: 2.0.0-alpha.2
'@tiptap/extension-character-count':
specifier: ^2.1.12

View File

@ -1,84 +1,23 @@
import { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import { Link } from 'react-router-dom';
import rehypeExternalLinks from 'rehype-external-links';
import remarkGfm from 'remark-gfm';
import {
Boost,
Hashtag,
ImagePreview,
Invoice,
LinkPreview,
MentionNote,
MentionUser,
VideoPreview,
} from '@shared/notes';
import { ImagePreview, LinkPreview, MentionNote, VideoPreview } from '@shared/notes';
import { parser } from '@utils/parser';
export function TextNote(props: { content?: string }) {
const richContent = parser(props.content) ?? null;
if (!richContent) {
return (
<div>
<ReactMarkdown
className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500"
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeExternalLinks({ target: '_blank' })]}
disallowedElements={['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code']}
unwrapDisallowed={true}
>
{props.content}
</ReactMarkdown>
</div>
);
}
const richContent = parser(props.content);
return (
<div>
<ReactMarkdown
className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500"
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeExternalLinks({ target: '_blank' })]}
components={{
a: ({ href }) => {
const cleanURL = new URL(href);
cleanURL.search = '';
return (
<Link to={href} target="_blank" className="w-max break-all hover:underline">
{cleanURL.hostname + cleanURL.pathname}
</Link>
);
},
del: ({ children }) => {
const key = children[0] as string;
if (typeof key !== 'string') return;
if (key.startsWith('pub') && key.length > 50 && key.length < 100) {
return <MentionUser pubkey={key.replace('pub-', '')} />;
}
if (key.startsWith('tag')) {
return <Hashtag tag={key.replace('tag-', '')} />;
}
if (key.startsWith('boost')) {
return <Boost boost={key.replace('boost-', '')} />;
}
if (key.startsWith('lnbc')) {
return <Invoice invoice={key.replace('lnbc-', '')} />;
}
},
}}
disallowedElements={['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code']}
unwrapDisallowed={true}
>
<div className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500">
{richContent.parsed}
</ReactMarkdown>
{richContent.images.length > 0 && <ImagePreview urls={richContent.images} />}
{richContent.videos.length > 0 && <VideoPreview urls={richContent.videos} />}
{richContent.links.length > 0 && <LinkPreview urls={richContent.links} />}
{richContent.notes.length > 0 &&
richContent.notes.map((note: string) => <MentionNote key={note} id={note} />)}
</div>
{richContent.images.length ? <ImagePreview urls={richContent.images} /> : null}
{richContent.videos.length ? <VideoPreview urls={richContent.videos} /> : null}
{richContent.links.length ? <LinkPreview urls={richContent.links} /> : null}
{richContent.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))}
</div>
);
}

View File

@ -440,9 +440,9 @@ export const User = memo(function User({
if (status === 'pending') {
return (
<div className="flex items-center gap-3">
<div className="flex items-start gap-3">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div className="flex h-6 flex-1 items-start gap-2">
<div className="h-6 flex-1">
<div className="h-4 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>

View File

@ -1,124 +0,0 @@
import { nip19 } from 'nostr-tools';
import { AddressPointer, EventPointer, ProfilePointer } from 'nostr-tools/lib/nip19';
import { RichContent } from '@utils/types';
function isURL(string: string) {
try {
const url = new URL(string);
if (url.protocol.length > 0) {
if (url.protocol === 'https:' || url.protocol === 'http:') {
return true;
} else {
return false;
}
}
return true;
} catch (e) {
return false;
}
}
export function parser(eventContent: string) {
if (!eventContent) return '';
try {
const content: RichContent = {
parsed: null,
images: [],
videos: [],
links: [],
notes: [],
};
const parse = eventContent.split(/\s/gm).map((word) => {
// url
if (isURL(word)) {
const url = new URL(word);
url.search = '';
if (url.pathname.match(/\.(jpg|jpeg|gif|png|webp|avif)$/)) {
// image url
content.images.push(word);
// remove url from original content
return word.replace(word, '');
}
if (url.pathname.match(/\.(mp4|mov|webm|wmv|flv|mts|avi|ogv|mkv|mp3|m3u8)$/)) {
// video
content.videos.push(word);
// remove url from original content
return word.replace(word, '');
}
content.links.push(url.toString());
}
// hashtag
if (word.startsWith('#') && word.length > 1) {
return word.replace(word, `~tag-${word}~`);
}
// boost
if (word.startsWith('$prism') && word.length > 1) {
return word.replace(word, `~boost-${word}~`);
}
// nostr account references (depreciated)
if (word.startsWith('@npub1')) {
const npub = word.replace('@', '').replace(/[^a-zA-Z0-9 ]/g, '');
return word.replace(word, `~pub-${nip19.decode(npub).data}~`);
}
// nostr account references
if (word.startsWith('nostr:npub1') || word.startsWith('npub1')) {
const npub = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
return word.replace(word, `~pub-${nip19.decode(npub).data}~`);
}
// nostr profile references
if (word.startsWith('nostr:nprofile1') || word.startsWith('nprofile1')) {
const nprofile = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
const decoded = nip19.decode(nprofile).data as ProfilePointer;
return word.replace(word, `~pub-${decoded.pubkey}~`);
}
// nostr account references
if (word.startsWith('nostr:note1') || word.startsWith('note1')) {
const note = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
content.notes.push(nip19.decode(note).data as string);
return word.replace(word, '');
}
// nostr event references
if (word.startsWith('nostr:nevent1') || word.startsWith('nevent1')) {
const nevent = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
const decoded = nip19.decode(nevent).data as EventPointer;
content.notes.push(decoded.id);
return word.replace(word, '');
}
// nostr address references
if (word.startsWith('nostr:naddr1') || word.startsWith('naddr1')) {
const naddr = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
const decoded = nip19.decode(naddr).data as AddressPointer;
return word.replace(word, `~pub-${decoded.pubkey}~`);
}
// lightning invoice
if (word.startsWith('lnbc') && word.length > 60) {
return word.replace(word, `~lnbc-${word}~`);
}
// normal word
return word;
});
// update content with parsed version
content.parsed = parse.join(' ');
return content;
} catch (e) {
console.error('cannot parse content, error: ', e);
}
}

173
src/utils/parser.tsx Normal file
View File

@ -0,0 +1,173 @@
import { nip19 } from 'nostr-tools';
import {
AddressPointer,
EventPointer,
ProfilePointer,
} from 'nostr-tools/lib/types/nip19';
import { Link } from 'react-router-dom';
import reactStringReplace from 'react-string-replace';
import { Boost, Hashtag, Invoice, MentionUser } from '@shared/notes';
import { RichContent } from '@utils/types';
function isURL(string: string) {
try {
const url = new URL(string);
if (url.protocol.length > 0) {
if (url.protocol === 'https:' || url.protocol === 'http:') {
return true;
} else {
return false;
}
}
return true;
} catch (e) {
return false;
}
}
export function parser(eventContent: string) {
const content: RichContent = {
parsed: null,
images: [],
videos: [],
links: [],
notes: [],
};
const parsed = eventContent.split(/\s/gm).map((word) => {
// nostr note references
if (word.startsWith('nostr:note1') || word.startsWith('note1')) {
const note = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
content.notes.push(nip19.decode(note).data as string);
return word.replace(word, ' ');
}
// nostr event references
if (word.startsWith('nostr:nevent1') || word.startsWith('nevent1')) {
const nevent = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
const decoded = nip19.decode(nevent).data as EventPointer;
content.notes.push(decoded.id);
return word.replace(word, ' ');
}
// url
if (isURL(word)) {
const url = new URL(word);
url.search = '';
if (url.pathname.match(/\.(jpg|jpeg|gif|png|webp|avif)$/)) {
// image url
content.images.push(word);
// remove url from original content
return word.replace(word, ' ');
}
if (url.pathname.match(/\.(mp4|mov|webm|wmv|flv|mts|avi|ogv|mkv|mp3|m3u8)$/)) {
// video url
content.videos.push(word);
// remove url from original content
return word.replace(word, ' ');
}
// normal url
if (content.links.length < 1) {
content.links.push(url.toString());
return word.replace(word, ' ');
} else {
return reactStringReplace(word, word, (match, i) => (
<>
{' '}
<Link key={match + i} to={word} target="_blank" rel="noreferrer">
{word}
</Link>{' '}
</>
));
}
}
// hashtag
if (word.startsWith('#') && word.length > 1) {
return reactStringReplace(word, word, (match, i) => (
<>
{' '}
<Hashtag key={match + i} tag={match} />{' '}
</>
));
}
// boost
if (word.startsWith('$prism') && word.length > 1) {
return reactStringReplace(word, word, (match, i) => (
<>
{' '}
<Boost key={match + i} boost={match} />{' '}
</>
));
}
// nostr account references (depreciated)
if (word.startsWith('@npub1')) {
const npub = word.replace('@', '').replace(/[^a-zA-Z0-9 ]/g, '');
return reactStringReplace(word, word, (match, i) => (
<>
{' '}
<MentionUser key={match + i} pubkey={nip19.decode(npub).data as string} />{' '}
</>
));
}
// nostr account references
if (word.startsWith('nostr:npub1') || word.startsWith('npub1')) {
const npub = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
return reactStringReplace(word, word, (match, i) => (
<>
{' '}
<MentionUser key={match + i} pubkey={nip19.decode(npub).data as string} />{' '}
</>
));
}
// nostr profile references
if (word.startsWith('nostr:nprofile1') || word.startsWith('nprofile1')) {
const nprofile = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
const decoded = nip19.decode(nprofile).data as ProfilePointer;
return reactStringReplace(word, word, (match, i) => (
<>
{' '}
<MentionUser key={match + i} pubkey={decoded.pubkey} />{' '}
</>
));
}
// nostr address references
if (word.startsWith('nostr:naddr1') || word.startsWith('naddr1')) {
const naddr = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
const decoded = nip19.decode(naddr).data as AddressPointer;
return reactStringReplace(word, word, (match, i) => (
<>
{' '}
<MentionUser key={match + i} pubkey={decoded.pubkey} />{' '}
</>
));
}
// lightning invoice
if (word.startsWith('lnbc') && word.length > 60) {
return reactStringReplace(word, word, (match, i) => (
<>
{' '}
<Invoice key={match + i} invoice={word} />{' '}
</>
));
}
// normal word
return ' ' + word + ' ';
});
// update content with parsed version
content.parsed = parsed;
return content;
}

View File

@ -1,8 +1,9 @@
import { type NDKEvent, type NDKUserProfile } from '@nostr-dev-kit/ndk';
import { type Response } from '@tauri-apps/plugin-http';
import { ReactNode } from 'react';
export interface RichContent {
parsed: string;
parsed: string | ReactNode[];
images: string[];
videos: string[];
links: string[];