New tokenized note rendering

This commit is contained in:
Bojan Mojsilovic 2023-10-04 21:29:07 +02:00
parent b6f3869c58
commit b36e3b9be9
9 changed files with 1090 additions and 341 deletions

View File

@ -5,13 +5,12 @@ import { Component, createMemo, JSXElement, Show } from 'solid-js';
import { useMediaContext } from '../../contexts/MediaContext';
import { useThreadContext } from '../../contexts/ThreadContext';
import { date } from '../../lib/dates';
import { parseNote2 } from '../../lib/notes';
import { trimVerification } from '../../lib/profile';
import { nip05Verification, userName } from '../../stores/profile';
import { note as t } from '../../translations';
import { PrimalNote, PrimalUser } from '../../types/primal';
import Avatar from '../Avatar/Avatar';
import { parseNoteLinks, parseNpubLinks } from '../ParsedNote/ParsedNote';
import ParsedNote from '../ParsedNote/ParsedNote';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './EmbeddedNote.module.scss';
@ -20,7 +19,6 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string
const threadContext = useThreadContext();
const intl = useIntl();
const media = useMediaContext();
const noteId = () => nip19.noteEncode(props.note.post.id);
@ -32,77 +30,6 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string
return trimVerification(props.note.user?.nip05);
});
const parsedContent = (text: string) => {
const regex = /\#\[([0-9]*)\]/g;
let parsed = text;
let refs = [];
let match;
while((match = regex.exec(text)) !== null) {
refs.push(match[1]);
}
if (refs.length > 0) {
for(let i =0; i < refs.length; i++) {
let r = parseInt(refs[i]);
const tag = props.note.post.tags[r];
if (
tag[0] === 'e' &&
props.note.mentionedNotes &&
props.note.mentionedNotes[tag[1]]
) {
const embeded = (
<span>
{intl.formatMessage(
t.mentionIndication,
{ name: userName(props.note.user) },
)}
</span>
);
// @ts-ignore
parsed = parsed.replace(`#[${r}]`, embeded.outerHTML);
}
if (tag[0] === 'p' && props.mentionedUsers && props.mentionedUsers[tag[1]]) {
const user = props.mentionedUsers[tag[1]];
const link = (
<span class='linkish'>
@{userName(user)}
</span>
);
// @ts-ignore
parsed = parsed.replace(`#[${r}]`, link.outerHTML);
}
}
}
return parsed;
};
const highlightHashtags = (text: string) => {
const regex = /(?:\s|^)#[^\s!@#$%^&*(),.?":{}|<>]+/ig;
return text.replace(regex, (token) => {
const [space, term] = token.split('#');
const embeded = (
<span>
{space}
<span class="linkish">#{term}</span>
</span>
);
// @ts-ignore
return embeded.outerHTML;
});
}
const wrapper = (children: JSXElement) => {
if (props.includeEmbeds) {
return (
@ -167,21 +94,8 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string
</span>
</span>
</div>
<div class={styles.noteContent} innerHTML={
parseNoteLinks(
parseNpubLinks(
parsedContent(
highlightHashtags(
parseNote2(props.note.post.content, media?.actions.getMediaUrl)
),
),
props.note,
'links',
),
props.note,
!props.includeEmbeds,
)
}>
<div class={styles.noteContent}>
<ParsedNote note={props.note} ignoreMentionedNotes={true} />
</div>
</>
);

View File

@ -1,5 +1,7 @@
import { Component, JSX } from "solid-js";
import { Component, createEffect, JSX, onCleanup, onMount } from "solid-js";
import styles from "./NoteImage.module.scss";
import mediumZoom from "medium-zoom";
import type { Zoom } from 'medium-zoom';
// @ts-ignore Bad types in nostr-tools
import { generatePrivateKey } from "nostr-tools";
@ -10,9 +12,51 @@ const NoteImage: Component<{
}> = (props) => {
const imgId = generatePrivateKey();
const imgRef = () => {
return document.getElementById(imgId)
};
let zoomRef: Zoom | undefined;
const klass = () => `${styles.noteImage} ${props.isDev ? 'redBorder' : ''}`;
return <img id={imgId} src={props.src} class={klass()} onerror={props.onError} />;
const doZoom = (e: MouseEvent) => {
if (!e.target || (e.target as HTMLImageElement).id !== imgId) {
return;
}
e.preventDefault();
e.stopPropagation();
zoomRef?.open();
};
const getZoom = () => {
const iRef = imgRef();
if (zoomRef || !iRef) {
return zoomRef;
}
zoomRef = mediumZoom(iRef, {
background: "var(--background-site)",
});
zoomRef.attach(iRef);
}
onMount(() => {
getZoom();
});
onCleanup(() => {
const iRef = imgRef();
iRef && zoomRef && zoomRef.detach(iRef);
});
return (
<div>
<img id={imgId} src={props.src} class={klass()} onerror={props.onError} onClick={doZoom} />
</div>
);
}
export default NoteImage;

View File

@ -0,0 +1,569 @@
import { A } from '@solidjs/router';
import { hexToNpub } from '../../lib/keys';
import {
addLinkPreviews,
isAppleMusic,
isHashtag,
isImage,
isInterpunction,
isLinebreak,
isMixCloud,
isMp4Video,
isNostrNests,
isNoteMention,
isOggVideo,
isSoundCloud,
isSpotify,
isTagMention,
isTwitch,
isUrl,
isUserMention,
isWavelake,
isWebmVideo,
isYouTube,
} from '../../lib/notes';
import { convertToUser, truncateNpub, userName } from '../../stores/profile';
import EmbeddedNote from '../EmbeddedNote/EmbeddedNote';
import {
Component, createEffect, createSignal, For, JSXElement, Match, onMount, Show, Switch,
} from 'solid-js';
import {
MediaSize,
NostrMentionContent,
NostrNoteContent,
NostrPostStats,
NostrStatsContent,
NostrUserContent,
NoteReference,
PrimalNote,
PrimalUser,
UserReference,
} from '../../types/primal';
import { nip19 } from 'nostr-tools';
import LinkPreview from '../LinkPreview/LinkPreview';
import MentionedUserLink from '../Note/MentionedUserLink/MentionedUserLink';
import { useMediaContext } from '../../contexts/MediaContext';
import { hookForDev } from '../../lib/devTools';
import { getMediaUrl, getMediaUrl as getMediaUrlDefault } from "../../lib/media";
import NoteImage from '../NoteImage/NoteImage';
import { createStore } from 'solid-js/store';
import { Kind, linebreakRegex } from '../../constants';
import { APP_ID } from '../../App';
import { getEvents } from '../../lib/feed';
import { getUserProfileInfo } from '../../lib/profile';
import { store, updateStore } from '../../services/StoreService';
import { subscribeTo } from '../../sockets';
import { convertToNotes } from '../../stores/note';
import { account } from '../../translations';
import { useAccountContext } from '../../contexts/AccountContext';
export type Token = {
type: string;
content: string | PrimalNote | PrimalUser,
options?: Object,
}
export type ParserContextStore = {
userRefs: UserReference,
noteRefs: NoteReference,
parsedToken: Token,
isDataFetched: boolean,
renderedUrl: JSXElement,
}
const ParsingToken: Component<{
token: string,
userRefs?: UserReference,
noteRefs?: NoteReference,
id?: string,
ignoreMedia?: boolean,
noLinks?: 'links' | 'text',
noPreviews?: boolean,
index?: number,
}> = (props) => {
const account = useAccountContext();
const [store, updateStore] = createStore<ParserContextStore>({
userRefs: {},
noteRefs: {},
parsedToken: { type: 'text', content: ''},
isDataFetched: false,
renderedUrl: <></>,
});
const getMentionedUser = (mention: string) => {
let [_, npub] = mention.trim().split(':');
const lastChar = npub[npub.length - 1];
if (isInterpunction(lastChar)) {
npub = npub.slice(0, -1);
}
const subId = `um_${APP_ID}`;
try {
const eventId = nip19.decode(npub).data as string | nip19.ProfilePointer;
const hex = typeof eventId === 'string' ? eventId : eventId.pubkey;
if (store.userRefs[hex]) {
return;
}
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
updateStore('isDataFetched', () => true)
unsub();
return;
}
if (type === 'EVENT') {
if (!content) return;
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
const u = convertToUser(user);
updateStore('userRefs', () => ({ [u.pubkey]: u }));
return;
}
}
});
getUserProfileInfo(hex, account?.publicKey, subId);
}
catch (e) {
console.log('Failed to fetch mentioned user info: ', e);
}
}
const getMentionedNote = (mention: string) => {
let [_, noteId] = mention.trim().split(':');
const lastChar = noteId[noteId.length - 1];
if (isInterpunction(lastChar)) {
noteId = noteId.slice(0, -1);
}
const subId = `nm_${noteId}_${APP_ID}`;
try{
const eventId = nip19.decode(noteId).data as string | nip19.EventPointer;
const hex = typeof eventId === 'string' ? eventId : eventId.id;
if (store.noteRefs[hex]) {
return;
}
let users: Record<string, NostrUserContent> = {};
let messages: NostrNoteContent[] = [];
let noteStats: NostrPostStats = {};
let noteMentions: Record<string, NostrNoteContent> = {};
const unsub = subscribeTo(subId, (type, subId, content) =>{
if (type === 'EOSE') {
const newNote = convertToNotes({
users,
messages,
postStats: noteStats,
mentions: noteMentions,
noteActions: {},
})[0];
updateStore('noteRefs', () => ({[newNote.post.id]: { ...newNote }}));
updateStore('isDataFetched', () => true)
unsub();
return;
}
if (type === 'EVENT') {
if (!content) {
return;
}
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
users[user.pubkey] = { ...user };
return;
}
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
messages.push(message);
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
noteStats[stat.event_id] = { ...stat };
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
noteMentions[mention.id] = { ...mention };
return;
}
}
});
getEvents(account?.publicKey, [hex], subId, true);
}
catch (e) {
console.log('Failed to fetch mentioned user info: ', e);
}
}
const prepareForParsing = async (token: string) => {
if (isUserMention(token)) {
getMentionedUser(token);
return;
}
if (isNoteMention(token)) {
getMentionedNote(token);
return;
}
}
createEffect(() => {
prepareForParsing(props.token);
});
createEffect(() => {
updateStore('userRefs', props.userRefs || {});
updateStore('noteRefs', props.noteRefs || {});
});
createEffect(() => {
if (!isUrl(props.token)) return;
if (props.noLinks === 'text') {
updateStore('renderedUrl', () => renderText(props.token));
return;
}
const url = props.token.trim();
updateStore('renderedUrl', () => <a link href={url} target="_blank" >{url}</a>);
addLinkPreviews(url).then(preview => {
const hasMinimalPreviewData = !props.noPreviews &&
preview &&
preview.url &&
((preview.description && preview.description.length > 0) ||
preview.image ||
preview.title
);
if (hasMinimalPreviewData) {
updateStore('renderedUrl', () => <div class="bordered"><LinkPreview preview={preview} /></div>);
}
});
});
const renderText = (token: string) => token;
const renderImage = (token: string) => {
const dev = localStorage.getItem('devMode') === 'true';
let imgUrl = getMediaUrl ? getMediaUrl(token) : token;
const url = imgUrl || getMediaUrlDefault(token)
return <NoteImage src={url} isDev={dev} />;
}
const renderVideo = (token: string, type: string) => {
return <video class="w-max" controls><source src={token} type={`video/${type}`} /></video>
}
const renderYouTube = (token: string) => {
const youtubeId = isYouTube(token) && RegExp.$1;
return (
<iframe
class="w-max"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
// @ts-ignore no property
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
);
}
const renderSpotify = (token: string) => {
const convertedUrl = token.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
return (
<iframe
style="borderRadius: 12"
src={convertedUrl}
width="100%"
height="352"
// @ts-ignore no property
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
></iframe>
);
};
const renderTwitch = (token: string) => {
const channel = token.split("/").slice(-1);
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
return (
<iframe
src={`https://player.twitch.tv/${args}`}
// @ts-ignore no property
className="w-max"
allowFullScreen
></iframe>
);
};
const renderMixCloud = (token: string) => {
const feedPath = (isMixCloud(token) && RegExp.$1) + "%2F" + (isMixCloud(token) && RegExp.$2);
return (
<iframe
title="SoundCloud player"
width="100%"
height="120"
// @ts-ignore no property
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&feed=%2F${feedPath}%2F`}
></iframe>
);
};
const renderSoundCloud = (token: string) => {
return (
<iframe
width="100%"
height="166"
// @ts-ignore no property
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${token}`}
></iframe>
);
};
const renderAppleMusic = (token: string) => {
const convertedUrl = token.replace("music.apple.com", "embed.music.apple.com");
const isSongLink = /\?i=\d+$/.test(convertedUrl);
return (
<iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
// @ts-ignore no property
frameBorder="0"
height={`${isSongLink ? 175 : 450}`}
style="width: 100%; maxWidth: 660; overflow: hidden; background: transparent;"
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}
></iframe>
);
};
const renderWavelake = (token: string) => {
const convertedUrl = token.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
return (
<iframe
style="borderRadius: 12"
src={convertedUrl}
width="100%"
height="380"
// @ts-ignore no property
frameBorder="0"
loading="lazy"
></iframe>
);
};
const renderNoteMention = (token: string) => {
let [nostr, noteId] = token.trim().split(':');
if (!noteId) {
return renderText(token);
}
let lastChar = noteId[noteId.length - 1];
if (isInterpunction(lastChar)) {
noteId = noteId.slice(0, -1);
} else {
lastChar = '';
}
try {
const eventId = nip19.decode(noteId).data as string | nip19.EventPointer;
const hex = typeof eventId === 'string' ? eventId : eventId.id;
const path = `/e/${noteId}`;
if (props.noLinks === 'links') {
return <><span class="linkish">{nostr}:{noteId}</span>{lastChar}</>;
}
if (!props.noLinks) {
const ment = store.noteRefs && store.noteRefs[hex];
return ment ?
<>
<EmbeddedNote
note={ment}
mentionedUsers={store.userRefs || {}}
includeEmbeds={true}
/>
{lastChar}
</> :
<><A href={path}>{nostr}:{noteId}</A>{lastChar}</>;
}
} catch (e) {
console.log('Failed to render note mention: ', e)
return <span class="error">{token}</span>;
}
}
const renderUserMention = (token: string) => {
let [_, npub] = token.trim().split(':');
if (!npub) {
return renderText(token);
}
let lastChar = npub[npub.length - 1];
if (isInterpunction(lastChar)) {
npub = npub.slice(0, -1);
} else {
lastChar = '';
}
try {
const profileId = nip19.decode(npub).data as string | nip19.ProfilePointer;
const hex = typeof profileId === 'string' ? profileId : profileId.pubkey;
const path = `/p/${npub}`;
let user = store.userRefs && store.userRefs[hex];
const label = user ? userName(user) : truncateNpub(npub);
if (props.noLinks === 'links') {
return <><span class="linkish">@{label}</span>{lastChar}</>;
}
if (!props.noLinks) {
return !user ? <><A href={path}>@{label}</A>{lastChar}</> : <>{MentionedUserLink({ user })}{lastChar}</>;
}
} catch (e) {
console.log('Failed to parse user mention: ', e)
return <span class="error">{token}</span>;
}
}
return (
<Switch fallback={renderText(props.token)}>
<Match when={props.token === '<_space_>'}>
<> </>
</Match>
<Match when={isUrl(props.token)}>
<Switch fallback={store.renderedUrl}>
<Match when={isImage(props.token) && !props.ignoreMedia}>
{renderImage(props.token)}
</Match>
<Match when={isMp4Video(props.token) && !props.ignoreMedia}>
{renderVideo(props.token, 'mp4')}
</Match>
<Match when={isOggVideo(props.token) && !props.ignoreMedia}>
{renderVideo(props.token, 'ogg')}
</Match>
<Match when={isWebmVideo(props.token) && !props.ignoreMedia}>
{renderVideo(props.token, 'webm')}
</Match>
<Match when={isYouTube(props.token) && !props.ignoreMedia}>
{renderYouTube(props.token)}
</Match>
<Match when={isSpotify(props.token) && !props.ignoreMedia}>
{renderSpotify(props.token)}
</Match>
<Match when={isTwitch(props.token) && !props.ignoreMedia}>
{renderTwitch(props.token)}
</Match>
<Match when={isMixCloud(props.token) && !props.ignoreMedia}>
{renderMixCloud(props.token)}
</Match>
<Match when={isSoundCloud(props.token) && !props.ignoreMedia}>
{renderSoundCloud(props.token)}
</Match>
<Match when={isAppleMusic(props.token) && !props.ignoreMedia}>
{renderAppleMusic(props.token)}
</Match>
<Match when={isWavelake(props.token) && !props.ignoreMedia}>
{renderWavelake(props.token)}
</Match>
</Switch>
</Match>
<Match when={isLinebreak(props.token)}>
<br/>
</Match>
<Match when={isInterpunction(props.token)}>
{renderText(props.token)}
</Match>
<Match when={isNoteMention(props.token)}>
<Show when={store.isDataFetched}>
{renderNoteMention(props.token)}
</Show>
</Match>
<Match when={isUserMention(props.token)}>
<Show when={store.isDataFetched}>
{renderUserMention(props.token)}
</Show>
</Match>
</Switch>
);
};
export default ParsingToken;

View File

@ -1,12 +1,32 @@
import { A } from '@solidjs/router';
import { hexToNpub } from '../../lib/keys';
import { linkPreviews, parseNote1 } from '../../lib/notes';
import {
addLinkPreviews,
isAppleMusic,
isHashtag,
isImage,
isInterpunction,
isMixCloud,
isMp4Video,
isNoteMention,
isOggVideo,
isSoundCloud,
isSpotify,
isTagMention,
isTwitch,
isUrl,
isUserMention,
isWavelake,
isWebmVideo,
isYouTube,
} from '../../lib/notes';
import { truncateNpub, userName } from '../../stores/profile';
import EmbeddedNote from '../EmbeddedNote/EmbeddedNote';
import {
Component, createEffect, createSignal,
Component, createEffect, For, JSXElement, onMount, Show,
} from 'solid-js';
import {
PrimalLinkPreview,
PrimalNote,
} from '../../types/primal';
@ -16,124 +36,378 @@ import LinkPreview from '../LinkPreview/LinkPreview';
import MentionedUserLink from '../Note/MentionedUserLink/MentionedUserLink';
import { useMediaContext } from '../../contexts/MediaContext';
import { hookForDev } from '../../lib/devTools';
import { getMediaUrl as getMediaUrlDefault } from "../../lib/media";
import NoteImage from '../NoteImage/NoteImage';
import { createStore } from 'solid-js/store';
import { linebreakRegex } from '../../constants';
export const parseNoteLinks = (text: string, note: PrimalNote, highlightOnly?: 'text' | 'links') => {
const regex = /\bnostr:((note|nevent)1\w+)\b|#\[(\d+)\]/g;
return text.replace(regex, (url) => {
const [_, id] = url.split(':');
if (!id) {
return url;
}
try {
const eventId = nip19.decode(id).data as string | nip19.EventPointer;
const hex = typeof eventId === 'string' ? eventId : eventId.id;
const noteId = nip19.noteEncode(hex);
const path = `/e/${noteId}`;
let link = <span>{url}</span>;
if (highlightOnly === 'links') {
link = <span class='linkish'>@{url}</span>;
}
if (!highlightOnly) {
const ment = note.mentionedNotes && note.mentionedNotes[hex];
link = ment ?
<div>
<EmbeddedNote
note={ment}
mentionedUsers={note.mentionedUsers || {}}
/>
</div> :
<A href={path}>{url}</A>;
}
// @ts-ignore
return link.outerHTML || url;
} catch (e) {
return `<span class="${styles.error}">${url}</span>`;
}
});
};
export const parseNpubLinks = (text: string, note: PrimalNote, highlightOnly?: 'links' | 'text') => {
const regex = /\bnostr:((npub|nprofile)1\w+)\b|#\[(\d+)\]/g;
return text.replace(regex, (url) => {
const [_, id] = url.split(':');
if (!id) {
return url;
}
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}`;
const user = note.mentionedUsers && note.mentionedUsers[hex];
const label = user ? userName(user) : truncateNpub(npub);
let link = <span>@{label}</span>;
if (highlightOnly === 'links') {
link = <span class='linkish'>@{label}</span>;
}
if (!highlightOnly) {
link = user ? <A href={path}>@{label}</A> : MentionedUserLink({ user });
}
// @ts-ignore
return link.outerHTML || url;
} catch (e) {
return `<span class="${styles.error}">${url}</span>`;
}
});
};
const ParsedNote: Component<{
note: PrimalNote,
ignoreMentionedNotes?: boolean,
id?: string,
ignoreMedia?: boolean,
noLinks?: 'links' | 'text',
noPreviews?: boolean,
}> = (props) => {
const media = useMediaContext();
const parsedContent = (text: string, highlightOnly?: 'text' | 'links') => {
const regex = /\#\[([0-9]*)\]/g;
let parsed = text;
const [tokens, setTokens] = createStore<string[]>([])
const [renderedUrl, setRenderedUrl] = createStore<Record<string, any>>({});
let refs = [];
let match;
const parseContent = () => {
const content = props.note.post.content.replace(linebreakRegex, ' __LB__ ').replace(/\s+/g, ' __SP__ ');
const tokens = content.split(/[\s]+/);
while((match = regex.exec(text)) !== null) {
refs.push(match[1]);
}
setTokens(() => [...tokens]);
}
if (refs.length > 0) {
for(let i =0; i < refs.length; i++) {
let r = parseInt(refs[i]);
const parseToken: (token: string) => JSXElement = (token: string) => {
if (token === '__LB__') {
// setElements(elements.length, <br />);
return <br />;
}
if (token === '__SP__') {
// setElements(elements.length, <br />);
return <> </>;
}
if (isInterpunction(token)) {
// setElements(elements.length, <span>{token}</span>)
return <span>{token}</span>;
}
if (isUrl(token)) {
const index = token.indexOf('http');
if (index > 0) {
const prefix = token.slice(0, index);
const url = token.slice(index);
// tokens.splice(i+1, 0, prefix);
// tokens.splice(i+2, 0, url);
return <>{parseToken(prefix)} {parseToken(url)}</>;
}
if (!props.ignoreMedia) {
if (isImage(token)) {
const dev = localStorage.getItem('devMode') === 'true';
let imgUrl = media?.actions.getMediaUrl(token);
const url = imgUrl || getMediaUrlDefault(token)
// setElements(elements.length, <NoteImage src={url} isDev={dev} />);
return <NoteImage src={url} isDev={dev} />;
}
if (isMp4Video(token)) {
// setElements(elements.length, <video class="w-max" controls><source src={token} type="video/mp4" /></video>);
return <video class="w-max" controls><source src={token} type="video/mp4" /></video>;
}
if (isOggVideo(token)) {
// setElements(elements.length, <video class="w-max" controls><source src={token} type="video/ogg" /></video>);
return <video class="w-max" controls><source src={token} type="video/ogg" /></video>;
}
if (isWebmVideo(token)) {
// setElements(elements.length, <video class="w-max" controls><source src={token} type="video/webm" /></video>);
return <video class="w-max" controls><source src={token} type="video/webm" /></video>;
}
if (isYouTube(token)) {
const youtubeId = isYouTube(token) && RegExp.$1;
// setElements(elements.length,
// <iframe
// class="w-max"
// src={`https://www.youtube.com/embed/${youtubeId}`}
// title="YouTube video player"
// // @ts-ignore no property
// key={youtubeId}
// frameBorder="0"
// allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
// allowFullScreen
// ></iframe>
// );
return <iframe
class="w-max"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
// @ts-ignore no property
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>;
}
if (isSpotify(token)) {
const convertedUrl = token.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
// setElements(elements.length,
// <iframe
// style="borderRadius: 12"
// src={convertedUrl}
// width="100%"
// height="352"
// // @ts-ignore no property
// frameBorder="0"
// allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
// loading="lazy"
// ></iframe>
// );
return <iframe
style="borderRadius: 12"
src={convertedUrl}
width="100%"
height="352"
// @ts-ignore no property
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
></iframe>;
}
if (isTwitch(token)) {
const channel = token.split("/").slice(-1);
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
// setElements(elements.length,
// <iframe
// src={`https://player.twitch.tv/${args}`}
// // @ts-ignore no property
// className="w-max"
// allowFullScreen
// ></iframe>
// );
return <iframe
src={`https://player.twitch.tv/${args}`}
// @ts-ignore no property
className="w-max"
allowFullScreen
></iframe>;
}
if (isMixCloud(token)) {
const feedPath = (isMixCloud(token) && RegExp.$1) + "%2F" + (isMixCloud(token) && RegExp.$2);
// setElements(elements.length,
// <div>
// <iframe
// title="SoundCloud player"
// width="100%"
// height="120"
// // @ts-ignore no property
// frameBorder="0"
// src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&feed=%2F${feedPath}%2F`}
// ></iframe>
// </div>
// );
return <div>
<iframe
title="SoundCloud player"
width="100%"
height="120"
// @ts-ignore no property
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&feed=%2F${feedPath}%2F`}
></iframe>
</div>;
}
if (isSoundCloud(token)) {
// setElements(elements.length,
// <iframe
// width="100%"
// height="166"
// // @ts-ignore no property
// scrolling="no"
// allow="autoplay"
// src={`https://w.soundcloud.com/player/?url=${token}`}
// ></iframe>
// );
return <iframe
width="100%"
height="166"
// @ts-ignore no property
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${token}`}
></iframe>;
}
if (isAppleMusic(token)) {
const convertedUrl = token.replace("music.apple.com", "embed.music.apple.com");
const isSongLink = /\?i=\d+$/.test(convertedUrl);
// setElements(elements.length,
// <iframe
// allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
// // @ts-ignore no property
// frameBorder="0"
// height={`${isSongLink ? 175 : 450}`}
// style="width: 100%; maxWidth: 660; overflow: hidden; background: transparent;"
// sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
// src={convertedUrl}
// ></iframe>
// );
return <iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
// @ts-ignore no property
frameBorder="0"
height={`${isSongLink ? 175 : 450}`}
style="width: 100%; maxWidth: 660; overflow: hidden; background: transparent;"
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}
></iframe>;
}
if (isWavelake(token)) {
const convertedUrl = token.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
// setElements(elements.length,
// <iframe
// style="borderRadius: 12"
// src={convertedUrl}
// width="100%"
// height="380"
// // @ts-ignore no property
// frameBorder="0"
// loading="lazy"
// ></iframe>
// );
return <iframe
style="borderRadius: 12"
src={convertedUrl}
width="100%"
height="380"
// @ts-ignore no property
frameBorder="0"
loading="lazy"
></iframe>;
}
}
if (props.noLinks === 'text') {
// setElements(elements.length, <span class="whole">{token}</span>);
return <span class="whole">{token}</span>;
}
addLinkPreviews(token).then(preview => {
replaceLink(token, preview);
});
// setElements(elements.length, c);
return <span data-url={token}><a link href={token} target="_blank" >{token}</a></span>;
}
if (isNoteMention(token)) {
const [_, id] = token.split(':');
if (!id) {
return token;
}
let link = <span>{token}</span>;
try {
const eventId = nip19.decode(id).data as string | nip19.EventPointer;
const hex = typeof eventId === 'string' ? eventId : eventId.id;
const noteId = nip19.noteEncode(hex);
const path = `/e/${noteId}`;
if (props.noLinks === 'links') {
link = <span class='linkish'>@{token}</span>;
}
if (!props.noLinks) {
const ment = props.note.mentionedNotes && props.note.mentionedNotes[hex];
link = ment ?
<div>
<EmbeddedNote
note={ment}
mentionedUsers={props.note.mentionedUsers || {}}
/>
</div> :
<A href={path}>{token}</A>;
}
} catch (e) {
link = <span class={styles.error}>{token}</span>;
}
// setElements(elements.length, <span class="whole"> {link}</span>);
return <span class="whole"> {link}</span>;
}
if (isUserMention(token)) {
let [_, id] = token.split(':');
if (!id) {
return token;
}
let end = id[id.length - 1];
if ([',', '?', ';', '!'].some(x => end === x)) {
id = id.slice(0, -1);
} else {
end = '';
}
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 = props.note.mentionedUsers && props.note.mentionedUsers[hex];
const label = user ? userName(user) : truncateNpub(npub);
let link = <span>@{label}{end}</span>;
if (props.noLinks === 'links') {
link = <><span class='linkish'>@{label}</span>{end}</>;
}
if (!props.noLinks) {
link = !user ?
<><A href={path}>@{label}</A>{end}</> :
<>{MentionedUserLink({ user })}{end}</>;
}
// setElements(elements.length, <span class="whole"> {link}</span>);
return <span class="whole"> {link}</span>;
} catch (e) {
// setElements(elements.length, <span class={styles.error}> {token}</span>);
return <span class={styles.error}> {token}</span>;
}
}
if (isTagMention(token)) {
let t = `${token}`;
let end = t[t.length - 1];
if ([',', '?', ';', '!'].some(x => end === x)) {
t = t.slice(0, -1);
} else {
end = '';
}
let r = parseInt(t.slice(2, t.length - 1));
const tag = props.note.post.tags[r];
if (tag === undefined || tag.length === 0) continue;
if (tag === undefined || tag.length === 0) return;
if (
tag[0] === 'e' &&
@ -144,13 +418,13 @@ const ParsedNote: Component<{
const noteId = `nostr:${nip19.noteEncode(hex)}`;
const path = `/e/${nip19.noteEncode(hex)}`;
let embeded = <span>{noteId}</span>;
let embeded = <span>{noteId}{end}</span>;
if (highlightOnly === 'links') {
embeded = <span class='linkish'>@{noteId}</span>;
if (props.noLinks === 'links') {
embeded = <><span class='linkish'>@{noteId}</span>{end}</>;
}
if (!highlightOnly) {
if (!props.noLinks) {
const ment = props.note.mentionedNotes[hex];
embeded = ment ?
@ -159,12 +433,13 @@ const ParsedNote: Component<{
note={ment}
mentionedUsers={props.note.mentionedUsers}
/>
{end}
</div> :
<A href={path}>{noteId}</A>;
<><A href={path}>{noteId}</A>{end}</>;
}
// @ts-ignore
parsed = parsed.replace(`#[${r}]`, embeded.outerHTML);
// setElements(elements.length, <span class="whole"> embeded</span>);
return <span class="whole"> embeded</span>;
}
if (tag[0] === 'p' && props.note.mentionedUsers && props.note.mentionedUsers[tag[1]]) {
@ -174,121 +449,70 @@ const ParsedNote: Component<{
const label = userName(user);
let link = <span>@{label}</span>;
let link = <span>@{label}{end}</span>;
if (highlightOnly === 'links') {
link = <span class='linkish'>@{label}</span>;
if (props.noLinks === 'links') {
link = <><span class='linkish'>@{label}</span>{end}</>;
}
if (!highlightOnly) {
link = user ? <A href={path}>@{label}</A> : MentionedUserLink({ user });
if (!props.noLinks) {
link = user ?
<><A href={path}>@{label}</A>{end}</> :
<>{MentionedUserLink({ user })}{end}</>;
}
// @ts-ignore
parsed = parsed.replace(`#[${r}]`, link.outerHTML);
// setElements(elements.length, <span> {link}</span>);
return <span> {link}</span>;
}
}
}
return parsed;
if (isHashtag(token)) {
const [_, term] = token.split('#');
const embeded = props.noLinks === 'text' ?
<span>#{term}</span> :
<A href={`/search/%23${term}`}>#{term}</A>;
};
const highlightHashtags = (text: string, noLinks?: 'links' | 'text') => {
const regex = /(?:\s|^)#[^\s!@#$%^&*(),.?":{}|<>]+/ig;
return text.replace(regex, (token) => {
const [space, term] = token.split('#');
const embeded = noLinks === 'text' ? (
<span>
{space}
<span>#{term}</span>
</span>
) : (
<span>
{space}
<A
href={`/search/%23${term}`}
>#{term}</A>
</span>
);
// @ts-ignore
return embeded.outerHTML;
});
}
const replaceLinkPreviews = (text: string, previews: Record<string, any>) => {
let parsed = text;
const regex = /__LINK__.*?__LINK__/ig;
parsed = parsed.replace(regex, (link) => {
const url = link.split('__LINK__')[1];
const preview = previews[url];
const hasMinimalPreviewData = preview && preview.url &&
((preview.description && preview.description.length > 0) || preview.image || preview.title);
if (!hasMinimalPreviewData) {
return `<a link href="${url}" target="_blank" >${url}</a>`;
// setElements(elements.length, <span class="whole"> {embeded}</span>);
return <span class="whole"> {embeded}</span>;
}
const linkElement = (<div class={styles.bordered}><LinkPreview preview={preview} /></div>);
// const c = <span class="whole">
// <Show when={i > 0}> </Show>
// {token}
// </span>;
// @ts-ignore
return linkElement.outerHTML;
});
return parsed;
}
const content = () => {
return parseNoteLinks(
parseNpubLinks(
parsedContent(
highlightHashtags(
parseNote1(props.note.post.content, media?.actions.getMediaUrl)
),
props.noLinks,
),
props.note,
props.noLinks,
),
props.note,
props.noLinks,
);
// setElements(elements.length, c);
return <span class="whole">{token}</span>;
};
const smallContent = () => {
return parseNoteLinks(
parseNpubLinks(
parsedContent(
highlightHashtags(
props.note.post.content,
props.noLinks,
),
props.noLinks,
),
props.note,
props.noLinks,
),
props.note,
props.noLinks,
);
};
const [displayedContent, setDisplayedContent] = createSignal<string>(props.ignoreMedia ? smallContent() : content());
createEffect(() => {
const newContent = replaceLinkPreviews(displayedContent(), { ...linkPreviews });
setDisplayedContent(() => newContent);
onMount(() => {
parseContent();
});
let noteHolder: HTMLDivElement | undefined;
const replaceLink = (url: string, preview: PrimalLinkPreview) => {
if (!noteHolder) return;
const hasMinimalPreviewData = !props.noPreviews &&
preview &&
preview.url &&
((preview.description && preview.description.length > 0) ||
preview.images ||
preview.title
);
if (hasMinimalPreviewData) {
// @ts-ignore
noteHolder.querySelector(`[data-url="${url}"]`).innerHTML = (<div class="bordered"><LinkPreview preview={preview} /></div>).outerHTML;
}
};
return (
<div id={props.id} innerHTML={displayedContent()}>
<div id={props.id} ref={noteHolder}>
<For each={tokens}>
{(token) => <>{parseToken(token)}</>}
</For>
</div>
);
};

View File

@ -230,9 +230,27 @@ export const notificationTypeNoteProps: Record<string, string> = {
}
export const noteRegex = /nostr:((note|nevent)1\w+)\b|#\[(\d+)\]/g;
export const profileRegex = /nostr:((npub|nprofile)1\w+)\b|#\[(\d+)\]/g;
export const editMentionRegex = /(?:\s|^)@\`(.*?)\`/ig;
// export const odyseeRegex = /odysee\.com\/([a-zA-Z0-9]+)/;
// export const magnetRegex = /(magnet:[\S]+)/i;
// export const tweetUrlRegex = /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
// export const tidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
export const spotifyRegex = /open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/;
export const twitchRegex = /twitch.tv\/([a-z0-9_]+$)/i;
export const mixCloudRegex = /mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
export const soundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
export const appleMusicRegex = /music\.apple\.com\/([a-z]{2}\/)?(?:album|playlist)\/[\w\d-]+\/([.a-zA-Z0-9-]+)(?:\?i=\d+)?/i;
export const nostrNestsRegex = /nostrnests\.com\/[a-zA-Z0-9]+/i;
export const wavlakeRegex = /(?:player\.)?wavlake\.com\/(track\/[.a-zA-Z0-9-]+|album\/[.a-zA-Z0-9-]+|[.a-zA-Z0-9-]+)/i;
export const youtubeRegex = /(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:live\/|shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
export const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9\u00F0-\u02AF@:%._\+~#=]{1,256}\.[a-zA-Z0-9\u00F0-\u02AF()]{1,8}\b([-a-zA-Z0-9\u00F0-\u02AF()@:%_\+.~#?&//=]*)/;
export const interpunctionRegex = /^(\.|,|;|\?|\!)$/;
export const hashtagRegex = /(?:\s|^)#[^\s!@#$%^&*(),.?":{}|<>]+/i;
export const linebreakRegex = /(\r\n|\r|\n)/ig;
export const tagMentionRegex = /\#\[([0-9]*)\]/;
export const noteRegex = /nostr:((note|nevent)1\w+)\b/;
export const profileRegex = /nostr:((npub|nprofile)1\w+)\b/;
export const editMentionRegex = /(?:\s|^)@\`(.*?)\`/i;
export const medZapLimit = 1000;

View File

@ -398,10 +398,10 @@ body {
}
.medium-zoom-image {
cursor: pointer !important;
// cursor: pointer !important;
}
.medium-zoom-overlay {
cursor: pointer !important;
// cursor: pointer !important;
z-index: var(--z-index-header);
}
.medium-zoom-image-opened {

View File

@ -3,7 +3,7 @@ import { Relay } from "nostr-tools";
import { createStore } from "solid-js/store";
import LinkPreview from "../components/LinkPreview/LinkPreview";
import NoteImage from "../components/NoteImage/NoteImage";
import { Kind } from "../constants";
import { appleMusicRegex, hashtagRegex, interpunctionRegex, Kind, linebreakRegex, mixCloudRegex, nostrNestsRegex, noteRegex, profileRegex, soundCloudRegex, spotifyRegex, tagMentionRegex, twitchRegex, urlRegex, wavlakeRegex, youtubeRegex } from "../constants";
import { sendMessage, subscribeTo } from "../sockets";
import { MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalNote, SendNoteResult } from "../types/primal";
import { getMediaUrl as getMediaUrlDefault } from "./media";
@ -47,20 +47,13 @@ export const addLinkPreviews = async (url: string) => {
}
};
export const spotifyRegex = /open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/;
export const twitchRegex = /twitch.tv\/([a-z0-9_]+$)/i;
export const mixCloudRegex = /mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
// export const tidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
export const soundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
// export const tweetUrlRegex = /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
export const appleMusicRegex = /music\.apple\.com\/([a-z]{2}\/)?(?:album|playlist)\/[\w\d-]+\/([.a-zA-Z0-9-]+)(?:\?i=\d+)?/i;
export const nostrNestsRegex = /nostrnests\.com\/[a-zA-Z0-9]+/i;
// export const magnetRegex = /(magnet:[\S]+)/i;
export const wavlakeRegex = /(?:player\.)?wavlake\.com\/(track\/[.a-zA-Z0-9-]+|album\/[.a-zA-Z0-9-]+|[.a-zA-Z0-9-]+)/i;
// export const odyseeRegex = /odysee\.com\/([a-zA-Z0-9]+)/;
export const youtubeRegex = /(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:live\/|shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
export const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9\u00F0-\u02AF@:%._\+~#=]{1,256}\.[a-zA-Z0-9\u00F0-\u02AF()]{1,8}\b([-a-zA-Z0-9\u00F0-\u02AF()@:%_\+.~#?&//=]*)/g;
export const isUrl = (url: string) => urlRegex.test(url);
export const isHashtag = (url: string) => hashtagRegex.test(url);
export const isLinebreak = (url: string) => linebreakRegex.test(url);
export const isTagMention = (url: string) => tagMentionRegex.test(url);
export const isNoteMention = (url: string) => noteRegex.test(url);
export const isUserMention = (url: string) => profileRegex.test(url);
export const isInterpunction = (url: string) => interpunctionRegex.test(url);
export const isImage = (url: string) => ['.jpg', '.jpeg', '.webp', '.png', '.gif', '.format=png'].some(x => url.includes(x));
export const isMp4Video = (url: string) => ['.mp4', '.mov'].some(x => url.includes(x));

View File

@ -193,7 +193,6 @@ const Thread: Component = () => {
</For>
</div>
</Show>
</div>
)
}

30
src/types/primal.d.ts vendored
View File

@ -491,27 +491,8 @@ export type ExploreFeedPayload = {
created_after?: number,
}
export type UserReference = {
id: string,
pubkey: string,
kind: number,
tags: string[][],
npub?: string,
name?: string,
about?: string,
picture?: string,
nip05?: string,
banner?: string,
display_name?: string,
displayName?: string,
location?: string,
lud06?: string,
lud16?: string,
website?: string,
content?: string,
created_at?: number,
sig?: string,
};
export type UserReference = Record<string, PrimalUser>;
export type NoteReference = Record<string, PrimalNote>;
export type ContextChildren =
number |
@ -520,6 +501,13 @@ export type ContextChildren =
JSX.ArrayElement |
(string & {}) | null | undefined;
export type PrimalLinkPreview = {
url: string,
description?: string,
title?: string,
images?: string[],
favicons?: string[],
};
export type PrimalTheme = { name: string, label: string, logo: string, dark?: boolean};