From b36e3b9be98906d354ebc4c3d5bd896b2abefe43 Mon Sep 17 00:00:00 2001 From: Bojan Mojsilovic Date: Wed, 4 Oct 2023 21:29:07 +0200 Subject: [PATCH] New tokenized note rendering --- src/components/EmbeddedNote/EmbeddedNote.tsx | 92 +-- src/components/NoteImage/NoteImage.tsx | 48 +- src/components/PargingToken/ParsingToken.tsx | 569 +++++++++++++++++ src/components/ParsedNote/ParsedNote.tsx | 640 +++++++++++++------ src/constants.ts | 24 +- src/index.scss | 4 +- src/lib/notes.tsx | 23 +- src/pages/Thread.tsx | 1 - src/types/primal.d.ts | 30 +- 9 files changed, 1090 insertions(+), 341 deletions(-) create mode 100644 src/components/PargingToken/ParsingToken.tsx diff --git a/src/components/EmbeddedNote/EmbeddedNote.tsx b/src/components/EmbeddedNote/EmbeddedNote.tsx index 3e35490..59b1389 100644 --- a/src/components/EmbeddedNote/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote/EmbeddedNote.tsx @@ -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 nip19.noteEncode(props.note.post.id); @@ -32,77 +30,6 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record { - 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 = ( - - {intl.formatMessage( - t.mentionIndication, - { name: userName(props.note.user) }, - )} - - ); - - // @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 = ( - - @{userName(user)} - - ); - - - // @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 = ( - - {space} - #{term} - - ); - - // @ts-ignore - return embeded.outerHTML; - }); - } - const wrapper = (children: JSXElement) => { if (props.includeEmbeds) { return ( @@ -167,21 +94,8 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record -
+
+
); diff --git a/src/components/NoteImage/NoteImage.tsx b/src/components/NoteImage/NoteImage.tsx index 043b180..d6d011e 100644 --- a/src/components/NoteImage/NoteImage.tsx +++ b/src/components/NoteImage/NoteImage.tsx @@ -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 ; + 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 ( +
+ +
+ ); } export default NoteImage; diff --git a/src/components/PargingToken/ParsingToken.tsx b/src/components/PargingToken/ParsingToken.tsx new file mode 100644 index 0000000..d1a221b --- /dev/null +++ b/src/components/PargingToken/ParsingToken.tsx @@ -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({ + 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 = {}; + let messages: NostrNoteContent[] = []; + let noteStats: NostrPostStats = {}; + let noteMentions: Record = {}; + + 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', () => {url}); + + 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', () =>
); + } + }); + }); + + 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 ; + } + + const renderVideo = (token: string, type: string) => { + return + } + + const renderYouTube = (token: string) => { + const youtubeId = isYouTube(token) && RegExp.$1; + + return ( + + ); + } + + const renderSpotify = (token: string) => { + const convertedUrl = token.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2"); + + return ( + + ); + }; + + const renderTwitch = (token: string) => { + const channel = token.split("/").slice(-1); + const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`; + + return ( + + ); + }; + + const renderMixCloud = (token: string) => { + const feedPath = (isMixCloud(token) && RegExp.$1) + "%2F" + (isMixCloud(token) && RegExp.$2); + + return ( + + ); + }; + + const renderSoundCloud = (token: string) => { + return ( + + ); + }; + + const renderAppleMusic = (token: string) => { + const convertedUrl = token.replace("music.apple.com", "embed.music.apple.com"); + const isSongLink = /\?i=\d+$/.test(convertedUrl); + + return ( + + ); + }; + + const renderWavelake = (token: string) => { + const convertedUrl = token.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com"); + + return ( + + ); + }; + + 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 <>{nostr}:{noteId}{lastChar}; + } + + if (!props.noLinks) { + const ment = store.noteRefs && store.noteRefs[hex]; + + return ment ? + <> + + {lastChar} + : + <>{nostr}:{noteId}{lastChar}; + } + + } catch (e) { + console.log('Failed to render note mention: ', e) + return {token}; + } + } + + 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 <>@{label}{lastChar}; + } + + if (!props.noLinks) { + return !user ? <>@{label}{lastChar} : <>{MentionedUserLink({ user })}{lastChar}; + } + + } catch (e) { + console.log('Failed to parse user mention: ', e) + return {token}; + } + } + + return ( + + '}> + <> + + + + + + + {renderImage(props.token)} + + + + {renderVideo(props.token, 'mp4')} + + + + {renderVideo(props.token, 'ogg')} + + + + {renderVideo(props.token, 'webm')} + + + + {renderYouTube(props.token)} + + + + {renderSpotify(props.token)} + + + + {renderTwitch(props.token)} + + + + {renderMixCloud(props.token)} + + + + {renderSoundCloud(props.token)} + + + + {renderAppleMusic(props.token)} + + + + {renderWavelake(props.token)} + + + + + +
+
+ + + {renderText(props.token)} + + + + + {renderNoteMention(props.token)} + + + + + + {renderUserMention(props.token)} + + + +
+ ); +}; + +export default ParsingToken; diff --git a/src/components/ParsedNote/ParsedNote.tsx b/src/components/ParsedNote/ParsedNote.tsx index de8e519..02ae553 100644 --- a/src/components/ParsedNote/ParsedNote.tsx +++ b/src/components/ParsedNote/ParsedNote.tsx @@ -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 = {url}; - - if (highlightOnly === 'links') { - link = @{url}; - } - - if (!highlightOnly) { - const ment = note.mentionedNotes && note.mentionedNotes[hex]; - - link = ment ? -
- -
: - {url}; - } - - // @ts-ignore - return link.outerHTML || url; - } catch (e) { - return `${url}`; - } - - }); - -}; - -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 = @{label}; - - if (highlightOnly === 'links') { - link = @{label}; - } - - if (!highlightOnly) { - link = user ? @{label} : MentionedUserLink({ user }); - } - - // @ts-ignore - return link.outerHTML || url; - } catch (e) { - return `${url}`; - } - }); - -}; - 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([]) + const [renderedUrl, setRenderedUrl] = createStore>({}); - 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,
); + return
; + } + if (token === '__SP__') { + // setElements(elements.length,
); + return <> ; + } + + if (isInterpunction(token)) { + // setElements(elements.length, {token}) + return {token}; + } + + 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, ); + return ; + } + + if (isMp4Video(token)) { + // setElements(elements.length, ); + return ; + } + + if (isOggVideo(token)) { + // setElements(elements.length, ); + return ; + } + + if (isWebmVideo(token)) { + // setElements(elements.length, ); + return ; + } + + if (isYouTube(token)) { + const youtubeId = isYouTube(token) && RegExp.$1; + + // setElements(elements.length, + // + // ); + return ; + } + + if (isSpotify(token)) { + const convertedUrl = token.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2"); + + // setElements(elements.length, + // + // ); + return ; + } + + if (isTwitch(token)) { + const channel = token.split("/").slice(-1); + + const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`; + + // setElements(elements.length, + // + // ); + return ; + } + + if (isMixCloud(token)) { + const feedPath = (isMixCloud(token) && RegExp.$1) + "%2F" + (isMixCloud(token) && RegExp.$2); + + // setElements(elements.length, + //
+ // + //
+ // ); + + return
+ +
; + } + + if (isSoundCloud(token)) { + // setElements(elements.length, + // + // ); + return ; + } + + if (isAppleMusic(token)) { + const convertedUrl = token.replace("music.apple.com", "embed.music.apple.com"); + const isSongLink = /\?i=\d+$/.test(convertedUrl); + + // setElements(elements.length, + // + // ); + return ; + } + + if (isWavelake(token)) { + const convertedUrl = token.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com"); + + // setElements(elements.length, + // + // ); + return ; + } + } + + if (props.noLinks === 'text') { + // setElements(elements.length, {token}); + return {token}; + } + + addLinkPreviews(token).then(preview => { + replaceLink(token, preview); + }); + + // setElements(elements.length, c); + return {token}; + } + + if (isNoteMention(token)) { + const [_, id] = token.split(':'); + + if (!id) { + return token; + } + + let link = {token}; + + 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 = @{token}; + } + + if (!props.noLinks) { + const ment = props.note.mentionedNotes && props.note.mentionedNotes[hex]; + + link = ment ? +
+ +
: + {token}; + } + + } catch (e) { + link = {token}; + } + + // setElements(elements.length, {link}); + return {link}; + } + + 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 = @{label}{end}; + + if (props.noLinks === 'links') { + link = <>@{label}{end}; + } + + if (!props.noLinks) { + link = !user ? + <>@{label}{end} : + <>{MentionedUserLink({ user })}{end}; + } + + // setElements(elements.length, {link}); + return {link}; + } catch (e) { + // setElements(elements.length, {token}); + return {token}; + } + } + + 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 = {noteId}; + let embeded = {noteId}{end}; - if (highlightOnly === 'links') { - embeded = @{noteId}; + if (props.noLinks === 'links') { + embeded = <>@{noteId}{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}
: - {noteId}; + <>{noteId}{end}; } - // @ts-ignore - parsed = parsed.replace(`#[${r}]`, embeded.outerHTML); + // setElements(elements.length, embeded); + return embeded; } 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 = @{label}; + let link = @{label}{end}; - if (highlightOnly === 'links') { - link = @{label}; + if (props.noLinks === 'links') { + link = <>@{label}{end}; } - if (!highlightOnly) { - link = user ? @{label} : MentionedUserLink({ user }); + if (!props.noLinks) { + link = user ? + <>@{label}{end} : + <>{MentionedUserLink({ user })}{end}; } - // @ts-ignore - parsed = parsed.replace(`#[${r}]`, link.outerHTML); + // setElements(elements.length, {link}); + return {link}; } } - } - return parsed; + if (isHashtag(token)) { + const [_, term] = token.split('#'); + const embeded = props.noLinks === 'text' ? + #{term} : + #{term}; - }; - - 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' ? ( - - {space} - #{term} - - ) : ( - - {space} - #{term} - - ); - - // @ts-ignore - return embeded.outerHTML; - }); - } - - const replaceLinkPreviews = (text: string, previews: Record) => { - 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 `${url}`; + // setElements(elements.length, {embeded}); + return {embeded}; } - const linkElement = (
); + // const c = + // 0}> + // {token} + // ; - // @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 {token}; }; - 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(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 = (
).outerHTML; + } + }; + return ( -
+
+ + {(token) => <>{parseToken(token)}} +
); }; diff --git a/src/constants.ts b/src/constants.ts index 39fcd08..a1a55f7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -230,9 +230,27 @@ export const notificationTypeNoteProps: Record = { } -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; diff --git a/src/index.scss b/src/index.scss index ef2330e..a0c7682 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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 { diff --git a/src/lib/notes.tsx b/src/lib/notes.tsx index 1f7192c..1dcf21c 100644 --- a/src/lib/notes.tsx +++ b/src/lib/notes.tsx @@ -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)); diff --git a/src/pages/Thread.tsx b/src/pages/Thread.tsx index 4d21420..99c9bd7 100644 --- a/src/pages/Thread.tsx +++ b/src/pages/Thread.tsx @@ -193,7 +193,6 @@ const Thread: Component = () => {
- ) } diff --git a/src/types/primal.d.ts b/src/types/primal.d.ts index b36957a..7c5c3bd 100644 --- a/src/types/primal.d.ts +++ b/src/types/primal.d.ts @@ -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; +export type NoteReference = Record; 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};