diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 0135b123..e222ba59 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -7,6 +7,7 @@ import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { cn } from '@/lib/utils' import { getHttpUrlFromITags } from '@/lib/event' +import { httpUrlSkipsBottomWebPreview } from '@/lib/nostr-from-http-url' import { cleanUrl, isImage, isMedia, isAudio, isVideo, isPseudoNostrHttpsUrl } from '@/lib/url' import { TImetaInfo } from '@/types' import { Event } from 'nostr-tools' @@ -17,7 +18,8 @@ import { EmbeddedMention, EmbeddedNormalUrl, EmbeddedNote, - EmbeddedWebsocketUrl + EmbeddedWebsocketUrl, + HttpNostrAwareUrl } from '../Embedded' import PaytoLink from '../PaytoLink' import Emoji from '../Emoji' @@ -109,7 +111,8 @@ export default function Content({ if (!nodes) return [] const links: string[] = [] const seenUrls = new Set() - + const appOrigin = typeof window !== 'undefined' ? window.location.origin : null + nodes.forEach((node) => { if (node.type === 'url') { const url = node.data @@ -121,14 +124,19 @@ export default function Content({ !isYouTubeUrl(url) ) { const cleaned = cleanUrl(url) - if (cleaned && !seenUrls.has(cleaned) && !(iArticleCleaned && cleaned === iArticleCleaned)) { + if ( + cleaned && + !seenUrls.has(cleaned) && + !(iArticleCleaned && cleaned === iArticleCleaned) && + !httpUrlSkipsBottomWebPreview(url, appOrigin) + ) { links.push(cleaned) seenUrls.add(cleaned) } } } }) - + return links }, [nodes, iArticleCleaned]) @@ -461,7 +469,14 @@ export default function Content({ if (iArticleCleaned && cleanedUrl === iArticleCleaned) { return null } - return + return ( + + ) } if (node.type === 'invoice') { return diff --git a/src/components/ContentPreview/NormalContentPreview.tsx b/src/components/ContentPreview/NormalContentPreview.tsx index 4abd3156..cee54429 100644 --- a/src/components/ContentPreview/NormalContentPreview.tsx +++ b/src/components/ContentPreview/NormalContentPreview.tsx @@ -1,4 +1,6 @@ +import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { Event } from 'nostr-tools' +import { useMemo } from 'react' import Content from './Content' export default function NormalContentPreview({ @@ -8,5 +10,6 @@ export default function NormalContentPreview({ event: Event className?: string }) { - return + const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event.tags]) + return } diff --git a/src/components/Embedded/HttpNostrAwareUrl.tsx b/src/components/Embedded/HttpNostrAwareUrl.tsx new file mode 100644 index 00000000..25eb0381 --- /dev/null +++ b/src/components/Embedded/HttpNostrAwareUrl.tsx @@ -0,0 +1,134 @@ +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { cleanUrl } from '@/lib/url' +import { + extractExternalUrlNostrForExpandable, + getBrowserAppOrigin, + parseSameOriginAppNostrUrl +} from '@/lib/nostr-from-http-url' +import { ChevronDown } from 'lucide-react' +import type { Event } from 'nostr-tools' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { EmbeddedMention } from './EmbeddedMention' +import { EmbeddedNormalUrl } from './EmbeddedNormalUrl' +import { EmbeddedNote } from './EmbeddedNote' +import WebPreview from '@/components/WebPreview' + +type RenderMode = 'note-content' | 'article' + +export function HttpNostrAwareUrl({ + url, + renderMode, + containingEvent, + className +}: { + url: string + renderMode: RenderMode + containingEvent?: Event + className?: string +}) { + const { t } = useTranslation() + const appOrigin = useMemo(() => getBrowserAppOrigin(), []) + const sameOriginTarget = useMemo( + () => parseSameOriginAppNostrUrl(url, appOrigin), + [url, appOrigin] + ) + const expandableTarget = useMemo( + () => (!sameOriginTarget ? extractExternalUrlNostrForExpandable(url, appOrigin) : null), + [url, appOrigin, sameOriginTarget] + ) + + const cleaned = cleanUrl(url) || url + + if (sameOriginTarget) { + if (sameOriginTarget.kind === 'event') { + return ( + + ) + } + return ( + + + + ) + } + + if (expandableTarget) { + return ( + + ) + } + + if (renderMode === 'article') { + return + } + + return +} + +function ExpandableExternalNostrLink({ + url, + cleanedUrl, + target, + containingEvent, + className, + expandLabel +}: { + url: string + cleanedUrl: string + target: { kind: 'event' | 'profile'; id: string } + containingEvent?: Event + className?: string + expandLabel: string +}) { + const [open, setOpen] = useState(false) + + return ( + + {cleanedUrl} + + {open ? ( + + {target.kind === 'event' ? ( + + ) : ( + + + + )} + + ) : null} + + ) +} diff --git a/src/components/Embedded/index.tsx b/src/components/Embedded/index.tsx index 8edeb0ca..66d8877c 100644 --- a/src/components/Embedded/index.tsx +++ b/src/components/Embedded/index.tsx @@ -1,4 +1,5 @@ export * from './EmbeddedCalendarEvent' +export * from './HttpNostrAwareUrl' export * from './EmbeddedHashtag' export * from './EmbeddedLNInvoice' export * from './EmbeddedMention' diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index b819dbbc..9ab1b859 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -32,7 +32,7 @@ import { createPortal } from 'react-dom' import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' import CalendarEventContent from '@/components/CalendarEventContent' -import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' +import { EmbeddedNote, EmbeddedMention, HttpNostrAwareUrl } from '@/components/Embedded' import EmbeddedCitation from '@/components/EmbeddedCitation' import { preprocessMarkdownMediaLinks } from './preprocessMarkup' import { PAYTO_URI_REGEX, parsePaytoUri } from '@/lib/payto' @@ -439,6 +439,8 @@ function parseMarkdownContent( fullCalendarInvite?: { naddr: string; event: Event } /** Cleaned URL variants: standalone markdown links matching any render as inline (OG elsewhere). */ suppressStandaloneWebPreviewCleanedUrls?: ReadonlySet + /** Event whose body is being rendered (embedded notes / HTTP nostr links). */ + containingEvent?: Event } ): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } { const { @@ -452,7 +454,8 @@ function parseMarkdownContent( getImageIdentifier, emojiInfos = [], fullCalendarInvite, - suppressStandaloneWebPreviewCleanedUrls + suppressStandaloneWebPreviewCleanedUrls, + containingEvent } = options const parts: React.ReactNode[] = [] const hashtagsInContent = new Set() @@ -1878,8 +1881,12 @@ function parseMarkdownContent( ) } else { parts.push( -
- +
+
) } @@ -3648,6 +3655,7 @@ export default function MarkdownArticle({ getImageIdentifier, emojiInfos, fullCalendarInvite, + containingEvent: event, suppressStandaloneWebPreviewCleanedUrls: webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined }) @@ -3655,6 +3663,7 @@ export default function MarkdownArticle({ return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent } }, [ preprocessedContent, + event, event.pubkey, imageIndexMap, openLightbox, diff --git a/src/components/UniversalContent/SimpleContent.tsx b/src/components/UniversalContent/SimpleContent.tsx index c3ce9456..b282cd63 100644 --- a/src/components/UniversalContent/SimpleContent.tsx +++ b/src/components/UniversalContent/SimpleContent.tsx @@ -48,7 +48,7 @@ export default function SimpleContent({ return (
- {renderNostrContent(parsedContent)} + {renderNostrContent(parsedContent, undefined, event)}
) } \ No newline at end of file diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8a46289b..529c5543 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -27,6 +27,7 @@ export default { SidebarRelays: 'Relays', Refresh: 'Refresh', 'refresh.longPressHardReload': 'Long-press: reload app and restore feed cache', + 'link.expandNostrEmbed': 'Show Nostr preview', Profile: 'Profile', Logout: 'Logout', Following: 'Following', diff --git a/src/lib/nostr-from-http-url.ts b/src/lib/nostr-from-http-url.ts new file mode 100644 index 00000000..349f0f40 --- /dev/null +++ b/src/lib/nostr-from-http-url.ts @@ -0,0 +1,194 @@ +import { nip19 } from 'nostr-tools' + +/** Find npub1 / nprofile1 / note1 / nevent1 / naddr1 tokens in text. */ +const BECH32_NOSTR_RE = /(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+/gi + +export type NostrUrlExtract = { kind: 'event' | 'profile'; id: string } + +function classifyBech32(id: string): NostrUrlExtract | null { + try { + const { type } = nip19.decode(id) + if (type === 'npub' || type === 'nprofile') return { kind: 'profile', id } + if (type === 'note' || type === 'nevent' || type === 'naddr') return { kind: 'event', id } + } catch { + // ignore + } + return null +} + +function firstNostrExtractInString(s: string): NostrUrlExtract | null { + const re = new RegExp(BECH32_NOSTR_RE.source, 'gi') + let m: RegExpExecArray | null + while ((m = re.exec(s)) !== null) { + const hit = classifyBech32(m[0]) + if (hit) return hit + } + return null +} + +function isValidEmbeddedNotePointer(id: string): boolean { + const s = id.trim() + if (/^[0-9a-f]{64}$/i.test(s)) return true + const hit = classifyBech32(s) + return hit?.kind === 'event' +} + +function isProfilePointer(id: string): boolean { + const s = id.trim() + if (/^[0-9a-f]{64}$/i.test(s)) return true + const hit = classifyBech32(s) + return hit?.kind === 'profile' +} + +function extractHex64(s: string): string | null { + const m = s.match(/\b[0-9a-f]{64}\b/i) + return m ? m[0].toLowerCase() : null +} + +/** + * True if this hostname serves this web app: current tab origin and/or known production/dev hosts. + * Needed so `https://jumble.imwald.eu/.../notes/nevent…` embeds while the dev server runs on localhost. + */ +export function urlHostnameIsKnownJumbleAppHost( + urlHostname: string, + appOrigin: string | null +): boolean { + const h = urlHostname.toLowerCase() + if (h === 'jumble.imwald.eu') return true + if (h === 'localhost' || h === '127.0.0.1') return true + if (appOrigin) { + try { + if (h === new URL(appOrigin).hostname.toLowerCase()) return true + } catch { + // ignore + } + } + return false +} + +/** + * In-app HTTP(S) links to our routes → embed like `nostr:…` (same tab origin or known jumble/localhost host). + */ +export function parseSameOriginAppNostrUrl(urlStr: string, appOrigin: string | null): NostrUrlExtract | null { + let u: URL + try { + u = new URL(urlStr) + } catch { + return null + } + if (!urlHostnameIsKnownJumbleAppHost(u.hostname, appOrigin)) return null + + let path = u.pathname + if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1) + if (!path) path = '/' + + const usersMatch = path.match(/^\/users\/([^/?#]+)$/i) + if (usersMatch) { + const id = decodeURIComponent(usersMatch[1]) + if (isProfilePointer(id)) { + return { kind: 'profile', id } + } + return null + } + + const notesMatch = path.match(/\/notes\/([^/?#]+)$/i) + if (notesMatch) { + const id = decodeURIComponent(notesMatch[1]) + if (isValidEmbeddedNotePointer(id)) { + return { kind: 'event', id } + } + return null + } + + return null +} + +const QUERY_KEYS_PRIORITY = [ + 'id', + 'nevent', + 'note', + 'naddr', + 'event', + 'e', + 'npub', + 'nprofile', + 'pubkey', + 'user', + 'p', + 'author' +] + +/** + * Third-party URLs: Nostr id in query or path — offer chevron-expand embed (not auto). + */ +export function extractExternalUrlNostrForExpandable( + urlStr: string, + appOrigin: string | null +): NostrUrlExtract | null { + if (parseSameOriginAppNostrUrl(urlStr, appOrigin)) return null + + let u: URL + try { + u = new URL(urlStr) + } catch { + return null + } + + const tryPiece = (raw: string): NostrUrlExtract | null => { + const s = raw.trim() + if (!s) return null + const hex = extractHex64(s) + if (hex) return { kind: 'event', id: hex } + const b = firstNostrExtractInString(s) + if (b) return b + return null + } + + for (const key of QUERY_KEYS_PRIORITY) { + const v = u.searchParams.get(key) + if (!v) continue + let decoded = v + try { + decoded = decodeURIComponent(v) + } catch { + // use raw + } + const hit = tryPiece(decoded) + if (hit) return hit + } + + for (const [, v] of u.searchParams.entries()) { + let decoded = v + try { + decoded = decodeURIComponent(v) + } catch { + // use raw + } + const hit = tryPiece(decoded) + if (hit) return hit + } + + const pathHit = tryPiece(u.pathname) + if (pathHit) return pathHit + + const hash = u.hash ? u.hash.slice(1) : '' + if (hash) { + const hashHit = tryPiece(hash) + if (hashHit) return hashHit + } + + return firstNostrExtractInString(u.href) ?? null +} + +export function getBrowserAppOrigin(): string | null { + if (typeof window === 'undefined') return null + return window.location.origin +} + +/** Skip duplicate WebPreview at bottom of note when URL is handled as embed / expandable. */ +export function httpUrlSkipsBottomWebPreview(urlStr: string, appOrigin: string | null): boolean { + return ( + parseSameOriginAppNostrUrl(urlStr, appOrigin) != null || + extractExternalUrlNostrForExpandable(urlStr, appOrigin) != null + ) +} diff --git a/src/lib/nostr-parser.tsx b/src/lib/nostr-parser.tsx index eac60682..cc49693a 100644 --- a/src/lib/nostr-parser.tsx +++ b/src/lib/nostr-parser.tsx @@ -3,9 +3,8 @@ */ import { nip19 } from 'nostr-tools' -import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' +import { EmbeddedMention, EmbeddedNote, HttpNostrAwareUrl } from '@/components/Embedded' import ImageGallery from '@/components/ImageGallery' -import WebPreview from '@/components/WebPreview' import { BookstrContent } from '@/components/Bookstr/BookstrContent' import { cleanUrl, isImage, isMedia, isPseudoNostrHttpsUrl } from '@/lib/url' import { getImetaInfosFromEvent } from '@/lib/event' @@ -19,7 +18,7 @@ import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' export interface ParsedNostrContent { elements: Array<{ - type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'gallery' | 'url' | 'jumble-note' | 'payto' + type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'gallery' | 'url' | 'payto' content: string bech32Id?: string nostrType?: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' @@ -62,16 +61,13 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo // Regex to match wikilinks: [[target]] or [[target|display text]] or [[book::...]] const wikilinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g - // Regex to match Jumble note URLs: https://jumble.imwald.eu/notes/noteId - const jumbleNoteRegex = /(https:\/\/jumble\.imwald\.eu\/notes\/([a-zA-Z0-9]+))/g - // Regex to match bookstr search URLs: any URL containing book%3A%3A or book:: // Matches the pattern and captures the search term (everything after book%3A%3A or book:: until /, ?, #, &, or end) const bookstrUrlRegex = /(https?:\/\/[^\s]*(?:book%3A%3A|book::)([^\/\?\#\&\s]+))/gi - // Collect all matches (nostr, URLs, hashtags, wikilinks, jumble notes, and bookstr URLs) and sort by position + // Collect all matches (nostr, URLs, hashtags, wikilinks, bookstr URLs) and sort by position const allMatches: Array<{ - type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'url' | 'jumble-note' | 'payto' + type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'url' | 'payto' match: RegExpExecArray start: number end: number @@ -81,7 +77,6 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo displayText?: string bookstrWikilink?: string sourceUrl?: string - noteId?: string paytoUri?: string }> = [] @@ -253,25 +248,12 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo } } - // Find Jumble note URL matches - let jumbleNoteMatch - while ((jumbleNoteMatch = jumbleNoteRegex.exec(content)) !== null) { - allMatches.push({ - type: 'jumble-note', - match: jumbleNoteMatch, - start: jumbleNoteMatch.index, - end: jumbleNoteMatch.index + jumbleNoteMatch[0].length, - url: jumbleNoteMatch[1], - noteId: jumbleNoteMatch[2] - }) - } - // Sort matches by position allMatches.sort((a, b) => a.start - b.start) let lastIndex = 0 - for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, sourceUrl, noteId, paytoUri } of allMatches) { + for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, sourceUrl, paytoUri } of allMatches) { // Add text before the match if (start > lastIndex) { const textContent = content.slice(lastIndex, start) @@ -373,13 +355,6 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo content: match[0], url: url }) - } else if (type === 'jumble-note' && url && noteId) { - elements.push({ - type: 'jumble-note', - content: match[0], - url: url, - noteId: noteId - }) } else if (type === 'payto' && paytoUri) { elements.push({ type: 'payto', @@ -556,7 +531,11 @@ function getNostrType(bech32Id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr /** * Render parsed nostr content as React elements */ -export function renderNostrContent(parsedContent: ParsedNostrContent, className?: string): JSX.Element { +export function renderNostrContent( + parsedContent: ParsedNostrContent, + className?: string, + containingEvent?: Event +): JSX.Element { return (
{parsedContent.elements.map((element, index) => { @@ -671,26 +650,16 @@ export function renderNostrContent(parsedContent: ParsedNostrContent, className? } if (element.type === 'url' && element.url) { - // Use WebPreview for URLs to show OpenGraph cards return ( - ) } - - if (element.type === 'jumble-note' && element.noteId) { - return ( - - ) - } - + if (element.type === 'payto' && element.paytoUri) { return (