Parse profile about

This commit is contained in:
Bojan Mojsilovic 2024-03-26 17:15:43 +01:00
parent 1638029c93
commit 98835daf54
4 changed files with 295 additions and 25 deletions

View File

@ -48,6 +48,11 @@ import PhotoSwipeLightbox from 'photoswipe/lightbox';
const groupGridLimit = 7;
export type NoteContent = {
type: string,
tokens: string[],
meta?: Record<string, any>,
};
export const groupGalleryImages = (noteHolder: HTMLDivElement | undefined) => {
@ -195,12 +200,6 @@ const ParsedNote: Component<{
setTokens(() => [...tokens]);
}
type NoteContent = {
type: string,
tokens: string[],
meta?: Record<string, any>,
};
const removeLinebreaks = () => {
if (lastSignificantContent === 'LB') {
const lastIndex = content.length - 1;

View File

@ -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<Record<string, any>>({});
const [aboutTokens, setAboutTokens] = createStore<string[]>([]);
const [aboutContent, setAboutContent] = createStore<NoteContent[]>([]);
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<string, any>) => {
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(<br/>)
return <For each={lineBreaks}>{_ => <br/>}</For>
};
const renderText = (item: NoteContent) => {
let tokens = [];
for (let i=0;i<item.tokens.length;i++) {
const token = item.tokens[i];
tokens.push(token)
}
const text = tokens.join(' ').replaceAll('&lt;', '<').replaceAll('&gt;', '>');
return <>{text}</>;
};
const renderLinks = (item: NoteContent, index?: number) => {
return <For each={item.tokens}>
{(token) => {
return <span data-url={token}><a link href={token} target="_blank" >{token}</a></span>;
}}
</For>
};
const renderUserMention = (item: NoteContent) => {
return <For each={item.tokens}>
{(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 ?
<><A href={path}>@{label}</A>{end}</> :
<>{MentionedUserLink({ user })}{end}</>;
} catch (e) {
return <span class={styles.error}> {token}</span>;
}
}}
</For>
};
const renderHashtag = (item: NoteContent) => {
return <For each={item.tokens}>
{(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 = <A href={`/search/%23${term}`}>#{term}</A>;
return <span class="whole"> {embeded}{end}</span>;
}}
</For>
};
const renderAboutContent = (item: NoteContent, index: number) => {
const renderers: Record<string, (item: NoteContent, index?: number) => 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 (
<Show when={aboutContent.length > 0}>
<div class={styles.profileAbout}>
<For each={aboutContent}>
{(item, index) => renderAboutContent(item, index())}
</For>
</div>
</Show>
);
}
export default ProfileAbout;

View File

@ -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";

View File

@ -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 = () => {
</Show>
</div>
<Show when={renderProfileAbout().length > 0}>
<div class={styles.profileAbout} innerHTML={renderProfileAbout()}>
</div>
</Show>
<ProfileAbout about={profile?.userProfile?.about} />
<Show when={profile?.userProfile?.website}>