1/** 2 * Link Embeds (Discord-style) 3 * Fetches Open Graph metadata and renders rich preview cards for URLs in messages 4 * Uses LinkMeta API (free, CORS-enabled) - https://linkmeta.dev 5 */ 6 7window.ChatroomLinkEmbeds = (function() { 8 'use strict'; 9 10 const EMBED_API = 'https://linkmeta.dev/api/v1/extract'; 11 const MAX_EMBEDS_PER_MESSAGE = 3; 12 const CACHE_TTL = 5 * 60 * 1000; // 5 minutes 13 const EMBED_MAX_WIDTH = 400; 14 const EMBED_IMAGE_MAX_HEIGHT = 200; 15 16 let embedCache = new Map(); // url -> { data, timestamp } 17 18 /** 19 * Check if link previews are enabled 20 */ 21 function isEnabled() { 22 if (window.ChatroomSettings?.getSetting?.('showLinkPreviews') === true) return true; 23 if (document.documentElement.classList.contains('show-link-previews')) return true; 24 return false; 25 } 26 27 /** 28 * Extract unique URLs from message element (from .message-link anchors) 29 */ 30 function extractUrlsFromMessage(messageEl) { 31 const links = messageEl.querySelectorAll('.message-text .message-link'); 32 const urls = new Set(); 33 links.forEach(a => { 34 try { 35 const href = a.getAttribute('href') || a.href; 36 if (href && /^https?:\/\//i.test(href)) { 37 urls.add(href); 38 } 39 } catch (e) { /* ignore */ } 40 }); 41 return Array.from(urls).slice(0, MAX_EMBEDS_PER_MESSAGE); 42 } 43 44 /** 45 * Fetch embed metadata from LinkMeta API 46 */ 47 async function fetchEmbedData(url) { 48 const cached = embedCache.get(url); 49 if (cached && Date.now() - cached.timestamp < CACHE_TTL) { 50 return cached.data; 51 } 52 53 try { 54 const encoded = encodeURIComponent(url); 55 const res = await fetch(`${EMBED_API}?url=${encoded}`, { 56 method: 'GET', 57 headers: { 'Accept': 'application/json' } 58 }); 59 if (!res.ok) return null; 60 const json = await res.json(); 61 if (json.status !== 'success' || !json.data) return null; 62 63 const data = json.data; 64 embedCache.set(url, { data, timestamp: Date.now() }); 65 return data; 66 } catch (e) { 67 console.warn('Link embed fetch failed:', url, e); 68 return null; 69 } 70 } 71 72 /** 73 * Create embed card DOM element 74 */ 75 function createEmbedCard(data, url) { 76 const card = document.createElement('a'); 77 card.className = 'message-embed'; 78 card.href = url; 79 card.target = '_blank'; 80 card.rel = 'noopener noreferrer'; 81 82 const title = data.title || data.siteName || new URL(url).hostname; 83 const description = (data.description || '').trim(); 84 const image = data.image; 85 const siteName = data.siteName || new URL(url).hostname; 86 87 let innerHTML = ''; 88 if (image) { 89 innerHTML += ` 90 <div class="message-embed-image"> 91 <img src="${escapeHtmlAttr(image)}" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"> 92 </div> 93 `; 94 } 95 innerHTML += ` 96 <div class="message-embed-content"> 97 <div class="message-embed-site">${escapeHtml(siteName)}</div> 98 <div class="message-embed-title">${escapeHtml(title)}</div> 99 ${description ? `<div class="message-embed-description">${escapeHtml(description.length > 150 ? description.slice(0, 150) + 'â¦' : description)}</div>` : ''} 100 </div> 101 `; 102 103 card.innerHTML = innerHTML; 104 return card; 105 } 106 107 function escapeHtml(text) { 108 const div = document.createElement('div'); 109 div.textContent = text || ''; 110 return div.innerHTML; 111 } 112 113 function escapeHtmlAttr(text) { 114 return String(text || '') 115 .replace(/&/g, '&') 116 .replace(/"/g, '"') 117 .replace(/'/g, ''') 118 .replace(/</g, '<') 119 .replace(/>/g, '>'); 120 } 121 122 /** 123 * Process a message and add embeds for any links 124 */ 125 async function processMessage(messageEl) { 126 if (!isEnabled()) return; 127 128 const contentEl = messageEl.querySelector('.message-content'); 129 if (!contentEl) return; 130 131 // Remove existing embeds (in case of re-process) 132 const existing = contentEl.querySelector('.message-embeds'); 133 if (existing) existing.remove(); 134 135 const urls = extractUrlsFromMessage(messageEl); 136 if (urls.length === 0) return; 137 138 const embedsContainer = document.createElement('div'); 139 embedsContainer.className = 'message-embeds'; 140 141 for (const url of urls) { 142 const data = await fetchEmbedData(url); 143 if (data) { 144 const card = createEmbedCard(data, url); 145 embedsContainer.appendChild(card); 146 } 147 } 148 149 if (embedsContainer.children.length > 0) { 150 // Insert before reactions 151 const reactions = contentEl.querySelector('.message-reactions'); 152 if (reactions) { 153 contentEl.insertBefore(embedsContainer, reactions); 154 } else { 155 contentEl.appendChild(embedsContainer); 156 } 157 158 // Keep user at bottom when embeds load - if they were scrolled to bottom, stay there 159 const container = document.getElementById('messages-container'); 160 if (container && window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) { 161 const wasNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 150; 162 if (wasNearBottom) { 163 setTimeout(() => { 164 container.scrollTop = container.scrollHeight; 165 }, 50); 166 } 167 } 168 } 169 } 170 171 /** 172 * Initialize embeds for a message element (call after message is in DOM) 173 */ 174 function initMessageEmbeds(messageEl) { 175 if (!messageEl || !isEnabled()) return; 176 processMessage(messageEl); 177 } 178 179 return { 180 initMessageEmbeds, 181 isEnabled 182 }; 183})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.