PageSourceSearch

https://pupwife.com/projects/chatroom/scripts/linkEmbeds.js

js pupwife.com collected 2026-09-30 15:06:59 UTC 6,415 bytes, 183 lines download raw bytes

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, '&amp;')
116            .replace(/"/g, '&quot;')
117            .replace(/'/g, '&#39;')
118            .replace(/</g, '&lt;')
119            .replace(/>/g, '&gt;');
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.