@ -124,68 +124,6 @@
@@ -124,68 +124,6 @@
}
}
// Convert plain media URLs (images, videos, audio) to HTML tags
function convertMediaUrls(text: string): string {
// Match media URLs (http/https URLs ending in media extensions)
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s< >"']*)?$/i;
const videoExtensions = /\.(mp4|webm|ogg|mov|avi|mkv)(\?[^\s< >"']*)?$/i;
const audioExtensions = /\.(mp3|wav|ogg|flac|aac|m4a)(\?[^\s< >"']*)?$/i;
const urlPattern = /https?:\/\/[^\s< >"']+/g;
// Normalize exclude URLs for comparison
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
let result = text;
const matches: Array< { url : string ; index : number ; endIndex : number ; type : 'image' | 'video' | 'audio' } > = [];
// Find all URLs
let match;
while ((match = urlPattern.exec(text)) !== null) {
const url = match[0];
const index = match.index;
const endIndex = index + url.length;
// Skip if this URL is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
continue;
}
// Check if this URL is already in markdown or HTML
const before = text.substring(Math.max(0, index - 10), index);
const after = text.substring(endIndex, Math.min(text.length, endIndex + 10));
// Skip if it's already in markdown or HTML tags
if (before.includes(' || after.startsWith('</ img > ') || after.startsWith('</ video > ') || after.startsWith('</ audio > ')) {
continue;
}
// Determine media type
if (imageExtensions.test(url)) {
matches.push({ url , index , endIndex , type : 'image' } );
} else if (videoExtensions.test(url)) {
matches.push({ url , index , endIndex , type : 'video' } );
} else if (audioExtensions.test(url)) {
matches.push({ url , index , endIndex , type : 'audio' } );
}
}
// Replace from end to start to preserve indices
for (let i = matches.length - 1; i >= 0; i--) {
const { url , index , endIndex , type } = matches[i];
const escapedUrl = escapeHtml(url);
if (type === 'image') {
result = result.substring(0, index) + `< img src = "$ { escapedUrl } " alt = "" style = "max-width: 600px; width: 100%; height: auto;" /> ` + result.substring(endIndex);
} else if (type === 'video') {
result = result.substring(0, index) + `< video src = "$ { escapedUrl } " controls preload = "none" style = "max-width: 600px; width: 100%; height: auto; max-height: 500px;" ></ video > ` + result.substring(endIndex);
} else if (type === 'audio') {
result = result.substring(0, index) + `< audio src = "$ { escapedUrl } " controls preload = "none" style = "max-width: 600px; width: 100%;" ></ audio > ` + result.substring(endIndex);
}
}
return result;
}
// Resolve custom emojis in content
async function resolveContentEmojis(text: string): Promise< void > {
@ -502,10 +440,144 @@
@@ -502,10 +440,144 @@
return result;
}
// Process and convert media URLs: handles markdown, AsciiDoc, and plain URLs
// Removes excluded URLs (displayed by MediaAttachments) and converts others to HTML tags
function processMediaUrls(text: string): string {
// Normalize exclude URLs for comparison
const normalizedExcludeUrls = excludeMediaUrls.length > 0
? new Set(excludeMediaUrls.map(url => normalizeUrl(url)))
: new Set< string > ();
// Debug: Log excluded URLs and check if they're being found in text
if (normalizedExcludeUrls.size > 0 && typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Excluding URLs:', Array.from(normalizedExcludeUrls));
console.debug('MarkdownRenderer: Original text:', text.substring(0, 200));
// Check if any excluded URLs appear in the text
for (const excludedUrl of normalizedExcludeUrls) {
// Try to find the URL in the text (check both normalized and original)
const urlInText = text.includes(excludedUrl) ||
excludeMediaUrls.some(orig => text.includes(orig));
if (urlInText) {
console.debug('MarkdownRenderer: Found excluded URL in text:', excludedUrl);
// Find where it appears
const index = text.indexOf(excludeMediaUrls.find(orig => text.includes(orig)) || '');
if (index >= 0) {
console.debug('MarkdownRenderer: URL found at index:', index, 'context:', text.substring(Math.max(0, index - 20), Math.min(text.length, index + 100)));
}
}
}
}
let result = text;
const replacements: Array< { fullMatch : string ; replacement : string ; index : number } > = [];
const processedIndices = new Set< number > (); // Track processed indices to avoid duplicates
// 1. Match markdown image syntax: 
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match;
while ((match = markdownImageRegex.exec(text)) !== null) {
const fullMatch = match[0];
const alt = match[1];
const url = match[2];
const index = match.index;
if (processedIndices.has(index)) continue;
processedIndices.add(index);
const normalizedUrl = normalizeUrl(url);
if (normalizedExcludeUrls.has(normalizedUrl)) {
// Remove excluded markdown images, leaving just alt text
replacements.push({ fullMatch , replacement : alt || '' , index } );
} else {
// Convert non-excluded markdown images to HTML (marked will handle this, but we ensure it's preserved)
// Marked will convert this, so we don't need to do anything here
}
}
// 2. Match AsciiDoc image syntax: image::url[] or image:url[]
const asciidocImageRegex = /image::?([^\s\[\]]+)(\[[^\]]*\])?/g;
asciidocImageRegex.lastIndex = 0;
let asciidocMatch;
while ((asciidocMatch = asciidocImageRegex.exec(text)) !== null) {
const fullMatch = asciidocMatch[0];
const url = asciidocMatch[1];
const index = asciidocMatch.index;
if (processedIndices.has(index)) continue;
// Only process if it's a URL (starts with http:// or https://)
if (url.startsWith('http://') || url.startsWith('https://')) {
processedIndices.add(index);
const normalizedUrl = normalizeUrl(url);
if (normalizedExcludeUrls.has(normalizedUrl)) {
// Remove excluded AsciiDoc images
replacements.push({ fullMatch , replacement : '' , index } );
}
// Non-excluded AsciiDoc images will be handled by AsciiDoc renderer
}
}
// 3. Match plain media URLs (using the SAME pattern as getMediaAttachmentUrls)
const urlRegex = /(https?:\/\/[^\s< >"{} |\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s< >"{} |\\^`\[\]]*)?)/gi;
urlRegex.lastIndex = 0;
while ((match = urlRegex.exec(text)) !== null) {
const url = match[1]; // The URL part (group 1)
const fullMatch = match[0]; // Full match
const index = match.index;
if (processedIndices.has(index)) continue;
processedIndices.add(index);
// Check if it's already in markdown or HTML
const before = text.substring(Math.max(0, index - 10), index);
const after = text.substring(index + fullMatch.length, Math.min(text.length, index + fullMatch.length + 10));
if (before.includes(' || after.startsWith('</ img > ') || after.startsWith('</ video > ') || after.startsWith('</ audio > ')) {
continue; // Already handled by markdown/HTML
}
const normalizedUrl = normalizeUrl(url);
if (normalizedExcludeUrls.has(normalizedUrl)) {
// Remove excluded plain URLs
if (typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Found excluded URL, removing:', url, 'normalized:', normalizedUrl);
}
replacements.push({ fullMatch , replacement : '' , index } );
} else {
if (typeof console !== 'undefined' && normalizedExcludeUrls.size > 0) {
console.debug('MarkdownRenderer: URL not excluded, will convert:', url, 'normalized:', normalizedUrl, 'excluded set:', Array.from(normalizedExcludeUrls));
}
// Convert non-excluded plain URLs to HTML tags
const ext = match[2].toLowerCase();
let htmlTag: string;
if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) {
htmlTag = `< video src = "$ { escapeHtml ( url )} " controls preload = "none" style = "max-width: 600px; width: 100%; height: auto; max-height: 500px;" ></ video > `;
} else if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext)) {
htmlTag = `< audio src = "$ { escapeHtml ( url )} " controls preload = "none" style = "max-width: 600px; width: 100%;" ></ audio > `;
} else {
htmlTag = `< img src = "$ { escapeHtml ( url )} " alt = "" style = "max-width: 600px; width: 100%; height: auto;" /> `;
}
replacements.push({ fullMatch , replacement : htmlTag , index } );
}
}
// Apply all replacements from end to start to preserve indices
replacements.sort((a, b) => b.index - a.index);
for (const { fullMatch , replacement , index } of replacements) {
result = result.substring(0, index) + replacement + result.substring(index + fullMatch.length);
}
return result;
}
// Process content: replace nostr URIs with HTML span elements and convert media URLs
function processContent(text: string): string {
// First, convert greentext (must be before markdown processing)
let processed = convertGreentext(text);
// Process media URLs (removes excluded ones, converts others to HTML)
let processed = processMediaUrls(text);
// Then, convert greentext (must be before markdown processing)
processed = convertGreentext(processed);
// Convert wikilinks to markdown links (before other processing)
processed = convertWikilinks(processed);
@ -516,9 +588,6 @@
@@ -516,9 +588,6 @@
// Convert hashtags to links
processed = convertHashtags(processed);
// Then, convert plain media URLs (images, videos, audio) to HTML tags
processed = convertMediaUrls(processed);
// Find all NIP-21 links (nostr:npub, nostr:nprofile, nostr:nevent, etc.)
const links = findNIP21Links(processed);
@ -688,6 +757,48 @@
@@ -688,6 +757,48 @@
});
}
// Helper function to apply exclusion filtering to HTML (used for both fresh and cached content)
function applyExclusionFiltering(htmlContent: string): string {
if (excludeMediaUrls.length === 0) return htmlContent;
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
let filtered = htmlContent;
// Remove ALL img tags with excluded URLs
filtered = filtered.replace(/< img [^ > ]*>/gi, (match) => {
const srcMatch = match.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
const src = srcMatch[1];
const normalizedSrc = normalizeUrl(src);
if (normalizedExcludeUrls.has(normalizedSrc)) {
if (typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Removing excluded img tag from content:', src);
}
return '';
}
}
return match;
});
// Remove links pointing to excluded image URLs
filtered = filtered.replace(/< a \ s +([^ > ]*?)href=["']([^"']+)["']([^>]*?)>([^< ]*?)< \/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => {
if (normalizedExcludeUrls.has(normalizeUrl(url)) && /\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?|$)/i.test(url)) {
return linkText;
}
return match;
});
// Remove excluded image URLs from text nodes
filtered = filtered.replace(/>([^< ]*?)(https?:\/\/[^\s< >"{} |\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s< >"{} |\\^`\[\]]*)?)([^< ]*?)< /gi, (match, before, url, ext, query, after) => {
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
return `>${ before } ${ after } < `;
}
return match;
});
return filtered;
}
// Render markdown or AsciiDoc to HTML
async function renderMarkdown(text: string): Promise< string > {
if (!content) return '';
@ -710,13 +821,15 @@
@@ -710,13 +821,15 @@
}
}
markdownCache.set(cacheKey, cachedFromDB);
return cachedFromDB;
// Apply exclusion filtering to cached content (exclusion list may have changed)
return applyExclusionFiltering(cachedFromDB);
}
// Check in-memory cache (faster for same session)
const cached = markdownCache.get(cacheKey);
if (cached !== undefined) {
return cached;
// Apply exclusion filtering to cached content (exclusion list may have changed)
return applyExclusionFiltering(cached);
}
const processed = processContent(contentToRender);
@ -744,6 +857,9 @@
@@ -744,6 +857,9 @@
// Post-process to fix any greentext that markdown converted to blockquotes
html = postProcessGreentext(html);
// Apply exclusion filtering to the rendered HTML
html = applyExclusionFiltering(html);
// Remove emoji images that are inside code blocks (they should be plain text)
// This handles cases where emojis were replaced before markdown/AsciiDoc parsing
// Handle < code > tags (inline code)
@ -773,9 +889,34 @@
@@ -773,9 +889,34 @@
// Normalize exclude URLs for comparison
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
// AGGRESSIVE CLEANUP: Remove ALL img tags with excluded URLs first (before any other processing)
// This is the most important step - it catches images regardless of how they were created
if (normalizedExcludeUrls.size > 0) {
const beforeCleanup = html;
html = html.replace(/< img [^ > ]*>/gi, (match) => {
// Extract src attribute
const srcMatch = match.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
const src = srcMatch[1];
const normalizedSrc = normalizeUrl(src);
// Remove if this image is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizedSrc)) {
if (typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Removing excluded img tag:', src, 'normalized:', normalizedSrc);
}
return ''; // Remove the img tag completely
}
}
return match;
});
if (typeof console !== 'undefined' && beforeCleanup !== html) {
console.debug('MarkdownRenderer: Cleaned up img tags. Before:', beforeCleanup.match(/< img [ ^ > ]*>/gi)?.length || 0, 'After:', html.match(/< img [ ^ > ]*>/gi)?.length || 0);
}
}
// Fix malformed image tags - ensure all img src attributes are absolute URLs
// This prevents the browser from trying to fetch markdown syntax or malformed tags as relative URLs
// Also filter out images that are already displayed by MediaAttachments
// Also filter out images that are already displayed by MediaAttachments (defensive check)
html = html.replace(/< img \ s +([^ > ]*?)>/gi, (match, attributes) => {
// Extract src attribute
const srcMatch = attributes.match(/src=["']([^"']+)["']/i);
@ -846,6 +987,11 @@
@@ -846,6 +987,11 @@
const urlMatch = pattern.match(/(https?:\/\/[^\s< >"')]+)/i);
if (urlMatch) {
const url = urlMatch[1];
// Skip if this URL is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
// Remove the URL pattern completely
return `>${ before } ${ after } < `;
}
// If it's an image URL, convert to proper img tag
if (/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s< >"']*)?$/i.test(url)) {
const escapedUrl = escapeHtml(url);
@ -856,6 +1002,32 @@
@@ -856,6 +1002,32 @@
return `>${ before } ${ after } < `;
});
// Final aggressive cleanup: Remove ANY reference to excluded URLs from the HTML
// This catches URLs that might have been converted to links by the markdown renderer
// or appeared in any other form
if (normalizedExcludeUrls.size > 0) {
// Remove links that point to excluded image URLs (marked might auto-link them)
html = html.replace(/< a \ s +([^ > ]*?)href=["']([^"']+)["']([^>]*?)>([^< ]*?)< \/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => {
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
// Remove the link but keep just the text content
return linkText;
}
return match;
});
// Remove excluded image URLs from text nodes (final safety net)
// Match URLs in text content (between > and < )
html = html.replace(/>([^< ]*?)(https?:\/\/[^\s< >"{} |\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s< >"{} |\\^`\[\]]*)?)([^< ]*?)< /gi, (match, before, url, ext, query, after) => {
// Check if this URL is excluded (already displayed by MediaAttachments)
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
// Remove the URL from the text completely
return `>${ before } ${ after } < `;
}
// Otherwise, leave it as-is
return match;
});
}
// Filter out invalid relative links (like /a, /b, etc.) that cause 404s
// These are likely malformed markdown links
html = html.replace(/< a \ s + href = "\/([a-z]|\.)" [^ > ]*>.*?< \/a>/gi, (match, char) => {
@ -869,8 +1041,51 @@
@@ -869,8 +1041,51 @@
return textMatch ? textMatch[1] : '';
});
// FINAL PASS: Remove any remaining references to excluded URLs
// This is the absolute last step before sanitization - catches anything we might have missed
if (normalizedExcludeUrls.size > 0) {
// Remove any img tags with excluded URLs (one more time, just to be sure)
html = html.replace(/< img [^ > ]*src=["']([^"']+)["'][^>]*>/gi, (match, src) => {
if (normalizedExcludeUrls.has(normalizeUrl(src))) {
return '';
}
return match;
});
// Remove any links pointing to excluded image URLs
html = html.replace(/< a \ s +[^ > ]*href=["']([^"']+)["'][^>]*>([^< ]*?)< \/a>/gi, (match, href, linkText) => {
if (normalizedExcludeUrls.has(normalizeUrl(href))) {
// Check if it's an image URL (ends with image extension)
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?|$)/i.test(href)) {
return linkText; // Remove link, keep text
}
}
return match;
});
// Remove excluded image URLs from any remaining text content
html = html.replace(/(https?:\/\/[^\s< >"{} |\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s< >"{} |\\^`\[\]]*)?)/gi, (match, url) => {
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
return '';
}
return match;
});
}
// Sanitize HTML (but preserve our data attributes and image src)
const sanitized = sanitizeMarkdown(html);
let sanitized = sanitizeMarkdown(html);
// ONE MORE FINAL PASS after sanitization - remove any excluded URLs that might have survived
// This is the absolute last chance to catch excluded images
if (normalizedExcludeUrls.size > 0) {
sanitized = sanitized.replace(/< img [^ > ]*>/gi, (match) => {
const srcMatch = match.match(/src=["']([^"']+)["']/i);
if (srcMatch && normalizedExcludeUrls.has(normalizeUrl(srcMatch[1]))) {
return '';
}
return match;
});
}
// Cache the result (with size limit to prevent memory bloat)
if (markdownCache.size >= MAX_CACHE_SIZE) {