/** * Client-side webmention fetcher * Supplements build-time cached webmentions with real-time data from proxy API */ (function () { const container = document.querySelector('[data-webmentions]'); if (!container) return; const target = container.dataset.target; const domain = container.dataset.domain; const buildTime = parseInt(container.dataset.buildtime, 10) || 0; if (!target || !domain) return; // Use server-side proxy to keep webmention.io token secure // Fetch both with and without trailing slash since webmention.io // stores targets inconsistently (Bridgy sends different formats) const targetWithSlash = target.endsWith('/') ? target : target + '/'; const targetWithoutSlash = target.endsWith('/') ? target.slice(0, -1) : target; const apiUrl1 = `/webmentions/api/mentions?target=${encodeURIComponent(targetWithSlash)}&per-page=100`; const apiUrl2 = `/webmentions/api/mentions?target=${encodeURIComponent(targetWithoutSlash)}&per-page=100`; // Check if build-time webmentions section exists const hasBuildTimeSection = document.getElementById('webmentions') !== null; // Cache API responses in sessionStorage (5 min TTL) so webmentions // persist across page refreshes without re-fetching every time const cacheKey = `wm-data-${target}`; const cacheTTL = 5 * 60 * 1000; // 5 minutes function getCachedData() { try { const cached = sessionStorage.getItem(cacheKey); if (!cached) return null; const parsed = JSON.parse(cached); if (Date.now() - parsed.ts > cacheTTL) { sessionStorage.removeItem(cacheKey); return null; } return parsed.children; } catch { return null; } } function setCachedData(children) { try { sessionStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), children: children })); } catch { // sessionStorage full or unavailable - no problem } } function processWebmentions(allChildren) { if (!allChildren || !allChildren.length) return; let mentionsToShow; if (hasBuildTimeSection) { // Build-time section exists - only show NEW webmentions to avoid duplicates. // Both webmention.io and conversations items are included at build time, // so filter all by timestamp (only show items received after the build). mentionsToShow = allChildren.filter((wm) => { const wmTime = new Date(wm['wm-received']).getTime(); return wmTime > buildTime; }); } else { // No build-time section - show ALL webmentions from API mentionsToShow = allChildren; } if (!mentionsToShow.length) return; // Group by type const likes = mentionsToShow.filter((m) => m['wm-property'] === 'like-of'); const reposts = mentionsToShow.filter((m) => m['wm-property'] === 'repost-of'); const replies = mentionsToShow.filter((m) => m['wm-property'] === 'in-reply-to'); const mentions = mentionsToShow.filter((m) => m['wm-property'] === 'mention-of'); // Append new likes if (likes.length) { appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes'); updateCount('.webmention-likes h3', likes.length, 'Like'); } // Append new reposts if (reposts.length) { appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts'); updateCount('.webmention-reposts h3', reposts.length, 'Repost'); } // Append new replies if (replies.length) { appendReplies('.webmention-replies ul', replies); updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y'); } // Append new mentions if (mentions.length) { appendMentions('.webmention-mentions ul', mentions); updateCount('.webmention-mentions h3', mentions.length, 'Mention'); } // Update total count in main header updateTotalCount(mentionsToShow.length); } // Try cached data first (renders instantly on refresh) const cached = getCachedData(); if (cached) { processWebmentions(cached); } // Conversations API URLs (dual-fetch for enriched data) const convApiUrl1 = `/conversations/api/mentions?target=${encodeURIComponent(targetWithSlash)}&per-page=100`; const convApiUrl2 = `/conversations/api/mentions?target=${encodeURIComponent(targetWithoutSlash)}&per-page=100`; // Always fetch fresh data from both APIs (updates cache for next refresh) Promise.all([ fetch(apiUrl1).then((res) => res.json()).catch(() => ({ children: [] })), fetch(apiUrl2).then((res) => res.json()).catch(() => ({ children: [] })), fetch(convApiUrl1).then((res) => res.ok ? res.json() : { children: [] }).catch(() => ({ children: [] })), fetch(convApiUrl2).then((res) => res.ok ? res.json() : { children: [] }).catch(() => ({ children: [] })), ]) .then(([wmData1, wmData2, convData1, convData2]) => { // Collect all items from both APIs const wmItems = [...(wmData1.children || []), ...(wmData2.children || [])]; const convItems = [...(convData1.children || []), ...(convData2.children || [])]; // Build dedup sets from conversations items (richer metadata, take priority) const convUrls = new Set(convItems.map(c => c.url).filter(Boolean)); const seen = new Set(); const allChildren = []; // Add conversations items first (they have platform provenance) for (const wm of convItems) { const key = wm['wm-id'] || wm.url; if (key && !seen.has(key)) { seen.add(key); allChildren.push(wm); } } // Build set of author+action keys from conversations for cross-source dedup const authorActions = new Set(); for (const wm of convItems) { const authorUrl = (wm.author && wm.author.url) || wm.url || ''; const action = wm['wm-property'] || 'mention'; if (authorUrl) authorActions.add(authorUrl + '::' + action); } // Add webmention-io items, skipping duplicates for (const wm of wmItems) { const key = wm['wm-id']; if (seen.has(key)) continue; // Also skip if same source URL exists in conversations if (wm.url && convUrls.has(wm.url)) continue; // Skip if same author + same action type already from conversations const authorUrl = (wm.author && wm.author.url) || wm.url || ''; const action = wm['wm-property'] || 'mention'; if (authorUrl && authorActions.has(authorUrl + '::' + action)) continue; seen.add(key); allChildren.push(wm); } // Cache the merged results setCachedData(allChildren); // Only render if we didn't already render from cache if (!cached) { processWebmentions(allChildren); } }) .catch((err) => { console.debug('[Webmentions] Error fetching:', err.message); }); function appendAvatars(selector, items, type) { let row = document.querySelector(selector); // Create section if it doesn't exist if (!row) { const section = createAvatarSection(type); let webmentionsSection = document.getElementById('webmentions'); if (!webmentionsSection) { createWebmentionsSection(); webmentionsSection = document.getElementById('webmentions'); } if (webmentionsSection) { webmentionsSection.appendChild(section); row = section.querySelector('.facepile'); } } if (!row) return; items.forEach((item) => { const author = item.author || {}; const link = document.createElement('a'); link.href = author.url || '#'; link.className = 'facepile-avatar'; link.title = author.name || 'Anonymous'; link.target = '_blank'; link.rel = 'noopener'; link.dataset.new = 'true'; const img = document.createElement('img'); img.src = author.photo || '/images/default-avatar.svg'; img.alt = author.name || 'Anonymous'; img.className = 'w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900'; img.loading = 'lazy'; link.appendChild(img); row.appendChild(link); }); } function appendReplies(selector, items) { let list = document.querySelector(selector); // Create section if it doesn't exist if (!list) { const section = createReplySection(); let webmentionsSection = document.getElementById('webmentions'); if (!webmentionsSection) { createWebmentionsSection(); webmentionsSection = document.getElementById('webmentions'); } if (webmentionsSection) { webmentionsSection.appendChild(section); list = section.querySelector('ul'); } } if (!list) return; items.forEach((item) => { const author = item.author || {}; const content = item.content || {}; const published = item.published || item['wm-received']; const li = document.createElement('li'); li.className = 'p-4 bg-surface-100 dark:bg-surface-800 rounded-lg ring-2 ring-accent-500'; li.dataset.new = 'true'; // Build reply card using DOM methods const wrapper = document.createElement('div'); wrapper.className = 'flex gap-3'; // Avatar link const avatarLink = document.createElement('a'); avatarLink.href = author.url || '#'; avatarLink.target = '_blank'; avatarLink.rel = 'noopener'; const avatarImg = document.createElement('img'); avatarImg.src = author.photo || '/images/default-avatar.svg'; avatarImg.alt = author.name || 'Anonymous'; avatarImg.className = 'w-10 h-10 rounded-full'; avatarImg.loading = 'lazy'; avatarLink.appendChild(avatarImg); // Content area const contentDiv = document.createElement('div'); contentDiv.className = 'flex-1 min-w-0'; // Header row const headerDiv = document.createElement('div'); headerDiv.className = 'flex items-baseline gap-2 mb-1'; const authorLink = document.createElement('a'); authorLink.href = author.url || '#'; authorLink.className = 'font-semibold text-surface-900 dark:text-surface-100 hover:underline'; authorLink.target = '_blank'; authorLink.rel = 'noopener'; authorLink.textContent = author.name || 'Anonymous'; const dateLink = document.createElement('a'); dateLink.href = item.url || '#'; dateLink.className = 'text-xs text-surface-500 hover:underline'; dateLink.target = '_blank'; dateLink.rel = 'noopener'; const timeEl = document.createElement('time'); timeEl.dateTime = published; timeEl.textContent = formatDate(published); dateLink.appendChild(timeEl); const newBadge = document.createElement('span'); newBadge.className = 'text-xs text-accent-600 dark:text-accent-400 font-medium'; newBadge.textContent = 'NEW'; headerDiv.appendChild(authorLink); headerDiv.appendChild(dateLink); headerDiv.appendChild(newBadge); // Reply content - use textContent for safety const replyDiv = document.createElement('div'); replyDiv.className = 'text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none'; replyDiv.textContent = content.text || ''; contentDiv.appendChild(headerDiv); contentDiv.appendChild(replyDiv); wrapper.appendChild(avatarLink); wrapper.appendChild(contentDiv); li.appendChild(wrapper); // Prepend to show newest first list.insertBefore(li, list.firstChild); }); } function appendMentions(selector, items) { let list = document.querySelector(selector); if (!list) { const section = createMentionSection(); let webmentionsSection = document.getElementById('webmentions'); if (!webmentionsSection) { createWebmentionsSection(); webmentionsSection = document.getElementById('webmentions'); } if (webmentionsSection) { webmentionsSection.appendChild(section); list = section.querySelector('ul'); } } if (!list) return; items.forEach((item) => { const author = item.author || {}; const published = item.published || item['wm-received']; const li = document.createElement('li'); li.dataset.new = 'true'; const link = document.createElement('a'); link.href = item.url || '#'; link.className = 'text-accent-600 dark:text-accent-400 hover:underline'; link.target = '_blank'; link.rel = 'noopener'; link.textContent = `${author.name || 'Someone'} mentioned this on ${formatDate(published)}`; const badge = document.createElement('span'); badge.className = 'text-xs text-accent-600 dark:text-accent-400 font-medium ml-1'; badge.textContent = 'NEW'; li.appendChild(link); li.appendChild(badge); list.insertBefore(li, list.firstChild); }); } function updateCount(selector, additionalCount, noun, pluralSuffix, singularSuffix) { const header = document.querySelector(selector); if (!header) return; const text = header.textContent; const match = text.match(/(\d+)/); if (match) { const currentCount = parseInt(match[1], 10); const newCount = currentCount + additionalCount; if (noun) { // Rebuild the header text with correct pluralization var suffix = pluralSuffix || 's'; var singSuffix = singularSuffix || ''; header.textContent = newCount + ' ' + noun + (newCount !== 1 ? suffix : singSuffix); } else { header.textContent = text.replace(/\d+/, newCount); } } } function updateTotalCount(additionalCount) { const header = document.querySelector('#webmentions h2'); if (!header) return; const text = header.textContent; const match = text.match(/\((\d+)\)/); if (match) { const currentCount = parseInt(match[1], 10); const newCount = currentCount + additionalCount; header.textContent = text.replace(/\(\d+\)/, `(${newCount})`); } } function createAvatarSection(type) { const section = document.createElement('div'); section.className = `webmention-${type} mb-6`; const header = document.createElement('h3'); header.className = 'text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3'; header.textContent = `0 ${type === 'likes' ? 'Likes' : 'Reposts'}`; const row = document.createElement('div'); row.className = 'facepile'; section.appendChild(header); section.appendChild(row); return section; } function createReplySection() { const section = document.createElement('div'); section.className = 'webmention-replies'; const header = document.createElement('h3'); header.className = 'text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-4'; header.textContent = '0 Replies'; const list = document.createElement('ul'); list.className = 'space-y-4'; section.appendChild(header); section.appendChild(list); return section; } function createMentionSection() { const section = document.createElement('div'); section.className = 'webmention-mentions mt-6'; const header = document.createElement('h3'); header.className = 'text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3'; header.textContent = '0 Mentions'; const list = document.createElement('ul'); list.className = 'space-y-2 text-sm'; section.appendChild(header); section.appendChild(list); return section; } function createWebmentionsSection() { const webmentionForm = document.querySelector('.webmention-form'); if (!webmentionForm) return; const section = document.createElement('section'); section.className = 'webmentions mt-8 pt-8 border-t border-surface-200 dark:border-surface-700'; section.id = 'webmentions'; const header = document.createElement('h2'); header.className = 'text-xl font-bold text-surface-900 dark:text-surface-100 mb-6'; header.textContent = 'Webmentions (0)'; section.appendChild(header); webmentionForm.parentNode.insertBefore(section, webmentionForm); } function formatDate(dateStr) { if (!dateStr) return ''; const date = new Date(dateStr); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); } })();