diff --git a/src/components/ParsedNote/ParsedNote.tsx b/src/components/ParsedNote/ParsedNote.tsx index 9a2b000..e391653 100644 --- a/src/components/ParsedNote/ParsedNote.tsx +++ b/src/components/ParsedNote/ParsedNote.tsx @@ -48,6 +48,11 @@ import PhotoSwipeLightbox from 'photoswipe/lightbox'; const groupGridLimit = 7; +export type NoteContent = { + type: string, + tokens: string[], + meta?: Record, +}; export const groupGalleryImages = (noteHolder: HTMLDivElement | undefined) => { @@ -195,12 +200,6 @@ const ParsedNote: Component<{ setTokens(() => [...tokens]); } - type NoteContent = { - type: string, - tokens: string[], - meta?: Record, - }; - const removeLinebreaks = () => { if (lastSignificantContent === 'LB') { const lastIndex = content.length - 1; diff --git a/src/components/ProfileAbout/ProfileAbout.tsx b/src/components/ProfileAbout/ProfileAbout.tsx new file mode 100644 index 0000000..f4cffa5 --- /dev/null +++ b/src/components/ProfileAbout/ProfileAbout.tsx @@ -0,0 +1,285 @@ +import { A } from '@solidjs/router'; +import { nip19 } from 'nostr-tools'; +import { Component, createEffect, For, JSXElement, Show } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { APP_ID } from '../../App'; +import { linebreakRegex, urlExtractRegex, specialCharsRegex, hashtagCharsRegex, profileRegexG, Kind } from '../../constants'; +import { hexToNpub, npubToHex } from '../../lib/keys'; +import { isInterpunction, isUrl, isUserMention, isHashtag } from '../../lib/notes'; +import { getUserProfiles } from '../../lib/profile'; +import { subscribeTo } from '../../sockets'; +import { userName, truncateNpub } from '../../stores/profile'; +import { NostrUserContent } from '../../types/primal'; +import MentionedUserLink from '../Note/MentionedUserLink/MentionedUserLink'; +import { NoteContent } from '../ParsedNote/ParsedNote'; + +import styles from '../../pages/Profile.module.scss'; + +const ProfileAbout: Component<{about: string | undefined }> = (props) => { + + const [usersMentionedInAbout, setUsersMentionedInAbout] = createStore>({}); + + const [aboutTokens, setAboutTokens] = createStore([]); + + const [aboutContent, setAboutContent] = createStore([]); + + let lastSignificantContent = 'text'; + + const tokenizeAbout = (about: string) => { + const content = about.replace(linebreakRegex, ' __LB__ ').replace(/\s+/g, ' __SP__ '); + const tokens = content.split(/[\s]+/); + + setAboutTokens(() => [...tokens]); + } + + const updateAboutContent = (type: string, token: string, meta?: Record) => { + setAboutContent((contentArray) => { + if (contentArray.length > 0 && contentArray[contentArray.length -1].type === type) { + const c = { ...contentArray[contentArray.length - 1] }; + + c.tokens = [...c.tokens, token]; + + if (meta) { + c.meta = { ...meta }; + } + + return [ ...contentArray.slice(0, contentArray.length - 1), { ...c }]; + } + + return [...contentArray, { type, tokens: [token], meta: { ...meta } }] + }); + } + + const parseAboutToken = (token: string) => { + if (token === '__LB__') { + + updateAboutContent('linebreak', token); + lastSignificantContent = 'LB'; + return; + } + + if (token === '__SP__') { + if (!['LB'].includes(lastSignificantContent)) { + updateAboutContent('text', ' '); + } + return; + } + + if (isInterpunction(token)) { + lastSignificantContent = 'text'; + updateAboutContent('text', token); + return; + } + + if (isUrl(token)) { + const index = token.indexOf('http'); + + if (index > 0) { + const prefix = token.slice(0, index); + + const matched = (token.match(urlExtractRegex) || [])[0]; + + if (matched) { + const suffix = token.substring(matched.length + index, token.length); + + parseAboutToken(prefix); + parseAboutToken(matched); + parseAboutToken(suffix); + return; + } else { + parseAboutToken(prefix); + parseAboutToken(token.slice(index)); + return; + } + } + + lastSignificantContent = 'link'; + updateAboutContent('link', token); + return; + } + + if (isUserMention(token)) { + lastSignificantContent = 'usermention'; + updateAboutContent('usermention', token); + return; + } + + if (isHashtag(token)) { + lastSignificantContent = 'hashtag'; + updateAboutContent('hashtag', token); + return; + } + + lastSignificantContent = 'text'; + updateAboutContent('text', token); + return; + }; + + const renderLinebreak = (item: NoteContent) => { + + // Allow max consecutive linebreak + const len = Math.min(2, item.tokens.length); + + const lineBreaks = Array(len).fill(
) + + return {_ =>
}
+ }; + + const renderText = (item: NoteContent) => { + let tokens = []; + + for (let i=0;i'); + + return <>{text}; + }; + + const renderLinks = (item: NoteContent, index?: number) => { + return + {(token) => { + return {token}; + }} + + }; + + const renderUserMention = (item: NoteContent) => { + return + {(token) => { + let [_, id] = token.split(':'); + + if (!id) { + return <>{token}; + } + + let end = ''; + + let match = specialCharsRegex.exec(id); + + if (match) { + const i = match.index; + end = id.slice(i); + id = id.slice(0, i); + } + + try { + const profileId = nip19.decode(id).data as string | nip19.ProfilePointer; + + const hex = typeof profileId === 'string' ? profileId : profileId.pubkey; + const npub = hexToNpub(hex); + + const path = `/p/${npub}`; + + let user = usersMentionedInAbout && usersMentionedInAbout[hex]; + + const label = user ? userName(user) : truncateNpub(npub); + + return !user ? + <>@{label}{end} : + <>{MentionedUserLink({ user })}{end}; + } catch (e) { + return {token}; + } + }} + + }; + + const renderHashtag = (item: NoteContent) => { + return + {(token) => { + let [_, term] = token.split('#'); + let end = ''; + + let match = hashtagCharsRegex.exec(term); + + if (match) { + const i = match.index; + end = term.slice(i); + term = term.slice(0, i); + } + + const embeded = #{term}; + + return {embeded}{end}; + }} + + }; + + const renderAboutContent = (item: NoteContent, index: number) => { + + const renderers: Record JSXElement> = { + linebreak: renderLinebreak, + text: renderText, + link: renderLinks, + usermention: renderUserMention, + hashtag: renderHashtag, + } + + return renderers[item.type] ? + renderers[item.type](item, index) : + <>; + }; + + createEffect(() => { + if (aboutTokens.length === 0) return; + + for (let i=0; i < aboutTokens.length; i++) { + parseAboutToken(aboutTokens[i]); + } + }); + + const parseForMentions = (about: string) => { + let userMentions = []; + let m; + + do { + m = profileRegexG.exec(about); + if (m) { + userMentions.push(npubToHex(m[1])) + } + } while (m); + + const subId = `pa_u_${APP_ID}`; + + const unsub = subscribeTo(subId, (type, _, content) => { + if (type === 'EVENT' && content?.kind === Kind.Metadata) { + const user = content as NostrUserContent; + const profile = JSON.parse(user.content); + + setUsersMentionedInAbout(() => ({[user.pubkey]: ({ ...profile })})); + } + + if(type === 'EOSE') { + unsub(); + tokenizeAbout(about); + } + }); + + getUserProfiles(userMentions, subId); + + // const a = linkifyNostrNoteLink(linkifyNostrProfileLink(urlify(sanitize(about), () => '', false, false, true))); + + // setRenderProfileAbout(a) + }; + + createEffect(() => { + if (props.about && props.about.length > 0) { + parseForMentions(props.about); + } + }); + return ( + 0}> +
+ + {(item, index) => renderAboutContent(item, index())} + +
+
+ ); +} + +export default ProfileAbout; diff --git a/src/lib/notes.tsx b/src/lib/notes.tsx index 811349d..2e80827 100644 --- a/src/lib/notes.tsx +++ b/src/lib/notes.tsx @@ -1,5 +1,5 @@ -// @ts-ignore Bad types in nostr-tools import { A } from "@solidjs/router"; +// @ts-ignore Bad types in nostr-tools import { Relay } from "nostr-tools"; import { createStore } from "solid-js/store"; import LinkPreview from "../components/LinkPreview/LinkPreview"; diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 2d37ce2..1e803e3 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,4 +1,4 @@ -import { RouteDataFuncArgs, useNavigate, useParams, useRouteData } from '@solidjs/router'; +import { A, RouteDataFuncArgs, useNavigate, useParams, useRouteData } from '@solidjs/router'; import { nip19 } from 'nostr-tools'; import { Component, @@ -19,7 +19,7 @@ import { useProfileContext } from '../contexts/ProfileContext'; import { useAccountContext } from '../contexts/AccountContext'; import Wormhole from '../components/Wormhole/Wormhole'; import { useIntl } from '@cookbook/solid-intl'; -import { urlify, sanitize, linkifyNostrProfileLink, linkifyNostrNoteLink } from '../lib/notes'; +import { sanitize } from '../lib/notes'; import { shortDate } from '../lib/dates'; import styles from './Profile.module.scss'; @@ -43,6 +43,7 @@ import PhotoSwipeLightbox from 'photoswipe/lightbox'; import NoteImage from '../components/NoteImage/NoteImage'; import ProfileQrCodeModal from '../components/ProfileQrCodeModal/ProfileQrCodeModal'; import { CustomZapInfo, useAppContext } from '../contexts/AppContext'; +import ProfileAbout from '../components/ProfileAbout/ProfileAbout'; const Profile: Component = () => { @@ -436,18 +437,6 @@ const Profile: Component = () => { toaster?.sendSuccess(intl.formatMessage(tToast.noteAuthorNpubCoppied)); }; - const [renderProfileAbout, setRenderProfileAbout] = createSignal(''); - - const getProfileAbout = (about: string) => { - const a = linkifyNostrNoteLink(linkifyNostrProfileLink(urlify(sanitize(about), () => '', false, false, true))); - - setRenderProfileAbout(a) - }; - - createEffect(() => { - getProfileAbout(profile?.userProfile?.about || ''); - }); - createEffect(() => { if (showContext()) { document.addEventListener('click', onClickOutside); @@ -664,10 +653,7 @@ const Profile: Component = () => { - 0}> -
-
-
+