From fc9f5968da7f4341091092372b97c0f2ab53c152 Mon Sep 17 00:00:00 2001 From: rmdes Date: Wed, 18 Feb 2026 21:28:01 +0100 Subject: [PATCH] feat: dual-fetch from conversations API for enriched interaction data Fetch from both /webmentions/api/mentions and /conversations/api/mentions, merge results with conversations items taking priority (richer metadata), and display platform badges (Mastodon/Bluesky icons) on interaction cards. Co-Authored-By: Claude Opus 4.6 --- interactions.njk | 115 ++++++++++++++++++++++++++++++++++------------ js/webmentions.js | 36 ++++++++++++--- 2 files changed, 116 insertions(+), 35 deletions(-) diff --git a/interactions.njk b/interactions.njk index 0b6d0be..04344bd 100644 --- a/interactions.njk +++ b/interactions.njk @@ -243,6 +243,14 @@ permalink: /interactions/ 🔖 bookmarked + {# Platform badge (from conversations API) #} + + + + + + + @@ -357,38 +365,47 @@ function interactionsApp() { this.error = null; try { - // Use our server-side proxy which has the token - const url = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`; + // Fetch from both webmention-io and conversations APIs in parallel + const wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`; + const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`; - const response = await fetch(url); - if (!response.ok) { - if (response.status === 404) { - this.notConfigured = true; - return; - } - const data = await response.json().catch(() => ({})); - throw new Error(data.message || `HTTP ${response.status}`); + const [wmResult, convResult] = await Promise.allSettled([ + fetch(wmUrl).then(r => { + if (r.status === 404) return { children: [], notConfigured: true }; + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }), + fetch(convUrl).then(r => { + if (!r.ok) return { children: [] }; + return r.json(); + }).catch(() => ({ children: [] })), + ]); + + const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] }; + const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] }; + + // Check if webmention-io is configured + if (wmData.notConfigured && (!convData.children || !convData.children.length)) { + this.notConfigured = true; + return; } this.notConfigured = false; - const data = await response.json(); - const newMentions = data.children || []; + // Merge and deduplicate - conversations items (with platform field) take priority + const merged = this.mergeAndDeduplicate( + wmData.children || [], + convData.children || [] + ); - // Sort by published date, newest first - newMentions.sort((a, b) => { + // Sort by date, newest first + merged.sort((a, b) => { const dateA = new Date(a.published || a['wm-received'] || 0); const dateB = new Date(b.published || b['wm-received'] || 0); return dateB - dateA; }); - if (silent) { - // For silent refresh, replace all - this.webmentions = newMentions; - } else { - this.webmentions = newMentions; - } - - this.hasMore = newMentions.length === this.perPage; + this.webmentions = merged; + this.hasMore = (wmData.children || []).length === this.perPage; } catch (err) { this.error = `Failed to load webmentions: ${err.message}`; console.error('[Interactions]', err); @@ -397,20 +414,60 @@ function interactionsApp() { } }, + mergeAndDeduplicate(wmItems, convItems) { + // Build a Set of source URLs from conversations for dedup + const convUrls = new Set(convItems.map(c => c.url).filter(Boolean)); + const seen = new Set(); + const result = []; + + // Add all conversations items first (they have richer metadata) + for (const item of convItems) { + const key = item['wm-id'] || item.url; + if (key && !seen.has(key)) { + seen.add(key); + result.push(item); + } + } + + // Add webmention-io items that aren't duplicated by conversations + for (const item of wmItems) { + const wmKey = item['wm-id']; + if (seen.has(wmKey)) continue; + + // Also check if this webmention's source URL matches a conversations item + // (same interaction from Bridgy webmention AND direct poll) + if (item.url && convUrls.has(item.url)) continue; + + seen.add(wmKey); + result.push(item); + } + + return result; + }, + async loadMore() { this.loadingMore = true; this.page++; try { - const url = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`; - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + const wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`; + const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`; - const data = await response.json(); - const newMentions = data.children || []; + const [wmResult, convResult] = await Promise.allSettled([ + fetch(wmUrl).then(r => r.ok ? r.json() : { children: [] }), + fetch(convUrl).then(r => r.ok ? r.json() : { children: [] }).catch(() => ({ children: [] })), + ]); - this.webmentions = [...this.webmentions, ...newMentions]; - this.hasMore = newMentions.length === this.perPage; + const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] }; + const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] }; + + const merged = this.mergeAndDeduplicate( + wmData.children || [], + convData.children || [] + ); + + this.webmentions = [...this.webmentions, ...merged]; + this.hasMore = (wmData.children || []).length === this.perPage; } catch (err) { this.error = `Failed to load more: ${err.message}`; } finally { diff --git a/js/webmentions.js b/js/webmentions.js index 72f522e..3f69f0f 100644 --- a/js/webmentions.js +++ b/js/webmentions.js @@ -109,22 +109,46 @@ processWebmentions(cached); } - // Always fetch fresh data (updates cache for next refresh) + // 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(([data1, data2]) => { - // Merge and deduplicate by wm-id + .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 = []; - for (const wm of [...(data1.children || []), ...(data2.children || [])]) { - if (!seen.has(wm['wm-id'])) { - seen.add(wm['wm-id']); + + // 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); } } + // 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; + seen.add(key); + allChildren.push(wm); + } + // Cache the merged results setCachedData(allChildren);